stealthrocket.tech
Spanruntime

Runtime

Wasm Performance: Profiling Golang Builds and Reading Benchmark Results

Compiling an existing service to WebAssembly is the easy half. The hard half arrives when it runs slower than the native build and every webassembly profiling tool you reach for returns an empty flame graph.

A flat schematic: a tall outlined container on the left densely filled with thin horizontal grey lines beside an identically sized empty container on the right, one violet arrow crossing the gap, both standing on a ticked baseline.
Bodyruntime.log

A service compiles to WebAssembly on the second attempt. It passes its tests. It runs, and it runs about two and a half times slower than the binary it replaced.

This is the point where the ordinary loop (profile, find the hot spot, fix the hot spot) stops working, because the profiler comes back with nothing. Not a wrong answer. An empty one. Every wasm performance investigation starts here, with a benchmark that says the port is slower and no profile that says where.

Where wasm performance profiling goes blind

A sampling profiler is a simple idea executed carefully. Set a timer, and on each tick interrupt the thread and walk the stack, recording which function was executing and who called it. Do that a few hundred times a second and the distribution of samples is the distribution of time.

Both halves of that break inside a module.

The interrupt half needs signal delivery. SIGPROF is what Go’s CPU profiler arms, the same runtime whose opaque stack keeps goroutines from being serialised and what perf relies on to sample a process; inside a WebAssembly guest there is generally no such mechanism, because the guest does not own a thread in the sense the operating system means. The stack-walking half needs a native frame chain: a linked list of saved frame pointers the walker can follow, the same structure CPython walks to print a traceback. WebAssembly’s call stack is the host runtime’s own structure and is not required to look like anything a native walker recognises.

Which is why a Wasm benchmark is usually easy to produce and a Wasm profile is not.

So Go’s pprof compiled to wasip1 will happily give you heap, goroutine, block and mutex profiles, because those are counters the Go runtime maintains in its own code and that code runs fine inside the module. CPU profiling returns an empty profile. That asymmetry is worth internalising before spending an afternoon on it: for Golang builds targeting Wasm, allocation problems are still findable and hot loops are not.

What Golang builds can measure instead

Three approaches, in ascending order of how much they distort what they measure.

Boundary accounting. Before touching the module at all, count the crossings. Every call from host to guest and back has a fixed cost, and every argument that is not a scalar gets copied into linear memory. Instrument the host side, which needs no special support because the host is ordinary native code, and you get a table of how often each import and export is called. A chatty interface shows up immediately here and is the most common cause of a bad wasm performance number. This is also the cheapest form of webassembly profiling available, because none of it runs inside the guest.

Runtime-level counters. Wasmtime, WAMR and the browser engines each expose some amount of their own accounting: compilation time, instantiation time, memory growth events, sometimes per-function entry counts. This tells you nothing about the shape of the hot loop but a great deal about whether the problem is in the loop at all. Modules that spend most of their wall clock being compiled are not rare, especially in short-lived invocations.

Compiled-in instrumentation. The reliable option, and the intrusive one. Compile the module with function-level entry and exit hooks and have it accumulate counts and cycle deltas into a table it exports. That is real webassembly profiling with real attribution (you get a call graph), and the overhead is not small and is not uniform. Small hot functions are penalised hardest, which is precisely where the interesting ones live, so the profile flatters large functions.

Golang performance profiling has historically leaned on pprof for everything, so this is the step teams resist longest. The third option is where most serious work on this target ends up, and the honest framing is that it is a different measurement, not the same measurement through a different lens. It answers “which functions are called how often, in what pattern” very well and “how many cycles does this loop take” only approximately.

A benchmark that is worth comparing

Most published Wasm numbers are not comparable, for a small number of avoidable reasons. If you are producing them:

Report Why it changes the number
Runtime and exact version Cranelift’s output changes between releases; AOT and JIT differ substantially
AOT or JIT, and whether compile time is included Short invocations are often dominated by compilation
SIMD enabled or not A vectorised inner loop compiled without SIMD support can be several times slower
Whether the boundary is in the measured region Excluding it measures the module; including it measures the system
Memory: initial and maximum pages Growth events are not free and a low initial size hides as latency

Without those five, a wasm performance claim is a number attached to an unnamed configuration.

Aside on the thing that is actually usually wrong

Anecdotally — and this is anecdote, not measurement — the most common root cause is neither the instruction stream nor the runtime. It is a data model that was fine when a function call was a jump and is expensive when a function call is a copy. Code that passed a struct by reference and now serialises it, per call, in a loop. The fix is an interface change, not an optimisation, which is unwelcome news at the point where someone has already committed to the port. Whether that means Wasm is the wrong target or the interface was always too chatty is a genuinely open question and it depends on the codebase.

The practical order

Count boundary crossings first. Check compile-versus-execute split second. Reach for golang performance profiling inside the guest third, and only third. Only then reach for instrumentation, because it is the expensive step in both engineering time and measurement distortion.

And take the empty CPU profile as information rather than a broken tool. It is telling you, correctly, that the mechanism it depends on is not present. The mistake is reading it as “no time is being spent here”.

Clarifications6 entries

Questions this raises

Why does a sampling profiler return nothing inside a Wasm module?

Sampling profilers work by interrupting the thread on a timer signal and walking the native stack. Inside a WebAssembly module there is usually no signal delivery, and the call stack is the runtime's own structure rather than a native frame chain, so the walk finds nothing recognisable. The samples arrive; there is just nothing there to attribute them to.

Does Go's pprof work when compiled to Wasm?

CPU profiling does not, because it depends on SIGPROF. Memory, goroutine, block and mutex profiles are bookkeeping the Go runtime does itself and continue to work, which is enough to find allocation problems but not enough to find a hot loop.

What is the realistic overhead of WebAssembly against a native build?

For compute-bound code compiled ahead of time, single digit to low double digit percent is the usual reported range. Anything worse than that is normally not the instruction stream but boundary crossings, memory copies at the host interface, or a missing SIMD path.

Where does the time usually actually go?

At the boundary. Every string, buffer or struct that crosses between host and module gets copied into linear memory, and code that was written assuming a cheap function call pays that copy per call. A chatty interface is the single most common cause of a bad Wasm benchmark.

Does the choice of runtime change the numbers much?

Enough to matter. Wasmtime with Cranelift, WAMR with its AOT mode, and a browser's own compiler make different trade-offs between compile time and code quality, and a module that is fast in one can be noticeably slower in another. Benchmarks that do not name the runtime and its version are not comparable.

Is instrumentation the only option?

In practice it is the reliable one. Compiling with function-level instrumentation and letting the module report its own counters gives attribution the sampling path cannot, at the cost of overhead you have to reason about. Some runtimes now emit enough information for external profilers, but support is uneven per runtime and per version.

Nextindex

Keep reading

The index orders every article from the widest subject to the narrowest; these are the two neighbours on that path.

All articles