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.
- The Float64 normals take the exact logarithm only on the 0.43 % of draws that miss the ziggurat's inner rectangles, so they keep the emulated fma and stay bit exact with tandem-c.
- The Float64 exponentials are bit exact with tandem-c. The WebAssembly kernel maps them two lanes at a time: the plain chain of the logarithm with a per-lane bound that proves each plain sum is the fused one, and the emulated fused chain for the few pairs that fail. In JavaScript alone, where engines lack WebAssembly SIMD, the same check runs per step at 0.40 GiB/s and the full emulation ran at 0.26. The plain chain alone, one ulp off tandem-c, ran at 1.01.
- The Float32 exponentials fuse exactly too. The kernel maps them four lanes at a time with each fused multiply-add built from a Veltkamp split, Dekker's product and the fused sum, and checks against the JavaScript map on all 2^24 inputs. In JavaScript alone, which emulates each fused multiply-add in double, the two-float logarithm of 2026-10-07 runs at 0.09 GiB/s.
- The Float32 normals round the plain multiply-add and stay within 4 ulps of tandem-c.
- A 4-wide unrolled map from words to floats measured the same as the
plain loop, and a 16-bit-limb
mulHiis 1.8 times faster than a double product in the JavaScript lane loop. - GPU exponentials (
fillExponential,exponential_f32) deviate from the tandem-cuda fixtures by at most 0.9 ulp under Deno, within the 8 ulps plus 1e-6 the tests allow.
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/.