tandem rng tandem-java GitHub

Performance

pixi run bench produces the CPU figures, pixi run bench-native adds the row with libtandem, and pixi run bench in cuda/ gives the GPU figures.

CPU

One thread, Apple M4 Pro, JDK 25.0.2 (Azul Zulu), pixi run bench-native. Minimum of seven runs after three warm-up runs, in GiB/s of output: 2^25 scalar draws at 4 bytes for nextInt and 8 for the others, and arrays of 2^24 elements. Both tables are the median of three such runs in one session on 2026-10-06, baselines included.

generator nextInt GiB/s nextLong GiB/s nextDouble GiB/s nextGaussian GiB/s nextExponential GiB/s int[] loop GiB/s double[] loop GiB/s
Tandem 3.32 5.14 4.64 3.13 2.83 3.35 4.68
L64X128MixRandom 3.26 6.70 6.59 2.18 2.08 3.28 6.56
SplittableRandom 8.59 15.21 12.12 3.30 3.17 9.28 14.96
Random 0.93 0.93 0.93 0.54 0.80 0.07 0.13

Scalar draws always run in Java. Array fills, in GiB/s, in Java and through libtandem (tandem-c d9e1e54, built by pixi run native). The baseline is a loop of the matching SplittableRandom draw into the array, (float) cast for the float normal and exponential. SplittableRandom is the fastest JDK generator in every column of the table above.

fill Java libtandem SplittableRandom loop
fill(int[]) 10.04 18.75 9.18
fill(long[]) 9.38 18.87 16.48
fill(float[]) 8.52 16.16 8.30
fill(double[]) 8.95 16.36 15.17
fillGaussian(double[]) 4.48 7.63 3.20
fillGaussian(float[]) 2.11 5.48 1.64
fillExponential(double[]) 3.43 6.04 3.11
fillExponential(float[]) 2.55 6.61 1.59

The libtandem column matches tandem-c's own figures. A fill of 512 elements or more takes the library, which is faster than Java from that length on. Each call writes straight into the Java array as a critical call, at most 2^16 elements at a time, so a long fill does not hold off the garbage collector for its whole length. When the library loads, a fill of 300 longs and 300 normals is compared with the Java fill, and a library that disagrees is not used.

The Java double normal fill reads one table of 2048 pairs, the layer threshold and the width with the sign of bit 10, by the 11 low bits of the draw. Its loop has no call and only records the misses, which a second loop finishes. A miss draws its fallback by random access, F on locals, and two draws share one lane.

Each generator runs in its own JVM. Arrays are filled by a loop of draws or by Tandem.fill.

A scalar draw costs in proportion to the bytes it takes, because every row is generated on demand and generation dominates: a long takes twice the stream of an int. The cached block is read through fields of the generator, with one unsigned range check per draw and the refill out of line. Each lane of a row is independent, so the cache holds a block of 32 rows. Two lanes step through the block together with their state in registers. Four lanes need 32 state words, more than C2 keeps in the AArch64 registers, and spilled half the loop to the stack. The seeding function runs two lanes at a time in the same way. int[], long[], float[] and double[] fills generate whole blocks directly into the destination. The row loops end on b != end, which C2 does not count: it unrolls counted loops with many xors twice, and the unrolled loop spilled. Each iteration steps two rows instead, which halves the range checks and safepoint polls of the uncounted loop. C2 has no 32 x 32 to 64-bit multiply and zero-extends both operands of each product, so the two words that feed the next products stay zero-extended in longs. A double takes its high word through the floating-point units, hi 2^-32 + (lo >>> 11) 2^-53, which is exact and leaves the integer units to the step.

The JIT does not vectorise the row step. The loop issues about 35 instructions per 16 bytes, near the integer issue rate of the M4 Pro core, so fills stay near 9 to 10 GiB/s against about 19 in tandem-c. A scalar nextLong pays that generation plus about 20 instructions of position update, block check and load. SplittableRandom spends fewer than that on its whole draw, so the pure Java scalar draws cannot reach it.

The JIT does not vectorise the polynomial logarithm either: the loop runs at the same speed with -XX:-UseSuperWord. So fillExponential costs a uniform fill plus a scalar transform of about the same cost, while tandem-c runs the transform on NEON or AVX2 lanes. That puts it under half of tandem-c.

Normals use no libm: the double ziggurat takes the tables of the spec and the polynomial logarithm of tandem.c in explicit Math.fma, and the float Box-Muller its polynomials, so both are bit identical to tandem-c on every JVM and compiler (tests check the hashes of 1e6 double and 2e6 float normals at five positions).

The Vector API (jdk.incubator.vector, still incubating in JDK 25) is not used. It has no widening or high-half 32-bit multiply, which the step needs twice, and C2 multiplies long lanes through the general registers on NEON. With the high half from four 16-bit products, one four-lane group of rows with its store reached 9.3 GiB/s on the M4 Pro without seeding or double conversion, bound by the latency of that multiply chain. Two groups need more than the 32 NEON registers and spill. Two-vector selectFrom is not intrinsified on NEON, two-vector rearrange costs two tbl and a select, and a loop that also steps scalar lanes passes C2's inlining node limit, after which the vectors are boxed. The scalar fill is as fast and needs no --add-modules.

GPU

GPU: NVIDIA A100 40 GB (PCIe), driver 570.124, JDK 25, pixi run bench in cuda/. GiB/s written by each device fill. Each sample is ten launches and one synchronize, and the figure is the minimum of 21 samples after a half-second warm-up. The GPU had no other process, and a second run agreed within 3 %. The tandem-cuda column is the figure of its speed page for the same kernel at 2^28 elements. The cuRAND columns are Philox4x32-10 of cuRAND 10.3.9 in the same run, by the same method, through its host API in the context the fills use. cuRAND has no 64-bit integer output for Philox, no bounded integers and no exponentials, so those rows give the nearest call, marked "nearest": curandGenerate into the same bytes, or the uniform the exponential reads.

fill 2^26 GiB/s 2^28 GiB/s tandem-cuda 2^28 GiB/s cuRAND 2^26 GiB/s cuRAND 2^28 GiB/s cuRAND call
fillInts 1337 1383 1377 1275 1299 curandGenerate
fillLongs 1367 1390 1389 1294 1307 curandGenerate, nearest
fillFloats 1330 1382 1379 1254 1279 curandGenerateUniform
fillDoubles 1362 1392 1388 781 790 curandGenerateUniformDouble
fillBelowU32, range 1000 1292 1347 1332 1270 1295 curandGenerate, nearest
fillBelowU64, range 1000 1328 1357 1354 1282 1313 curandGenerate, nearest
fillGaussianFloats 1198 1201 1219 865 874 curandGenerateNormal
fillGaussianDoubles 864 1015 1064 575 570 curandGenerateNormalDouble
fillExponentialFloats 856 873 926 1237 1274 curandGenerateUniform, nearest
fillExponentialDoubles 896 907 955 783 790 curandGenerateUniformDouble, nearest

Array fills copy over PCIe: fill(double[]) of 2^26 elements runs at 8.8 GiB/s. Keep data on the device when a GPU consumes it.

The uniform and bounded fills run at the card's memory bandwidth, as in tandem-cuda. The fillGaussianDoubles row is the ziggurat, the kernels of tandem.cuh itself. The normal and exponential fills run below the uniforms because the card holds 250 W: their arithmetic lowers its clock until it bounds them, see tandem-cuda's design. The kernels come from tandem-cuda e98daee, whose f32 exponential is within 0.571 ulp for every draw, so that 1 - exp(-x) maps back to the draw's own 2^-24 grid point. Its two-float logarithm takes 10 more f32 operations per draw than the single-float one before it. cuRAND leads the f32 exponential row, where its nearest call is a uniform without the logarithm. The device fills of the API also allocate their memory on each call. The array fills add the copy over PCIe into the Java heap: fill(double[]) of 2^26 elements runs at 8.8 GiB/s end to end, about 150 times slower than the device fill.