作者:ecznlai@腾讯文档
前段时间通过优化业务里的相关实现,将高频调用场景性能优化到原来的十倍,使文档核心指标耗时达到 10~15% 的下降。本文将从 V8 整体架构出发,深入浅出 V8 对象模型,从汇编细节点出其 ICs 优化细节以及原理,最后根据这些优化原理来编写超快的 JS 代码
js 代码从源码到执行 —— v8 编译器管线:
parser 将源码编译为 AST,并在 AST 基础上编译为「字节码 bytecode」
ignition 是 v8 的字节码解释器,可以运行字节码,并在运行过程中持续收集「feedback」即绿线,给到 turbofan 做最终的机器码编译优化。
而由于 js 是相当动态的语言,编译出来的「机器指令」未必能正确,因此其运行过程中有可能要回滚到 ignition 解释器来运行,这些问题通过「红线」反馈给 ignition 解释器,这个过程叫做「反优化」。
—— 更具体来说:
将源码一段线性 buffer string 解析为 Token 流,最后依据 Token 流生成 AST 树状构造,这是所有语言都会有的过程。
运行过程中产生并持续收集的反馈信息,比如多次调用 add(1, 2) 就会产生「add 函数的两个参数 “大概率” 是整数」的反馈,v8 会收集这类信息,并在后续 TurboFan codegen 的时候根据这些反馈来做假设,并依据这些假设做深度优化,后文将从汇编的角度讨论这个细节。
前面提到 「add 函数的两个参数 “大概率” 是整数」 的假设,当假设被打破的时候会触发所谓的「deoptimize」反优化,比如你在运行了很久的 add(number, number) 上突然来一个 add("123", "abc") 那么此时就会降级重新回到 ignition bytecode 执行。
前者生成 byte code,后者根据执行过程中收集的 feedback 来生成深度优化的 machine code
世界上能执行代码的地方有很多,数轴上的两个极端: 左边是抽象程度最高的人脑,右边是抽象程度最低的 CPU:
上图中三个实体以不同的角度理解下面这样的代码,从源码到字节码再到机器码其实就是不断编译为另外一个语言的过程
const a = 3 + 4;
计算 3+4 存储到 js 变量 const a 中
将代码解析为 AST 树(一种 JSON 结构)
iginition 会将代码理解编译为 bytecode :
...LdaSmi [3] // 加载字面量 3 到栈顶Star0 // 将栈顶 3 pop 到寄存器 r0Add r0, [4] // 计算 r0 + 4...
TurboFan 会将代码理解为汇编:
...mov ax 3 # 将 3 赋值到寄存器 axadd ax 4 # 计算 ax = ax + 4...
本质上来说 v8 bytecode 和 x86 汇编是一样的,只是世界上没有裸机能跑出 v8 所理解的 bytecode 而已,机器码为什么快是因为 CPU 能在硬件层面上裸跑汇编,因此速度特别快。
总之为了充分表达 js 动态特性以及方便优化为 CPU 能直接裸跑的汇编,v8 引入了 bytecode 这个层次,它比 AST 更接近物理机,因为它没有层次嵌套,是一种基于寄存器的指令集。
JIT 指的是边运行边优化为机器码的编译技术,其中的代表有 jvm / lua jit / v8,这类优化技术会在运行过程中持续收集执行信息并优化程序性能。AOT 指的是传统的编译行为,在静态类型语言(如 C、C++、Rust)和某些动态类型语言(如 Go、Swift)中得到了广泛应用,由于能提前看到完整代码,编译器/语言运行时可以在编译阶段进行充分的优化,从而提高程序的性能。
由于 JIT 语言并不能提前分析代码并优化执行,因此 JIT 语言的「编译期」很薄,而「运行时」相当厚实,诸多编译优化都是在代码运行的过程中实现的。
ignition 负责解释执行 V8 引入的中间层次字节码,上接人脑里的 js 规范,下承底层 CPU 机器指令
TurboFan 可以将字节码编译为最快的机器码,让裸机直接运行,达到最快的执行速度。
利用这个参数开启 v8 注入的 runtime call,帮助分析和调试 v8
# node 下开启$ node --allow-natives-syntax# chrome 下开启$ open -a Chromium --args --js-flags="--allow-natives-syntax"
下面是一些常用指令说明。
可以打印对象在 v8 的内部信息,比如打印一个函数:
告诉 v8 下次调用主动触发优化函数 fn
获取函数当前的优化 status,后文会详细介绍:
对应的是 V8 源码里的这个枚举:
从开发视角来看,一个函数最佳的 status 应该是 00000000000001010001 (81) 即:
%HasFastProperties 可以用来打印对象是否是 Fast Properties 模式
后文会介绍这个 Fast Properties 和与之对立的 Slow Properties。
首先 Tagged Pointer 是 C/C++ 里常用的优化技术,不只在 V8 里有用,具体来说就是依据 pointer 自身的数值的某些位来决定 pointer 的行为,也就是说这类指针的特点是「其指针数值上的某些位有特殊含义」。
比如在 v8 里,js 堆指针和 SMI 小整数类型(small intergers)是通过 Tagged Pointer 来表达和引用的,区别就在于最低一位是不是 0 来决定其指针类型:
对象指针(32 位):
xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxx1
SMI 小整数(32 位)其中 xxx 部分为数值部分:
xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxx0
用 C 表达就是这样:
#include <stdio.h>void printTaggedPointer(void * p) { // 强转一下, 关注 p 本身的数值 unsigned int tp = ((unsigned int) p); if ((tp & 0b1) == 0b0) { printf("p 是 SMI, 数值大小为 0x%x \n", tp >> 1); return; } printf("p 是堆对象指针, Object<0x%x> \n", tp); // printObject(*p); // 假设有个方法可以打印堆对象}int main() { printTaggedPointer(0x1234 << 1); // smi printTaggedPointer(17); // object return 0;}
运行效果:
备注:
我们先来看这个例子,一个 add(x,y) 函数,如果运行期间出现了多种类型的传参,那么会导致代码变慢:
我们可以看到,L15 速度慢了非常多,比一开始的 66ms 慢了几倍。
原因:
比如一开始传的是 number,走到了优化过的代码,里面走的是汇编指令 add;当传入 string 或者 其他什么合法的 JSValue 后,编译为汇编的 add 函数的执行真的没问题吗?—— 不会有问题,因为 TurboFan 在编译后的「机器码」里会带上很多 checkpoint,其实这些 checkpoint 就是在做类型检查 type guard,如果类型对不上立刻就会终止这次调用并执行「反优化」让 ignition 走字节码解释执行。
上述说法可能会比较含糊,我们可以具体看看打出来的汇编是咋样的,可以通过以下方式打印出优化后的 x86 汇编(m1 芯片的苹果电脑应该是 arm 指令)。
$ node --print-opt-code --allow-natives-syntax --trace-opt --trace-deopt ./a.js
如下图所示,这个 test 函数实现是将第一个入参加上 0x1234 并返回,而这个核心逻辑对应 L37 那行汇编,而其他的部分除了 v8 自身的「调用约定」外,其他的就是 checkpoint 检查类型,以及一些 debug 断点了:
从前面的 Tagged Pointer 的相关讨论可知,L19 ~ L22 其实就是在判断入参是不是 SMI,具体来说是 [rbx+0xf] 与 0x1 做按位与操作([rbx+0xf] 是通过栈传递的参数,是 v8 里 js 的调用约定)如果结果是 0 则跳转 0x10b7cc34f 即后续的正常流程,否则走到
CompileLazyDeoptimizedCode 走反优化流程用字节码解释器去执行了,我这里大概写了一个反汇编伪码对照:
另外我们也可以看到,核心逻辑对应到汇编也就一行,剩余的指令要么是 checkpoint 要么是 v8/js 的调用约定,在这么多冗余指令的情况下执行性能依然很快,可见汇编的执行效率比起 line-by-line 的解释器要高得多了。
通过 %DebugPrint 可以看到
当打破这个 assumption 后,会变成 Any:
不会
根据前面提到的 checkpoint,上面三个 mono 的 checkpoint 最少,而最后的 mega 将会非常多,优化性能最差,或者 V8 干脆就不会对这类函数做更深度的机器码优化了(比如后文会提到的 ICs)
从 JS AST / bytecode 编译到机器码也需要开销,毫秒级。
根据这篇文章 V8 function optimization - Blog by Kemal Erdem 如果某个函数「反优化」超过 5 次后,v8 以后就不再会对这个函数做优化了,不过我无法复现他说的这个情况,可能是老版本的 v8 的表现,node16 不会这样,不管怎样只要 run 了足够多次都turbofanned,只是如果「曾经传的参数类型太 union typed」会导致优化效果出现非常大的折损。
前面我们已经知道了「运行足够多次」会触发优化,而这只是其中一种情况,具体可以参考 v8 里 ShouldOptimize 的实现,里面有详细定义何时启动优化:
作为开发视角来看:
备注:maglev 是去年 chrome v8 团队搞的新特性 —— 编译层次优化,总的来说就是根据 feedback 对机器码的编译层次做精细控制来达到更好的优化效果,下图是 v8 团队发布的 benchmark 对比:
具体可参考 v8.dev/blog/maglev
会的,而且有时候这部分内存占用非常多,这也是 Chrome 经常被调侃为内存杀手的重要原因之一,以 qq.com 为例,具体对应是 heapdump 里的 (compiled code) 包含了编译后的代码内存占用:
本节开始是本文的重点部分,因为只有了解 V8 对象的内存构造,才能真正理解 V8 诸多优化的理由。
在正式进入之前,我们先看看 C 里面 struct 的「点读」是怎么做的。
C 会将 struct 理解为一段连续的线性 buffer 结构,并在上面根据字段的类型来划分好从下标的哪里到哪里是哪个字段(对齐),因此在编译 point.x 的时候会改成 base+4 的方式进行属性访问,如下图所示,时间复杂度是 O(1) 的:
也因此 C 里面没提供从字段 key 名的方式去取 struct value 的方法,也就是不支持 point['x']这样,需要你自己写 getter 才能实现类似操作。
这类根据 string value 来从对象取值的技术通常在现代编程语言里都是自带了的,通常称为反射,可以在运行时访问源码信息。
但在 JS 里,对象是动态的,可以有任意多的 key-values,而且这些 kv 键值对还可能在运行时期间动态发生变化,比如我可以随时 p.xxx =123 又或者 delete p.xxx 去删掉它,这意味着一个 object 的 “shapes” 及其「内存结构」是无法被静态分析出来的,而且这种内存结构必然不是「定长固定」的,是需要动态 malloc 变长的。
假设现在是 2008 年,你是 google 的工程师,正在 chrome v8 项目组开发,你会怎样设计 JS 的对象的内存结构?
const obj = { x: 3, y: 5 }// obj 的内存结构可以设计成怎样?
一眼丁真,开搞:
一个 key 定义加一个值,然后将这个结构数组化就可以表达对象的 kv 结构,增加属性就在后面继续扩增,查找算法则是从头查到尾,时间复杂度为 O(n)
但是如果按这个设计,下面两个 obj 就会有重复的 key 定义内存消耗了:
const obj1 = { x: 11, y: 22 } // "x" 11 "y" 22const obj1 = { x: 33, y: 44 } // "x" 33 "y" 44 // 会重复 "x" 和 "y"
好了就上面这样简单弄一下就搞出了好多问题了。从下面开始正式进入,V8 是如何描述对象,参见下文。
在 js 标准里 Array 是一类特殊的 Object,但出于性能考虑 V8 底层针对对象和数组的处理是不同的:
如下图所示,JSObject:
在 V8 里:
嗯?对象的 Shapes?那是什么?
所谓对象的 shapes,其实就是对象上有什么 key,前面提到过 V8 的优化需要在运行时不断收集 feedback,比如当执行下面这段代码的时候,引擎就可以知道「obj 有两个 key,一个是 a 一个是 b」:
const obj = {}obj.a = 123;obj.b = 124;doSomething(obj);
V8 通过 Hidden Class 结构来记录 JSObject 在运行时的时候有哪些 key,也就是记录对象的 shapes,由于 JSObject 是动态的,后续也可以随意设置 obj.xxx = 123,也就是对象的 shapes 会变,也因此对象持有的 Hidden Class 会随着特定代码的运行而变化
Hidden Class 是比较学术的说法,在 V8 源码里的「工程命名」是 Map,在微软 Edge Chakra (edge) 里叫做 Types,在 JavaScriptCore (WebKit Safari) 里叫做 Structure,在 SpiderMonkey (FireFox) 里叫做 Shapes .... 总之各个主流引擎都有实现追踪「对象 shapes 变化」
后文可能会混淆上面几个用语,它们都是指 Hidden Class,用来描述对象的 shapes。
前面提到除了 *properties 和 *elements 可以用来存储对象成员之外,JSObject 还提供了所谓 in-object properties 的方式来存储对象成员,也就是将对象成员保存在「JSObject 结构体」上,并配合 Hidden Class 进行键值描述:
上图里 Hidden Class 里底下有个叫做 DescriptorArrays 的子结构,这个结构会记录对象成员 key 以及其对应存储的 in-object 下标,也就是上面的紫框。
或许你会问:
如果 Hidden Class 是静态的,那么这图就足够描述 Hidden Class 了:
但是对象的 shapes 会变,也因此对象持有的 Hidden Class 会随着特定代码的运行而变化,V8 使用了 Transition Chain,一种基于链表构造的方式来描述「变化中的 Hidden Class」:
备注:为了方便讨论,后文可能不会将 Hidden Class 画成链表,而是画成一起并且省略空对象的 shapes,另外 Hidden Class Node 上还有其他字段,相对不那么重要,就忽略了
由于链表的特性,显然可以比较容易地让具有相同 shapes 的对象能复用同一个 Hidden Class ,比如下面这个 case,o1 o2 均复用了地址为 0xABCD 的 Hidden Class 节点:
当出现不同走向的时候,此时会单独开一个 branch 来描述这种情况,此时 o1 和 o2 就不再一样了:
从前文的讨论,可以得到的结论:
悬而未决的问题:
请带着这两个问题到下一章 Inline Caches 继续阅读。
引入 Hidden Class 后,为了读取某个成员,那不还得查一次 Hidden Class 拿到 in-object 的下标,这个过程不还是 O(n) 吗?
是的,如果事先不知道 JSObject 的 shapes 的情况下去读取成员确实是 O(n) 的,但前面我已经提过了:
V8 的诸多优化是基于 assumption 的,那么在已知 obj 的 Shapes 的情况下,你会怎么优化下面这个 distance 函数?
如此优化就可以将「通过遍历 *properties访问成员的O(n) 过程」直接优化为「直接按下标偏移直接读取 `in-object` 的 O(1)过程」了,这种优化手段就叫做 Inline Caches (ICs),有点类似 C 语言的 struct 将字段点读编译为偏移访问,只不过这个过程是 JIT 的,不是 C 那样 AOT 静态编译确定的,是 V8 在函数执行多次收集了足够多的 feedback 后实现的。
你可能还会问:在调用优化后的 distance2 的时候具体要怎么确定传入的 p1 p2 的 shapes 是否有变化?还记得前面那个 0xABCD 吗?没错,编译后的汇编 checkpoint 就是直接判断传入对象的 hidden classs 指针数值是不是 *0xABCD*,如果不是就触发「反优化」兜底解释器模式运行即可。
—— 下面这个实例将手把手介绍 ICs 的真实场景以及汇编细节
从前面 Inline Cache 的讨论中可以得知,必须要确定了访问的 key 才能做 ICs 优化,因此写代码的过程中,如有可能请尽量避免下面这样通过 key string 动态查找对象属性:
function test(obj: any, key: string) { return obj[key]; }
如果能明确知道 key 的具体值,此时建议写为:
function test(obj: any, key: 'a' | 'b') { if (key === 'a') return obj.a; if (key === 'b') return obj.b;}
即使确实不得不动态查询,但是你知道某个子 case 占了 99% 的调用次数,此时也可以这样优化:
function test(obj, key: 'a' | 'b') { // 为 'a' 的调用次数占了 99% 可以这样提前优化 if (key === 'a') return obj.a; return obj[key];}
静态和动态两种写法风格可能会有几倍甚至上百倍的差距,如果业务里有大几百万次的调用 test,优化后能省不少毫秒,比如下面这个「简化的服务发现」例子有近百倍的差距:
原因是 s2.js 里那些属性访问都被 ICs 技术优化成 O(1) 访问了,速度很快 —— 为了探究内部的 ICs 相关汇编逻辑,尝试输出 serviecMap 的 Hidden Class (V8 里 hidden class 别名是 Map) 以及汇编源码:
首先 %DebugPrint 出 serviceMap 的 Hidden Class 的物理地址,可以看到是 0x3a8d76b74971 然后看后续编译优化的 arm machine code 是怎么利用这个地址实现 ICs 技术优化的:(笔者这会的电脑是 mac m1 因此是 arm 汇编,不是 x86 汇编)。
可以看到,ICs 优化后汇编的 checkpoint 其实就是将 Hidden Map 的指针物理地址直接 Inline 到汇编里了,通过判等的方式来验证假设,然后就可以直接将属性访问优化为 O(1) 的 in-object properties 访问了,这也是这个技术为什么叫做 Inline Cahce (ICs) 了。
(这几乎是 V8 里效果最好的优化了,也因此部分 benchmark 里 nodejs 对象可能比 Java 对象还快,因为 Java 里有可能滥用反射导致对象性能非常差)。
如果知道 ICs 技术内涵的话,理解 Fast Properties 和 Slow Properties (或者称字典模式) 就不会有困难了。
下图描述了 JSObject 的主要构造:当把对象成员存储到 in-object properties 的时候,此时称对象是 Fast Properties 模式,这意味着对象访问 V8 会在合适的时候将其 Inline Cache 到优化后的汇编里;反之,当成员存储到 *properties 的时候,此时称为 Slow Properties,此时就不会对这类对象做 inline cache 优化了,此时对象访问性能最差(因为要遍历 *properties字典,通常慢几十到几百倍,取决于对象成员数量)。
我们可以用 %HasFastProperties 来打印对象是否是 Fast Properties 模式,如下图所示:
delete 会将对象转为 slow properties 模式,为什么呢?因为 delete 带来的问题可太多了,缓存技术最怕的就是 delete,如图所示:
我拍脑子就能想到上面四个问题,要完整的确保 delete 的安全性可太难了,因此维护 delete 后的 hidden class 非常麻烦,V8 采取的方式是直接将 in-object 释放掉,然后将对象属性都复制存储到 *properties 里了,以后这个对象就不再开启 ICs 优化了,此时这种退化后的对象就称为 slow properties (或者称字典模式)。
Hidden Class 是比较学术的名字,在 V8 里对应的「工程命名」是 Map,可以在 heapdump 里看到:
利用查找 Hidden Class 的方式可以快速定位大批量相同 shapes 的对象哦,很方便查找内存溢出问题。
跟 C++ 里的 inline 关键字一样,将函数直接提前展开,少一次调用栈和函数作用域开销。
基于 Sea Of Nodes 的 PL 理论进行优化,分析对象生命周期,如果对象是一次性的,那么就可以做编译替换提升性能,比如下图里对象 o 只用到了 a,那么就可以优化成右边那样,减少对象内存分配并提升寻址速度:
通过打 heapdump 的方式可以发现下面第二行的空对象的 shallow size 是 28 字节,而后一个是 16 字节:
window.arr = []; // 打一次 heapdump arr.push({}); // 打一次 heapdump arr.push({ ggg: undefined });
原因:V8 假设空对象后面都会设置新的 key 上去,因此会预先 malloc 了一些 in-object 字段到 JSObject 上,最后就是 28,比 16 要大;而第三行这样固定就只会 malloc 一个 in-object 字段了(其实看图里还有一个 proto 字段)。
那么 new Object() 呢?一样会;如果是 Object.create(null) 呢?这种情况就不会申请了,shallow size 此时最小,为 12 字节。
28 - 12 = 16 字节,而一个指针占 4 字节,因此 V8 对一个空对象会默认为其多创建 4 个 in-object 字段以备后续使用,而这类预分配的内存空间,会在下次 GC 的时候将没用到的回收掉,这项技术叫做 「Slack Tracking 松弛追踪」。
v8 里还有很多针对 string / Array 的优化技术,本次技术优化主要涉及 ICs 相关优化,就不展开写了,参见后文链接(其实大部分对象优化技术都是围绕 V8 对象模型来进行的)。
Safari 的 WebKit JSCore 引擎也有基于 LLVM 后端的 JIT 技术,因此很多优化手段是共通的,比如 safari 也有 type feedback 和属性追踪,也有自己的 hidden class / ICs 实现,可以打开 safari 的调试工具看到运行时的 type feedback:(macOS、iOS、iPadOS 上都有 JIT,在 chrome 上优化后全平台都能受益)。
在这些优化技术的加持上,safari jscore 某些情况下甚至会比 chrome v8 还要快:
大部分业务场景里更关心可维护性,性能不是最重要的,另外就是面向引擎/底层优化逻辑写的 js 未必是符合最佳实践的,有时候会显得非常脏,这里总结一下个人遇到的常见实例对照,供参考:
热点函数会优先走 turbofan 编译为机器码,性能会更好,要如何利用好这个特性?将项目里的一些高频原子操作拆成独立函数,人为制造热点代码,比如计算点距离,单位换算等等这些需要高性能的地方:
除了前面提到的热区之外,拆解后的函数如果足够短,那么 V8 在调用的时候会做 inline 展开优化,节省一次调用栈开销。
从前面的 add 的例子我们可以知道,V8 TurboFan 优化是基于 assumption 的,应该尽量保持函数的单态性 (Monomorphic),或者说减少函数的状态,具体来说高频函数不要传 Union Types 作为参数。(这个不够准确,最好是不要打破参数的 V8 内部类型表示以及汇编 checkpoint,比如一会传浮点数、一会传 SMI 这样即使都是 number 也会打破 v8 的假设,因为 v8 内部实现的浮点数会装箱,而小整数 SMI 不会,两者的汇编逻辑不一样)。
推荐使用 TypeScript 来写 js 应用,限制函数的入参类型可以有效保证函数的单态性质,更容易编写高性能的 js 代码
赋值顺序的不同会产生不同的 Hidden Class 链,不同的链不能做 ICs 优化。
class A { a?: number}class A { a = undefined // 或 null}
理由跟前一点一样,前者 A 有 shapes 链是 空对象+a,而后者就是确定的 a 了。
但是,赋值会多消耗一点内存,内存敏感型场景慎用。
delete 后会将对象转为 Slow Properties 模式,这种模式下的对象不会被 inline cache 到优化后的汇编机器码里,对性能影响比较大,另外这样的对象如果到处传的话就会到处触发「反优化」将污染已经优化过的代码。
前面的例子里提到,反优化后的函数再优化性能不会比最开始要好,换言之被「feedback 污染」了,我们应当尽量避免反优化的出现(即 checkpoint 被打破的情况)。
前面已经讨论过这类情况了,静态种写法 V8 可以做 ICs 优化,将属性访问直接改为 in-object 访问,速度可以比动态 key 查找快近百倍。
const obj = { a: 1, b: 2 };const obj = {};obj.a = 1;obj.b = 2;
从 Hidden Class 的角度来看,第二种会使 Hidden Class 变化三次,而第一种直接声明其实就隐含了 Hidden Class 了,V8 可以直接提前静态分析得出。
v8 会分析 ast,将左侧优化成右侧。
在 React / Vue 里有这种 Ref 构造来实现访问同一个实例的操作(类似指针)
type Ref<T> = { ref: T}// React 的是 current 作为 keytype ReactRef<T> = { current: T }
前面提到过的 ICs 优化,因此上述这样的构造并不会造成严重的性能损失,会多消耗一点内存,大多数情况下可以放心使用(多消耗 16 字节)。
这块参考了大量资料,有的地方只有源码里才有,这里简单列一下:
另外特别感谢元宝对我工作的大力支持 ❤️