2026-09-06

A Stan runtime that runs on wasm

#stanwasm #Stan #WebAssembly #Rust

Lately I have been hooked on data analysis and WebAssembly.
There is something about it that feels like the mixed martial arts of AI and computing, and I like that.
On the not-very-considered grounds that Bayesian inference in the browser would be fun, I built stanwasm, which runs Stan on WebAssembly.
This post introduces it, and tries to put the thinking behind it into words.
stanwasm takes a subset of the Stan language and does everything — parsing, compilation, sampling — inside the browser.
The parser, the evaluator, the autodiff tape and the code generator were written from scratch in Rust for this project; only the sampler is borrowed, by compiling nuts-rs by Adrian Seyboldt and the PyMC developers to wasm.

A demo of Bayesian inference with stanwasm. Sampling runs in the browser with nothing installed
habakan/stanwasmA Stan subset that runs entirely in the browser. Apache-2.0, on npm as stanwasmgithub.com

It is still alpha and the API may change, but npm install [email protected] will get you it.

import init, { StanModel } from "stanwasm";
await init();

const model = new StanModel(stanCode, JSON.stringify(data));
const draws = model.sample(model.randomInit(42n), 1000, 1000, 42n);
//                          init, warmup, draws, seed

It is not a replacement for CmdStan or Stan Playground; it aims at the "embed it in a browser" case those do not fit.
That said, after looking at several designs, keeping everything inside the browser and simple ended up meaning that almost none of Stan's existing assets are used.
So what this really is, is a runtime of my own that samples with NUTS from a model written in the Stan language.

What to watch out for when putting Stan on wasm

Let me start with a quick tour of Stan's toolchain.
Stan is implemented on top of C++.
You describe a probabilistic graphical model in Stan's syntax, and from that, the sampling code that computes the posterior with MCMC and friends is compiled.
The distributions written in Stan are parsed and then turned into derivatives so that HMC can sample them. What handles the differentiation is Stan Math Library, an autodiff library optimised for speed.
It is this long-maintained C++ code that lets Stan sample posteriors fast.

If it is implemented in C++, surely emscripten can turn it into wasm right away? Some of you will think that, but there are a few obstacles in the way.
They come down to the fact that a compiler is part of the toolchain that gets you from Stan source to a posterior.

Laid out, the structure looks like this.

Stan's toolchain A model written in Stan is turned into C++ by stanc3, which is written in OCaml. That C++ is built into an executable by a C++ compiler on your machine, together with Stan Math for autodiff and stan::services for the sampler. Running it produces draws as CSV. CmdStan drives this build and adds a CLI; CmdStanPy and CmdStanR call CmdStan. RStan and PyStan wire the same parts through their own paths. The Stan core repository carries Stan Math as a submodule, and Stan Math in turn carries Eigen, Boost, TBB and SUNDIALS. Stan's toolchain INTERFACES CmdStan · CmdStanPy · CmdStanR · RStan · PyStan The API you call. All of them run the build below, so a C++ compiler is required data JSON / Rdump compile() sample(data=...) THE BUILD PATH — THE INTERFACES DRIVE IT model.stan Stan language stanc3 Stan → C++ model.hpp model code (C++) C++ compiler executable PER MODEL draws (CSV) LINKED IN AT COMPILE TIME STAN CORE — STAN-DEV/STAN Stan Math — autodiff SUBMODULE · STAN-DEV/MATH stan::services · mcmc NUTS / HMC Eigen Boost TBB SUNDIALS Both the derivatives and the sampler are linked into the executable at compile time

model.stan is first turned into C++ source (model.hpp) by stanc3.
That is then built by a C++ compiler on your machine, together with Stan Math for autodiff and stan::services for NUTS / HMC.
What comes out is an executable specific to that model; run it and the draws come out as CSV.
Both the derivatives and the sampler are pulled into the executable at this point.
Whichever interface you use, this build runs, so you need a C++ compiler on hand.
Only the data is passed at run time, so the same executable can be reused with different data.

If the interface is a web page where someone writes Stan, then of course the runtime cannot know in advance which distributions or functions will show up.
That narrows things to two options.

One is to reproduce the existing path in the browser.
On top of stanc3 and Stan Math, that means bringing the C++ compiler itself into the browser.

The other is to compile every distribution and function Stan offers ahead of time, and ship the whole set as a runtime.
Since you cannot know which model is coming, you end up carrying things that are never used.
No compiler is needed, but the download grows accordingly.

So the question for running Stan on wasm becomes: how do you represent Stan's toolchain compactly in the world of wasm, and for that, what representation belongs between the Stan source and the sampler? The answer I settled on for now was to make the autodiff graph an explicit intermediate representation, and emit wasm instructions from it.

How stanwasm is put together

Before that, let me set out why autodiff and a tape are needed at all.

NUTS and HMC look at the gradient of the posterior to choose the next point.
To run sampling, you need to know how each distribution in the model contributes to the log density — its gradient.

When distributions and operations are chained inside a model, gradients compose through the chain rule.
Doing that automatically is autodiff.

The composition runs backwards, from the output towards the inputs, so it cannot be computed unless you know which operations built the log likelihood, and in what order.
So you record the process of the computation itself.
That record is what is called a tape.

Stan, in the course of compiling a model into an executable, pulls in the code that computes that model's derivatives by autodiff.
Because it builds per model, the types of the nodes pushed onto the tape are fixed to that model too.
This is what makes its posterior computation fast.

Where stanwasm diverges is when the tape is built, and what is done with it afterwards.

Laid out in the same frame, the stanwasm side looks like this.

stanwasm's toolchain Hand source and data to new StanModel and, once at load time, parsing and data binding run, recording into an autodiff tape while evaluating. Two paths use that recorded tape. The default, sample, has nuts-rs read the tape on every gradient. The other calls compileToWasm, which takes the parsed model rather than the tape, re-records a tape while evaluating with dummy parameters, and emits a wasm module for that model. The bytes are instantiated on the JS side and bound with setAotExports; calling sampleViaAot has nuts-rs call that wasm. Two paths out of the same model. stanwasm's toolchain INTERFACES npm: stanwasm (JS) · pystanwasm (Python on Pyodide) The API you call. Both just load the same wasm; there is no build step data JSON new StanModel(src, data) ONCE, AT LOAD TIME model.stan Stan language parser recursive descent autodiff tape record while evaluating Records while evaluating. Recorded here, once only. Data is bound here and enters the tape. codegen does not run yet. TWO PATHS FROM THE TAPE sample(...) Default. Uses the tape above as is nuts-rs the same tape Reads op and branches on every gradient. The tape is not rebuilt. draws compileToWasm() → sampleViaAot(...) Input is the parsed model, not the tape above codegen re-records per-model wasm nuts-rs draws Calls this wasm on every gradient. No branching, no indirection.

stanwasm builds the tape once, at load time. Inside new StanModel(src, data) it parses, binds the data, and records while evaluating — all of that is done by then.

On the path from the previous section, the operations that build and run the derivatives land inside the executable at the moment the C++ is compiled.

stanwasm turns the forward and backward passes into a wasm module after it has seen a specific model in the browser. What makes that possible is that stanwasm keeps the derivatives explicitly as data. Stan Math builds a tape at run time too. var_stack_ is a sequence of vari_base*, and grad() reads it in reverse. The difference is not whether there is a tape, but what the nodes hold.

Read the tape every time, or emit instructions once

stanwasm's Tape is a struct that just holds op, arg1, val and grad as arrays of the same length. op[i] is Op::Mul — that is, the value 3 — and the backward pass reads it and branches on it itself.

What it means for the tape to be a sequence of values is that you can change how you read it. Read it front to back and you get the log density; read it back to front and you get the gradient; read it forwards while emitting instructions and you get a wasm module for that model.

What is on the autodiff tape, and three ways to read it Each row of the tape is an operation, its inputs, and where the result goes. The inputs are the data y and the parameters mu and sigma. Read forwards and the log density comes out. Read backwards and the partial derivatives of each intermediate accumulate into the gradient. Code generation writes both out as instructions and collects them into log_prob_grad, one function that returns the log density and the gradient together. The tape is then read once when building, not on every gradient. A tape row is an op, its inputs, and where the result goes INPUTS y DATA mu PARAM sigma PARAM TAPE #1 Sub y, mu → v1 #2 Div #1, sigma → v2 #3 Mul #2, #2 → v3 #4 Log sigma → v4 lp = -0.5 * v3 - v4 - 0.5*log(2pi) READ FORWARD compute each row in order, out comes the log density lp READ BACKWARD accumulate ∂lp/∂v for each v, and the gradient comes out The same tape, written out as instructions instead of computed Same reading directions as above. The difference: instead of producing a value, each row lays down its wasm instruction. READ AND EMIT FORWARD → VALUES #1 f64x2.sub #2 f64x2.div #3 f64x2.mul #4 call log BACKWARD → GRADIENT #4 f64x2.div #3 f64x2.mul #2 f64x2.div #1 f64x2.add export log_prob_grad returns log density + gradient Not a switch — both sit side by side in one function NUTS-RS CALLS IT FOR EVERY GRADIENT
Read the same tape forwards for values, backwards for gradients, and while emitting for wasm

Let me first say why you would do that.

NUTS calls for a gradient tens of thousands of times, and over all of those the structure of the computation never changes once.
What changes is only the values of the parameters.
The path that reads the tape is re-reading that settled structure on every gradient.
If the structure is fixed from the start, it should be enough to build code with it baked in once, and then just call that.
Call it the difference between an interpreter and compiled code, if you like.

What it takes is simple.
The backward pass reads op[i] and branches on "the derivative of this operation is this"; you swap that branch for "the wasm instruction for this operation is this" (stanwasm-codegen).
The shape of the tape being read is the same; the only difference is whether, on seeing Op::Mul, you multiply there and then, or lay down one multiply instruction.
In the implementation the load-time tape is not reused — it reads one recorded afresh with dummy parameters.

This is done for both directions, and the instructions that compute the log density and the instructions that accumulate the gradient are collected into a single function, log_prob_grad.
It is not that the log density and the gradient are switched between; both sets of instructions sit side by side in the same function. NUTS needs both at once anyway.

Stan Math cannot do the same thing.
Calling the chain method attached to a node advances the differentiation, but you cannot pull out "what operation is this" as a value. Which chain gets called was fixed when the C++ was compiled.
Adding a mechanism to emit instructions would mean rebuilding Stan Math, and we are back to the original problem of needing a compiler.

What you do with the recorded tape What you do with the autodiff tape built at load time splits two ways. On the left the tape is read: on every gradient, each node's op is loaded, branched on, and its values dereferenced. The route that reuses Stan's existing assets sits on this side too; the call shape differs but the per-node dispatch remains. With Stan Math you call the chain method attached to a node, and what operation it is cannot be pulled out as a value. On the right the tape is emitted: reading it when building produces a module of wasm instructions, so the per-gradient branching and indirection are gone. This side is 1.5 to 12 times faster per gradient. What you do with the recorded tape autodiff tape built in the figure above READ EVERY TIME — the tape backwards, per gradient sample(...) match self.op[i] { Op::Mul => grad[a1] += g * vb, Per gradient, repeated for every node: load op[i] load arg1[i] branch on match · deref grad[a1] Reusing Stan's assets lands on this side too Even shipping a precompiled runtime, the per-node dispatch remains. var_stack_[i]->chain() Stan Math looks like this. Calling the chain attached to a node advances the derivative. But “what operation” is not available as a value. Different call shape, same per-node dispatch. EMIT — read when building, not per gradient compileToWasm() → sampleViaAot(...) match self.op[i] { Op::Mul => f.instruction(F64x2Mul), Inside the wasm. This runs on every gradient: local.get 2 · local.get 5 f64x2.mul local.set 7 No branching, no indirection. The numbers are immediates. Written in WAT-like notation; the actual output is binary. 1.5–12x The same match arm, swapped between computing a value and emitting an instruction. This works because the tape is data, not code that gets executed.

The instructions being f64x2 SIMD is also something only a per-model module can do. When you read a tape generically you do not know what operation comes next, so you cannot decide to pair up two doubles.

What the implementation showed

A rough implementation seems to get this far.

Size 664 KB, 253 KB gzipped. It is all in the npm package; there is no per-model download
Cold start Blank page to first draw: 0.4 s at 50 Mbps, 1.6 s at 1.6 Mbps
Platforms Chromium, Firefox, WebKit. iOS included
Execution paths Reading the tape, and emitting a per-model wasm. There is also a path that re-records per gradient, for models whose shape moves with the parameters

Correctness is checked against posteriordb, a collection of Stan models with reference results.

What was checked Result
Whether load, gradient evaluation and compilation go through 141 of 147 posteriors
Whether log density and gradient match CmdStan 2.38.0 Worst 3.3e-13 over 16 models; 13 of them under 1e-14
Whether posterior means match the reference draws 42 of 45 posteriors with references within 0.2 sd; none over 0.5 sd
How much faster the per-model wasm is 1.5–12x per gradient over reading the tape

The posterior mean check is means only; R-hat, ESS and divergences have not been compared yet.
The 1.5–12x is also an internal comparison between my own two paths, not a benchmark against Stan.

That said, because so much of this was written from scratch, plenty has been given up.

  • The language is a subset of Stan, not all of it
  • The sampler is separate code, so the same seed does not reproduce Stan's draws
  • Fixes to Stan Math do not flow through. The special functions and the distribution edge cases were written and tested here

And above all, things whose computation changes shape with the parameters sit badly with recording once.
Adaptive ODE solvers are the clear example: how they take steps depends on the parameters, so a recorded graph freezes into the shape it had at the tracing point.
The record-and-replay path refuses these, and sampleFresh() re-records the tape on every gradient instead. integrate_ode_rk45 works on this path, at 5–8x the cost of replay.
With ode_rk4_fixed, where the caller fixes the step count, the graph does not move, so it runs on replay and AOT alike. integrate_ode_bdf is not there yet.

There is no single right answer to what belongs between the Stan source and the sampler.
Putting a static graph on top of precompiled kernels is one, and I think the difference is just in the trade between compatibility, artifact size and how far you specialise.

Indeed, stanli goes down that road.
I only learned it existed after building stanwasm.
It makes good use of existing assets such as Stan Math while putting the per-model autodiff on wasm as an interpreter, which I think is wonderful.

Closing

To run Stan models with nothing but a browser, I ended up holding the autodiff graph as an explicit intermediate representation and emitting wasm instructions from it.
It is still alpha and the language subset is narrow, but it is something you can try without installing anything.

I thought I was thinking about Bayes, and somewhere along the way it turned into a study of assembly and compilers.