tandem rng tandem-webgpu GitHub

Performance

npm run bench:cpu produces the CPU figures and deno task bench the GPU figures. Both tables come from one session on an Apple M4 Pro, in GiB/s of output.

CPU

Node 26, one thread, 2^22 elements, best of five. Each baseline is the fastest generator the runtime offers for that output: crypto.getRandomValues for raw words, otherwise a Math.random loop into an array of the same type, with Box-Muller pairs for the normals and -log(1 - u) for the exponentials.

Apple M4 Pro Tandem baseline
fillU32 14.5 11.1
fillF32 4.65 0.81
fillF64 4.95 1.74
fillU32Below, range 1000 1.67 0.81
fillNormalF64 1.35 0.46
fillNormalF32 0.31 0.23
fillExponentialF64 0.71 0.77
fillExponentialF32 0.40 0.36
nextU32 loop 1.03 0.83

The fills run the WebAssembly SIMD kernel of src/stream.wat. The JavaScript kernel, which engines without WebAssembly SIMD fall back to, fills u32 at about a quarter of that rate. The float fills map the words in a JavaScript loop, which caps them below the u32 fill.

GPU

Deno 2.9 (wgpu, naga), 2^26 words into device memory with no readback, minimum of seven after a warm-up. No WebGPU library ships a generator, so the baseline is a plain Philox4x32-10 shader in tools/bench.ts, one 16-byte block per counter, with the same 16-bit-half mul_hi as the Tandem shader. The derived rows compare with its raw words, marked *.

Apple M4 Pro GPU Tandem Philox4x32-10
fill_u32, one fill per submit 17.3 16.2
fill_u32, 32 fills per submit 140 110
fill_below u32, range 1000, 32 per submit 140 110*
fill_below u32, range 2^31 + 1, 32 per submit 4.9 110*
fill_below u64, range 1000, 32 per submit 137 110*
fill_normal f32, 32 per submit 59.5 110*

A constant 16-byte store per invocation into the same buffer reaches 770 GiB/s, so both generators are bound by the GPU's integer throughput, not by memory. A submit and its completion cost about 0.4 ms on wgpu, so a single fill is latency bound: batch fills with fillMany or fill large buffers. Under Chromium (Dawn, Tint) an earlier session filled 167 GiB/s at 2^26 words with one fill per submit and 213 at 2^24 words with 16 per submit, without a Philox run beside them.

Variants measured on this GPU and rejected, all within noise of the committed shader or slower: a plain * in place of the exact 16-bit-half mul_hi, no bounds check, workgroups of 64 or 128, four steps unrolled per store burst, a lane-major thread mapping, two chunks per invocation, and a workgroup tile with 512-byte writes per SIMD group, which measured five times slower. Off Apple GPUs the raw-word fill takes that tile, fill_tile: under wgpu on an NVIDIA A100 the same shader in tandem-rs ran its kernel at 1344 GiB/s against 1294 for the direct stores. Deno offers no way to pick one of the host's two A100s, so this page has no A100 row.

A bounded fill costs the same as a plain fill until draws reject. A rejection derives a fallback key with three seeding functions and then draws on that stream, so the half-rejecting range above is the worst case and ranges far from 2^31 reject rarely. The normal fill is two passes, the uniform fill and an in-place pair pass, and runs at about 0.45 of the plain fill.

demo/index.html runs the fill in a browser: python3 -m http.server in the repo root and open /demo/.