天我们将为大家介绍一个令网页性能大幅提升的神奇技术——CSS硬件加速。随着移动互联网的蓬勃发展和网页设计越发复杂,如何优化网页性能成为了前端开发者们亟待解决的问题。在这篇文章中,我们将深入了解CSS硬件加速的原理,并通过一个生动的案例来展示它如何帮助我们改善网页的渲染性能。
一、什么是CSS硬件加速
在传统的网页渲染中,浏览器使用中央处理器(CPU)来处理CSS样式和页面渲染。然而,随着网页变得越来越复杂,例如包含大量动画、过渡效果或复杂的变换,CPU可能会承担较重的负担,导致页面加载缓慢或卡顿。CSS硬件加速是一种解决方案,它充分利用了计算机的图形处理单元(GPU)来加快CSS样式的处理和渲染,从而提高页面性能和流畅度。
1.1 CPU
CPU 即中央处理器。
CPU是计算机的大脑,它提供了一套指令集,我们写的程序最终会通过 CPU 指令来控制的计算机的运行。它会对指令进行译码,然后通过逻辑电路执行该指令。整个执行的流程分为了多个阶段,叫做流水线。指令流水线包括取指令、译码、执行、取数、写回五步,这是一个指令周期。CPU会不断的执行指令周期来完成各种任务。
1.2 GPU
GPU 即图形处理器。
GPU,是Graphics ProcessingUnit的简写,是现代显卡中非常重要的一个部分,其地位与CPU在主板上的地位一致,主要负责的任务是加速图形处理速度。GPU是显卡的“大脑”,它决定了该显卡的档次和大部分性能,同时也是2D显示卡和3D显示卡的区别依据。2D显示芯片在处理3D图像和特效时主要依赖CPU的处理能力,称为“软加速”。3D显示芯片是将三维图像和特效处理功能集中在显示芯片内,也即所谓的“硬件加速”功能。
二、CSS硬件加速原理
CSS硬件加速的原理涉及到浏览器的渲染引擎、GPU以及优化渲染的过程。
2.1 浏览器的渲染流程
一个完整的渲染步骤大致可总结为如下:
2.2 CSS硬件加速触发
在传统的渲染过程中,布局和绘制是由CPU来完成的,而在CSS硬件加速下,GPU参与了渲染的处理,从而提高了性能。
CSS 中的以下几个属性能触发硬件加速:
1.transform属性:该属性用于应用2D或3D变换效果,如旋转、缩放、平移等。当使用transform属性时,浏览器会将变换任务交给GPU处理,从而实现硬件加速。
2.opacity属性:该属性用于设置元素的不透明度。虽然它主要用于控制透明度,但是一个不为1的值(例如0.99)也可以触发硬件加速。
3.will-change属性:will-change属性用于提示浏览器一个元素将要发生的变化,以便浏览器在渲染过程中做出优化。
一旦CSS硬件加速被触发,相关的渲染任务将被GPU处理。GPU在处理图形和动画方面通常比CPU更快和更高效。对于复杂的CSS动画和变换,GPU可以并行处理多个任务,从而提高性能和流畅度。
请注意,CSS硬件加速并不是适用于所有情况。虽然它在许多情况下可以带来显著的性能提升,但有时也可能导致额外的GPU资源占用,从而影响其他应用程序的性能。因此,在使用CSS硬件加速时,我们应该进行性能测试和优化,确保在特定情况下确实能获得性能的提升。
三、CSS硬件加速案例
现在,我们来看一个实际的案例,通过启用CSS硬件加速来改善网页性能。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
.app {
position: relative;
width: 400px;
height: 400px;
}
.box {
position: absolute;
left: 0;
top: 0;
width: 100px;
height: 100px;
background-color: yellowgreen;
}
.box-run1 {
-webkit-animation: run1 4s infinite;
animation: run1 4s infinite;
}
.box-run2 {
-webkit-animation: run2 4s infinite;
animation: run2 4s infinite;
}
@keyframes run1 {
0% {
top: 0;
left: 0;
}
25% {
top: 0;
left: 200px;
}
50% {
top: 200px;
left: 200px;
}
75% {
top: 200px;
left: 0;
}
}
@keyframes run2 {
0% {
transform: translate(0, 0);
}
25% {
transform: translate(200px, 0);
}
50% {
transform: translate(200px, 200px);
}
75% {
transform: translate(0, 200px);
}
}
</style>
</head>
<body>
<div class="app">
<div class="box"></div>
</div>
<button class="btn1">循环转换</button>
<button class="btn2">硬件加速</button>
<script>
let box = document.querySelector(".box");
let btn1 = document.querySelector(".btn1");
let btn2 = document.querySelector(".btn2");
btn1.addEventListener("click", function (e) {
box.classList.remove("box-run2");
box.classList.add("box-run1");
});
btn2.addEventListener("click", function (e) {
box.classList.remove("box-run1");
box.classList.add("box-run2");
});
</script>
</body>
</html>
此时我们可以运行代码,在页面上可以看到,2个按钮均能使box在app当中循环移动。但对于这两种方式的移动,他们的效率却有着很大的差异。我们可以使用开发者工具里的Performance去查看。
当我们点击btn1时,此时box盒子通过定位的left和top进行循环移动时。
此时我们可以看到细节模块的记录详情。
蓝色(Loading):网络通信和HTML解析
黄色(Scripting):Javascript执行
紫色(Rendering):样式计算和布局,即重排
绿色(Painting):重绘
灰色(Other):其他事件花费的时间
白色(Idle):空闲时间
细节模块有4个面板,Summary面板每个事件都会有,其他三个只针对特定事件会有。
当我们点击btn2时,此时box盒子通过transform属性进行css硬件加速后进行循环移动时。
通过对比我们不难发现,当启用硬件加速时,方块的变换会更加流畅,其样式计算和布局、重绘的时间都会减少。因为GPU参与了渲染过程。
总结
CSS硬件加速是一个强大的前端技术,可以显著提高网页的性能和流畅度。通过启用硬件加速,我们可以将一些渲染任务交给GPU来处理,减轻CPU的负担,从而优化网页的渲染性能。然而,我们需要注意不要滥用硬件加速,避免触发不必要的GPU渲染,以确保真正获得性能提升。在日常的网页开发中,我们可以灵活运用CSS硬件加速,为用户带来更好的浏览体验。
数适配器机制不仅复杂,而且成本很高。
本文最初发表于 v8.dev(Faster JavaScript calls),基于 CC 3.0 协议分享,由 InfoQ 翻译并发布。
JavaScript 允许使用与预期形式参数数量不同的实际参数来调用一个函数,也就是传递的实参可以少于或者多于声明的形参数量。前者称为申请不足(under-application),后者称为申请过度(over-application)。
在申请不足的情况下,剩余形式参数会被分配 undefined 值。在申请过度的情况下,可以使用 rest 参数和 arguments 属性访问剩余实参,或者如果它们是多余的可以直接忽略。如今,许多 Web/Node.js 框架都使用这个 JS 特性来接受可选形参,并创建更灵活的 API。
直到最近,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 栈框架。
为了讲清楚我们如何加快调用,首先我们来看看 V8 如何执行一个调用,以及参数适配器框架如何工作。
当我们在 JS 中调用一个函数调用时,V8 内部会发生什么呢?用以下 JS 脚本为例:
function add42(x) {
return x + 42;
}
add42(3);在函数调用期间 V8 内部的执行流程。
V8 是一个多层 VM。它的第一层称为 Ignition,是一个具有累加器寄存器的字节码栈机。V8 首先会将代码编译为 Ignition 字节码。上面的调用被编译为以下内容:
0d LdaUndefined ;; Load undefined into the accumulator
26 f9 Star r2 ;; Store it in register r2
13 01 00 LdaGlobal [1] ;; Load global pointed by const 1 (add42)
26 fa Star r1 ;; Store it in register r1
0c 03 LdaSmi [3] ;; Load small integer 3 into the accumulator
26 f8 Star r3 ;; Store it in register r3
5f 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 accumulator
40 2a 00 AddSmi [42] ;; Add 42 to it
ab 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 accumulator
40 2a 00 AddSmi [42] ;; Add 42 to it
26 02 Star a0 ;; Store accumulator in the first argument slot
ab Return ;; Return the accumulator如你所见,我们现在修改 a0。因此,在调用 add42(1, 2, 3)的情况下,参数适配器框架中的插槽将被修改,但调用者框架仍将包含数字 1。我们需要注意,参数对象正在访问修改后的值,而不是旧值。
从函数返回很简单,只是会很慢。还记得 LeaveInterpreterFrame 做什么吗?它基本上会弹出被调用者框架和参数,直到到达最大形参计数为止。因此,当我们返回参数适配器存根时,栈如下所示:
被调用者 add42 执行之后的框架状态。
我们需要弹出参数数量,弹出适配器框架,根据实际参数计数弹出所有参数,然后返回到调用者执行。
简单总结:参数适配器机制不仅复杂,而且成本很高。
我们可以做得更好吗?我们可以移除适配器框架吗?事实证明我们确实可以。
我们回顾一下之前的需求:
如果要消除多余的框架,则需要确定将参数放在何处:在被调用者框架中还是在调用者框架中。
假设我们将参数放在被调用者框架中。这似乎是一个好主意,因为无论何时弹出框架,我们都会一次弹出所有参数!
参数必须位于保存的框架指针和框架末尾之间的某个位置。这就要求框架的大小不会被静态地知晓。访问参数仍然很容易,它就是一个来自框架指针的简单偏移量。但现在访问寄存器要复杂得多,因为它会根据参数的数量而变化。
栈指针总是指向最后一个寄存器,然后我们可以使用它来访问寄存器而无需知道参数计数。这种方法可能行得通,但它有一个关键缺陷。它需要复制所有可以访问寄存器和参数的字节码。我们将需要 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 以读取栈中的参数计数,并弹出参数计数和形式参数计数之间的较大数字。
那么代码优化呢?我们来稍微更改一下初始脚本,以强制 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 argument
movq rcx,0x1a8e082030d1 ;; <JSGlobal Object>
push rcx ;; Push receiver (the global proxy object)
movl rax,0x1 ;; Save the arguments count in rax
movl rcx,[rdi+0x17] ;; Load function object {Code} field in rcx
call rcx ;; Finally, call the code object!尽管这段代码使用了汇编来编写,但如果你仔细看我的注释应该很容易能懂。本质上,在编译调用时,TF 需要完成之前在 InterpreterPushArgsThenCall、Call、CallFunction 和 InvokeFunctionCall 内置组件中完成的所有工作。它应该会有更多的静态信息来执行此操作并发出更少的计算机指令。
现在,让我们来看看参数数量和参数计数不匹配的情况。考虑调用 add42(1, 2, 3)。它会编译为:
movq rdi,0x4250820fff1 ;; Load the function object <JSFunction add42>
;; Push receiver and arguments SMIs 1, 2 and 3
movq rcx,0x42508080dd5 ;; <JSGlobal Object>
push rcx
push 0x2
push 0x4
push 0x6
movl rax,0x3 ;; Save the arguments count in rax
movl rbx,0x1 ;; Save the formal parameters count in rbx
movq r10,0x564ed7fdf840 ;; <ArgumentsAdaptorTrampoline>
call r10 ;; Call the ArgumentsAdaptorTrampoline如你所见,不难为 TF 添加对参数和参数计数不匹配的支持。只需调用参数适配器 trampoline 即可!
然而这种方法成本很高。对于每个优化的调用,我们现在都需要进入参数适配器 trampoline,并像未优化的代码一样处理框架。这就解释了为什么在优化的代码中移除适配器框架的性能收益比在 Ignition 上大得多。
但是,生成的代码非常简单。从中返回非常容易(结尾):
movq rsp,rbp ;; Clean callee frame
pop rbp
ret 0x8 ;; Pops a single argument (the receiver)我们弹出框架并根据参数计数发出一个返回指令。如果实参计数和形参计数不匹配,则适配器框架 trampoline 将对其进行处理。
生成的代码本质上与参数计数匹配的调用代码相同。考虑调用 add42(1, 2, 3)。这将生成:
movq rdi,0x35ac082126ad ;; Load the function object <JSFunction add42>
;; Push receiver and arguments 1, 2 and 3 (reversed)
push 0x6
push 0x4
push 0x2
movq rcx,0x35ac082030d1 ;; <JSGlobal Object>
push rcx
movl rax,0x3 ;; Save the arguments count in rax
movl rcx,[rdi+0x17] ;; Load function object {Code} field in rcx
call rcx ;; Finally, call the code object!该函数的结尾如何?我们不再回到参数适配器 trampoline 了,因此结尾确实比以前复杂了一些。
movq rcx,[rbp-0x18] ;; Load the argument count (from callee frame) to rcx
movq rsp,rbp ;; Pop out callee frame
pop rbp
cmpq rcx,0x0 ;; Compare arguments count with formal parameter count
jg 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 address
leaq rsp,[rsp+rcx*8+0x8] ;; Pop all arguments according to rcx
push r10 ;; Recover the return address
retl参数适配器框架是一个临时解决方案,用于实际参数和形式参数计数不匹配的调用。这是一个简单的解决方案,但它带来了很高的性能成本,并增加了代码库的复杂性。如今,许多 Web 框架使用这一特性来创建更灵活的 API,结果带来了更高的性能成本。反转栈中参数这个简单的想法可以大大降低实现复杂性,并消除了此类调用的几乎所有开销。
原文链接:
https://v8.dev/blog/adaptor-frame
延伸阅读:
关注我并转发此篇文章,即可获得学习资料~若想了解更多,也可移步InfoQ官网,获取InfoQ最新资讯~
家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!
高级前端进阶
与二十年前相比,如今 JavaScript 在浏览器中的运行速度要快好多倍。而这多亏了浏览器厂商们在此期间坚持不懈地加强性能优化。
而现在,我们又要开始在完全不同的运行环境中优化 JavaScript 的性能 —— 这些新环境中的游戏规则是截然不同的。而让 JavaScript 能够适应不同运行环境的,正是 WebAssembly。
这里我们要明确一点 —— 如果你是在浏览器中运行 JavaScript,那么直接部署 JavaScript 就行了。浏览器中的 JavaScript 引擎已经被精心调校过,可以很快速地运行装载进来的 JavaScript 程序。
但如果是在无服务器(Serverless)功能中运行 JavaScript 呢?又或者说,如果想要在 iOS 或游戏机这类不支持通常的即时编译的环境中运行 JavaScript,又该如何把控性能?
在这些使用场景中,你会需要关注这新一轮的 JavaScript 优化。另外,若想要让 Python、Ruby 或者 Lua 等其他运行时语言在上述使用场景中提速,JavaScript 优化也有参考价值。
但在开始探索如何在不同环境中进行优化前,我们需要了解一下其中的基本原理。
不论你在何时运行 Javascript 程序,JavaScript 代码终归要以机器编码的形式执行。 JavaScript 引擎通过一系列技术来实现这一转换,例如各种解释器和 JIT 编译器。(详情请参见即时(JIT)编译器速成课。)
但如果你想要运行程序的平台没有 JavaScript 引擎怎么办?那你就需要把 JavaScript 引擎和程序代码一起部署。
为了能让 JavaScript 随处运行,我们把 JavaScript 引擎部署为一个 WebAssembly 模块,这样就能够跨越不同机器架构之间的差异。而且,借助 WASI,跨操作系统也同样成为可能。
这意味着,整个 JavaScript 运行环境被集成进了 WebAssembly 实例中。部署了 WebAssembly 后, 你只需把 JavaScript 代码喂进去就行了,WebAssembly 实例会自行消化代码。
JavaScript 引擎并不会直接在机器内存中运转,从二进制码到二进制码的垃圾回收对象,JavaScript 引擎把这一切都放到 Wasm 模块的线性内存中。
对于 JavaScript 引擎,我们选用了 SpiderMonkey,就是 Firefox 浏览器中用到的那个。SpiderMonkey 是行业级别的 JavaScript 虚拟机(VM)之一,在浏览器领域里是久经沙场的老将。当你运行不可信代码,或者代码会处理不可信输入信息时,这种皮实耐用、安全性高的特性就显得尤为重要了。
SpiderMonkey 还使用了一种叫做精确堆栈扫描的技术,它对我下面将要说到的部分优化点极其重要。SpiderMonkey 还具有包容度极高的代码库,这一点也很重要,因为协作开发者们来自三个不同的组织 —— Fastly、Mozilla 和 Igalia。
我刚刚描述的运行方式并没有显得具有什么颠覆性特征。几年前大家就已经开始这样用 WebAssembly 运行 JavaScript 了。
但问题在于,这样运行很慢。WebAssembly 并不支持动态地生成新的机器编码,然后在纯 Wasm 代码里运行。这就意味着你无法使用即时编译。你只能使用解释器。
知道了有这种局限性,你可能会问:
鉴于即时编译让浏览器能快速运行 JavaScript(且鉴于在 WebAssembly 模块中不能进行即时编译),还想提速似乎是反直觉的。
但假如,即使不能用即时编译,我们还有没有办法能让 JavaScript 运行提速呢?
让我们通过几个案例来看看,如果 WebAssembly 可以快速运行 JavaScript,将会产生多么大的效益。
在有些环境下,由于安全原因,无法使用即时编译,举例来说,无特殊权限的 iOS 应用、部分智能电视以及游戏机设备都属于此范畴。
在这些平台上,必须要使用解释器才行。但想在这些平台上运行的,都是那种运行周期长、代码量大的应用。正是这些条件让你不想用解释器,因为解释器会严重拖慢执行速度。
如果能让 JavaScript 在这样的环境中提速,那么开发者们就可以在不支持即时编译的平台使用 JavaScript 而无需顾虑性能了。
在另外一些场景中,即时编译不成问题,但启动时间却拖了后腿,比如在使用无服务器功能时。这就是冷启动延迟的问题,你可能已经有所耳闻。
即使用精简到极致的 JavaScript 环境 , 一个仅启动纯 JavaScript 引擎的隔离环境,最低延迟也有 5 毫秒左右,还没有把初始化应用的时间算进去。
倒是有一些办法可以把收到的请求的启动延迟隐藏起来。但随着 QUIC 这类提案在网络层中对连接时长的优化,想要隐藏延迟越来越困难。而当你链式执行多个无服务器功能等这类操作时,要隐藏延迟更是难上加难。
使用这些技术去隐藏延迟的平台页,常常会在多个请求间复用实例。某些情况下,这意味着在不同请求中都可以观察到全局状态,这就是拿安全当儿戏了。
正是由于这个冷启动问题,开发者们常常无法遵循最佳实践来开发。他们会在一次无服务器部署中,塞入大量功能。这就导致了另一个安全问题 ,一处暴雷,全盘完蛋。如果这次部署中的一部分破防了,那么攻击者就有了整个部署的访问权限。
但如果能把上述场景中 JavaScript 的启动时间降到足够低,那自然就无需再费尽心思去隐藏启动时间了,因为能在几微秒之间就启动一个实例。
如果能做到这种程度,就能为每个请求提供一个新实例,于是不会再有全局状态横穿多个请求。而且,由于这些实例足够轻量,开发者能够任意把代码拆分成粒度更细的片段,把每一段代码的故障范围压缩到最小。
这种实现还有另外一个安全方面的优点。除了实例能保持轻量、代码隔离粒度更优之外,Wasm 引擎能提供的安全壁垒也更坚固了。
JavaScript 引擎过去用来创建隔离的代码库庞大无比,包含着大量用来进行极其复杂的优化工作的底层代码,所以很容易产生 Bug,从而使得攻击者跳出虚拟机、获取到虚拟机所在系统的访问权限。这就是为何像 Chrome 和 Firefox 这样的浏览器要竭尽全力确保网站运行在完全隔离的进程中。
相反的是,Wasm 引擎需要的代码极少,因此便于检查,而且它们中有许多是用 Rust 这种内存无害语言写的。而由 WebAssembly 模块生成的原生二进制码,其内存隔离的安全性是可以验证的。
通过在 Wasm 引擎中运行 JavaScript 代码,构筑起了这座安全性更高的外部沙盒堡垒,以此作为另一道防线。
因此,在上述这些场景中,让 JavaScript 在 Wasm 引擎上运行得更快,是裨益良多的。那我们怎么来实现呢?要回答这个问题,需要弄清楚 JavaScript 引擎把时间都消磨在哪里了。
可以粗略地把 JavaScript 引擎所做的工作拆分为两个部分:初始化和运行时。
把 JavaScript 看作是一个包工头。这位包工头被雇用来完成这样一份工作,即运行 JavaScript 代码,并得出结果。
在这位包工头真正开始运作项目之前,它需要做一点预备工作。此初始化阶段包括了在执行之初所有那些只需运行一次的操作。
不论是什么项目,合同工都需要了解一下客户的需求,然后配置要完成任务所需的资源。
例如,合同工浏览一遍项目概要以及其他支持文档,然后把它们转化成自己能处理的东西,比如搭建一个项目管理系统,把所有文档存储并整理起来。
在 JavaScript 引擎看来,这个任务更像是通读顶层源码并把各项功能解析为字节码、为声明的变量分配内存、给已经定义过的变量赋值。
在无服务器等特定场景中,还有另一个需要初始化的部分,发生在应用初始化之前。
那就是引擎初始化。引擎本身需要率先启动起来,内置函数需要添加到环境当中。可以把这个过程看作在开始工作之前要先把办公室布置好 ,组装桌椅之类的事。
这个过程也可能花费一定量的时间,也是导致冷启动成为无服务器使用场景的大问题的原因之一。
一旦初始化阶段结束,JavaScript 引擎就能开始运行代码了。
把这部分工作的完成速度称为吞吐量(Throughput),能影响吞吐量的因素有很多。比如:
那么这就是 JavaScript 消耗时间的两个阶段。
那该如何让这两个阶段运行得更快呢?
先使用 Wizer 这个工具来加快初始化过程。稍后我会解释如何操作,但为了让心急的读者一睹为快,下面先给出运行一个非常简单的 JavaScript 应用时的加速情况。
当用 Wizer 运行这个小应用时,只消耗了 0.36 毫秒(等于 360 微秒)。这要比纯 JavaScript 的方式快了不止 13 倍。
启动能如此迅速,是因为借助了快照(Snapshot)。Nick Fitzgerald 在 WebAssembly 峰会上关于 Wizer 的演讲中进行了更为详尽的解释。
那么其中的原理是什么?在部署代码之前,作为构建步骤的一部分,用 JavaScript 引擎运行 JavaScript 代码,直到初始化结束。
在此处,JavaScript 引擎把所有的 JavaScript 代码解析成了字节码,并存储在了线性内存中。在这一阶段,引擎还会进行大量的内存分配和初始化工作。
由于线性内存的独立完备性非常强,当所有的数据值被存进来后,直接把这块内存绑定为 Wasm 模块的数据区块即可。
当 JavaScript 引擎模块被实例化后,它就能访问数据区块中的所有数据了。当引擎需要使用这块内存时,它可以复制所需的区块(或者内存页)到自己的线性内存中去。这样,JavaScript 引擎在启动时就无需再做配置工作了。所有的预初始化的数据就都已经准备就绪、听凭差遣了。
眼下,把这个数据区块和 JavaScript 引擎绑在了一起。但在将来,一旦模块链接(Module linking)可用了,就能把数据区块装载为一个单独的模块了,也就能让 JavaScript 引擎被多个不同的 JavaScript 应用复用了。
这样就实现了真正干净清爽的解耦。
JavaScript 引擎模块只包含引擎本身的代码。这意味着一经编译完成,这部分代码就可以高效率地被多个不同实例缓存和复用了。
另一方面,特定的应用模块不包含 Wasm 代码。它只含有线性内存,而线性内存只含有 JavaScript 代码字节码,以及初始化生成的 JavaScript 引擎状态数据。这让内存整理和分配十分便利。
就好像是包工头 JavaScript 引擎根本不需要再去布置办公室了。它直接可以拎包入住了。它的包里装下了整个办公室,所有器具一应俱全,全部都调校就绪,就等 JavaScript 引擎破土动工了。
而最酷的就是,这不是特地为 JavaScript 实现的 —— 只需要使用 WebAssembly 现有的属性即可。所以你也可以把这个办法用在 Python、Ruby、Lua 或其他运行时环境中。
通过这种方式,可以让启动时长超级短了,那如何优化吞吐量呢?
对于某些情况来说,吞吐量其实不算差。如果你的 JavaScript 应用运行周期非常短,它怎么也轮不到即时编译来处理 —— 它的全程都在解释器中完成。在这种情况中,吞吐量就和在浏览器中一样了,在传统的 JavaScript 引擎初始化完成之前,程序就已经运行完了。
但是对于运行周期更长的 JavaScript 代码,即时编译用不了多久就得开始介入了。一旦发生这种情况,吞吐量的差异就开始变得悬殊了。
如上面所言,在纯 WebAssembly 环境中是不可能使用即时编译的。但事实上,可以把即时编译的一些想法应用到提前编译模型中。
即时编译用到的一个优化技术是内联缓存(Inline caching)。通过内联缓存,即时编译创建一个存根链表,其中包含了机器编码的快捷路径,指向曾经运行过的 JavaScript 字节码的所有运行方式。(详情请参阅文章:即时编译器速成课)
之所以需要用链表,是因为 JavaScript 是动态类型语言。每当一行代码变换了不同的类型,就需要生成一个新的存根,添加到链表中。但如果之前就处理过这个类型,那就可以直接使用已经生成好的存根。
由于内联缓存(IC)在即时编译中比较常用,人们会认为它们是非常动态化的,并且专用于特定程序。但实际上,它们也可以用于 AOT 场景。
即使还没有看到 JavaScript 代码,也对要生成的 IC 存根比较熟悉了。这是因为 JavaScript 中有一些模式是经常被使用到的。
访问对象属性就是一个有力佐证。访问对象属性在 JavaScript 中非常常见,而使用 IC 存根就能为这个操作提速。对于那些有确定“形状”或者“隐藏类”(即属性的存储位置相对固定)的对象来说,当你读取这类对象的某个属性,该属性总在同样的偏移位置(Offset)上。
按照传统,即时编译中的这种 IC 存根会硬编码为两种值:一个是指向形状的指针,一个是属性的偏移量。而这所需的信息,是提前预知不到的。但能做的是把 IC 存根参数化。可以把形状和属性偏移量看作是传到存根里的变量。
这样,就能创建出一个单独的存根,它从内存中加载值,然后可以到处使用这个存根。可以把属于常见模式的所有存根合成一个 AOT 编译模块,不去关心 JavaScript 代码的具体功能细节。即使在浏览器设置中,这种 IC 共享也是有益处的,因为这让 JavaScript 引擎生成更少的机器编码,提升启动速度,优化本地指令缓存。
对于我们的使用场景来说,IC 共享尤其重要。它意味着可以把属于常见模式的所有存根合成一个 AOT 编译模块,不去关心 JavaScript 代码的具体实现细节。
我们发现,仅需几 KB 的 IC 存根,就能覆盖全部 JavaScript 代码中的绝大部分。例如,只需 2 KB 的 IC 存根,就足以覆盖 Google Octane 基准测试中 95% 的 JavaScript 代码。从初步测试结果来看,通常的网页浏览场景似乎都能保持这个比率。
因此,使用这种优化手段,我们应该能够达到早期即时编译的吞吐量水平。一旦我们做到这个程度,我们就将加入更细粒度的优化,进一步打磨性能,正如各个浏览器厂商的 JavaScript 引擎开发团队在早期即时编译中所做的那样。
以上是能提前做的,无需知道程序是做什么的,也无需知道它都使用了什么类型的数据。但要是能像即时编译一样访问到分析数据呢?那就可以全面优化代码了。
但这会引出一个问题 ,开发者分析起自己的代码来往往十分困难。要想提取出有代表性的代码样本,实非易事。因此没法确定是否能得到优质的分析数据。
如果能找合适的工具来进行分析,那么还是有可能让 JavaScript 代码运行得像如今的即时编译一样快速(连热身的时间都不需要!)的。
这种新的方式让我们激动不已,期盼着能更上一层楼。也很激动地看到,其他动态类型语言可以用这种方式拥抱 WebAssembly 了。
因此,下面是有几种上手的方式,如果有任何问题,可以在 Zulip 中提问。
要想在自己的平台运行 JavaScript,你需要嵌入一个支持 WASI 的 WebAssembly 引擎,比如Wasmtime。
然后需要 JavaScript 引擎。在这一步里,我们为 Mozilla 的构建系统添加了对编译 SpiderMonkey 到 WASI 的完全支持。Mozilla 将把 SpiderMonkey 的 WASI 构建添加到用于构建和测试 Firefox 的 CI 设置中。这让 WASI 成为了 SpiderMonkey 的线上质量目标,确保了 WASI 构建能够一直保持运转。这意味着可以如文中所讲的那样使用 SpiderMonkey。
最后,需要让用户提供预先初始化的 JavaScript 代码。为了能助你一臂之力,我们还开源了 Wizer,可以集成到构建工具中,产出针对特定应用的 WebAssembly 模块,以适用于 JavaScript 引擎模块所用的预先初始化内存。
如果是 Python、Ruby、Lua 等语言的使用者,可以针对该语言构建出一个自己的版本。
首先,需要把运行时编译成 WebAssembly,使用 WASI 作为系统调用,可参考我们对 SpiderMonkey 的处理。然后,可以按照上文所说,把 Wizer 集成到构建工具中,生成内存快照,这样就能用快照来加速启动。
原文链接:https://bytecodealliance.org/articles/making-javascript-run-fast-on-webassembly
原文作者:Lin Clark
中文参考翻译:https://juejin.cn/post/6981685894470172679
*请认真填写需求信息,我们会在24小时内与您取得联系。