Rust + WebAssembly 全链路解析
第9章 WASM 模块体积优化
"Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." — Antoine de Saint-Exupery
9.1 体积为什么重要:下载、解析与编译
在浏览器环境中,.wasm 文件的体积直接影响三个阶段的延迟:网络下载、流式解析和 JIT 编译。流式编译下前两步可以边下边做,但体积增大会让每一步都变慢。下面各节标了「实测」的体积数字,都是作者在 rustc 1.96.0、wasm-bindgen 0.2.118、wasm-opt 126(Binaryen version_126)下用正文给出的命令编出来的,只对那几个示例 crate 成立;要拿自己项目的数字,只能自己量(twiggy 或 wasm-opt --func-metrics,改动前后对比)。
下载阶段:.wasm 通过 HTTP 传输到浏览器。指令流本身已经是紧凑的二进制编码,但压缩照样有效:本章两个实测例子经 wasm-opt -Oz 之后,brotli(质量 11)分别压到原体积的约 41%(serde_json 例,60,789 → 25,482 字节)和 36%(pulldown-cmark 例,186,260 → 67,781 字节)。所以服务端一定要对 .wasm 开压缩,而压缩后的字节数仍随原始体积同涨同落。
解析阶段:浏览器用流式解码器边下载边解析 .wasm。V8 的 Liftoff 基线编译器速度很快,但解码加基线编译的工作量仍随代码量增长——模块越大,这段时间越长,在它完成之前模块不可用。
编译阶段:TurboFan 的优化编译在后台线程进行。模块越大、函数越多,需要分析和优化的量就越大,占用的 CPU 也越多——在低端移动设备上这部分尤其明显。
flowchart LR
subgraph "WASM 加载流水线"
A["网络下载<br/>带宽限制"] --> B["流式解码<br/>耗时随字节数增长"]
B --> C["Liftoff 基线编译<br/>可与下载重叠"]
C --> D["TurboFan 优化<br/>后台线程"]
end
subgraph "体积→延迟"
E["字节数 ↑"] --> F["下载时间 ↑"]
F --> G["解码 + 基线编译 ↑"]
G --> H["首次可调用的时刻 ↑"]
end
style A fill:#dc2626,color:#fff
style B fill:#f59e0b,color:#333
style C fill:#6366f1,color:#fff
style D fill:#10b981,color:#333
体积优化的目标不只是节省带宽——更重要的是缩短用户等待 WASM 模块可用的时间:在模块下载、编译完成之前,依赖它的功能都无法响应。
体积对移动端的影响尤为严重。移动网络的不稳定性意味着大体积 WASM 更容易因为网络波动而加载失败——如果用户在地铁里打开页面,2MB 的 .wasm 可能因为一次网络中断导致整个模块下载失败,需要从头开始。分块加载(code splitting)在 WASM 生态中还不够成熟(不像 JS 的动态 import()),所以最有效的策略就是减小单模块的体积。
另一个常被忽视的维度是内存占用。WASM 模块加载后,编译出的机器码驻留在浏览器的 WASM 引擎内存中,再加上运行时的线性内存(Rust 模块光初始内存就是 17 页、约 1.1MB,见第 3 章的实测),一个模块占掉的进程内存不止它的文件大小。在内存受限的移动设备上(如 2GB RAM 的低端 Android 手机),多个标签页共享有限的内存,大体积 WASM 模块更容易触发浏览器的 OOM(Out of Memory),导致标签页被强制终止。
9.2 体积从哪来:剖析 .wasm 的组成
优化之前先理解问题。先看一个极端例子(实测):只有一个 add(a, b) 的 wasm-bindgen crate,默认 cargo build --release --target wasm32-unknown-unknown 产出 35,437 字节;过一遍 wasm-bindgen CLI 剩 18,687 字节;再跑 wasm-opt -O 只剩 1,250 字节。中间差出来的几乎全是「没人用却还留在文件里」的东西。
按函数拆体积用 twiggy(Binaryen 侧的对应能力是 wasm-opt --func-metrics:它逐个函数列出 [binary-bytes]、逐个导出列出去掉它能省多少字节([removable-bytes-without-it]),但不排序、不给调用关系;Binaryen 里没有 --print-function-sizes 这个选项):
cargo install twiggy --version 0.7.0 --locked
twiggy top -n 10 js_bg.wasm
下面是实测输出,对象是 §9.3.5 那个 serde_json 示例 crate(opt-level="z" + lto + codegen-units=1)经 wasm-bindgen --target web 之后、wasm-opt 之前的 _bg.wasm:
Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼──────────────────────────────────────────────────────────────────────────────────────────────────
14457 ┊ 18.23% ┊ "function names" subsection
12545 ┊ 15.82% ┊ data[0]
4579 ┊ 5.77% ┊ <dlmalloc[e7770d3bef70d9dc]::dlmalloc::Dlmalloc<dlmalloc[e7770d3bef70d9dc]::sys::System>>::malloc
2522 ┊ 3.18% ┊ serde_json::de::from_str::h13fa299d0bc9d39f
2134 ┊ 2.69% ┊ normalize
2085 ┊ 2.63% ┊ <serde_json::error::Error as serde_core::de::Error>::custom::h3bac1fdf5eb871b1
1989 ┊ 2.51% ┊ <core::marker::PhantomData<T> as serde_core::de::DeserializeSeed>::deserialize::ha45c8f5ac63cc510
1561 ┊ 1.97% ┊ <char>::escape_debug_ext
1147 ┊ 1.45% ┊ core[2c5ad5f686fcca5c]::str::slice_error_fail_rt
1110 ┊ 1.40% ┊ serde_core::de::MapAccess::next_value::h7bfe46878cec85ce
35162 ┊ 44.35% ┊ ... and 261 more.
79291 ┊ 100.00% ┊ Σ [271 Total Rows]
关键在于比例而不是绝对值:排第一的是名称段(wasm-opt 默认输出不带它,见 §9.5.3),紧跟着是静态数据、dlmalloc 的 malloc,以及 panic/格式化路径上的 escape_debug_ext、slice_error_fail_rt;真正的业务函数 normalize 只占 2.7%。小项目里用户代码往往只占很小一块,剩下的是标准库支持代码——panic 处理、格式化、分配器。
一个实操上的坑:本机实测 twiggy 0.7.0 读 wasm-opt 126 的输出会断言失败退出(should not parse the same key into multiple items),所以 twiggy 要喂 wasm-bindgen 的输出(wasm-opt 之前);看 wasm-opt 之后的最终产物,用上面的 wasm-opt --func-metrics。
WASM 二进制格式本身的组成也值得关注。一个 .wasm 文件由以下段(section)构成(DataCount 段是 bulk-memory 带进 Core 2.0 的,MVP 里没有;Table=4、Start=8 这里略去):
| 段 ID | 名称 | 内容 | 体积 |
|---|---|---|---|
| 1 | Type | 函数签名 | 很小 |
| 2 | Import | 导入的函数/内存/表 | 很小 |
| 3 | Function | 函数索引到类型的映射 | 很小 |
| 5 | Memory | 内存声明 | 很小 |
| 6 | Global | 全局变量 | 很小 |
| 7 | Export | 导出项 | 很小 |
| 9 | Element | 表的初始化数据 | 很小 |
| 10 | Code | 函数体(指令流) | 大头 |
| 11 | Data | 静态数据段 | 次大,视静态数据多少而定 |
| 12 | DataCount | 数据段计数 | 1 字节 |
实测(§9.3.5 的两个例子,wasm-opt -Oz 之后):serde_json 例 Code 47,434 字节、Data 12,328 字节,合计占 98%;pulldown-cmark 例 Code 111,219 字节、Data 73,912 字节,合计占 99%——后者的 Data 占到四成,大块静态表不容忽视(§9.11)。Code 和 Data 是优化的主要目标,其他段都是元数据,开销可忽略。
9.3 第一层优化:编译选项——改几行 Cargo.toml
这是最简单、效果最显著的优化层——只改 Cargo.toml,§9.3.5 的两个实测例子(经 wasm-bindgen 与 wasm-opt -O 之后)就分别减小了约 44% 和 24%。
9.3.1 panic = "abort":wasm32 上写它只是把默认值写明
[profile.release]
panic = "abort"
先把结论摆前面:在 wasm32-unknown-unknown、wasm32-wasip1/p2 这些目标上,这一行不会让产物变小——原因见本节末尾。它值得写,但不是因为体积。
在默认 unwind 的目标(x86_64-unknown-linux-gnu 之类)上,panic = "unwind" 需要展开所用的元数据和清理代码——即使代码中没有任何 catch_unwind,编译器也必须为每个可能 panic 的函数生成展开(unwind)信息。这些信息包括:每段代码的栈帧布局与寄存器保存规则(CFI,展开时据此逐帧回退)、每个调用点对应哪个 landing pad(LSDA),而 landing pad 里就是按顺序执行析构的清理代码。
WASM MVP 没有 try/catch 指令(那是异常处理提案的内容,Chrome 95+ 才支持)。C/C++ 走 Emscripten 时是用 JS 侧的 invoke/resume 机制模拟展开的——每个可能抛异常的调用被包装成 invoke,异常时跳到 landing pad,生成的代码比 abort 方案多得多。Rust 只在 wasm32-unknown-emscripten 上沿用了这条路(该目标默认 panic=unwind),其余 wasm 目标直接钉在 abort 上。
panic = "abort" 让 panic 在跑完 panic hook 之后调用 abort()(在 WASM 上就是 unreachable 指令,产生 trap),不需要展开表。附带效果:只为 panic 路径准备的清理代码(landing pad 里那些 drop)不再需要——panic 时直接终止,不必按顺序析构变量;正常返回路径上的 drop 照旧。
需要说清楚的前提:走 wasm 公共基线的目标本来就默认 panic=abort——compiler/rustc_target/src/spec/base/wasm.rs:68 里写死了 panic_strategy: PanicStrategy::Abort,上面 63 行的注释也说明「Unwinding doesn't work right now」。唯一的例外是 wasm32-unknown-emscripten:它在 compiler/rustc_target/src/spec/targets/wasm32_unknown_emscripten.rs:31 改成了 PanicStrategy::Unwind(§5.8)。对 wasm32-unknown-unknown 来说,rustup 发的 std 是按 abort 预编译的,stable 上没法把它切成 unwind 来对比(要 unwind 得 -Zbuild-std 重编 std),也就谈不上"切过去省了多少"。
那为什么还要在 Cargo.toml 里显式写一遍?两个理由:一是防止 workspace 里别处的 profile 配置把它改掉;二是让意图对读代码的人可见。至于体积,真正在 wasm32 上起作用的是后面几项——主要是 opt-level 和 LTO(codegen-units、strip 的实测增量很小,见 §9.3.5),以及代码层面砍掉 panic/格式化路径(§9.3.6、§9.4.1)。
9.3.2 opt-level = "z" 或 "s"
[profile.release]
opt-level = "z" # 最小体积
# 或
# opt-level = "s" # 平衡体积和速度(同一个 profile 里只能写一个)
| opt-level | 对应的 LLVM 级别 | 取向 |
|---|---|---|
3 |
-O3 |
最快速度,允许为速度换体积 |
2 |
-O2 |
速度优先,比 3 保守一点 |
"s" |
-Os |
在 -O2 基础上抑制会增大代码的优化;循环展开与向量化已在此关闭 |
"z" |
-Oz |
最激进的体积取向,在 -Os 之上继续压 |
"z" 和 "s" 在 rustc 里的区别是交给 LLVM 的开关不同:"s" 给每个函数打 optsize 属性、走 LLVM 的 Os 流水线;"z" 同时打 minsize 和 optsize、走 Oz 流水线(属性由 default_optimisation_attrs 添加,见 compiler/rustc_codegen_llvm/src/attributes.rs:372-388;compiler/rustc_codegen_llvm/src/back/write.rs:160 把 SizeMin 映射到 PassBuilderOptLevel::Oz)。minsize 让 LLVM 各个 pass 在「体积 vs 速度」的取舍上一律偏向体积,比如内联门槛更低。循环展开与向量化则在 "s" 上就已经关掉了,这两条都能在 rustc 里逐字查到:
compiler/rustc_codegen_llvm/src/back/write.rs:596是unroll_loops = opt_level != config::OptLevel::Size && opt_level != config::OptLevel::SizeMin;,所以-Os和-Oz都不展开循环。- 上面那两个枚举值就是 s 和 z:
compiler/rustc_session/src/config.rs:2265起把-Copt-level=s解析成OptLevel::Size、=z解析成OptLevel::SizeMin(枚举定义在同文件 93 行起,每个变体的文档注释直接标着对应的-Copt-level=取值)。 compiler/rustc_codegen_ssa/src/back/write.rs:221起的vectorize_loop只在OptLevel::More(-O2)或Aggressive(-O3)时为真,vectorize_slp更是只在Aggressive时为真;注释写着这是照抄 clang 的做法。
LLVM 的 -Os 对应 Rust 的 opt-level = "s",-Oz 对应 "z"。
"z" 通常比 "s" 再小一些(实测,均配 lto = true、codegen-units = 1,再经 wasm-bindgen 与 wasm-opt -Oz:serde_json 例 61,749 → 60,789 字节,只差 1.6%;pulldown-cmark 例 219,933 → 186,260 字节,差 15%),代价是热路径可能变慢。考虑到体积减小同时带来下载和编译的加速,对首屏加载敏感的场景净效果往往是正面的——但如果项目里有真正的计算热点,"s" 和 "z" 都要各测一遍再定。
9.3.3 LTO:跨编译单元的全局优化
[profile.release]
lto = true # fat LTO
# 或
# lto = "thin" # thin LTO,编译更快(二选一)
LTO(Link-Time Optimization)允许链接器跨编译单元做优化。Rust 的每个依赖 crate 独立编译为 .o 文件,LTO 让链接器看到所有代码后做全局优化——跨 crate 内联、死代码消除、常量传播。
Thin LTO vs Fat LTO:Thin LTO 对每个编译单元做独立分析,模块间只交换摘要信息,因此可以并行、编译时间增幅有限。Fat LTO 把所有编译单元合并成一个大的 LLVM IR 模块做完全全局优化,优化效果最好,但这一步是串行的、内存占用高,编译时间增幅显著。
| LTO 模式 | 体积 | 编译时间 | 推荐场景 |
|---|---|---|---|
false(Cargo 默认;仍会在本 crate 的各 CGU 之间做 thin local LTO) |
基准 | 基准 | 开发构建 |
"thin" |
更小 | 略增,可并行 | CI、日常发布 |
true(fat) |
最小 | 明显增加,且串行 | 正式发布构建 |
LTO 的体积收益主要来自跨 crate 死代码消除。Rust 的标准库和常用 crate(如 serde、chrono)包含大量泛型代码,每个 crate 独立编译时无法确定哪些泛型实例化是死代码——LTO 把它们放在一起后才看得清。
9.3.4 codegen-units = 1
[profile.release]
codegen-units = 1
Rust 默认把每个 crate 分成 16 个代码生成单元(codegen units)并行编译——compiler/rustc_session/src/session.rs:869 结尾就是一句 CodegenUnits::Default(16),上面那一长串注释解释了为什么选 16(开增量编译时改成 256)。多单元加快编译速度但限制了跨单元优化。codegen-units = 1 强制单单元,允许 LLVM 做更激进的全局优化:更精确的内联决策、更彻底的死代码消除、更好的常量传播。
与 LTO 的关系:codegen-units = 1 优化的是 crate 内部的代码生成质量;LTO 优化的是 crate 之间的代码。但别把「fat LTO 需要配 codegen-units = 1」当成前提——它不需要:compiler/rustc_codegen_llvm/src/back/lto.rs:152 的 run_fat 收的是 modules: Vec<FatLtoInput<...>>、返回单个 ModuleCodegen<ModuleLlvm>,本来就是把一组模块合并成一个再优化,和 CGU 分了几份无关(CGU 怎么切是 rustc 侧 rustc_session::codegen_units 加 rustc_monomorphize 分区的事,不在 LLVM 里)。写 codegen-units = 1 的真实收益是让 LTO 之前的那一轮 crate 内优化也看到全貌,代价是这一段没法并行、编译时间明显变长。详见 §5.13.4。
9.3.5 完整的 release profile
[profile.release]
panic = "abort" # wasm32 本就是默认值,写出来是防止被别处 profile 改掉
opt-level = "z" # 最小体积优化
lto = true # 跨 crate 全局优化
codegen-units = 1 # 单代码生成单元,允许全局优化
strip = "debuginfo" # 别写 true:会删掉 target_features 段,wasm-pack 里的 wasm-opt 随之验证失败(见下文)
这组配置能把产物压掉一大截。下面是逐项打开的实测结果,用了两个示例 crate(crate-type = ["cdylib"],edition 2024):
- serde_json 例:
wasm-bindgen = "=0.2.118"、serde 1.0.229(derive)、serde_json 1.0.151,导出normalize(&str) -> Result<String, JsError>和total(&str) -> Result<f64, JsError>,把一个订单 JSON 反序列化成两层结构体再处理; - pulldown-cmark 例:
pulldown-cmark 0.13.4(default-features = false, features = ["html"]),导出render(&str) -> String,Markdown 转 HTML。
每一步用环境变量打开对应配置(如 CARGO_PROFILE_RELEASE_OPT_LEVEL=z CARGO_PROFILE_RELEASE_LTO=true cargo build --release --target wasm32-unknown-unknown),然后按 wasm-pack --release 的顺序处理:wasm-bindgen --target web,再 wasm-opt -O(wasm-pack 的默认参数,§9.5.1)。表中是最后那个 _bg.wasm 的字节数;panic = "abort" 没有单独列,因为它在 wasm32 上是默认值、打开与否体积不动(§9.3.1):
| 阶段 | serde_json 例 | pulldown-cmark 例 |
|---|---|---|
默认 release(opt-level = 3) |
108,702(100%) | 245,722(100%) |
+ opt-level = "z" |
88,949(82%) | 198,170(81%) |
+ lto = true |
61,004(56%) | 187,505(76%) |
+ codegen-units = 1 |
60,889(56%) | 186,485(76%) |
+ strip = true |
60,481(56%)※ | 186,188(76%)※ |
几点从数字里能直接读出来的结论:
- 收益集中在
opt-level和 LTO;有了 LTO 之后codegen-units = 1只多省一两百字节。 strip = true在走完wasm-opt之后几乎不省:名称段wasm-opt本来就不输出(§9.5.3),剩下那几百字节是producers与target_features两个自定义段。它的效果只在不跑wasm-opt时才明显(serde_json 例cargo build的直接产物 101,646 → 82,415 字节)。- ※ 带
strip = true的那两格,原样照 wasm-pack 的方式只传-O是跑不通的:strip = true让 rustc 给wasm-ld传--strip-all(compiler/rustc_codegen_ssa/src/back/linker.rs:1436起WasmLd的debuginfo),连target_features段一起删掉了。这个段有两个读者,两边都会出问题:wasm-bindgen CLI 靠它判断能否走 externref 路径,段没了就退回heap数组那套簿记(实测 serde_json 例的 JS 胶水从 7,400 字节变成 8,555 字节,多出addHeapObject等函数,§6.6);wasm-opt靠它得知模块用了哪些提案特性(Binaryensrc/wasm/wasm-binary.cpp:2083遇到这个段就调readFeatures)。实测wasm-opt -O直接报验证错误memory.copy operations require bulk memory operations [--enable-bulk-memory-opt];表中数字是补上--enable-bulk-memory --enable-nontrapping-float-to-int --enable-sign-ext --enable-mutable-globals --enable-multivalue --enable-reference-types之后得到的。于是wasm-pack build --release在 wasm-opt 这一步报错退出。结论:用 wasm-pack 时别开strip = true;要写就写strip = "debuginfo",它只让wasm-ld做--strip-debug,名称段和target_features都保留(实测如此),而名称段反正会被随后的wasm-opt丢掉(§9.5.3)。
9.3.6 panic 路径本身的体积:build-std + immediate-abort
panic = "abort" 只是不展开,panic 发生时仍然走完整的 std 流程:构造 PanicInfo、调 panic hook、把消息格式化出来,然后才 abort。于是只要代码里还有一处可能 panic 的地方(一次切片边界检查、一个 unwrap),core::fmt 和装 panic 负载用的分配器就会一起被链进来。rustup 发的 std 是预编译的,这一截在 stable 上拿不掉。
1.96.0 里能把它拿掉的是一个 unstable 的 panic 策略 immediate-abort:panic 处直接 trap,不格式化、不调 hook。library/core/src/panicking.rs:35-41 里旧的 panic_immediate_abort feature 如今只剩一句 compile_error!,开头就是 "panic_immediate_abort is now a real panic strategy!",接着说明要在 Cargo.toml 里写 panic = "immediate-abort",或者用编译器参数 -Zunstable-options -Cpanic=immediate-abort,且两种写法都仍需用 -Zbuild-std 之类的办法重编 core;compiler/rustc_session/src/config.rs:2649 也要求它必须配 -Zunstable-options。所以老教程里的 -Zbuild-std-features=panic_immediate_abort 在 1.96 上会直接编译失败。实测写法(1.96.0 已装 rust-src 组件,靠 RUSTC_BOOTSTRAP=1 在 stable 编译器上打开 unstable 选项;正式项目请用 nightly):
RUSTC_BOOTSTRAP=1 RUSTFLAGS="-Zunstable-options -Cpanic=immediate-abort" \
cargo build --release --target wasm32-unknown-unknown -Zbuild-std=std,panic_abort
实测效果(opt-level="z" + lto + codegen-units=1,再经 wasm-bindgen 与 wasm-opt -Oz):serde_json 例 60,789 → 48,521 字节,pulldown-cmark 例 186,260 → 149,062 字节,都在 20% 左右;serde_json 例在 Node 里实跑,正常输入与错误输入(JsError 抛出的错误信息)都与原来一致——因为 Result 走的是正常返回值,受影响的只是真正 panic 时的行为:没有 panic 消息,只剩一个 unreachable trap。§9.4.1 里还有一个更极端的例子。
9.4 第二层优化:代码层面——消除不必要的依赖
编译选项解决的是"编译器能看到的冗余",但编译器无法消除你主动引入的依赖。这一层的优化需要理解 Rust 的编译模型:每个 use、每个 trait derive、每个泛型实例化都可能拉入新的代码。
9.4.1 避免 String 格式化
format!() / println!() 是体积杀手——它们拉入整个 core::fmt:不只是 format! 本身,还有 Display、Debug、LowerExp 等一整套 trait 实现,以及格式化规范解析器({:.3?} 这种)。在只有几 KB 代码的小模块里,这一块经常是 twiggy top 的头几名。
// 拉入 core::fmt 的格式化机器,外加 String 的分配
let msg = format!("Error: code {}", code);
// 用静态字符串——零额外体积
let msg = if code == 0 { "OK" } else { "Error" };
// 用 itoa crate(不经过 core::fmt)
let mut buf = itoa::Buffer::new();
let msg = buf.format(code);
itoa 把整数转字符串不需要格式化框架——它直接按位写入 buffer。ryu 对浮点数同理。但真正的前提是:你的代码路径里没有别的地方再把 core::fmt 拖进来——而最常见的「别的地方」恰恰是 panic。实测一个很说明问题的对照(无 wasm-bindgen 的 cdylib,把 "Error: code " 加上 -42 写进一块 64 字节的静态缓冲区、不用堆,opt-level="z" + lto + codegen-units=1 后跑 wasm-opt -Oz,itoa 1.0.18):
| 写法 | .wasm 字节数 |
|---|---|
write!(w, "Error: code {}", code)(w 实现 core::fmt::Write,写入时不会 panic) |
2,379 |
itoa:w.write_str(buf.format(code)) |
13,378 |
itoa,但用 -Cpanic=immediate-abort + -Zbuild-std 构建(§9.3.6) |
947 |
itoa 版反而大了 5 倍多。twiggy paths 查出原因:describe 里留下了一个 core::panicking::panic_bounds_check(数字查表的边界检查没被消掉),而 std 的 panic 路径会格式化 panic 消息、把负载装进 Box——于是 core::fmt::write、<usize as Display>::fmt 和整个 dlmalloc 一起进来了。换成 immediate-abort,panic 路径只剩一条 unreachable,itoa 版才显出它本来的体积。结论:想靠去掉 format! 省掉 core::fmt,得同时确认没有任何残留的 panic 路径(对 wasm-bindgen 输出或 cargo build 的直接产物跑 twiggy paths <文件> 'panicking::panic_fmt' --regex,一查便知谁在引用它),否则省不下来。
9.4.2 选择轻量级分配器
dlmalloc 是 Rust 标准库在 wasm32-unknown-unknown 上的默认分配器(library/std/src/sys/alloc/wasm.rs,1.96.0 锁定的是 dlmalloc 0.2.11),功能完整,体积是几种选择里最大的。替代方案(talc 5.1 的写法,与 §5.6 一致):
// 换一个为 wasm 设计的小分配器(talc 5.1.1)
#[global_allocator]
static ALLOC: talc::wasm::WasmDynamicTalc = talc::wasm::new_wasm_dynamic_allocator();
实测各分配器的体积贡献:一个不用 wasm-bindgen 的最小 cdylib,只导出 add 和两个直接调 std::alloc::alloc / dealloc 的函数,opt-level="z" + lto + codegen-units=1,再 wasm-opt -Oz:
| 分配器 | 实测 .wasm 字节数 |
说明 |
|---|---|---|
dlmalloc(默认) |
11,834 | 功能完整、通用;其中 dlmalloc 自身的函数约 7.1KB(twiggy 在 wasm-opt 前统计的 shallow 合计),其余是随 std 分配路径一起进来的 panic/格式化代码 |
talc 5.1.1(WasmDynamicTalc) |
1,536 | 为 wasm/嵌入式设计的小分配器,仍在维护 |
lol_alloc 0.4.1(AssumeSingleThreaded<FreeListAllocator>) |
849 | 提供从「只泄漏不回收」到带空闲链表的多档实现,按需选 |
wee_alloc |
— | 不要在新项目里用:仓库已归档、停止维护,且有未修复的内存泄漏问题 |
| 不分配(同一 crate 去掉两个分配函数) | 331 | 纯计算、不需要堆 |
真实项目能省多少取决于堆操作的比重,以及 panic/格式化代码是否本来就在(在的话,换分配器省下的只是 dlmalloc 自身那部分)——换完用 twiggy 对比前后产物。
如果 Rust 代码本身不需要堆分配(纯计算函数),可以走 #![no_std],连 std 和它的 panic 流程一起去掉。注意三件事:wasm-bindgen 要关掉默认的 std feature(default-features = false);no_std 的 cdylib 必须自己提供 #[panic_handler];而 wasm-bindgen 自己依赖 alloc,所以哪怕你的函数一个字节都不分配,也得有一个 #[global_allocator]——不写的话 1.96.0 实测报 no global memory allocator found but one is required。能编过的最小写法:
#![no_std]
use wasm_bindgen::prelude::*;
// wasm-bindgen 依赖 alloc,no_std 下必须自带分配器(这里用 talc 5.1.1)
#[global_allocator]
static ALLOC: talc::wasm::WasmDynamicTalc = talc::wasm::new_wasm_dynamic_allocator();
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
#[wasm_bindgen]
pub fn pure_compute(input: i32) -> i32 {
input * 2 + 1 // 纯计算,零堆分配
}
#![no_std] 排除整个标准库,只保留 core(加上 alloc)——同样结构的 add 函数实测经 wasm-bindgen 与 wasm-opt -Oz 后只有 448 字节(§9.12)。代价是能用的类型变少:String、Vec 要从 alloc 里取,HashMap 这类 std 独有的没有,I/O、线程之类的 std 能力也都没了。
9.4.3 减少泛型单态化
Rust 的泛型通过单态化(monomorphization)实现——每个具体类型参数组合生成一份代码。一个泛型类型的 3 个方法被 5 种类型参数用到,就是 15 个函数实例。在 WASM 中,这些实例(没被内联掉、也没被合并掉的那些)的体积直接累加。
// 被 i8/i16/i32/u8/u16 等 5 种类型调用,就生成 5 份实例
fn process<T: Into<i64>>(input: T) -> i64 { input.into() }
// 用具体类型——只有 2 个函数实例
fn process_i32(input: i32) -> i64 { input as i64 }
fn process_i64(input: i64) -> i64 { input }
更重要的泛型陷阱是 serde 的序列化——serde_json 对每种组合类型生成独立的序列化/反序列化代码,字段里嵌套的泛型容器越多,实例越多。§9.2 的 twiggy 输出里,光 serde_json::de::from_str 这一棵支配子树就保留了约 21KB(§9.6.3),而那个例子只有两个小结构体。
9.4.4 避免不必要的 trait 实现
Rust 的 #[derive(Debug)] 会为类型生成一个 fmt::Debug 实现,里面逐个格式化字段——一旦被用到,就拉入 core::fmt。注意「被用到」:没人调用的 impl 会被链接器的 DCE(§9.7)删掉,derive 本身不花字节;花字节的是用到它的地方——{:?}、dbg!,以及 Result<T, E> 上的 unwrap()/expect()(它们要求 E: Debug,失败分支会把 E 格式化进 panic 消息)。
// 只要某处 {:?} 或 unwrap() 用到了它,core::fmt 就进来了
#[derive(Debug)]
pub struct Config { value: i32 }
// 不 derive Debug——也就没有人能用到它
pub struct Config { value: i32 }
类似地,Clone、PartialEq 用到了也都有各自的代码量(Display 不能 derive,要自己实现,但同样会拉进 core::fmt)。在 WASM 场景中,只 derive 真正需要的 trait。特别提醒:任何 panic 路径——切片越界、unwrap 的失败分支——都会把 core::fmt 拖进来(§9.4.1 的实测),#[derive(Debug)] 只是最显眼的那个入口。
9.5 第三层优化:二进制级别——wasm-opt
wasm-opt 在 .wasm 的二进制表示上做优化——这是编译器看不到的层面。wasm-opt 是 Binaryen 工具包的核心组件,它直接操作 WASM 的抽象语法树,做编译器后端无法完成的优化。
9.5.1 优化级别:-Oz vs -O vs -O4
# 最小体积优化
wasm-opt -Oz input.wasm -o output.wasm
# wasm-pack 的默认档:-O,等同 -Os,已经偏向体积
wasm-opt -O input.wasm -o output.wasm
# 最快速度优化
wasm-opt -O4 input.wasm -o output.wasm
先纠正一个到处流传的说法:wasm-opt 的 -O 不等于 -O2,它等于 -Os。Binaryen 的帮助文本就是这么写的——"execute default optimization passes (equivalent to -Os)"——而且源码里跟了一条 static_assert 专门守着这句话别写飘(Binaryen 126 的 src/tools/optimization-options.h)。落到参数上:Binaryen 用「优化级别 optimizeLevel」和「体积级别 shrinkLevel」两个旋钮,-O 与 -Os 都是 (2, 1),-O2 是 (2, 0),-Oz 是 (2, 2)。
| 级别 | (optimizeLevel, shrinkLevel) | 取向 |
|---|---|---|
-Oz |
(2, 2) | 最小体积;wasm-pack 想再省一点就显式配这个 |
-O = -Os |
(2, 1) | 偏体积——这就是 wasm-pack 的默认(src/manifest/mod.rs:416 的 Enabled(true) => Some(vec!["-O".to_string()])) |
-O2 |
(2, 0) | 同样的优化级别,但不为体积让步 |
-O3 |
(3, 0) | 速度优先,"spends potentially a lot of time optimizing" |
-O4 |
(4, 0) | 在 -O3 之上还会 flatten IR,耗时和内存都明显更高 |
所以 wasm-pack --release 的默认已经是偏体积的档位了,不是很多文章说的「平衡档」;从默认换到 -Oz 是从 shrinkLevel 1 走到 2,还有得压,但没有「从平衡换到体积」那么大的想象空间。实测(§9.3.5 的两个例子,opt-level="z" + lto + codegen-units=1):-O → -Oz 只从 60,889 降到 60,789 字节、从 186,485 降到 186,260 字节,差距都在 0.2% 以内。想在 wasm-pack 里换档,写在 Cargo.toml 里(键名以 wasm-pack 0.14 的 docs/src/cargo-toml-configuration.md 为准):
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz"]
想榨到极限还可以加 --converge(Binaryen 的说明是 "Run passes to convergence, continuing while binary size decreases")——反复跑直到体积不再下降,代价是构建变慢。
默认优化流水线里和体积关系最大的几个 pass(Binaryen 126 src/passes/pass.cpp 的 addDefaultGlobalOptimizationPrePasses / addDefaultFunctionOptimizationPasses / addDefaultGlobalOptimizationPostPasses;大多数 -O 与 -Oz 都跑,只在某些级别才跑的另行标出):
-
重复函数消除(
duplicate-function-elimination):两个函数如果指令序列完全相同(可能名字不同),合并为一个。这在 Rust 的单态化场景中很常见——不同类型的Clone::clone可能生成相同的代码(例如Vec<u32>和Vec<i32>的 clone 逻辑相同,只是类型签名不同)。它在任何优化级别都会跑;-Oz额外还会跑merge-similar-functions(合并「相似但不完全相同」的函数),这一条只在 shrinkLevel ≥ 2 时启用(src/passes/pass.cpp:814-816),是-Oz相对-O/-Os的实打实增量之一(另一个是precompute-propagate取代precompute)。 -
局部变量简化(
simplify-locals):把local.set直接下沉到唯一的local.get处,消掉「存进局部变量再取回」的往返。LLVM 的 wasm 后端分配局部变量时(它不跑通用寄存器分配器,由 RegStackify/RegColoring 完成,§5.3)难免留下冗余——wasm-opt 在二进制层面清理这些冗余。 -
代码折叠(
code-folding,描述是 "fold code, merging duplicates"):多个分支如果末尾有相同的指令序列,合并成一份,用跳转指向。这就是编译器里的 tail merging,作用范围在函数内部;跨函数的重复由上面的duplicate-function-elimination处理。它只在 optimizeLevel ≥ 3 或 shrinkLevel ≥ 1 时跑(src/passes/pass.cpp:718-720),所以-O/-Os/-Oz有、-O2没有。 -
死代码消除:分两层。
dce("removes unreachable code")清理的是函数体内不可达的代码;删掉整个没人用的函数、全局、表项的是remove-unused-module-elements——它从导出、start 函数等根出发标记可达的模块元素,其余一律删除。前者在函数内工作,后者在整个模块内工作——可以消除链接器和 LTO 遗漏的死代码。 -
数据段紧凑化(
memory-packing,Binaryen 对它的描述是 "packs memory into separate segments, skipping zeros"):把静态数据重新切段并跳过全零区域。Rust 的静态初始化数据里常有大片零,打包后能省掉这部分字节。(注意merge-blocks是另一回事——它合并 IR 里的 block,和数据段无关。)
9.5.2 启用提案指令
# 模块里没有 target_features 段时(比如开了 strip = true),手动告诉 wasm-opt 用了哪些提案
wasm-opt -Oz --enable-bulk-memory --enable-nontrapping-float-to-int \
--enable-sign-ext --enable-mutable-globals --enable-multivalue \
--enable-reference-types input.wasm -o output.wasm
--enable-* 这组开关的作用是声明模块允许用哪些提案特性,让 wasm-opt 的验证器放行、让各个 pass 知道能用哪些指令——它们本身不会让产物变小。正常情况下根本不用写:rustc 产出的 .wasm 带一个 target_features 自定义段,wasm-opt 读二进制时会据此自动打开对应特性(Binaryen src/wasm/wasm-binary.cpp:2083 遇到这个段就调 readFeatures),wasm-bindgen 也会把这个段保留下来。只有这个段丢了(最常见的原因就是 strip = true,§9.3.5)才需要手写,否则 rustc 默认发出的 memory.copy 等指令会让验证直接失败。
几个常见误解顺带纠正:--enable-bulk-memory 不会把逐字节的复制循环改写成 memory.copy——Binaryen 没有这种 pass,memory.copy/memory.fill 是 LLVM 在 rustc 侧就发出来的(1.87 起 bulk-memory 进了 rustc 默认 feature 集,§9.14);--enable-simd 也不会替你向量化;而 --enable-reference-types 与 JS 胶水里有没有那套 heap 数组簿记无关——那是 wasm-bindgen CLI 读 target_features 段决定的(§6.6)。
9.5.3 名称剥离
# wasm-opt 不加 -g 时,输出里本来就不写名称段
wasm-opt -Oz input.wasm -o output.wasm
# 加了 -g(保留名称段)之后又想去掉,才用 --strip-debug
# (它的官方描述是 "strip debug info (including the names section)";Binaryen 没有 --strip-names)
wasm-opt -Oz --strip-debug input.wasm -o output.wasm
# producers 段默认会保留,要去掉得显式加
wasm-opt -Oz --strip-producers input.wasm -o output.wasm
# 不跑 wasm-opt 时,也可以让 wasm-bindgen 直接不输出这两个段
wasm-bindgen --remove-name-section --remove-producers-section --target web --out-dir pkg input.wasm
名称段对执行没有影响——它只用于调试和栈回溯。剥离后省下的是所有函数名字符串,Rust 的符号名又长又多,这一块在未剥离的产物里相当可观:§9.2 那份 twiggy 输出里 "function names" subsection 排第一,占 18%。
但要知道它默认就会被 wasm-opt 丢掉:wasm-opt 写二进制时只有 -g/--debuginfo 打开才输出名称段(这个选项的帮助文字在 Binaryen 126 src/tools/optimization-options.h:196;写出时的开关是 src/tools/wasm-opt.cpp:489 的 writer.setDebugInfo(options.passOptions.debugInfo))。实测 §9.3.5 的产物经 wasm-opt -O 后已没有 name 段,只剩 producers 和 target_features 两个自定义段。wasm-bindgen 这边则默认保留名称段和 producers 段:crates/cli-support/src/lib.rs:517 是 generate_name_section(!self.remove_name_section),下一行是对应的 generate_producers_section;wasm-pack 0.14 调 wasm-bindgen 时也不传这两个开关(src/bindgen.rs)——在 wasm-opt 会跑的构建里,这无所谓。
wasm-pack 在 --release 下默认跑 wasm-opt。要注意只有 --dev 才是真的不跑——src/manifest/mod.rs:282 的 default_dev() 把 wasm_opt 置为 None(:291),而 default_release()(:295)、default_profiling()(:308)和 default_custom()(:321)统统是 Enabled(true)(分别在 :304、:317、:330),也就是都会带上 -O。所以「用 --profiling 来保留调试信息」这个常见说法不成立:--profiling 给 cargo 传的就是 --release(src/build/mod.rs:94-100,那里保留着一行被注释掉的 cmd.env("RUSTFLAGS", "-g")),用的是 Cargo 的 release profile 而不是 [profile.profiling];它对 wasm-bindgen 的默认参数也与 release 相同,wasm-opt 照样跑 -O。想要带名称或 DWARF 的「分析用」构建,得自己在 [package.metadata.wasm-pack.profile.profiling] 里配(比如 wasm-opt = ["-O", "-g"]、wasm-bindgen.dwarf-debug-info = true),或者用 --profile <名字> 指定一个自定义的 Cargo profile。
9.5.4 strip 调试信息
除了名称,WASM 二进制还可能包含 DWARF 调试信息(.debug_info、.debug_str 等一组自定义段):
# 剥离 DWARF 调试信息
wasm-opt -Oz --strip-dwarf input.wasm -o output.wasm
DWARF 调试信息包括源码位置映射、变量类型信息等,体量可以远超代码本身:实测只有一个 add 的 wasm-bindgen crate 开 debug = true 做 release 构建,cargo build 产物 1,722,042 字节,其中 .debug_* 段约 1.69MB,而 Code 段只有 16,562 字节。默认的 release 构建不会生成它(Cargo release profile 默认 debug = false);即使生成了,wasm-bindgen 默认也会丢掉 DWARF,只有加 --keep-debug 才保留(crates/cli-support/src/lib.rs:516 的 generate_dwarf(self.keep_debug),实测如此)。要当心的是绕过 wasm-bindgen 直接跑 wasm-opt 的流程:不加 -g 时 wasm-opt 并不删这些段,实测上面那个文件经 wasm-opt -Oz 后 .debug_* 段仍有约 1.45MB,而代码已被改写、这些 DWARF 已经对不上号——这时必须显式 --strip-dwarf。
9.6 依赖审计:找出体积的真正来源
编译选项和代码层面优化之后,如果体积仍然偏大,需要做依赖审计——找出是哪个 crate 或哪个函数占了大头。
9.6.1 一个必须先澄清的事:cargo bloat 不能用于 WASM
很多中文教程会让你跑 cargo bloat --target wasm32-unknown-unknown——这是错的。cargo-bloat 只解析 ELF / Mach-O / PE 三种可执行格式,它的 README 第一屏就写得很直白:WASM is not supported. Use twiggy instead.(那些带 .text / .data 段的示例输出是原生二进制的形状,.wasm 里根本没有这两个段)。
cargo bloat 在 WASM 项目里唯一还有用的场景,是把同一份代码编到原生目标上看看哪些 crate 占体积大头,作为粗略的方向参考——但最终产物的体积必须用 twiggy 量。
9.6.2 cargo tree
cargo tree 展示依赖关系——帮你理解为什么某个 crate 被引入:
# 查看完整依赖树
cargo tree --target wasm32-unknown-unknown
# 反向依赖:谁依赖了某个 crate
cargo tree --target wasm32-unknown-unknown -i serde
# 查看被多个 crate 共享的依赖
cargo tree --target wasm32-unknown-unknown --duplicates
一个常见的体积陷阱是:你只 use 了一个函数,但这个函数的 crate 依赖了其他大 crate。例如,chrono(0.4.45)依赖 num-traits,默认 feature 里还带着 clock、wasmbind(后者在 wasm32-unknown-unknown 上拉进 js-sys/wasm-bindgen,Utc::now() 靠它走 js_sys::Date);如果你只需要一个时间戳,js_sys::Date::now() 就够了。用 cargo tree -i num-traits 这类反查可以确认某个依赖是被谁拉进来的。(依赖进了依赖树不等于代码进了 .wasm——没被调用的部分会被 DCE 掉;最终还是以 twiggy 的结果为准。)
9.6.3 twiggy:WASM 体积分析的正解
twiggy 是 Rust WASM 生态的专用体积分析工具——它构建调用图,标注每个函数和数据的体积占比,还能追踪"谁拉入了这个函数":
cargo install twiggy --version 0.7.0 --locked
twiggy top pkg/my_lib_bg.wasm
twiggy top 的真实输出见 §9.2。twiggy dominators 找到体积膨胀的根因——不是"哪个函数最大",而是"哪个函数的存在导致了最多其他代码被引入"。对同一个 serde_json 例子的实测输出(-r 14 限制行数):
twiggy dominators -r 14 js_bg.wasm
Retained Bytes │ Retained % │ Dominator Tree
────────────────┼────────────┼────────────────────
20923 ┊ 26.39% ┊ serde_json::de::from_str::h13fa299d0bc9d39f
3198 ┊ 4.03% ┊ ⤷ <serde_json::error::Error as serde_core::de::Error>::custom::h3bac1fdf5eb871b1
596 ┊ 0.75% ┊ ⤷ core::str::pattern::TwoWaySearcher::next_back::h07b946d257a807a8
10 ┊ 0.01% ┊ ⤷ type[17]: (i32, i32, i32, i32, i32, i32, i32) -> nil
303 ┊ 0.38% ┊ ⤷ <usize as core[2c5ad5f686fcca5c]::str::traits::FromStr>::from_str
132 ┊ 0.17% ┊ ⤷ <alloc::string::String as core::ops::index::Index<I>>::index::h789b4c7ac4163d0a
82 ┊ 0.10% ┊ ⤷ <alloc::string::String as core::ops::index::Index<I>>::index::hd3954055918a6a90
2937 ┊ 3.70% ┊ ⤷ <core::marker::PhantomData<T> as serde_core::de::DeserializeSeed>::deserialize::ha45c8f5ac63cc510
485 ┊ 0.61% ┊ ⤷ <core::marker::PhantomData<T> as serde_core::de::DeserializeSeed>::deserialize::hbf7716b817ca7ced
391 ┊ 0.49% ┊ ⤷ <core::marker::PhantomData<T> as serde_core::de::DeserializeSeed>::deserialize::h75e5c4d5a72109c0
72 ┊ 0.09% ┊ ⤷ alloc::raw_vec::RawVec<T,A>::grow_one::he6c85a19893f371b
2328 ┊ 2.94% ┊ ⤷ serde_json::de::Deserializer<R>::parse_integer::hddb75450e47a5e56
658 ┊ 0.83% ┊ ⤷ serde_json::de::Deserializer<R>::parse_exponent::hce48240945f66486
183 ┊ 0.23% ┊ ⤷ serde_json::de::Deserializer<R>::parse_exponent_overflow::hc4da470c299382d4
12857 ┊ 16.21% ┊ [6 Unreachable Items]
"Retained"表示"如果删除这个函数,同时可以删除的所有依赖代码的总体积"——也就是只能经由它才够得着的那些代码。serde_json::de::from_str 的 retained 是 20,923 字节,占 26%:只要不再调用它,JSON 解析器连同它独占的错误处理、整数/指数解析都能一起消失。这是定位「砍掉哪条调用路径最划算」最有效的指标。
9.6.4 per-dependency 体积审计方法
一个系统化的审计流程:
flowchart TD
A["twiggy top / dominators<br/>看各项体积占比"] --> B{"有单项占比过高?"}
B -->|是| C["cargo tree -i <crate><br/>查看谁引入了这个 crate"]
B -->|否| D["twiggy dominators<br/>找 retained 最大的函数"]
C --> E{"可以直接去掉这个依赖?"}
E -->|是| F["移除依赖<br/>替换为轻量替代"]
E -->|否| G["启用该 crate 的 feature<br/>禁用不需要的 feature"]
D --> H["重构代码<br/>消除引入该函数的路径"]
F --> I["重新测量体积"]
G --> I
H --> I
I --> J{"体积达标?"}
J -->|是| K["完成"]
J -->|否| A
特别需要注意的"体积刺客" crate:serde_json、regex(Unicode 表和 DFA 引擎是大头)、chrono、reqwest。如果只用到很小一部分功能,考虑更轻量的替代——serde_json 换 serde_json_core(no_std),regex 换 regex-lite(砍掉了 Unicode 表和 DFA 优化,是官方维护的精简版)。注意别换成 fancy-regex:它是在 regex 之上再加回溯引擎,只会更大。具体省多少一律用 twiggy diff 量。
9.7 Tree Shaking:WASM 中的死代码消除
"Tree shaking"一词来自 JS 生态(Rollup/Webpack 的术语),核心思想是:从入口函数出发,只保留可达的代码,删除所有不可达的代码。WASM 的 tree shaking 分为三个层次。
9.7.1 链接时消除(Linker-level DCE)
WASM 的链接语义天然支持 DCE:一个 .wasm 模块的入口是导出函数(exported functions),链接器从导出函数出发遍历调用图,不可达的函数不编入最终模块。
Rust 的链接器在 wasm32-unknown-unknown 目标上默认做符号(函数)级 DCE。对 cdylib 来说,根只有真正导出的符号——#[unsafe(no_mangle)] extern "C" 函数和 #[wasm_bindgen] 生成的导出;普通的 pub fn 并不会因为写了 pub 就进导出表,没人调用照样会被删掉。粒度的限制在另一头:只要一个函数可能被调用(哪怕只在永远走不到的分支里),它和它引用的一切都得留下。
9.7.2 LTO 级别的消除
LTO 把所有 crate 的 IR 合并后,可以做更精确的 DCE——跨 crate 看到调用全貌,能消除单个 crate 内部无法判断的死代码。例如,crate A 导出了 pub fn helper(),crate B 没有调用它——LTO 能看到 B 没有用 A 的 helper,把它删掉。
9.7.3 wasm-opt 级别的消除
wasm-opt 的 DCE 在二进制层面做——它不关心源码的 pub/private 语义,只看 WASM 模块内的实际调用图。从所有导出函数出发,标记可达的函数、表项、数据段,删除不可达的一切。这比 LTO 的 DCE 更激进——因为它看到了最终链接后的完整模块。
三个层次的消除是串联的:链接器 DCE(rustc 只要没开 -C link-dead-code 就会给 wasm-ld 加上 --gc-sections,见 §5.9)去掉明显不可达的符号,LTO DCE 去掉跨 crate 的死代码,wasm-opt DCE 去掉二进制层面的残留。三层都开比只开任何一层都更彻底——但它们清理的是不同来源的垃圾,收益不叠加成固定倍数。
值得注意的是,WASM 的 tree shaking 和 JS 生态的 tree shaking 有本质差异。JS 的 tree shaking(如 Rollup/Webpack)依赖 ES Module 的静态 import/export 语义——编译器可以静态分析哪些导出被使用。WASM 的导出是模块级别的——一个 #[wasm_bindgen] 标注的 pub fn 在 WASM 模块中是一个导出函数,是上面三层 DCE 共同的根:模块自己看不到 JS 侧到底调没调它,链接器、LTO、wasm-opt 都不会删它,它引用的代码也就全部留下。JS 打包器即使把胶水里没用到的包装函数 tree-shake 掉,.wasm 里的导出也还在。所以要让一个导出函数真正从 .wasm 里消失,只能在 Rust 侧不导出它(例如用 feature 控制,§9.10.2 模式二)。
另一个常见误区是认为 #[cfg(feature = "...")] 可以实现 tree shaking。Feature gate 只影响编译时是否包含代码——如果你的 crate 依赖了一个启用了全部 feature 的中间 crate,那些 feature 对应的代码照样会被编译,其中被调用到的部分(包括被导出、被初始化代码引用的)就会留在 .wasm 里。要真正实现按需裁剪,需要在整个依赖链上统一控制 feature——cargo tree --duplicates 和 cargo tree -i <crate> 用来查依赖是谁拉进来的,最终效果再用 twiggy diff 验证。
9.8 真实项目的优化数据
把前面各层手段按顺序叠在同一个项目上,看各自贡献多少。对象是 §9.3.5 的 pulldown-cmark 例(Markdown 转 HTML,编译到浏览器),全部实测;「最终产物」一列是按 wasm-pack 的顺序跑完 wasm-bindgen --target web 与 wasm-opt -Oz 之后的 _bg.wasm。这组数字只对这个 crate 成立,别的项目各步的比例会不同:
| 优化步骤 | cargo build 直接产物 |
最终产物 | 相对基线 |
|---|---|---|---|
默认 release(opt-level = 3;wasm32 本就 panic=abort) |
316,795 | 244,705 | 100% |
+ opt-level = "z" |
292,050 | 197,964 | 80.9% |
+ lto = true |
249,938 | 187,287 | 76.5% |
+ codegen-units = 1 |
248,353 | 186,260 | 76.1% |
+ 换 talc 5.1.1 分配器 |
240,918 | 179,569 | 73.4% |
+ -Cpanic=immediate-abort + -Zbuild-std(§9.3.6) |
198,784 | 145,177 | 59.3% |
| 上一行再 brotli(质量 11) | — | 55,129 | 22.5%(传输体积) |
strip = true 没有列进来:走完 wasm-opt 之后它只省几百字节,而且会让 wasm-pack 默认的 wasm-opt -O 验证失败(§9.3.5)。这个 crate 自己的代码里没有 format!,所以「去掉 format!」这一步也无从谈起;残留的 core::fmt 全来自 panic 路径,是 immediate-abort 那一步拿掉的。改后的产物在 Node 里实跑,渲染结果正常。
读这张表:opt-level = "z" 是最大的一步;LTO 次之;codegen-units = 1 在已开 LTO 时几乎没有增量;换分配器省下的约 6.7KB 大致就是 dlmalloc 本身的体积(§9.4.2);immediate-abort 又砍掉 19%,但它要 nightly(或 RUSTC_BOOTSTRAP)和 -Zbuild-std,且 panic 时不再有任何消息。最后 brotli 让传输体积再降到最终产物的三分之一多一点——静态数据多的模块压缩得更狠。
graph LR
subgraph "优化流水线(pulldown-cmark 例,最终产物字节数)"
A["244,705<br/>默认 release"] --> C["197,964<br/>+ opt-level=z"]
C --> D["187,287<br/>+ LTO"]
D --> E["186,260<br/>+ codegen-units=1"]
E --> F["179,569<br/>+ talc"]
F --> G["145,177<br/>+ immediate-abort"]
G --> H["55,129<br/>brotli 压缩"]
end
style A fill:#dc2626,color:#fff
style H fill:#10b981,color:#333
9.9 优化决策流程
flowchart TD
A["测量当前 .wasm 体积"] --> B{"> 100KB?"}
B -->|否| C["可能已经够小<br/>检查是否满足需求"]
B -->|是| D["应用编译选项<br/>panic=abort + opt-level=z + LTO"]
D --> E{"体积减半后仍 > 50KB?"}
E -->|否| C
E -->|是| F["用 twiggy / wasm-opt --func-metrics<br/>分析体积组成"]
F --> G{"大头是 std/fmt?"}
G -->|是| H["去掉 format! 与残留 panic 路径<br/>换小分配器<br/>考虑 no_std / immediate-abort"]
G -->|否| I{"大头是用户代码?"}
I -->|是| J["减少泛型单态化<br/>拆分导出函数<br/>用 wasm-opt -Oz"]
I -->|否| K["检查数据段<br/>压缩静态数据<br/>审计依赖树"]
H --> L["重新测量"]
J --> L
K --> L
L --> M{"达标?"}
M -->|是| C
M -->|否| F
核心原则:先测量,再优化。用 twiggy 找到体积大头,针对性优化,不要凭直觉猜测。
体积 vs 性能的权衡
体积优化不是免费的——较小的体积可能意味着较慢的执行。但权衡的天平在 WASM 场景中通常偏向体积:体积影响的是用户可感知的加载延迟,而多数体积手段对执行速度的影响是二阶的。下表只给方向,不给可复现的数字。
| 优化 | 体积 | 执行速度 | 原因 |
|---|---|---|---|
opt-level = "z" |
明显变小 | 可能变慢 | 在 "s" 已关闭循环展开与向量化的基础上继续压体积 |
wasm-opt -Oz |
变小 | 基本持平或略好 | 代码更紧凑,i-cache 压力更小 |
| 换小分配器 | 变小 | 分配密集场景可能变慢 | 更简单的分配算法换体积 |
panic = "abort" |
wasm32 上不变(本就是默认值) | 不变 | 只影响 panic 路径 |
| LTO | 变小 | 通常略快 | 跨 crate 内联 + 死代码消除 |
--strip-debug |
变小 | 不变 | 名称段不参与执行 |
#![no_std] |
大幅变小 | 不变(纯计算场景) | 整块去掉 std |
一个值得注意的非直觉结果:更小的体积有时反而更快。原因是 i-cache 效应——如果代码能完全放入 L1 指令缓存(32-64KB),执行速度可能比更大的"优化"版本更快。另外,浏览器的 JIT 编译器需要解码并编译整个 .wasm,更小的文件在这一步也更快(§9.1)。
9.10 代码分割与多模块架构
单体 WASM 模块在体积无法继续压缩时,下一步是把功能拆分到多个模块——按需加载,让用户不为没用的功能买单。
9.10.1 单体模块的极限
一个集成了图像处理、PDF 解析、加密算法的 WASM 模块,体积很容易到 MB 级,做完所有压缩传输体积依然可观。但用户进入页面时,往往大部分功能用不到——这部分代码在首屏完全是浪费。
代码分割的收益来源很直白:首屏只下载首屏真正会调用的那部分模块,其余按用户操作懒加载。省下多少完全等于「首屏用不到的功能」在总体积里的占比——先用 twiggy dominators 量出各功能块的 retained 体积,再决定切在哪里,不要凭感觉拆。
9.10.2 wasm-bindgen 多模块的工程模式
wasm-bindgen 默认假设单 WASM 模块。多模块的实战路径:
模式一:多 crate 多模块——每个功能编一个独立的 .wasm,JS 侧按需加载:
let imageModule = null;
async function ensureImageModule() {
if (!imageModule) {
imageModule = await import('./image_wasm/image_wasm.js');
await imageModule.default();
}
return imageModule;
}
// 用户点击"应用滤镜"时才加载
button.addEventListener('click', async () => {
const m = await ensureImageModule();
m.apply_filter(input);
});
每个模块独立编译——独立的内存、独立的导出表。模块间的数据传递必须经过 JS(先把 WASM A 的输出读到 JS,再写入 WASM B 的内存)。
模式二:feature gate + 多个构建产物——同一个 Rust crate 通过 feature 控制编译出不同体积的 .wasm:
[features]
default = []
image = ["dep:image"]
pdf = ["dep:pdf-rs"]
all = ["image", "pdf"]
构建脚本根据需要生成多个产物:
wasm-pack build --features image --out-dir pkg/image
wasm-pack build --features pdf --out-dir pkg/pdf
wasm-pack build --features all --out-dir pkg/full
JS 根据用户场景加载对应版本——比模式一简单(不需要跨模块通信),但用户切换功能时可能需要重新加载更大的版本。
9.10.3 模块间通信的开销
多模块之所以有代价,是因为模块边界的数据传递需要 JS 中转:
flowchart LR
subgraph "WASM 模块 A"
A1["内存 A"]
end
subgraph "JS"
J["读 A 内存<br/>→ 写 B 内存"]
end
subgraph "WASM 模块 B"
B1["内存 B"]
end
A1 -.export.-> J
J -.import.-> B1
style J fill:#f59e0b,color:#333
一次数据从 A 到 B 要经过:A 导出指针 → JS 在 A 的 memory.buffer 上开视图 → 在 B 里 malloc → 把字节从 A 的视图复制进 B 的视图。固定开销是几次跨边界调用,可变开销是两次按字节的复制(读出来一次、写进去一次),随数据量线性增长。单次通常可接受,高频大块传输就会成为瓶颈。
如果模块间通信频繁,最直接的办法是合并这些模块(重新评估分割是否合理)。「让两个模块导入同一块共享内存」在 WASM 层面可行,但两个各自独立编译的 Rust 模块都默认自己独占线性内存——影子栈、静态数据、__heap_base 之后的堆都从固定地址排起(§3.2),直接共用会互相踩坏,不是改个配置就能用的方案。
9.10.4 共享运行时:减少重复代码
每个 WASM 模块都带一份 wasm-bindgen 生成的 JS 胶水(实测 --target web 下:只有 add 的例子 3,800 字节,serde_json 例 7,400 字节,pulldown-cmark 例 6,523 字节,均未压缩),.wasm 里也各有一份分配器、panic 路径等运行时代码。模块越多,这些重复越多。
需要澄清一点:wasm-bindgen 并没有提供「多个 .wasm 共享一份运行时」的机制。每个模块的胶水是独立生成、独立持有自己那份 heap/externref 表和辅助函数的;reference-types 只是改变单个模块内部持有 JS 值的方式,不会让 A 模块和 B 模块共用同一张表。
能做的有限:一是把公共逻辑合并进同一个 crate、只出一个 .wasm(重新评估分割是否划算);二是所有模块统一用同一版本的 wasm-bindgen,便于统一升级和排查——每个模块的 wasm-bindgen crate 版本都必须与所用 CLI 匹配,不匹配时 CLI 会以 schema 版本不匹配报错(§6.10),CI 里应当校验。
9.11 静态数据的体积优化
WASM 的 Data 段(静态数据)经常被忽略——但它可能占到总体积的很大一块:§9.2 实测的 pulldown-cmark 例里 Data 段约占四成。优化静态数据需要不同于代码优化的手段。
9.11.1 哪些数据落入 Data 段
Rust 编译时确定的常量数据都进入 WASM 的 Data 段:
- 字面量字符串:
"hello world"等 static变量:static TABLE: [u32; 1024] = [...];- 编译时通过
include_bytes!/include_str!嵌入的资源 - 泛型代码生成的查找表(如某些哈希算法的预计算表)
- panic 信息中的源码位置字符串
#[derive(Debug)]生成的类型名、字段名字符串(该实现被用到时)
# 查看 .wasm 各段大小
wasm-objdump -h my_lib.wasm
wasm-objdump(WABT 工具集)-h 的输出是每段一行的 start/end/size,形如(数字仅作格式示意):
Sections:
Type start=0x0000000a end=0x00000087 (size=0x0000007d) count: 18
Function start=0x00000089 end=0x000000d5 (size=0x0000004c) count: 74
Code start=0x00000150 end=0x00009560 (size=0x00009410) count: 74
Data start=0x00009562 end=0x0000c6a2 (size=0x00003140) count: 2
把 Data 的 size 和 Code 的 size 一比就知道静态数据值不值得优化——上面这个例子里 Data 约是 Code 的 1/3,显然值得。
9.11.2 嵌入资源的优化
include_bytes! 嵌入的资源(图标、字体、配置文件)原样进入 Data 段。优化思路:
编译时压缩:用构建脚本预压缩资源,运行时解压。压缩在宿主机上做,可以用绑定 C 版 libzstd 的 zstd crate(放进 [build-dependencies]);解压要在 wasm 里跑,zstd crate 编到 wasm32-unknown-unknown 需要能产出 wasm 的 C 编译器,省事的做法是用纯 Rust 的解码器 ruzstd:
// build.rs([build-dependencies] zstd = "0.13")
fn main() {
let raw = std::fs::read("assets/big_icon.png").unwrap();
let compressed = zstd::encode_all(&raw[..], 22).unwrap();
std::fs::write("assets/big_icon.zst", &compressed).unwrap();
}
// lib.rs([dependencies] ruzstd = "0.8")
use std::io::Read;
use wasm_bindgen::prelude::*;
const ICON_COMPRESSED: &[u8] = include_bytes!("../assets/big_icon.zst");
#[wasm_bindgen]
pub fn get_icon() -> Vec<u8> {
let mut decoder = ruzstd::decoding::StreamingDecoder::new(ICON_COMPRESSED).expect("valid zstd frame");
let mut out = Vec::new();
decoder.read_to_end(&mut out).expect("decode");
out
}
代价是解压器本身的体积,而且不小:实测同一个嵌入资源的 wasm-bindgen crate(opt-level="z" + lto + codegen-units=1,wasm-bindgen 后 wasm-opt -Oz),加上 ruzstd 0.8.3 解码前 32,743 字节、加上之后 109,330 字节,解码器多出约 76KB(Code 多 55KB,Data 多 20KB)。再考虑到 .wasm 在 HTTP 传输时本来就会被 brotli/gzip 压一遍,预压缩只有在资源很大(远大于解码器本身)、且 zstd 对它的压缩明显好于传输层压缩时才划算;小资源直接嵌入更省。
外部加载替代嵌入:把资源放到 assets/ 目录,由 JS 加载后传给 WASM:
const iconBytes = await fetch('./assets/icon.png').then(r => r.arrayBuffer());
wasm.set_icon(new Uint8Array(iconBytes));
这把资源从 .wasm 中剥离——浏览器可以并发下载多个资源,且资源能被 CDN 独立缓存。
9.11.3 字符串去重
Rust 编译器对同一个 crate 内相同的字符串字面量会自动去重。跨 crate 的相同字符串要看有没有开 LTO:实测一个依赖 crate 和 cdylib 各写一遍同一个 48 字节的字面量,默认 release(不开 lto)时 .wasm 里有两份、两个指针不同;开 lto = true 后 LLVM 在合并后的模块里把它们并成一份。注意这只合并完全相同的字面量——只是前缀相同(比如一堆以 "failed to " 开头的消息)不会被合并。
二进制层面能帮上忙的是 wasm-opt --memory-packing(跳过全零区域重新切段);至于跨 crate 的重复字符串,Binaryen 没有一个「合并相同数据段内容」的 pass——它那几个 merge 类 pass 处理的都是代码:duplicate-function-elimination("removes duplicate functions",要求函数体完全相同)和 merge-similar-functions("merges similar functions when benefical",上游把 beneficial 拼错了,一直没改)。后者值得单独记一笔:它只在 shrinkLevel ≥ 2 时才进默认流水线,也就是只有 -Oz 会跑,-O/-Os(shrinkLevel 1)不跑。没开 LTO 时,数据段的去重基本得在源码层解决。
手动的办法是找出重复的字符串,重构代码用同一个常量:
// 优化前:每个模块都有自己的错误前缀
const A_ERR: &str = "failed to allocate";
const B_ERR: &str = "failed to allocate";
// 优化后:共享常量
mod errors {
pub const ALLOC_FAILED: &str = "failed to allocate";
}
9.11.4 查表 vs 计算
某些算法用预计算表换取运行时速度——CRC32 用 1KB 表,AES 用 4KB 表,三角函数用插值表。WASM 体积敏感场景下,权衡变化:
| 方案 | 体积 | 速度 | 适用 |
|---|---|---|---|
| CRC32 完整表(256 项 × 4 字节 = 1KB) | +1KB | 最快,每字节查一次表 | 体积充裕 |
| CRC32 半字节表(16 项 × 4 字节 = 64B) | +64B | 较慢,每字节查两次表 | 中等体积 |
| CRC32 无表(逐位计算) | +0 | 最慢,每字节 8 轮移位异或 | 极致体积 |
对于不在性能热路径的算法,去掉查表换体积是值得的。#[cfg(feature = "small")] 切换实现:
#[cfg(feature = "small")]
fn crc32(data: &[u8]) -> u32 { /* 计算实现 */ }
#[cfg(not(feature = "small"))]
fn crc32(data: &[u8]) -> u32 { /* 查表实现 */ }
9.12 极端体积:< 10KB 的实战
把一个 wasm-bindgen 项目一路压到极限,能加深对 WASM 体积构成的理解。下面每一步都是实测(opt-level="z" + lto + codegen-units=1,数字是 wasm-bindgen 处理后再 wasm-opt -Oz 的最终 .wasm;第 4 步不经 wasm-bindgen)。
9.12.1 起点:默认 wasm-bindgen 项目
最小的 hello-world 项目:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
默认 cargo build --release 出来是 35,437 字节。对一个加法函数来说这是天文数字——里面是基础设施:dlmalloc、core::fmt、panic 路径、wasm-bindgen 的描述符(__wasm_bindgen_unstable 自定义段和一大串描述符导出)、名称段。但这些大多是「没人用却还在文件里」:wasm-bindgen CLI 处理完剩 18,687 字节,再跑 wasm-pack 默认的 wasm-opt -O 只剩 1,250 字节——add 根本不分配内存,dlmalloc 整个被当作死代码删掉了。剩下的 1,250 字节里,Code 段只有 9 字节,800 字节是 std 带进来的静态数据(wasm-opt 无法判断这些数据没人读,只能留着),272 字节是 producers 与 target_features 两个自定义段。
9.12.2 逐步压缩
步骤 1:Cargo.toml 优化(§9.3 已覆盖)——对这个例子几乎没用:1,250 → 1,243 字节。§9.3.5 那种有真实依赖的项目才看得出效果。
步骤 2:禁用 wasm-bindgen 的 std 功能:
[dependencies]
wasm-bindgen = { version = "0.2", default-features = false }
wasm-bindgen 的 default feature 就是 std(0.2.118 的 Cargo.toml 第 25 行:default = ["std"],而 std = [] 本身是个空 feature,靠 #[cfg(feature = "std")] 在代码里分流)。关掉之后 crate 走 core + alloc 路线——但 String/Vec 的转换照样可用(它们来自 alloc),std 只多管了几样东西:已废弃的 JsStatic、impl<E: std::error::Error> From<E> for JsError、panic=unwind 下的相关支持,以及内部出错时是 std::process::abort() 还是直接 unreachable(src/lib.rs:1353-1360、:1788,src/rt/mod.rs)。所以单独这一步几乎不省:实测 1,243 → 1,242 字节。它的意义是为下一步 #![no_std] 扫清障碍。
步骤 3:no_std + 自定义 panic handler + 全局分配器(wasm-bindgen 依赖 alloc,必须有 #[global_allocator],§9.4.2):
#![no_std]
use wasm_bindgen::prelude::*;
#[global_allocator]
static ALLOC: talc::wasm::WasmDynamicTalc = talc::wasm::new_wasm_dynamic_allocator();
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
实测 448 字节,Node 里调用 add(2, 3) 得 5。省下的主要是 std 带进来的那 800 字节静态数据。剩下的是 wasm-bindgen 自己的那一点运行时:给 JS 胶水用的 externref 表导出、__wbindgen_start 与它导入的表初始化函数。用到字符串等类型时,__wbindgen_malloc/__wbindgen_realloc/__wbindgen_free 和分配器也会跟着回来——这部分不换方案是拿不掉的。
步骤 4:放弃 wasm-bindgen,手写 extern "C":
#![no_std]
#![no_main]
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
// edition 2024 下裸 #[no_mangle] 编不过,要写成 #[unsafe(no_mangle)]
#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
Cargo.toml 里照旧要有 [lib] crate-type = ["cdylib"]。直接编译为 wasm32-unknown-unknown 目标(不用 wasm-pack):
cargo build --target wasm32-unknown-unknown --release
wasm-opt -Oz target/wasm32-unknown-unknown/release/my_lib.wasm -o final.wasm
实测 331 字节,其中 225 字节是 producers 与 target_features 两个自定义段;再加 --strip-producers --strip-target-features 只剩 101 字节——模块里真的只剩下那条 i32.add、内存与几个全局的声明和导出(memory、add,以及 wasm-ld 默认导出的 __data_end、__heap_base)。代价是失去所有高级类型:没有字符串、没有对象、没有自动生成的 JS 胶水和 .d.ts,JS 侧的一切都得手写。
9.12.3 体积/便利性的权衡矩阵
graph LR A["原生 extern C<br/>331 B<br/>仅数值类型"] --> C["wasm-bindgen no_std<br/>448 B<br/>+ JS 胶水与 .d.ts"] C --> D["wasm-bindgen std<br/>1,243 B<br/>+ 整个 std"] D --> E["wasm-bindgen + serde_json<br/>60,789 B<br/>+ 序列化(§9.3.5)"] style A fill:#10b981,color:#333 style E fill:#dc2626,color:#fff
| 体积区间 | 适用场景 | 代价 |
|---|---|---|
| < 1KB | 纯计算函数(哈希、数学),不分配、不留 panic 路径 | 基本只能传数值 |
| 1-10KB | 小算法库 | 要盯住分配器和 panic/格式化代码 |
| 10-50KB | 普通 wasm-bindgen 应用 | 标准开发体验 |
| 50-200KB | 复杂应用 + serde | 需要持续审计依赖 |
| > 200KB | 大型应用 + 多个第三方 crate | 必须做代码分割 |
实际项目几乎不需要 < 1KB——但理解极限有助于判断"我的项目能压到多小"。一个 50KB 的纯计算 WASM 项目几乎一定有冗余——要么 wasm-bindgen 用得太多,要么有不必要的 String 操作,要么残留的 panic 路径把 core::fmt 和 dlmalloc 拖了进来(§9.4.1)。
9.12.4 监控体积回归
体积优化最容易反弹——加一个新依赖、用一次 format!,几个 KB 就回来了。CI 中加入体积守门:
# .github/workflows/wasm-size.yml
- name: Check WASM size
run: |
wasm-pack build --release --target bundler
SIZE=$(stat -c%s pkg/my_lib_bg.wasm)
if [ $SIZE -gt 51200 ]; then
echo "WASM size $SIZE exceeds 50KB budget"
exit 1
fi
量的一定要是 wasm-bindgen 与 wasm-opt 之后的 pkg/*_bg.wasm,而不是 target/wasm32-unknown-unknown/release/ 下 cargo build 的直接产物——后者还带着描述符和名称段,§9.12.1 那个例子两者差了近 30 倍。更精细的方案:用 twiggy diff 对比当前 PR 和 main 分支的产物(对 wasm-bindgen 的输出做,§9.2),超过阈值(如 +5KB)时阻止合并。这能让体积问题在引入时立即被发现,而不是等到生产环境用户抱怨加载慢。
9.13 体积监控与持续优化
体积优化做完后只是开始——后续每次 PR 都可能引入新依赖让体积反弹。没有持续监控的体积优化几乎一定会在数月后回到原点。生产团队需要把体积控制嵌入工程流程。
9.13.1 CI 中的体积守门
flowchart LR
A["PR 提交"] --> B["CI 构建 .wasm"]
B --> C["对比 main 的体积"]
C --> D{"增长 > 5%?"}
D -->|否| E["允许合并"]
D -->|是| F{"是否在豁免清单?"}
F -->|是| E
F -->|否| G["要求 commit message 解释<br/>+ reviewer 批准"]
style E fill:#10b981,color:#333
style G fill:#f59e0b,color:#333
GitHub Actions 实现:
- name: Build WASM (PR)
run: wasm-pack build --release --target bundler
- name: Build WASM (main)
run: |
git fetch origin main
git worktree add ../base origin/main
(cd ../base && wasm-pack build --release --target bundler)
- name: Compare size with main
env:
SIZE_EXEMPT: ${{ contains(github.event.pull_request.labels.*.name, 'size-exempt') }}
run: |
NEW_SIZE=$(stat -c%s pkg/my_lib_bg.wasm)
OLD_SIZE=$(stat -c%s ../base/pkg/my_lib_bg.wasm)
PCT=$(awk "BEGIN { printf \"%.1f\", ($NEW_SIZE - $OLD_SIZE) * 100 / $OLD_SIZE }")
echo "::notice::WASM size: $OLD_SIZE → $NEW_SIZE bytes (${PCT}%)"
if awk "BEGIN { exit !($PCT > 5) }" && [ "$SIZE_EXEMPT" != "true" ]; then
echo "::error::Size increased by ${PCT}% — review, then add the size-exempt label"
exit 1
fi
main 的产物一般不进仓库,所以要在 CI 里把 main 也编一遍(这里用 git worktree 检出到旁边的目录)。::notice:: 会作为注解显示在这次 Actions 运行的结果页上,reviewer 不用自己构建就能看到体积变化;想直接发成 PR 评论,还得再加一步调用 GitHub API 的 action。
9.13.2 体积预算(size budget)
把体积控制变成数字承诺,写入项目配置(下面是自拟的格式,没有现成工具直接读它,需要配一个自己的检查脚本):
# size-budget.toml
[budgets]
"pkg/my_lib_bg.wasm" = 51200 # 50KB
"pkg/optional/*.wasm" = 102400 # 100KB
"pkg/total" = 204800 # 总和 200KB
[alerts]
warn_at_pct = 90 # 达到 90% 预算时警告
fail_at_pct = 100 # 超出预算阻止合并
这种"数字预算"机制类似前端的 bundle size budget——把质量目标量化。Webpack/Vite 都有同类工具,WASM 项目应该有等价机制。
9.13.3 历史趋势可视化
单点检查不够——需要看趋势:
graph LR A["每次 main 合并"] --> B["记录体积"] B --> C["写入时序数据库"] C --> D["Grafana 看板"] D --> E["按周显示<br/>+ release tag 标注"] D --> F["按 crate 拆分<br/>定位增长来源"] D --> G["与功能数量对比<br/>判断是否合理"] style D fill:#6366f1,color:#fff
前端那些按文件路径和字节上限做检查的体积工具,对 .wasm 文件一样能用;或者自己写脚本:
# 每次合并后追加历史
echo "$(date -Iseconds),$(stat -c%s pkg/my_lib_bg.wasm)" >> size-history.csv
把 size-history.csv 提交到独立分支或 S3,Grafana 直接读取展示。
9.13.4 减少回归的工程纪律
持续保持小体积需要纪律:
| 纪律 | 实现 | 频率 |
|---|---|---|
| 体积守门 | CI 阻止 > 5% 增长 | 每 PR |
| 月度审计 | twiggy 报告 + review 会议 | 每月 |
| 季度优化 | 集中投入 1-2 天做体积优化 | 每季度 |
| 依赖审查 | 引入新依赖必须评估体积影响 | 引入时 |
这套机制让 WASM 体积成为持续被关注的工程指标——而不是"上线前才看一眼"的事后补丁。
9.13.5 反模式:体积优化的常见陷阱
graph TD A["体积优化反模式"] --> B["1. 一次性大优化<br/>不维护"] A --> C["2. 只看体积<br/>忽视性能"] A --> D["3. 牺牲可维护性<br/>过度抽象"] A --> E["4. 错配分配器<br/>性能下降无监控"] A --> F["5. 过早禁用 std<br/>开发体验差"] style A fill:#dc2626,color:#fff
每个反模式的纠正:
- 一次性大优化:要持续维护,不要一次性优化完就抛在脑后
- 只看体积:每次体积优化必须跟一次性能基准对比,确认没退化
- 过度抽象:宁愿多 5KB 也别让代码难以理解——可维护性 > 体积
- 错配分配器:把
dlmalloc换成小分配器后,分配密集场景可能变慢,换之前先确认这条路径不在热点上;顺带提醒老教程里的wee_alloc已经停止维护,别再用 - 过早禁用 std:早期项目用 std 简化开发,体积成为问题再优化
9.14 WASM 新提案对体积的影响
WASM 不是静态规范——每年都有新提案进入主线。理解这些提案对体积的影响有助于规划长期的体积策略——某些痛点可能在下一个工具链版本里就消失了。
9.14.1 已落地的体积相关提案
graph TD A["WASM 体积相关提案"] --> B["已标准化(Wasm 2.0)<br/>rustc 默认开启"] A --> C["已标准化(Wasm 3.0)<br/>rustc 未默认开启"] A --> D["不再推进"] B --> B1["bulk-memory<br/>memcpy 合并"] B --> B2["sign-extension<br/>压缩整数指令"] B --> B3["multivalue<br/>多返回值"] B --> B4["nontrapping-fptoint<br/>移除 trap 检查"] C --> C1["GC types<br/>消除手写分配器"] C --> C2["tail-call<br/>尾调用不增栈"] C --> C3["exception-handling<br/>替代 panic 表"] D --> D1["interface-types<br/>已由 Component Model 路线取代"] style B fill:#10b981,color:#333 style C fill:#f59e0b,color:#333 style D fill:#6366f1,color:#fff
每个提案对体积的影响:
| 提案 | rustc 侧状态(§5.10) | 对体积的作用 |
|---|---|---|
bulk-memory |
Rust 1.87+ 默认启用 | memory.copy / memory.fill 顶掉逐字节循环,memcpy/memset 从一段循环压成一条指令 |
sign-ext |
默认启用 | i32.extend8_s 等单指令替代「移位两次」的模式 |
multivalue |
Rust 1.82+ 默认启用 | 多返回值省掉 retptr 那一趟内存往返(§6.13) |
nontrapping-fptoint |
Rust 1.87+ 默认启用 | i32.trunc_sat_* 单指令替代「先判范围再转换」的分支 |
reference-types |
Rust 1.82+ 默认启用 | 省掉 JS 侧 heap 数组那套簿记代码 |
tail-call |
stable,需手动开 | return_call 保证尾调用不增长调用栈,主要惠及互递归、间接尾调用(§9.14.4);对体积影响很小 |
exception-handling |
unstable(wasm_target_feature) |
有可能替代模拟出来的 unwind 机制 |
WasmGC (gc) |
unstable(wasm_target_feature),且 Rust 没有面向它的后端 |
理论上可以去掉自带分配器,但今天用不上 |
需要纠正一个流传很广的说法:bulk-memory 和 nontrapping-fptoint 不再需要手动加 RUSTFLAGS——它们从 Rust 1.87(LLVM 20)起就在 wasm32-unknown-unknown 的默认 feature 集里(src/doc/rustc/src/platform-support/wasm32-unknown-unknown.md 有清单)。要确认当前工具链到底开了哪些,跑 rustc --print cfg --target wasm32-unknown-unknown | grep target_feature。
至于每项能省多少,本专栏不给百分比——用 twiggy diff 对比开关前后的产物是唯一可信的答案。
9.14.2 GC types 提案:消除手写分配器
核心 WASM 的线性内存里没有 GC——内存管理由模块自己实现(Rust 目标上是 dlmalloc)。WasmGC 提案让宿主提供 GC 堆和结构化的堆对象类型,模块可以直接用。
理论上的收益是把 dlmalloc 整块从产物里去掉——它的 malloc 在小模块里常是体积最大的单个函数(§9.2 的 twiggy top 里,除名称段和数据段外排第一)。
但对 Rust 用户来说这件事目前还不可用,原因不在浏览器:WasmGC 已经在主流浏览器落地(Safari 18.2 起四家集齐,第 1 章时间线),gc 也确实出现在 rustc 的 wasm target-feature 列表里(Unstable(wasm_target_feature)),但 rustc/LLVM 并没有一个把 Rust 的 Vec/Box 降低到 WasmGC 堆对象的后端——Rust 编译到 wasm 仍然只用线性内存。所以本节讲的是方向,不是今天能开的开关;那些 GcVec 之类的写法在 Rust 里并不存在。
9.14.3 exception-handling:替代 unwind 表
这里要先说清一个前提:走 wasm 公共基线的目标在 compiler/rustc_target/src/spec/base/wasm.rs 里写死了 panic_strategy: PanicStrategy::Abort(§5.8;wasm32-unknown-emscripten 例外,它默认 unwind,走 Emscripten 自己的异常机制),rustup 发的 std 按 abort 预编译,stable 上切不到 unwind。所以「panic = "unwind" 占多少体积」在今天的 Rust + wasm 上是个假设性问题——本节讨论的是提案落地后可能的变化,不是当前可测的数据。
exception-handling 提案引入 WASM 原生 try/catch。
下面这段是用户代码的示意,不是从任何源码文件里摘的:
// 当前:unwind 通过模拟实现,体积大
fn might_panic() -> i32 {
if cond { panic!("error") }
42
}
// exception-handling 之后:不再需要宿主侧模拟
// LLVM 直接发射 WASM 的异常指令(1.96 下默认仍是旧版 try/catch,
// 要新版 try_table/throw 得加 -Cllvm-args=-wasm-use-legacy-eh=false,§5.8)
体积影响的方向是清楚的:没有原生异常指令时,unwind 只能像 Emscripten 那样靠宿主侧的 invoke 包装模拟,代价很高;有了原生指令,LLVM 可以直接发射 throw 与 try_table(或旧版 try/catch),剩下的主要是 landing pad 里的清理代码本身——但它不会是零,unwind 产物仍会比 abort 大,具体多少只能等能在 stable 上构建时再实测。等它在 rustc 上 stable,panic = "unwind" 才会重新成为一个值得考虑的选项。wasm-bindgen 那边已经在为这一天做准备——0.2.109 起 panic=unwind 下 Closure::new() 要求 UnwindSafe,并新增了 *_aborting 变体(§6.15)。
9.14.4 tail-call:尾调用不增栈
某些算法(解析器、解释器)大量使用递归。WASM 的每次 call 都会占用引擎的调用栈——深递归直接 stack overflow。tail-call 提案(已进入 Wasm 3.0)新增 return_call/return_call_indirect,保证尾位置的调用复用当前栈帧。
但要分清它管的是哪种递归。自递归根本用不着它:LLVM 在优化时就会把自己调自己的尾递归改写成循环。实测 1.96.0(--target wasm32-unknown-unknown,没开 tail-call feature)下,下面两种写法在 opt-level="z" 和 3 下都编出一个 loop,没有任何 call $factorial:
// 普通写法:乘法在递归调用之后,LLVM 也能引入累加器改写成循环
#[unsafe(no_mangle)]
pub extern "C" fn factorial(n: u64) -> u64 {
if n == 0 { 1 } else { n.wrapping_mul(factorial(n - 1)) }
}
// 显式尾递归:同样是一个 loop
#[unsafe(no_mangle)]
pub extern "C" fn factorial_acc(n: u64, acc: u64) -> u64 {
if n == 0 { acc } else { factorial_acc(n - 1, n.wrapping_mul(acc)) }
}
tail-call 真正起作用的是跨函数的尾调用——互递归、状态机里「处理完这个状态就跳去下一个状态的函数」、解释器经函数指针表的分派(return_call_indirect)。而且 Rust 在 stable 上没有「保证尾调用」的语法(become 关键字对应的 explicit_tail_calls 在 1.96 仍是 unstable 特性),开了 tail-call feature 也只是允许 LLVM 在它认为合适时使用这些指令。对体积的影响很小,主要价值在栈深度。
9.14.5 跟踪提案演进
WebAssembly CG 的提案流程是 0 到 5 六个阶段,各阶段的正式名称见 WebAssembly/meetings 的 process/phases.md:0 Pre-Proposal、1 Feature Proposal、2 Feature Description Available、3 Implementation Phase、4 Standardize the Feature、5 The Feature is Standardized。注意阶段 2 的官方名字是「Feature Description Available」,不是常被写成的「spec text available」:
flowchart LR A["0<br/>Pre-Proposal"] --> B["1<br/>Feature Proposal"] B --> C["2<br/>Feature Description<br/>Available"] C --> D["3<br/>Implementation Phase"] D --> E["4<br/>Standardize the Feature"] E --> F["5<br/>The Feature is<br/>Standardized"] style A fill:#dc2626,color:#fff style C fill:#f59e0b,color:#333 style F fill:#10b981,color:#333
需要提醒的是:走到阶段 5 不等于 LLVM/rustc 会立刻默认启用——rustc 文档明确写了,feature 要在多数引擎里存在「相当长一段时间」之后才会进默认集,且没有固定的月数标准。
跟踪渠道:
- WebAssembly/proposals on GitHub:所有提案的中央索引
- WebAssembly/meetings:CG 会议纪要
- Rust 工具链 release notes:何时支持新 feature
9.14.6 长期体积策略
flowchart TD A["体积优化的时间维度"] --> B["短期 0-3 月"] A --> C["中期 3-12 月"] A --> D["长期 1-3 年"] B --> B1["编译选项 + wasm-opt<br/>+ 代码 audit"] C --> C1["新进默认集的 feature<br/>跟随 Rust 升级重测基线"] D --> D1["exception-handling 等<br/>视 rustc 支持情况评估"] style B fill:#10b981,color:#333 style C fill:#6366f1,color:#fff style D fill:#f59e0b,color:#333
短期策略主导日常工作——这部分内容前面章节已覆盖。中长期策略需要工程团队跟踪 WASM 路线图——半年一次评审,识别可以采用的新提案。
值得注意的是,这几年产物变小有相当一部分不是靠项目自己努力,而是工具链默认值在变——multivalue、reference-types、bulk-memory、nontrapping-fptoint 陆续进了 rustc 的默认 feature 集,升一次 Rust 就白拿一部分收益。所以体积基线要定期重测,不要拿一年前的数字做决策。
9.14.7 决策:何时采用新提案
flowchart TD
A["新提案出现"] --> B{"提案阶段 >= 3?"}
B -->|否| C["只关注,不动"]
B -->|是| D{"目标平台支持?"}
D -->|否| E["等待,关注实施进度"]
D -->|是| F{"体积/性能收益明显?"}
F -->|是| G["试用,性能基准对比"]
F -->|否| H["低优先级"]
style C fill:#dc2626,color:#fff
style G fill:#10b981,color:#333
不要追新提案——目标环境不能全覆盖的提案就不要在生产里开(WASM 模块没有运行时特性降级,验证不过就是整个实例化失败,见 §5.10.3)。但要"知道"新提案,等平台支持齐了第一时间评估。
9.15 体积优化的成本-收益边际
§9.3-§9.12 介绍了一层层的优化手段——但每层投入的工程成本不同,收益也递减。生产团队应该按投入产出决定做到哪一层。
9.15.1 各层手段的投入与代价
把前面的实测放在一起看(§9.3.5、§9.8、§9.12),成本和收益大致呈这样的梯度:
| 手段 | 工程投入 | 隐性代价 | 实测收益(本章示例) |
|---|---|---|---|
Cargo.toml 的 opt-level/lto + wasm-pack 默认的 wasm-opt |
改几行配置 | 构建变慢 | 最大的一块:pulldown-cmark 例最终产物降到 76% |
wasm-opt 从 -O 换 -Oz |
一行配置 | 几乎没有 | 很小(< 0.2%) |
依赖审计、去掉 format!、换小分配器 |
看项目,要读 twiggy 报告 | 维护精简后的依赖 | 视项目而定;换分配器约省 dlmalloc 本身的几 KB |
| immediate-abort + build-std | 构建脚本改动 | 需要 nightly 或 RUSTC_BOOTSTRAP;panic 没有任何消息 |
两个例子都约 20% |
#![no_std] / 手写 ABI |
大,可能要重写接口 | 失去 std 与 wasm-bindgen 的便利 | 只对极小模块有意义(§9.12) |
层级越深,每减一字节带来的代价越大——不只是时间投入,还包括代码质量、可维护性的损失。
9.15.2 何时该停止优化
flowchart TD
A["何时停止体积优化?"] --> B{"用户反馈?"}
B -->|加载快| C["停"]
B -->|加载慢| D{"瓶颈在?"}
D --> E["WASM 太大"]
D --> F["网络慢"]
D --> G["编译慢"]
E --> H["继续优化下一层"]
F --> I["CDN/压缩,不优化体积"]
G --> J["wasm-opt 更激进 / 代码分割"]
style C fill:#10b981,color:#333
style H fill:#f59e0b,color:#333
判断标准:用户感知的加载时间是否可接受。Lighthouse / RUM 指标比"WASM 字节数"更重要。如果 P95 加载已经 < 1s,继续优化体积没必要——把工程时间投到别处更有 ROI。
9.15.3 反模式:完美主义陷阱
graph TD A["体积优化反模式"] --> B["完美主义陷阱"] A --> C["数字焦虑"] A --> D["错配优先级"] B --> B1["追求绝对最小<br/>忽视代码质量"] C --> C1["每周比对 1KB 变化<br/>忽略业务发展"] D --> D1["把全部时间投在体积<br/>核心功能停滞"] style A fill:#dc2626,color:#fff
逐条说:
- 完美主义:为了从 200KB 压到 100KB 牺牲了可读性,下一任接手时往往又改回去
- 数字焦虑:1MB 减到 200KB 是大事,200KB 减到 198KB 不是
- 优先级:用户更需要新功能、更稳定,而不是 .wasm 再小 5KB
工程纪律:体积优化是手段,不是目的。目的是用户体验、业务效果。任何时候这两者出现冲突,业务和体验优先。
9.16 不同语言编译到 WASM 的体积对比
WASM 是字节码——但不同源语言编译出的 .wasm 体积差异显著。这一节对比主流语言的编译产物特征,帮助选语言时考虑体积维度。
9.16.1 同等功能下的语言对比
这里不给具体字节数——跨六套工具链的"实测"没有一个可复现的口径(版本、优化开关、被测程序的功能边界都不一样),本专栏不编造这种数字。能负责任地说的是量级排序和排序的成因,以同一个功能(比如一个 SHA-256 加一点简单的请求处理)为参照:
| 源语言 | 体积量级(由小到大) | 主要优化手段 | 体积由什么决定 |
|---|---|---|---|
| AssemblyScript | 最小 | --optimizeLevel 3 |
几乎没有运行时要带 |
| Rust | 小 | LTO + opt-level="z" + wasm-opt -Oz |
无 GC;主要负担是分配器与 core::fmt |
| C/C++ (Emscripten) | 小(未优化时很大) | -Oz + -sSTANDALONE_WASM |
无 GC;未优化时的大头是 POSIX shim |
| Java (TeaVM) | 中 | TeaVM 自身优化 | 必带 GC 与类型元数据 |
| Go (TinyGo) | 大 | 只能用 TinyGo,标准编译器更大 | 必带 GC 与调度器运行时 |
| Python (Pyodide) | 最大 | 基本压不动 | 整个 CPython 解释器都在模块里 |
要自己项目的数字,就把同一个功能在候选工具链上各编一遍,用 twiggy(或对应语言的体积工具)量——别引用任何一篇文章里的绝对值,包括这一篇。
观察:
- AssemblyScript 体积最小——专为 WASM 设计
- Rust 与 C/C++ 接近——都走 LLVM 后端,都不带 GC
- Go 较大——即使 TinyGo 也要带 GC 和调度器
- Python 巨大——CPython 不是为 WASM 设计,模块里装的是整个解释器
9.16.2 各语言的体积来源分析
graph TD A["语言 → WASM 体积来源"] --> B["运行时"] A --> C["标准库"] A --> D["GC"] A --> E["类型系统元数据"] B --> B1["Rust: 几乎无<br/>C: 几乎无<br/>Go: 标准 GC<br/>Python: 完整解释器"] C --> C1["视用法精简能力"] D --> D1["Rust/C: 无 GC<br/>Go: 必带<br/>Java/C#: 必带"] E --> E1["反射 / 类型信息"] style B fill:#10b981,color:#333
每种语言的体积"地板"不同——某些语言天生大(带运行时),某些天生小(直接编译)。
9.16.3 选语言的体积考量
flowchart TD
A["按体积选语言"] --> B{"体积优先级?"}
B --> C["极致小(< 10KB)"]
B --> D["平衡(10-200KB)"]
B --> E["不敏感(200KB+)"]
C --> F["AssemblyScript / 手写 WAT<br/>/ Rust no_std"]
D --> G["Rust / C/C++"]
E --> H["Go / Java / Python"]
style F fill:#10b981,color:#333
style H fill:#dc2626,color:#fff
9.16.4 AssemblyScript 的体积优势
// AssemblyScript - TypeScript 子集,专为 WASM 设计
export function add(a: i32, b: i32): i32 {
return a + b;
}
这种纯数值函数编译出来只有几百字节量级——因为它不需要任何运行时支撑。同样一个加法函数,Rust 挂上 #[wasm_bindgen]、走完 wasm-bindgen 与 wasm-opt 之后实测 1,243 字节(其中 800 字节是 std 带进来的静态数据),改成 no_std 是 448 字节,不用 wasm-bindgen 的 extern "C" 版本是 331 字节(§9.12)——差距主要在运行时和胶水,而不在语言本身。
AssemblyScript 的优势:
- 直接编译为 WASM 指令,不带解释器
- 运行时极薄;不用堆的代码可以完全不带 GC(AssemblyScript 现在用的是增量式追踪 GC,早期版本用的是引用计数,别照抄旧文档的说法)
- 类型系统本身就是照着 WASM 的值类型设计的
代价:生态远不如 Rust,复杂项目维护性差。
9.16.5 Rust 的体积工程
Rust 的体积手段前面各节已经逐一实测过,最终落在多少取决于依赖:本章的 serde_json 例最终约 60KB,pulldown-cmark 例约 145-186KB(§9.8):
graph LR A["Rust WASM 体积优化"] --> B["opt-level=z"] A --> C["LTO"] A --> D["codegen-units=1"] A --> E["换小分配器"] A --> F["wasm-opt -Oz"] A --> G["no_std"] style A fill:#6366f1,color:#fff
这些手段叠加起来能把产物压掉一大截——但每一层都要工程投入,投多少看 §9.15 的 ROI 判断。
9.16.6 C/C++ 的优势与陷阱
C/C++ 的 WASM 编译靠 Emscripten——优势:现有代码库直接编译。陷阱:默认带很多 POSIX shim,体积大。
# 只要一个裸 .wasm、不要 Emscripten 的 JS 运行时
# -Oz:体积优先;-sSTANDALONE_WASM:产出不依赖 Emscripten JS 胶水的模块;
# --no-entry:没有 main,只导出函数
emcc input.c \
-Oz \
-sSTANDALONE_WASM \
--no-entry \
-sEXPORTED_FUNCTIONS='["_my_func"]' \
-o output.wasm
关键是 -sSTANDALONE_WASM + --no-entry——不生成 Emscripten 的 JS 胶水,输出接近裸模块;用到 stdio 之类系统能力时,它会尽量改走 WASI 导入(Emscripten 设置说明里对 STANDALONE_WASM 的原话是 "The file will use standard APIs such as wasi as much as possible"),宿主得提供这些导入。(-sSIDE_MODULE=2 仍然存在,但它是给动态链接用的 side module,不是产出独立小模块的办法。)
9.16.7 Go 的特殊处境
graph TD A["Go → WASM"] --> B["标准 Go 编译器"] A --> C["TinyGo"] B --> B1["完整运行时<br/>体积大"] C --> C1["精简运行时<br/>小得多"] style B fill:#dc2626,color:#fff style C fill:#10b981,color:#333
Go 的标准编译器产出的 WASM 太大——生产几乎只用 TinyGo。但 TinyGo 有兼容性限制——某些 Go 标准库用不了。
9.16.8 Python 的现实
Python 的 WASM(Pyodide)实际是"在 WASM 里跑 CPython 解释器"——整个解释器都在模块里,体积是 MB 级,无法显著优化。
flowchart LR A["Python WASM"] --> B["Pyodide<br/>完整 CPython"] A --> C["RustPython<br/>Rust 实现的 Python"] B --> B1["MB 级"] C --> C1["同样要带整个解释器"] style B fill:#dc2626,color:#fff style C fill:#f59e0b,color:#333
如果体积敏感,Python 不是合适选择——除非接受这是"只能在 PoC 用"的限制。
9.16.9 多语言项目的体积策略
flowchart TD
A["多语言项目"] --> B{"主语言?"}
B --> C["Rust/C++"]
B --> D["Go"]
B --> E["Python"]
C --> F["传统优化即可"]
D --> G["TinyGo + WIT"]
E --> H["仅做 PoC 或非热路径"]
style F fill:#10b981,color:#333
style H fill:#dc2626,color:#fff
如果项目多语言并存,让 Rust/C++ 写核心模块(小),Python 写非热路径(大但不影响体验)——这种分层让多语言的体积代价可控。
9.16.10 体积选语言的工程清单
flowchart TD A["按体积选语言"] --> B["1. 算清体积底线<br/>50KB? 500KB? 5MB?"] A --> C["2. Rust/C++ 是默认<br/>体积可控且生态好"] A --> D["3. 体积极致 → AssemblyScript"] A --> E["4. 已有代码库 → 接受语言原本"] A --> F["5. Python 仅用于 PoC"] style A fill:#6366f1,color:#fff
体积是技术选型的重要维度——这套清单帮助避免"选了 Go 才发现体积不可接受"的尴尬。
9.17 跨专栏关联:与 Rust 编译器优化的对照
WASM 的体积优化手段和《Rust 编译器与运行时揭秘》专栏第 16 章「LLVM 代码生成:从 MIR 到机器码」描述的 LLVM 优化管线有直接的对应关系:
- panic = "abort" 对应的是 LLVM 层不再为可能 panic 的调用生成 landing pad 和异常表——
invoke全部退化成普通call,函数里那些只为「panic 时按序析构」而存在的清理路径也一并消失。这条对照在原生目标上才有"省下多少"的意义;wasm32 一开始就是 abort,等于生来就在这条路径上 - LTO 对应该专栏 §16.7 讲的 rustc 侧 LTO 驱动(
compiler/rustc_codegen_llvm/src/back/lto.rs:fat 走run_fat把所有模块并成一个,thin 走 LLVM 的 ThinLTO 接口)——跨模块的 IPO(Inter-Procedural Optimization) - codegen-units = 1 对应的是 rustc 侧的 CGU 划分(
rustc_session的codegen_units()决定个数、rustc_monomorphize的 partitioning 决定怎么分),不是 LLVM 的策略——归到一个 Module 之后,跨函数的内联与 IPO 才有机会做(寄存器分配本身仍是函数级的) - wasm-opt -Oz 对应 Binaryen 的优化 pass——这是 LLVM 之外的独立优化器,利用 WASM 二进制格式的特殊性质(栈式指令、类型化操作数)做 LLVM 无法做的优化
下一章从体积转向速度——WASM 运行时的性能特征。