Your hand-written WASM matmul beats OpenBLAS-in-WASM — here's why
A hand-written WebAssembly dgemm kernel sustains 37.9 GFLOPS single-thread where OpenBLAS compiled to WASM manages 30.7. The reason is what native BLAS assumes about the machine — and what the WASM compile takes away.
Here is a result that should not happen, if you believe the usual story about hand-tuned libraries. On a Ryzen 9950X, Float64, N=4000, single-threaded: Equana's hand-written WebAssembly dgemm kernel sustains 37.9 GFLOPS. OpenBLAS — the reference open-source BLAS, decades of assembly micro-kernels by people who tune matrix multiply for a living — compiled to WebAssembly on the same problem manages about 30.7 GFLOPS. The kernel I wrote in an afternoon beats the world-class library by roughly 23%.
This is not because my kernel is cleverer than OpenBLAS. Run both natively and OpenBLAS wins by a mile. It's because OpenBLAS's cleverness doesn't survive the compile to WebAssembly, and mine was written for the machine WASM actually is. That distinction is the whole post.
What OpenBLAS assumes
OpenBLAS is fast because it is written against a specific microarchitecture. Its dgemm inner kernels are hand-written assembly, one variant per instruction set — Haswell, Zen, Skylake-X — and each one is a precise negotiation with the physical machine. Three assumptions in particular carry most of the performance:
- A large named register file. x86-64 with AVX gives you 16 named vector registers (32 under AVX-512). OpenBLAS's micro-kernel picks a tile — say 8×6 accumulators of
Float64— sized so the entire accumulator grid plus the operand vectors live in physical registers for the whole inner loop, never spilling. The exact tile shape is chosen against the exact register count. - Wide SIMD. Those registers are 256 bits (AVX) or 512 bits (AVX-512) wide — four or eight
Float64lanes per instruction. The tile shape and the flop-per-load ratio are tuned around that lane width. - Hardware prefetch and explicit prefetch intrinsics. The kernel issues
prefetchinstructions to pull the next strip of the packed panel into cache before the arithmetic needs it, hiding memory latency behind compute.
Every one of these is an assumption about hardware that WebAssembly does not expose.
What the WASM compile takes away
When you run OpenBLAS in the browser, you don't run that assembly. You run OpenBLAS's C reference kernels — or its assembly path best-effort retargeted — through emscripten to WebAssembly, and every one of those three assumptions breaks:
- There is no named register file in the bytecode. WASM is a stack machine: values are pushed and popped, not named. There are no architectural registers in the bytecode at all. The browser's baseline or optimizing compiler does its own register allocation from the stack ops onto the real CPU when it JITs the module — and it is a different allocator than the one OpenBLAS's tile shape was designed for. An 8×6 tile tuned to fit 16 AVX registers has no guarantee of fitting after that second allocation pass; ask for too many live values and the browser's allocator spills them to linear memory, and the kernel that was supposed to be register-resident starts round-tripping through the cache.
- SIMD is 128 bits, full stop. The WASM SIMD proposal standardized a single 128-bit
v128type — twoFloat64lanes. There is nof64x4, no AVX-512 analog, in the browser. OpenBLAS's whole tile geometry is sized for 256- or 512-bit lanes; drop it onto 128-bit lanes and the carefully balanced flop-to-load ratio is wrong. You're running a kernel shaped for a vector unit that isn't there. - There are no prefetch intrinsics. WebAssembly has no
prefetchinstruction. The single most important latency-hiding trick in a native GEMM micro-kernel simply cannot be expressed. The best you can do is structure your access pattern so the hardware prefetcher guesses right on its own.
So OpenBLAS-in-WASM is a Formula 1 car with the wheels swapped for the ones off a delivery van. It's still a good engine. But everything downstream of the engine was designed around parts that got left at the border.
What I wrote instead
The winning kernel makes the opposite bet: assume nothing that WASM can't deliver, and exploit everything it can. Concretely, Equana's dgemm micro-kernel is built directly on the 128-bit lane, in Float64 pairs:
// column of A as one f64x2, B element broadcast across the lanes
v128_t a_col = wasm_v128_load(a + p * lda);
acc0 = wasm_f64x2_add(acc0, wasm_f64x2_mul(a_col, wasm_f64x2_splat(b0)));
acc1 = wasm_f64x2_add(acc1, wasm_f64x2_mul(a_col, wasm_f64x2_splat(b1)));
// ...one accumulator per column of the output tile
Three design choices, each the mirror image of an OpenBLAS assumption:
- The tile is sized for the browser's register allocator, not x86's. The micro-kernel keeps a small grid of
f64x2accumulators — few enough that the JIT's own allocation pass keeps them all in physical registers with room for the operand vectors. It was tuned by measuring the point where widening the tile stopped helping, which is exactly the point where the browser started spilling. The tile shape is a property of the WASM toolchain, not of any CPU. f64x2is the native unit, not a downgrade. Because the kernel was designed for two lanes from the start, there's no wasted geometry.wasm_f64x2_splatbroadcasts aBvalue across both lanes; onewasm_f64x2_mulpluswasm_f64x2_addretires two multiply-adds and updates two rows ofC. Nothing is left on the table waiting for lanes that don't exist.- Prefetch is replaced by layout. With no prefetch intrinsic, the operands are packed into contiguous, unit-stride scratch panels before the inner loop runs, so the hardware prefetcher sees a clean linear stream and pulls the next panel in on its own. You can't ask for a prefetch, but you can make one inevitable.
None of this is exotic. It's the standard GEMM playbook — cache blocking, register blocking, packed panels — applied to a machine with exactly two SIMD lanes, no named registers, and no prefetch, instead of a machine with sixteen registers, eight lanes, and explicit prefetch. Same techniques, different constants. The constants are the entire ballgame.
The lesson, stated plainly
Portable performance is a myth in one specific way: a kernel tuned to the bone for one machine is anti-portable, because every constant that made it fast on the original hardware is now wrong. OpenBLAS is the fastest BLAS in the world on the CPU it was written for, and that is exactly why it isn't the fastest BLAS in WebAssembly — its tile shapes, lane widths, and prefetch schedule are load-bearing assumptions about a machine the browser doesn't provide. Compile it anyway and you inherit all the overhead of the tuning with none of the payoff.
Write for the target you actually have — 128-bit lanes, a stack machine, a register allocator you don't control, no prefetch — and you beat the ported library not by being smarter, but by not fighting the platform. That's the whole trick, and it generalizes: when you port a hyper-optimized kernel to a genuinely different execution model, the optimizations don't transfer, the assumptions do, and the assumptions are wrong.
This is the single-thread story. Add WASM threads over the shared memory and the same kernel scales to 410 GFLOPS across 32 cores — enough to sit alongside a native OpenBLAS build. You can run it right now at equana.dev. For the full optimization ladder that produced this kernel, read the dgemm optimization diary; for why every kernel is a separate lazily-loaded binary, one .wasm file per function.