我完全错解了WASM!🤯
- WASM 既适用于前端也适用于后端,不只是提升了浏览器中的 JavaScript 性能。
- 后端的 WASM 运作方式与外部函数接口 (FFI) 不同。WASM 设计用于运行得更快且更高效。
- WASM 的速度来自其低级别的二进制格式、简单的内存模型以及预编译。这减小了开销,使得其性能接近原生代码。
- 我使用 Rust 和 WASM 优化了 ULID 的生成,在 wa-ulid 中实现了 40 倍的速度提升,相比起 JavaScript 版本。
- 目前,WASM 文件比 JavaScript 更大,这可能会带来一些挑战。但随着 WASM 工具链和优化技术的改进,WASM 将更适合于前后端应用程序。
作为一名开发者,我经常会经历一些阶段,在探索新技术时,类似于Gartner 技术成熟度曲线。这一过程展示了采用新技术的典型路径。在这篇文章中,我想解释一下我是如何从怀疑变得兴奋的,尤其是在我发现它能够增强后端性能时。
Gartner 技术炒作周期
WASM 是一种低级别的指令格式。它被设计为C、C++和Rust等语言的编译目标。主要目的是为了实现高性能的Web应用程序。但它也越来越被用于服务器端,特别是在对性能要求极高的情况下。
我的WASM之旅有高潮也有低谷。一开始我对它有着过高的期望,然后是失望的阶段,最终我获得了扎实的理解并能够在实际中应用。
最初的误解最初的误解是指最初形成的错误理解,这里将对这些误解进行探讨和纠正。
当我第一次听说WASM时,我充满期待。我认为WASM将让我们能够将复杂的计算无缝集成到网页浏览器中。它与FFI让高级语言执行机器码的方式相似。
FFI 是什么?FFI(异构函数接口)允许一种语言的代码直接调用另一种语言中的代码。当性能至关重要时,就会使用 FFI,且某些逻辑是在像 C 或 Rust 这样的低级语言中实现的。然后这些高级语言如 Python 或 JavaScript 可以调用该低级代码。
我原以为WASM就像FFI一样,只是在浏览器中运行机器级代码的一种方式。这听起来合理,因为它把高级语言编译成低级二进制格式。但我没有注意到WASM的独特架构和一些限制。
比较 WebAssembly (WASM) 和 外部函数接口 (FFI)将WASM视为FFI让我没注意到WASM与传统机器代码之间的区别。在FFI中,主机语言和外部函数之间的切换往往会带来很大的开销。在不同内存布局之间移动数据也相当耗资源。
现实检验我发现我的最初期望与实际使用WASM的体验之间存在差距。
用WASM和Rust迈出第一步:首先,我开始用wasm-bindgen来试验WASM的使用,这是一款帮助WASM模块与JavaScript协同工作的工具。我第一个尝试的例子非常简单。
use wasm_bindgen::prelude::*;
// 定义一个加法函数
#[wasm_bindgen]
pub fn add(a: u32, b: u32) -> u32 {
a + b
}
使用wasm-pack和链接时优化(LTO),这个简单的加法函数编译成了一个仅214字节的WASM模块。最初,这似乎表明WASM能提供紧凑高效的代码。
探索WAT格式:为了更好地理解这段简短代码是如何工作的,我检查了WAT(WebAssembly文本格式,简称WAT)版本。WAT是WASM二进制的一种易读版本。 它对于调试和优化WASM应用非常重要。这里是add
函数的WAT:
(func (export "add") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add)
(模块
(类型 (;0;) (函数 (参数 i32 i32) (结果 i32)))
(函数 (;0;) (类型 0) (参数 i32 i32) (结果 i32)
获取局部 0
获取局部 1
i32 加)
(存储 (;0;) 17)
(导出为 "memory" (存储 0))
(导出为 "add" (函数 0)))
这种简洁的形式展示了WASM在简单计算任务上的高效——没有额外负担,只有实现功能的核心操作。
复杂度增加的影响是什么然后我在示例中添加了字符串操作,看看这对模块大小有什么影响。
use wasm_bindgen::prelude::*;
#[wasm_bindgen] // WebAssembly绑定属性
pub fn add(a: u32, b: u32) -> u32 { // 公共函数,用于加法操作
// 以下两行将输入转换为字符串再转换回u32,实际上并非必要,因为函数已接收u32类型输入
let a = a.to_string().parse::<u32>().unwrap();
let b = b.to_string().parse::<u32>().unwrap();
return a + b; // 返回两个数的和
}
这段代码定义了一个名为add
的公共函数,用于加法操作,输入为两个32位无符号整数,返回它们的和。函数内部不必要的字符串转换步骤已标注。
尽管进行了相同的数学运算,这个版本生成的WASM模块大小却大幅增加到了14.5KB。WAT文件的行数也超过了7,126行,这反映了由于需要处理字符串所带来的额外复杂性和额外的开销。
在添加字符串操作后的 WAT 文件的一部分
The WebAssembly.Instance 构造函数只能同步编译不超过 4KB 的模块。而更大的模块则需要异步处理。但对我来说,这似乎是不可能的,保持 WASM 文件在这个限制之下。
幻灭感一个主要的挑战在于,在增加如字符串操作等功能时,WASM模块的大小急剧膨胀。文件大小的这种膨胀与WASM作为轻量高效格式的承诺相悖。
WebAssembly (WASM)优化以减小体积为了解决这些问题,我探索了减少WASM模块大小的方法。这里有一些策略可以减小WASM应用程序的大小:
- 避免恐慌:Rust的恐慌处理会增加开销。使用
Option
和Result
类型可以高效地管理错误,并且避免恐慌处理带来的开销。 - 限制使用字符串:动态字符串的使用会显著增加WASM模块的大小。使用整型或固定大小的数据类型可以保持模块的紧凑性。
- 链接时间优化(LTO):在Rust编译器中启用LTO可以减少编译后的WASM大小,通过移除未使用的代码并在跨crate边界进行优化。
- 手动移除无用代码:虽然在Rust到WASM的管道中自动剔除无用代码的能力有限,但手动确保仅包含必要的函数和依赖可以减轻膨胀现象。
尽管做出了这些努力,一些固有的挑战似乎有时难以战胜,特别是在处理高级编程中的任务时常见的复杂数据类型和操作。
动态语言在WASM中WASM遇到的困难并不只出现在Rust中。其他语言,尤其是像Python这样的动态语言,面临的挑战更大,甚至更为艰巨。我们来看一下为何如此,想象一下将一种动态语言,比如Python,编译成WASM会是什么样子:
- 编译解释器:对于Python,整个解释器必须编译成WASM,而不仅仅是用户的代码。这包括该语言支持的所有内置函数和库。
- 执行代码:运行编译成WASM的Python代码意味着在一个解释器中运行另一个解释器。这会带来显著的额外开销,从而生成较大的WASM二进制文件。
Python 转译器被编译成 WASM 代码,用于将 Python 源代码转译成可以在 WASM 环境中执行的代码。
即使是像 Go 这样的静态类型且编译型语言,根据 Go 编程语言维基,最小的 WASM 文件大小为 2MB。
社区面临的挑战我感到很失望,这反映了更广泛的开发者的感受。许多文章提到了类似的问题和挣扎。
- Zaplib 项目回顾:这家初创公司由于性能提升有限和开发复杂度高,最终放弃了 WASM。
- 偏离园艺的算法:文章指出了 WASM 工具链中树摇过程的不成熟,树摇是减少最终二进制文件大小的关键步骤,通过移除未使用的代码来实现。
这些社区中的这些经验强调了当前版本的WASM所面临的挑战,并形成了强烈的对比,与初时的兴奋相比。
逆袭在我对WASM感到失望的过程中,在那一刻,我找到了一个关键的解决方案。这个库有C、Python、Java和JavaScript等多种语言版本,并且h3-js利用Emscripten将从C编译的WASM与JavaScript连接起来。
关于 h3 的了解h3 是为地理空间索引设计的。它提供了一种将坐标映射到六边形网格的方法。这个系统对于需要处理大量地理空间数据的应用特别有用。我经常用的一个功能是 latLngToCell
,它将经纬度转换成六边形网格单元的标识符。
为了测试h3-js的性能,我用WASM将C版本与JavaScript版本进行对比。幸运的是,他们的仓库中已经包含了基准测试程序。以下是我在本地的M2 MacBookPro上得到的一些结果:
来看看WASM能做什么受到 h3-js 的意外结果启发,我决定进一步研究 WASM 的能力。我通过解决一个计算问题:科拉茨猜想,比较了它的性能与 JavaScript 和 FFI 的性能。
GitHub - yujiosaka/wasm-and-ffi-performance-comparison-in-node:比较 Rust FFI 和 Rust 编译为 WASM 在 Node.js 中求解 Collatz 猜想的性能 - github.com 什么是卡拉兹猜想?科拉茨猜想,也称为“3n加1问题”,是一个与以下定义的序列相关的数学猜想。
-
选择任意一个正整数 n 开始。
-
如果 n 是偶数,将它除以 2。
-
如果 n 是奇数,将它乘以 3 后再加上 1。
- 重复上述步骤,直到 n 变成 1。
从 n=3 开始的 Collatz 猜想 (从数字 n 开始,通常是 n=3)
该猜测认为,无论初始的 n 值是什么,序列最终都会变成 1。
柯拉茲猜想的JS、FFI和WASM实现为了比较性能表现,我用纯JavaScript实现了猜想,通过FFI调用Rust函数的方法,并直接在WASM中实现。并用n = 670617279作为输入,这个数字需要986步才能达到1。
- JavaScript
function 柯拉兹步骤(n) {
let 计数器 = 0;
当 n 不等于 1 时 {
如果 n 除以 2 的余数为 0 {
n /= 2;
} 否则 {
n = 3 * n + 1;
}
// 计数器加一
计数器++;
}
返回计数器;
}
Rust[FFI],和 Rust[WASM]
pub fn collatz_steps(mut n: u64) -> u64 {
let mut 计数 = 0;
while n != 1 {
if n % 2 == 0 {
n /= 2;
} else {
n = 3 * n + 1;
}
计数 += 1;
}
return 计数;
}
更多详情,请参阅我的仓库。可以在这里找到M2 MacBook Pro的基准测试结果。
这些发现表明,WASM在处理计算密集型任务时,能够优于原生JavaScript和FFI(外部函数接口)。
深入挖掘WASM性能看过WASM在h3-js和柯拉茲猜想中的出色表现之后,变得很明显,WASM不仅仅是初识那么简单,比我最初想象的更加厉害。
WASM 和 FFI 的不同之处在哪里?理解WASM效率的关键在于其设计为低级二进制指令格式,这一点。它不仅平台无关,还针对执行速度和代码紧凑性进行了优化。这与FFI不同,FFI由于在执行上下文之间传输数据以及处理不同的内存模型,可能会导致较高的开销。这种设置通过以下几点来减少常见的FFI开销:确保数据传输的高效性,避免不必要的内存复制,以及优化内存模型的处理。
- 内存管理是线性的和统一的:WASM 使用的是单一连续的内存块,简化了与宿主环境的接口。这减少了与传统 FFI 设置相关的内存管理成本。
- 优化执行的二进制格式:WASM 二进制格式适用于现代 JIT(即时编译)编译器的解码和执行。这使性能接近原生机器代码的水平,而没有通常的解释开销。
h3-js库的研究成果以及我对科拉茨猜想的实验,改变了我对WASM应用程序生态的看法。
- 后端比前端更强大:虽然 WebAssembly(WASM)最初因其在网页应用程序中的潜力而受到推崇,但其优势在后端和其他非浏览器环境中尤为突出,例如数据处理、科学计算和实时媒体编解码等计算密集型任务。
- 边缘计算:WASM 适用于边缘计算应用,在这些应用中,将代码运行在靠近数据源的地方可以显著提高响应速度并降低带宽消耗。
我在实际应用中利用WASM的高性能来生成Universally Unique Lexicographically Sortable Identifiers (ULIDs)。ULIDs类似于UUID,但具有可排序性。它们由时间戳和随机组件构成,并通过编码确保唯一性和词典顺序的可排序性。这使得它们在需要排序顺序和唯一性至关重要的分布式系统中特别有用。
性能提升了40倍通过将现有的JavaScript ULID生成实现转换为编译成WASM的Rust,我实现了显著的性能提升。新的版本大约是原版JavaScript版本的40倍快。
GitHub - yujiosaka/wa-ulid: 一个高性能的 ULID(通用唯一且可字典序排序的标识符)生成器,使用 WebAssembly,可以在这里查看 GitHub - yujiosaka/wa-ulid最初的翻译很简单,但随后采取了更精细的方法来进一步提升性能。
接下来的优化最初,性能提升了大约10倍左右。然而,通过多次优化Rust实现,我将这一提升增加到了40倍。以下是一些关键的技术,它们对这一显著的性能提升做出了贡献,尽管它们不特定于WASM。
1. 高效的数据结构
优化实现中使用的数据结构,比如使用预先分配容量的向量而非动态调整大小的向量,确保了内存分配最小化,从而避免了因频繁内存操作导致的性能下降。
// 之前是
String::new();
// 之后是
String::with_capacity(len);
2. 减少不必要的转换并避免内存分配
原来的 Rust 实现涉及了一些不必要的字符串和字符的转换,这些转换在计算上比较耗资源。通过优化数据处理和减少内存分配,性能得到了显著提升。例如,尽可能直接使用字节数组而不是转换为字符串或字符,这样可以减少开销。
// 原先
const ENCODING: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
...
let mut chars = Vec::with_capacity(len);
for index in 0..len {
chars.push(ENCODING.chars().nth(index).unwrap());
}
// 现在
const ENCODING: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const ENCODING_BYTES: &[u8] = ENCODING.as_bytes();
...
let mut chars = Vec::with_capacity(len);
for index in 0..len {
chars.push(ENCODING_BYTES[index] as char);
}
2. 预计算和缓存计算
预先计算那些会在多次函数调用中重复使用的值,例如编码长度的幂次,然后将其缓存,大幅减少了计算负担。这在诸如 decode_time
的函数中特别有效,这些函数的操作非常重复且可预测。
// 之前的代码
const ENCODING_LEN: usize = 32;
const TIME_LEN: usize = 10;
...
for i in 0..TIME_LEN {
time += i as f64 * (ENCODING_LEN as u64).pow(index as u32) as f64;
}
// 之后的代码
const ENCODING_LEN: usize = 32;
const POWERS: [f64; 10] = [1.0, 32.0, ..., 35184372088832.0]; // POWERS 数组存储了不同次方的结果
...
for i in 0..TIME_LEN {
time += i as f64 * POWERS[index]; // 使用POWERS数组中的值进行计算
}
结论部分
(注:此处使用“结论部分”以更符合口语化的中文表达,并保留了原文的标题形式。如用于Markdown文件中,可以考虑使用更简洁的Markdown格式或直接平铺文字。)
通过使用WASM优化ULID生成的例子展示了如何通过理解并利用WASM的能力来实现显著的性能提升在实际应用中。这个案例研究只是WASM有效应用的例子之一,不仅后台系统需要性能和效率,而且随着工具链的发展,WASM在Web领域中也展现出了巨大的潜力。
当前,较大的二进制文件尺寸在一定程度上限制了WASM在前端应用中的使用,特别是在下载和执行速度至关重要的前端应用中。然而,这并不是一个永久性的限制,而只是一个当前的障碍。随着WASM工具链在死代码剔除和优化二进制输出等技术上的不断进步,我们预计这些二进制文件的大小将会显著减小。
WASM在网页上的未来前景看好。随着工具链的发展,生成更小、更高效的二进制文件的能力也在提升,WASM革新区域网性能和能力的潜力也随之增长。这不仅会提升后端应用的性能,还可能彻底改变复杂应用程序在浏览器中的部署和运行方式,使它们在效率和性能上与原生应用无异。
共同学习,写下你的评论
评论加载中...
作者其他优质文章