Modular arithmetic, hashing, and PCG32 in Python
Overview
Section titled “Overview”| Module | M06.3 · build · Python · Pass 2 · 4 to 5 h |
| You build | python/tinyllm/num/rng.py: PCG32 (next_u32, uniform, uniforms, below, shuffle, substream, state, set_state), splitmix64, child_seed, fnv1a64, universal_hash |
| Contract | course/contracts/py/tinyllm/num/rng.pyi · the algorithm, to the bit: spec/pcg32.md |
| Tests | course/tests/M06.3/test_rng.py checks Python against published golden vectors (section 4) |
| Needs | nothing to build first. Reading: S-M05 (counting), S-M06a (modular arithmetic and hashing by hand) |
| Used by | M07.0 normal draws by Box-Muller · L0.2 dropout masks · L0.4 the default init and dropout streams · L0.5 the bigram’s sampler · L0.6 the token stream’s window starts · later L8.1 the sampler, rt.04 KV block hashes, data.03 Bloom hashing, data.04 MinHash, and ethics.04 reproducible bootstrap resampling; ported to Rust by L10.1 and to Go by load.01 · later: L2.2, L2.3, L3.2, L3.3, L3.6, L4.1, L4.2, L4.3, L5.3, L5.4, L5.5, L6.1, L6.2, L6.3, L6.5, L6.7, L7.2, L7.5, L7.6, L7.8, L7.9 |
| Milestone | MS-P2 (the foundations gate) |
| Optional depth | O’Neill, “PCG: A Family of Simple Fast Space-Efficient Statistically Good Algorithms for Random Number Generation” (2014); Knuth, TAOCP vol. 2, ch. 3 (linear congruential generators, the spectral test); Steele, Lea, and Flood, “Fast Splittable Pseudorandom Number Generators” (2014); Carter and Wegman, “Universal Classes of Hash Functions” (1979); Noll’s FNV page (isthe.com/chongo/tech/comp/fnv) |
Key Takeaways
Section titled “Key Takeaways”- Python integers are unbounded, so PCG’s 64-bit state transition explicitly applies
& (2**64 - 1)after each multiply and add (test_first_1024_outputs_match_spec_vectors). - PCG32 is a 64-bit linear congruential generator whose state is hidden behind a permutation (an xorshift and a rotation chosen by the top bits); seeded the same way, Python, Rust, and Go produce the same stream bit for bit (
test_first_1024_outputs_match_spec_vectors). - A uniform double with all 53 bits takes two 32-bit draws and only integer arithmetic, so it is bit-exact across languages and never equals 1.0 (
test_uniform_uses_53_bits_and_stays_below_one). r mod nis biased unless divides ; rejection removes the bias (test_below_has_no_modulo_bias).- Sub-streams derived with SplitMix64 keep initialization, dropout, shuffling, and sampling independent of each other (
test_substream_ignores_draws_already_made); FNV-1a chains over buffers, which is how a KV block hash extends its parent’s (fnv1a64_chains_over_buffers).
How to work this chapter
Section titled “How to work this chapter”ol start M06.3 # stubs python/tinyllm/num/rng.pyol tests M06.3 # read the test catalog first: rung R0, you write no tests hereol check M06.3 # Python tests against published golden vectorsol diff M06.3 # after passing: your code against the reference1. Why now
Section titled “1. Why now”From Pass 2 on, your system makes random choices everywhere: initial weights, dropout masks, the order of training windows, the token the sampler picks. Every one of them has to be reproducible from one seed (P11), or a failing run cannot be replayed, a resumed checkpoint diverges from the run it continues, and a parity test between your Python sampler and your Rust engine (L10.1) has nothing to compare. numpy’s generator, Rust’s rand, and Go’s math/rand all produce different streams for the same seed. So the course fixes one generator to the bit, PCG32 (spec/pcg32.md), and you implement it in Python for training. Published vectors give the later Rust and Go ports the same oracle. The same mathematics, arithmetic modulo a power of two, gives you the hash functions the system needs: FNV-1a for KV block hashes and universal hashing for hash tables.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type |
|---|---|---|
| the remainder of divided by , in | integer | |
| the integers with and taken mod | ||
| generator state after steps | u64 | |
| the PCG multiplier 6364136223846793005 | u64 | |
the increment, inc , always odd | u64 | |
| rotate the 32-bit right by bits | u32 | |
| a uniform draw in | double | |
| a hash function, its number of buckets, a prime | ||
bitwise exclusive or (^) |
2.1 Modular arithmetic
Section titled “2.1 Modular arithmetic”Every integer and modulus give a unique quotient and remainder, with ; write . Two integers are congruent mod when they have the same remainder. Remainders respect addition and multiplication: , and the same for . So you may reduce at every step instead of at the end, and is a closed number system.
A uint64_t in C holds exactly : the C standard defines unsigned overflow as wraparound, which is reduction mod . Python integers never overflow, so x * y of two 64-bit values has up to 128 bits; you reduce with & (2**64 - 1) (keeping the low 64 bits is the remainder mod ). Forget it once and the state grows without bound while the outputs, which read the low bits, go quietly wrong after the first wrap.
Two facts about carry the rest of the chapter. An odd number has a multiplicative inverse mod , so multiplying by an odd constant is a bijection (it scrambles without losing information). And is a bijection too (the top bits pass through unchanged and determine how to undo the rest).
2.2 Hashing
Section titled “2.2 Hashing”A hash function maps keys to bucket numbers, . Two keys collide when . With keys in buckets the load factor is , and if collisions are as rare as for random assignment, a lookup scans about keys.
No single fixed function is good for every set of keys (an adversary can pick keys that all collide). A universal family picks at random so that for any two different keys, . Carter and Wegman’s family: choose a prime larger than every key, draw and , and set
Why it works: for , the map is a bijection onto the pairs with , because has an inverse mod the prime , so and then are determined. A collision needs ; for each , at most values qualify. That gives at most colliding pairs out of , a probability of at most . The order of the two reductions matters: is a different function and loses the guarantee.
FNV-1a is a fast non-cryptographic hash of a byte string: start from the 64-bit offset basis and for each byte do with the prime . Xor first, then multiply: that order is the “1a” (FNV-1 multiplies first, and hashes differently). Because the state between bytes is just , hashing a buffer in two pieces, the second starting from the first’s result, equals hashing it at once: . rt.04 uses exactly that to chain each KV block’s hash onto its parent’s.
2.3 From an LCG to PCG32
Section titled “2.3 From an LCG to PCG32”A linear congruential generator (LCG) steps . With odd and it visits all states before repeating (the Hull-Dobell theorem). Its weakness is the low bits: bit of depends only on bits of the previous state, so it repeats with period , and the lowest bit simply alternates. An LCG’s raw low bits are not random at all.
PCG keeps the LCG for the state and outputs a permutation of the old state that only uses its good high bits:
next_u32(): old = state state = old * A + inc (mod 2^64) xs = u32(((old >> 18) XOR old) >> 27) (xorshift: high bits fold into the middle) rot = old >> 59 (the top 5 bits, 0 to 31) return rotr32(xs, rot) (a rotation chosen by the state itself)Taking the output from old lets C compute the multiply and the permutation in parallel. inc selects one of streams: inc = (seq << 1) | 1, forced odd, because an even increment breaks the full period. Seeding is O’Neill’s pcg32_srandom_r(seed, seq): state = 0, one step, state += seed, one step. The two steps push the seed through the multiplier so that seeds 0 and 1 do not give nearly identical first outputs.
In C, the rotation has one trap: xs << (32 - rot) with rot = 0 shifts a 32-bit value by 32, which is undefined behavior (x86 and ARM happen to compute something, the optimizer may compute something else, and UBSan stops the program). Write the left shift as xs << ((0u - rot) & 31u): for rot = 0 it shifts by 0, and xs | xs is still xs.
2.4 Uniform doubles from integers
Section titled “2.4 Uniform doubles from integers”A double has a 53-bit significand, so the finest grid of evenly spaced doubles in is for . One 32-bit draw is not enough bits; the spec uses two:
27 bits from and 26 from . The integer in the parentheses is below , so the double holds it exactly, and multiplying by a power of two is exact too. No rounding happens anywhere, which is why uniform() is bit-identical in every language, and why always: inverse-CDF sampling (L8.1) relies on never seeing 1.0. In C, compute in uint64_t: (a << 26) on a 32-bit type loses the top bits.
2.5 Unbiased integers and shuffling
Section titled “2.5 Unbiased integers and shuffling”To draw an integer in from uniform on , r % n is biased whenever does not divide : the first residues get one extra preimage. For the values below come up half the time instead of a third. The fix is rejection: let (computed as (2**32 - n) % n so it fits in 32 bits), draw until , return . The accepted range has values, a multiple of , so every residue is equally likely; fewer than half of the draws are ever rejected.
Fisher-Yates shuffles a list uniformly: for down to , pick uniformly in and swap positions and . Each of the sequences of choices gives a different permutation, so all are equally likely. Picking in instead (Sattolo’s algorithm) never leaves an element in place and produces only the cyclic permutations.
2.6 Sub-streams with SplitMix64
Section titled “2.6 Sub-streams with SplitMix64”One user seed must feed several independent consumers (initialization, dropout, shuffling, sampling), so that turning dropout on does not change the initial weights. The spec derives each purpose’s generator from the seed alone:
with purpose ids init 1, dropout 2, shuffle 3, sample 4, mutation 5. mix64 is SplitMix64’s output function, two rounds of xorshift and odd multiply (both bijections, 2.1):
z = (z XOR (z >> 30)) * 0xBF58476D1CE4E5B9z = (z XOR (z >> 27)) * 0x94D049BB133111EBreturn z XOR (z >> 31) (all mod 2^64)A SplitMix64 generator seeded with adds the golden-ratio constant to its state and mixes, so its -th output is : the child seed of purpose is simply output of SplitMix64 seeded with . It depends on the seed, never on how many draws the parent made.
3. Worked example by hand
Section titled “3. Worked example by hand”The first output of pcg32(0) (test_worked_example_first_output_of_seed_0, worked_example_seed_0). Seeding with seed = 0, seq = 54:
| Step | Computation | Result |
|---|---|---|
| inc | 109 | |
| first step | state = 109 | |
| add the seed | 109 | |
| second step | state = 0x9AE4F7499BA72696 |
Now next_u32() with old = 0x9AE4F7499BA72696:
| Quantity | Value |
|---|---|
old >> 18 | 0x000026B93DD266E9 |
(old >> 18) XOR old | 0x9AE4D1F0A675407F |
... >> 27, low 32 bits | xs = 0x5C9A3E14 |
rot = old >> 59 | 19 (the top 5 bits, 10011) |
rotr32(xs, 19) = (xs >> 19) | (xs << 13) (low 32 bits) | 0x47C28B93 = 1203932051 |
That is the first entry of next_u32["0"] in spec/pcg32.vectors.json. The second draw is 0xB98F6A27, so the first uniform() is and , giving (test_uniform_bit_exact).
FNV-1a of "a" (test_fnv1a64_published_vectors): "a" is the byte 0x61. , then (the full product is 0xCBF29CE5DEAF63DC4C8601EC8C; keep the low 16 hex digits).
A universal hash (test_universal_hash_hand_example): , , , , . ; ; .
4. The interface
Section titled “4. The interface”# tinyllm/num/rng.py (contract: rng.pyi)class PCG32: def __init__(self, seed: int, seq: int = 54) -> None: ... def next_u32(self) -> int: ... def uniform(self) -> float: ... def uniforms(self, n: int) -> NDArray: ... def below(self, n: int) -> int: ... def shuffle(self, xs: MutableSequence[T]) -> None: ... def substream(self, purpose: str) -> "PCG32": ... def state(self) -> tuple[int, int]: ... def set_state(self, s: tuple[int, int]) -> None: ...def splitmix64(x: int) -> int: ...def child_seed(seed: int, purpose_id: int) -> int: ...def fnv1a64(data: bytes, h: int = 0xCBF29CE484222325) -> int: ...def universal_hash(x: int, a: int, b: int, p: int, m: int) -> int: ...What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_worked_example_first_output_of_seed_0 | unit, smoke | section 3: the seeded state and 0x47C28B93 | you and the spec agree on seeding |
test_oneill_demo_line | golden | PCG32(42) gives O’Neill’s published first six outputs | an oracle outside the course |
test_first_1024_outputs_match_spec_vectors | golden | 1024 outputs for seeds 0, 1, | every language’s stream (D10) |
test_uniform_bit_exact | golden | the spec’s uniform_f64 vectors, also through uniforms | the sampler’s one draw per token |
test_uniform_uses_53_bits_and_stays_below_one | property | integral, , more than 32 bits used | inverse-CDF sampling in L8.1 |
test_below_and_shuffle_match_spec_vectors | golden | below(10) and shuffle([0..9]) vectors | L0.5 replays its data order on resume |
test_below_has_no_modulo_bias | statistical | : the share below is 1/3, not 1/2 | unbiased sampling of large ranges |
test_below_rejects_bad_n | boundary | , negative, or above raise | |
test_substreams_match_spec_vectors | golden | child_seed and the first outputs of each purpose | init, dropout, shuffle, sample stay independent |
test_substream_ignores_draws_already_made | property | a sub-stream depends on the seed only | adding dropout does not change init |
test_splitmix64_published_outputs | golden | SplitMix64 seeded 0 and 1234567, published outputs | the mixer is the standard one |
test_fnv1a64_published_vectors | golden, smoke | FNV-1a 64 reference vectors | the KV block hash in rt.04 and the Rust engine |
test_fnv1a64_chains | property | fnv(b, fnv(a)) == fnv(a + b) on random bytes | chained block hashes |
test_universal_hash_hand_example | unit | section 3’s universal hash | |
test_universal_hash_collision_rate | property | exhaustive over all for : collisions at most | hash tables in ds.* |
test_state_roundtrip_resumes_the_stream | property | set_state(state()) continues the stream; an even inc is rejected | checkpoints store the generator (trainer_state.json) |
test_uniforms_rejects_negative_n | boundary | uniforms(-1) raises, uniforms(0) is empty |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| permuting the new state instead of the old one | a plausible stream that matches no other language | test_first_1024_outputs_match_spec_vectors (mutant s01) |
an even increment (seq << 1 without | 1) | short period; wrong from the first output | test_worked_example_first_output_of_seed_0 (mutant s02) |
no & MASK64 in Python | correct until the state first wraps, then wrong forever | test_first_1024_outputs_match_spec_vectors (mutant s03) |
| a uniform from one 32-bit draw | only 32 random bits, so the lower 21 bits of a double are always zero | test_uniform_uses_53_bits_and_stays_below_one (mutant s04) |
| seeding in the wrong order (seed before the first step) | streams for nearby seeds look alike, and no vector matches | test_oneill_demo_line (mutant s05) |
j = below(i) in the shuffle (Sattolo) | only cyclic permutations; no element ever stays put | test_below_and_shuffle_match_spec_vectors (mutant s06) |
r % n without rejection | small values favoured: 1/2 instead of 1/3 for | test_below_has_no_modulo_bias (mutant s07) |
| deriving a sub-stream from the current state | dropout masks change when the number of init draws changes | test_substream_ignores_draws_already_made (mutant s08) |
a different mixer (Murmur’s >> 33) | child seeds differ from every other port | test_splitmix64_published_outputs (mutant s09) |
| multiply before xor (FNV-1, not FNV-1a) | every KV block hash differs from the Rust engine’s | test_fnv1a64_published_vectors (mutant s10) |
| reducing mod before mod | not universal; clustered buckets | test_universal_hash_hand_example (mutant s11) |
accepting an even inc in set_state | a corrupt checkpoint resumes a non-PCG sequence | test_state_roundtrip_resumes_the_stream (mutant s12) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Forward | L2.2 | Registered call site uses this module. |
| Forward | L2.3 | Registered call site uses this module. |
| Forward | L3.2 | Registered call site uses this module. |
| Forward | L3.3 | Registered call site uses this module. |
| Forward | L3.6 | Registered call site uses this module. |
| Forward | L4.1 | Registered call site uses this module. |
| Forward | L4.2 | Registered call site uses this module. |
| Forward | L4.3 | Registered call site uses this module. |
| Forward | L5.3 | Registered call site uses this module. |
| Forward | L5.4 | Registered call site uses this module. |
| Forward | L5.5 | Registered call site uses this module. |
| Forward | L6.1 | Registered call site uses this module. |
| Forward | L6.2 | Registered call site uses this module. |
| Forward | L6.3 | Registered call site uses this module. |
| Forward | L6.5 | Registered call site uses this module. |
| Forward | L6.7 | Registered call site uses this module. |
| Forward | L7.2 | Registered call site uses this module. |
| Forward | L7.5 | Registered call site uses this module. |
| Forward | L7.6 | Registered call site uses this module. |
| Forward | L7.8 | Registered call site uses this module. |
| Forward | L7.9 | Registered call site uses this module. |
| Forward | data.04 | Registered call site uses this module. |
| Direction | Module | How it uses this |
|---|---|---|
| Back | S-M05 | counting arguments behind the rejection threshold and shuffles (reading) |
| Back | S-M06a | modular arithmetic, hashing, and load factor by hand (reading) |
| Forward | M07.0 | normal(rng, n): Box-Muller over two uniform() draws |
| Forward | L0.2 | dropout masks and gradcheck_all’s random inputs |
| Forward | L0.4 | layers default to PCG32(0).substream(purpose), so initialization and dropout use separate streams and adding dropout does not change the initial weights |
| Forward | L0.5 | the autograd bigram samples text with one uniform() per token |
| Forward | L0.6 | TokenStream draws its window starts from a PCG32 and saves the generator’s state in its cursor, so a resumed run reads the same batches |
| Forward | ethics.04 | bias-evaluation bootstrap intervals resample paired prompts with PCG32 so the reports are reproducible |
| Forward | L8.1 | the sampler: one uniform() per token on stream(seed, sample) (spec/sampling.md) |
| Forward | L10.1, load.01 | the Rust and Go ports, held to the same published vectors |
| Forward | data.03 | the Python Bloom screen derives stable bit positions from FNV-1a and SplitMix64 |
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
| PCG32 | the PCG family (pcg64, numpy’s default PCG64) | 128-bit state, the DXSM output function, jump-ahead in | numpy numpy/random/_pcg64.pyx; O’Neill’s pcg-cpp |
| sub-streams by SplitMix64 | JAX’s counter-based threefry keys | split a key into independent keys without any shared state, ideal for parallel devices | jax/_src/prng.py |
below by rejection | Lemire’s nearly divisionless method | one multiply and rarely a division instead of a modulo per draw | Lemire, “Fast Random Integer Generation in an Interval” (2019) |
| FNV-1a | xxHash, wyhash | many bytes per step with SIMD, better avalanche, still non-cryptographic | xxhash.h (XXH3) |
| block hash chaining | vLLM prefix caching | hashes each KV block with its parent hash and extra keys (LoRA id, multimodal inputs) | vLLM vllm/v1/core/kv_cache_utils.py, hash_block_tokens |