· 4 min read

The branch predictor cannot force your thunk

Compiling a lazy functional language to WebAssembly works best when demand analysis removes thunks before lowering; branch hints can only annotate the predictable control flow left behind.

Aerial photograph of a classification yard where dozens of rail tracks fan out from a single hump before converging again.
Carsten Steger, CC BY-SA 4.0

WebAssembly is not the problem. Your thunk is. The source has one case, but a lazy compiler may reach it only after classifying a closure, entering an unknown function, and updating the heap.

Plan.hs
data Plan = Free | Prolabel user =  case plan user of    Free -> "free"    Pro  -> "pro"

I lost an afternoon staring at a tidy br_table in a Wasm dump. The constructor switch came last; the thunk-entry path three blocks earlier had already burned the time.

Source

I wrote one case expression.

CPU

Is the scrutinee a value, thunk, function, or partial application?

Source

It is a Plan.

CPU

That was not the question.

Laziness adds a conversation before the branch you wrote.

The case arrives late#

Call-by-need represents deferred work as a heap object. Evaluating plan user can mean reading its info table, deciding whether the object has already been evaluated, jumping through its entry pointer, and replacing the thunk with an indirection to the result. The second visit follows a different path from the first.

  1. Load the closure

    plan user may still be suspended work.

  2. Classify it

    Value, thunk, function, or partial application?

  3. Run the thunk

    Follow an indirect target into the evaluator.

  4. Update the result

    Memoize the value for the next demand.

  5. Take your branch

    Only now do Free and Pro appear.

The source-level case reaches the constructor after four runtime decisions.

The processor sees native machine code produced by the Wasm engine, so its dynamic predictor can still learn regular behavior. Laziness makes that behavior depend on history: cold data takes the thunk path, warm data the value path. A polymorphic entry point muddies it further by sending each closure through different code.

I haven't measured your evaluator. A cache miss on the closure header or the indirect target may cost more than the misprediction. Either way, the useful compiler move happens before Wasm, back where the frontend still knows why each of those dynamic choices exists.

Lowering sketch
;; STG-shaped sketch, not literal compiler outputlocal.get $plani32.load8_u offset=$closure_kindbr_table $value $thunk $function $pap$thunk:  local.get $plan  return_call_indirect $enter$value:  local.get $plan  i32.load8_u offset=$constructor  br_table $free $pro

The final br_table is the branch from the source. Everything above it belongs to the evaluation strategy. WebAssembly's tail-call instructions help an evaluator transfer control without growing the native stack, including through an indirect call. That keeps the stack flat, but the engine still has no idea where the call will land.

Delete the uncertainty upstream#

A serious functional compiler does not lower every thunk faithfully and hope the hardware gets good at guessing. GHC's demand and usage analyses can pass strict arguments by value, unbox them, omit absent work, and turn a one-use thunk into a non-memoised computation. Worker/wrapper then keeps the lazy public shape while giving hot code a stricter worker.

The first time I watched worker/wrapper erase an evaluatedness check, I had been trying to tune that check with layout changes. The better branch prediction came from deleting the branch.

Schematic optimized Core
-- Schematic Core after demand analysis and specializationlabel user =  case plan user of    p -> label_worker (constructorTag p)label_worker tag =  case tag of    0# -> "free"    _  -> "pro"

If label always needs the plan, the worker can receive the constructor tag directly. If another consumer sometimes skips the plan, keeping the outer computation lazy still saves real work. Making everything strict would erase that distinction, and the demand analysis exists so the compiler does not have to.

Specialization matters for the same reason. A known closure entry becomes a direct call, and once the constructor set is known the classification collapses into a compact switch. A strict, unboxed field stops asking whether a pointer names work or a value. By the time the compiler emits Wasm, most of what remains should be branches the source actually contains.

Hints come last#

WebAssembly's branch-hinting proposal attaches likely or unlikely metadata to if and br_if. An engine may use those hints for code layout and register allocation, or ignore them. They cannot turncall_indirect into a known target, and they cannot tell whether this closure was forced five microseconds ago.

Profile the residual branches instead: error paths, or a constructor distribution that stays stable in production. A static hint learned from cold startup can become backward after the heap warms, so phase behavior belongs in the benchmark rather than in a programmer's hunch.

GHC's Wasm backend already has to bridge a Haskell calling convention into WebAssembly and runs a continuation-passing transformation near the end of lowering. That machinery is where laziness meets the target, and a likely-bit on the last branch helps only after those passes have settled everything they can.

I still keep the Wasm dump open, but the next tab is the demand dump now. Whencall_indirect sits at the top of the profile, I stop decorating the constructor switch and look for the proof the compiler failed to carry across the boundary.