大约一个月前,WebAssembly 社区组投票将分支提示提案推进到第 4 阶段,有效地推荐将其添加到标准中,工作组上周通过投票将其纳入第 5 阶段,正式将其添加到标准中。
这对作者来说是一个重大成就,因为作者从大约 4 年前该提案的发起就一直是提案的倡导者,也是赞助这项工作的Leaning Technologies(作者的公司)。
在本文中,作者将解释该提案的目的,以及将其从想法带到标准的历程。
问题
一切始于一个非常实际的问题:提高应用程序的性能。该应用程序是 CheerpX,一种在浏览器中运行的 X86 虚拟机。
CheerpX 的特别之处在于它即时(JIT)将 X86 linux 应用程序编译为 WebAssembly。
CheerpX 的内部结构对于当前的主题并不重要,但非常有趣。如果您对此感到好奇,可以观看下面的视频以获取更多详细信息。(不幸的是,前几分钟的音频丢失了)。
<iframe title="CheerpX: a WebAssembly-based x86 virtual machine in the browser, Yuri Iozzelli" src="https://www.youtube.com/embed/7JUs4c99-mo" width="100%" frameborder="0" allowfullscreen=""></iframe>
像大多数 JIT 编译器一样,CheerpX 会做出一些假设以生成更好的代码,并需要在继续之前检查这些假设是否成立。生成的代码有许多以下模式的实例:
while(...) { if (unlikely) slow_path(); else { ... }}
现代 CPU 非常擅长预测不太可能的分支,因此if
的成本非常低。问题是不太可能的分支的代码仍然会被加载到指令缓存中,只是因为它在内存中紧挨着出现。
在将 C/C++编译为本机代码时,可以使用__builtin_expect
或[[unlikely]]
来提示编译器将该代码移出热路径(并可能执行其他有用的操作,例如不内联对slow_path()
的调用,或偏向于其他块的寄存器分配)。
本机平台的 JIT 编译器将直接发出机器代码,并手动进行这些优化。
然而,在编译为 WebAssembly 时,这是不可能的,因为其对控制流的限制:在 WebAssembly 中,除了循环之外,块的所有前驱都在块本身之前语法上出现。有一些方法可以解决这个问题,但结果代码将有一些开销,这将违背目的。(有关提案存储库中的动机页面,请参阅更深入的解释)。
但最终的机器代码是由引擎生成的,所以如果我们能够向它传递一个提示,类似于 C++中的[[unlikely]]
,我们就可以通过将最佳代码排序委托给引擎本身来解决这个问题。
提案诞生
作者开始研究 V8(Chrome 的 JavaScript 引擎)的内部,看看是否有办法强制它将慢路径移动到函数的末尾,并且作者意识到这个概念已经存在并且在编译 JavaScript 代码时被广泛使用。它甚至用于 WebAssembly 指令br_if_null
(假设空情况不太可能)。
所以作者很快就提出了一个新的br_if_unlikely
指令的概念验证,它的行为类似于常规的br_if
,但添加了unlikely
提示。
在实际用例上的初步测试显示有 7 - 10%的加速,这是相当显著的。
所以作者在 WebAssembly 社区组内开始了讨论。
虽然作者没有立即说服每个人这个功能是必要的,但 CG同意进一步探索这个问题是值得的,并且“分支提示提案”从第 1 阶段开始,在 WebAssembly 组织下有自己的存储库。
提案遵循一个过程,在成为标准的一部分之前分为 5 个阶段。这只是旅程的开始。
历程
如何提示?
一个(令人惊讶的)有争议的细节是如何编码提示:
- 应该添加新的指令变体(类似于概念验证)?
- 或者也许是一个应该出现在提示分支之前的单个指令?
- 或者是一些不直接编码在指令流中的其他形式的元数据?
最后选择了最后一个选项,理由如下:
分支提示只是……提示。它们不会影响程序的语义。引擎可以通过完全不执行它们来实现它们,并且仍然符合要求。那么将它们编码为指令并强制所有引擎识别和解析它们是错误的,即使是那些无法或不会使用它们的引擎(例如解释器)。可以想象未来会添加其他类型的提示(例如内联、预取等),并且将它们添加为指令只会增加这种负担。
WebAssembly 已经有一种将一些元数据附加到模块的方法:自定义部分。
自定义部分由一个名称标识,可以包含任意数据。它不会影响程序的语义,即使引擎识别它,未能验证或解析它也不会阻止模块的执行。
非常适合我们的用例。
如何引用指令?
决定将提示放在自定义部分后,现在需要一种从该部分引用特定指令的方法。
在 WebAssembly 中,“事物”通常由一个索引标识:函数、全局变量、局部变量、内存等。将指令也由索引标识也是很自然的。
指令 N 将是从代码部分的开头或从其函数的开头开始的第 N 个指令。或者,我们可以使用从该部分(或函数)的开头开始的字节偏移量。
有一些权衡:
- 索引用于引用所有其他项目。
- 索引是“稳定的”:相同 WebAssembly 模块的不同(有效)二进制编码可能导致不同的偏移量。
但是: - 字节偏移量不需要解码提示之前的所有指令即可识别它。
- 用于调试信息的 DWARF 标准使用字节偏移量来引用指令。
- 引擎实现者和编译器编写者表示更喜欢字节偏移量,因为在他们的实现中,这是引用指令的更自然表示。
对于字节偏移量的共识更强,所以作者继续选择该选项。
如何将其表示为文本?
直到最近,WebAssembly 还没有一种在文本格式中表示自定义部分的方法。
注释提案是解决此缺陷的方法,但由于一些问题而停滞不前。其中最大的问题是如何为该提案添加测试(标准化的要求),因为它涉及自定义部分,而这些部分根据定义不会影响模块的语义。稍后我将回到这个问题。
如前所述,字节偏移量不是“稳定的”:相同 WebAssembly 模块的不同编码可能导致相同指令的不同偏移量(用于整数的LEB128编码对于小值有多种表示形式)。这意味着在转换为文本格式时,我们不能简单地依赖字节偏移量,而需要另一种表示形式。
一个优雅的解决方案是利用注释提案中的自定义注释,并定义一个分支提示注释,该注释出现在文本中的提示指令旁边。这样,提示隐式地引用了指令,与二进制编码无关:
(@metadata.code.branch_hint "\0") br_if $label
如何将其与测试套件集成?
在这一点上,标准化的主要障碍是对注释提案的依赖以及官方测试套件中缺乏测试。注释提案本身也由于缺乏测试而停滞不前,原因非常相似。
这是一个棘手的问题需要解决(并且是第 3 阶段和第 4 阶段之间 3 年差距的原因),因为如何在不影响程序执行的可观察方式的情况下测试某些东西?并且,在更实际的层面上,WebAssembly 参考解释器根本不支持自定义部分(结果,即使是已经标准的“name”部分到目前为止也没有测试)。
决定通过验证自定义部分的内容是否有效并确保二进制和文本格式之间的正确往返来测试自定义部分。
与 Andreas Rossberg(注释提案的倡导者)一起,作者扩展了参考解释器以处理自定义部分并(可选)验证它们。
这最终为两个提案的标准化解锁。
规范放在哪里?
实际上还有一个最后要解决的问题:将新功能的规范放在哪里。
WebAssembly 实际上有 3 个不同的规范:
- 核心规范:定义 WebAssembly 模块的结构、它们的指令集以及它们在二进制和文本格式中的表示,以及验证、实例化和执行的语义。
- JavaScript 嵌入:定义用于在 JavaScript 中访问 WebAssembly 的 JavaScript 类和对象,包括用于验证、编译、实例化的方法,以及表示和操作导入和导出作为 JavaScript 对象的类。
- Web 嵌入:定义专门在 Web 浏览器中提供的 JavaScript API 的扩展,特别是用于从源绑定的 Response 类型进行流式编译和实例化的接口。
在这 3 个中,核心规范最有意义。然而,作者的功能实际上并没有改变 WebAssembly 的语义,只是引擎如何决定编译代码。目前,核心规范中描述的唯一自定义部分是附录中的“name”部分。在那里添加越来越多的部分格式似乎不合适。
所以创建了一个新文档:WebAssembly 的第四个规范文档。 - 代码元数据:定义附加到指令的元数据。
该文档首先描述了引用指令的自定义部分的一般结构(代码元数据),然后基于此描述了分支提示部分。
这将对也希望向指令添加元数据并避免多种不兼容格式的扩散的未来提案很有用。例如,编译提示提案利用了为分支提示构建的所有共享基础设施。
结论
该提案现在已在所有主要的 Web 引擎(V8、SpiderMonkey、JavaScriptCore)、一些工具(Wabt、wasm-tools、Cheerp)中实现,当然也在 CheerpX 中使用!
浏览器仍在将该功能隐藏在一个标志后面,但这在不久的将来应该会改变。
这是一段漫长的旅程,但与许多不同的利益相关者(编译器作者、浏览器开发人员、研究人员等)合作以改进提案并在其周围达成共识是一次宝贵的经验。
想了解更多关于我们所做的事情?加入我们的 Discord!作者在 Leaning Technologies 的团队随时准备回答您可能有的任何问题。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。