Performance
make bench produces the CPU figures, Philox included,
and make bench-target the GPU figures.
CPU
Apple M4 and AMD EPYC 7702P, one thread, make bench,
clang -O2, minimum of seven runs of 2^24 elements, in
GiB/s. Each machine's columns come from one session and give the median
of three passes. The EPYC session ran on one pinned core. A plain
make gets these columns: on x86-64 it runs the AVX2 and FMA
copy wherever the CPU has them. The arm64 object code is the same with
the exact fma emulation of the base copy, so the M4 figures stand.
| M4 | M4 with TANDEM_NO_SIMD |
mt19937, M4 | Philox4x32-10, M4 | |
|---|---|---|---|---|
tandem_fill_u32 |
19.0 | 11.8 | 2.7 | 2.5 |
tandem_fill_u64 |
19.1 | 11.7 | 5.0 | 2.4 |
tandem_fill_f32 |
16.4 | 10.7 | 2.8 | 2.4 |
tandem_fill_f64 |
16.5 | 10.6 | 5.1 | 2.4 |
tandem_fill_normal_f64 |
7.6 | 5.8 | 0.90 | - |
tandem_fill_normal_f32 |
5.5 | 4.6 | 0.67 | - |
tandem_fill_exponential_f64 |
6.1 | 5.0 | 1.1 | - |
tandem_fill_exponential_f32 |
6.7 | 5.4 | 1.6 | - |
| EPYC 7702P | mt19937, EPYC | Philox4x32-10, EPYC | |
|---|---|---|---|
tandem_fill_u32 |
8.9 | 1.1 | 1.1 |
tandem_fill_u64 |
8.8 | 2.2 | 1.1 |
tandem_fill_f32 |
8.4 | 0.72 | 1.0 |
tandem_fill_f64 |
7.4 | 1.2 | 0.97 |
tandem_fill_normal_f64 |
3.6 | 0.13 | - |
tandem_fill_normal_f32 |
3.0 | 0.070 | - |
tandem_fill_exponential_f64 |
3.4 | 0.46 | - |
tandem_fill_exponential_f32 |
3.8 | 0.37 | - |
tandem_next_f64 chain |
3.1 | 1.1 | 0.95 |
The mt19937 column is std::mt19937 for u32 and f32 and
std::mt19937_64 for u64 and f64, with
std::uniform_real_distribution,
std::normal_distribution and
std::exponential_distribution of libc++ for the floats,
from tools/bench_std.cpp. On the EPYC an earlier session
found the same file about twice as fast against libstdc++ as against
libc++ on every row, so that column is libstdc++, GCC 14.4. Random123's
Box-Muller on Philox wrote fewer normals in an earlier session. The
Philox column is tools/bench_philox.c: Random123 1.14.0
philox4x32 with 10 rounds, one block per call, written to
the buffer with the bit-to-float maps of tandem.c. Clang
does not vectorize its 32x32-bit products, so it runs scalar. The chain
row sums 2^24 tandem_next_f64 draws,
std::uniform_real_distribution<double> draws on
std::mt19937_64, or Philox draws.
The EPYC build is conda-forge clang 23.1.2 at plain -O2
with no -m flags, so it runs the AVX2 copy picked at run
time, whose code the exact fma emulation leaves unchanged. The base
copy, which TANDEM_NO_AVX2 selects, ran at 6.0 GiB/s for
tandem_fill_u32 and 2.8 for
tandem_fill_normal_f64 in that session, and its Float32
normal and exponential fills at 0.17 and 0.15, with a library
fma call per multiply-add. The base copy now rounds each
multiply-add by the exact emulation in the vectorized loop instead, and
its figures await a remeasure. GCC 12 at -O2 reaches 9.1
GiB/s for tandem_fill_u32 and 3.2 for
tandem_fill_normal_f64, but only 0.55 for
tandem_fill_normal_f32. GCC honours
-fno-math-errno only on the command line, not as a function
attribute, so sqrt keeps its errno branch and the
Box-Muller loop stays scalar.
The normal and exponential rows count the bytes written. The f32
normal and the exponential fills run their vectorized loops after the
float fill, so they depend on TANDEM_NO_SIMD only through
the uniforms. The f64 normal fill runs a table pass after the u64 fill,
which TANDEM_NO_SIMD slows. Before the ziggurat, the
Box-Muller f64 normal fill ran at 5.0 GiB/s on the M4 and 2.4 on the
EPYC.
The row loop keeps the eight lane states in registers and stores each
row by a vector transpose, which is where the throughput comes from.
Float fills map the words to floats in the same loop, before the store.
On AArch64 the fixed-point ucvtf does the shift and the
scale in one instruction. The 32x32 to 64-bit products are NEON
umull and SSE2 pmuludq through intrinsics:
from the portable spelling, a widened 64-bit vector multiply, GCC emits
scalar multiplies on AArch64 and three pmuludq per product
on x86. Clang gives the figures in the table. GCC 16 at -O2
reaches 14.5 GiB/s for tandem_fill_u32 on the same machine
and 16.1 at -O3, because it keeps the lane states in memory
for part of the row loop. The scalar fallback is one straight-line step
per lane in a loop over the eight lanes. Clang vectorizes that loop, GCC
16 does not at -O2 and reaches 5.6 GiB/s.
Scalar draws
Apple M4, one thread, tools/bench_scalar.c and
tools/bench_std.cpp, clang -O2. Each chain
sums 2^24 dependent draws, minimum of seven runs, median of three passes
from one session, in GiB/s of output at 8 bytes per draw.
| Tandem | TANDEM_NO_SIMD |
xoshiro256++ | mt19937_64 | Philox4x32-10 | |
|---|---|---|---|---|---|
tandem_next_u64 chain |
10.6 | 7.0 | 10.7 | 4.9 | - |
tandem_next_f64 chain |
9.7 | 6.7 | 10.4 | 5.5 | 2.2 |
tandem_next_f64 chain, through a function pointer |
5.7 | 4.8 | - | - | - |
xoshiro256++ is the public-domain reference code, inlined into the
loop. mt19937_64 is the libc++ engine, with
std::uniform_real_distribution<double> for f64.
Philox draws two f64 from each block of
tools/bench_philox.c. The f64 chains wait on the latency of
each double add.
The 32- and 64-bit draws are inline functions in
tandem.h. A draw inside the readable row is a load and an
add in the caller. The one call, tandem_refill, makes the
next row readable and steps the cache one row further, into the other of
its two row slots. A loop so keeps the position in a register, and no
draw reads a row that was just stored, which store forwarding serves
badly. The library also exports the draws as functions for callers from
other languages, which pay a call per draw: the function pointer row.
Before the inline draws, tandem_next_f64 was such a call
everywhere and its chain ran at 5.2 GiB/s.
GPU
OpenMP target offload on an A100 40 GB PCIe (driver 570, GPU idle
before each run) with nvc 25.3: make bench-target writes
2^28 elements into device memory at 480 to 850 GiB/s over ten runs,
median about 570, the same for all four types, against 1386 GiB/s for
the CUDA kernels of tandem-cuda. Build with
OMP_FLAGS="-mp=gpu -gpu=cc80" CC=nvc.
Other generators
make bench also runs tools/bench_std.cpp,
the C++ wrapper against the generators of the C and C++ standard
libraries on the M4 (Apple clang 21, libc++), in GiB/s, from the same
session as the CPU table. The standard generators have no fill
interface, so each writes one value per call. Each loop has its own
generator. The mt19937 figures move with code layout: in a program with
only that loop, std::mt19937_64 with
std::uniform_real_distribution<double> reaches 8.2
GiB/s. The two tandem::rng scalar rows and the
uint64_t chain row come from the session of the scalar
draws.
| GiB/s | |
|---|---|
tandem::rng::fill<uint32_t> |
19.2 |
tandem::rng::fill<uint64_t> |
19.0 |
tandem::rng::fill<float> |
16.5 |
tandem::rng::fill<double> |
16.5 |
tandem::rng::next<double> chain |
8.9 |
std::uniform_real_distribution<double> on
tandem::rng |
8.2 |
std::mt19937, uint32_t |
2.7 |
std::mt19937_64, uint64_t |
5.0 |
std::mt19937_64, uint64_t, chain |
4.9 |
std::mt19937_64 with
std::generate_canonical<double, 53> |
5.1 |
std::mt19937_64 with
std::uniform_real_distribution<double>, chain |
5.5 |
arc4random_buf |
4.5 |
rand(), 31 bits per call into
uint32_t |
0.92 |
random(), 31 bits per call into
uint32_t |
2.5 |
std::mt19937 with
std::uniform_real_distribution<float> |
2.8 |
std::mt19937_64 with
std::uniform_real_distribution<double> |
5.1 |
std::mt19937_64 with
std::normal_distribution<double> |
0.90 |
std::mt19937 with
std::normal_distribution<float> |
0.67 |
std::mt19937_64 with
std::exponential_distribution<double> |
1.1 |
std::mt19937 with
std::exponential_distribution<float> |
1.6 |