Skip to content

The WASM backend

MVP — not yet in the native pipeline

The WASM backend is not yet wired into the build driver, registry, or CLI — a wasm module is not a linkable .o, so it does not go through the native pipeline the way C / QBE / LLVM do. Use WASMBackend().emit(module) to get WAT and assemble it with wasm-tools. It lowers scalars, array element ops, and libm calls; the generalized-ufunc loop, sub-word ints, heap allocation, complex, and pipeline integration are follow-ups.

The wasm backend lowers the shared typed-SSA IR to WebAssembly text (WAT), which wasm-tools assembles to a .wasm module and wasmtime runs. Unlike the C, QBE, and LLVM backends — which produce a native object linked into a .so and called via the C ABI — a wasm module is self-contained and runs in a wasm VM; it exports pp_<name> directly (there are no C export wrappers inside a wasm module).

Its output is held to the same rule as the other non-reference backends: byte-identical to C wherever the arithmetic is exact. That equivalence is the correctness oracle.

Pipeline

IR Module
  → emit_module  →  WAT text (.wat)
  → wasm-tools parse out.wat -o out.wasm      (WAT → wasm binary)
  → wasmtime: instantiate, invoke pp_<func>   (in-process, typed args/results)

WASMBackend.emit returns the WAT text; the module exports every public function as pp_<name>. Because a wasm module is not a native object, there is no compile_object/link step — the harness assembles and runs it directly (see Correctness).

What makes it different

Two things set this backend apart from the others, and from a general Python→wasm compiler such as our own p2w (from which the WAT-assembly approach is reused, while the value model diverges):

  • Unboxed. POST is a typed numeric subset, so each dtype maps to a native wasm value type and each IR value maps to a mutable wasm local. Because wasm locals are mutable, a reassigned local is just a local.set — there are no stack slots (unlike the QBE/LLVM alloc/alloca model). This is the opposite of p2w, which boxes every Python value behind WASM-GC structs (ref.i31, $INT64, $FLOAT, …) to support dynamic typing.
  • CFG → structured control flow. The IR is an unstructured CFG of basic blocks and branches, but WAT has only structured control flow (block/loop/if/br). This is the one problem an AST→wasm compiler never meets, and it is the backend's core innovation.

The dispatch loop

A single-block function emits straight-line WAT. A multi-block function is lowered to a br_table dispatch loop:

(local.set $__block (i32.const 0))   ;; entry is block 0
(loop $dispatch
  (block $b0 (block $b1 (block $b2
    (br_table $b0 $b1 $b2 (local.get $__block)))  ;; jump to state's block
    <block 2 code + terminator>)
    <block 1 code + terminator>)
  <block 0 code + terminator>)
(unreachable)                        ;; the loop never falls through

A $__block state local holds the current block index; the br_table jumps to it. Each block ends with its terminator: a branch sets the next state and br $dispatch; a conditional branch does so in an if/else; a return emits (return …), which unwinds all enclosing blocks directly. Because every block ends by branching or returning, the loop never falls through — the trailing (unreachable) tells the validator so, satisfying the function's result type.

Coverage

Scalar int/float/bool arithmetic including floor-div, mod, integer **, abs, casts, compare, and select; control flow; and array element ops (load/store/dim/stride/len). Arrays live in the module's exported memory64 linear memory: using i64 addresses makes the frontend's i64 byte-index arithmetic and the native __pp_array layout (all-i64 fields) map directly, with no i32 wrapping. An array (or output-by-pointer) parameter is passed as the i64 memory offset of its struct, and the module declares/exports its memory whenever a function needs one. The host marshals arrays into linear memory and reads results back (see Correctness).

wasm has no pow/exp/sqrt intrinsics, so float ** and any call to a POST-visible libm function (postc.math.exp, …) lower to (call $sym …) plus an (import "libm" "sym" …) synthesised from the call site (like the LLVM declare-every-callee rule, but as a wasm import). The host supplies each import from the same system libm the C backend links, so results stay byte-identical. What it does not lower yet:

Not lowered Why / status
The generalized-ufunc loop; heap Alloc follow-ups on top of the array ABI
Complex64/128, Float16, sub-word ints not modelled in the MVP
Registry / build-driver / CLI selection a wasm module is not a linkable .o

Fail loud, never miscompile

Any IR construct the backend cannot lower raises WASMUnsupportedError, so a build fails clearly rather than silently changing behavior.

Type mapping

POST Python dtype wasm value type
Bool, Int32, UInt32 i32
Int64, UInt64 i64
Float32 f32
Float64 f64

Signedness is carried by the operation (div_s/div_u, extend_i32_s/_u, trunc_f64_s/_u), since wasm integer types are sign-agnostic. Mixed-type operands are coerced to C's usual-arithmetic-conversion common type first. Python floor-div / mod are built from div_s/rem_s with a sign-correction term; integer ** is exponentiation-by-squaring inlined as a loop (a negative signed exponent yields 0, matching __pp_ipow_*); float mod is a - b*trunc(a/b) and float floor-div uses f64.floor/f32.floor — no host imports.

Correctness

Every kernel is compiled through the C and WASM backends and their results compared: the C result via the ctypes ABI, the wasm result by assembling with wasm-tools and invoking the exported pp_<func> in-process with wasmtime (a wheel, in the wasm extra). The two must agree byte-for-byte, and agree with CPython where POST and Python semantics coincide. The br_table dispatch is exercised with if, while, and nested for kernels; array kernels (a 1-D dot reduction, a 2-D matmul) are checked by marshalling __pp_array structs into the module's linear memory, calling, and reading the output back.

See also