Chapter 25

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:

#What it does not25.3

And here is what you should do yourself:

#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:

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:

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:

#Practical rules25.8

  1. Keep values in locals. Spill to the shadow stack only what must have an address.
  2. Use 32-bit memory and pointers unless you truly need more than 4 GiB.
  3. Use multiple results to return small aggregates.
  4. Use memory.copy and memory.fill rather than loops.
  5. Give your language SIMD types, and lay hot data out as structures of arrays.
  6. Batch boundary crossings, and pass bulk data as typed array views.
  7. Emit the name section in development, strip it in release.
  8. Run wasm-opt for release builds.
  9. Measure in steady state, not in the first second.
  10. 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.