Equana

All articles
July 12, 2026·Max Schurigwebassemblyperformancearchitecture

Running LAPACK-style kernels in the browser: zero-copy WASM via SharedArrayBuffer

How Equana's TypeScript interpreter and its WASM kernels share one linear memory, why slices allocate nothing, and what the benchmarks honestly say.

Equana is a scientific-computing notebook that runs entirely in the browser — the language interpreter is TypeScript, and the numerical heavy lifting happens in WebAssembly. This post is about the seam between those two worlds, because that seam is where browser numerics usually goes wrong. The short version: Equana's interpreter and all of its WASM kernels operate on a single shared linear memory, and data never crosses a boundary by being copied. Here's how that works, why it's designed that way, and what it costs.

The copy tax

The default way to use WASM from JavaScript looks like this: you have data in a JS Float64Array, you copy it into the WASM module's linear memory, the kernel runs, and you copy the result back out into a fresh JS array. Every call pays serialization twice.

For one large operation, fine — the kernel dominates. But scientific code isn't one large operation. It's chains of small and medium ones: slice a matrix, multiply, add a vector, take a norm. A 1000×1000 Float64 matrix is 8 MB; copy two operands in and one result out and you've moved 24 MB of memory to do one multiply. Do that for every step of an expression like A * B + C * D and the copy tax dwarfs the arithmetic for anything that isn't huge. Worse, each WASM module traditionally owns its own memory, so composing two kernels means copying between modules too.

The conclusion I kept arriving at: the memory has to be shared, and it has to be shared by everything.

One shared linear memory

In Equana, a single SharedArrayBuffer is the backing store for all numerical data — every array, matrix, and tensor in a session. The interpreter manages an allocator over this buffer. When you write:

A = [1.0, 2.0, 3.0]

the interpreter allocates a block in shared memory and writes the three floats there. The value bound to A inside the interpreter is not the data — it's a handle: a pointer (byte offset into the buffer), a dtype, a shape, and strides.

If that description sounds familiar, it should. Pointer plus dimensions plus strides is exactly how BLAS and LAPACK have described matrices since the Fortran days. dgemm doesn't take a "matrix object"; it takes a pointer, m, n, k, and leading dimensions. Equana's handle is the same idea, so handing an array to a LAPACK-style kernel requires no translation layer at all.

What gemm actually receives

Walk through a matrix multiply. The interpreter evaluates A * B where both are 2-D Float64 arrays, and dispatches to a WASM kernel. What crosses the JS↔WASM boundary is a handful of integers: A's pointer, its dimensions and strides, the same for B. The kernel reads the operands directly out of the shared buffer and writes the result directly back into it. Nothing is serialized, nothing is copied — the "argument passing" is a dozen scalars.

Output allocation is the one subtle part. The kernel needs somewhere to put the result, but I didn't want WASM binaries owning memory — that's how you end up with per-module heaps and copying again. So the host exports an allocator function into every WASM instance. When a kernel needs an output buffer, it calls back into the host allocator, which carves a block out of the same shared memory and tracks it. The kernel returns the pointer; the interpreter wraps it in a handle. Kernels never free anything and never own anything — allocation lifetime is entirely the interpreter's problem (that's the reference-counting story, which gets its own post).

Slicing is where the design pays off most visibly:

B = A[1:50, :] # first 50 rows of A

This allocates nothing. B is a new handle with a different shape, different strides, and an offset pointing into A's block — a view, in the NumPy sense. Passing B to a kernel works identically because kernels already speak strides. Row slices, column slices, strided slices: all just handle arithmetic over memory that never moves.

One .wasm file per function, fetched lazily

The kernels themselves are compiled as single-function binaries: gemm_f64.wasm is a file, gemm_f32.wasm is another file, and so on across the BLAS/LAPACK-style surface. That sounds wasteful compared to one big module, but it inverts nicely on the web: binaries are loaded lazily at runtime, so only the operations your notebook actually calls get fetched. A notebook that does FFTs downloads FFT kernels; it never pays for the sparse solvers.

The language side dispatches to typed binaries by name interpolation. A generic function resolves its type parameter at call time and builds the binary name from it:

function r = gemm(A: Matrix<T>, B: Matrix<T>) { r = wacall("gemm_${typename(T)}", A.ptr, A.rows, A.cols, B.ptr, B.rows, B.cols) }

Call it with Float64 matrices and wacall fetches (once) and invokes gemm_f64.wasm; with Float32, gemm_f32.wasm. One generic body covers every supported dtype, and the fetch cache means each binary is downloaded and instantiated at most once per session.

The backend concept is deliberately open and trampoline-style: WASM is the default that runs everywhere, with WebGPU in the browser and native CPU/GPU backends (OpenBLAS, cuBLAS) via the Tauri desktop app. The dispatcher picks the best available backend at runtime based on the environment and the data — the handle carries which backend owns its memory, so functions stay backend-agnostic.

The web-worker angle, and the price of admission

SharedArrayBuffer is the one browser primitive that makes all of this possible, and it doesn't come free. Since Spectre, browsers only expose it on cross-origin isolated pages: you must serve Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy headers, and everything your page embeds has to cooperate. This is genuine deployment pain — third-party scripts, embeds, and CDN assets all need to be CORP/CORS-clean, and you discover the violations one broken resource at a time.

The payoff is worth it twice over. First, threading: the kernel doesn't run on the UI thread at all. Equana's notebook kernel lives in a web worker, and because the SharedArrayBuffer is visible to every worker, moving computation off the main thread involves no data transfer — the UI stays responsive during a long factorization, and a running cell can be interrupted. Second, parallelism: multiple workers can operate on the same memory simultaneously. Each worker runs its own interpreter instance over the shared buffer, and multi-threaded kernels split one operation across workers. There's no GIL to work around, because the memory model was multi-threaded from day one.

Numbers, honestly

Benchmarks in blog posts deserve suspicion, so here is exactly one workload, with its setup stated. Matrix multiply, Float64, measured on a Ryzen 9950X (32 threads):

N=1000 matmulTimeThroughput
Naive JavaScript~649 ms3.1 GFLOPS
WASM SIMD, 1 thread~57 ms35 GFLOPS
WASM, multi-threaded~15 ms130 GFLOPS

At N=4000, the multi-threaded WASM kernel finishes in ~312 ms — about 410 GFLOPS, which is on par with a native-SSE OpenBLAS build (~400 GFLOPS reference) on the same machine.

A few honest caveats. Matmul is the friendliest possible workload for this comparison — dense, regular, compute-bound; memory-bound operations won't show gaps like this. It's one machine, and browser thread scheduling varies. And "naive JavaScript" means a straightforward triple loop, which is the right baseline for "what you'd get without kernels" but not the best JS can theoretically do. Still, the shape of the result is the point: WASM SIMD buys roughly an order of magnitude over plain JS, threads buy most of another, and at larger sizes the browser lands in the same league as a native BLAS. Five years ago I would not have believed that last sentence.

For environments where the binaries or SharedArrayBuffer aren't available, TypeScript fallbacks exist for the core operations — slower, but the notebook still works.

What's not solved

Equana is in alpha, and this architecture has open edges. Not every operation has a WASM kernel yet — some still run on the TypeScript fallback path, and we add kernels roughly in order of user pain. The allocator over the shared buffer is deliberately simple, and the escape hatch for a session that has gotten into a bad state is a kernel restart, which disposes all backend memory in bulk. WebGPU dispatch exists in the design but is early. And cross-origin isolation remains a deployment constraint we have to carry forever.

But the core bet — one shared linear memory, handles instead of copies, kernels that speak BLAS's native language — has held up under everything we've thrown at it. If you want to poke at it, equana.dev runs in your browser (alpha, sign-in required), and the interpreter is on npm as equana. The companion post on how the interpreter frees all this memory — reference counting instead of GC — is next.

Next: Reference counting instead of GC: deterministic memory for a browser notebook