← back to the garden

pi-storm · part 02 of 09

Estimating pi with Rust and WASM

planted · June 4, 2026
#pistorm#rust#webassembly#determinism#monte-carlo

Last week I made a promise: open a browser tab and your machine joins a global computation. Before any of the grid machinery exists, that computation has to exist. This post is about the smallest piece of it, the kernel, and about a design decision inside it that looked minor at the time and turned out to be the most important call in the whole project.

The job is estimating pi by throwing darts.

Randomness that produces a precise number

Take a unit square. Inscribe a quarter circle of radius 1 in it. The quarter circle covers pi/4 of the square's area. Now throw darts at the square uniformly at random and count how many land inside the circle, meaning x squared plus y squared is at most 1. The fraction that lands inside converges to pi/4, so four times that fraction converges to pi.

That is the whole algorithm. What makes it interesting is not cleverness, it is volume. Monte Carlo error shrinks like one over the square root of the sample count, so every additional digit of pi costs roughly a hundred times more darts. A million darts gets you two or three decimal places. Billions get you five or six. Nothing about any individual dart matters; the precision lives entirely in the aggregate. That is exactly the shape of problem you want when your compute nodes are strangers' browser tabs: embarrassingly parallel, tiny inputs, tiny outputs, and the answer only cares about totals.

So the unit of work is small and self-describing:

pi_unit(seed: u64, count: u32) -> { inside: u32, total: u32 }

A node receives a seed and a dart count, returns how many landed inside, and echoes the total back so the result explains itself without joining back to the request. The coordinator sums inside and total across every completed unit and multiplies by four. That is the entire protocol between "random noise" and "a precise number."

The kernel, for real

The kernel is Rust compiled to WebAssembly with wasm-pack. Here it is, trimmed only of comments and boilerplate; this is the code that runs in every browser tab:

const PCG32_MULT: u64 = 6364136223846793005;
const PCG32_INC: u64 = 1442695040888963407; // fixed stream, must be odd

impl Pcg32 {
    fn next_u32(&mut self) -> u32 {
        let old = self.state;
        self.state = old.wrapping_mul(PCG32_MULT).wrapping_add(PCG32_INC);
        let xorshifted = (((old >> 18) ^ old) >> 27) as u32;
        let rot = (old >> 59) as u32;
        xorshifted.rotate_right(rot)
    }

    // Uniform f64 in [0, 1). u32 to f64 is exact (the mantissa has
    // 53 bits) and multiplying by a power of two is exact, so this
    // mapping is bit-identical on every platform.
    fn next_f64_unit(&mut self) -> f64 {
        (self.next_u32() as f64) * (1.0 / 4294967296.0)
    }
}

pub fn pi_unit(seed: u64, count: u32) -> UnitOutput {
    let mut rng = Pcg32::new(seed);
    let mut inside: u32 = 0;
    for _ in 0..count {
        let x = rng.next_f64_unit();
        let y = rng.next_f64_unit();
        if x * x + y * y <= 1.0 {
            inside += 1;
        }
    }
    UnitOutput { inside, total: count }
}

The PRNG is PCG32, the PCG-XSH-RR 64/32 variant from M. E. O'Neill's pcg-random.org. Two decisions here are doing more work than they appear to.

First, it is implemented inline, about twenty lines, instead of pulled in as a crate. That is not performance vanity. The exact output sequence of the generator is now pinned by this repository, not by whatever a dependency does in its next minor version. If a crate quietly changed its seeding procedure, every number in the system would still look plausibly random and every aggregate would still converge to pi, and something subtle downstream would be broken. I wanted the sequence to be mine.

Second, and this is the quiet insight of the whole post: the randomness is seeded, and the seed comes from the coordinator. There is no call to the operating system's entropy anywhere in the compute path. Which means pi_unit is not a random process at all. It is a pure function. Same seed, same count, same output, every single time, on every honest machine.

Deterministic floats, really

The reflex objection is floating point. Everyone has been burned by floats behaving differently across compilers, CPUs, and math libraries, so "bit-identical across machines" sounds naive. But WASM is unusually strict here: its f64 arithmetic is fully IEEE-754 specified. The only wobble the spec permits is in NaN bit patterns, and no operation in this kernel can produce a NaN. The conversion from u32 to f64 is exact because the mantissa has 53 bits, multiplying by two to the minus 32 is exact because it is a power of two, and x * x + y * y <= 1.0 is a plain sequence of IEEE operations with one defined answer.

So the guarantee holds all the way down to the bits: same (seed, count) gives byte-identical {inside, total} on any OS, any CPU, native or WASM. One caveat I noted for the future: this guarantee does not extend to WebGPU, where floating point behavior is deliberately looser. The day this project grows GPU kernels, verification has to change shape. For CPU and WASM, determinism comes free.

To keep that promise from silently rotting, there is a golden vector pinned in the test suite: seed 42, one million darts, exactly 785,866 inside. That gives a pi estimate of 3.143464, off from pi by about 0.0019, which is almost exactly what the one over root N statistics predict for a million samples. The same vector is asserted in three places that must always agree: the native cargo test, a gate the browser runs against the freshly instantiated WASM module before it will accept any work, and a plain TypeScript twin of the kernel on the coordinator. If any of them ever disagrees, the determinism contract is broken, or the kernel changed and its version ID has to be bumped, because results from different kernel versions are never comparable.

Why does WASM earn its place at all, if a TypeScript twin exists? Speed is the obvious answer, and it is real: the compiled kernel chews through a million-dart unit in a couple of milliseconds, hundreds of millions of darts per second on a single core, which is what makes the multi-core numbers in the next post possible. The TS twin is much slower and does not care, because it only ever runs on a tiny fraction of units, ones where the coordinator wants a trusted answer of its own. More on that in a moment.

Why determinism is the actual product

Here is where the seeded PRNG stops being a testing convenience and becomes the foundation of everything that follows. The premise of this project is that none of the participating browsers can be trusted. Any tab can lie, return garbage, or return something plausible but wrong. The classical fix is to recompute everything yourself, which defeats the purpose of a grid.

But if a work unit is a pure function of its inputs, verification collapses into something almost embarrassingly cheap: hand the same (seed, count) to two unrelated strangers and compare the bytes that come back. Eight bytes each. If they match, either both are honest or two strangers independently invented the identical lie. No recomputation, no tolerance thresholds, no "close enough" judgment calls that a clever cheater could hide inside. Byte equality is the whole test, and it only works because of the decision buried in that twenty-line PRNG.

I did not fully appreciate this when I wrote the kernel. I seeded the PRNG because reproducible tests are nice. It took building the verification layer to see that determinism was not a property of the kernel, it was the product. The darts were never really the point.

Previous in the series: every browser a compute node. Next: getting a single browser to use every core it has, in one browser, every core.