WebAssembly

2,106 阅读11分钟

WebAssembly

WebAssembly(以下简称wasm)很快,你可能听说过这个,但是是什么让wasm这么快速呢?通过这篇文章,我将为你们展示一下wasm的背景和wasm为什么会这么快!

谈起诞生背景之前,我们聊一下wasm的一些热点事件

《毁灭战士3》这个项目使用 Emscripten 来编译开源的 C++ 代码库,不过这当然没那么简单,它并不是把编译器指向代码库就能搞定的。源代码有许多特性是移植版尚未支持的,因此这个项目还在持续进行中。不过这个演示真的引发了好长时间的欢呼……

Google Earth 一款使用wasm打造的支持各大浏览器的3D地图,运行非常流畅

Blazor 让 .NET 代码也能在浏览器运行

CityBound,这是一款模拟城市环境的城市建造游戏,里面挤满了人、汽车、道路和房屋。这是一个引人入胜的微缩景观,值得一试。更棒的是整个游戏都是开源的,并用 Rust 编写,编译为 WebAssembly。

Wasm诞生背景

谈起WebAssembly,我们可以把WebAssembly分开来看,Web和Assembly,谈起Web我们就不得不提到Web中唯一的脚本语言JS。

1995年 - JS诞生 JavaScript语言从诞生开始就是严肃程序员鄙视的对象:语言设计垃圾、运行比蜗牛还慢、它只是给不懂编程的人用的玩具等。当然出现这些观点也有一定的客观因素:JavaScript运行确实够慢,语言也没有经过严谨的设计,甚至没有很多高级语言标配的块作用域特性。

2005年 - Ajax Ajax(Asynchronous JavaScript and XML)方法横空出世,JavaScript终于开始火爆。据说是Jesse James Garrett发明了这个词汇。谷歌当时发布的Google Maps项目大量采用该方法标志着Ajax开始流行。Ajax几乎成了新一代网站的标准做法,并且促成了Web 2.0时代的来临。作为Ajax核心部分的JavaScript语言突然变得异常重要。

2008年 - V8 谷歌公司为Chrome浏览器而开发的V8即时编译器引擎的诞生彻底改变了JavaScript低能儿的形象。V8引擎下的JavaScript语言突然成了地球上最快的脚本语言!在很多场景下的性能已经和C/C++程序在一个数量级。JavaScript终于手握Ajax和V8两大神器,此后真的是飞速发展。

2009年 - Nodejs Ryan Dahl创建Node.js项目,JavaScript开始进军服务器领域。

2013年 - React Facebook公司发布React项目

2014年 - Vue 尤雨溪发布了Vue项目

2015年 - React Native & ES6 2015年发布React Native项目。目前JavaScript语言已经开始颠覆iOS和Android等手机应用的开发。ES6标准的建立

回顾整个互联网技术的发展历程,可以发现在Web发展历程中出现过各种各样的技术,例如,号称跨平台的Java Applet、仅支持IE浏览器的ActiveX控件、曾经差点称霸浏览器的Flash等。但是,在所有的脚本语言中只有JavaScript语言顽强地活了下来,而且有席卷整个软件领域的趋势。

JavaScript语言被历史选中并不完全是偶然的,偶然之中也有着必然的因素。它的优点同样不可替代。

  • 简单易用,不用专门学习就可以使用。
  • 运行系统极其稳定
  • 紧抱HTML标准,站在Ajax、WebSocket、WebGL、WebWorker、SIMD.js等前沿技术的肩膀之上。

谈完Web的JS,我们聊一下Assembly(汇编):

高级代码编译成低级代码有两种方式,解释器编译器

解释器 可以快速启动和运行。在开始运行代码之前,您不必完成整个编译步骤。您只需开始翻译第一行并运行它。

正因为如此,解释器似乎很适合 JavaScript 之类的东西。对于 Web 开发人员来说,能够快速开始并运行他们的代码非常重要。

这就是为什么浏览器一开始就使用 JavaScript 解释器的原因。

但是,当您多次运行相同的代码时,就会出现使用解释器的弊端。例如,如果您处于循环中。然后你必须一遍又一遍地做同样的翻译。

编译器

编译器有相反的权衡。

启动需要更多时间,因为它必须在一开始就经过那个编译步骤。但是循环中的代码运行得更快,因为它不需要为每次通过该循环重复翻译。

另一个区别是编译器有更多时间查看代码并对其进行编辑,以便它运行得更快。这些编辑称为优化。

解释器在运行时完成它的工作,因此在翻译阶段不会花费太多时间来找出这些优化。


有没有一种方式,即可以快速启动,又可以拥有编译器的速度呢?

Answer: JIT

我们看最优秀的JIT实现:V8 V8

不同的浏览器执行此操作的方式略有不同,但基本思想是相同的。他们为 JavaScript 引擎添加了一个新部分,称为监视器(也称为分析器)。该监视器在代码运行时监视它,并记下它运行了多少次以及使用了哪些类型。

起初,监视器只是通过解释器运行所有内容。

如果相同的代码行运行几次,则该代码段称为warm。如果它运行很多,那么它被称为hot!分别代表基线编译器优化编辑器

基线编译器

当一个函数开始变热时,JIT 会将其发送出去进行编译。然后它将存储该编译。

函数的每一行都被编译成一个“存根”。存根由行号和变量类型索引(稍后我将解释为什么这很重要)。如果监视器看到执行再次使用相同的变量类型执行相同的代码,它只会拉出其编译版本。

如果代码真的很热——如果它被运行了很多次——那么花额外的时间进行更多的优化是值得的。

优化编译器

当部分代码非常热时,监视器会将其发送到优化编译器。这将创建另一个更快的函数版本,该版本也将被存储。

优化示例:类型特化

优化编译器的最大胜利之一来自于称为类型专业化的东西。JavaScript 使用的动态类型系统在运行时需要一些额外的工作。例如,考虑以下代码:

function arraySum(arr) {
  var sum = 0;
  for(var i = 0; i < arr.length; i++) {
    sum += arr[i]
  }
}

+= 循环中的步骤可能看起来很简单。看起来您可以一步计算,但由于是动态类型,它需要的步骤比您预期的要多。

假设这arr是一个 100 个整数的数组。一旦代码预热,基线编译器将为函数中的每个操作创建一个存根。所以会有一个 stub sum += arr[i],它将 += 操作作为整数加法处理。

但是,sum并不arr[i]保证是整数。因为类型在 JavaScript 中是动态的,所以在循环的后续迭代中,有arr[i]可能是字符串。整数加法和字符串连接是两个非常不同的操作,因此它们会编译成非常不同的机器代码。

JIT 处理这个问题的方式是编译多个基线存根。如果一段代码是单态的(也就是说,总是用相同的类型调用),它将得到一个存根。如果它是多态的(使用不同的类型从一个代码传递到另一个代码),那么它将为通过该操作的每个类型组合获得一个存根。

这意味着 JIT 在选择存根之前必须提出很多问题。

因为每行代码在基线编译器中都有自己的存根集,所以 JIT 需要在每次执行代码行时不断检查类型。因此,对于循环中的每次迭代,它都必须提出相同的问题。

如果 JIT 不需要重复这些检查,代码的执行速度会快很多。这就是优化编译器所做的事情之一。

所谓的不需要重复这些检查,就是JIT会自己去猜测下一次执行此代码的时候,应该去使用哪个基线编译版本,假设前面99次都是整型,那么JIT会认为,第100次,也是整型,猜对了,起到了加速作用,没猜对,那可能需要回到基线编译器甚至是解释器,这个过程叫做去优化

Assembly

对于不同的机器,其底层架构也是有所不同,像x86ARM,所以我们编译的目标不止一个。

您希望能够将这些高级编程语言中的任何一种翻译成这些汇编语言中的任何一种(对应于不同的体系结构)。一种方法是创建一大堆不同的翻译器,它们可以从每种语言转到每个程序集。

Assembly

这将是非常低效的。为了解决这个问题,大多数编译器至少在两者之间放置了一层。编译器将采用这种高级编程语言并将其翻译成不那么高级的东西,但也不能在机器代码级别上工作。这就是所谓的中间表示(IR)

IR 是一种类似汇编的底层语言, 是一种强类型的精简指令集

Assembly

这意味着编译器可以采用这些高级语言中的任何一种并将其翻译成一种 IR 语言。从那里,编译器的另一部分可以获取该 IR 并将其编译为特定于目标体系结构的内容。

编译器的前端将高级编程语言转换为 IR。编译器的后端从 IR 到目标架构的汇编代码。这就是汇编!

Wasm简史

和很多其他项目(比如Go和Rust语言)一样, Wasm也起源于一个业余时间项目(Part-time Project)。2010年,Alon Zakai放弃了自己的创业公 司,加入Mozilla从事Android Firefox开发相关工 作。此时的Alon想把他以前开发的游戏引擎移植到浏 览器上运行,他认为JavaScript的执行速度已经足够 快了,所以开始在业余时间编写编译器,把C++代码 (通过LLVM IR)编译成JavaScript代码。这个业余时 间项目就是Emscripten。

到了2011年底,Emscripten项目已经取得了很大 进展,甚至能够成功编译Python和Doom等大型C++项 目。Mozilla觉得这个项目很有前途,于是成立研究团 队,邀请Alon加入并全职开发Emscripten。如前文所述,由于JavaScript语言太灵活了,JIT编译器很难再 做一些激进的优化(例如类型转化)。为了帮助JIT编 译器做这些优化,Alon和Luke Wagner、David Herman 等人一起,在2013年提出了asm.js[1]规范。asm.js是 JavaScript语言的一个严格子集,试图通过减少动态 特性和添加类型提示的方式帮助浏览器提升 JavaScript优化空间。相较于完整的JavaScript语 言,裁剪后的asm.js更靠近底层,更适合作为编译器 目标语言。下面是一个用asm.js编写的例子。

function MyAsmModule() { 
  "use asm"; // 告诉浏览器这是一个asm.js模块 
  function add(x, y) { 
    x = x | 0; // x是整数 
    y = y | 0; // y也是整数 
    return (x + y) | 0; // 返回值也是整数 
  }
  return { add: add }; 
}

从上面这个例子不难看出,asm.js有优点也有缺 点。优点非常明显:asm.js代码就是JavaScript代 码,因此完全可以跨浏览器运行。能识别特殊标记的 “聪明”浏览器可以根据提示进行激进的JIT优化,甚 至是AOT编译,大幅提升性能。不能识别特殊标记的 “笨”浏览器也可以忽略这些提示,直接按普通 JavaScript代码来执行。asm.js的缺点也很明显,那 就是“底层”得不够彻底,例如代码仍然是文本格 式;代码编写仍然受JavaScript语法限制;浏览器仍 然需要完成解析脚本、解释执行、收集性能指标、JIT 编译等一系列步骤。如果采用像Java类文件那样的二 进制格式,不仅能缩小文件体积,减少网络传输时间 和解析时间,还能选用更接近机器的字节码,这样 AOT/JIT编译器实现起来会更轻松,效果也更好。

差不多在Alon和Mozilla开发Emscripten/asm.js 的同时,Google的Chrome团队也在试图解决 JavaScript性能问题,但方向有所不同。Chrome给出 的解决方案是NaCl(Google Native Client)和 PNaCl(Portable NaCl)。通过NaCl/PNaC1,Chrome 浏览器可以在沙箱环境中直接执行本地代码。asm.js 和NaCl/PNaC1技术各有优缺点,二者可以取长补短。 Mozilla和Google也看到了这一点,所以从2013年开 始,两个团队就经常交流和合作。

在交流过程中,Mozilla和Google决定结合两个项 目的长处,合作开发一种基于字节码的技术。到了 2015年4月,这一想法已经很成熟了, “WebAssembly”也取代其他临时名称,逐渐出现在两 个团队的沟通邮件中。2015年7月,Wasm正式开始设计 并对外公开开发速度。同年,W3C成立了Wasm社区小组 (成员包括Chrome、Edge、Firefox和WebKit),致力 于推动Wasm技术的发展。

2017年2月底,Wasm社区小组达成共识,Wasm的 MVP(Minimum Viable Product)设计基本定稿。一个 月后,Google决定放弃PNaCl技术,推荐使用Wasm。 Mozilla也基本放弃了asm.js技术,甚至可能会在未来 停止Emscripten对asm.js的支持,仅支持Wasm。截至 本书完稿,Wasm规范已经发布了1.1版,虽然还在修改,但已经足够稳定。

除了四大浏览器的一致支持,Wasm也获得了主流 编程语言的强力支持。C/C++是最先可以编译为Wasm的 语言,因为Emscripten已经支持asm.js,只要把 asm.js编译成Wasm即可。由于两项技术比较相似,这 个编译工作并不难。2016年12月,Rust 1.14发布,开 始实验性支持Wasm。2018年8月,Go 1.11发布,开始 实验性支持Wasm。2019年3月,LLVM 8.0.0发布,正式 支持Wasm。同年10月,Emscripten改为默认使用LLVM 提供的Wasm编译后端直接生成Wasm。

虽然诞生于Web和浏览器,但是Wasm并没有和Web 或者浏览器绑定。相反,Wasm核心规范很少提及Web和 浏览器,这就给Wasm未来的应用前景提供了更加广阔 的想象空间。在浏览器之外,可能首先被Wasm吸引的 就是区块链项目。目前已经有一些区块链基于Wasm技 术实现智能合约平台,比如EOS。著名的以太坊项目也 正在将其虚拟机(Ethereum Virtual Machine,EVM) 切换到Wasm,并计划在以太坊2.0正式启用。开源世界 对于Wasm技术的热情非常高,目前已经有许多高质量 的开源Wasm实现,包括用C/C++、Rust、Go语言实现的 Wasm解释器、AOT/JIT编译器。Wasm技术可谓前途一片光明。

Wasm创建和使用

直接去写 wasm 对我们来说,有点不太好理解,我们先看一个 compile C => wasm

// 这是一个C函数 
int add42(int num) {
  return num + 42;
}
// 编译之后的 wasm 文件
00 61 73 6D 0D 00 00 00 01 86 80 80 80 00 01 60
01 7F 01 7F 03 82 80 80 80 00 01 00 04 84 80 80
80 00 01 70 00 00 05 83 80 80 80 00 01 00 01 06
81 80 80 80 00 00 07 96 80 80 80 00 02 06 6D 65
6D 6F 72 79 02 00 09 5F 5A 35 61 64 64 34 32 69
00 00 0A 8D 80 80 80 00 01 87 80 80 80 00 00 20
00 41 2A 6A 0B

我们可以看出 wasm 文件是一个二进制模块,这让我们几乎不可能去完成编写,虽然我们可以把代码转化成我们可以可读的方式,差不多类似于下面示例

0000000: 0061 736d                                 ; WASM_BINARY_MAGIC
0000004: 0100 0000                                 ; WASM_BINARY_VERSION
; section "Type" (1)
0000008: 01                                        ; section code
0000009: 00                                        ; section size (guess)
000000a: 01                                        ; num types
; func type 0
000000b: 60                                        ; func
000000c: 01                                        ; num params
000000d: 7f                                        ; i32
000000e: 01                                        ; num results
000000f: 7f                                        ; i32
0000009: 06                                        ; FIXUP section size
; section "Function" (3)
0000010: 03                                        ; section code
0000011: 00                                        ; section size (guess)
0000012: 01                                        ; num functions
0000013: 00                                        ; function 0 signature index
0000011: 02                                        ; FIXUP section size
; section "Export" (7)
0000014: 07                                        ; section code
0000015: 00                                        ; section size (guess)
0000016: 01                                        ; num exports
0000017: 06                                        ; string length
0000018: 6164 6431 3030                           add100  ; export name
000001e: 00                                        ; export kind
000001f: 00                                        ; export func index
0000015: 0a                                        ; FIXUP section size
; section "Code" (10)
0000020: 0a                                        ; section code
0000021: 00                                        ; section size (guess)
0000022: 01                                        ; num functions
; function body 0
0000023: 00                                        ; func body size (guess)
0000024: 00                                        ; local decl count
0000025: 20                                        ; local.get
0000026: 00                                        ; local index
0000027: 41                                        ; i32.const
0000028: 2a                                        ; i32 literal
0000029: 6a                                        ; i32.add
000002a: 0b                                        ; end
0000023: 07                                        ; FIXUP func body size
0000021: 09                                        ; FIXUP section size

虽然能大概看懂一些代码表达的意思,但是若让我们直接去编写,难度太高.

为了更好的阅读和编写,Wasm 将定义一种标准化的文本格式,该格式以与二进制格式等效的方式对 Wasm 模块及其所有包含的定义进行编码。这种格式将使用S 表达式(避免语法循环讨论)来表达模块和定义,同时允许函数体中的代码线性表示。这种格式被工具理解并在调试 模块时在浏览器中使用。

S-表达式写法如下:

(module
  (func $add42 (param $l i32) (result i32)
    local.get $l
    i32.const 42
    i32.add
  )
  (export "add42" (func $add42))
)

在这个例子中,你可能已经注意到,该add操作没有说明它的值应该来自哪里。这是因为 Wasm 是一种称为堆栈机器的示例。这意味着在执行操作之前,操作所需的所有值都在堆栈中排队。

像这样的操作add知道他们需要多少值。由于add需要两个,它将从堆栈顶部获取两个值。这意味着add指令可以很短(单个字节),因为指令不需要指定源寄存器或目标寄存器。这减小了 .wasm 文件的大小,这意味着下载所需的时间更少。

尽管 Wasm 是根据堆栈机器指定的,但这并不是它在物理机器上的工作方式。当浏览器将 转换为运行浏览器的机器的机器代码时,它将使用寄存器。由于 Wasm 代码没有指定寄存器,它为浏览器提供了更大的灵活性,可以为该机器使用最佳寄存器分配

用于Wasm的其他语言

学习并使用S表达式对于我们来说,成本太高,其语言更是接近底层语言,有没有可以使用我们高级语言就可以直接编写Wasm呢?有的

C/C++ 通过 Emscripten: An LLVM-to-WebAssembly Compiler 工具,可以将 C/C++直接编译成 Wasm。

Rust 通过wasm-pack: your favorite rust -> wasm workflow tool! 工具,可以将 Rust 编译成 Wasm。

AssemblyScript

你可以编写类似 TS 代码,通过AssemblyScript: A TypeScript-like language for WebAssembly, 也可以实现,这对于我们前端同学来说,几乎可以直接上手。我们看一个 AssemblyScript 的示例:

export function fibonacci(n: i32): i32 {
  if(n <= 2) return 1
  return fibonacci(n -1) + fibonacci(n - 2)
}

声明一个函数 fibonacci, 接受一个类型为i32的参数,函数返回一个类型为i32的值。


学会了怎么写 wasm, 我们看看 Javascript调用wasm的

Javascript调用wasm

<script>
  fetch('output.wasm').then(response=>
   response.arrayBuffer()
  ).then(bytes => 
    WebAssembly.instantiate(bytes)
  ).then(result => 
    console.log(result.instance.exports.add42(18))
  )
</script>

简单理解下这段代码,JS 调用 wasm,需要先fetch, 之后需要 arrayBuffer, 再进行 instantiate,最后才能拿到我们需要的 add42,每次 JS 与 wasm 交互,都需要进行以上操作,当然在 wasm 后续计划中,采用了 ES module的方式,例子:

import { add42 } from 'output.wasm'

add42(18)

JS和Wasm性能比较

了解了Wasm和创建和使用,我们再聊聊Wasm的速度吧,我们都知道 Wasm 很快,比JS快很多,那到底是哪里快呢?

在了解 JavaScript 和 Wasm 的性能差异之前,我们需要了解 JS 引擎所做的工作。

今天的 JavaScript 性能如何?

JS

每个条形显示执行特定任务所花费的时间。

  • 解析——将源代码处理成解释器可以运行的东西所花费的时间。
  • 编译 + 优化——在基线编译器和优化编译器中花费的时间。一些优化编译器的工作不在主线程上,所以这里不包括。
  • 重新优化——当假设失败时,JIT 花费的时间重新调整,包括重新优化代码和将优化的代码恢复到基线代码。
  • 执行——运行代码所花费的时间。
  • 垃圾收集——清理内存所花费的时间。

需要注意的一件重要事情是:这些任务不会以离散的块或特定的顺序发生。相反,它们将被交错。会发生一点解析,然后是一些执行,然后是一些编译,然后是更多的解析,然后是更多的执行,等等。

Wasm 如何比较?

Wasm

  • 解码——只需要被解码和验证以确保其中没有任何错误。
  • 编译 + 优化——不同的浏览器处理编译 Wasm 的方式不同。一些浏览器在开始执行之前会对 Wasm 进行基线编译,而其他浏览器则使用 JIT。

结论

在许多情况下,Wasm 比 JavaScript 更快,因为:

  • 获取 Wasm 花费的时间更少,因为它比 JavaScript 更紧凑,即使在压缩时也是如此。
  • 解码 Wasm 比解析 JavaScript 花费的时间更少。
  • 编译和优化花费的时间更少,因为 Wasm 比 JavaScript 更接近机器代码,并且已经在服务器端进行了优化。
  • 不需要重新优化,因为 Wasm 内置了类型和其他信息,因此 JS 引擎不需要推测它何时优化它使用 JavaScript 的方式。
  • 执行通常需要更少的时间,因为开发人员需要知道的编译器技巧和陷阱更少,才能编写出一致的高性能代码,而且 Wasm 的指令集更适合机器。
  • 由于内存是手动管理的,因此不需要垃圾收集。

这就是为什么在许多情况下,Wasm 在执行相同任务时会胜过 JavaScript。

应用场景

  • 游戏
  • 区块链
  • CAD应用
  • 图像/视频剪辑
  • ...

举几个典型的例子:

Ebay

WebAssembly at eBay: A Real-World Use Case

ebay

用于网络的条形码扫描仪,通过Wasm成功率由20%提高到了接近100%。

未来规划

proposals

  • Tail call
  • Memory64
  • Exception handling
  • Relaxed SIMD
  • ECMAScript module integration
  • Typed Function References
  • Garbage collection
  • JS Promise Integration
  • WebAssembly C and C++ API
  • ...

提案进度

proposalnum
Finished7
Phase50
Phase40
Phase39
Phase29
Phase112
Phase01
Inactive4

举几个例子:

Reference Types

(module
  (import "imports" "append"
  (func $append (param externref i32 i32)))
  (memory (export "memory") 1)
  (data (i32 const 0x42) "Hello, Reference Types!")
  (func (export "hello") (param externrer)
    (call $append
      (local.get 0)
      (i32 const 0x42)
      (i32 const 24)
    )
  )
)
async function main() {
  const imports = {
    "imports" : {
      append(domNode, address, length) {
        // ...
      }
    }
  }

  const response = await fetch("./example.wasm")
  const wasmBytes = await response.arrayBuffer()
  const { instance } = await WebAssembly.instantiate(wasmBytes, imports)
  const output = document.getElementById("output")
  instance.exports.hello(output)
}

这个例子是想把一个dom节点和一个imports对象传到wasm中去,在wasm中执行imports下的append方法, 可是我们知道 wasm 是一个强类型语言,仅支持几个很少的基本类型,不支持dom节点这个类型,在之前Reference Types出现之前,我们若想实现这个操作,需要很复杂的操作才行,但是现在,我们可以给 param 一个externrer类型,代表这个类型我们不需要关心,给什么就用什么,有点像 TS泛型

SIMD

  • SIMD 是 Single Instruction Multiple Data 的缩写,中文术语为「单指令多数据流」,可以使用单条指令并行处理多个数据。
  • WebAssembly SIMD 定义了一个可移植的、高性能的 SIMD 操作子集,极大的提升了 WebAssembly 数据处理的性能。
  • 此前 Chrome 91、Node.js 16.4.0 和 Rust 1.54.0 已经正式开启该特性,其他相关工具链正在紧锣密鼓的支持中。
  • TC39发布提案中,针对 SIMD 的提案已经废弃了,改由Wasm实现

SIMD

说两个关于Wasm SIMD的应用

  • 基于TensorFlow所实现的人脸识别应用BlazeFace,使用WebAssembly SIMD可以将其性能提升1.7~ 2.1倍:
  • 对于更加复杂的应用,比如AI领域大名鼎鼎的ImageNet,其基于TensorFlow.js所实现的MobileNet V2模型,使用WebAssembly SIMD可以将其性能提升2.2 ~ 4.5倍:

关于使用 SIMD 和多线程提升 WebAssembly 性能,请点击此处

更多提案,请关注 gihub WebAssembly


时至今日,Wasm更新很快,社区也非常活跃,随着新功能和对浏览器实现的改进,它应该会变得更快。相信 Wasm 未来能成为一种可能!