tandem rng tandem-mojo GitHub

Performance

pixi run bench and pixi run bench-exponential produce the CPU figures, pixi run bench-gpu the GPU figures.

CPU

One thread, minimum of seven runs of 2^24 elements (2^22 and five runs for the exponentials), in GiB/s of output, every row from one session. A next_f64 draw counts 8 bytes. The baseline is the Mojo standard library: Philox4x32-10 of std.random.philox for the plain fills, its NormalRandom for the f32 normals, and std.random for the rest: rand for bounded integers, randn for the f64 normals, -log(1 - random_float64()) for the exponentials and random_float64 for the chain.

Apple M4 Pro Tandem Mojo standard library
fill_u32 17.8 1.42
fill_u64 18.0 1.42
fill_f32 15.6 1.38
fill_f64 15.4 1.37
fill_below_u32, bound 1000 11.5 0.27
fill_below_u64, bound 1000 10.4 0.53
fill_normal_f32 5.16 0.33
fill_normal_f64 6.59 0.15
fill_exponential_f32 6.12 0.16
fill_exponential_f64 5.50 0.32
next_f64 chain 8.71 0.58

The CPU fill converts floats in the same pass that stores the row. The 32-bit low word of each product is a plain vector multiply, and only the high word is a widening one: taking both from one 64-bit product made LLVM emit two widening multiplies. The bounded and normal fills do extra arithmetic per draw, so they run below the plain rate. The f32 normals are limited by the vector pipes, not by memory. The f64 normals spend most of their time in the fallbacks of the 0.43 % of draws that miss the ziggurat's inner rectangles.

A scalar draw reads one word of the cached row through a pointer. Indexing the 32-wide row vector by a runtime index spilled the whole vector on every draw, which held the chain at 2.8 GiB/s. F's rounds unroll on the GPU only: on the CPU the unrolled copy inlines into the row seek of each scalar draw and cost the chain a third.

GPU

GPU fills into device memory, pixi run bench-gpu, 1 GiB per fill, minimum of 21 after a half-second warm-up, in GiB/s. The GPU had no other process, and a second run agreed within 1 %. The cuRAND column is Philox4x32-10 of cuRAND 10.3.9 in the same run, by the same method, through its host API in the DeviceContext's context. cuRAND has no 64-bit integer output for Philox, so the u64 row's figure is curandGenerate writing the same bytes as 32-bit words, marked "nearest".

NVIDIA A100 40 GB PCIe GiB/s cuRAND Philox4x32-10 cuRAND call
fill_u32_gpu 1379 1304 curandGenerate
fill_u64_gpu 1379 1306 curandGenerate, nearest
fill_f32_gpu 1378 1287 curandGenerateUniform
fill_f64_gpu 1378 801 curandGenerateUniformDouble

For K a multiple of 8, the default, the kernel stages eight steps of 32 groups in 32 KiB of shared memory and stores them as 512 contiguous bytes per warp, as tandem-cuda's tile kernel does, at the card's memory bandwidth. Other K take one thread per chunk with direct stores. The fills ran at 1171 to 1201 GiB/s before the tile and before F's round constants were unrolled: indexing them with the round number put them in local memory.