本文最初揭橥于 v8.dev(Faster JavaScript calls),基于 CC 3.0 协议分享,由 InfoQ 翻译并发布。

JavaScript 许可利用与预期形式参数数量不同的实际参数来调用一个函数,也便是通报的实参可以少于或者多于声明的形参数量。
前者称为申请不敷(under-application),后者称为申请过度(over-application)。

在申请不敷的情形下,剩余形式参数会被分配 undefined 值。
在申请过度的情形下,可以利用 rest 参数和 arguments 属性访问剩余实参,或者如果它们是多余的可以直接忽略。
如今,许多 Web/Node.js 框架都利用这个 JS 特性来接管可选形参,并创建更灵巧的 API。

html调用最新文章JavaScript 挪用提速 40 的实践 AJAX
(图片来自网络侵删)

直到最近,V8 都有一种专门的机制来处理参数大小不匹配的情形:这种机制叫做参数适配器框架。
不幸的是,参数适配是有性能本钱的,但在当代的前端和中间件框架中这种本钱每每是必须的。
但事实证明,我们可以通过一个奥妙的技巧来拿掉这个多余的框架,简化 V8 代码库并肃清险些所有的开销。

我们可以通过一个微型基准测试来打算移除参数适配器框架可以得到的性能收益。

console.time();function f(x, y, z) {}for (let i = 0; i < N; i++) { f(1, 2, 3, 4, 5);}console.timeEnd();

移除参数适配器框架的性能收益,通过一个微基准测试来得出。

上图显示,在无 JIT 模式(Ignition)下运行时,开销消逝,并且性能提高了 11.2%。
利用 TurboFan 时,我们的速率提高了 40%。

这个微基准测试自然是为了最大程度地展现参数适配器框架的影响而设计的。
但是,我们也在许多基准测试中看到了显著的改进,例如我们内部的 JSTests/Array 基准测试(7%)和 Octane2(Richards 子项为 4.6%,EarleyBoyer 为 6.1%)。

太长不看版:反转参数

这个项目的重点是移除参数适配器框架,这个框架在访问栈中被调用者的参数时为其供应了一个同等的接口。
为此,我们须要反转栈中的参数,并在被调用者框架中添加一个包含实际参数计数的新插槽。
下图显示了变动前后的范例框架示例。

移除参数适配器框架之前和之后的范例 JavaScript 栈框架。

加快 JavaScript 调用

为了讲清楚我们如何加快调用,首先我们来看看 V8 如何实行一个调用,以及参数适配器框架如何事情。

当我们在 JS 中调用一个函数调用时,V8 内部会发生什么呢?用以下 JS 脚本为例:

function add42(x) { return x + 42;}add42(3);

在函数调用期间 V8 内部的实行流程。

Ignition

V8 是一个多层 VM。
它的第一层称为 Ignition,是一个具有累加器寄存器的字节码栈机。
V8 首先会将代码编译为 Ignition 字节码。
上面的调用被编译为以下内容:

0d LdaUndefined ;; Load undefined into the accumulator26 f9 Star r2 ;; Store it in register r213 01 00 LdaGlobal [1] ;; Load global pointed by const 1 (add42)26 fa Star r1 ;; Store it in register r10c 03 LdaSmi [3] ;; Load small integer 3 into the accumulator26 f8 Star r3 ;; Store it in register r35f fa f9 02 CallNoFeedback r1, r2-r3 ;; Invoke call

调用的第一个参数常日称为吸收器(receiver)。
吸收器是 JSFunction 中的 this 工具,并且每个 JS 函数调用都必须有一个 this。
CallNoFeedback 的字节码处理器须要利用寄存器列表 r2-r3 中的参数来调用工具 r1。

在深入研究字节码处理器之前,请先把稳寄存器在字节码中的编码办法。
它们是负的单字节整数:r1 编码为 fa,r2 编码为 f9,r3 编码为 f8。
我们可以将任何寄存器 ri 称为 fb - i,实际上正如我们所见,精确的编码是- 2 - kFixedFrameHeaderSize - i。
寄存器列表利用第一个寄存器和列表的大小来编码,因此 r2-r3 为 f9 02。

Ignition 中有许多字节码调用处理器。
可以在此处查看它们的列表。
它们彼此之间略有不同。
有些字节码针对 undefined 的吸收器调用、属性调用、具有固天命量的参数调用或通用调用进行了优化。
在这里我们剖析 CallNoFeedback,这是一个通用调用,在该调用中我们不会积累实行过程中的反馈。

这个字节码的处理器非常大略。
它是用 CodeStubAssembler 编写的,你可以在此处查看。
实质上,它会尾调用一个架构依赖的内置 InterpreterPushArgsThenCall。

这个内置方法实际上是将返回地址弹出到一个临时寄存器中,压入所有参数(包括吸收器),然后压回该返回地址。
此时,我们不知道被调用者是否是可调用工具,也不知道被调用者期望多少个参数,也便是它的形式参数数量。

内置 InterpreterPushArgsThenCall 实行后的框架状态。

终极,实行会尾调用到内置的 Call。
它会在那里检讨目标是否是适当的函数、布局器或任何可调用工具。
它还会读取共享 shared function info 构造以得到其形式参数计数。

如果被调用者是一个函数工具,它将对内置的 CallFunction 进行尾部调用,并在个中进行一系列检讨,包括是否有 undefined 工具作为吸收器。
如果我们有一个 undefined 或 null 工具作为吸收器,则应根据 ECMA 规范对其修补,以引用全局代理工具。

实行随后会对内置的 InvokeFunctionCode 进行尾调用。
在没有参数不匹配的情形下,InvokeFunctionCode 只会调用被调用工具中字段 Code 所指向的内容。
这可以是一个优化函数,也可以是内置的 InterpreterEntryTrampoline。

如果我们假设要调用的函数尚未优化,则 Ignition trampoline 将设置一个 IntepreterFrame。
你可以在此处查看V8 中框架类型的简短择要。

接下来发生的事情就不用多谈了,我们可以看一个被调用者实行期间的阐明器框架快照。

我们看到框架中有固天命量的插槽:返回地址、前一个框架指针、高下文、我们正在实行确当前函数工具、该函数的字节码数组以及我们当前正在实行的字节码偏移量。
末了,我们有一个专用于此函数的寄存器列表(你可以将它们视为函数局部变量)。
add42 函数实际上没有任何寄存器,但是调用者具有类似的框架,个中包含 3 个寄存器。

如预期的那样,add42 是一个大略的函数:

25 02 Ldar a0 ;; Load the first argument to the accumulator40 2a 00 AddSmi [42] ;; Add 42 to itab Return ;; Return the accumulator

请把稳我们在 Ldar(Load Accumulator Register)字节码中编码参数的办法:参数 1(a0)用数字 02 编码。
实际上,任何参数的编码规则都是[ai] = 2 + parameter_count - i - 1,吸收器[this] = 2 + parameter_count,或者在本例中[this] = 3。
此处的参数计数不包括吸收器。

现在我们就能理解为什么用这种办法对寄存器和参数进行编码。
它们只是表示一个框架指针的偏移量。
然后,我们可以用相同的办法处理参数/寄存器的加载和存储。
框架指针的末了一个参数偏移量为 2(先前的框架指针和返回地址)。
这就阐明了编码中的 2。
阐明器框架的固定部分是 6 个插槽(4 个来自框架指针),因此寄存器零位于偏移量-5 处,也便是 fb,寄存器 1 位于 fa 处。
很聪明是吧?

但请把稳,为了能够访问参数,该函数必须知道栈中有多少个参数!
无论有多少参数,索引 2 都指向末了一个参数!

Return 的字节码处理器将调用内置的 LeaveInterpreterFrame 来完成。
该内置函数实质上是从框架中读取函数工具以获取参数计数,弹出当前框架,规复框架指针,将返回地址保存在一个暂存器中,根据参数计数弹出参数并跳转到暂存器中的地址。

这套流程很棒!
但是,当我们调用一个实参数量少于或多于其形参数量的函数时,会发生什么呢?这个聪明的参数/寄存器访问流程将失落败,我们该如何在调用结束时清理参数?

参数适配器框架

现在,我们利用更少或更多的实参来调用 add42:

add42();add42(1, 2, 3);

JS 开拓职员会知道,在第一种情形下,x 将被分配 undefined,并且该函数将返回 undefined + 42 = NaN。
在第二种情形下,x 将被分配 1,函数将返回 43,别的参数将被忽略。
请把稳,调用者不知道是否会发生这种情形。
纵然调用者检讨了参数计数,被调用者也可以利用 rest 参数或 arguments 工具访问其他所有参数。
实际上,在 sloppy 模式下乃至可以在 add42 外部访问 arguments 工具。

如果我们实行与之前相同的步骤,则将首先调用内置的 InterpreterPushArgsThenCall。
它将像这样将参数推入栈:

内置 InterpreterPushArgsThenCall 实行后的框架状态。

连续与以前相同的过程,我们检讨被调用者是否为函数工具,获取其参数计数,并将吸收器补到全局代理。
终极,我们到达了 InvokeFunctionCode。

在这里我们不会跳转到被调用者工具中的 Code。
我们检讨参数大小和参数计数之间是否存在不匹配,然后跳转到 ArgumentsAdaptorTrampoline。

在这个内置组件中,我们构建了一个额外的框架,也便是臭名昭著的参数适配器框架。
这里我不会阐明内置组件内部发生了什么,只会向你展示内置组件调用被调用者的 Code 之前的框架状态。
请把稳,这是一个精确的 x64 call(不是 jmp),在被调用者实行之后,我们将返回到 ArgumentsAdaptorTrampoline。
这与进行尾调用的 InvokeFunctionCode 恰好相反。

我们创建了另一个框架,该框架复制了所有必需的参数,以便在被调用者框架顶部精确地包含参数的形参计数。
它创建了一个被调用者函数的接口,因此后者无需知道参数数量。
被调用者将始终能够利用与以前相同的打算结果来访问其参数,即[ai] = 2 + parameter_count - i - 1。

V8 具有一些分外的内置函数,它们在须要通过 rest 参数或 arguments 工具访问别的参数时能够理解适配器框架。
它们始终须要检讨被调用者框架顶部的适配器框架类型,然后采纳相应方法。

如你所见,我们办理了参数/寄存器访问问题,但是却添加了很多繁芜性。
须要访问所有参数的内置组件都须要理解并检讨适配器框架的存在。
不仅如此,我们还须要把稳不要访问过期的旧数据。
考虑对 add42 的以下变动:

function add42(x) { x += 42; return x;}

现在,字节码数组为:

25 02 Ldar a0 ;; Load the first argument to the accumulator40 2a 00 AddSmi [42] ;; Add 42 to it26 02 Star a0 ;; Store accumulator in the first argument slotab Return ;; Return the accumulator

如你所见,我们现在修正 a0。
因此,在调用 add42(1, 2, 3)的情形下,参数适配器框架中的插槽将被修正,但调用者框架仍将包含数字 1。
我们须要把稳,参数工具正在访问修正后的值,而不是旧值。

从函数返回很大略,只是会很慢。
还记得 LeaveInterpreterFrame 做什么吗?它基本上会弹出被调用者框架和参数,直达到到最大形参计数为止。
因此,当我们返回参数适配器存根时,栈如下所示:

被调用者 add42 实行之后的框架状态。

我们须要弹出参数数量,弹出适配器框架,根据实际参数计数弹出所有参数,然后返回到调用者实行。

大略总结:参数适配器机制不仅繁芜,而且本钱很高。

移除参数适配器框架

我们可以做得更好吗?我们可以移除适配器框架吗?事实证明我们确实可以。

我们回顾一下之前的需求:

我们须要能够像以前一样无缝访问参数和寄存器。
访问它们时无法进行检讨。
那本钱太高了。
我们须要能够从栈中布局 rest 参数和 arguments 工具。
从一个调用返回时,我们须要能够轻松清理未知数量的参数。
此外,当然我们希望没有额外的框架!

如果要肃清多余的框架,则须要确定将参数放在何处:在被调用者框架中还是在调用者框架中。

被调用者框架中的参数

假设我们将参数放在被调用者框架中。
这彷佛是一个好主张,由于无论何时弹出框架,我们都会一次弹出所有参数!

参数必须位于保存的框架指针和框架末端之间的某个位置。
这就哀求框架的大小不会被静态地知晓。
访问参数仍旧很随意马虎,它便是一个来自框架指针的大略偏移量。
但现在访问寄存器要繁芜得多,由于它会根据参数的数量而变革。

栈指针总是指向末了一个寄存器,然后我们可以利用它来访问寄存器而无需知道参数计数。
这种方法可能行得通,但它有一个关键毛病。
它须要复制所有可以访问寄存器和参数的字节码。
我们将须要 LdaArgument 和 LdaRegister,而不是大略的 Ldar。
当然,我们还可以检讨我们是否正在访问一个参数或寄存器(正或负偏移量),但这将须要检讨每个参数和寄存器访问。
显然这种方法太昂贵了!

调用者框架中的参数

好的,如果我们在调用者框架中放参数呢?

记住如何打算一个框架中参数 i 的偏移量:[ai] = 2 + parameter_count - i - 1。
如果我们拥有所有参数(不仅是形式参数),则偏移量将为[ai] = 2 + parameter_count - i - 1.也便是说,对付每个参数访问,我们都须要加载实际的参数计数。

但如果我们反转参数会发生什么呢?现在可以大略地将偏移量打算为[ai] = 2 + i。
我们不须要知道栈中有多少个参数,但如果我们可以担保栈中至少有形参计数那么多的参数,那么我们就能一贯利用这种方案来打算偏移量。

换句话说,压入栈的参数数量将始终是参数数量和形参数量之间的最大值,并且在须要时利用 undefined 工具进行添补。

这还有另一个好处!
对付任何 JS 函数,吸收器始终位于相同的偏移量处,就在返回地址的正上方:[this] = 2。

对付我们的第 1 和第 4 条哀求,这是一个干净的办理方案。
其余两个哀求又如何呢?我们如何布局 rest 参数和 arguments 工具?返回调用者时如何清理栈中的参数?为此,我们短缺的只是参数计数而已。
我们须要将其保存在某个地方。
只要可以轻松访问此信息即可,详细怎么做没那么多限定。
两种基本选项分别是:将其推送到调用者框架中的吸收者之后,或被调用者框架中的固定标头部分。
我们实现了后者,由于它合并了 Interpreter 和 Optimized 框架的固定标头部分。

如果在 V8 v8.9 中运行前面的示例,则在 InterpreterArgsThenPush 之后将看到以下栈(请把稳,现在参数已反转):

内置 InterpreterPushArgsThenCall 实行后的框架状态。

所有实行都遵照类似的路径,直达到到 InvokeFunctionCode。
在这里,我们在申请不敷的情形下处理参数,根据须要推送尽可能多的 undefined 工具。
请把稳,在申请过度的情形下,我们不会进行任何变动。
末了,我们通过一个寄存器将参数数量通报给被调用者的 Code。
在 x64 的情形下,我们利用寄存器 rax。

如果被调用者尚未进行优化,我们将到达 InterpreterEntryTrampoline,它会构建以下栈框架。

没有参数适配器的栈框架。

被调用者框架有一个额外的插槽,个中包含的参数计数可用于布局 rest 参数或 arguments 工具,并在返回到调用者之前打消栈中参数。

返回时,我们修正 LeaveInterpreterFrame 以读取栈中的参数计数,并弹出参数计数和形式参数计数之间的较大数字。

TurboFan

那么代码优化呢?我们来轻微变动一下初始脚本,以逼迫 V8 利用 TurboFan 对其进行编译:

function add42(x) { return x + 42; }function callAdd42() { add42(3); }%PrepareFunctionForOptimization(callAdd42);callAdd42();%OptimizeFunctionOnNextCall(callAdd42);callAdd42();

在这里,我们利用 V8 内部函数来逼迫 V8 优化调用,否则 V8 仅在我们的小函数变热(常常利用)时才对其进行优化。
我们在优化之前调用它一次,以网络一些可用于辅导编译的类型信息。
在此处阅读有关 TurboFan 的更多信息(https://v8.dev/docs/turbofan)。

这里,我只展示与主题干系的部分天生代码。

movq rdi,0x1a8e082126ad ;; Load the function object <JSFunction add42>push 0x6 ;; Push SMI 3 as argumentmovq rcx,0x1a8e082030d1 ;; <JSGlobal Object>push rcx ;; Push receiver (the global proxy object)movl rax,0x1 ;; Save the arguments count in raxmovl rcx,[rdi+0x17] ;; Load function object {Code} field in rcxcall rcx ;; Finally, call the code object!

只管这段代码利用了汇编来编写,但如果你仔细看我的注释该当很随意马虎能懂。
实质上,在编译调用时,TF 须要完成之前在 InterpreterPushArgsThenCall、Call、CallFunction 和 InvokeFunctionCall 内置组件中完成的所有事情。
它该当会有更多的静态信息来实行此操作并发出更少的打算机指令。

带参数适配器框架的 TurboFan

现在,让我们来看看参数数量和参数计数不匹配的情形。
考虑调用 add42(1, 2, 3)。
它会编译为:

movq rdi,0x4250820fff1 ;; Load the function object <JSFunction add42>;; Push receiver and arguments SMIs 1, 2 and 3movq rcx,0x42508080dd5 ;; <JSGlobal Object>push rcxpush 0x2push 0x4push 0x6movl rax,0x3 ;; Save the arguments count in raxmovl rbx,0x1 ;; Save the formal parameters count in rbxmovq r10,0x564ed7fdf840 ;; <ArgumentsAdaptorTrampoline>call r10 ;; Call the ArgumentsAdaptorTrampoline

如你所见,不难为 TF 添加对参数和参数计数不匹配的支持。
只需调用参数适配器 trampoline 即可!

然而这种方法本钱很高。
对付每个优化的调用,我们现在都须要进入参数适配器 trampoline,并像未优化的代码一样处理框架。
这就阐明了为什么在优化的代码中移除适配器框架的性能收益比在 Ignition 上大得多。

但是,天生的代码非常大略。
从中返回非常随意马虎(结尾):

movq rsp,rbp ;; Clean callee framepop rbpret 0x8 ;; Pops a single argument (the receiver)

我们弹出框架并根据参数计数发出一个返回指令。
如果实参计数和形参计数不匹配,则适配器框架 trampoline 将对其进行处理。

没有参数适配器框架的 TurboFan

天生的代码实质上与参数计数匹配的调用代码相同。
考虑调用 add42(1, 2, 3)。
这将天生:

movq rdi,0x35ac082126ad ;; Load the function object <JSFunction add42>;; Push receiver and arguments 1, 2 and 3 (reversed)push 0x6push 0x4push 0x2movq rcx,0x35ac082030d1 ;; <JSGlobal Object>push rcxmovl rax,0x3 ;; Save the arguments count in raxmovl rcx,[rdi+0x17] ;; Load function object {Code} field in rcxcall rcx ;; Finally, call the code object!

该函数的结尾如何?我们不再回到参数适配器 trampoline 了,因此结尾确实比以前繁芜了一些。

movq rcx,[rbp-0x18] ;; Load the argument count (from callee frame) to rcxmovq rsp,rbp ;; Pop out callee framepop rbpcmpq rcx,0x0 ;; Compare arguments count with formal parameter countjg 0x35ac000840c6 <+0x86>;; If arguments count is smaller (or equal) than the formal parameter count:ret 0x8 ;; Return as usual (parameter count is statically known);; If we have more arguments in the stack than formal parameters:pop r10 ;; Save the return addressleaq rsp,[rsp+rcx8+0x8] ;; Pop all arguments according to rcxpush r10 ;; Recover the return addressretl小结

参数适配器框架是一个临时办理方案,用于实际参数和形式参数计数不匹配的调用。
这是一个大略的办理方案,但它带来了很高的性能本钱,并增加了代码库的繁芜性。
如今,许多 Web 框架利用这一特性来创建更灵巧的 API,结果带来了更高的性能本钱。
反转栈中参数这个大略的想法可以大大降落实现繁芜性,并肃清了此类调用的险些所有开销。

原文链接:

https://v8.dev/blog/adaptor-frame

延伸阅读:

Deno 2020 年大事记-InfoQ

关注我并转发此篇文章,即可得到学习资料~若想理解更多,也可移步InfoQ官网,获取InfoQ最新资讯~