WebAssembly & LLVM笔记

837 阅读14分钟

前浪先驱

NaCl

NaCl 是由 Google 在 2011 年于 Chrome 浏览器中发布的一项技 术,该技术旨在提供一个沙盒环境,可以让基于 C/C++ 语言编写的 Native 应用,安全地运行在浏览器中。NaCl 的全称 “Native Client” 也暗示了这一点

如下图所示,一个标准 NaCl 应用的组成结构,与普通的 JavaScript Web 应用十分类似。NaCl模块作为应用的一部分,主要用来进行复杂的数据处理和运算,JavaScript 则负责处理应用与外部用户的交互逻辑。NaCl 实例与 JavaScript 代码之间可以通过“订阅 / 发布”模型,来互相传递消息。

NaCl.png

但现实却存在着很多问题。通常,一个 NaCl模块文件需要在开发者本地进行 编译,然后才能够在浏览器中使用。而本地编译的模块文件通常仅含有架构相关 (architecture-dependent)的代码,因此没有办法直接在其他类型的系统中使用。 一个完整的 NaCl 应用,在分发时需要提供支持多个架构平台(X86_32 / X86_64 / ARM 等)的模块文件。浏览器在实际使用时,会根据当前系统的具体架构类型,来动态地选 择,对应合适的模块文件进行使用。

不仅如此,由于 NaCl 模块“平台依赖”的特殊性,因此 NaCl 模块进行分发的过程,仅能 够在 Chrome Web Store 中进行。

另一方面,如果你想要将已经存在的 C/C++ 代码库 编译至 NaCl,并在浏览器中使用,你还需要通过名为 Pepper的库来对这些代码进行重写。

鉴于 NaCl 存在的“平台依赖”问题,Google 在后期又推出了名为 PNaCl 的技术。这里名字中多出来的 “P” 代表着“Portable”,也就是“可移植”的意思。

PNaCl 采用了不一样的生命周期,参考下图我们可以看到,相较于 NaCl 模块直接包含有平台架构相关的代码,PNaCl 将源 C/C++ 代码编译到一种中间代码。这些中间代码会在浏览器实际加载这个 PNaCl 模块时,再被转换为对应的平台相关代码。因此,对于 PNaCl 模块而言,分发的过程变得更加简单,且不用担心移植性的问题。

PNaCl.png

但这种技术也是仅仅被 Chrome 浏览器支持。且需要使用 Pepper 重写 C/C++代码。

asm.js

为了提高 javascript 的性能,Mozilla 在 2013年定义了一个名为 asm.js 的 javascript 严格子集。通俗来说,同样是js代码,符合asm.js规范的代码对JS引擎更加友好。

当 JavaScript 引擎发行代码满足一定条件后,便会通过 AOT(Ahead-of-Time) 静态编译的方式,将这些被标记的 ASM.js 代码,编译成对应的机器码并加以保存。当 JavaScript 引擎再次执行(甚至在第一次执行)这段 ASM.js代码时,便会直接使用先前已经存储好的机器 码版本。因此,引擎的性能会得到大幅的提升。

asm.js 具有如下优势:

  • 不直接写 asm.js,而是用 C/C++编写,然后 Transpiling为 javascript。(我理解只是需要一种强类型语言)
  • 当浏览器发现 'use asm' 标签,代表这部分代码是可以用底层调用,而不是 javascript 解释器。
  • 从第一次调用,就可以获得更快的代码执行速度。代码中的类型是给定且不会改变的。比如整数类型中途不会被修改为字符串。因此浏览器 javascript 引擎不需要监测代码来确定类型和需要优化的部分,而可以直接编译优化。

为什么

Web 应用随着时间发展,变得越来越庞大,前端逻辑也越来越复杂。浏览器引入一个 JIT(just in time)的编译概念,来让代码执行的更快。 他是如何做到的? javascript 引擎在运行时监测代码,如果某一部分代码被使用的足够多,那么引擎就会尝试将这一部分代码编译为机器码,这样他就能绕过 javascript 引擎,转而用底层系统实现,这要快得多。

但是,javascript 是动态语言,一个变量可以是任意类型。比如一个变量开始是整数类型,运行了若干次后被赋值为字符串。而编译需要类型确定,而引擎监测代码运行多次后才编译也是这个原因。这也是上面asm.js 能被更好的优化的原因。

主要编译困难

  • C/C++ 是静态类型语言,而 Javascript 是动态类型语言。
  • C/C++ 是手动内存管理,而 Javascript 依靠垃圾回收机制。

asm.js 就是为了解决这两个问,它的变量一律都是静态类型,并且取消垃圾回收机制。除了这两点,它与 JavaScript 并无差异,所以说,asm.js 是 JavaScript 的一个严格的子集,只能使用后者的一部分语法。

asm.js 只有两种数据类型。

  • 32位带符号整形
  • 64位带符号浮点型

其他数据类型,比如字符串、布尔值或者对象,asm.js 一概不提供。它们都是以数值的形式存在,保存在内存中,通过 TypedArray 调用。

如果变量的类型要在运行时确定,asm.js 就要求事先声明类型,并且不得改变,这样就节省了类型判断的时间。

asm.js 的类型声明有固定写法,变量 | 0表示整数,+变量表示浮点数。

下面是一段 asm.js 代码示例

// OR 0 的操作,不会对不支持 asm.js 的引擎有什么副作用,且把值限定到了整数类型范围
function AsmModule() {
    'use asm';
    return {
        add: function(a, b) {
            a = a | 0;
            b = b | 0;
            return (a + b) | 0;
            // 如果改为
            // const sum = a + b;
            // return +sum;
            // 则表示函数返回一个双精度浮点型
        }
    }
}

参数a 和 b,如果用不支持 asm.js 的写法,只有在运行时给他传了参数,才能确定类型;如果使用了 asm.js 的写法,声明时就知道数据类型为整数。同样返回值也是。

垃圾回收

asm.js 没有垃圾回收机制,通过 TypedArray 直接读写内存。

asm.js 缺点

  • 添加类型提示,会让 javascript 文件变大
  • asm.js,仍然是 javascript 文件,他仍然需要 javascript 引擎读入和解析。
  • javascript 语言依然无法编译
  • 仅有FirFox的浏览器有良好的支持

后来浏览器厂商认为 asm.js 这个想法挺不错的,但需要改进和标准化一下。于是想出了一个 WebAssembly 最小化可行产品( minimum viable product,MVP) 的方案。2017年,4个主流浏览器厂商(Google、MS、Apple、Mozilla)都更新了浏览器,提供对 MVP(有时候也称为 wasm) 的支持。

什么是 WebAssembly

"WebAssembly" 是由 "Web" 与 "Assembly" 两个单词组 成的。前面的 “Web” 代指 Web 平台;后面的 "Assembly" 在我们所熟悉的编程语言体系中,可以理解为"汇编"。 通常来说,汇编语言给人的第一感觉便是“底层,外加高性能”。而这,也正是第一次听说Wasm这门技术的开发者们的第一感受。

简单理解

易于理解的方式讲:WebAssembly 是一种技术,他可以使用非 javascript 语言编写的代码,代码经过编译后可以运行于浏览器。相当于把其他语言带入了浏览器,在需要大量 CPU 密集运算的场景(如前端加密算法,3D 图形渲染等等)可以弥补 JavaScript 的性能不足

Webassembly Org

WebAssembly 的特点

  • 是一种底层汇编语言,可以在浏览器上以接近本地的运行速度运行。
  • 文件小,紧凑(二进制),可以很快速的传输和下载。
  • 他是一个编译目标,WebAssembly 支持很多语言作为开发语言。可用的编程语言有 C、C++、Rust、Swift 等。
  • WebAssembly 不是 javascript 的替代品,而是他的一个补充。WebAssembly 也是运行于 javascript VM 中的。

严谨解释

“WebAssembly(缩写为 Wasm)是一种基于堆栈式虚拟机的二进制指令集。Wasm 被设计成为一种编程语言的可移植编译目标,并且可以通过将其部署在 Web 平台上,以便为客户端及服务端应用程序提供服务”。

看起来是不是不知所云。我们把定义放这里,下面一点点分解内容来逐渐认识 WebAssembly。

WebAssembly 是个编译目标,他有一套二进制的指令集。也就是说,理论上无论什么语言,只要能转为 IR(Intermediate Representation),那就可以被继续转换成 JavaScript。

下面插播一下编译器原理

传统的编译器

传统的编译器通常分为三个部分,前端(frontEnd),优化器(Optimizer)和后端(backEnd)。

  • 前端(Frontend)负责分析源代码、检查语法级错误(语法分析),构建针对该语言的抽象语法树(Abstract Syntax Tree,AST) ,并将代码编译成中间表示(Intermediate Representation,IR)。
  • 抽象语法树可以进一步转换为优化,最终转为新的表示方式, 然后再交给优化器。
  • 优化器则是在前端的基础上,对得到的中间代码进行优化,使代码更加高效
  • 最终由后端生成可执行的机器码。 三部分各司其职。如果要创造一种新的编程语言,增加实现一个前端就可以。同样如果需要增加一种处理器架构(如:arm64、x86等),只需要增加一种后端。而 WebAssembly 正是一个这种后端。

comp1.png

这种三段式的结构还有一个好处,开发前端的人只需要知道如何将源代码转换为优化器能够理解的中间代码就可以了,他不需要知道优化器的工作原理,也不需要了解目标机器的知识。这大大降低了编译器的开发难度,使更多的开发人员可以参与进来。

GCC 也将三段式做的比较好,但前端和后端没分得太开,耦合在一起。虽然也实现了很多前端,支持了很多语言。但是他们是一个完整的可执行文件,没有给其它语言的开发者提供代码重用的接口。即使 GCC 是开源的,但是源代码重用的难度也比较大。LLVM 工具集很好的实现了三段式编译。

LLVM

LLVM 全称是 Low Level Virtual Machine,它是源自 the University of Illinois 的一个研究项目,该项目旨在提供一个现代化的编译机制,使得对任何编程语言既可以做到静态编译也可以动态编译,而且非常高效。后来 LLVM 项目逐渐发展,并孵化了许多子项目,比如 Clang,LLDB, OpenMP 等。

LLVM前端已支持的编程语言:C、C++、ActionScript、Ada、D语言、Fortran、GLSL、Haskell、Java字节码、Objective-C、Swift、Python、Ruby、Crystal、Rust、Scala以及C#等。

LLVM后端已支持指令集架构:x86、x86-64、ARM、MIPS、PowerPC以及RISC-V等。

这时候我们大概了解了。

Emscripten 就是使用了 Clang 把C/C++代码转为 LLVM IR,然后实现的一个后端目标平台wasm。

comp2.png

此时,.wasm 文件中的二进制代码还不是机器码,他只是支持 WebAssembly 的浏览器能够理解的一组虚拟指令,是一组字节码(bytecode)。等到浏览器把 wasm 文件加载完后,他会验证这个文件的合法性,然后将这些字节码继续编译为浏览器所运行的设备的机器码。WebAssembly 字节码非常接近机器码,可以非常快的被翻译为对应架构的机器码,因此WebAssembly运行速度和机器码接近,这听上去非常像Java字节码。

comp3.png

Wasm 浏览器加载流程

一个 Wasm 二进制模块需要经过怎样的流程,才能够最终在 Web 浏览器中被使用。你可以参考一下我画的这张图,这些流程可 以被粗略地划分为以下四个阶段。 wasm_flow.png

首先是 “Fetch” 阶段。作为一个客户端 Web 应用,在这个阶段中,我们需要将被使用到的 Wasm 二进制模块,从网络上的某个位置通过 HTTP 请求的方式,加载到浏览器中。

这个 Wasm 二进制模块的加载过程,同我们日常开发的 Web 应用在浏览器中加载JavaScript 脚本文件等静态资源的过程,没有任何区别。对于 Wasm 模块,你也可以选择将它放置到 CDN 中,或者经由 Service Worker 缓存,以加速资源的下载和后续使用过程。

接下来是 “Compile” 阶段。在这个阶段中,浏览器会将从远程位置获取到的 Wasm 模块二进制代码,编译为可执行的平台相关代码和数据结构。此时,浏览器引擎只是将 Wasm 的字节码编译为平台相关的代码,而这些代码还并没有开始执行。

紧接着便是最为关键的 “Instantiate” 阶段。在这个阶段中,浏览器引擎开始执行在上一 步中生成的代码。会将那些 Wasm 模块规定 需要从外界宿主环境中导入的资源,导入到正在实例化中的模块,以完成最后的实例化过程。这一阶段完成后,我们便可以得到一个动态的、保存有状态信息的 Wasm 模块实例对象。

接下来我们就可以使用这些 wasm 提供的方法了。

Emscripten 登场

Emscripten is a complete Open Source compiler toolchain to WebAssembly。 website

Emscripten 关于自己的介绍:

  • Compile C and C++ code, or any other language that uses LLVM, into WebAssembly, and run it on the Web, Node.js, or other wasm runtimes.

  • Compile the C/C++ runtimes of other languages into WebAssembly, and then run code in those other languages in an indirect way (for example, this has been done for Python and Lua).

其他适用环境

  • 一些移动浏览器开始支持 WebAssembly,包括 Chrome,Android Firefox,Safari。
  • NodeJs 从版本 8 开始,支持 WebAseembly。
  • 云环境。例如:WasmEdge项目
  • WASI(WebAssembly System Interface, Wasm 操作系统接口)。通过这项标准,Wasm 将可以直接与操作系统打交道。

发展历程

  • 2015 年 4 月,WebAssembly Community Group 成
  • 2015 年 6 月,WebAssembly 第一次以 WCG 的官方名义向外界公布
  • 2016 年 8 月,WebAssembly 开始进入了漫长的 “Browser Preview” 阶段
  • 2017 年 2 月,WebAssembly 官方 LOGO 在 Github 上的众多讨论中被最终确定;同年同月,一个历史性的阶段,四大浏览器(FireFox、Chrome、Edge、WebKit)在 WebAssembly 的 MVP(最小可用版本)标准实现上达成共识,这意味着 WebAssembly 在其 MVP 标准上的 “Brower Preview” 阶段已经结束
  • 2017 年 8 月,W3C WebAssembly Working Group 成立,意味着 WebAssembly 正式成为 W3C 众多技术标准中的一员。
  • 2019年12月5日,WebAssembly 成为了 W3C 的正式标准。

Emscripten 工具包

许多语言都有编译为 WebAssembly 的方案。 目前 Emscripten 工具包是将 C/C++代码编译为 WebAssembly 字节码最成熟的工具包。他使用 LLVM 编译器。

Emscripten 接收 由Clang 作为编译前端生成的 LLVM IR,然后将其转换为一种二进制字节码。

Emscripten Wiki

github

Emscripten 安装

# Get the emsdk repo
git clone https://github.com/emscripten-core/emsdk.git

# Enter that directory
cd emsdk

# Fetch the latest version of the emsdk (not needed the first time you clone)
git pull

# Download and install the latest SDK tools.
./emsdk install latest

# Make the "latest" SDK "active" for the current user. (writes .emscripten file)
./emsdk activate latest

# Activate PATH and other environment variables in the current terminal
source ./emsdk_env.sh

# check install
emcc -v

测试一下

Emscripten 有三种生成 wasm 的方法。每种方法用到的参数和生成的结果不一样。

创建一个 cpp 文件

#include <iostream>
#include <emscripten.h>
using namespace std;

extern "C" {
    EMSCRIPTEN_KEEPALIVE
    int add(int x, int y)
    {
        return x + y;
    }
}

int main()
{
    cout << "Hello WebAssembly, Add result is : " << add(11, 22) << endl;
    return 0;
}

整个函数的定义被放置在 extern "C" {} 结构中,指示编译器以C 语言的方式编译这部分代码。因为 C++支持函数重载,函数参数和函数名会被统一作为标识。以防止函数名被 C++ Name Mangling 改变。这样我们可以确保当在宿主环境(比如浏览器)中调用该函数时,可以用基本与 C/C++ 源代码中保持一致的函数名,来直接调用这个函数。

使用了名为 “EMSCRIPTEN_KEEPALIVE” 的宏标记了该 函数。这个宏定义在头文件 "emscripten.h" 中,通过使用它,我们能够确保被 "标 记" 的函数不会在编译器的编译过程中,被 DCE(Dead Code Elimination)过程处理掉。编译器会优化删除从未使用过的函数,而我们定义的函数有些是为了提供给 javascript 调用的,不能被优化删除掉。

生成 WebAssembly 模块、javascript plubming 文件,以及 html 模板文件

emcc hello.cpp -O3 -o hello.html

生成了 hello.html、hello.js、hello.wasm 三个文件。 启动一个本地服务,打开 hello.html 可以看到代码执行结果。

这种方式实际产品中几乎用不到,只是有时候需要测试某个代码或者函数的时候,会用到。

javascript plumbing 文件是 Emscripten 生成的一个 javascript 文件。这个文件会自动完成 WeabAssembly 文件下载并在浏览器中完成编译和实例化的工作。

生成 WebAssembly 模块、javascript plubming 文件

emcc hello.cpp -O3 -o hello_plumbing.js

这时候已经有了 WebAssembly 模块和 javascript plumbling 文件,需要你自己写 html 页面引入 javascript 文件。

页面打开后,你可以在控制台看到这句 Hello WebAssembly, Add result is : 33

这时候,WebAssembly 模块的加载和编译实例化还是依靠 javascript plumbing 文件完成的。

只生成 WebAssembly 模块

这种情况下,Emscripten 只生成 WebAssembly 模块,模块加载和实例化都需要自己完成。

emcc hello.cpp  -O2 -s WASM=1 --no-entry -o hello_wasm.wasm