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/LLVMalloc/allocamodel). This is the opposite ofp2w, 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¶
- The LLVM backend — the sibling package this one's layout mirrors.
- The C backend — the correctness oracle it is measured against.
- IR reference — the input all backends consume.