Performance

Choose the draw interface

Use sequential rand_next calls when each draw determines the next computation. Use rand_fill! for many values and reuse the destination. Use Stateful for libraries that require Random.AbstractRNG. Stateful caches the current row for sequential draws through the Random API.

rand_at reconstructs each component from its chunk seed and up to K recurrence steps. Use it for addressed access. Sequential draws reuse cached state.

CPU fills distribute groups across Julia threads. Small arrays can be slower with many tasks. Pass nthreads = 1 when an outer loop already uses threads:

using TandemRNG
rng = Tandem8x32(42)
buffer = Vector{Float32}(undef, 1024)
rng = rand_fill!(rng, buffer; nthreads = 1)
rngposition(rng)
0x0000000000008000

normal_fill! and exponential_fill! take the same keyword. They map each group of draws while it is in cache, the Float64 normals by the ziggurat's tables and the others with tandem-c's vectorized polynomial log, cos, and sin. See Measurements for their speed.

For GPU work, allocate on the bound backend and synchronize before measuring completion. Host scalar calls on a GPU-bound generator still run on the host.

Precompilation

PrecompileTools runs automatically during package precompilation. CPU workloads cover all 17 chunk lengths, all 18 draw types, fills, derivations, and Stateful. The default chunk length also covers matrices, views, allocating draws, and bulk normal and exponential draws. This coverage increases precompile time and cache size.

Optional backend extensions precompile host methods when the backend, KernelAbstractions, and GPUArraysCore are installed. Metal also needs GPUArrays. Workloads cover vectors, matrices, supported draw types, and K1/K32/K64.

Backend precompilation requires Julia 1.10.11 or later in 1.10, or Julia 1.11.2 or later. Earlier supported Julia versions retain runtime GPU support. Device compilation also requires Julia 1.12 or later and a working device. Coverage and forced bounds-checking builds skip device workloads. Metal caches host methods during downstream package precompilation without initializing a device.

Persistent device-code reuse depends on the backend, compiler, and target. First use on another device can still compile. Reactant precompiles conversion and tracing entry points. The first Reactant.@compile still builds the executable for its concrete backend and shape.

No GPU is required to load or precompile TandemRNG. Use benchmark/latency.jl in fresh Julia processes to measure first-use latency.

Measurements

CPU

Apple M4 Pro, Julia 1.13.1, 2026-10-06, all CPU and Metal figures from one session. One task, fills of 2^22 elements, GiB/s written, best of three BenchmarkTools passes with alternating generator order, benchmark/draws.jl. Tandem calls the immutable fills with nthreads = 1, and Xoshiro and Random123 the Random API.

one taskTandemXoshiroRandom123 Philox4x32
rand! Float6417.219.81.81
rand! Float3216.819.61.71
rand! UInt3219.626.31.77
randn! Float647.747.191.49
randn! Float325.541.250.74
randexp! Float646.166.351.29
randexp! Float326.781.130.65

In the same session tandem-c reaches 16.6, 16.5 and 19.1 GiB/s for the Float64, Float32 and UInt32 fills, 7.7 and 5.5 for the normals and 6.1 and 6.7 for the exponentials. A UInt32 row of 128 bytes takes 81 vector instructions, so the uniform fills run at the vector issue rate of the core. Xoshiro's normals and exponentials are Julia's ziggurats.

The same fills on 14 threads. Tandem fills through Stateful. Xoshiro and Random123 have no threaded fill, so each task fills its own contiguous chunk with its own generator.

14 tasksTandem StatefulXoshiroRandom123 Philox4x32
rand! Float6411111711.2
rand! Float3210812610.3
rand! UInt3212615011.3
randn! Float6449.447.99.22
randn! Float3236.79.764.76
randexp! Float6441.441.98.09
randexp! Float3243.710.84.18

Scalar chains of 1024 Float64 draws, which span two complete K = 32 groups and so include the reseeding, one task, three passes with alternating generator order, GiB/s at 8 bytes per draw, compare_cpu in benchmark/benchmarks.jl. Tandem chains rand_next, and the references draw through the Random sampler.

generatorchain, GiB/s
Tandem7.60–7.60
Random123 Philox4x321.83–1.83
Random123 Philox4x642.99–3.05
Xoshiro10.2–10.4

tandem-c's own Float64 chain benchmark reaches 5.2 GiB/s in the same session. Each Tandem draw that ends inside its block reads the held block and changes no state. Every second Float64 draw crosses a block and rotates the row state by one lane.

Float64 normals are the ziggurat: a table pass over each group of UInt64 draws, three draws per test. The 0.43 % of draws that miss queue across groups and resolve eight at a time on fallbacks seeded eight wide, as in tandem-c. Float32 normals and the exponentials run tandem-c's polynomials, vectorized two doubles or four floats wide with four interleaved iterations. Every float fill converts the words in the row loop before the store.

AMD EPYC 7702P (AVX2), Julia 1.13.1, 2026-10-06, one session. One task, 2^20 elements, three BenchmarkTools passes with alternating generator order, compare_cpu in benchmark/benchmarks.jl. Every case below allocates zero bytes.

generatorFloat64 fill, GiB/sFloat32 fill, GiB/sUInt32 fill, GiB/s
Tandem9.37–9.3810.06–10.0714.68–14.68
Xoshiro6.41–6.4114.24–14.2416.63–16.63
Random123 Philox4x641.40–1.410.71–0.720.77–0.78
Random123 Philox4x320.77–0.770.67–0.680.71–0.72

Random123 1.7.1 supplies 23/52 random bits for these Float32/Float64 APIs; the other generators supply 24/53.

GPU

NVIDIA A100 40 GB core measurements (2026-09-21), idle GPU, three passes. Compilation precedes a 0.5-second warm-up; each figure uses the minimum of 30 CUDA event timings.

Tandem K=32Random123 Philox4x32
core draws, one chain/thread, GiB/s generated4657–46822861–2901

The core comparison calls Random123's actual Philox function in an identical kernel, folding all generated words into one stored checksum per chunk. Tandem includes seeding. Its core throughput is about 1.6× Random123's.

A100 fills (2026-10-06), one session on an idle GPU, CUDA.jl 6.4.2, three passes with alternating generator order, minimum of 30 CUDA event timings after a 0.5-second warm-up, compare_gpu in benchmark/gpu.jl. The fill stages the rows of each workgroup in shared memory so a warp writes 512 contiguous bytes. Within each type the generators are in speed order. cuRAND is its library Philox4x32-10 generator.

fillgenerator2^27 elements, GiB/s2^28 elements, GiB/s
Float32Tandem K=321323–13271347–1353
cuRAND Philox4x32-101233–12681272–1282
CUDA.jl native585–590583–584
UInt32Tandem K=321262–13201338–1347
cuRAND Philox4x32-101215–12951295–1307
CUDA.jl native1073–11001080–1116
Float64Tandem K=321347–13581374–1377
CUDA.jl native1068–10831068–1101
cuRAND Philox4x32-10787–788786–788

At 2^27 the fixed launch and clock-ramp cost shows, which is why the two sizes differ. Chained scalar Float64 calls through the public A100 API reach about 597 GiB/s. These measure different work.

Apple M4 Pro GPU through Metal.jl 1.11.1, in the CPU session above. Each figure is the minimum of seven synchronized fills after a 0.5 s warm-up, best of three passes with alternating generator order, benchmark/metal/fills.jl. Metal.jl's rand! draws these types from Metal Performance Shaders' Philox.

elementsTandemMetal.jl rand!
UInt32, 2^2474.6127
UInt32, 2^26164184
UInt64, 2^25165175
Float32, 2^26153183

A synchronized Tandem fill of 1024 elements takes about 0.2 ms, twice Metal.jl's, which dominates at 2^24. A constant-store kernel reaches 720 GiB/s on the same GPU, so the Apple fill is bound by integer throughput, not by memory.

Reproduce measurements

The benchmark guide provides separate CPU, CUDA, Metal, and first-use runners. It compares Tandem with third-party generators only: Xoshiro, Random123, CUDA.jl, cuRAND, and Metal.jl. It documents setup, supported types, timing protocols, and API precision differences.

Use an idle host and repeated passes. Separate compilation, allocation, and first touch from sustained draw time. Report generated-byte throughput separately from array-write throughput. Keep runner sources, manifests, source hashes, and full output directories with published results.

The first-use runner starts fresh processes after package precompilation. Its load measurements exclude process startup. Later calls can reuse code compiled by earlier calls, so they are not independent cold measurements.