Performance
A module's speed depends on two compilers: yours, and the engine's. This chapter explains what the engine's does, in V8 in particular, so that you know which optimisations to leave to it and which to do yourself. It then shows Binaryen's optimiser at work, covers code size and start-up time, and finishes with a list of practical rules.
#How engines compile25.1
Every major engine compiles WebAssembly in tiers:
| Engine | First tier | Optimising tier |
|---|---|---|
| V8 (Chrome, Edge, Node) | Liftoff | TurboFan |
| SpiderMonkey (Firefox) | Baseline | Ion |
| JavaScriptCore (Safari) | an in-place interpreter, then BBQ | OMG |
| Wasmtime | Winch (optional) | Cranelift |
The first tier exists to start running quickly. Liftoff, for instance, reads each function once, front to back, and emits machine code as it goes, tracking the operand stack in registers where it can and spilling where it cannot. It does no optimisation across instructions: no common subexpression elimination, no loop-invariant code motion, no inlining, and only simple register allocation. It compiles many times faster than the optimising tier, fast enough to keep up with a download, which is why large modules can start almost immediately.
As functions run, V8 counts how much time each one spends, and recompiles
hot ones with TurboFan on a background thread, replacing the Liftoff code
when it is ready. TurboFan is a real optimising compiler: it builds an SSA
graph, inlines small functions (including, speculatively, the usual
targets of call_indirect and call_ref), hoists loop invariants,
eliminates redundant bounds checks for GC arrays, and allocates registers
properly. Code that runs for more than a fraction of a second spends most
of its life in TurboFan code.
#What the engine does for you25.2
Because the optimising tier is a real compiler, a lot of classic backend work is done for you:
- Instruction selection.
(i32.add (local.get 0) (i32.const 1))becomes one machine instruction. Addressing modes fold the offset immediate and even shifts into loads. - Register allocation. Locals and operand stack slots become machine registers, or stack slots in the engine's own frame.
- Bounds checks. On 64-bit platforms, 32-bit memory accesses are checked by guard pages (Chapter 9), so they cost nothing.
- Inlining, of small and hot functions, though less aggressively than an ahead-of-time compiler, since time spent compiling is time the user waits.
- Common subexpressions, constant folding, dead code, within a function after inlining.
#What it does not25.3
And here is what you should do yourself:
- Anything the first tier needs. Code runs in Liftoff before it is hot, and short-lived code may never leave it. Start-up paths, such as level loading, benefit from code that is already reasonably good.
- Inlining across a module, and specialisation. Engines inline conservatively; your compiler knows far more about which calls matter.
- High-level optimisations. Escape analysis to keep values out of the shadow stack, scalar replacement of aggregates, removal of redundant bounds or null checks that your language inserted: the engine sees only the comparison and the branch, not what they mean.
- Loop optimisations such as strength reduction and vectorisation. Engines do not auto-vectorise.
- Data layout. Structure of arrays versus array of structures, field ordering, and alignment are decided by your compiler and nobody else.
#Binaryen's optimiser25.4
Many compilers do no WebAssembly-level optimisation of their own, and run
Binaryen's wasm-opt over their output instead. It has a few hundred
passes, written specifically for WebAssembly: local coalescing,
redundant-set elimination, inlining, dead code and dead global removal,
constant propagation, code folding, and a set of passes for WasmGC.
Here is code a naive code generator might produce, with a temporary local for every intermediate value and a helper that is called once:
(module
(func $square (param $x i32) (result i32)
(local $t i32)
(local.set $t (i32.mul (local.get $x) (local.get $x)))
(local.get $t))
(func (export "f") (param $a i32) (param $b i32) (result i32)
(local $t1 i32) (local $t2 i32) (local $t3 i32)
(local.set $t1 (call $square (local.get $a)))
(local.set $t2 (call $square (local.get $b)))
(local.set $t3 (i32.add (local.get $t1) (local.get $t2)))
(if (i32.const 0)
(then (local.set $t3 (i32.const 99))))
(local.get $t3)))
wasm-opt -O3 naive.wasm -o opt.wasm
wasm2wat opt.wasm
wc -c naive.wasm opt.wasm
(module
(type (;0;) (func (param i32 i32) (result i32)))
(func (;0;) (type 0) (param i32 i32) (result i32)
local.get 0
local.get 0
i32.mul
local.get 1
local.get 1
i32.mul
i32.add)
(export "f" (func 0)))
136 naive.wasm
45 opt.wasm
181 total
The helper was inlined, the temporaries disappeared, the dead if was
removed, and the name section was dropped (pass -g to keep it). Engines
would have arrived at much the same machine code from either version in
their optimising tier, but the first tier, the download and the compile
all benefit from the smaller one.
wasm-opt -O3 optimises for speed, -Oz for size, and -O2 and -Os are
quicker versions of each. Running it costs build time, so a common setup is
to skip it in development builds and run it for release.
#Code size and start-up25.5
On the web, a module must be downloaded, then compiled, before it runs. Both scale with its size:
- Download. WebAssembly compresses well, typically to a third or less with Brotli or gzip, so serve it compressed. Size still matters on mobile connections.
- Compilation. With streaming compilation, Liftoff keeps up with download speeds, so for most modules the code is ready when the last byte arrives. Very large modules, tens of megabytes, can still take a noticeable time, and TurboFan's background work competes with the game for cores.
- Caching. Browsers cache the compiled code of modules loaded with
instantiateStreaming, keyed by URL, so repeat visits skip most compilation. Changing the module's URL, or its bytes, invalidates the cache.
To keep modules small: avoid unbounded monomorphisation, strip the name
section from release builds, prefer table-driven code to large unrolled
code, make sure the runtime library is not dragging in unused features,
and run wasm-opt -Oz when size matters more than the last few per cent
of speed. wasm-objdump -h and Binaryen's --func-metrics show which
sections and functions take the space.
A large program can also be split: a small module to start the game
quickly, and further modules, linked as Chapter 8 described, loaded later
for levels or features. Binaryen's wasm-split automates this for
programs compiled as one module.
#The cost of the boundary25.6
Calls between WebAssembly and JavaScript used to be expensive, and the folklore persists. In current engines, a call in either direction with numeric arguments costs a few nanoseconds, and V8 inlines small JavaScript-to-WebAssembly calls in its JavaScript optimiser. What still costs:
- BigInt conversion for
i64parameters, which allocates. - Anything the JavaScript side does with the arguments, such as creating strings or typed arrays per call.
- The browser API on the other side, which is usually the real cost.
So the advice of Chapter 16 stands: prefer numeric parameters, batch small calls, and pass bulk data as views over memory.
#Memory64 and other costs25.7
A few features have costs worth knowing about:
- memory64 needs an explicit bounds check on every access, since the guard page trick does not cover 64-bit addresses. Engines are working on reducing it, but expect a slowdown of a few per cent to tens of per cent on memory-heavy code.
- Multiple memories cost a little more per access to any memory but the first, since its base address may not be kept in a register.
call_indirectcosts a bounds check, a signature check and an indirect jump;call_refskips the signature check. Hot indirect calls to the same target are often inlined speculatively.- GC allocation is fast, a bump allocation in the engine's young generation, but every object has a header and costs collector work.
#Practical rules25.8
- Keep values in locals. Spill to the shadow stack only what must have an address.
- Use 32-bit memory and pointers unless you truly need more than 4 GiB.
- Use multiple results to return small aggregates.
- Use
memory.copyandmemory.fillrather than loops. - Give your language SIMD types, and lay hot data out as structures of arrays.
- Batch boundary crossings, and pass bulk data as typed array views.
- Emit the name section in development, strip it in release.
- Run
wasm-optfor release builds. - Measure in steady state, not in the first second.
- Test the start-up path too, since it runs in the first tier.
#What you have seen25.9
Engines compile in tiers: a fast, simple first tier to start quickly, and
an optimising tier for hot code. The optimising tier handles instruction
selection, register allocation, bounds checks and some inlining, so your
compiler should concentrate on what needs your language's semantics:
escape analysis, check elimination, data layout and vectorisation.
wasm-opt cleans up simple code generators' output. Code size affects
download and compilation, and the boundary is cheaper than its
reputation.
That ends Part VII. The last part is about making sure your compiler's output is correct.