这是使用 Rust 和 WebAssembly 的**未发布**文档,已发布的文档可在 Rust 和 WebAssembly 主文档网站上找到。此处记录的功能可能在 Rust 和 WebAssembly 工具的已发布版本中不可用。

缩小 .wasm 代码大小

本节将教你如何优化 .wasm 构建以获得较小的代码大小,以及如何识别更改 Rust 源代码的机会,以便生成更少的 .wasm 代码。

为什么要关心代码大小?

在网络上提供 .wasm 文件时,文件越小,客户端下载速度越快。更快的 .wasm 下载速度会导致更快的页面加载时间,从而带来更愉快的用户体验。

但是,重要的是要记住,代码大小可能不是你关注的最终指标,而是更模糊、更难以衡量的指标,例如“首次交互时间”。虽然代码大小在此指标中起着重要作用(如果没有所有代码,就无法做任何事情!),但它不是唯一因素。

WebAssembly 通常以 gzip 压缩形式提供给用户,因此你应该确保比较 gzip 压缩后的文件大小,以了解网络传输时间差异。还要记住,WebAssembly 二进制格式非常适合 gzip 压缩,通常可以将文件大小减少 50% 以上。

此外,WebAssembly 的二进制格式针对非常快的解析和处理进行了优化。如今的浏览器拥有“基线编译器”,它可以解析 WebAssembly 并尽可能快地生成编译后的代码,就像 wasm 从网络上获取一样。这意味着 如果你使用 instantiateStreaming,那么 WebAssembly 模块很可能在 Web 请求完成的瞬间就准备好了。另一方面,JavaScript 通常需要更长的时间才能解析,而且还需要更长的时间才能通过 JIT 编译等方式达到最佳状态。

最后,请记住,WebAssembly 在执行速度方面也比 JavaScript 优化得多。你应该确保测量 JavaScript 和 WebAssembly 之间的运行时比较,以将代码大小的重要性纳入考量。

总而言之,如果你的 .wasm 文件比预期更大,不要立即灰心!代码大小最终可能只是端到端故事中的众多因素之一。仅关注代码大小的 JavaScript 和 WebAssembly 之间的比较,就如同只见树木不见森林。

优化构建以获得较小的代码大小

我们可以使用许多配置选项来让 rustc 生成更小的 .wasm 二进制文件。在某些情况下,我们用更长的编译时间来换取更小的 .wasm 文件大小。在其他情况下,我们用 WebAssembly 的运行时速度来换取更小的代码大小。我们应该意识到每个选项的权衡,并且在用运行时速度换取代码大小的情况下,要进行分析和测量,以做出明智的决定,判断这种权衡是否值得。

使用链接时优化 (LTO) 编译

Cargo.toml 中,在 [profile.release] 部分添加 lto = true

[profile.release]
lto = true

这将为 LLVM 提供更多机会来内联和修剪函数。这不仅会使 .wasm 更小,而且还会使它在运行时更快!缺点是编译时间会更长。

告诉 LLVM 优化大小而不是速度

默认情况下,LLVM 的优化过程针对速度而不是大小进行了调整。我们可以通过修改 Cargo.toml 中的 [profile.release] 部分来将目标更改为代码大小,如下所示

[profile.release]
opt-level = 's'

或者,为了更积极地优化大小,即使可能进一步降低速度

[profile.release]
opt-level = 'z'

请注意,令人惊讶的是,opt-level = "s" 有时会导致比 opt-level = "z" 更小的二进制文件。始终进行测量!

使用 wasm-opt 工具

Binaryen 工具包是 WebAssembly 特定编译器工具的集合。它比 LLVM 的 WebAssembly 后端更进一步,使用它的 wasm-opt 工具对 LLVM 生成的 .wasm 二进制文件进行后处理,通常可以节省 15-20% 的代码大小。它通常还会同时提高运行时速度!

# Optimize for size.
wasm-opt -Os -o output.wasm input.wasm

# Optimize aggressively for size.
wasm-opt -Oz -o output.wasm input.wasm

# Optimize for speed.
wasm-opt -O -o output.wasm input.wasm

# Optimize aggressively for speed.
wasm-opt -O3 -o output.wasm input.wasm

关于调试信息的说明

wasm 二进制文件大小的最大贡献者之一可能是调试信息和 wasm 二进制文件的 names 部分。但是,wasm-pack 工具默认情况下会删除调试信息。此外,wasm-opt 默认情况下会删除 names 部分,除非还指定了 -g

这意味着,如果你按照上述步骤操作,默认情况下,wasm 二进制文件中不应该包含调试信息或 names 部分。但是,如果你以其他方式手动保留了 wasm 二进制文件中的调试信息,请务必注意这一点!

大小分析

如果调整构建配置以优化代码大小没有导致 .wasm 二进制文件足够小,那么就该进行一些分析,看看剩余的代码大小来自哪里。

⚡ 就像我们让时间分析指导我们的加速工作一样,我们也希望让大小分析指导我们的代码大小缩减工作。如果这样做,你可能会浪费自己的时间!

twiggy 代码大小分析器

twiggy 是一个代码大小分析器,它支持 WebAssembly 作为输入。它分析二进制文件的调用图,以回答以下问题

  • 为什么这个函数被包含在二进制文件中?

  • 这个函数的保留大小是多少?也就是说,如果我删除它以及删除它后成为死代码的所有函数,可以节省多少空间?

$ twiggy top -n 20 pkg/wasm_game_of_life_bg.wasm
 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼────────────────────────────────────────────────────────────────────────────────────────
          9158 ┊    19.65% ┊ "function names" subsection
          3251 ┊     6.98% ┊ dlmalloc::dlmalloc::Dlmalloc::malloc::h632d10c184fef6e8
          2510 ┊     5.39% ┊ <str as core::fmt::Debug>::fmt::he0d87479d1c208ea
          1737 ┊     3.73% ┊ data[0]
          1574 ┊     3.38% ┊ data[3]
          1524 ┊     3.27% ┊ core::fmt::Formatter::pad::h6825605b326ea2c5
          1413 ┊     3.03% ┊ std::panicking::rust_panic_with_hook::h1d3660f2e339513d
          1200 ┊     2.57% ┊ core::fmt::Formatter::pad_integral::h06996c5859a57ced
          1131 ┊     2.43% ┊ core::str::slice_error_fail::h6da90c14857ae01b
          1051 ┊     2.26% ┊ core::fmt::write::h03ff8c7a2f3a9605
           931 ┊     2.00% ┊ data[4]
           864 ┊     1.85% ┊ dlmalloc::dlmalloc::Dlmalloc::free::h27b781e3b06bdb05
           841 ┊     1.80% ┊ <char as core::fmt::Debug>::fmt::h07742d9f4a8c56f2
           813 ┊     1.74% ┊ __rust_realloc
           708 ┊     1.52% ┊ core::slice::memchr::memchr::h6243a1b2885fdb85
           678 ┊     1.45% ┊ <core::fmt::builders::PadAdapter<'a> as core::fmt::Write>::write_str::h96b72fb7457d3062
           631 ┊     1.35% ┊ universe_tick
           631 ┊     1.35% ┊ dlmalloc::dlmalloc::Dlmalloc::dispose_chunk::hae6c5c8634e575b8
           514 ┊     1.10% ┊ std::panicking::default_hook::{{closure}}::hfae0c204085471d5
           503 ┊     1.08% ┊ <&'a T as core::fmt::Debug>::fmt::hba207e4f7abaece6

手动检查 LLVM-IR

LLVM-IR 是编译器工具链中 LLVM 生成 WebAssembly 之前的最终中间表示。因此,它与最终生成的 WebAssembly 非常相似。更多的 LLVM-IR 通常意味着更大的 .wasm 文件大小,如果一个函数占用了 25% 的 LLVM-IR,那么它通常也会占用 25% 的 .wasm。虽然这些数字只在一般情况下成立,但 LLVM-IR 包含了 .wasm 中没有的关键信息(因为 WebAssembly 缺乏像 DWARF 这样的调试格式):哪些子例程被内联到给定函数中。

你可以使用以下 cargo 命令生成 LLVM-IR

cargo rustc --release -- --emit llvm-ir

然后,你可以使用 findcargotarget 目录中找到包含 LLVM-IR 的 .ll 文件

find target/release -type f -name '*.ll'

参考

更具侵入性的工具和技术

调整构建配置以获得更小的 .wasm 二进制文件非常省事。但是,当你需要更进一步时,你就可以使用更具侵入性的技术,例如重写源代码以避免膨胀。以下是一些你可以应用的动手操作技术,以获得更小的代码大小。

避免字符串格式化

format!to_string 等... 会带来很多代码膨胀。如果可能,只在调试模式下进行字符串格式化,而在发布模式下使用静态字符串。

避免恐慌

这说起来容易做起来难,但像 twiggy 这样的工具以及手动检查 LLVM-IR 可以帮助你找出哪些函数正在恐慌。

恐慌并不总是以 panic!() 宏调用形式出现。它们会隐式地从许多结构中产生,例如

  • 对切片的索引在越界索引上会恐慌:my_slice[i]

  • 如果除数为零,除法会恐慌:dividend / divisor

  • 解包 OptionResultopt.unwrap()res.unwrap()

前两种可以转换为第三种。索引可以替换为不可靠的 my_slice.get(i) 操作。除法可以替换为 checked_div 调用。现在我们只需要处理一种情况。

在不恐慌的情况下解包 OptionResult 有两种方法:安全和不安全。

安全的方法是在遇到 NoneError中止,而不是恐慌


# #![allow(unused_variables)]
#fn main() {
#[inline]
pub fn unwrap_abort<T>(o: Option<T>) -> T {
    use std::process;
    match o {
        Some(t) => t,
        None => process::abort(),
    }
}
#}

最终,恐慌在 wasm32-unknown-unknown 中会转换为中止,因此这会给你相同的行为,但不会造成代码膨胀。

或者,unreachable crate 提供了一个不安全的 unchecked_unwrap 扩展方法 用于 OptionResult,它告诉 Rust 编译器假设 OptionSomeResultOk。如果该假设不成立,则行为未定义。你真的只想在 110% 知道该假设成立,而编译器不够聪明无法识别它时才使用这种不安全的方法。即使你采取了这种方式,你也应该有一个仍然进行检查的调试构建配置,并且只在发布构建中使用未经检查的操作。

避免分配或切换到 wee_alloc

Rust 为 WebAssembly 的默认分配器是 dlmalloc 的 Rust 移植版本。它的体积约为 10 千字节。如果你可以完全避免动态分配,那么你应该能够减轻这 10 千字节的负担。

完全避免动态分配可能非常困难。但是,从热代码路径中删除分配通常要容易得多(并且通常也有助于使这些热代码路径更快)。在这些情况下,wee_alloc 替换默认的全局分配器 应该可以为你节省大部分(但不是全部)这 10 千字节。wee_alloc 是一个为需要某种分配器但不需要特别快的分配器的情况而设计的分配器,它很乐意以更小的代码大小来换取分配速度。

使用 Trait 对象而不是泛型类型参数

当你创建使用类型参数的泛型函数时,例如


# #![allow(unused_variables)]
#fn main() {
fn whatever<T: MyTrait>(t: T) { ... }
#}

那么 rustc 和 LLVM 将为函数使用的每个 T 类型创建一个新的函数副本。这为基于每个副本正在使用的特定 T 的编译器优化提供了许多机会,但这些副本在代码大小方面迅速累积。

如果你使用 Trait 对象而不是类型参数,例如


# #![allow(unused_variables)]
#fn main() {
fn whatever(t: Box<MyTrait>) { ... }
// or
fn whatever(t: &MyTrait) { ... }
// etc...
#}

那么将使用通过虚拟调用的动态分派,并且只在 .wasm 中发出函数的单个版本。缺点是失去了编译器优化机会,以及间接、动态分派函数调用的额外成本。

使用 wasm-snip 工具

wasm-snip 将 WebAssembly 函数的主体替换为 unreachable 指令。 对于那些看起来像钉子(如果你眯起眼睛看的话)的函数来说,这是一个相当沉重、钝的锤子。

也许你知道某些函数在运行时永远不会被调用,但编译器无法在编译时证明这一点?把它剪掉!之后,再次运行 wasm-opt,使用 --dce 标志,所有被剪掉的函数间接调用的函数(在运行时也可能永远不会被调用)也将被删除。

此工具对于删除恐慌基础设施特别有用,因为恐慌最终会转换为陷阱。