tandem rng tandem-cuda GitHub

Design

These fills are not part of the specification. Other ports should follow the same contract, and the C library's tandem_fill_u32_below and tandem_fill_u64_below give the same values bit for bit, rejected draws included.

Bounded integers

Element e uses its own draw d[e] of the UInt32 (UInt64) fill and Lemire's multiply and reject: m = d * range, accepted when the low word of m is at least 2^32 mod range (or its 64-bit analogue), result the high word. The threshold 2^32 mod range is computed once per fill, not per element, which took large ranges from 1107 to 1348 GiB/s (u32) and from 644 to 1351 (u64). The fill consumes exactly n draws, so it returns align(pos, w) + w n at once, without waiting for the device. A sequential Rng::urand(range) loop would consume extra draws after a rejection, and a parallel fill cannot know how many. So a rejected element e retries on a fallback stream: draws 0, 1, ... of split(g) of sub(P) of the fill's generator at position 0 (same key and K), with P = 0x424c573332 for 32-bit and 0x424c573634 for 64-bit ranges, until one is accepted. g is the global draw index align(pos, w) / w + e (spec Appendix A), so a fill cut at any element boundary, each piece starting where the last ended, equals the whole fill. Those two purposes are reserved. A rejection has probability (2^32 mod range) / 2^32, so ranges that are powers of two never reject, and a fill without rejections equals the sequential loop. range = 0 returns 0 and still consumes the draw. The spec's conformance/fill_below.json holds the fixtures for ports, which tests/conformance copies.

Normals

Float64

fill_normal_f64 is the 1024-layer ziggurat of Appendix A of the specification. Element e comes from UInt64 draw e of the fill that starts at pos aligned up to 64 bits: bits 0-9 pick the layer i, bit 10 the sign, bits 11-63 the magnitude ra, and x = +-ra W[i] is the normal when ra < K[i], 99.57 % of the time. A miss continues on the draws of split(g) of sub(0x4e524d3634) of the generator with the fill's key at position 0, where g is the global draw index align(pos, 64) / 64 + e, through the wedge test ln y < -x^2 / 2 and Marsaglia's tail. Each element starts its own fallback, so a fill consumes n draws, a fill cut at any element equals the whole fill, and Rng::normal() is element 0 of a fill. Rng::normal2() is two such draws. An empty fill returns pos aligned up to 64 bits, as an empty uniform fill does.

The tables W, K and Y come from the specification's tables/normal_f64_zig1024.json through tools/gen_normal_tables.py, which checks the file's SHA-256, into include/tandem/normal_tables.hpp (make tables). CI regenerates the header from the spec repository and fails on any difference. W[i] and K[i] sit side by side, so the fast path reads one 16-byte entry: two separate tables cost the A100 the L1 throughput of a second gather. Device code reads its own copy in global memory through the read-only cache. ln is the reference logarithm -L(x) / 2, the polynomial of the exponentials, every multiply-add an explicit std::fma. Every other operation of the slow path rounds once: the wedge's product is an fma with a zero addend and its sum __dadd_rn on a device, so no compiler fuses them. Host and device therefore return tandem-c's values bit for bit, also under -ffp-contract=fast.

The fill runs two kernels. The table pass is the direct fill kernel with the fast path: one thread per chunk, each block's two elements stored as one 16-byte vector. A miss goes to a queue in shared memory, and the block appends its queue to a list in global memory with one atomic add. The second kernel continues each listed miss, one thread per miss. The table pass makes no call, which keeps its registers and its loop free of the slow path's setup. The list has room for n / 128 misses, twice the expected count, n / 8 bytes. If it overflows, the second kernel walks the whole fill again and continues every miss.

The library keeps the lists between fills, per device. A fill takes a list whose last fill is done, or one last used on its own stream, which it waits for in stream order. Otherwise it allocates one with cudaMalloc, rounded up to a power of two. So the lists grow to the number of streams that fill at once, and they live to the end of the program. A list from cudaMallocAsync in each fill went back to the default pool at every synchronize, after which the next allocation waited about 0.7 ms on the host. With a synchronize after each fill, the A100 then wrote these medians (GiB/s):

fill cudaMallocAsync kept list cudaMallocAsync, release threshold raised
2^28, even start 588, 591 1059, 1061 1055, 1059
2^28, odd start 578, 583 975, 988 975, 997
2^24 123 803, 808 808
2^20 9 231 231

Ten fills back to back gave 995 against 1070 at 2^28 and 67 against 273 at 2^20. The kept list matched the raised threshold everywhere. An earlier comparison by the fastest of 21 fills found caching no faster, because the fastest fill was one that found the pool's memory. A fill captured into a graph still takes its list from cudaMallocAsync, so that each launch of the graph has its own. Fills below 2^16 elements, or without a list, run one kernel that continues each miss in place and allocate nothing.

When the output and the stream differ by 8 bytes modulo 16, as for a start at an odd draw into an aligned buffer, an element pair of one block straddles two 16-byte slots. A warp shuffle then brings each thread the low element of the next lane, and the thread stores its high element with it. Lane 7's partner is lane 0's next block, and the last lane of a group pairs with the next group's first element, through the warp or, across warps, shared memory. Every group's 128 bytes then cover whole 32-byte sectors. A row 8 bytes off the sectors halved the speed, even with 16-byte stores, and one lone element at each group's ends, a sector half written by one warp and finished by another much later, cost 15 %. Both kernels store each block one step late, so that the store does not wait for the step's table reads.

Those stores cover whole sectors only when the stream's rows start on a sector, or 8 bytes before one for an odd draw. A start at draw 2 or 3 modulo 4 into a 32-byte aligned output put each group's 128 bytes 16 bytes off the sectors, five sectors per group and step, and word 6 (draw 3) ran at 689 GiB/s against 990 for draw 1. Those starts take tandem-sycl's octet stores (fill_normal64_octets): units of two elements from the fill's first draw, lane l of a group writing unit 8m + l of octet m, 128 aligned bytes, with the values passed between lanes by warp shuffles and stored one step late. Word 6 then runs at 931 to 954. The octets lost 3 to 11 % at the other starts, so those keep the kernel above.

Float32

fill_normal_f32 is Box-Muller on Float32 draws, in float: pair j, the elements 2j (cos half) and 2j + 1 (sin half), comes from the draws 2j and 2j + 1, as the flattened Rng::normalf2() calls, with u = 1 - d[2j], v = d[2j + 1], precise logf and sqrtf. An odd n uses the cos half of its last pair and still advances past both draws, and an empty fill returns pos unchanged. On a device the square root is the fast path of sqrtf without its range check and slow path, sqrt_rn_nonneg, which equals the IEEE square root on the radius of every Float32 draw and took the A100 fill from 1172 to 1204 GiB/s. The device fill takes the angle through the fast __sincosf on 2 pi (v - 0.5), which is accurate on [-pi, pi], because the precise sincospif made the fill compute bound at 1065 GiB/s against 1300 memory bound. The result differs from the precise step by at most 1.73e-6 absolute over the random fills and 7.2e-7 on the fixtures, up to 47 ulps for a value near 0.01, where the absolute error dominates. The bound is 16 ulps + 2.1e-6: __sincosf errs by up to 2^-21.41 absolute on [-pi, pi], times a radius of up to 5.77. That exceeds the 1e-6 floor of the specification's tolerance for about one value in 10^6. The tests' seed holds one: a = 0x1.fff256p-1, b = 0x1.040388p-2, radius 5, give -0.10545215 against the host's -0.105453432, 1.28e-6 apart. So the tests take 16 ulps + 2.1e-6 for the device fill. On the A100, sincospif made the fill compute bound at 1065 GiB/s. The C reference's float polynomials, bit exact with tandem-c, gave 935, also with precise logf for the radius, while __sincosf gave 1270 in the same runs. __logf is not used, because its absolute error near 1 distorts small radii by thousands of ulps. Define TANDEM_PRECISE_F32_NORMAL for sincospif and 4 ulps. The fill starts at pos aligned up to 32 bits and consumes 64 ceil(n / 2) bits, so a block holds two pairs and a start at an odd Float32 draw makes some span two blocks. Float normals agree across ports and devices to a few ulps, not bit for bit, because libm float functions differ. The host version of the f32 step takes its angle in double and rounds the results, because a float angle 2 pi b is off by up to 2 pi b 2^-24 where sincospif is not. The uniforms are exact, and everything else in this library is bit for bit. The spec's conformance/normal.json holds the normal fixtures for ports, which match exactly in f64 and on hosts in f32, and to the tolerances above on devices.

Exponentials

fill_exponential_f64 writes element i as -ln(1 - u) of Float64 draw i of the fill that starts at pos aligned up to 64 bits, and fill_exponential_f32 the same in float on Float32 draws aligned up to 32 bits, as Appendix A of the specification defines them. A fill consumes n draws, equals the Rng::exponential() (exponentialf()) calls, and an empty fill returns pos unchanged. tandem::exponential_f64 and exponential_f32 in core.hpp compute the reference logarithm and tandem-c's f32 exponential logarithm on hosts and devices: no libm call, every multiply-add an explicit std::fma, and no plain product feeding a plain sum unless the product is exact. Device contraction therefore cannot change the bits, and every host and device returns tandem-c's values, which the tests check byte for byte. Building device code with -use_fast_math or -prec-div=false changes the f64 division and breaks that. The maximum error is 1.1e-15 relative in f64 and 0.571 ulp in f32. The fills run the direct kernel, because the map makes them compute bound and the tile kernel's separate write phase lost 7 to 13 % on the A100. The spec's conformance/exponential.json holds the fixtures for ports.

The reference logarithm multiplies by -4 and halves the result. neg_log_f64 moves those powers of two into the operands instead: the numerator 2 - 2m gives t = -2s, the polynomial runs on t^2 with each coefficient of s^(2j) divided by 4^j, and the ln 2 terms are halved. A power of two commutes with rounding while no value is subnormal, so each operation rounds to the reference's value times that power of two, and the result has the reference's bits with two multiplications fewer. On the A100 the f64 fill went from 909 to 935 to 931 to 955 GiB/s. The f64 division keeps its check: the same steps with rcp.approx.ftz.f64 as the seed differed from div.rn.f64 in 0.2 % of 2^34 test inputs.

exponential_f32 is tandem-c's neg_log_f32 of 1 - u. It carries u = (2 - 2m) / (m + 1) as uh + r / d, with m + 1 = d + dl exactly and r the residual of uh, and adds nk ln2_hi to uh by an exact fast two-sum. The result is within 0.571 ulp for every Float32 draw, so 1 - exp(-x) maps each draw back to its own 2^-24 grid point. uh rounds in an fma, because a contracted num * rcp would feed the unrounded product to the two-sum. On a device 1 / d is the fast path of the IEEE division without its range check and slow path, which only inputs outside [1, 4) or near the float range's ends need. tests/test_cuda.cu compares the f32 map with tandem-c's arithmetic for all 2^24 Float32 draws, on the device and on the host. This map adds 10 f32 operations per draw to the single-float one before it, and the A100 runs the f32 fill at 926 GiB/s, see speed.

Every fill reaches the A100's 250 W power cap when it runs for long. A bench that times the fastest of a few calls after a shared warm-up then measures the power the previous rows left. The f32 exponential row read 1022 GiB/s after the f64 Box-Muller rows, 1315 after the ziggurat rows that replaced them in commit 0ff5f18, and 1140 alone, with the same machine code. So tools/bench runs each fill for two seconds before it times that fill, and takes the median. The f32 exponential row then read 1019 to 1036 alone and in the table, before the folded arithmetic above.

At the power cap the SM clock depends on the work per byte. The uniform tile fills run at 1170 to 1215 MHz and the direct ones at 1260, bound by memory. The f32 normal and the exponentials run at 1035 to 1050 MHz and the f64 normal at about 1100, bound by their arithmetic at that clock. The f64 exponential spends about 22 f64 operations per element, eight of them in the division, on 32 f64 lanes per SM. The f64 normal's table pass runs at 1270 GiB/s, and its misses kernel takes 0.3 ms of the 1.9 ms of a 2^28 fill. Each miss seeds two child keys and its fallback chunk, three runs of F, before its wedge test. A table in shared memory, the misses kernel at four blocks per SM, and the fallback's sub key computed once on the host gained nothing there.

Weighted choice

fill_choice draws indices in [0, m) with probability proportional to Float64 weights, by the alias table of Appendix C of the specification. choice_build in core.hpp builds the table on the host in exact integers: it scales the weights so that their total is just below 2^63, rounds each one up, gives the rounding excess to the largest, and pairs short columns with full ones by Vose's method. Column j then holds mass cut[j] of index j and capacity - cut[j] of alias[j]. The map of one UInt64 draw r picks column j = floor(r m / 2^64) and compares floor((r m mod 2^64) capacity / 2^64) with cut[j]. Both are integer operations, so every host and device returns tandem-c's indices. Element i of a fill maps draw i of the plain UInt64 fill, so a fill consumes 64 bits per element, never retries, equals the scalar choice calls, and a fill cut anywhere equals the whole fill. An empty fill aligns the position to 64 bits, as an empty uniform fill does. The fill is a kind of the uniform fill kernels: the map runs in the store, and the two table reads go through the read-only cache. core.hpp holds the build and the map, so tandem-kokkos and tandem-sycl share them.