Same code, three backends: the TS/WASM/native FFI dispatch chain
Every numerical function in Equana runs three ways — native system libraries, a portable WASM binary, and a TypeScript fallback — behind one call. Here is how the dispatch chain is wired.
When you call solve(A, b) in Equana, one of three completely different things happens depending on where you are running. On the desktop app it can call into a system LAPACK — OpenBLAS, Apple Accelerate — through a native foreign function interface. In any browser it calls a hand-compiled WebAssembly kernel. And if neither is available, it runs a pure TypeScript implementation that always works. Same function name, same arguments, same result — three radically different execution paths, chosen at call time.
Getting that right is what makes Equana both fast and dependable. The fast paths give you native-class throughput; the slow path guarantees the notebook never simply fails to run an operation. This post walks the dispatch chain that ties them together, the marshalling that moves values across the FFI boundary, and why the humble TypeScript fallback is not optional. It doubles as the contributor guide for adding a function — the canonical version lives in the interpreter's FFI-GUIDE.md.
Three tiers, tried in order
Every numerical function is defined in the interpreter's prelude as a small Equana function whose body is a fallback ladder. Here is solve, close to how it is actually written:
function r = solve(A, b) {
try { r = nlibcall("lapack.solve", A, b) } catch e {
try { r = nbincall("lapack.solve", A, b) } catch e2 {
# WASM LAPACK fallback via dgesv
let Af = A * 1.0 # promote to float64 — LAPACK wants doubles
let bf = b * 1.0
let n = size(Af, 1)
let ipiv = walloc(n * 4) # int32 pivot scratch
let Ac = wacopy(Af) # dgesv destroys A
r = wacopy(bf) # dgesv overwrites b with the solution
wacall("gesv_f64", n, 1, wptr(Ac), n, ipiv, wptr(r), n)
}
}
}
Three builtins, three tiers:
nlibcall— desktop only. It dispatches to a system-installed library through Tauri IPC into Rust, which resolves the symbol withdlsymand calls into native BLAS/LAPACK. The Rust side knows the LAPACK calling convention, so it handles pivot allocation and Fortran by-reference semantics for you. This is the fastest path; native OpenBLAS references around 400 GFLOPS fordgemmon a Ryzen 9950X.nbincall— the same structured interface, but against a pre-compiled native binary shipped with the app and loaded viadlopen, rather than a library the user happened to have installed.wacall— the portable WASM fallback, and the default that runs everywhere including the browser. It calls a hand-written.wasmkernel, lazy-loaded on first use.
The ordering is deliberate: try the fastest thing that might be present, fall through to the next on any failure. The try/catch is the whole control-flow mechanism — if the system library is not installed, nlibcall throws and we drop to nbincall; if no native binary shipped, we drop to wacall. On the web, the first two throw immediately and every call lands on WASM. The desktop story — how nlibcall reaches real OpenBLAS through Tauri — is its own post: the desktop app with Tauri.
The WASM tier is C calling convention, laid bare
Notice how much more explicit the wacall branch is than nlibcall. That is not accidental. nlibcall and nbincall present a structured, high-level interface — you hand them arrays and they marshal everything. wacall calls a raw C function, so you speak C: pointers, dimensions, leading dimensions, and scratch buffers, passed one positional argument at a time.
# C signature: int dgesv(size_t n, size_t nrhs, double* A, size_t lda,
# int* ipiv, double* B, size_t ldb)
let ipiv = walloc(n * 4) # allocate int32 scratch
let Ac = wacopy(A) # copy inputs; the kernel overwrites them
let x = wacopy(b)
let info = wacall("gesv_f64", n, 1, wptr(Ac), n, ipiv, wptr(x), n)
walloc reserves raw bytes in the shared WASM memory, wptr extracts the byte offset from an NDArray, and wacopy makes an independent copy where a kernel will destroy its input. The kernel reads and writes those buffers in place — no copying across the boundary, because the interpreter and every WASM binary share one linear memory. That shared-memory design is covered in zero-copy WASM via SharedArrayBuffer; here the point is that the WASM tier trades convenience for total control over the C ABI.
Marshalling: what actually crosses the boundary
A WASM function takes numbers, not Equana values. Converting between the two is two small, total functions. Going in:
export function marshalToWasm(v: EqValue, span: Span): number | bigint {
switch (v.type) {
case 'Int': return v.value
case 'Float': return v.value
case 'Bool': return v.value ? 1 : 0
case 'NDArray': return ndHandle(v).inner.byteOffset
default:
throw new EqException('TypeError', `wacall: cannot marshal ${v.type} to wasm`, span)
}
}
That last case is the crux of the zero-copy story: an NDArray does not get serialized, it gets reduced to its byte offset into the shared buffer — exactly the double* a BLAS routine expects. Coming back out, marshalFromWasm turns a returned scalar into an Equana Int or Float (small integers become Int, everything else Float, bigint becomes Int). A void kernel that wrote its result into a buffer you passed by pointer returns Nothing. The marshalling layer is intentionally thin because the interesting data never travels through it — only handles and scalars do.
Why the TypeScript fallback is mandatory
It would be tempting to treat the pure-TypeScript implementation as a nice-to-have and skip it for functions that "obviously" always have a WASM kernel. Equana treats it as mandatory, for three reasons.
Universal availability. The WASM tier depends on SharedArrayBuffer, which depends on cross-origin isolation, which depends on the deployment cooperating. Environments exist — a stripped embedding context, a test runner, a platform that has not granted isolation — where the binaries or the shared buffer are not there. A function with a TypeScript fallback still returns the right answer, slower. A function without one throws. The floor of the whole system is "the notebook always runs," and TypeScript is that floor.
Development velocity. A new function can ship with only its TypeScript implementation and be correct and testable the same day. The WASM kernel and the native dispatch are optimizations you add when the operation shows up on a profile. You never block a feature on writing and compiling C.
Robustness and reference. The TypeScript implementation is also the executable specification. When a hand-written SIMD kernel and the reference disagree, the reference is what you trust, and every tier is tested against it. Three implementations of one function that must agree is a strong correctness harness — the fallback is not dead weight, it is the oracle.
Where kcall fits
The prelude ladder above is the explicit, per-function fallback. Alongside it, higher-level array operations flow through kcall, the kernel dispatcher, which resolves an implementation from the interpreter's kernel registry keyed on the operation name, the backend that owns the data, and the dtype:
const impl = this.kernelRegistry.resolve(kernelName, backendType, resolvedDtype)
backendType is read from the NDArray arguments themselves — a handle carries whether its memory lives on the wasm backend or the typescript backend — so an array allocated in WASM memory resolves to the WASM kernel, and one on the TS backend resolves to the TS kernel, automatically. The nlibcall → nbincall → wacall ladder and the kcall registry are two views of the same principle: pick the most capable implementation available for this data, in this environment, right now, and keep the function's public identity single.
Adding a function
Concretely, a fully-wired function touches these points, in the order you actually do them:
- TypeScript first — write and test the fallback. The function is now correct everywhere.
- WASM kernel — C/C++ source under
packages/numwa/src/bin/, exporting anextern "C"function over the shared memory. - Manifest — declare the binary in
generate-package-manifests.tsand regenerate so it lands in R2 with its variants. - Lazy registration — map the binary name to its package so
LazyWasmRegistrycan fetch it on first call (see one .wasm file per function). - Native dispatch — add the Rust arm in
nlibcall.rsfor the desktop system-library path.
Steps 2–5 are all optional accelerations layered onto a function that already works from step 1. That ordering is the whole philosophy in miniature: correctness is mandatory and comes free in TypeScript; speed is earned, tier by tier, without ever changing what the function is.
The engine runs in your browser at equana.dev. For the layer that delivers those WASM kernels one file at a time, read one .wasm file per function; for how the native tier reaches real BLAS on the desktop, the desktop app with Tauri.