Why compiling Rust to WebAssembly is slow

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

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

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 knows how many records it needs to find. The old code still read forward from the definition until it reached the end of the block. After copies, some records can sit above the definition.

A forward scan can never reach them, so its count never reaches zero and it always reads to the end. That’s why LLVM’s existing change doesn’t help this Rust case.

The patch makes the thing walk upward as well as forward. The upward walk counts off records above the definition, while the forward walk keeps the same order and still stops at another definition.

Once both walks have accounted for every record, they stop. The compiler finds the same records in the same order without reading the rest of the block.

That same first change also avoids building expensive lookup keys for records it will discard. While collecting records between two points, the old code built and hashed a full identifier for every record it passed, then usually threw it away because it described a variable it didn’t track.

Now it first asks whether it cares about that variable with one cheap comparison. That reduced hash-table lookups from 27.4M to 3.0M. Pretty significant.

The second change is a non-WebAssembly-specific change.

LLVM gives real instructions position numbers so it can compare their order without walking the instruction list. But debug records never get a position number, yet the old code looked each one up before learning that it wasn’t there. The patch just skips those useless lookups. And every target benefits from it, not just WebAssembly.

The third change is back in the WebAssembly pass.

It asks a cheaper question about whether one instruction always runs before another. The general-purpose helper answered by walking the block from the beginning every time. The instructions already have position numbers, so comparing two numbers gives the same answer.

These changes don’t affect the compiled code itself at all, so there are no runtime performance regressions or behavior changes.

Let’s benchmark the reproducer again

Here are llc -O3 -time-passes measurements on the reproducer’s bitcode, the compiled form of the program that LLVM reads:

Pass Before After
WebAssembly Register Stackify 45.9s 1.19s
WebAssembly Explicit Locals 6.5s 0.013s
Total codegen pass time 53.3s 2.17s

With debug info disabled, Register Stackify takes 1.14s on this input. The remaining debug-info overhead in that pass is about 0.05 seconds.

End to end, debug = 2 fell from 50.56s to 2.72s.

And debug = 0 fell from 1.55s to 0.89s. Pretty cool.

Processing and emitting 267k debug records still costs time, but the quadratic blowup is gone.

Testing on real-world code

Does this affect code people actually compile? Yes.

I repeated the crate benchmark with common crates.

Every crate used the release profile with debug = 2 and codegen-units = 1.

I emitted LLVM bitcode for every crate with:

RUSTFLAGS="--emit=llvm-bc" cargo build --release --target=wasm32-unknown-unknown

Then I ran each module through both compilers:

llc -O3 -time-passes -filetype=obj -o /dev/null <crate>.bc

Both llc binaries came from the same source tree with the same build configuration. The patch was the only difference.

Cryptography

I benchmarked an app with a bunch of crypto crates: aegis, sha2, sha3, blake2, blake3, md-5, ripemd, chacha20poly1305, aes-gcm, argon2, curve25519-dalek, ed25519-compact, k256, p256, p384, ahash, etc.

  Before After
Time in Register Stackify, all 134 modules 1702.7s 26.2s
Total wasm code generation time, all 134 modules 1922.2s 67.4s

ed25519-compact 2.4.0 alone went from 1683.4s to 23.2s in Register Stackify, and from 1884.9s to 48.2s for total code generation.

That’s 31 minutes of code generation for one ordinary crate, down to 48 seconds.

The patched compiler still spends 23 seconds in that pass. Once its field arithmetic is inlined, the crate really is enormous. The quadratic scan is what turned 23 seconds into half an hour.

Here’s a detailed benchmark for some crates:

Crate Register Stackify before after Whole code generation
ed25519-compact 1683.4s 23.2s 39x faster
k256 5.06s 0.21s 4.8x faster
blake2 1.43s 0.21s 4.9x faster
ripemd 0.61s 0.064s 3.9x faster
sha2 3.01s 0.83s 2.8x faster
p256 3.22s 0.44s 2.4x faster
p384 0.89s 0.12s 2.3x faster
blake3 0.27s 0.046s 2.3x faster
jwt-simple 0.68s 0.073s 1.6x faster
curve25519-dalek 0.14s 0.021s 1.3x faster
aegis 0.017s 0.0028s 1.2x faster

For sha2, 88% of the crate’s entire WebAssembly code-generation time was in Register Stackify before the fix.

Non-crypto things

I also tried a bunch of compression crates (brotli, flate2, miniz_oxide, ruzstd, zopfli, libflate, lzma-rs, snap, bzip2-rs, zune-inflate, lz4_flex).

Register Stackify went from 0.51s to 0.19s, 2.6x faster.

I also tried linear algebra crates (nalgebra, glam, and cgmath), and compilation got 4.3x faster.

The bug shows up everywhere, but it really gets expensive when a function gets big.

The target (wasm32-unknown-unknown, wasm32-wasip1, etc.) also doesn’t make any difference.

Who pays for it today

Rust users targeting any wasm target with debug info enabled are affected, including the default dev profile and release profiles with debug = 2 (debug = 1 isn’t affected).

C and C++ users compiling wasm with clang and -g are affected too, which is how issue #168326 in LLVM first appeared.

More codegen units or no debug info work around the problem until the patch lands, but that’s not ideal.

Is it going to be fixed once Rust updates its LLVM fork to include the fix originally made for clang?

Let’s see:

Module Current Rust Upstream LLVM fix Ours
repro RegStackify 45.37s 6.32s 1.17s
repro total codegen 52.58s 7.12s 1.99s
sha2 RegStackify 3.04s 2.35s 0.83s
sha2 total codegen 3.46s 2.75s 1.21s
k256 RegStackify 5.07s 0.67s 0.22s
k256 total codegen 6.79s 1.86s 1.42s

Well… no. An LLVM update will definitely help, but not as much as the changes proposed here.