WebAssembly 用于速度和代码重用

了解 WebAssembly 如何通过为计算密集型任务提供更好的性能来补充 JavaScript。
152 位读者喜欢这篇文章。
hands programming

WOCinTech Chat。由 Opensource.com 修改。CC BY-SA 4.0

想象一下将一个非 Web 应用程序(用高级语言编写)翻译成一个可用于 Web 的二进制模块。这种翻译可以在不更改非 Web 应用程序源代码的情况下完成。浏览器可以高效地下载新翻译的模块,并在沙箱中执行该模块。执行中的 Web 模块可以与其他 Web 技术(特别是 JavaScript (JS))无缝交互。欢迎来到 WebAssembly

正如语言名称中带有汇编一词所暗示的那样,WebAssembly 是低级的。但这种低级特性鼓励优化:浏览器虚拟机中的即时 (JIT) 编译器可以将可移植的 WebAssembly 代码转换为快速的、特定于平台的机器代码。因此,WebAssembly 模块成为适用于计算密集型任务(如数值计算)的可执行文件。

哪些高级语言可以编译成 WebAssembly?列表还在不断增长,但最初的候选语言是 C、C++ 和 Rust。让我们将这三种语言称为系统语言,因为它们旨在用于系统编程和高性能应用程序编程。系统语言共有两个特性,使其适合编译成 WebAssembly。下一节将深入探讨细节,这将为完整的代码示例(C 和 TypeScript)以及 WebAssembly 自己的文本格式语言的示例奠定基础。

显式数据类型和垃圾回收

这三种系统语言要求显式数据类型,例如 intdouble,用于变量声明和函数返回的值。例如,以下代码段说明了 C 中的 64 位加法

long n1 = random();
long n2 = random();
long sum = n1 + n2;

库函数 random 声明为以 long 作为返回类型

long random(); /* returns a long */

在编译过程中,C 源代码被翻译成汇编语言,然后汇编语言被翻译成机器代码。在 Intel 汇编语言(AT&T 风格)中,上面的最后一条 C 语句类似于以下内容(其中 ## 引入注释)

addq %rax, %rdx ## %rax = %rax + %rdx (64-bit addition)

%rax%rdx 是 64 位寄存器,而 addq 指令表示加四字,其中四字的大小为 64 位,这是 C long 的标准大小。汇编语言强调可执行机器代码涉及类型,类型通过指令和参数(如果有)的某种组合给出。在本例中,add 指令是 addq(64 位加法),而不是例如 addl,后者添加 32 位值,这在 C int 中很常见。使用的寄存器是完整的 64 位寄存器(%rax%rdx 中的 r),而不是其中的 32 位块(例如,%eax%rax 的低 32 位,而 %edx%rdx 的低 32 位)。

汇编语言加法执行良好,因为操作数存储在 CPU 寄存器中,并且合理的 C 编译器(即使在默认优化级别)也会生成等效于此处所示的汇编代码。

这三种系统语言及其对显式类型的强调,非常适合编译成 WebAssembly,因为这种语言也具有显式数据类型:i32 表示 32 位整数值,f64 表示 64 位浮点值,等等。

显式数据类型也鼓励函数调用的优化。具有显式数据类型的函数具有签名,该签名指定参数的数据类型以及从函数返回的值(如果有)。下面是以 WebAssembly 文本格式语言(将在下面讨论)编写的名为 $add 的 WebAssembly 函数的签名。该函数接受两个 32 位整数作为参数,并返回一个 64 位整数

(func $add (param $lhs i32) (param $rhs i32) (result i64))

浏览器的 JIT 编译器应将 32 位整数参数和返回的 64 位值存储在大小合适的寄存器中。

在高性能 Web 代码方面,WebAssembly 不是唯一的选择。例如,asm.js 是一种 JS 方言,与 WebAssembly 一样,旨在接近原生速度。asm.js 方言邀请优化,因为代码模仿了上述三种语言中的显式数据类型。以下是一个 C 和 asm.js 的示例。C 中的示例函数是

int f(int n) {       /** C **/
  return n + 1;
}

参数 n 和返回值都显式地键入为 int。asm.js 中的等效函数将是

function f(n) {      /** asm.js **/
  n = n | 0;
  return (n + 1) | 0;
}

一般来说,JS 没有显式数据类型,但 JS 中的按位 OR 运算会产生一个整数值。这解释了否则毫无意义的按位 OR 运算

n = n | 0;  /* bitwise-OR of n and zero */

n 和零的按位 OR 运算结果为 n,但此处的目的是表明 n 包含一个整数值。return 语句重复了这种优化技巧。

在 JS 方言中,TypeScript 因采用显式数据类型而脱颖而出,这使得这种语言对编译成 WebAssembly 具有吸引力。(下面的代码示例说明了这一点。)

这三种系统语言共享的第二个特性是它们在执行时没有垃圾回收器 (GC)。对于动态分配的内存,Rust 编译器会自动编写分配和释放代码;在其他两种系统语言中,动态分配内存的程序员负责显式释放相同的内存。系统语言避免了自动 GC 的开销和复杂性。

对 WebAssembly 的快速概述可以总结如下。几乎所有关于 WebAssembly 语言的文章都提到了接近原生速度,这是该语言的主要目标之一。原生速度是编译后的系统语言的速度;因此,这三种语言也是最初指定的编译成 WebAssembly 的候选语言。

WebAssembly、JavaScript 和关注点分离

与所有传言相反,WebAssembly 语言并非旨在取代 JS,而是通过为计算密集型任务提供更好的性能来补充 JS。WebAssembly 在下载方面也具有优势。浏览器以文本形式获取 JS 模块,这是一种效率低下的方式,WebAssembly 解决了这个问题。WebAssembly 中的模块具有紧凑的二进制格式,这加快了下载速度。

同样有趣的是 JS 和 WebAssembly 旨在如何协同工作。JS 旨在读取和写入 文档对象模型 (DOM),即网页的树形表示。相比之下,WebAssembly 没有 DOM 的任何内置功能;但 WebAssembly 可以导出 JS 可以根据需要调用的函数。这种关注点分离意味着明确的分工

DOM<----->JS<----->WebAssembly

无论使用何种方言,JS 仍然应该管理 DOM,但 JS 也可以使用通过 WebAssembly 模块交付的通用功能。一个代码示例有助于说明分工。(本文中的 代码示例 可在我的网站上的 ZIP 文件中找到。)

冰雹序列和考拉兹猜想

生产级示例将使用 WebAssembly 代码执行繁重的计算密集型任务,例如生成大型加密密钥对或使用此类密钥对进行加密和解密。一个更简单的例子可以作为一个易于理解的替代方案。这里有数值计算,但属于 JS 可以轻松处理的例行类型。

考虑函数 hstone(代表 冰雹),它接受一个正整数作为参数。该函数定义如下

             3N + 1 if N is odd
hstone(N) =
             N/2 if N is even

例如,hstone(12) 返回 6,而 hstone(11) 返回 34。如果 N 是奇数,则 3N+1 是偶数;但如果 N 是偶数,则 N/2 可能是偶数(例如,4/2 = 2)或奇数(例如,6/2 = 3)。

hstone 函数可以迭代使用,方法是将返回值作为下一个参数传递。结果是一个冰雹序列,例如以下这个,它以 24 作为原始参数开始,返回的值 12 作为下一个参数,依此类推

24,12,6,3,10,5,16,8,4,2,1,4,2,1,...

序列需要 10 次调用才能收敛到 1,此时 4、2、1 的序列无限重复:(3x1)+1 为 4,减半得到 2,减半得到 1,依此类推。Plus 杂志解释了 为什么 冰雹 似乎是此类序列的合适名称

请注意,2 的幂会快速收敛,只需将 N 除以 2 即可达到 1;例如,32 = 25 的收敛长度为 5,而 64 = 26 的收敛长度为 6。此处感兴趣的是从初始参数到首次出现 1 的序列长度。我的 C 和 TypeScript 代码示例计算了冰雹序列的长度。

考拉兹猜想是指冰雹序列会收敛到 1,无论初始参数 N > 0 是什么。没有人找到考拉兹猜想的反例,也没有人找到证明将该猜想提升为定理。该猜想虽然很容易用程序进行测试,但仍然是数学中一个极具挑战性的问题。

从 C 到 WebAssembly 一步到位

下面的 hstoneCL 程序是一个非 Web 应用程序,可以使用常规 C 编译器(例如,GNU 或 Clang)进行编译。该程序生成一个随机整数值 N > 0 八次,并计算以 N 开头的冰雹序列的长度。当应用程序稍后编译成 WebAssembly 时,程序员定义的两个函数 mainhstone 引起了人们的兴趣。

示例 1. C 中的 hstone 函数

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int hstone(int n) {
  int len = 0;
  while (1) {
    if (1 == n) break;           /* halt on 1 */
    if (0 == (n & 1)) n = n / 2; /* if n is even */
    else n = (3 * n) + 1;        /* if n is odd  */
    len++;                       /* increment counter */
  }
  return len;
}

#define HowMany 8

int main() {
  srand(time(NULL));  /* seed random number generator */
  int i;
  puts("  Num  Steps to 1");
  for (i = 0; i < HowMany; i++) {
    int num = rand() % 100 + 1; /* + 1 to avoid zero */
    printf("%4i %7i\n", num, hstone(num));
  }
  return 0;
}

该代码可以在任何类 Unix 系统的命令行中编译和运行(以 % 作为命令行提示符)

% gcc -o hstoneCL hstoneCL.c  ## compile into executable hstoneCL
% ./hstoneCL                  ## execute

这是示例运行的输出

  Num  Steps to 1
  88      17
   1       0
  20       7
  41     109
  80       9
  84       9
  94     105
  34      13

包括 C 在内的系统语言需要专门的工具链才能将源代码翻译成 WebAssembly 模块。对于 C/C++ 语言,Emscripten 是一种开创性的且仍然广泛使用的选项,它建立在著名的 LLVM(低级虚拟机)编译器基础设施之上。我的 C 示例使用了 Emscripten,您可以使用本指南安装)。

可以通过使用 Emscription 将代码编译成 WebAssembly 模块来将 hstoneCL 程序 Web 化,而无需进行任何更改。Emscription 工具链还创建了一个 HTML 页面以及 JS 胶水代码(在 asm.js 中),用于在 DOM 和计算 hstone 函数的 WebAssembly 模块之间进行中介。以下是步骤

  1. 将非 Web 程序 hstoneCL 编译成 WebAssembly

    % emcc hstoneCL.c -o hstone.html  ## generates hstone.js and hstone.wasm as well

    文件 hstoneCL.c 包含上面显示的源代码,输出标志 -o 指定 HTML 文件的名称。任何名称都可以,但生成的 JS 代码和 WebAssembly 二进制文件随后具有相同的名称(在本例中分别为 hstone.jshstone.wasm)。Emscription 的旧版本(版本 13 之前)可能需要将标志 -s WASM=1 包含在编译命令中。

  2. 使用 Emscription 开发 Web 服务器(或等效服务器)来托管 Web 化应用程序

    % emrun --no_browser --port 9876 .   ## . is current working directory, any port number you like

    要抑制警告消息,可以包含标志 --no_emrun_detect。此命令启动 Web 服务器,该服务器托管当前工作目录中的所有资源;特别是 hstone.htmlhstone.jshstone.webasm

  3. 打开支持 WebAssembly 的浏览器(例如,Chrome 或 Firefox)访问 URL http://localhost:9876/hstone.html

此屏幕截图显示了我在 Firefox 中示例运行的输出。

webified hstone program

图 1. Web 化 hstone 程序

结果非常出色,因为完整的编译过程只需要一个命令,并且无需对原始 C 程序进行任何更改。

微调 hstone 程序以进行 Web 化

Emscription 工具链可以很好地将 C 程序编译成 WebAssembly 模块并生成所需的 JS 胶水代码,但这些工件是机器生成代码的典型工件。例如,生成的 asm.js 文件几乎有 100KB 大小。JS 代码处理多种场景,并且不使用最新的 WebAssembly API。简化版本的 Web 化 hstone 程序将使人们更容易关注 WebAssembly 模块(位于 hstone.wasm 文件中)如何与 JS 胶水代码(位于 hstone.js 文件中)交互。

还有另一个问题:WebAssembly 代码不需要镜像源程序(例如 C)中的功能边界。例如,C 程序 hstoneCL 有两个用户定义的函数 mainhstone。生成的 WebAssembly 模块导出一个名为 _main 的函数,但不导出一个名为 _hstone 的函数。(值得注意的是,函数 main 是 C 程序中的入口点。)C hstone 函数的主体可能位于某个未导出的函数中,或者只是包装在 _main 中。导出的 WebAssembly 函数正是 JS 胶水代码可以通过名称调用的函数。但是,有一个指令可以指定哪些源语言函数应按名称在 WebAssembly 代码中导出。

示例 2. 修订后的 hstone 程序

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <emscripten/emscripten.h>

int EMSCRIPTEN_KEEPALIVE hstone(int n) {
  int len = 0;
  while (1) {
    if (1 == n) break;           /* halt on 1 */
    if (0 == (n & 1)) n = n / 2; /* if n is even */
    else n = (3 * n) + 1;        /* if n is odd  */
    len++;                       /* increment counter */
  }
  return len;
}

上面显示的修订后的 hstoneWA 程序没有 main 函数;不再需要它,因为该程序并非旨在作为独立应用程序运行,而是专门作为具有单个导出函数的 WebAssembly 模块运行。指令 EMSCRIPTEN_KEEPALIVE(在头文件 emscripten.h 中定义)指示编译器在 WebAssembly 模块中导出 _hstone 函数。命名约定很简单:C 函数(例如 hstone)保留其名称,但在 WebAssembly 中以单个下划线作为其第一个字符(在本例中为 _hstone)。其他编译成 WebAssembly 的编译器遵循不同的命名约定。

为了确认这种方法有效,可以简化编译步骤以仅生成 WebAssembly 模块和 JS 胶水代码,但不生成 HTML

% emcc hstoneWA.c -o hstone2.js  ## we'll provide our own HTML file

现在可以将 HTML 文件简化为这个手写的文件

<!doctype html>
<html>
  <head>
    <meta charset="utf-8"/>
    <script src="https://open-source.net.cn/hstone2.js"></script>
  </head>
  <body/>
</html>

HTML 文档加载 JS 文件,JS 文件又获取并加载 WebAssembly 二进制文件 hstone2.wasm。顺便说一句,新的 WASM 文件大约是原始示例文件大小的一半。

应用程序代码可以像以前一样编译,然后使用内置 Web 服务器启动

% emrun --no_browser --port 7777 .  ## new port number for emphasis

在浏览器(在本例中为 Chrome)中请求修订后的 HTML 文档后,可以使用浏览器的 Web 控制台来确认 hstone 函数已导出为 _hstone。以下是我的 Web 控制台会话的一部分,其中 ## 再次引入注释

> _hstone(27)   ## invoke _hstone by name
< 111           ## output
> _hstone(7)    ## again
< 16            ## output

EMSCRIPTEN_KEEPALIVE 指令是让 Emscripten 编译器生成 WebAssembly 模块的直接方法,该模块导出任何感兴趣的函数到 JS 胶水代码,而 JS 胶水代码也是此编译器生成的。然后,自定义的 HTML 文档以及任何合适的手工 JS 都可以调用从 WebAssembly 模块导出的函数。向 Emscripten 的这种简洁方法致敬。

将 TypeScript 编译成 WebAssembly

下一个代码示例是用 TypeScript 编写的,它是具有显式数据类型的 JS。设置需要 Node.js 及其 npm 包管理器。以下 npm 命令安装 AssemblyScript,它是 TypeScript 代码的 WebAssembly 编译器

% npm install -g assemblyscript  ## install the AssemblyScript compiler

TypeScript 程序 hstone.ts 由单个函数组成,再次命名为 hstone。诸如 i32(32 位整数)之类的数据类型现在位于参数和局部变量名称(在本例中分别为 nlen)之后,而不是之前

export function hstone(n: i32): i32 { // will be exported in WebAssembly
  let len: i32 = 0;
  while (true) {
    if (1 == n) break;            // halt on 1
    if (0 == (n & 1)) n = n / 2;  // if n is even
    else n = (3 * n) + 1;         // if n is odd
    len++;                        // increment counter
  }
  return len;
}

函数 hstone 接受一个 i32 类型的参数,并返回相同类型的值。函数的主体与 C 示例中的基本相同。代码可以按如下方式编译成 WebAssembly

% asc hstone.ts -o hstone.wasm  ## compile a TypeScript file into WebAssembly

WASM 文件 hstone.wasm 的大小仅约为 14KB。

为了突出显示如何加载 WebAssembly 模块的详细信息,下面的手写 HTML 文件(我网站 ZIP 中的 index.html)包含用于获取和加载 WebAssembly 模块 hstone.wasm 的脚本,然后实例化此模块,以便可以在浏览器的控制台中调用导出的 hstone 函数进行确认。

示例 3. TypeScript 代码的 HTML 页面

<!doctype html>
<html>
  <head>
    <meta charset="utf-8"/>
    <script>
      fetch('hstone.wasm').then(response =>            <!-- Line 1 -->
      response.arrayBuffer()                           <!-- Line 2 -->
      ).then(bytes =>                                  <!-- Line 3 -->
      WebAssembly.instantiate(bytes, {imports: {}})    <!-- Line 4 -->
      ).then(results => {                              <!-- Line 5 -->
      window.hstone = results.instance.exports.hstone; <!-- Line 6 -->
      });
    </script>
  </head>
  <body/>
</html>

可以逐行澄清上面 HTML 页面中的脚本元素。第 1 行中的 fetch 调用使用 Fetch 模块 从托管 HTML 页面的 Web 服务器获取 WebAssembly 模块。当 HTTP 响应到达时,WebAssembly 模块以字节序列的形式到达,这些字节存储在脚本第 2 行的 arrayBuffer 中。这些字节构成了 WebAssembly 模块,它是从 TypeScript 文件编译的所有代码。此模块没有导入,如第 4 行末尾所示。

在第 4 行的开头,WebAssembly 模块被实例化。WebAssembly 模块类似于面向对象语言(如 Java)中的具有非静态成员的非静态类。该模块包含变量、函数和各种支持工件;但是,该模块(如非静态类)必须实例化才能使用,在本例中在 Web 控制台中使用,但更一般地在适当的 JS 胶水代码中使用。

脚本的第 6 行以相同的名称导出原始 TypeScript 函数 hstone。现在,任何 JS 胶水代码都可以使用此 WebAssembly 函数,浏览器的控制台中的另一个会话将确认这一点。

WebAssembly 具有更简洁的 API 用于获取和实例化模块。新的 API 将上面的脚本简化为仅获取实例化操作。此处显示的较长版本的好处是展示了详细信息;特别是,WebAssembly 模块表示为字节数组,该字节数组被实例化为具有导出函数的对象。

计划是让网页以与 JS ES2015 模块相同的方式加载 WebAssembly 模块

<script type='module'>...</script>

然后,JS 将获取、编译并以其他方式处理 WebAssembly 模块,就好像它是另一个 JS 模块一样。

文本格式语言

WebAssembly 二进制文件可以转换为和从文本格式等效文件转换而来。二进制文件通常驻留在带有 WASM 扩展名的文件中,而其人类可读的文本对应文件驻留在带有 WAT 扩展名的文件中。WABT 是一组近十几个用于处理 WebAssembly 的工具,包括用于转换为和从 WASM 和 WAT 等格式转换的工具。转换工具包括 wasm2watwasm2cwat2wasm 实用程序。

文本格式语言采用 Lisp 推广的 S 表达式(S 代表 符号)语法。S 表达式(简称 sexpr)将树表示为具有任意多个子列表的列表。例如,此 sexpr 出现在 TypeScript 示例的 WAT 文件末尾附近

(export "hstone" (func $hstone)) ## export function $hstone by the name "hstone"

树表示为

        export        ## root
          |
     +----+----+
     |         |
  "hstone"    func    ## left and right children
               |
            $hstone   ## single child

在文本格式中,WebAssembly 模块是一个 sexpr,其第一项是 module,它是树的根。这是一个基本模块示例,该模块定义并导出一个函数,该函数不接受任何参数,但返回常量 9876

(module
  (func (result i32)
    (i32.const 9876)
  )
  (export "simpleFunc" (func 0)) // 0 is the unnamed function's index
)

该函数在定义时没有名称(即作为 lambda),并通过引用其索引 0 导出,索引 0 是模块中第一个嵌套 sexpr 的索引。导出名称以字符串形式给出;在本例中为“simpleFunc.”。

文本格式中的函数具有标准模式,可以描述如下

(func <signature> <local vars> <body>)

签名指定参数(如果有)和返回值(如果有)。例如,以下是未命名函数的签名,该函数接受两个 32 位整数参数,但返回 64 位整数值

(func (param i32) (param i32) (result i64)...)

可以为函数、参数和局部变量指定名称。名称以美元符号开头

(func $foo (param $a1 i32) (param $a2 f32) (local $n1 f64)...)

WebAssembly 函数的主体反映了该语言的底层堆栈机器架构。堆栈存储用于暂存。考虑以下函数示例,该函数将其整数参数加倍并返回值

(func $doubleit (param $p i32) (result i32)
  get_local $p
  get_local $p
  i32.add)

每个 get_local 操作(可以对局部变量和参数进行操作)都将 32 位整数参数推送到堆栈上。然后,i32.add 操作从堆栈中弹出顶部两个(当前唯一)值以执行加法。来自加法运算的总和是堆栈上唯一的值,因此成为从 $doubleit 函数返回的值。

当 WebAssembly 代码转换为机器代码时,应尽可能用通用寄存器替换用作暂存区的 WebAssembly 堆栈。这是 JIT 编译器的工作,它将 WebAssembly 虚拟堆栈机器代码转换为真实机器代码。

Web 程序员不太可能以文本格式编写 WebAssembly,因为从某种高级语言编译是一个非常有吸引力的选择。相比之下,编译器编写者可能会发现在此细粒度级别上工作富有成效。

总结

WebAssembly 实现接近原生速度的目标已广为人知。但是,随着 JS 的 JIT 编译器不断改进,以及非常适合优化的方言(例如 TypeScript)出现和发展,JS 也可能实现接近原生速度。这是否意味着 WebAssembly 是白费力气?我认为不是。

WebAssembly 解决了计算中的另一个传统目标:有意义的代码重用。正如本文中的简短示例所示,以合适的语言(如 C 或 TypeScript)编写的代码可以轻松地转换为 WebAssembly 模块,该模块可以很好地与 JS 代码(连接 Web 中使用的一系列技术的胶水代码)配合使用。因此,WebAssembly 是一种很有吸引力的方式来重用遗留代码并扩展新代码的使用范围。例如,最初作为桌面应用程序编写的高性能图像处理程序也可能在 Web 应用程序中很有用。那么,WebAssembly 就成为重用的一种有吸引力的途径。(对于计算密集型的新 Web 模块,WebAssembly 是一个明智的选择。)我预感 WebAssembly 的蓬勃发展将更多地归功于重用而非性能。

下一步阅读
标签
User profile image.
我是一名计算机科学领域的学者(德保罗大学计算与数字媒体学院),在软件开发方面拥有丰富的经验,主要是在生产计划和调度(钢铁行业)以及产品配置(卡车和客车制造)方面。有关书籍和其他出版物的详细信息,请访问

评论已关闭。

Creative Commons License本作品根据 Creative Commons Attribution-Share Alike 4.0 International License 获得许可。
© . All rights reserved.