learn-tech/专栏/JavaScript进阶实战课/14通过SparkPlug深入了解调用栈.md
2024-10-16 06:37:41 +08:00

100 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
14 通过SparkPlug深入了解调用栈
你好,我是石川。
在第11讲的时候我们通过函数中的闭包了解了栈和堆这两种数据结构。在12讲中我们通过递归了解了函数在调用栈中的循环执行。那么今天我们再通过V8的Sparkplug编译器来深入的了解下JavaScript引擎中的调用栈其中栈帧stack frame栈指针stack pointer和帧指针frame pointer的概念。
这里可能有不太了解V8的同学我们来简单看一下Sparkplug的前世今生。最开始的时候啊V8用的是一个相对比较快的Full-codegen编译器生成未优化的代码然后通过Crankshaft这个及时JIT编译器对代码进行优化和反优化。但是随着更多人浏览网页的习惯从PC端转向移动端性能问题变得更加重要了。而这个流水线既没能对ES6之前的也没能对ES6之后版本的JS做到理想的优化。
所以随之而来的V8就引入了Ignition解释器和TurboFan的优化编译器以移动优先为目的的流水线。它最大的特点呢就是通过Ignition最终生成字节码之后再通过TurboFan生成优化后的代码和做相关的反优化。
可是这个时候问题又来了TurboFan和Crankshaft比起来有时也有不足。所以为了解决这个问题V8又创建了一个把Full-codegenCrankshaftIgnition和TurboFan整合起来的“全家桶”的流水线。这显然让问题显得更复杂了。
还有就是从2016年开始V8团队也从理论往实际转变包括自研的Octane测试引擎都被真实场景的测试所取代了。并且努力在优化编译器外寻求性能优化方向包括解析器、流、对象模型、垃圾回收器、编译代码缓存等方面的优化。然而在这个过程中他们开始发现在优化解释器时遇到了比如字节码解码或分派这些成本的限制。基于V8的双编译器的模型是没办法实现更快的分层代码优化的。如果想要提速就只能删除一些优化关卡但这样会降低峰值性能。运行的初始阶段在还没有稳定的对象隐藏类反馈的情况下也没有办法开始优化。基于上面的种种原因和问题Sparkplug就诞生了。作为非优化编译器它存在于Ingition解释器和TurboFan优化编译器之间。
为什么多了一个编译器
既然Sparkplug的目的是快速编译那么为了达到这个目的它就用到了两个重要的方法。
首先它编译的函数已经被编译为字节码了。字节码编译器已经完成了大部分复杂的工作比如变量解析、判断括号是否是箭头函数、去掉语法糖和解构语句等等。Sparkplug的特点是从字节码而不是从JavaScript源代码编译因此不必担心这些问题。这也是它和Full-codegen的区别。因为在Full-codegen的场景里Crankshaft需要将源码重新解析到AST语法树来编译并且为了反优化到Full-codegen它需要重复Full-codegen编译来搞定栈帧。
第二个方法是Sparkplug不会像TurboFan那样生成任何中间码 (IR)。什么是IR呢它是一种从抽象到具象的分层结构。在具象中可以对具体内容比如新的ES标准或特定机器比如IBM、ARM或Intel做特殊的机器码生成。TurboFan用的是基于节点海思想的IR。TurboFan在接受到Ignition的指示后会进行优化处理并生成针对于平台的机器代码。相反Sparkplug在字节码上的单次线性传递中直接编译为机器码产出与该字节码执行相匹配的代码。所以事实上整个Sparkplug编译器是一个for循环内的switch语句分配到基于每个固定的字节码的机器码的生成函数上。
// Sparkplug 编译器的部分代码
for (; !iterator.done(); iterator.Advance()) {
VisitSingleBytecode();
}
IR的缺失意味着除了非常有限的窥孔优化外Sparkplug的优化机会有限。这就是说因为它没有独立于架构的中间阶段所以必须将整个实现分别移植到支持的每个架构中。但是实际上呢这些都不是问题因为Sparkplug是一个简单快速的编译器所以代码很容易移植而且因为在工作流中还是有TurboFan的所以不需要进行大量优化。并且我们看到TurboFan的反优化会回到SparkPlug而不是Ignition。
栈指针和帧指针的使用
下面我们就来看看栈帧的原理还有栈指针和帧指针的使用。在成熟的JavaScript虚机中添加新的编译器其实很难。因为除了Sparkplug自身的功能它还必须支持例如调试器、遍历堆栈的CPU分析器、栈的异常跟踪、分层的集成及热循环到优化代码的栈替换OSRon-stack replacement等工作。
那Sparkplug则用了一个比较聪明的方法简化了大多数问题就是它维护了“与解释器兼容的栈帧”。我们知道调用栈是代码执行存储函数状态的方式而每当我们调用一个新函数时它都会为该函数的本地变量创建一个新的栈帧。 栈帧由帧指针(标记其开始)和栈指针(标记其结束)定义。
当一个函数被调用时,返回地址也会被压入栈内的。返回地址在返回时由函数弹出,以便知道返回到哪里。当该函数创建一个新栈帧时,也会将旧的帧指针保存在栈中,并将新的帧指针设置为它自己栈帧的开头。因此,栈有一系列的帧指针 ,每个都标记指向前一个栈帧的开始。
除了函数的本地变量和回调地址外,栈中还会有传参和储值。参数(包括接收者)在调用函数之前以相反的顺序压入栈内,帧指针前面的几个栈槽是当前正在被调用的函数、上下文,以及传递的参数数量。这是“标准” JS 框架布局:
为了使我们在性能分析时,以最小的成本遍历栈,这种 JS 调用约定在优化和解释栈帧之间是共享的。
Ignition解释器会进一步让调用约定变得更加明确。Ignition是基于寄存器的解释器和机器寄存器的不同在于它是一个虚拟寄存器。它的作用是存储解释器的当前状态包括JavaScript函数局部变量var/let/const 声明)和临时值。这些寄存器存储在解释器的栈帧中。除此以外,栈帧中还有一个指向正在执行的字节码数组的指针,以及当前字节码在该数组中的偏移量。
后来V8团队对解释器栈帧做了一个小改动就是Sparkplug在代码执行期间不再保留最新的字节码偏移量改为了存储从Sparkplug代码地址范围到相应字节码偏移量的双向映射。因为Sparkplug代码是直接从字节码的线性遍历中发出的所以这是一个相对简单的编码映射。每当栈帧访问并想知道Sparkplug栈帧的“字节码偏移量”时都会在映射中查找当前正在执行的指令并返回相应的字节码偏移量。同样每当它想从解释器到Sparkplug进行栈替换OSRon-stack replacement都可以在映射中查找当前字节码偏移量并跳转到相应的Sparkplug指令。
Sparkplug特意创建并维护与解释器相匹配的栈帧布局每当解释器存储一个寄存器值时Sparkplug也会存储一个。它这样做有几点好处一是简化了Sparkplug的编译, Sparkplug可以只镜像解释器的行为而不必保留从解释器寄存器到Sparkplug状态的映射。二是它还加快了编译速度因为字节码编译器已经完成了寄存器分配的繁琐的工作。三是它与系统其余部分如调试器、分析器的集成是基本适配的。四是任何适用于解释器的栈替换OSRon-stack replacement的逻辑都适用于Sparkplug并且解释器和Sparkplug代码之间交换的栈帧转换成本几乎为零。
之前字节码偏移量空出来的位置在栈帧上形成了一个未使用的插槽这个栈槽被重新定义了目的来缓存当前正在执行的函数的“反馈向量”来存储在大多数操作中都需要被加载的对象结构的数据。因此Sparkplug栈帧最终是这个样子的
Sparkplug实际的代码很少基本工作就是内置模块调用和控制流。为什么会这样呢因为JavaScript语义很复杂即使是最简单的操作也需要大量代码。强制Sparkplug在每次编译时内联重新生成代码会明显增加编译时间而且这样也会增加Sparkplug代码的内存消耗并且V8必须为Sparkplug的一堆JavaScript功能重新实现代码生成这也可能引起更多的错误和造成更大的安全暴露。因此大多数Sparkplug代码是调用“内置模块”的即嵌入在二进制文件中的小段的机器码来完成实际的脏活累活儿。这些内置函数基本与解释器使用相同或至少大部分共享。
这时你可能会产生一个疑问就是Sparkplug存在的意义感觉它和解释器做几乎同样的工作。在许多方面Sparkplug的确只是解释器执行的序列化调用相同的内置功能并维护相同的栈帧。但尽管如此它也是有价值的因为它消除了或更严谨地说预编译了那些无法消除的解释器成本因为实际上解释器影响了许多CPU优化。
例如操作符解码和下一个字节码调度。解释器从内存中动态读取静态操作符导致CPU要么停止要么推测值可能是什么分派到下一个字节码需要成功的分支预测才能保持性能即使推测和预测是正确的仍然必须执行所有解码和分派代码结果依然是用尽了各种缓冲区中的宝贵空间和缓存。尽管CPU用于机器码但本身实际上就是一个解释器。从这个角度看Sparkplug其实是一个从Ignition到CPU字节码的“转换器”将函数从“模拟器”中转移到“本机”运行。
总结
通过今天对Sparkplug的讲解我们在之前对栈这种数据结构的了解基础上更多的了解了栈帧、栈针和帧指针的概念同时也更多的了解了JS的编译流水线。感兴趣的同学也可以看看我在参考中提供的V8博客中的相关文章的链接同时这个网站也有很多的语言和编译原理的解释从中我们也可以看到V8对性能极致优化的追求也是很值得学习的资料。同时如果你对编译原理很感兴趣的话也可以看看隔壁班宫文学老师的编译原理课。
思考题
在前面我们说过Sparkplug并不是完全没有优化它也会做窥孔优化。你知道这个优化的原理吗
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
参考
V8 博客https://v8.dev/blog/sparkplug
隔壁班宫文学老师的课程:《编译原理之美》