Why compiling Rust to WebAssembly is slow
Why compiling Rust to WebAssembly is slow
为什么将 Rust 编译为 WebAssembly 的速度很慢
Compiling Rust to WebAssembly with debug info is slower than it should be. Sometimes unbearably slower. For example, here’s a 40-line Rust reproducer that takes 50 seconds to build with debug info and 1.5 seconds without it. This reproducer was reduced from a crate (ed25519-compact) where enabling debug info made compilation ~40x slower, but the bug itself is broader and affects all Rust code compiled to WebAssembly to varying degrees. This is actually a known LLVM bug that had already been reported and fixed for clang. But the fix is incomplete. 在开启调试信息的情况下,将 Rust 编译为 WebAssembly 的速度比预期的要慢,有时甚至慢到令人无法忍受。例如,这里有一个 40 行的 Rust 复现代码,开启调试信息时构建需要 50 秒,而关闭时仅需 1.5 秒。这个复现代码是从一个 crate (ed25519-compact) 中精简出来的,在该 crate 中,开启调试信息会导致编译速度变慢约 40 倍。但这个 Bug 的影响范围更广,不同程度地影响着所有编译为 WebAssembly 的 Rust 代码。这实际上是一个已知的 LLVM Bug,此前已针对 clang 进行过报告和修复,但该修复并不完整。
Debug info becomes records in the instruction list. Cargo has a debug setting to control debug info. debug = 2 asks LLVM for full DWARF information, which very few people use in practice with WebAssembly, but which people like to enable anyway (if only because debug = true is an alias for debug = 2). This debugging data is designed for profiling a wasm binary and getting real symbol names in stack traces. It’s also the default for Rust’s dev profile.
调试信息会变成指令列表中的记录。Cargo 提供了调试设置来控制调试信息。debug = 2 会要求 LLVM 生成完整的 DWARF 信息,虽然在 WebAssembly 的实际应用中很少有人使用,但人们往往还是会开启它(仅仅因为 debug = true 是 debug = 2 的别名)。这些调试数据旨在用于分析 wasm 二进制文件并在堆栈跟踪中获取真实的符号名称。这也是 Rust 开发配置文件的默认设置。
Something important to understand first: LLVM represents a source variable’s location with a DBG_VALUE record. The record says that, at this point in the generated code, a variable lives in a register, a stack slot, or a constant. It sits in LLVM’s machine-level intermediate representation, or MIR, and produces no code by itself. But it’s relevant when code is moved. A debugger must see the right value, so every pass that moves an instruction has to move or update its DBG_VALUE records too. With debug = 2, heavy inlining can produce hundreds of thousands of DBG_VALUE records in one function. And when targeting WebAssembly, a lot of code has to be moved.
首先需要理解一点:LLVM 使用 DBG_VALUE 记录来表示源变量的位置。该记录表明,在生成的代码的这一点上,变量位于寄存器、堆栈槽或常量中。它存在于 LLVM 的机器级中间表示(MIR)中,本身不会生成任何代码。但当代码被移动时,它就变得至关重要。调试器必须看到正确的值,因此每个移动指令的编译过程(pass)也必须移动或更新其 DBG_VALUE 记录。在 debug = 2 的情况下,大量的内联可能会在一个函数中产生数十万条 DBG_VALUE 记录。而当目标为 WebAssembly 时,大量代码必须被移动。
WebAssembly has to move values onto the stack. Unlike native targets, WebAssembly is a stack machine. LLVM first generates instructions using named temporary registers, then a backend pass called “Register Stackify” moves values it can use onto the stack near the end of code generation. A definition computes a value, while a use consumes it. And when a definition has one use, Register Stackify can move that definition immediately before the use. The value then stays on the wasm operand stack instead of passing through a local, which saves wasm code and runtime work. This happens inside a basic block, a straight sequence of instructions with no branches into or out of its middle. But as we saw before, moving a definition also means moving its debug records. WebAssembly 必须将值移动到堆栈上。与原生目标不同,WebAssembly 是一个堆栈机。LLVM 首先使用命名临时寄存器生成指令,然后在代码生成的最后阶段,一个名为“Register Stackify”的后端过程会将可用的值移动到堆栈上。定义计算一个值,而使用则消耗它。当一个定义只有一个使用点时,Register Stackify 可以将该定义移动到使用点之前。这样值就会保留在 wasm 操作数堆栈上,而不是通过局部变量传递,从而节省了 wasm 代码量和运行时开销。这发生在基本块(basic block)内部,即没有分支进入或离开中间的连续指令序列中。但正如我们之前所见,移动定义也意味着移动其调试记录。
This is where things start to suck. The pass keeps rescanning the list it grows. Before it can move a definition, the pass scans from that definition to the end of its basic block for its debug records, stopping if the register is defined again. It also scans from the definition to the place where the instruction will be inserted, collecting records for the variables it tracks. Those are linear scans. But repeating them for many definitions turns them into quadratic work: twice as many records can mean four times as much scanning. Yikes. 问题就在这里开始变得糟糕了。该过程不断扫描它所增长的列表。在移动定义之前,该过程会从定义处扫描到基本块末尾以查找调试记录,如果寄存器被重新定义则停止。它还会从定义处扫描到指令插入位置,收集它所跟踪变量的记录。这些是线性扫描。但对许多定义重复这些操作会使其变成二次方复杂度的工作:记录数量翻倍可能意味着扫描工作量增加四倍。糟糕。
The pass also makes its own input larger as it runs. When it sinks a definition, it leaves the old debug records in the instruction list with their locations blanked out instead of deleting them. When a value is cheap to compute, such as a constant, it computes that value again at every use instead of carrying it around. Each copy gets fresh DBG_VALUE records. Re-yikes. So the pass keeps adding records to the same list it keeps rescanning. It’s very inefficient and awful for large functions.
该过程在运行时还会使自身的输入变大。当它下沉(sink)一个定义时,它会将旧的调试记录留在指令列表中,只是将其位置清空,而不是删除它们。当一个值计算成本很低(例如常量)时,它会在每次使用时重新计算该值,而不是携带它。每个副本都会获得新的 DBG_VALUE 记录。更糟糕了。因此,该过程不断向它正在反复扫描的同一个列表中添加记录。这对于大型函数来说非常低效且糟糕。
A small reproducer. That reproducer repeatedly squares a [u64; 5] through a chain of #[inline(always)] functions. On my machine, this command: cargo build --release --target=wasm32-unknown-unknown produced:
一个小的复现案例。该复现案例通过一系列 #[inline(always)] 函数反复对 [u64; 5] 进行平方运算。在我的机器上,执行 cargo build --release --target=wasm32-unknown-unknown 命令的结果如下:
| Configuration | Build time |
|---|---|
| debug = 2 | 50.56s |
| debug = 0 | 1.55s |
The 1.55 seconds is the whole cargo build time with debug info off. Adding full debug info turns the same build into a 50-second wait. Ouch! And rustc -Z time-llvm-passes shows where it goes. With debug = 2, LLVM pass time was 50.85s: WebAssembly Register Stackify took 43.51s, or 85.6%, and Explicit Locals took 6.31s, or 12.4%. Everything else is negligible. With debug = 0, total pass time was 1.42s. Register Stackify took 0.96s and Explicit Locals took 0.003s, making them roughly 45x and 2000x slower with debug info. This is all due to the inefficient handling of DBG_VALUE records.
1.55 秒是关闭调试信息后的完整 cargo 构建时间。添加完整的调试信息使同样的构建过程变成了 50 秒的等待。哎哟!rustc -Z time-llvm-passes 显示了时间消耗在哪里。在 debug = 2 时,LLVM 过程耗时 50.85 秒:WebAssembly Register Stackify 耗时 43.51 秒(占 85.6%),Explicit Locals 耗时 6.31 秒(占 12.4%)。其他部分几乎可以忽略不计。在 debug = 0 时,总过程耗时 1.42 秒。Register Stackify 耗时 0.96 秒,Explicit Locals 耗时 0.003 秒,这意味着在开启调试信息后,它们分别慢了约 45 倍和 2000 倍。这一切都是由于对 DBG_VALUE 记录的处理效率低下造成的。
The crate looks small and innocent: it just produces one function with one basic block. But by the time Register Stackify is done, it has about 90k real instructions, 267k DBG_VALUE records, and 355k lines of MIR. Explicit Locals isn’t broken. It’s a linear pass that receives 350k instructions instead of 90k. Pretty bad.
这个 crate 看起来很小且无害:它只生成了一个包含一个基本块的函数。但当 Register Stackify 完成时,它已经有了约 9 万条真实指令、26.7 万条 DBG_VALUE 记录和 35.5 万行 MIR。Explicit Locals 并没有坏,它只是一个线性过程,却接收了 35 万条指令而不是 9 万条。相当糟糕。
LLVM’s fix is incomplete. There’s already llvm/llvm-project issue #168326, which was reported against clang and describes the same problem. It was closed on 2026-03-27 by commit fe990b9005260bcf4a5630b577483e954c6bb60e. The way it works is that it counts a register’s DBG_VALUE uses, then stops the forward scan after it has found them all. The counter is provided by a use list, the compiler’s unordered list of every place a value is used. But that doesn’t help Rust (TBH it does, but very little). The catch is that, while moving values, Register Stackify can point an existing DBG_VALUE at a different register without moving the record itself. And after enough copies, a record near the top of the block can refer to a register defined near the bottom. It appears in that register’s use list, yet a forward scan from the definition can never reach it. The counter includes the earlier record, so it never reaches zero.
LLVM 的修复是不完整的。此前已经有了 llvm/llvm-project 的 issue #168326,它是针对 clang 报告的,描述了同样的问题。该 issue 已于 2026 年 3 月 27 日通过提交 fe990b9005260bcf4a5630b577483e954c6bb60e 关闭。其工作原理是计算寄存器的 DBG_VALUE 使用次数,然后在找到所有使用点后停止前向扫描。计数器由使用列表(use list)提供,这是编译器记录每个值被使用位置的无序列表。但这并不能帮助 Rust(老实说有一点帮助,但非常有限)。问题在于,在移动值时,Register Stackify 可能会将现有的 DBG_VALUE 指向不同的寄存器,而无需移动记录本身。经过足够多次的复制后,块顶部的记录可能会引用块底部定义的寄存器。它出现在该寄存器的使用列表中,但从定义处进行的前向扫描永远无法到达它。计数器包含了较早的记录,因此它永远不会归零。
A better fix. Here’s a better fix as a single patch that can be applied to the LLVM code that currently ships with Rust. It contains three independent changes. The first change is in the WebAssembly debug-record helper. It stops Register Stackify from reading to the end of a block when it doesn’t need to. The compiler already keeps a list of every place a value is used, including the debug records that mention it, so it kn… 一个更好的修复方案。这是一个更好的修复方案,作为一个可以应用于当前 Rust 自带 LLVM 代码的单一补丁。它包含三个独立的更改。第一个更改是在 WebAssembly 调试记录辅助程序中。它阻止了 Register Stackify 在不需要时读取到块的末尾。编译器已经维护了一个列表,记录了每个值被使用的所有位置,包括提及它的调试记录,所以它知…