Rust + WebAssembly 全链路解析

第9章 WASM 模块体积优化

作者 杨艺韬 · 20,987 字 · 发布于 · 更新于

"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 靠它得知模块用了哪些提案特性(Binaryen src/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 都跑,只在某些级别才跑的另行标出):

  1. 重复函数消除(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)。

  2. 局部变量简化(simplify-locals):把 local.set 直接下沉到唯一的 local.get 处,消掉「存进局部变量再取回」的往返。LLVM 的 wasm 后端分配局部变量时(它不跑通用寄存器分配器,由 RegStackify/RegColoring 完成,§5.3)难免留下冗余——wasm-opt 在二进制层面清理这些冗余。

  3. 代码折叠(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 没有。

  4. 死代码消除:分两层。dce("removes unreachable code")清理的是函数体内不可达的代码;删掉整个没人用的函数、全局、表项的是 remove-unused-module-elements——它从导出、start 函数等根出发标记可达的模块元素,其余一律删除。前者在函数内工作,后者在整个模块内工作——可以消除链接器和 LTO 遗漏的死代码。

  5. 数据段紧凑化(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 运行时的性能特征。