缩小 .wasm
的体积
本节将教你如何优化 .wasm
构建以获得较小的代码体积,以及如何确定更改源代码的机会,从而减少 .wasm
文件。
为什么要关心代码大小?
通过网络提供 .wasm
文件时,文件越小,客户端下载文件的速度就越快。
.wasm
下载速度越快,页面加载速度越好,用户体验越好。
然而,重要的是要记住,虽然代码大小可能不是你最终感兴趣的指标,但更像是一些更模糊和难以衡量的东西,比如”第一次互动的时间”。 虽然代码大小在这个测量中起着很大的作用(如果你还没有所有的代码,也无法做任何事情!),但它不是唯一的因素。
此外,WebAssembly 的二进制格式经过优化,可以进行非常快速的解析和处理。
浏览器现在拥有 ”baseline 编译器”,它解析网络上 WebAssembly 并以尽可能快的速度启用编译代码。
这意味着如果你正在使用 instantiateStreaming
,那么第二个 Web 请求完成后,WebAssembly 模块可能已准备就绪。
另一方面,JavaScript 通常就需要更长时间才能解析,但也可以通过 JIT 编译等方式加快速度。
最后,记住 WebAssembly 在执行速度方面也比 JavaScript 优化得多。 你需要确保测量 JavaScript和WebAssembly之间的运行时比较,以将其考虑到代码大小的重要性。
如果你的 .wasm
文件比预期的大,不用伤心!
代码大小最终可能只是端到端过程中许多因素中的一个。
只看代码大小的 JavaScript 和 WebAssembly 之间像是的比较缺少树的林。
优化代码大小的构建
我们可以使用一系列配置选项让 rustc
生成更小的 .wasm
二进制文件。
在某些情况下,我们用更长的编译时间换取更小的 .wasm
大小。
在其他情况下,我们用 WebAssembly 的运行时速度换取较小的代码大小。
我们应该意识到每个选项的权衡,在这种情况下,我们用运行时速度来换取代码大小、概要文件和度量,从而做出明智的决定,判断这种权衡是否值得。
使用链接时优化编译 (LTO)
在 Cargo.toml
中,在 [profile.release]
部分添加 lto=true
:
[profile.release]
lto = true
这为 LLVM 提供了更多内联和删减函数的机会。
它不仅会使 .wasm
更小,而且会使它在运行时更快!
缺点是编译需要更长的时间。
告诉 LLVM 优化大小而不是速度
默认情况下,LLVM 的优化过程被调优为提高速度,而不是提高大小。
我们可以将 Cargo.toml
中的 [profile.release]
部分修改为:
[profile.release]
opt-level = 's'
或者,为了更积极地优化尺寸,以进一步的潜在速度成本:
[profile.release]
opt-level = 'z'
请注意,令人惊讶的是 opt-level = "s"
,有时会导致比 opt-level = "z"
更小。
总是需要对比!
使用 wasm-opt
工具
Binaryen 工具箱是 WebAssembly 特定编译器工具的集合。
它比 LLVM 的 WebAssembly 后端更进一步,使用它的 wasm-opt
工具对 LLVM 生成的 .wasm
二进制文件进行后期处理通常可以在代码大小上再节省 15-20%。
它通常会同时产生运行时加速!
# Optimize for size.
wasm-opt -Os -o output.wasm input.wasm
# Optimize aggressively for size.
wasm-opt -Oz -o output.wasm input.wasm
# Optimize for speed.
wasm-opt -O -o output.wasm input.wasm
# Optimize aggressively for speed.
wasm-opt -O3 -o output.wasm input.wasm
关于调试信息的注释
wasm
二进制文件大小最大的来源之一是调试信息和 wasm
二进制文件的 names
部分。
但是,wasm-pack
工具默认会删除调试信息。
另外,除非还指定了 -g
,否则 wasm-opt
默认情况下会删除 names
部分。
这意味着,如果按照上述步骤操作,默认情况下,wasm 二进制文件中既没有调试信息,也没有names部分。 但是,如果你手动在 wasm 二进制文件中保存此调试信息,请务必注意这一点!
体积分析
如果调整构建配置以优化代码体积并没有导致一个足够小的 .wasm
二进制文件,那么现在就应该进行一些分析,看看剩余的代码大小是从哪里来的。
⚡ 就像我们让时间分析来指导我们的加速工作一样,我们希望让大小分析来指导我们的代码大小缩减工作。 如果做不到这一点,你就有浪费自己时间的危险!
twiggy
代码体积分析器
twiggy
是一个代码体积分析器,支持 WebAssembly 作为输入。
它分析二进制的调用图来回答如下问题:
- 为什么这个函数首先包含在二进制文件中?
- 此功能的保留大小是多少?也就是说,如果我删除它,会节省多少空间,以及删除后所有变成死代码的函数?
$ twiggy top -n 20 pkg/wasm_game_of_life_bg.wasm
Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼────────────────────────────────────────────────────────────────────────────────────────
9158 ┊ 19.65% ┊ "function names" subsection
3251 ┊ 6.98% ┊ dlmalloc::dlmalloc::Dlmalloc::malloc::h632d10c184fef6e8
2510 ┊ 5.39% ┊ <str as core::fmt::Debug>::fmt::he0d87479d1c208ea
1737 ┊ 3.73% ┊ data[0]
1574 ┊ 3.38% ┊ data[3]
1524 ┊ 3.27% ┊ core::fmt::Formatter::pad::h6825605b326ea2c5
1413 ┊ 3.03% ┊ std::panicking::rust_panic_with_hook::h1d3660f2e339513d
1200 ┊ 2.57% ┊ core::fmt::Formatter::pad_integral::h06996c5859a57ced
1131 ┊ 2.43% ┊ core::str::slice_error_fail::h6da90c14857ae01b
1051 ┊ 2.26% ┊ core::fmt::write::h03ff8c7a2f3a9605
931 ┊ 2.00% ┊ data[4]
864 ┊ 1.85% ┊ dlmalloc::dlmalloc::Dlmalloc::free::h27b781e3b06bdb05
841 ┊ 1.80% ┊ <char as core::fmt::Debug>::fmt::h07742d9f4a8c56f2
813 ┊ 1.74% ┊ __rust_realloc
708 ┊ 1.52% ┊ core::slice::memchr::memchr::h6243a1b2885fdb85
678 ┊ 1.45% ┊ <core::fmt::builders::PadAdapter<'a> as core::fmt::Write>::write_str::h96b72fb7457d3062
631 ┊ 1.35% ┊ universe_tick
631 ┊ 1.35% ┊ dlmalloc::dlmalloc::Dlmalloc::dispose_chunk::hae6c5c8634e575b8
514 ┊ 1.10% ┊ std::panicking::default_hook::{{closure}}::hfae0c204085471d5
503 ┊ 1.08% ┊ <&'a T as core::fmt::Debug>::fmt::hba207e4f7abaece6
手动检查 LLVM-IR
LLVM-IR 是 LLVM 生成 WebAssembly 之前编译器工具链中的最终中间表示形式。
因此,它与最终发出的 WebAssembly 非常相似。
LLVM-IR 越多通常意味着 .wasm
的大小就越大,如果一个函数占用了 LLVM-IR 的 25%,那么它通常会占用 .wasm
的 25%。
虽然这些数据只适用于一般情况,但 LLVM-IR 具有 .wasm
中没有的关键信息(因为 WebAssembly 缺少像 DWARF 这样的调试格式):哪些子例程内联到给定函数中。
可以使用以下命令生成 LLVM-IR:
cargo rustc --release -- --emit llvm-ir
然后,可以使用 find 在 cargo 的目标目录中找到包含 LLVM-IR 的 .ll
文件:
find target/release -type f -name '*.ll'
参考
更具侵入性的工具和技术
调整构建配置使之变小 .wasm
二进制文件非常容易操作。
然而,当你需要做更多的工作时,你已经准备好使用更具侵入性的技术,比如重写源代码以避免膨胀。
下面是一个 让你的手脏 技术的集合,你可以应用到更小的代码大小。
避免字符串格式
format!
,to_string
等…… 会带来大量代码膨胀。如果可能,只在调试模式下格式化字符串,在发布模式下使用静态字符串。
避免 Panic
这个说起来容易做起来难,但是像 twiggy
和手动检查 LLVM-IR
这样的工具可以帮助你找出哪些函数 Panic。
Panics 并不总是表现为 panic!()
调用。
它们隐式地产生于许多构造,例如:
- 一个切片在越界索引上的 panic:
my_slice[i]
- 如果除数为零,会引起
panic
:dividend / divisor
- 打开一个
Option
或者Result
:opt.unwrap()
或res.unwrap()
前两个可以与第三个相同。
索引可以替换为 my_slice.get(i)
操作。
除法可以替换为 checked_div
调用。
现在我们只有一个案例要处理。
打开 Option
或 Result
没有 panic 有两种风格:安全和不安全。
安全的方法是 abort
当遇到 None
或 Error
时,不会 panic:
#[inline]
pub fn unwrap_abort<T>(o: Option<T>) -> T {
use std::process;
match o {
Some(t) => t,
None => process::abort(),
}
}
最终,无论如何在 wasm32-unknown-unknown
的 panic 都会转化为 abort
,所以这给你相同的行为,并没有代码膨胀。
或者,unreachable
crate 提供一个不安全的 unchecked_unwrap
拓展方法 给 Option
和 Result
,它告诉 Rust 编译器假设 Option
是 Some
或 Result
是 Ok
。
如果这个假设不成立,会发生什么是未定义的行为。
你真的只想在 110% 的人都知道这个假设成立的时候使用这种不安全的方法,而编译器只是不够聪明,看不到而已。
即使你走这条路,你也应该有一个调试生成配置,它仍然执行检查,并且只在发布生成中使用未检查的操作。
避免分配或切换到 wee_alloc
Rust 的默认 WebAssembly 分配器是一个要 Rust 的 dlmalloc
端口。
它的体积大约在 10 千字节左右。
如果可以完全避免动态分配,那么应该能够减少这 10 千字节。
完全避免动态分配可能非常困难。
但是从热代码路径中删除分配通常要容易得多(而且通常有助于使这些热代码路径更快)。
在这种情况下,将默认全局分配器替换为 wee_alloc
应该可以节省这10千字节中的大部分(但不是全部)。
wee_alloc
是一个分配器,设计用于需要某种分配器,但不需要特别快的分配器的情况,它很乐意用分配速度换取较小的代码大小。
使用 Trait 对象而不是泛型类型参数
创建使用类型参数的泛型函数时,如下所示: 
fn whatever<T: MyTrait>(t: T) { }
然后,rustc
和 LLVM
将为与该函数一起使用的每个 T
类型创建一个新的函数副本。
这为编译器优化提供了许多机会,每个副本都是基于哪个特定的 T
来工作的,但是这些副本在代码大小方面加起来很快。
如果使用 trait 对象而不是类型参数,如下所示:
fn whatever(t: Box<MyTrait>) { }
// 或
fn whatever2(t: &MyTrait) { }
// 等……
然后使用通过虚拟调用的动态调度,在 .wasm
中只发出函数的一个版本。
缺点是编译器优化机会的丧失以及间接的、动态调度的函数调用所增加的成本。
使用 wasm-snip
剪工具
wasm-snip
用一个替换 WebAssembly 函数的主体的 unreachable
指令。
这是一个相当重的,钝的锤子,如果你斜视足够用力的话,它看起来就像钉子。
也许你知道某些函数永远不会在运行时被调用,但是编译器不能在编译时证明这一点?
移除它!之后带着参数 --dce
再运行 wasm-opt
,所有被剪断的函数传递调用的函数(在运行时也永远不会被调用)也将被删除。
这个工具对于消除 panic 的基础设施特别有用,因为 panic 最终会转化为陷阱。