Why compiling Rust to WebAssembly is slow

jedisct11 pts0 comments

Why compiling Rust to WebAssembly is slow - Frank DENIS random thoughts.

Skip to main content

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.

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.

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.

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. 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.

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:

Configuration<br>Build time

debug = 2<br>50.56s

debug = 0<br>1.55s

The 1.55 seconds is the whole cargo build time with debug info off.<br>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.

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.

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...

debug records llvm webassembly pass info

Related Articles