在Emscripten中缩减WebAssembly和JavaScript代码大小

Emscripten 是一个用于 asm.js 和 WebAssembly 的编译器工具链,它允许你在 web 上以接近原生速度运行 C 和 C++ 代码。

Emscripten 的输出大小最近大幅减少,尤其对于较小的程序。例如,这里有一小段 C 代码

#include <emscripten.h>

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

这是纯粹计算的“hello world”:它导出一个将两个数字相加的单一函数。使用 -Os -s WASM=1 (优化大小,构建为 wasm)编译它,生成的 WebAssembly 二进制文件只有 42 字节。反汇编它,它包含了你的预期,没有更多。

(module
 (type $0 (func (param i32 i32) (result i32)))
 (export "_add" (func $0))
 (func $0 (; 0 ;) (type $0) (param $var$0 i32) (param $var$1 i32) (result i32)
  (i32.add
   (get_local $var$1)
   (get_local $var$0)
  )
 )
)

相当好!事实上,它非常小,以至于即使 Emscripten 同时创建了一个 JavaScript 文件来为你加载它,你也可以轻松地编写你自己的加载代码,因为它不依赖于任何特殊的运行时支持。

为了比较,Emscripten 1.37.22 以前会为该代码示例生成一个 10,837 字节的 WebAssembly 二进制文件,所以改进到 42 字节是巨大的。那么更大的程序呢?那里也有很多改进:比较 Emscripten 1.37.22 和 1.37.29 上的 C hello world 程序使用 printf,WebAssembly 二进制文件从 11,745 字节减少到 2,438 字节,缩小了近 5 倍。查看生成的 JavaScript 文件,并使用 –closure-compiler 1 运行 emcc 来运行 Closure Compiler(强烈推荐!),最近的 Emscripten 改进将其从 23,707 字节缩减到 11,690 字节,缩小了 2 倍以上。(稍后会详细介绍这些数字。)

发生了什么变化?

Emscripten 主要集中在使其 轻松 移植 现有 C/C++ 代码。这意味着支持各种 POSIX API、模拟文件系统,以及对诸如 longjmp 和 C++ 异常等尚未在 WebAssembly 中获得原生支持的事物的特殊处理。我们还努力简化从 JavaScript 使用编译后的代码,通过提供各种 JavaScript API(ccall 等)。所有这些使得移植像 OpenGLSDL 等有用 API 到 Web 变得切实可行。这些功能依赖于 Emscripten 的运行时和库,而我们以前包含的库比你实际需要的要多,主要有两个原因。

首先,我们过去默认导出很多东西,也就是说,我们在输出中包含了太多你可能使用的東西。我们最近专注于 将默认值更改为更合理的值

第二个原因更有趣:Emscripten 会生成 WebAssembly 和 JavaScript 的组合,从概念上来说是这样的

Emscripten emits a combination of WebAssembly & JavaScript (a conceptual diagram)

圆圈代表函数,箭头代表调用。其中一些函数可能是根,我们需要保持它们活跃,我们希望执行死代码消除(DCE),即删除所有无法从根访问的东西。但是,如果我们只看其中一面(仅 JavaScript 或仅 WebAssembly),那么我们必须将从另一侧访问的所有东西都视为根,因此我们无法删除像上面链条的最后两个部分和底部整个循环这样的东西。

事实上,事情以前并没有那么糟糕,因为我们确实考虑了这两个域之间的一些连接——足以对更大的程序做一些合理的工作(例如,我们只包含必要的 JS 库代码,所以如果你不需要 WebGL 支持,你就不会得到它)。但是,如果我们没有使用它们,我们无法删除核心运行时组件,这在较小的程序中非常明显。

对此的解决方案是我们称之为(由于没有更好的名称)元-DCE。它将 WebAssembly 和 JavaScript 的组合图作为一个整体来查看。在实践中,这通过扫描 JavaScript 侧并将该信息传递到 Binaryen 的 wasm-metadce 工具来实现,该工具随后可以查看完整图片并确定可以消除什么。它会删除不必要的 WebAssembly 内容,优化模块(删除内容可能会在剩余代码中打开新的优化机会),并报告可以从 JavaScript 中删除什么(Emscripten JavaScript 优化器会将其屏蔽,我们依赖于 Closure Compiler 来清理所有剩余的部分)。

共同消除 JavaScript 和 WebAssembly 的必要性是内在的,在任何项目中同时包含 JavaScript 和 WebAssembly 并且允许它们之间建立有趣的连接时都是不可避免的。这类应用程序预计将变得越来越普遍,因此这个问题不仅在 Emscripten 中很重要。也许,例如,Binaryen 的 wasm-metadce 工具可以作为 JavaScript 模块打包程序中的一个选项集成:这样一来,如果你包含一个 WebAssembly 库,那么你没有实际使用的部分就可以自动删除。

关于代码大小的更多信息

让我们回到 C hello world。为了强调优化的重要性,如果你只使用 -s WASM=1(构建为 wasm,没有指定优化)编译它,你会得到 44,954 字节的 WebAssembly 和 100,462 字节的 JavaScript。在没有优化的情况下,编译器不会努力减小代码大小,因此输出中包含诸如注释、空格和不必要的代码之类的东西。添加 -Os –closure 1 来优化大小,我们得到 2,438 字节的 WebAssembly 和 11,690 字节的 JavaScript,如本文前面所述。这要好得多——实际上比未优化版本小 10 倍以上——但为什么它还不够小?事实上,为什么它不只是输出 console.log(“hello, world”)

C hello world 使用 printf,它是在 libc(Emscripten 中的 musl)中实现的。printf 使用足够通用的 libc 流代码来处理不仅打印到控制台,而且打印到像文件这样的任意设备,并且它实现了缓冲和错误处理等等。期望优化器删除所有这些复杂性是不合理的——实际上,问题是,如果我们只想打印到控制台,那么我们应该使用比 printf 更简单的 API。

一个选项是使用 emscripten_log,它只打印到控制台,但它支持许多选项(比如打印堆栈跟踪、格式化等),因此它在减小代码大小方面帮助不大。如果我们真的只想使用 console.log,我们可以通过使用 EM_ASM 来实现,这是一种调用任意 JavaScript 的方法

#include <emscripten.h>

int main() {
  EM_ASM({
    console.log("hello, world!");
  });
}

(我们也可以接收参数并返回结果,所以我们可以用这种方式实现我们自己的最小日志记录方法。)这个文件编译为 206 字节的 WebAssembly 和 10,272 字节的 JavaScript。这让我们几乎达到了我们想要的目标,但为什么 JavaScript 仍然不是那么小?那是因为 Emscripten 的 JavaScript 输出支持许多东西

  • 它可以在 Web、Node.js 和各种 JavaScript VM shell 中运行。我们有大量代码来弥合这些差异。
  • WebAssembly 加载代码支持许多选项,比如使用 (如果可用)。
  • 提供了钩子,让你可以在程序执行的各个点运行代码(例如,在 main() 之前)。这些很有用,因为 WebAssembly 启动是异步的。

所有这些都相当重要,因此很难直接删除它们。但也许将来可以将这些内容设为可选,也许我们可以找到用更少的代码来实现它们的方法。

展望未来

有了元-DCE,我们拥有了代码大小所需的大部分优化基础设施。但除了上一节结尾提到的可能的 JavaScript 改进之外,我们还可以做更多的事情。想参与进来吗?看看下面的问题,看看有没有你想研究的东西。

关于 Alon Zakai

Alon 是 Mozilla 研究团队的一员,主要从事 Emscripten 的工作,Emscripten 是一个将 C 和 C++ 编译为 JavaScript 的编译器。Alon 于 2010 年创立了 Emscripten 项目。

更多 Alon Zakai 的文章……


3 条评论

  1. Hernan Saez

    太棒了!期待尝试新的变化。
    感谢分享

    2018 年 1 月 30 日 上午 10:24

  2. Josh Triplett

    GCC 有优化功能,可以将没有格式说明符的 printf 调用替换为 puts 调用。enscripten/binaryen 可以做同样的优化,然后优化 puts 吗?

    2018 年 1 月 30 日 上午 11:55

    1. Alon Zakai

      是的,你说得对,对于 printf(),clang 会将其优化为 puts() 用于常量字符串,并且这里考虑到了这一点。puts() 确实避免了格式化开销,但在 musl(和其他 libcs)内部,它仍然使用流,包括所有间接寻址和缓冲等开销。

      编辑:是的,我们可以尝试将 puts 优化为更简单的东西,但需要考虑到缓冲等问题,因此并不容易。

      2018 年 1 月 30 日 下午 12:28

本文的评论已关闭。