速度一直很快,
在我的上一张幻灯片中, 文章,我介绍了 WebAssembly 可让您将 C/C++ 的库生态系统引入 Web。一款应用 广泛利用了 C/C++ 库的 squoosh Web 应用,可让您使用 从 C++ 编译到 WebAssembly。
WebAssembly 是一种低层级虚拟机,运行
位于 .wasm
文件中。该字节码属于强类型,并且采用如下结构:
与针对主机系统进行编译和优化相比,
JavaScript 可以。WebAssembly 提供了一个环境
从一开始就考虑到沙盒和嵌入功能
根据我的经验,网络上的大多数性能问题都是 但有时应用需要执行 需要大量时间才能完成的计算开销大的任务。WebAssembly 可以提供帮助 此处。
热门路径
在 squoosh 中,我们编写了一个 JavaScript 函数。 将图片缓冲区旋转 90 度的倍数。虽然 OffscreenCanvas 非常适合以下用途: 但其目标浏览器并不支持该功能, 在 Chrome 中有漏洞。
此函数迭代输入图像的每个像素,并将其复制到 在输出图像中的不同位置实现旋转。对于 4094px 4096 像素的图片(1600 万像素),则需要对 内部代码块,我们称之为“热路径”。尽管有相当大的 迭代次数,我们测试过的浏览器中有 2 个会在 2 年内完成任务 不超过几秒。此类互动可接受的时长。
for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
outBuffer[i] = inBuffer[in_idx];
i += 1;
}
}
但是,一个浏览器需要 8 秒以上的时间。浏览器优化 JavaScript 的方式 非常复杂,而且不同的引擎会针对不同的目标进行优化。 有些针对原始执行进行优化,有些针对与 DOM 的交互进行优化。在 在本例中,我们在一个浏览器中遇到了一条未经优化的路径。
另一方面,WebAssembly 完全围绕原始执行速度构建。因此 如果我们希望像这样的代码那样在各种浏览器中都能快速预测性能, WebAssembly 可以为您提供帮助。
利用 WebAssembly 实现可预测的性能
一般来说,JavaScript 和 WebAssembly 可以实现相同的峰值性能。 不过,对于 JavaScript,这种性能只能通过“快速路径”实现, 而要始终走在“快捷路径”通常并非易事Google Cloud WebAssembly 提供可预测的性能,即便是跨浏览器。严格的 低级别架构可让编译器 保证 WebAssembly 代码只需优化一次, 始终使用“快捷路径”。
为 WebAssembly 编写代码
之前,我们将 C/C++ 库编译为 WebAssembly,以便使用 其他功能我们并没有涉及任何库的代码, 只编写了少量 C/C++ 代码来搭建浏览器间的桥梁, 和库这一次,我们的动机不一样了: 使用 WebAssembly 方法创建代码 WebAssembly 的优势
WebAssembly 架构
在为 WebAssembly 编写代码时,详细了解一下 WebAssembly 究竟是什么
引用 WebAssembly.org 的话:
将一段 C 或 Rust 代码编译为 WebAssembly 时,您会看到一个 .wasm
包含模块声明的文件。此声明包含
"导入"模块期望从其环境中获取内容、此
模块(函数、常量、内存块)可供主机使用,并且
当然是其中包含函数的实际二进制指令。
在深入了解之前,我才意识到一个问题: WebAssembly 是一个“基于堆栈的虚拟机”未存储在 WebAssembly 模块使用的内存该堆栈完全位于虚拟机内部 Web 开发者无法访问(除非通过开发者工具)。因此, 编写完全不需要额外内存的 WebAssembly 模块, 仅使用虚拟机内部堆栈
在本例中,我们需要使用一些额外的内存来允许任意访问
并生成该图像的旋转版本。这是
WebAssembly.Memory
的用途。
内存管理
通常,一旦使用了额外的内存,就会发现
管理这些记忆内存的哪些部分正在使用中?哪些应用是免费的?
例如,在 C 中,您可以使用 malloc(n)
函数查找内存空间
共有 n
个连续字节。此类函数也称为“分配器”。
当然,正在使用的分配器的实现必须包含在您的
WebAssembly 模块,同时还会增大文件大小。此大小和性能
而这些内存管理功能在内存管理方面可能会存在很大差异,具体取决于
所用的算法,正因如此,许多语言都提供
进行选择(“dmalloc”、“emmalloc”、“wee_alloc”等)。
在本例中,我们知道输入图片的尺寸(因此也知道 输出图像尺寸),然后再运行 WebAssembly 模块。这里我们 发现了一个机会:传统上,我们会将输入图片的 RGBA 缓冲区作为 参数传递给 WebAssembly 函数,并将旋转的图片作为返回内容返回 值。要生成该返回值,我们必须使用分配器。 但由于我们知道所需的内存总量(是输入大小的两倍) 一次用于输入,一次用于输出),我们就可以将输入图片放入 WebAssembly 内存使用 JavaScript,请运行 WebAssembly 模块以生成 第 2 张旋转的图片,然后使用 JavaScript 读回结果。我们可以 完全不需要使用任何内存管理!
品类众多,令人爱不释手
如果您之前查看过原始 JavaScript 函数 可以看到,这是一个纯计算 不含 JavaScript 专用 API 的代码。因此,它应该相当直截了当 可将此代码移植到任何语言。我们评估了 3 种不同的语言 可以编译为 WebAssembly:C/C++、Rust 和 AssemblyScript。唯一的问题 我们需要回答的问题是: 如何访问原始内存 而不使用内存管理功能?
C 和 Emscripten
Emscripten 是适用于 WebAssembly 目标的 C 编译器。Emscripten 的目标是 函数直接取代 GCC 或 Clang 等知名 C 编译器 并且大体上与标志兼容这是 Emscripten 的任务的核心所在。 因为它希望将现有 C 和 C++ 代码编译到 WebAssembly 中变得像
访问原始内存是 C 语言的本质,存在指向原始内存的指针 原因:
uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;
在这里,我们将数字 0x124
转换为指向无符号 8 位的指针。
整数(或字节)。这样可以有效地将 ptr
变量转换为数组
从内存地址 0x124
开始,我们可以像使用任何其他数组一样使用,
允许我们访问各个字节以进行读取和写入。在本例中,我们
我们查看的是图像的 RGBA 缓冲区,我们要进行重新排序,
轮替。要移动一个像素,实际上需要一次性移动 4 个连续字节
(每个通道一个字节:R、G、B 和 A)。为了简化操作,我们可以使用
无符号 32 位整数数组。按照惯例,我们的输入图片将
从地址 4 开始,输出图像将紧跟在输入图像之后
结束时间:
int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);
for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
outBuffer[i] = inBuffer[in_idx];
i += 1;
}
}
将整个 JavaScript 函数移植到 C 后,我们就可以编译 C 文件了
与 emcc
共享:
$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c
与往常一样,emscripten 会生成一个名为 c.js
的粘合代码文件和一个 wasm 模块
名为 c.wasm
。请注意, wasm 模块仅 gzip 约 260 字节,而
粘合代码在 gzip 后大约为 3.5KB。我们稍作调整,就成功放弃了
粘合代码,并使用原版 API 实例化 WebAssembly 模块。
使用 Emscripten 通常可以实现此操作,只要您不使用任何内容,
C 标准库中的内容。
Rust
Rust 是一种新型的现代编程语言,具有丰富的类型系统,无需运行时 以及可保证内存安全和线程安全的所有权模型。锈红色 也支持将 WebAssembly 作为核心功能,Rust 团队 为 WebAssembly 生态系统贡献了大量出色的工具。
其中一种工具是 wasm-pack
Rustwasm 工作组。wasm-pack
可接受您的代码并将其转换为适合网页使用的模块,
支持开箱即用。wasm-pack
是一个非常
非常方便,但目前仅适用于 Rust。该群组是
考虑增加对其他以 WebAssembly 为目标的语言的支持。
在 Rust 中,切片是 C 中的数组。就像使用 C 语言一样,我们需要创建
使用起始地址的切片。这与内存安全模型背道而驰
所以我们必须使用 unsafe
关键字,
允许我们编写不符合该模式的代码。
let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}
for d2 in 0..d2Limit {
for d1 in 0..d1Limit {
let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
outBuffer[i as usize] = inBuffer[in_idx as usize];
i += 1;
}
}
使用以下代码编译 Rust 文件:
$ wasm-pack build
会生成一个 7.6KB 的 wasm 模块,其中包含约 100 字节的粘合代码(均在 gzip 之后)。
AssemblyScript
AssemblyScript是 旨在成为 TypeScript-to-WebAssembly 编译器的年轻项目。时间是 但需要注意的是,它不会仅使用任何 TypeScript。 AssemblyScript 使用的语法与 TypeScript 相同,但前者从后者中 自己的资源库他们的标准库 WebAssembly。也就是说,不能只编译你谎报的任何 TypeScript 但这确实意味着您不必学习 来编写 WebAssembly!
for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
i += 1;
}
}
考虑到我们的 rotate()
函数具有较小的类型 surface,
将此代码移植到 AssemblyScript 是相当容易的函数 load<T>(ptr:
usize)
和 store<T>(ptr: usize, value: T)
由 AssemblyScript 提供,用于
访问原始内存。要编译 AssemblyScript 文件,请执行以下操作:
我们只需安装 AssemblyScript/assemblyscript
npm 软件包并运行
$ asc rotate.ts -b assemblyscript.wasm --validate -O3
AssemblyScript 将向我们提供约 300 字节的 wasm 模块,且无粘合代码。 该模块仅适用于原始 WebAssembly API。
WebAssembly 取证
与另外两种语言相比,Rust 的 7.6KB 大到惊人。那里 WebAssembly 生态系统中有几款工具可以帮助您 WebAssembly 文件(无论创建时使用的是哪种语言)和 告诉您发生了什么,并帮助您改善情况。
树枝
Twiggy 是 Rust 的
WebAssembly 团队从 WebAssembly 中提取大量富有参考价值的数据
模块。该工具并非特定于 Rust,可让您检查
模块的调用图,确定未使用的或多余的部分,并找出
模块总文件大小的哪些部分通过
后一种方法可以使用 Twiggy 的 top
命令完成:
$ twiggy top rotate_bg.wasm
![Twiggy 安装屏幕截图](https://developer.chrome.google.cn/static/blog/hotpath-with-wasm/image/twiggy-installation-scree-6faa973bc4a89.png?authuser=9&hl=sk)
在本例中,我们可以看到文件的大小主要来自于 分配器。出乎意料的是,我们的代码没有使用动态分配。 另一个重要影响因素是“函数名称”子部分中。
Wasm-Strip
wasm-strip
是 WebAssembly Binary Toolkit(简称 wabt)中的一种工具。它包含一个
几个工具,可让您检查和操控 WebAssembly 模块。
wasm2wat
是一个反汇编器,可将二进制 Wasm 模块转换为
简单易懂的格式Wabt 还包含 wat2wasm
,让您可以
转换为二进制 wasm 模块。虽然我们确实使用了
利用这两个互补工具来检查 WebAssembly 文件,
wasm-strip
才是最有用的。wasm-strip
可移除不必要的版块
和来自 WebAssembly 模块的元数据:
$ wasm-strip rotate_bg.wasm
这会将 Rust 模块的文件大小从 7.5KB 减少到 6.6KB(使用 gzip 之后)。
wasm-opt
wasm-opt
是 Binaryen 中的工具。
它需要一个 WebAssembly 模块,并尝试针对其大小和
只取决于字节码。一些工具(如 Emscripten)已在运行
但有些不需要。一般来说,最好的做法是
额外增加的字节数
wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm
借助 wasm-opt
,我们可以再削减一些字节,
执行 gzip 后 6.2KB。
#![no_std]
经过一番咨询和研究,我们重新编写了 Rust 代码,但没有使用
Rust 的标准库,使用
#![no_std]
功能。这也会完全停用动态内存分配
分配器代码。编译此 Rust 文件
替换为
$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs
在 wasm-opt
、wasm-strip
和 gzip 之后生成了 1.6KB wasm 模块。虽然
仍然比 C 和 AssemblyScript 生成的模块大,
可以认为是轻量级
性能
在我们单纯根据文件大小下结论之前,我们一起走过了这段旅程 以提高性能,而非文件大小。那么,我们是如何衡量绩效的? 效果如何?
如何进行基准比较
尽管 WebAssembly 是一种低级字节码格式,仍然需要将其 通过编译器生成主机特定的机器代码。与 JavaScript 一样 编译器会分多个阶段运行简而言之:第一阶段是 但编译速度往往较慢。该模块 浏览器会观察哪些部分被频繁使用, 通过一个优化程度更高但速度更慢的编译器来完成。
我们的应用场景很有趣,用于旋转图片的代码将用于 一次还是两次因此在绝大多数情况下, 优化编译器的优势。在设置广告系列时,请务必注意 基准化分析。在循环中运行 WebAssembly 模块 10,000 次会产生 不切实际的结果。为了得到实际数字,我们应该运行一次模块, 根据单次运行的数据做出决策。
效果对比
这两个图表是对相同数据的不同视图。在第一个图中, 在第二个图表中,我们针对所用的每种语言进行比较请 请注意,我选择了对数时间刻度。同样重要的是 使用相同的 1600 万像素测试图片和同一主机进行基准测试 除一个浏览器外,该浏览器不能在同一台计算机上运行。
不用过多分析这些图表,很明显,我们解开了原始图表, 性能问题:所有 WebAssembly 模块的运行时间约为 500 毫秒或更短。这个 WebAssembly 提供可预测的 性能无论我们选择哪种语言,浏览器之间的差异 和语言最少。确切地说,JavaScript 的标准差 在所有浏览器上都约为 400 毫秒,而我们所有浏览器的标准差 WebAssembly 模块在所有浏览器中的运行时间均为 80 毫秒左右。
有效时间
另一项指标是我们为打造和集成 转换为 squoosh。很难将数值分配给 所以我不创建任何图表了, 指出:
AssemblyScript 可以顺畅运行。它不仅可以让你使用 TypeScript 编写 WebAssembly,使代码审核对我的同事来说非常轻松,但也 可生成无粘合的 WebAssembly 模块,这些模块非常小巧,并且 性能TypeScript 生态系统中的工具(如 prettier 和 tslint) 可能就会奏效
将 Rust 与 wasm-pack
组合使用也非常方便,但会更加出色
在更大的 WebAssembly 项目中,绑定和内存管理
所需的资源。为了实现竞争优势,我们不得不偏离“满意”路径
文件大小。
C 和 Emscripten 创建了一个非常小且高性能的 WebAssembly 模块 开箱即用,但又没有勇气直接使用胶水代码并将其简化为 最终,总大小(WebAssembly 模块 + 粘合代码)的必要性 非常大
总结
如果您有 JS 热路径并希望将其 与 WebAssembly 实现更快或更一致。一如既往地提升性能 答案是:这要看情况。我们配送的是什么货物?
<ph type="x-smartling-placeholder">比较不同语言的模块大小 / 性能 C 或 AssemblyScript,是最佳选择。我们决定推出 Rust。那里 导致这一决定有多种原因:到目前为止,Squoosh 中搭载了所有编解码器 都是使用 Emscripten 编译的。我们想更深入地了解 WebAssembly 生态系统并在生产环境中使用不同语言。 AssemblyScript 是一个非常好的替代方案,但该项目相对较年轻, 编译器不如 Rust 编译器成熟。
虽然 Rust 与其他语言版本在文件大小上 在散点图中看起来相当大,但实际上并没有那么大的意义: 加载 500B 数据,即使在 2G 网络环境中加载 1.6KB 也不到 1/10 秒。且 Rust 有望很快缩小模块大小方面的差距。
就运行时性能而言,Rust 在各种浏览器中的平均速度比 AssemblyScript。特别是在更大的项目中 生成更快的代码,而无需手动优化代码。但 您不应妨碍您选用自己最熟悉的工具。
不过,AssemblyScript 是个不错的发现。它允许 开发者不必学习 语言。AssemblyScript 团队响应迅速,并且积极 正在努力改进其工具链。我们一定会密切关注 AssemblyScript。
更新:Rust
发布这篇文章后,Nick Fitzgerald
为我们推荐了他们优秀的《Rust Wasm》一书,
一个关于优化文件大小的部分。遵循
此处的说明(最值得注意的是启用链接时间优化和手动
紧急处理)使我们能够编写“正常”的 Rust 代码,然后返回
Cargo
(Rust 的 npm
),而不会使文件大小增大。Rust 模块到此结束
经过 gzip 处理后大小增加到 370B。如需了解详情,请查看我在 Squoosh 上开设的公关。
特别感谢 Ashley Williams、Steve Klabnik、Nick Fitzgerald 和 Max Graey 在此旅程中提供的大力帮助。