Chapter 9

The WASM Compiler

Pure-numeric functions, straight to WebAssembly

Bloom has a third execution path beyond the tree-walking interpreter and the bytecode VM: a WebAssembly compiler. It takes Bloom functions that do nothing but arithmetic and compiles them directly from the AST into a real WASM module the browser runs at near-native speed. It lives in src/lang/wasm-compiler.ts.

Currently a docs demo The WASM backend is wired up for the How Bloom Works pipeline explorer (the final "WASM" stage), where you can watch any eligible function turn into WebAssembly text. It is not yet the default runtime for sketches — those run on the VM. This page explains how the compiler decides what it can compile and what it emits.

The Numeric Model

WebAssembly is typed; Bloom is not. To bridge the gap, the compiler picks one type and uses it everywhere: every value is an f64 (a 64-bit float). Numbers are obviously f64. Booleans and comparisons produce 1.0 for true and 0.0 for false, exactly like C. There are no strings, arrays, objects, or colors inside a compiled function — only floating-point math.

Unlike the VM (which compiles the AST to a flat list of jump-based opcodes), WASM has structured control flowblock, loop, if/else, br. So the compiler walks the AST and emits matching structured instructions directly, rather than translating bytecode.

Eligibility: What Can Be Compiled

A function is compiled only if every construct it uses fits the numeric subset. computeEligible() figures this out in two phases.

First, each function is checked on its own (isLocallyEligible()): it may use arithmetic, comparisons, if/while/for, local lets, return, numeric parameters, and a handful of supported math built-ins (sin, cos, sqrt, abs, floor, min, max, …). Anything outside that — a string, an array, a call to circle(), an object literal — disqualifies it.

Second, eligibility propagates across calls. A fixed-point loop repeatedly removes any function that calls a function that isn't eligible, until nothing changes:

src/lang/wasm-compiler.ts — computeEligible()
let changed = true;
while (changed) {
  changed = false;
  for (const name of [...eligible]) {
    for (const callee of collectCallees(fn.body)) {
      // math built-ins are always allowed
      if (callee in WASM_MATH_UNARY || callee in WASM_MATH_BINARY) continue;
      if (!eligible.has(callee)) {
        eligible.delete(name);  // calls something we can't compile
        changed = true;
        break;
      }
    }
  }
}

This is what lets a recursive function like Fibonacci compile: it only calls itself, and it itself is eligible, so the fixed point keeps it in the set.

From AST to Bytes

The compiler builds a real binary WebAssembly module by hand. Three pieces cooperate:

1

WasmModuleBuilder

Assembles the binary sections (Type, Function, Export, Code) and encodes integers as LEB128 and constants as little-endian f64. It writes the magic header \0asm and version, then each section.

2

FunctionCodegen

Walks one FunctionStmt and emits structured WASM opcodes — f64.add, f64.lt, local.get, call, loop/block/br_if — for its body.

3

WebAssembly.Instance

The finished bytes are handed to new WebAssembly.Module() / Instance(). Each eligible function becomes a callable JS export.

The whole flow is one function, compileProgramToWasm(source), which returns a CompiledWasmProgram (the module bytes, a map of callable functions, and a WAT text view) — or null if nothing is eligible, signalling the caller to fall back.

Booleans as Floats

Because everything is an f64, comparisons need a tiny bit of care. WASM's f64.lt pushes an i32 (0 or 1), so the codegen converts it back to f64 with f64.convert_i32_u. An if condition does the reverse: it compares the f64 against zero to get the i32 that if expects. This keeps the "one type everywhere" invariant intact while still using WASM's native comparison ops.

The WAT View

Binary WASM is unreadable, so toWat(source) renders the eligible functions as WebAssembly text — the human-readable S-expression form. This is exactly what the pipeline explorer shows in its final stage. For a simple square function you'd see something like:

WAT output
(func $square (param $x f64) (result f64)
  local.get $x
  local.get $x
  f64.mul)

How It Fits the Big Picture

The three backends form a ladder of speed against generality:

Interpreter
Bytecode VM
WASM
Handles
Everything
Everything except modules
Pure-numeric functions only
Speed
Reference
Much faster
Near-native
Status
Fallback
Default
Demo / explorer

In the Source Code

Everything here is in src/lang/wasm-compiler.ts. Key entry points:

See it live in the How Bloom Works explorer — type a numeric function and watch the WAT appear.