Derived draws
Bounded integers, normals, and exponentials follow Appendix A of the specification. Every Tandem port computes them the same way, so TandemRNG returns the values of tandem-c and tandem-cuda bit for bit. The test suite checks this against their fixtures.
using TandemRNG, Random
rng = Tandem8x32(42)
die, rng = rand_next(rng, 1:6)
z, rng = normal_next(rng)
e, rng = exponential_next(rng, Float32)
A = Vector{Float64}(undef, 1000)
rng = normal_fill!(rng, A)
st = Stateful(rng)
(die, z, e, rand(st, 1:6), randn(st), randexp(st))(1, -1.0296584102210211, 3.501452f0, 1, 0.2504268605124895, 0.2125776347609817)Bounded integers
rand_next and rand_fill! take an integer range. Stateful gives the same values through rand(st, r) and rand!(st, A, r) for supported unit ranges. Julia's collection and non-unit-range samplers can use other algorithms, so their stream consumption need not equal a native bounded draw.
- Draws use Lemire's multiply-and-reject method on uniform
w-bit draws. - The width comes from the number of values
m = length(r):w = 32whenm ≤ 2^32, elsew = 64. The element type of the range does not change the value. lo:hidraws onm = hi − lo + 1and addslo.- A scalar draw rejects by drawing the next
wbits. - Element
iof a fill maps drawiof the plainw-bit fill, so a fill consumes exactly one draw per element. A rejected draw retries on a fallback generator:splitrngindexgofsubrngpurposeP_wof the key at position 0.gis the draw's index in the stream, the aligned start position overwplusi.P_32 = 0x424c573332andP_64 = 0x424c573634are reserved for this. - A fill cut at any element boundary therefore equals the whole fill. A fill without rejections equals the sequence of scalar draws.
- An empty fill leaves the position unchanged.
rand_below_next and rand_below_fill! name the width by the type of n, as tandem-c's tandem_u32_below and tandem_fill_u64_below do. n = 0 returns 0 after one draw. Immutable range draws reject ranges of more than 2^64 values. Stateful keeps Random's sampler for those 128-bit ranges.
Normals
Float64: ziggurat
Float64 normals use the 1024-layer ziggurat of Appendix A, one UInt64 draw per element.
- Element
iof a fill comes from UInt64 drawi, and a fill ofnelements consumesndraws. A scalar draw consumes one draw and equals element 1 of a fill. An empty fill aligns the position to 64 bits. - 99.57 % of the draws land in an inner rectangle and cost a table lookup and a multiply.
- A draw that misses continues on its own fallback generator:
splitrngindexgofsubrngpurpose0x4e524d3634of the key at position 0, wheregis the draw's index in the stream. So a fill cut at any element equals the whole fill, and threads and workers may start anywhere. - The tables come from the spec's
tables/normal_f64_zig1024.json.tools/gen_zig_tables.jlwrites them intosrc/zig_tables.jlafter it checks the file's SHA-256, and CI checks the generated file is current. The logarithm is tandem-c's polynomial, so the values are exact across ports.
Float32: Box-Muller
Float32 normals use the Box-Muller transform on two Float32 uniform draws a and b:
r = sqrt(−2 log(1 − a))
z₀ = r cos(2π b)
z₁ = r sin(2π b)- Fill elements
2j − 1and2jarez₀andz₁of uniform draws2j − 1and2j. - A fill of
nelements consumes2·cld(n, 2)uniforms. An oddnwrites onlyz₀of its last pair. An empty fill leaves the position unchanged. - A scalar draw returns
z₀and consumes two uniforms, so it equals element 1 of a fill.randn(st, Float32)onStatefuldoes the same and keeps noz₁. - They are computed in Float32.
log,cos, andsinare tandem-c's short polynomials with explicit fused multiply-adds and no library call. Julia does not contract other products into sums, so the bits match on every CPU.
To split a Float32 normal fill across workers, start each range at an odd element (an even offset), so that the pairs fall the same way. Only the last range may have odd length.
Exponentials
exponential_next and exponential_fill! return −log(1 − u) for one uniform draw u of the output type. Float64 uses the polynomial log of the normals. Float32 carries the leading term in two floats and adds k log 2 by an exact two-sum, within 0.58 ulp, as tandem-c does. A fill consumes one uniform per element, and element 1 equals the scalar draw.
Weighted choice
ChoiceTable builds Walker's alias table of Appendix C from the weights in exact integers, so the table equals tandem-c's. choice_next and choice_fill! draw one-based indices, one UInt64 draw per element, and an empty fill aligns the position to 64 bits. Stateful gives the same values through rand(st, t) and rand!(st, A, t).
Threads and devices
Normal and exponential fills split their groups over nthreads tasks as rand_fill! does, and the values do not depend on the thread count. Bounded fills thread the uniform fill and map the draws in one task. Derived fills run on the CPU only.
The PureRNGs extension keeps PureRNGs' own samplers: PureRNGs.randn_next and the other PureRNGs distribution draws do not follow Appendix A.
Tests
test/derived.jl checks the values against copies of tandem-c's cross_below.h and cross_normal.h, tandem-cuda's cross_fill_below.h and cross_fill_exponential.h, the latter generated from tandem-cuda e98daee's core, the spec's conformance/exponential.json, and the SHA-256 of tandem-c's 1e6-element normal dump. The exponential dump matches the SHA-256 and FNV-1a of the spec's conformance/hashes.json. test/metal checks Float32 exponentials drawn in Metal kernels against the CPU fill. The cross_normal.h rows put a wedge accept, a wedge reject and a tail at unaligned starts. The normals also match the FNV-1a hashes of tandem-c's test_normal_bits.c, including the spec's Python implementation of Appendix A. Normals and exponentials pass moment and Kolmogorov-Smirnov tests on 1e7 draws. test/choice.jl checks the weighted choice tables and indices against a copy of the spec's conformance/choice.json, plus a chi-square test on 1e6 draws.