Chapter 10: Random Numbers and Sharding
Extra Advanced chapter · Environment: Python 3.14 · JAX 0.12.0.1 · CPU only (8 simulated devices). Random numbers have come up here and there in earlier chapters. Basics Chapter 1 compared
keywithPRNGKeyand coveredsplitandfold_in. Basics Chapter 6 treated the key as explicit state. Basics Chapter 7 put the key into thescancarry and the TrainState in DP-SGD and the capstone project. Several chapters also warned about "reusing the same key". This chapter ties those pieces together and focuses on what we haven't covered yet: the internal representation of keys, the 6 PRNG algorithms,jax_threefry_partitionableand sharded generation, and how it explains the10.81from Chapter 1 that couldn't be reproduced.All conclusions were run on JAX 0.12.0.1 and checked with
assert. Timings are only printed, never asserted.
⚠️ Note: the first code cell of this chapter sets
XLA_FLAGS(8 simulated CPU devices), and it must take effect beforeimport jax. If you've already imported jax in this kernel, restart the kernel first and run from the top.⚠️ Known issue in this environment (see 00_overview; root cause in Advanced 02 · 2.3): the CPU backend of jaxlib 0.12.0.1 has a bug where a constant matrix captured by a jit closure gives wrong results for
W @ xfollowed by a sum. So in this chapter, weights and data are always passed in as function arguments.
Learning goals
- Explain why JAX doesn't use global random state: how the three requirements (reproducible, parallelizable, works with
vmap/jit) lead to the design of "explicit keys + a counter-based PRNG"; - Understand the shape semantics of typed keys,
key_data/wrap_key_data/jax.dtypes.prng_key, and which array operations are legal on keys and which raise errors; - Know the 6 PRNG implementations in 0.12 (threefry2x32 / threefry4x32 / philox2x32 / philox4x32 / rbg / unsafe_rbg), choose between them with
impl=andjax_default_prng_impl, and understand how they differ in speed, split quality, andvmapsemantics; - Understand
jax_threefry_partitionable: what its default is, why the numbers differ when you flip it, how it lets sharded arrays be generated locally with no communication, and how to verify that in the HLO; - Use the 7 key management patterns fluently (discard the old key after split,
split(key, n),fold_in,scancarry,vmapover a key array, deriving keys across processes, dropout /nnx.Rngs), and avoid 4 kinds of common pitfalls; - Run simple statistical tests on random numbers using the mean, variance, KS test, and correlation coefficient.
Contents
| Section | Content |
|---|---|
| 0 | Common setup (8 simulated devices, timing / HLO / subprocess helpers) |
| 1 | Design: why there's no global state |
| 2 | Two representations of keys: typed keys and raw uint32 keys |
| 3 | PRNG algorithms: threefry, philox, rbg, unsafe_rbg |
| 4 | jax_threefry_partitionable: default, numerical differences, sharded generation, cross-version consistency and 10.81 |
| 5 | Key management patterns (7 ❌ / ✅ pairs) |
| 6 | Common pitfalls (4 ❌ / ✅ pairs) |
| 7 | Simple tests of statistical quality |
| 8 | Practical rules checklist |
| — | Exercises (4) · Quiz (6) · Chapter summary · Cheat sheet |
0 Common setup¶
⚪ Optional: definitions of the 8 simulated devices and the timing / HLO / subprocess helpers; come back when you need them
- The first line sets
XLA_FLAGSto simulate 8 devices on CPU (needed for the sharding demos in Section 4); bench: warms up first, then times and takes the median (milliseconds); printed only;collectives: counts how many times each kind of collective operation appears in the compiled HLO;run_py: runs a piece of code in a fresh subprocess and parses the subprocess's lastJSON:output line back. Some comparisons need a different configuration from process startup (for examplejax_threefry_partitionableon vs. off), so they all run in subprocesses and don't interfere with each other.
# ⚠️ Must be set before import jax: simulate 8 devices on CPU (if jax is already imported, restart the kernel)
import os
os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=8"
import sys, re, io, json, time, socket, subprocess, warnings
import random as py_random # Python's standard random module, aliased so it isn't confused with jax.random
from collections import Counter
import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
from jax.sharding import NamedSharding, PartitionSpec as P
from scipy import stats
import flax
from flax import nnx
print("Python", sys.version.split()[0], "| JAX", jax.__version__, "| Flax", flax.__version__)
print("Devices", jax.devices())
assert jax.__version__ == "0.12.0.1"
assert jax.device_count() == 8
def bench(f, *args, warmup=2, runs=10):
"""Timing: warm up `warmup` times (including compilation), then run `runs` times and return the median in ms. For printing only."""
for _ in range(warmup):
jax.block_until_ready(f(*args))
ts = []
for _ in range(runs):
t0 = time.perf_counter()
jax.block_until_ready(f(*args))
ts.append(time.perf_counter() - t0)
return float(np.median(ts)) * 1e3
COLLECTIVE_OPS = ["all-reduce", "all-gather", "reduce-scatter", "all-to-all", "collective-permute"]
def collectives(hlo_text):
"""Count how many times each kind of collective appears in compiled HLO text (including the async *-start forms)."""
return {op: len(re.findall(op + r"(?:-start)?\(", hlo_text)) for op in COLLECTIVE_OPS}
def run_py(code, *args, env=None, timeout=600):
"""Run code in a fresh Python subprocess and return the object parsed from its last 'JSON:' output line."""
full_env = dict(os.environ, **(env or {}))
r = subprocess.run([sys.executable, "-c", code, *map(str, args)],
capture_output=True, text=True, env=full_env, timeout=timeout)
assert r.returncode == 0, r.stderr[-3000:]
lines = [l for l in r.stdout.splitlines() if l.startswith("JSON:")]
assert lines, r.stdout[-2000:] + r.stderr[-2000:]
return json.loads(lines[-1][5:])
def err_line(e):
"""Exception type + first line of the message (used when demonstrating wrong code)."""
msg = str(e).strip().splitlines()[0] if str(e).strip() else ""
return f"{type(e).__name__}: {msg[:170]}"
print("Common helpers defined")
1 Design: why there's no global state¶
🟢 Must-read
NumPy's classic interface np.random.seed(0); np.random.normal() is backed by a hidden global generator. Every call quietly moves its internal state forward one step. That's convenient in a single-threaded script, but it causes three problems:
- Results depend on call order: same seed, same functions, but if the call order changes (even just one extra line of debug code that prints a random sample), every random number after it changes too;
- No parallelism: when several threads or devices share one state, who gets which chunk of random numbers depends on scheduling, so results can't be reproduced. If you want each device to generate its own, you first have to figure out "how to split the state";
- Conflicts with function transformations:
jittraces only once, and side effects happen only during tracing (see Section 6.4).vmapturns a function into "process B examples at once", so should the global state advance once or B times? Neither answer fits.
JAX's approach: make the random number generator itself a pure function. The output of a random function depends only on the key passed in (random_bits = hash(key, counter); this design is called a counter-based PRNG). A key is just an ordinary, immutable array value. As a result:
| Requirement | How explicit keys meet it |
|---|---|
| Reproducible | Same key → same random numbers, regardless of call order or call count |
| Parallelizable | With the default config (jax_threefry_partitionable=True), the random bits of each element depend only on (key, that element's global index), so any device can compute just its own shard (Section 4) |
Works with vmap / jit | A key is an array: jit treats it as a regular argument, so changing the key doesn't recompile; vmap can map over a batch of keys directly (Section 5.5) |
The price is that you have to manage keys yourself. That's what Sections 5 and 6 are about.
# NumPy's global state: same seed, we only swap the call order of two functions
def init_weights_np():
return np.random.normal(size=3)
def sample_noise_np():
return np.random.normal(size=3)
np.random.seed(0); w_a = init_weights_np(); n_a = sample_noise_np()
np.random.seed(0); n_b = sample_noise_np(); w_b = init_weights_np() # reversed order
print("Weights with init first, then noise:", w_a)
print("Weights with noise first, then init:", w_b)
assert not np.array_equal(w_a, w_b) # the weights changed
assert np.array_equal(w_a, n_b) # and "weights" and "noise" swapped identities
print("✓ NumPy global state: results depend on call order")
# JAX: random numbers depend only on the key, not on call order
def init_weights(key):
return jax.random.normal(key, (3,))
def sample_noise(key):
return jax.random.normal(key, (3,))
k_w, k_n = jax.random.split(jax.random.key(0))
w1 = init_weights(k_w); n1 = sample_noise(k_n)
n2 = sample_noise(k_n); w2 = init_weights(k_w) # reversed order
assert jnp.array_equal(w1, w2) and jnp.array_equal(n1, n2)
# The key is a regular argument: jit traces / compiles once, and changing the key doesn't recompile
trace_count = 0
def noisy(key, x):
global trace_count
trace_count += 1 # runs only during tracing
return x + jax.random.normal(key, x.shape)
noisy_jit = jax.jit(noisy)
x0 = jnp.zeros(3)
outs = [noisy_jit(k, x0) for k in jax.random.split(jax.random.key(1), 4)]
print("Results for 4 different keys are all distinct:", len({tuple(np.asarray(o).tolist()) for o in outs}) == 4,
"| trace count =", trace_count)
assert trace_count == 1
assert len({tuple(np.asarray(o).tolist()) for o in outs}) == 4
print("✓ JAX: order-independent and reproducible; a key as an argument doesn't trigger recompilation")
NumPy's newer
np.random.default_rng(seed)puts the state into aGeneratorobject, which avoids the "global" part. But it's still a mutable object that every call modifies. Insidejitit runs only once, during tracing (see Section 6.4), so it's just as unsuitable for function transformations.
2 Two representations of keys¶
Chapter 1, Section 1.5 already compared jax.random.key(0) (typed key, dtype key<fry>, shape ()) with jax.random.PRNGKey(0) (raw uint32[2]), and showed that key + 1 raises an error and that wrap_key_data converts between them. We won't repeat that table here; we'll just go deeper on a few points: shape semantics, which operations are legal, how to serialize, and where the algorithm information lives.
2.1 A typed key array is "one element = one key"¶
⚪ Optional: when you want to understand the shape semantics of key arrays and which array operations are legal on keys
The shape of a typed key array only says "how many keys there are". The bit data inside each key (2 uint32s for threefry, 4 for rbg) is hidden in the element type. You need key_data to see it, and the extra dimension is a trailing dimension.
k = jax.random.key(0)
raw = jax.random.PRNGKey(0)
# The standard way to check "is this a typed key": jax.dtypes.prng_key is the abstract parent of all key dtypes
print("key(0).dtype =", k.dtype, "| is prng_key:", jnp.issubdtype(k.dtype, jax.dtypes.prng_key))
print("PRNGKey(0).dtype =", raw.dtype, "| is prng_key:", jnp.issubdtype(raw.dtype, jax.dtypes.prng_key))
print("The key remembers its algorithm:", jax.random.key_impl(k))
assert jnp.issubdtype(k.dtype, jax.dtypes.prng_key)
assert not jnp.issubdtype(raw.dtype, jax.dtypes.prng_key)
assert str(jax.random.key_impl(k)) == "threefry2x32"
# Shape semantics: key.shape is the number of keys; key_data adds one trailing dimension
print(f"\n{'Expression':28s} {'key.shape':>10s} {'key_data.shape':>15s}")
for name, kk in [("key(0)", k),
("split(key, 4)", jax.random.split(k, 4)),
("split(key, (2, 3))", jax.random.split(k, (2, 3))),
("key(0, impl='rbg')", jax.random.key(0, impl="rbg")),
("split(rbg_key, (2, 3))", jax.random.split(jax.random.key(0, impl="rbg"), (2, 3))),
("key(0, impl='philox2x32')", jax.random.key(0, impl="philox2x32"))]:
print(f"{name:28s} {str(kk.shape):>10s} {str(jax.random.key_data(kk).shape):>15s}")
assert jax.random.split(k, (2, 3)).shape == (2, 3)
assert jax.random.key_data(jax.random.split(k, (2, 3))).shape == (2, 3, 2)
assert jax.random.key_data(jax.random.split(jax.random.key(0, impl="rbg"), (2, 3))).shape == (2, 3, 4)
assert jax.random.key_data(jax.random.key(0, impl="philox2x32")).shape == (1,)
When you use keys as arrays, operations that reshape or pick elements are legal, because they don't touch the key's internal bits. Arithmetic, reductions, and type conversions all raise errors:
ks = jax.random.split(k, 4)
ok_ops = {
"ks[1:3] (slicing)": lambda: ks[1:3].shape,
"ks.reshape(2, 2)": lambda: ks.reshape(2, 2).shape,
"ks.reshape(2, 2).T": lambda: ks.reshape(2, 2).T.shape,
"jnp.stack([k, k])": lambda: jnp.stack([k, k]).shape,
"jnp.concatenate([ks, ks])": lambda: jnp.concatenate([ks, ks]).shape,
"jnp.where(mask, ks, ks[::-1])": lambda: jnp.where(jnp.array([True, False, True, False]), ks, ks[::-1]).shape,
"jnp.broadcast_to(k, (3,))": lambda: jnp.broadcast_to(k, (3,)).shape,
"ks == ks (comparison)": lambda: (ks == ks).tolist(),
}
for name, f in ok_ops.items():
print(f"✅ {name:32s} → {f()}")
assert jnp.stack([k, k]).dtype == k.dtype and bool(jnp.all(ks == ks))
# ❌ Wrong: arithmetic / reductions / type conversions on typed keys (trying to "derive" a new key by hand, or save a key as data)
bad_ops = {
"k + 1": lambda: k + 1,
"k * 2": lambda: k * 2,
"k ^ k": lambda: k ^ k,
"jnp.sum(ks)": lambda: jnp.sum(ks),
"jnp.unique(ks)": lambda: jnp.unique(ks),
"jnp.asarray(k, jnp.uint32)": lambda: jnp.asarray(k, jnp.uint32),
"np.asarray(k)": lambda: np.asarray(k),
}
bad_errors = {}
for name, f in bad_ops.items():
try:
out = f()
print(f"(no error) {name} → {out}")
except Exception as e:
bad_errors[name] = type(e).__name__
print(f"❌ {name:28s} → {err_line(e)}")
assert set(bad_errors) == set(bad_ops) # all of them raise
assert bad_errors["k + 1"] == "TypeError" and bad_errors["np.asarray(k)"] == "TypeError"
# For comparison: doing the same with a raw uint32 key "silently succeeds", which is exactly the danger of the old style
print("\nRaw key: PRNGKey(0) + 1 =", raw + 1, "(silently succeeds)")
assert jnp.array_equal(raw + 1, jnp.array([1, 1], jnp.uint32))
# ✅ Correct: derive new keys with fold_in / split; get the underlying bits with key_data
k_step1 = jax.random.fold_in(k, 1) # instead of "k + 1"
raw_bits = jax.random.key_data(k) # instead of "np.asarray(k)"
print("Underlying data of fold_in(k, 1):", jax.random.key_data(k_step1))
print("key_data(k) =", raw_bits, raw_bits.dtype)
assert k_step1.dtype == k.dtype and not bool(k_step1 == k)
assert raw_bits.dtype == jnp.uint32 and raw_bits.shape == (2,)
2.2 Converting and serializing: key_data / wrap_key_data need impl¶
⚪ Optional: when you need to store keys in a checkpoint or pass them between processes
Raw uint32 data doesn't remember which algorithm it belongs to: a threefry key is 2 words, rbg is 4 words, and philox2x32 is just 1 word. So when you save keys (into a checkpoint, or to pass between processes), save the algorithm name together with the data, and restore with wrap_key_data(data, impl=...).
# ❌ Wrong: save only the raw data of an rbg key and restore it without impl (interpreted as the default threefry2x32)
rbg_key = jax.random.key(7, impl="rbg")
rbg_raw = jax.random.key_data(rbg_key)
try:
restored = jax.random.wrap_key_data(rbg_raw)
print("(no error) restored as", restored.dtype)
wrap_err = None
except Exception as e:
wrap_err = e
print("❌", err_line(e))
assert isinstance(wrap_err, TypeError)
# ✅ Correct: save (impl name, key_data), restore with wrap_key_data(..., impl=impl)
def save_keys(keys):
"""Serialize a typed key array to bytes: impl name + raw uint32 data."""
buf = io.BytesIO()
np.savez(buf, impl=str(jax.random.key_impl(keys)), data=np.asarray(jax.random.key_data(keys)))
return buf.getvalue()
def load_keys(blob):
z = np.load(io.BytesIO(blob))
return jax.random.wrap_key_data(jnp.asarray(z["data"]), impl=str(z["impl"]))
for impl in ["threefry2x32", "rbg", "philox2x32"]:
keys = jax.random.split(jax.random.key(7, impl=impl), (2, 3))
back = load_keys(save_keys(keys))
same = bool(jnp.all(back == keys)) and back.dtype == keys.dtype
same_samples = jnp.array_equal(jax.random.normal(back[1, 2], (4,)), jax.random.normal(keys[1, 2], (4,)))
print(f"{impl:13s} after round trip dtype={back.dtype}, shape={back.shape}, same key={same}, same samples={bool(same_samples)}")
assert same and same_samples
🔍 Extra: two "silent" behaviors related to keys¶
⚪ Optional: when you use large integers (timestamps, hashes) as seeds, or migrate old PRNGKey code
(1) In the default 32-bit mode, seeds wider than 32 bits are silently truncated. Without jax_enable_x64, jax.random.key(seed) keeps only the low 32 bits of the seed, with no error and no warning. If you use a nanosecond timestamp or a hash as the seed, different seeds can end up as the same key. With x64 enabled, the high bits go into the key data (compared in a subprocess below). To seed with a large integer, split it into two halves and mix them in one after another with fold_in.
(2) Make "raw keys" raise errors with jax_legacy_prng_key. Its default is 'allow'. Set it to 'warn' or 'error', and passing a raw uint32 array as a key to a jax.random function warns or raises. That's a good way to catch leftover PRNGKey usage when migrating old code. Note that it checks the "pass into a random function" step; PRNGKey(0) itself doesn't raise.
# (1) Seed truncation
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
same_key = bool(jax.random.key(2**32 + 5) == jax.random.key(5))
print("32-bit mode: key(2**32 + 5) == key(5)?", same_key, "| warnings:", len(caught))
assert same_key and len(caught) == 0
x64_res = run_py("""
import json, jax
jax.config.update("jax_enable_x64", True)
a = jax.random.key(2**32 + 5)
print("JSON:" + json.dumps({"data": jax.random.key_data(a).tolist(), "same": bool(a == jax.random.key(5))}))
""")
print("x64 mode (subprocess): key_data(key(2**32 + 5)) =", x64_res["data"], "| same as key(5)?", x64_res["same"])
assert x64_res["data"] == [1, 5] and not x64_res["same"]
# A safe way to use a large integer seed: split it into high and low halves and fold_in each; the result doesn't depend on the x64 setting
def key_from_big_seed(seed: int):
hi, lo = (seed >> 32) & 0xFFFFFFFF, seed & 0xFFFFFFFF
return jax.random.fold_in(jax.random.fold_in(jax.random.key(0), hi), lo)
assert not bool(key_from_big_seed(2**32 + 5) == key_from_big_seed(5))
# (2) jax_legacy_prng_key
print("\nDefault of jax_legacy_prng_key:", jax.config.jax_legacy_prng_key)
with jax.legacy_prng_key("error"):
try:
jax.random.normal(jax.random.PRNGKey(0), (2,))
legacy_err = None
except Exception as e:
legacy_err = e
print("Passing a raw key in 'error' mode →", err_line(e))
ok_typed = jax.random.normal(jax.random.key(0), (2,)) # typed keys aren't affected
assert isinstance(legacy_err, ValueError) and ok_typed.shape == (2,)
3 PRNG algorithms: threefry, philox, rbg, unsafe_rbg¶
A key's dtype carries the algorithm information (the fry in key<fry> stands for threefry). JAX 0.12 offers 6 implementations (see the Advanced RNG configuration section of the jax.random module docs):
impl= | dtype | Key data | Generating random bits | Deriving keys (split / fold_in) | Notes |
|---|---|---|---|---|---|
threefry2x32 (default) | key<fry> | 2 × u32 | Threefry hash | Threefry | 64-bit key space, 64-bit counter; bit-identical across backends |
threefry4x32 | key<fry4> | 4 × u32 | Threefry | Threefry | 128-bit key / counter |
philox2x32 / philox4x32 | key<phx2> / key<phx4> | 1 / 2 × u32 | Philox hash | Philox | Also a counter-based PRNG (Salmon et al. 2011) |
rbg (experimental) | key<rbg> | 4 × u32 | XLA RngBitGenerator | Threefry | Fastest on TPU; derived keys still have threefry quality |
unsafe_rbg (experimental) | key<urbg> | 4 × u32 | XLA RngBitGenerator | RBG used as a hash | The statistical quality of derived keys hasn't been tested, hence "unsafe" |
There are three ways to choose:
- For a single key:
jax.random.key(seed, impl="rbg")(recommended; the typed key carries this information from then on); - Change the global default:
jax.config.update("jax_default_prng_impl", "rbg"), or set the environment variableJAX_DEFAULT_PRNG_IMPL=rbg; - Switch temporarily:
with jax.default_prng_impl("rbg"): ....
The global config affects only two kinds of keys: new keys created without impl=, and raw uint32 keys. A raw key carries no algorithm information, so it can only be interpreted according to the global config. That's one more reason to switch to typed keys.
IMPLS = ["threefry2x32", "threefry4x32", "philox2x32", "philox4x32", "rbg", "unsafe_rbg"]
samples = {}
print(f"{'impl':13s} {'dtype':10s} {'key_data':>9s} normal(key(0), (3,))")
for impl in IMPLS:
kk = jax.random.key(0, impl=impl)
samples[impl] = np.asarray(jax.random.normal(kk, (3,)))
print(f"{impl:13s} {str(kk.dtype):10s} {str(jax.random.key_data(kk).shape):>9s} {samples[impl]}")
expected_dtype = {"threefry2x32": "key<fry>", "threefry4x32": "key<fry4>", "philox2x32": "key<phx2>",
"philox4x32": "key<phx4>", "rbg": "key<rbg>", "unsafe_rbg": "key<urbg>"}
assert all(str(jax.random.key(0, impl=i).dtype) == d for i, d in expected_dtype.items())
# Same seed, different algorithms give different random numbers (rbg and unsafe_rbg share the "generation" part; only key derivation differs)
distinct = {tuple(v.tolist()) for v in samples.values()}
assert len(distinct) == 5 and np.array_equal(samples["rbg"], samples["unsafe_rbg"])
# Global default algorithm: environment variable (subprocess, effective from startup) and context manager (this process, temporary)
env_res = run_py("""
import json, jax
print("JSON:" + json.dumps({
"config": jax.config.jax_default_prng_impl,
"key_dtype": str(jax.random.key(0).dtype),
"raw_shape": list(jax.random.PRNGKey(0).shape), # raw keys are interpreted using the global config
"explicit": str(jax.random.key(0, impl="threefry2x32").dtype)})) # an explicit impl isn't affected
""", env={"JAX_DEFAULT_PRNG_IMPL": "rbg"})
print("Subprocess with JAX_DEFAULT_PRNG_IMPL=rbg:", env_res)
assert env_res == {"config": "rbg", "key_dtype": "key<rbg>", "raw_shape": [4], "explicit": "key<fry>"}
with jax.default_prng_impl("philox4x32"):
inside = str(jax.random.key(0).dtype)
outside = str(jax.random.key(0).dtype)
print("Inside with jax.default_prng_impl('philox4x32'):", inside, "| after exiting:", outside)
assert inside == "key<phx4>" and outside == "key<fry>"
3.1 Speed comparison (this machine's CPU, printed only)¶
⚪ Optional: comparing the speed of each PRNG algorithm on this machine's CPU
Below we measure three times after jit: "generate 4 million normal random numbers", "split out 100,000 keys", and the first compilation. These numbers only describe this machine's CPU. On CPU, rbg uses XLA's CPU implementation of RngBitGenerator and has no real advantage. The official docs say rbg / unsafe_rbg are fastest on TPU, and that threefry is relatively slow to compile and run on TPU; we can't verify that on CPU.
print(f"{'impl':13s} {'gen 4M normal':>14s} {'split 100k':>11s} {'1st compile':>9s}")
for impl in IMPLS:
kk = jax.random.key(0, impl=impl)
gen = jax.jit(lambda key: jax.random.normal(key, (4_000_000,)))
spl = jax.jit(lambda key: jax.random.split(key, 100_000))
t0 = time.perf_counter(); gen.lower(kk).compile(); t_compile = (time.perf_counter() - t0) * 1e3
print(f"{impl:13s} {bench(gen, kk):11.2f} ms {bench(spl, kk):8.2f} ms {t_compile:6.0f} ms")
3.2 Comparing properties: split quality and vmap semantics¶
⚪ Optional: if you're considering rbg / unsafe_rbg, you must read about their different vmap semantics
How keys are derived. rbg's split cuts the 4 words into two halves and applies threefry's split to each. unsafe_rbg instead iterates RngBitGenerator 10 times and uses it as a hash function. Let's verify that directly.
kr = jax.random.key(0, impl="rbg")
ku = jax.random.key(0, impl="unsafe_rbg")
halves = jax.random.key_data(kr).reshape(2, 2) # rbg key = two threefry keys concatenated
tf_split = jnp.concatenate([jax.random.key_data(jax.random.split(jax.random.wrap_key_data(h), 3)) for h in halves], -1)
rbg_like_tf = bool(jnp.array_equal(jax.random.key_data(jax.random.split(kr, 3)), tf_split))
urbg_like_tf = bool(jnp.array_equal(jax.random.key_data(jax.random.split(ku, 3)), tf_split))
print("rbg's split == threefry split on each half:", rbg_like_tf)
print("unsafe_rbg's split == same as above:", urbg_like_tf, "(it uses RBG as a hash; quality untested)")
assert rbg_like_tf and not urbg_like_tf
vmap semantics. For threefry / philox, vmap(f)(keys) is bit-identical to calling f(keys[i]) one at a time. rbg / unsafe_rbg are different: XLA's RBG doesn't support efficient batching, so JAX's workaround is to use only the first key in the batch to generate the whole batch of random numbers, i.e. vmap(normal)(keys) == normal(keys[0], (B,)).
# ❌ Wrong: with rbg, assuming "vmap equals calling one at a time" (e.g. cross-checking a vmap version against a loop version)
keys_rbg = jax.random.split(jax.random.key(0, impl="rbg"), 8)
try:
v = jax.vmap(lambda kk: jax.random.normal(kk))(keys_rbg)
loop = jnp.stack([jax.random.normal(kk) for kk in keys_rbg])
first_only = jax.random.normal(keys_rbg[0], (8,))
print("No error, but vmap == one at a time?", bool(jnp.array_equal(v, loop)),
"| vmap == normal(keys[0], (8,))?", bool(jnp.array_equal(v, first_only)))
except Exception as e:
print("❌", err_line(e))
assert not jnp.array_equal(v, loop) and jnp.array_equal(v, first_only)
# ✅ Correct: when you need "vmap matches calling one at a time", use threefry / philox (the default threefry2x32 is fine)
for impl in ["threefry2x32", "philox4x32"]:
keys_ok = jax.random.split(jax.random.key(0, impl=impl), 8)
v_ok = jax.vmap(lambda kk: jax.random.normal(kk))(keys_ok)
loop_ok = jnp.stack([jax.random.normal(kk) for kk in keys_ok])
print(f"{impl:13s} vmap == one at a time: {bool(jnp.array_equal(v_ok, loop_ok))}")
assert jnp.array_equal(v_ok, loop_ok)
Summary of properties from the official docs (* means jax_threefry_partitionable=True, the default since 0.5.0; ** means the XLA flag --xla_tpu_spmd_rng_bit_generator_unsafe=1 is set):
| Property | Threefry | Threefry* | Philox | rbg | unsafe_rbg | rbg** | unsafe_rbg** |
|---|---|---|---|---|---|---|---|
| Fastest on TPU | ✅ | ✅ | ✅ | ✅ | |||
| Efficient sharded generation (jit auto-partitioning) | ✅ | ✅ | ✅ | ✅ | |||
| Same result under any sharding | ✅ | ✅ | ✅ | ✅ | ✅ | ||
| Same result on CPU / GPU / TPU | ✅ | ✅ | ✅ | ||||
vmap matches calling one at a time | ✅ | ✅ | ✅ |
🔍 Extra: how to choose¶
⚪ Optional: when you need to change the PRNG algorithm; the default threefry2x32 fits the vast majority of cases
- Use
threefry2x32by default: bit-identical across backends, exactvmapsemantics, efficient sharded generation, and the best reproducibility. - Consider
rbgonly when you train at large scale on TPU and a profile shows random number generation (typically dropout masks) is really a bottleneck. It generates fast, and derived keys still have threefry quality. The price is that results change with the backend and compiler version, andvmapuses only the first key. - Use
unsafe_rbgonly when key derivation has also become a bottleneck, and only if you can accept that "derivation quality hasn't been tested". - Switching algorithms changes all random numbers (including parameter initialization), so record it in your experiment config like any other hyperparameter.
4 jax_threefry_partitionable¶
This is a switch inside the threefry implementation. It decides how counters are assigned to each element of the output array. When it's on, the random bits of each element depend only on (key, the element's global index). That lets jit have each device compute only its own shard, with no communication.
4.1 Default value¶
⚪ Optional: the default of jax_threefry_partitionable and three ways to change it
print("jax_threefry_partitionable =", jax.config.jax_threefry_partitionable)
assert jax.config.jax_threefry_partitionable is True
# The jax.random module docs state which version made it the default
assert "As of JAX v.0.5.0, this is the" in jax.random.__doc__
print("jax.random docs: 'As of JAX v.0.5.0, this is the default.' ✓")
# Three ways to change it: jax.config.update / environment variable JAX_THREEFRY_PARTITIONABLE / context manager
env_flag = run_py('import json, jax; print("JSON:" + json.dumps(jax.config.jax_threefry_partitionable))',
env={"JAX_THREEFRY_PARTITIONABLE": "0"})
with jax.threefry_partitionable(False):
ctx_flag = jax.config.jax_threefry_partitionable
print("Env var JAX_THREEFRY_PARTITIONABLE=0 →", env_flag, "| inside context manager →", ctx_flag,
"| after exiting →", jax.config.jax_threefry_partitionable)
assert env_flag is False and ctx_flag is False and jax.config.jax_threefry_partitionable is True
4.2 On vs. off: same key, same shape, different results? (subprocess comparison)¶
⚪ Optional: implementation detail: measuring how random numbers differ when you flip the switch
We start two subprocesses for each setting. Each process sets the switch with jax.config.update right at startup, then generates random numbers of the same shape from the same key.
FLAG_SCRIPT = """
import sys, json, jax, jax.numpy as jnp
jax.config.update("jax_threefry_partitionable", sys.argv[1] == "1")
k = jax.random.key(0)
x10 = jax.random.normal(k, (10,))
print("JSON:" + json.dumps({
"flag": jax.config.jax_threefry_partitionable,
"normal_8": jax.random.normal(k, (8,)).tolist(),
"uniform_3": jax.random.uniform(k, (3,)).tolist(),
"split": jax.random.key_data(jax.random.split(k)).tolist(),
"fold_in": jax.random.key_data(jax.random.fold_in(k, 1)).tolist(),
"dot10": float(jnp.dot(x10, x10)), # the number from the install check script in Chapter 1, Section 1.4
}))
"""
flag_runs = {flag: [run_py(FLAG_SCRIPT, int(flag)) for _ in range(2)] for flag in (True, False)}
on, off = flag_runs[True][0], flag_runs[False][0]
for name in ["normal_8", "uniform_3", "split", "fold_in", "dot10"]:
print(f"{name:10s} on: {on[name]}\n{'':10s} off: {off[name]}\n")
assert on["flag"] is True and off["flag"] is False
assert flag_runs[True][0] == flag_runs[True][1] and flag_runs[False][0] == flag_runs[False][1] # reproducible under the same setting
assert on["normal_8"] != off["normal_8"] and on["uniform_3"] != off["uniform_3"] # random bits differ
assert on["split"] != off["split"] # split differs too
assert on["fold_in"] == off["fold_in"] # fold_in isn't affected
# Matches the measured values in Chapter 1, Section 1.4: on 9.7387…, off 2.6585…
assert np.isclose(on["dot10"], 9.738718032836914) and np.isclose(off["dot10"], 2.6585559844970703)
# This process's default setting is bit-identical to the "on" subprocess
assert jax.random.normal(jax.random.key(0), (8,)).tolist() == on["normal_8"]
print("✓ On vs. off: random bits and split differ, fold_in is the same; each setting is reproducible")
4.3 Why they differ: how counters are assigned¶
⚪ Optional: under the hood: the two ways threefry assigns counters
threefry2x32 is a hash: it takes 2 32-bit counter words and outputs 2 32-bit words. To generate $n$ 32-bit random numbers, counters can be assigned in two ways:
| Off (original implementation) | On (partitionable) | |
|---|---|---|
| Counter | Split iota(n) into a front half and a back half; block $i$ gets input $(i,\ i+n/2)$ | Element $i$ uses 64-bit counter $i$, i.e. input $(\text{hi}(i),\ \text{lo}(i))$ |
| Output | The two output words of block $i$ go to elements $i$ and $i+n/2$ | XOR the two output words to get element $i$ |
| Element $i$ depends on | key, $i$, total length $n$ | only key and the global index $i$ |
Below we reproduce both assignments directly with the threefry primitive from jax.extend.random, then verify one consequence, called the "prefix property": when on, normal(k, (10,))[:5] == normal(k, (5,)); when off, it doesn't hold.
from jax.extend.random import threefry_2x32, threefry2x32_p
kd = jax.random.key_data(k)
n = 8
with jax.threefry_partitionable(False):
bits_off = jax.random.bits(k, (n,))
manual_off = threefry_2x32(kd, jnp.arange(n, dtype=jnp.uint32)) # internally splits the counter in two halves
with jax.threefry_partitionable(True):
bits_on = jax.random.bits(k, (n,))
w0, w1 = threefry2x32_p.bind(kd[0], kd[1], jnp.zeros(n, jnp.uint32), jnp.arange(n, dtype=jnp.uint32))
manual_on = w0 ^ w1 # counter = global index i, XOR the two words
print("off: bits =", bits_off)
print("off: by hand =", manual_off)
print("on: bits =", bits_on)
print("on: by hand =", manual_on)
assert jnp.array_equal(bits_off, manual_off) and jnp.array_equal(bits_on, manual_on)
for flag in (True, False):
with jax.threefry_partitionable(flag):
prefix_1d = bool(jnp.array_equal(jax.random.normal(k, (10,))[:5], jax.random.normal(k, (5,))))
prefix_2d = bool(jnp.array_equal(jax.random.normal(k, (4, 5))[:2], jax.random.normal(k, (2, 5))))
split_prefix = bool(jnp.all(jax.random.split(k, 5)[:3] == jax.random.split(k, 3)))
split_is_fold = bool(jnp.all(jax.random.split(k, 4) == jnp.stack([jax.random.fold_in(k, i) for i in range(4)])))
print(f"switch={flag!s:5}: prefix property 1D={prefix_1d}, 2D row prefix={prefix_2d}, "
f"split(k,5)[:3]==split(k,3): {split_prefix}, split(k,n)[i]==fold_in(k,i): {split_is_fold}")
assert prefix_1d == prefix_2d == split_prefix == split_is_fold == flag
The last column is a bonus: with the default config,
split(k, n)[i]is bit-identical tofold_in(k, i), and the child key doesn't depend on $n$. So "firstsplit(key, 1000), then take the 37th" and "justfold_in(key, 37)" are the same thing, and the latter doesn't need to generate the whole array.
4.4 Effect on sharded generation: measured on 8 simulated devices¶
⚪ Optional: when you generate sharded random arrays on multiple devices and care about communication and redundant computation
On 8 devices, we use jit(..., out_shardings=...) to directly generate a sharded (8, N) array, split by rows (P('x')) and by columns (P(None, 'x')). Under both on and off settings, we compare three things:
- Whether the result is bit-identical to the unsharded one;
- Whether the compiled HLO contains any collective operations;
- How many random values each device actually generates. We read this from the shape of the
xorin the threefry round function in the HLO: when off, each threefry block produces 2 values; when on, it produces 1.
mesh = jax.make_mesh((8,), ("x",))
N = 1024
k_sh = jax.random.key(0)
def threefry_values_per_device(hlo_text, flag):
"""Estimate how many random values each device generates from the xor shape in the threefry round function in the HLO."""
shapes = re.findall(r"u32\[([0-9,]+)\][^\n]*? xor\(", hlo_text)
elems = max(int(np.prod([int(d) for d in s.split(",")])) for s in shapes)
return elems * (1 if flag else 2)
rows = []
for flag in (True, False):
with jax.threefry_partitionable(flag):
ref = jax.jit(lambda key: jax.random.normal(key, (8, N)))(k_sh) # unsharded reference
for spec in (P("x"), P(None, "x")):
f = jax.jit(lambda key: jax.random.normal(key, (8, N)), out_shardings=NamedSharding(mesh, spec))
out = f(k_sh)
txt = f.lower(k_sh).compile().as_text()
rows.append(dict(flag=flag, spec=str(spec), equal=bool(jnp.array_equal(out, ref)),
comm=collectives(txt), per_dev=threefry_values_per_device(txt, flag),
shard_shape=out.addressable_shards[0].data.shape))
print(f"{'flag':5s} {'sharding':14s} {'= unsharded':>10s} {'shard/dev':>10s} {'gen/dev':>9s} collectives")
for r in rows:
comm = {kk: v for kk, v in r["comm"].items() if v} or "none"
print(f"{r['flag']!s:5s} {r['spec']:14s} {r['equal']!s:>10s} {str(r['shard_shape']):>12s} {r['per_dev']:>9d} {comm}")
R = {(r["flag"], r["spec"]): r for r in rows}
assert all(r["equal"] for r in rows) # all 4 cases match the unsharded result
# On: no collectives with either split, and each device generates only its own 8*N/8 values
assert all(sum(R[(True, s)]["comm"].values()) == 0 and R[(True, s)]["per_dev"] == N for s in ("P('x',)", "P(None, 'x')"))
# Off + split by rows: collectives (all-gather / collective-permute) are needed to move paired elements to the right device
assert sum(R[(False, "P('x',)")]["comm"].values()) > 0
# Off + split by columns: no communication, but every device computes the whole 8*N array (8x redundant computation)
assert sum(R[(False, "P(None, 'x')")]["comm"].values()) == 0 and R[(False, "P(None, 'x')")]["per_dev"] == 8 * N
print("\n✓ On: generated locally, zero communication; off: either communication, or every device redundantly computes the whole array")
The result matches the help text of this config option exactly: Without this flag, using the standard jax.random pseudo-random number generation may result in extraneous communication and/or redundant distributed computation.
- When off: elements $i$ and $i+n/2$ come from the same threefry block. When splitting by rows, they often land on different devices. So either you need communication (the all-gather + collective-permute we saw above), or, as with splitting by columns, every device just computes the whole array and keeps its own shard (above, each device generated $8N$ values, 8 times what it needs).
- When on: elements depend only on the global index, so each device generates directly from the indices it owns, with zero communication and zero redundancy.
- Under both settings, results within a single process match the unsharded result. XLA keeps them correct through communication or redundant computation, at the cost of performance. The case that really gives inconsistent results is multiple processes passing in different keys; see the 2-process experiment in Section 5.6.
Advanced Chapter 6, Section 6.4 relies on exactly this to make "global view" dropout with jit + NamedSharding bit-identical to the single-device result.
4.5 "Random numbers aren't guaranteed to match across versions", and the 10.81 from Chapter 1¶
🟢 Must-read
We saw in Section 4.2 that the default of jax_threefry_partitionable changed from False to True in JAX 0.5.0. That alone means the same code with the same seed gives completely different random numbers before and after 0.5.0. split changed too, so everything derived from split changed as well. This is the classic reason why JAX only promises "reproducible with the same version and the same config". Other reasons include switching the PRNG algorithm, switching the sampling algorithm (e.g. how normal is transformed from a uniform distribution), rbg depending on the compiler implementation, and so on.
The install check script in Chapter 1, Section 1.4 computes dot(x, x) (x = normal(PRNGKey(0), (10,))), and its sample output says 10.811179161071777. Under the 0.12 default config we get 9.7387…; with the switch set back to False we get 2.6585…, which is what old versions before 0.5.0 would print. So where does 10.81 come from? Below we run an exhaustive search in subprocesses: switch × x64 × 6 algorithms × seeds 0–999. rbg has special batching semantics (see Section 3.2), so we loop over seeds with lax.map (sequential) rather than vmap.
SEARCH_SCRIPT = """
import sys, json, jax, jax.numpy as jnp, numpy as np
from jax import lax
jax.config.update("jax_threefry_partitionable", sys.argv[1] == "1")
jax.config.update("jax_enable_x64", sys.argv[2] == "1")
TARGET = 10.811179161071777
out = {}
for impl in ["threefry2x32", "threefry4x32", "philox2x32", "philox4x32", "rbg", "unsafe_rbg"]:
def one(seed):
x = jax.random.normal(jax.random.key(seed, impl=impl), (10,))
return jnp.dot(x, x)
vals = np.asarray(jax.jit(lambda s: lax.map(one, s))(jnp.arange(1000)), np.float64)
d = np.abs(vals - TARGET)
out[impl] = {"seed0": float(vals[0]), "min_dist": float(d.min()), "argmin": int(d.argmin())}
print("JSON:" + json.dumps(out))
"""
search = {(f, x): run_py(SEARCH_SCRIPT, f, x) for f in (1, 0) for x in (0, 1)}
best = min(((v["min_dist"], cfg, impl, v["argmin"]) for cfg, res in search.items() for impl, v in res.items()))
print(f"{'partitionable, x64':20s} {'dot(x,x) for threefry2x32 seed 0':>32s}")
for (f, x), res in search.items():
print(f"{f!s:>10s}, {x!s:<8s} {res['threefry2x32']['seed0']:>32.6f}")
print(f"\nClosest to 10.811179 among all {len(search) * 6 * 1000} combinations: distance {best[0]:.2e}"
f" (partitionable={best[1][0]}, x64={best[1][1]}, impl={best[2]}, seed={best[3]})")
assert np.isclose(search[(1, 0)]["threefry2x32"]["seed0"], 9.7387, atol=1e-3) # 0.12 default
assert np.isclose(search[(0, 0)]["threefry2x32"]["seed0"], 2.6586, atol=1e-3) # default before 0.5.0
assert best[0] > 1e-4 # no combination reproduces 10.811179 within floating-point error (float32 error is about 1e-6)
Conclusion: jax_threefry_partitionable explains why "the same code gives different numbers on different JAX versions". Old versions (switch defaulting to False) print 2.6585…, and from 0.5.0 on it prints 9.7387…. threefry is bit-identical on CPU / GPU / TPU, so the backend isn't the cause either. But it can't explain 10.81 itself: after exhaustively trying 24,000 combinations (switch, x64, 6 algorithms, 1,000 seeds), the closest one is still off by more than $10^{-4}$, far more than float32 rounding error. So this number is most likely not the real output of this code on standard JAX. Maybe it came from different code, a different seed or shape, or maybe it's just an illustrative value. For us, there's only one thing to remember: don't hard-code specific random values in tests. When you really need bit-for-bit reproduction, pin the JAX version, explicitly set jax_threefry_partitionable and the PRNG algorithm in your code, or simply save the generated data.
5 Key management patterns¶
Each pattern comes with a ❌ / ✅ pair. Basics Chapter 6 already covered the iron rule that "a key is explicit state"; here we focus on how to write it in specific situations.
5.1 Discard the old key after split¶
🟢 Must-read
key, sub = jax.random.split(key): use sub, and the new key overwrites the old one, which is never used again. The most common mistake is forgetting to assign the new key back.
# ❌ Wrong: key isn't updated after split, so the next split starts from the same old key → you get the same child key twice
key = jax.random.key(0)
try:
_, k_init = jax.random.split(key) # the new key was thrown into _
w_init = jax.random.normal(k_init, (4,))
_, k_noise = jax.random.split(key) # split from the old key again
noise = jax.random.normal(k_noise, (4,))
print("No error, but the \"init\" and the \"noise\" are identical:", bool(jnp.array_equal(w_init, noise)))
except Exception as e:
print("❌", err_line(e))
assert jnp.array_equal(w_init, noise)
# ✅ Correct: after every split, assign the new key back to key and never use the old one again
key = jax.random.key(0)
key, k_init = jax.random.split(key)
w_init = jax.random.normal(k_init, (4,))
key, k_noise = jax.random.split(key)
noise = jax.random.normal(k_noise, (4,))
print("Init and noise differ:", not bool(jnp.array_equal(w_init, noise)))
assert not jnp.array_equal(w_init, noise) and not bool(k_init == k_noise)
5.2 split(key, n) makes several at once¶
🟢 Must-read
When you need $n$ independent keys (one per layer, one per worker, one per environment), generate them all at once with split(key, n). A common mistake is using "adjacent seeds" key(seed + i). Statistically it looks fine on its own (Section 7.2 tests it), but different experiments collide: worker 1 of seed 0 and worker 0 of seed 1 use the same key.
# ❌ Wrong: derive the key for worker i with key(seed + i) → experiments with different seeds share random numbers
def worker_keys_bad(seed, n):
return [jax.random.key(seed + i) for i in range(n)]
try:
run_seed0 = worker_keys_bad(0, 4)
run_seed1 = worker_keys_bad(1, 4)
collide = bool(run_seed0[1] == run_seed1[0])
print("No error, but seed0/worker1 and seed1/worker0 have the same key:", collide)
except Exception as e:
print("❌", err_line(e))
assert collide
assert jnp.array_equal(jax.random.normal(run_seed0[1], (3,)), jax.random.normal(run_seed1[0], (3,)))
# ✅ Correct: split n keys at once from the experiment's main key
def worker_keys(seed, n):
return jax.random.split(jax.random.key(seed), n)
a, b = worker_keys(0, 4), worker_keys(1, 4)
all_keys = [tuple(r) for r in np.asarray(jax.random.key_data(jnp.concatenate([a, b]))).tolist()]
print("8 keys across the two experiments, all distinct:", len(set(all_keys)) == 8)
assert len(set(all_keys)) == 8
# One split(key, n) vs. n splits in a Python loop (both results are usable, but the loop needs n dispatches)
def loop_split(key, n):
out = []
for _ in range(n):
key, sub = jax.random.split(key)
out.append(sub)
return out
t_loop = bench(lambda kk: loop_split(kk, 200), jax.random.key(0), runs=5) # bench warms up first, excluding the first compilation
t_once = bench(lambda kk: jax.random.split(kk, 200), jax.random.key(0))
print(f"200 keys: Python loop of splits {t_loop:.2f} ms, one split(key, 200) {t_once:.3f} ms (median after warmup, printed only)")
5.3 fold_in(key, step): deriving keys for training steps / data indices¶
🟢 Must-read
fold_in(key, i) "mixes" an integer into a key and gives you a new key. Compared with "split every step and carry the key into the next step", it has three advantages:
- Random access: the key for step 37 is just
fold_in(base, 37), with no need to replay from the start. To resume training, you only need to savebaseand the step count; - Inside jit,
stepis a regular dynamic argument: changing the step doesn't recompile; - When you derive keys from data indices, each example's random augmentation depends only on its index, regardless of how the batch is formed or ordered.
The integer passed to fold_in must fit in a uint32 (negative numbers raise OverflowError).
# ❌ Wrong: every step of the training loop generates the dropout mask from the same key → every step has the same mask
base = jax.random.key(42)
x_act = jnp.ones((4, 6))
@jax.jit
def dropout_mask(key, x):
return jax.random.bernoulli(key, 0.5, x.shape)
try:
masks_bad = [dropout_mask(base, x_act) for step in range(5)]
print("No error, but the masks of all 5 steps are identical:", all(bool(jnp.array_equal(m, masks_bad[0])) for m in masks_bad))
except Exception as e:
print("❌", err_line(e))
assert all(jnp.array_equal(m, masks_bad[0]) for m in masks_bad)
# ✅ Correct: inside jit, derive each step's key with fold_in(base, step), passing step as a dynamic argument
trace_count = 0
@jax.jit
def dropout_mask_at(base, step, x):
global trace_count
trace_count += 1
return jax.random.bernoulli(jax.random.fold_in(base, step), 0.5, x.shape)
masks = [dropout_mask_at(base, jnp.uint32(s), x_act) for s in range(5)]
distinct_masks = len({np.asarray(m).tobytes() for m in masks})
print("Masks of the 5 steps are all distinct:", distinct_masks == 5, "| trace count:", trace_count)
assert distinct_masks == 5 and trace_count == 1
# Random access: recompute step 3 directly, without replaying earlier steps
assert jnp.array_equal(dropout_mask_at(base, jnp.uint32(3), x_act), masks[3])
# Derive from data indices: the same example gets the same augmentation noise no matter which batch it lands in
k_aug = jax.random.key(7)
def augment(idx, x): # x is a single example, idx is its index in the dataset
return x + 0.1 * jax.random.normal(jax.random.fold_in(k_aug, idx), x.shape)
data = jnp.arange(10.0).reshape(10, 1) * jnp.ones((10, 3))
batch_a = jnp.array([5, 1, 7]); batch_b = jnp.array([2, 5, 9, 0])
aug_a = jax.vmap(augment)(batch_a, data[batch_a])
aug_b = jax.vmap(augment)(batch_b, data[batch_b])
print("Example 5 gets the same augmentation in two different batches:", bool(jnp.array_equal(aug_a[0], aug_b[1])))
assert jnp.array_equal(aug_a[0], aug_b[1])
try:
jax.random.fold_in(base, -1)
except OverflowError as e:
print("fold_in(base, -1) →", err_line(e))
5.4 Putting the key in the carry of lax.scan¶
🟢 Must-read
The loop body of scan is a pure function body(carry, x) -> (carry, y). There are two ways to handle the key. One is to put it in the carry: split every step and hand the new key to the next step. The other is to split(key, T) ahead of time into a key array and feed it in step by step as xs. The DP-SGD (topic 4) and the capstone project's train_epoch in Basics Chapter 7 use the first way.
# ❌ Wrong: the key is in the carry, but the loop body never updates it → every step gets the same random number
def body_bad(key, _):
return key, jax.random.normal(key) # passes the old key to the next step unchanged
try:
_, ys_bad = lax.scan(body_bad, jax.random.key(0), None, length=6)
print("No error, but the random numbers of the 6 steps:", ys_bad)
except Exception as e:
print("❌", err_line(e))
assert bool(jnp.all(ys_bad == ys_bad[0]))
# ✅ Correct, option A: split every step and hand the new key to the next step as the carry
def body(key, _):
key, sub = jax.random.split(key)
return key, jax.random.normal(sub)
_, ys_a = jax.jit(lambda kk: lax.scan(body, kk, None, length=6))(jax.random.key(0))
# Bit-identical to the equivalent Python loop
kk, ys_loop = jax.random.key(0), []
for _ in range(6):
kk, sub = jax.random.split(kk)
ys_loop.append(jax.random.normal(sub))
assert jnp.array_equal(ys_a, jnp.stack(ys_loop))
# ✅ Correct, option B: split T keys ahead of time and feed them in as xs (no key in the carry)
_, ys_b = lax.scan(lambda c, kt: (c, jax.random.normal(kt)), None, jax.random.split(jax.random.key(0), 6))
print("Option A:", ys_a)
print("Option B:", ys_b)
assert len(set(np.asarray(ys_a).tolist())) == 6 and len(set(np.asarray(ys_b).tolist())) == 6
5.5 Mapping over a key array with vmap¶
🟢 Must-read
When each example needs its own random numbers (per-example dropout, a random initial state per environment), first split(key, B), then vmap over that key array. Don't broadcast the same key to every example.
# ❌ Wrong: the key isn't mapped under vmap (in_axes=None) → every example gets the same "random numbers"
def noisy_sample(key, x):
return x + jax.random.normal(key, x.shape)
xs_batch = jnp.zeros((4, 3))
try:
out_bad = jax.vmap(noisy_sample, in_axes=(None, 0))(jax.random.key(0), xs_batch)
print("No error, but the noise is identical for all 4 examples:", bool(jnp.all(out_bad == out_bad[0])))
except Exception as e:
print("❌", err_line(e))
assert bool(jnp.all(out_bad == out_bad[0]))
# ✅ Correct: split B keys and map them along axis 0 together with the examples
keys_b = jax.random.split(jax.random.key(0), xs_batch.shape[0])
out_ok = jax.vmap(noisy_sample)(keys_b, xs_batch)
loop_ok = jnp.stack([noisy_sample(kb, xb) for kb, xb in zip(keys_b, xs_batch)])
print("vmap result matches calling one at a time:", bool(jnp.array_equal(out_ok, loop_ok)))
assert jnp.array_equal(out_ok, loop_ok)
assert len({np.asarray(r).tobytes() for r in out_ok}) == 4
# Two levels of vmap: split(key, (B, T)) gives a 2D key array directly
keys_bt = jax.random.split(jax.random.key(1), (4, 5))
out_bt = jax.vmap(jax.vmap(lambda kk: jax.random.normal(kk)))(keys_bt)
assert out_bt.shape == (4, 5) and out_bt[2, 3] == jax.random.normal(keys_bt[2, 3])
5.6 Multiple processes: deriving a key for each process¶
⚪ Optional: for multi-host / multi-process training
In multi-host training, every process runs the same script. Random numbers fall into two categories, and they're handled in exactly opposite ways:
| Use | Where the key comes from | Why |
|---|---|---|
Global arrays (sharded parameter init generated by jit, dropout on the global batch) | All processes use the same key | jit treats inputs that aren't global arrays as "identical across processes"; partitionable makes sure each process computes only its own shard |
| Process-local randomness (shuffling this process's data, data augmentation on the host) | fold_in(key(seed), jax.process_index()) | Each process needs a different random stream, tied to the seed and reproducible |
Don't use jax.random.key(jax.process_index()) directly. It treats the process index as the seed and, just like in Section 5.2, collides across experiments.
# ❌ Wrong: use the process index directly as the seed (process_index simulated with an argument here)
def process_key_bad(seed, process_index):
return jax.random.key(process_index) # the seed is ignored!
try:
collide = bool(process_key_bad(0, 1) == process_key_bad(123, 1))
print("No error, but process 1 gets the same key in the seed-0 and seed-123 experiments:", collide)
except Exception as e:
print("❌", err_line(e))
assert collide
# ✅ Correct: the experiment seed determines the main key, then fold_in the process index
def process_key(seed, process_index):
return jax.random.fold_in(jax.random.key(seed), process_index)
pkeys = [process_key(s, p) for s in (0, 123) for p in range(4)]
print("2 experiments × 4 processes = 8 keys, all distinct:",
len({tuple(np.asarray(jax.random.key_data(x)).tolist()) for x in pkeys}) == 8)
assert len({tuple(np.asarray(jax.random.key_data(x)).tolist()) for x in pkeys}) == 8
assert bool(process_key(0, jax.process_index()) == jax.random.fold_in(jax.random.key(0), 0)) # index=0 in a single process
🔍 Extra: really run 2 processes on this machine and see what happens when a global array uses the wrong key¶
⚪ Optional: for multi-process training, if you want to see the consequences of the wrong key for yourself
Following Advanced Chapter 3, Section 3.8, we start 2 processes with subprocess (each simulating 4 CPU devices, 8 global devices in total) and generate a row-sharded global array normal(key, (8, 4)) inside jit:
- ✅ Both processes pass in the same key: each process computes only its own 4 rows, and put together they're bit-identical to the single-process
normal(key(42), (8, 4)); - ❌ Each process passes in
fold_in(key, process_index): no error, but the global array becomes a patchwork of "first 4 rows from key A, last 4 rows from key B", which doesn't equal the array generated by any single key. This is what "inconsistent sharded random numbers" really looks like; - The process-local
permutationusesfold_in(key, process_index), so the two processes get different shuffles, which is exactly what we want.
MP_WORKER = """
import os, sys, json
pid, nproc, port = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=4" # 4 devices per process
import jax, jax.numpy as jnp, numpy as np
from jax.sharding import NamedSharding, PartitionSpec as P
jax.distributed.initialize(coordinator_address=f"localhost:{port}", num_processes=nproc, process_id=pid)
mesh = jax.make_mesh((jax.device_count(),), ("data",))
gen = jax.jit(lambda k: jax.random.normal(k, (8, 4)), out_shardings=NamedSharding(mesh, P("data")))
base = jax.random.key(42)
def local_rows(arr): # shards this process can access: {starting row: data}
return {int(s.index[0].start): np.asarray(s.data).tolist() for s in arr.addressable_shards}
out = {"pid": jax.process_index(), "nproc": jax.process_count(), "ndev": jax.device_count(),
"shared": local_rows(gen(base)), # ✅ same key
"per_process": local_rows(gen(jax.random.fold_in(base, jax.process_index()))), # ❌ a different key per process
"local_perm": np.asarray(jax.random.permutation(jax.random.fold_in(base, jax.process_index()), 6)).tolist()}
print("JSON:" + json.dumps(out))
"""
def run_two_processes(code, nproc=2, timeout=300):
s = socket.socket(); s.bind(("localhost", 0)); port = s.getsockname()[1]; s.close()
procs = [subprocess.Popen([sys.executable, "-c", code, str(i), str(nproc), str(port)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) for i in range(nproc)]
results = {}
for p in procs:
o, e = p.communicate(timeout=timeout)
assert p.returncode == 0, e[-3000:]
r = json.loads([l for l in o.splitlines() if l.startswith("JSON:")][-1][5:])
results[r["pid"]] = r
return results
mp = run_two_processes(MP_WORKER)
def assemble(field):
rows = {}
for r in mp.values():
rows.update({int(i): v for i, v in r[field].items()})
return np.concatenate([np.array(rows[i]) for i in sorted(rows)])
ref42 = np.asarray(jax.random.normal(jax.random.key(42), (8, 4)))
ref_p0 = np.asarray(jax.random.normal(jax.random.fold_in(jax.random.key(42), 0), (8, 4)))
ref_p1 = np.asarray(jax.random.normal(jax.random.fold_in(jax.random.key(42), 1), (8, 4)))
g_shared, g_bad = assemble("shared"), assemble("per_process")
for pid, r in sorted(mp.items()):
print(f"Process {pid}/{r['nproc']}: {r['ndev']} global devices, rows held by this process {sorted(int(i) for i in r['shared'])}, local shuffle {r['local_perm']}")
print("✅ Same key: assembled global array == single-process normal(key(42), (8,4))?", np.array_equal(g_shared, ref42))
print("❌ Different key per process: first 4 rows == first 4 rows of key A?", np.array_equal(g_bad[:4], ref_p0[:4]),
"| last 4 rows == last 4 rows of key B?", np.array_equal(g_bad[4:], ref_p1[4:]),
"| equal to any complete array?", any(np.array_equal(g_bad, r_) for r_ in (ref42, ref_p0, ref_p1)))
assert all(r["nproc"] == 2 and r["ndev"] == 8 for r in mp.values())
assert np.array_equal(g_shared, ref42)
assert np.array_equal(g_bad[:4], ref_p0[:4]) and np.array_equal(g_bad[4:], ref_p1[4:])
assert not any(np.array_equal(g_bad, r_) for r_ in (ref42, ref_p0, ref_p1))
assert mp[0]["local_perm"] != mp[1]["local_perm"]
5.7 Key management for dropout, and the design of Flax nnx.Rngs¶
⚪ Optional: when you hand-write dropout or use Flax NNX; the key point is to use a different key for each layer
When you write dropout in plain JAX, the keys usually come from here: one step key per step, and each layer derives its own from it. The easiest mistake is having several dropout layers share one key. Then layers of the same shape get exactly the same mask, and both layers drop exactly the same positions.
def dropout(key, x, rate, train=True):
"""Pure-function dropout: during training, zero out with probability rate and scale by 1/(1-rate) to keep the expectation unchanged."""
if not train or rate == 0.0:
return x
keep = jax.random.bernoulli(key, 1.0 - rate, x.shape)
return jnp.where(keep, x / (1.0 - rate), 0.0)
def init_mlp(key, sizes):
ks_ = jax.random.split(key, len(sizes) - 1)
return [{"w": jax.random.normal(kk, (a, b)) / np.sqrt(a), "b": jnp.zeros(b)}
for kk, a, b in zip(ks_, sizes[:-1], sizes[1:])]
params_mlp = init_mlp(jax.random.key(0), [16, 32, 32, 4])
x_mlp = jax.random.normal(jax.random.key(1), (8, 16))
def hidden_masks(params, x, layer_keys, rate=0.5):
"""Forward pass that returns the dropout mask (kept or not) of each hidden layer, for inspection. Weights and data are passed in as arguments."""
masks, h = [], x
for layer, kk in zip(params[:-1], layer_keys):
h = jax.nn.relu(h @ layer["w"] + layer["b"])
h_d = dropout(kk, jnp.ones_like(h), rate) # apply dropout to all ones to read out the mask directly
masks.append(h_d > 0)
h = h * h_d
return masks
# ❌ Wrong: both hidden layers use the same key → layers of the same shape get exactly the same mask
step_key = jax.random.fold_in(jax.random.key(2), 0)
try:
m1, m2 = jax.jit(hidden_masks)(params_mlp, x_mlp, [step_key, step_key])
print("No error, but the dropout masks of layers 1 and 2 are identical:", bool(jnp.array_equal(m1, m2)))
except Exception as e:
print("❌", err_line(e))
assert jnp.array_equal(m1, m2)
# ✅ Correct: each layer derives its own key from the step key (fold_in the layer index, or split out n_layers keys)
layer_keys = [jax.random.fold_in(step_key, layer_id) for layer_id in range(2)]
m1, m2 = jax.jit(hidden_masks)(params_mlp, x_mlp, layer_keys)
print("Masks of layers 1 and 2 differ:", not bool(jnp.array_equal(m1, m2)),
f"| keep fractions {float(m1.mean()):.3f}, {float(m2.mean()):.3f} (expected 0.5)")
assert not jnp.array_equal(m1, m2)
# dropout keeps the expectation: the mean over a large array is about the original value
big = jnp.full((200_000,), 3.0)
mean_after = float(dropout(jax.random.key(3), big, 0.3).mean())
print(f"Mean before/after dropout(rate=0.3): 3.0 → {mean_after:.4f}")
assert abs(mean_after - 3.0) < 0.02
assert jnp.array_equal(dropout(jax.random.key(3), big, 0.3, train=False), big) # unchanged in eval mode
How Flax NNX does it. nnx.Rngs(params=0, dropout=1) creates a "random stream" RngStream for each name. A stream stores just two things: a fixed key and a counter count. Each call to rngs.dropout() returns fold_in(key, count) and then adds 1 to count. Put simply, it wraps the fold_in(base, step) pattern from Section 5.3 into an object that "looks stateful":
- The only mutable state is an integer counter. It's cheap and can be saved in a checkpoint, so when you resume training the random stream continues where it left off;
nnx.jitpasses the counter in as module state and writes the updated value back, so each call after jit still gets a new mask;- In eval mode (
model.eval()),Dropoutconsumes no random numbers, and the counter doesn't change.
(Advanced Chapter 6 already built a training loop with nnx.Rngs; here we only look at its internal key management.)
rngs = nnx.Rngs(params=0, dropout=1)
stream = rngs.dropout
c0 = int(stream.count[...])
k_a = rngs.dropout()
k_b = rngs.dropout()
print("Key of the dropout stream:", jax.random.key_data(stream.key[...]), "| counter:", c0, "→", int(stream.count[...]))
assert bool(stream.key[...] == jax.random.key(1)) # the stream's key is key(1) and never changes
assert bool(k_a == jax.random.fold_in(jax.random.key(1), 0)) # 1st call = fold_in(key, 0)
assert bool(k_b == jax.random.fold_in(jax.random.key(1), 1)) # 2nd call = fold_in(key, 1)
assert int(stream.count[...]) == c0 + 2
print("✓ rngs.dropout() == fold_in(key(1), count), and each call does count += 1")
# ❌ Wrong: no rngs given when creating nnx.Dropout, and none passed at call time → nowhere to get a key in training mode
try:
nnx.Dropout(0.5)(jnp.ones((2, 4)))
nnx_err = None
except Exception as e:
nnx_err = e
print("❌", err_line(e))
assert isinstance(nnx_err, ValueError)
# ✅ Correct: give rngs to the module (or pass rngs=... at call time) and let the random stream manage the key
class DropMLP(nnx.Module):
def __init__(self, rngs: nnx.Rngs):
self.lin = nnx.Linear(4, 6, rngs=rngs) # initialized from the params stream
self.drop = nnx.Dropout(0.5, rngs=rngs) # derives its own stream from the dropout stream
def __call__(self, x):
return self.drop(self.lin(x))
model = DropMLP(nnx.Rngs(params=0, dropout=1))
x_in = jnp.ones((3, 4))
drop_stream = model.drop.rngs
# Where the mask comes from: bernoulli(fold_in(stream key, current count), 1 - rate)
kd_stream, cnt = drop_stream.key[...], int(drop_stream.count[...])
h_lin = model.lin(x_in)
y1 = model(x_in)
keep = jax.random.bernoulli(jax.random.fold_in(kd_stream, cnt), 0.5, h_lin.shape)
assert jnp.array_equal(y1, jnp.where(keep, h_lin / 0.5, 0.0))
y2 = model(x_in)
step_fn = nnx.jit(lambda m, x: m(x)) # the module (including the counter) is passed in as an argument and written back after update
y3, y4 = step_fn(model, x_in), step_fn(model, x_in)
outs_nnx = [np.asarray(y).tobytes() for y in (y1, y2, y3, y4)]
print("4 calls in training mode (2 eager + 2 nnx.jit) all give different results:", len(set(outs_nnx)) == 4,
"| counter:", cnt, "→", int(drop_stream.count[...]))
assert len(set(outs_nnx)) == 4 and int(drop_stream.count[...]) == cnt + 4
model.eval()
cnt_eval = int(drop_stream.count[...])
assert jnp.array_equal(model(x_in), model(x_in)) and int(drop_stream.count[...]) == cnt_eval
model.train()
print("✓ In eval mode the output is deterministic and no random numbers are consumed")
6 Common pitfalls¶
6.1 Reusing a key makes results correlated¶
🟢 Must-read
Chapter 1 already showed that generating normal twice from the same key gives identical results. A sneakier case is feeding the same key to different distributions. uniform, normal, bernoulli, and exponential all apply different monotonic transformations to the same stream of random bits, so they're deterministically related. Looking different doesn't mean they're independent.
# ❌ Wrong: generate "uniform noise", "Gaussian noise", and a "dropout mask" from the same key and assume they're independent
n_s = 100_000
k_same = jax.random.key(0)
try:
u = np.asarray(jax.random.uniform(k_same, (n_s,)))
z = np.asarray(jax.random.normal(k_same, (n_s,)))
bern = np.asarray(jax.random.bernoulli(k_same, 0.3, (n_s,)))
pearson = np.corrcoef(u, z)[0, 1]
spearman = stats.spearmanr(u, z).statistic
print(f"No error, but corr(uniform, normal): Pearson={pearson:.4f}, Spearman={spearman:.6f}")
print("bernoulli(k, 0.3) == (uniform(k) < 0.3) holds element-wise:", bool(np.array_equal(bern, u < 0.3)))
except Exception as e:
print("❌", err_line(e))
assert pearson > 0.97 and spearman > 0.99999 # ranks match exactly: normal is a monotonic function of uniform
assert np.array_equal(bern, u < 0.3)
# ✅ Correct: one child key per purpose
k_u, k_z, k_b = jax.random.split(jax.random.key(0), 3)
u = np.asarray(jax.random.uniform(k_u, (n_s,)))
z = np.asarray(jax.random.normal(k_z, (n_s,)))
bern = np.asarray(jax.random.bernoulli(k_b, 0.3, (n_s,)))
r_uz = np.corrcoef(u, z)[0, 1]; r_ub = np.corrcoef(u, bern)[0, 1]
print(f"corr(uniform, normal) = {r_uz:+.4f}, corr(uniform, bernoulli) = {r_ub:+.4f} (threshold ±{5 / np.sqrt(n_s):.4f})")
assert abs(r_uz) < 5 / np.sqrt(n_s) and abs(r_ub) < 5 / np.sqrt(n_s)
6.2 Capturing a key in a closure inside jit: the random numbers become constants¶
🟢 Must-read
A key referenced through a closure in a jit function isn't an argument. It's a constant at trace time and gets compiled straight into the program. Every later call uses that one key, so the result is always the same. You can see it among the constants (consts) of the jaxpr.
# ❌ Wrong: the key is captured by a closure
key_global = jax.random.key(0)
@jax.jit
def add_noise_bad(x):
return x + jax.random.normal(key_global, x.shape)
try:
r1, r2 = add_noise_bad(jnp.zeros(3)), add_noise_bad(jnp.zeros(3))
closed = jax.make_jaxpr(lambda x: x + jax.random.normal(key_global, x.shape))(jnp.zeros(3))
print("No error, but both calls give the same result:", bool(jnp.array_equal(r1, r2)))
print("Constants of the jaxpr:", [getattr(c, "dtype", type(c)) for c in closed.consts])
except Exception as e:
print("❌", err_line(e))
assert jnp.array_equal(r1, r2)
assert any(jnp.issubdtype(getattr(c, "dtype", np.float32), jax.dtypes.prng_key) for c in closed.consts)
# ✅ Correct: pass the key in as an argument; the caller is responsible for splitting each time
@jax.jit
def add_noise(key, x):
return x + jax.random.normal(key, x.shape)
key = jax.random.key(0)
key, k1 = jax.random.split(key)
key, k2 = jax.random.split(key)
s1, s2 = add_noise(k1, jnp.zeros(3)), add_noise(k2, jnp.zeros(3))
print("The two calls give different results:", not bool(jnp.array_equal(s1, s2)))
assert not jnp.array_equal(s1, s2)
assert len(jax.make_jaxpr(add_noise)(k1, jnp.zeros(3)).consts) == 0
6.3 split(key) returns a key array, which can't be used directly as a key¶
⚪ Optional: an error that raises ValueError right away; look it up when you hit it
jax.random.split(key) returns a key array of shape (2,) (split(key, n) gives (n,)). Random functions accept only a single key (shape ()), and passing a key array raises ValueError. Raw keys behave the same way: split(PRNGKey(0)) is uint32[2, 2].
# ❌ Wrong: assign the whole result of split back to key, then sample with it directly
key = jax.random.key(0)
split_errors = []
for name, kk in [("typed: key = split(key)", jax.random.split(key)),
("raw: key = split(PRNGKey(0))", jax.random.split(jax.random.PRNGKey(0)))]:
try:
jax.random.normal(kk, (3,))
print("(no error)", name)
except Exception as e:
split_errors.append(type(e).__name__)
print(f"❌ {name} → {err_line(e)}")
assert split_errors == ["ValueError", "ValueError"]
# ✅ Correct: unpack; or index into / vmap over the key array
key = jax.random.key(0)
key, sub = jax.random.split(key) # unpack into two separate keys
a1 = jax.random.normal(sub, (3,))
ks4 = jax.random.split(key, 4)
a2 = jax.random.normal(ks4[2], (3,)) # take one out
a3 = jax.vmap(lambda kk: jax.random.normal(kk, (3,)))(ks4) # or map over the whole set
print("sub.shape =", sub.shape, "| ks4[2].shape =", ks4[2].shape, "| vmap output", a3.shape)
assert sub.shape == () and a3.shape == (4, 3) and jnp.array_equal(a3[2], a2)
6.4 Python random / NumPy random numbers run only once in jit, during tracing¶
🟢 Must-read
When jit traces a function, Python-level code runs only once. random.random() and np.random.normal() return ordinary Python / NumPy values, so they get compiled into the program as constants. Every later call that hits the cache uses that constant.
# ❌ Wrong: using Python random / NumPy random numbers inside jit
trace_py = 0
@jax.jit
def jitter_bad(x):
global trace_py
trace_py += 1
return x + py_random.random() + np.random.normal(size=x.shape)
try:
outs_bad = [jitter_bad(jnp.zeros(3)) for _ in range(3)]
print("No error, but the results of 3 calls:", [np.round(np.asarray(o), 4).tolist() for o in outs_bad], "| trace count:", trace_py)
except Exception as e:
print("❌", err_line(e))
assert all(jnp.array_equal(o, outs_bad[0]) for o in outs_bad) and trace_py == 1
# ✅ Correct: use jax.random with the key as an argument
@jax.jit
def jitter(key, x):
k_u, k_n = jax.random.split(key)
return x + jax.random.uniform(k_u) + jax.random.normal(k_n, x.shape)
outs_ok = [jitter(jax.random.fold_in(jax.random.key(0), i), jnp.zeros(3)) for i in range(3)]
print("The 3 calls all give different results:", len({np.asarray(o).tobytes() for o in outs_ok}) == 3)
assert len({np.asarray(o).tobytes() for o in outs_ok}) == 3
7 Simple tests of statistical quality¶
These tests can't prove a PRNG is "good" (that takes large test suites like BigCrush), but they quickly catch usage mistakes: the wrong distribution, a reused key, correlated child keys. The random numbers are determined by fixed keys, so the asserts below give the same result on every run.
7.1 Mean, variance, and the KS test¶
⚪ Optional: when you need to test random numbers statistically or track down a wrong distribution
For $n$ samples, the standard error of the mean is $\sigma/\sqrt n$, and the standard error of the variance estimate is about $\sigma^2\sqrt{2/n}$ (in the normal case). We use 5 standard errors as the tolerance. The KS test compares the largest gap between the empirical and theoretical distributions; a very small p-value means the samples "don't look like" that distribution. Finally, a counterexample confirms that the test really can catch problems.
n_t = 200_000
kn, ku, ke, kr_ = jax.random.split(jax.random.key(2026), 4)
z = np.asarray(jax.random.normal(kn, (n_t,)), np.float64)
u = np.asarray(jax.random.uniform(ku, (n_t,)), np.float64)
e = np.asarray(jax.random.exponential(ke, (n_t,)), np.float64)
ri = np.asarray(jax.random.randint(kr_, (n_t,), 0, 10))
checks = [
("normal", z, 0.0, 1.0, stats.kstest(z, "norm").pvalue),
("uniform", u, 0.5, 1 / 12, stats.kstest(u, "uniform").pvalue),
("exponential", e, 1.0, 1.0, stats.kstest(e, "expon").pvalue),
]
print(f"{'dist':12s} {'mean':>9s} {'theory':>6s} {'variance':>9s} {'theory':>7s} {'KS p':>8s}")
for name, s, mu, var, p in checks:
print(f"{name:12s} {s.mean():9.5f} {mu:6.3f} {s.var():9.5f} {var:7.4f} {p:8.4f}")
assert abs(s.mean() - mu) < 5 * np.sqrt(var / n_t)
assert abs(s.var() - var) < 5 * var * np.sqrt(2 / n_t) * (3 if name == "exponential" else 1) # the variance estimate of the exponential is more spread out, so loosen it
assert p > 1e-3
# randint uses a chi-square test: the counts of the 10 values should be close to uniform
counts = np.bincount(ri, minlength=10)
chi2_p = stats.chisquare(counts).pvalue
print(f"\nrandint(0, 10) counts {counts.tolist()}, chi-square test p = {chi2_p:.4f}")
assert chi2_p > 1e-3 and ri.min() == 0 and ri.max() == 9
# Counterexample: test uniform samples as if they were normal; KS should firmly reject → the test has detection power
p_wrong = stats.kstest(u, "norm").pvalue
print(f"Counterexample: uniform samples vs. normal distribution, KS p = {p_wrong:.2e}")
assert p_wrong < 1e-10
7.2 Independence between child keys¶
⚪ Optional: when you want to check whether child keys are correlated
Generate $n$ normal samples from each of 64 child keys, and compute the off-diagonal entries of the $64\times64$ correlation matrix. If they're independent, each correlation coefficient is roughly $N(0, 1/n)$, and the largest of the 2016 pairs is usually around $4/\sqrt n$; we use $5/\sqrt n$ as the threshold. We test split, fold_in, adjacent seeds, and unsafe_rbg's split in turn, and finally use "a reused key" as the counterexample. Also, taking the first sample from each of 100,000 child keys and putting them together should give $N(0,1)$ as well (checked with a KS test).
K, n_i = 64, 20_000
thr = 5 / np.sqrt(n_i)
def max_offdiag_corr(keys):
s = np.asarray(jax.vmap(lambda kk: jax.random.normal(kk, (n_i,)))(keys), np.float64)
c = np.corrcoef(s)
return np.abs(c[~np.eye(len(c), dtype=bool)]).max()
groups = {
"split(key, 64)": jax.random.split(jax.random.key(0), K),
"fold_in(key, 0..63)": jnp.stack([jax.random.fold_in(jax.random.key(0), i) for i in range(K)]),
"key(0..63) adjacent seeds": jnp.stack([jax.random.key(i) for i in range(K)]),
"unsafe_rbg: split(key, 64)": None, # under vmap the rbg family uses only the first key, so generate with a loop separately
"❌ counterexample: 1 key x64": jnp.stack([jax.random.key(0)] * K),
}
results_corr = {}
for name, keys in groups.items():
if keys is None:
ukeys = jax.random.split(jax.random.key(0, impl="unsafe_rbg"), K)
s = np.stack([np.asarray(jax.random.normal(kk, (n_i,)), np.float64) for kk in ukeys])
c = np.corrcoef(s); results_corr[name] = np.abs(c[~np.eye(K, dtype=bool)]).max()
else:
results_corr[name] = max_offdiag_corr(keys)
print(f"{name:32s} max |correlation| = {results_corr[name]:.4f} (threshold {thr:.4f})")
for name, v in results_corr.items():
assert (v > 0.999) if name.startswith("❌") else (v < thr)
first_samples = np.asarray(jax.vmap(lambda kk: jax.random.normal(kk))(jax.random.split(jax.random.key(5), 100_000)), np.float64)
p_first = stats.kstest(first_samples, "norm").pvalue
print(f"\nFirst sample from each of 100k child keys: mean {first_samples.mean():+.4f}, variance {first_samples.var():.4f}, KS p = {p_first:.4f}")
assert p_first > 1e-3 and abs(first_samples.mean()) < 5 / np.sqrt(100_000)
Adjacent seeds
key(0..63)also pass this test. threefry is a cryptography-style hash, and the outputs for adjacent inputs show no visible correlation. So the reason Section 5.2 argues againstkey(seed + i)is not statistical quality but a semantic collision (different experiments share a random stream). And unsafe_rbg passing this rough test doesn't mean its derivation quality is guaranteed; the official docs say clearly that it hasn't gone through systematic randomness testing.
8 Practical rules checklist¶
🟢 Must-read
| # | Rule | Section |
|---|---|---|
| 1 | New code uses only jax.random.key(seed); when migrating old code, set jax_legacy_prng_key='error' to catch raw keys | 2.2 |
| 2 | key, sub = split(key): use sub, pass the new key on, and never touch the old key again | 5.1 |
| 3 | For several keys at once, use split(key, n); don't use key(seed + i) | 5.2 |
| 4 | Derive keys for training steps, data indices, and layer indices with fold_in; pass the step into jit as a dynamic argument | 5.3, 5.7 |
| 5 | In scan, either put the key in the carry and split every step, or split(key, T) ahead of time and pass it as xs | 5.4 |
| 6 | Per-example randomness: split(key, B), then vmap over the key array; don't broadcast the same key | 5.5 |
| 7 | When generating globally sharded arrays, all processes use the same key; for process-local randomness, use fold_in(key(seed), process_index()) | 5.6 |
| 8 | One key per purpose: noise, masks, and initialization never share a key, even if the distributions differ | 6.1 |
| 9 | Always pass keys into jit as arguments, never capture them in a closure; don't use Python random / NumPy random numbers inside jit | 6.2, 6.4 |
| 10 | Save keys as (impl, key_data) and restore with wrap_key_data(..., impl=); split large integer seeds and fold_in the parts | 2.2 |
| 11 | Use threefry2x32 by default and keep jax_threefry_partitionable=True; consider rbg only when a profile on TPU proves RNG is the bottleneck | 3, 4.4 |
| 12 | Don't hard-code specific random values in tests; for bit-for-bit reproduction, pin the version and write out the PRNG-related config explicitly | 4.5 |
Exercises¶
⚪ Optional: exercises to reinforce key upgrades, scan and vmap, dropout keys, and sharded generation
Exercise 1: upgrade the raw keys in an old checkpoint
Old code saved 4 keys as a raw uint32 array: legacy = jax.random.split(jax.random.PRNGKey(7), 4) (shape (4, 2)); another module used rbg and saved legacy_rbg = jax.random.key_data(jax.random.split(jax.random.key(7, impl="rbg"), 4)) (shape (4, 4)).
Write a function upgrade_keys(raw, impl) that returns a typed key array, and verify:
- In both cases the returned shape is
(4,), and the dtypes arekey<fry>andkey<rbg>respectively; - For threefry: sampling with the upgraded
keys[i]is bit-identical to the old code sampling directly withlegacy[i]; - For rbg: the wrong
impl(not passing one) raisesTypeError.
Self-check interface: upgrade_keys(raw, impl="threefry2x32"); when impl isn't passed, interpret as threefry.
# ✍️ Your answer: Exercise 1
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 1
try:
upgrade_keys
except NameError as e:
print(f"⏳ Exercise 1: not answered yet: {e}")
else:
_raw = jax.random.split(jax.random.PRNGKey(99), 3) # a fresh old-style key array, shape (3, 2)
_k = upgrade_keys(_raw)
assert _k.shape == (3,) and str(_k.dtype) == "key<fry>", f"threefry: expected shape (3,) and dtype key<fry>, got {_k.shape} {_k.dtype}"
for _i in range(3):
assert jnp.array_equal(jax.random.uniform(_k[_i], (4,)), jax.random.uniform(_raw[_i], (4,))), \
f"Samples from upgraded key {_i} differ from the old key"
_k1 = upgrade_keys(jax.random.PRNGKey(5)) # a single key
assert _k1.shape == () and str(_k1.dtype) == "key<fry>", f"A single raw key should upgrade to a scalar key, got {_k1.shape} {_k1.dtype}"
_orig = jax.random.split(jax.random.key(11, impl="rbg"), 2)
_kr = upgrade_keys(jax.random.key_data(_orig), impl="rbg")
assert _kr.shape == (2,) and str(_kr.dtype) == "key<rbg>", f"rbg: expected shape (2,) and dtype key<rbg>, got {_kr.shape} {_kr.dtype}"
assert bool(jnp.all(_kr == _orig)), "Upgraded rbg keys don't equal the original keys"
try:
upgrade_keys(jax.random.key_data(_orig))
except TypeError:
pass
else:
raise AssertionError("rbg data without impl should raise TypeError")
print("✅ Exercise 1: self-check passed")
Exercise 2: simulate random walks with scan + vmap
Write walk(key, T): start at 0, add a standard normal increment at each step, implement it with lax.scan with the key in the carry, and return the sequence of T positions. Then:
- Verify that
walk(key(0), 50)is bit-identical to the equivalent Python loop (same split order); - Use
vmapoversplit(key(1), 4000)to simulate 4000 walkers in parallel; verify that the variance of the final positions is close toT(10% tolerance) and that all walkers are distinct; - Put the whole
vmap(walk)insidejit, and compile it only once.
Self-check interface: walk(key, T) returns a position sequence of shape (T,); many_walks(keys, T=...): wrapped in jit, internally vmaps walk, returns shape (len(keys), T).
# ✍️ Your answer: Exercise 2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 2
try:
walk, many_walks
except NameError as e:
print(f"⏳ Exercise 2: not answered yet: {e}")
else:
_T = 20
_path = walk(jax.random.key(5), _T)
_kk, _pos, _ref = jax.random.key(5), 0.0, []
for _ in range(_T): # the equivalent Python loop from the exercise
_kk, _sub = jax.random.split(_kk)
_pos = _pos + jax.random.normal(_sub)
_ref.append(_pos)
assert _path.shape == (_T,), f"walk should return shape ({_T},), got {_path.shape}"
assert jnp.allclose(_path, jnp.stack(_ref), atol=1e-5), "walk doesn't match the equivalent Python loop (check the key split order)"
assert hasattr(many_walks, "lower"), "many_walks should be a function wrapped with jax.jit"
_paths = many_walks(jax.random.split(jax.random.key(3), 2000), T=30)
assert _paths.shape == (2000, 30), f"many_walks output shape should be (2000, 30), got {_paths.shape}"
assert jnp.allclose(_paths[7], walk(jax.random.split(jax.random.key(3), 2000)[7], 30), atol=1e-5), "Path 7 from many_walks doesn't match calling walk on its own"
_v = float(_paths[:, -1].var())
assert abs(_v - 30) / 30 < 0.12, f"The variance of the final positions should be close to T = 30, got {_v:.2f}"
if hasattr(many_walks, "_cache_size"):
_n = many_walks._cache_size()
many_walks(jax.random.split(jax.random.key(4), 2000), T=30)
assert many_walks._cache_size() == _n, "Calling many_walks with a different set of keys triggered recompilation"
print("✅ Exercise 2: self-check passed")
Exercise 3: dropout keys per step and per layer
Write layer_key(base, step, layer) that derives the key for "step step, layer layer" with two fold_ins. Verify:
- 10 steps × 3 layers = 30 keys, all distinct;
- Reproducible: recomputing the key for step 7, layer 2 gives the same value;
layer_key(base, 1, 2)differs fromlayer_key(base, 2, 1)(order matters);- Using these keys to generate dropout masks with
rate=0.2(shape(256, 256)), the overall keep fraction is within0.8 ± 0.005.
Self-check interface: layer_key(base, step, layer); step and layer can be Python integers or dynamic integers inside jit.
# ✍️ Your answer: Exercise 3
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 3
try:
layer_key
except NameError as e:
print(f"⏳ Exercise 3: not answered yet: {e}")
else:
_base = jax.random.key(2024)
_ks = [layer_key(_base, s, l) for s in range(6) for l in range(4)]
_uniq = {tuple(np.asarray(jax.random.key_data(k)).tolist()) for k in _ks}
assert len(_uniq) == 24, f"6 steps × 4 layers should give 24 distinct keys, but there are only {len(_uniq)} of them"
assert bool(layer_key(_base, 5, 3) == _ks[5 * 4 + 3]), "Recomputing the same (step, layer) gave a different key"
assert not bool(layer_key(_base, 2, 3) == layer_key(_base, 3, 2)), "(2, 3) and (3, 2) shouldn't give the same key"
assert not bool(layer_key(jax.random.key(1), 1, 1) == layer_key(_base, 1, 1)), "Different bases should give different keys"
_kj = jax.jit(layer_key)(_base, jnp.int32(4), jnp.int32(2)) # step as a dynamic argument
assert bool(_kj == _ks[4 * 4 + 2]), "The key from dynamic step / layer inside jit differs from the Python-integer version"
_keep = float(jnp.mean(jnp.stack([jax.random.bernoulli(k, 0.8, (256, 256)) for k in _ks])))
assert abs(_keep - 0.8) < 0.005, f"The overall keep fraction of masks with rate = 0.2 should be within 0.8 ± 0.005, got {_keep:.4f}"
print("✅ Exercise 3: self-check passed")
Exercise 4: a checking tool for sharded generation
Write check_sharded_rng(spec, shape=(16, 512), partitionable=True): on an 8-device mesh, generate jax.random.uniform(key(0), shape) with jit(..., out_shardings=NamedSharding(mesh, spec)), and return (whether it equals the unsharded result, total number of collectives in the HLO, the generated array) (the third item is for verifying point 3). Verify:
- With the default setting, both
P('x', None)andP(None, 'x')match the unsharded result, with 0 collectives; - With partitionable off, splitting by rows still gives the same result, but the number of collectives is greater than 0;
- With partitionable off, the array generated from the same key differs from the one under the default setting.
Self-check interface: check_sharded_rng(spec, shape=(16, 512), partitionable=True); the first two items of the return value are (equals unsharded result: bool, total collectives: int); the self-check only checks these two.
# ✍️ Your answer: Exercise 4
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 4
try:
check_sharded_rng
except NameError as e:
print(f"⏳ Exercise 4: not answered yet: {e}")
else:
_shape = (16, 256) # use a different shape
for _spec in (P("x", None), P(None, "x")):
_r = check_sharded_rng(_spec, shape=_shape)
assert bool(_r[0]) is True, f"With the default setting, {_spec} should match the unsharded result"
assert int(_r[1]) == 0, f"With the default setting, {_spec} shouldn't have collectives, got {_r[1]}"
_r = check_sharded_rng(P("x", None), shape=_shape, partitionable=False)
assert bool(_r[0]) is True, "With partitionable off, splitting by rows should still match the unsharded result"
assert int(_r[1]) > 0, "With partitionable off, splitting by rows should produce collectives"
print("✅ Exercise 4: self-check passed")
Quiz¶
⚪ Optional: for self-testing
1. After ks = jax.random.split(jax.random.key(0)), what does jax.random.normal(ks, (3,)) do? What is jax.random.key_data(ks).shape?
Answer and explanation
It raises ValueError (normal accepts a single key, but was given a key array of shape (2,)). split returns a key array of 2 keys; unpack it with key, sub = split(key) or index it with ks[1]. key_data(ks).shape == (2, 2): the key array shape (2,) plus a trailing dimension for threefry's 2 words (Sections 2.1 and 6.3).
2. What is the default of jax_threefry_partitionable in JAX 0.12? If you turn it off, which of normal(key(0), (8,)), split(key(0)), and fold_in(key(0), 1) change?
Answer and explanation
The default is True (since 0.5.0). Turning it off changes normal (the way counters are assigned to random bits changes) and split, but not fold_in (see the subprocess experiment in Section 4.2). That's why the same code gives different random numbers before and after 0.5.0, and where the 2.6585… and 9.7387… for dot(x, x) in Chapter 1 come from.
3. If you call the code below 3 times, what's special about the output?
key = jax.random.key(0)
f = jax.jit(lambda x: x + jax.random.normal(key, x.shape))
Answer and explanation
All 3 outputs are identical. key is captured by the closure and becomes a constant compiled into the program at trace time (you can see it in the jaxpr's consts). Pass the key in as an argument instead and have the caller split it each time (Section 6.2).
4. Create 8 keys keys with impl="rbg". What does jax.vmap(lambda k: jax.random.normal(k))(keys) equal?
Answer and explanation
It equals jax.random.normal(keys[0], (8,)), and does not equal calling normal(keys[i]) one at a time and stacking the results. XLA's RBG is hard to batch, so JAX's workaround is to use only the first key in the batch to generate the whole batch under vmap. threefry / philox don't have this problem (Section 3.2).
5. 2 processes do data parallelism, and inside jit they apply dropout to globally sharded activations. Should each process pass in the same key, or fold_in(key, process_index())? What happens if you get it wrong?
Answer and explanation
Pass in the same key. jit assumes such inputs are the same in every process, and partitionable makes sure each process computes only its own shard, so the pieces put together match the single-process result. If the processes pass in different keys, there's no error, but the global array becomes a patchwork where "each process's shard comes from a different key", which doesn't equal the result from any single key (see the 2-process experiment in Section 5.6). fold_in(key, process_index()) is only for process-local randomness, such as shuffling this process's data.
6. With the default config (x64 off), what is jax.random.key(2**32 + 5) == jax.random.key(5)? Is there a warning?
Answer and explanation
True, and there's no warning, because in 32-bit mode the seed keeps only its low 32 bits. With jax_enable_x64 enabled they differ (the key data is [1, 5]). Split large integer seeds into high and low halves and mix them in one after another with fold_in (see the Extra in Section 2.2).
# Verify the quiz: check the answers to all 6 questions with code
k0 = jax.random.key(0)
try:
jax.random.normal(jax.random.split(k0), (3,)); q1 = None
except ValueError as e:
q1 = "ValueError"
assert q1 == "ValueError" and jax.random.key_data(jax.random.split(k0)).shape == (2, 2) # Question 1
assert jax.config.jax_threefry_partitionable is True # Question 2
assert on["split"] != off["split"] and on["normal_8"] != off["normal_8"] and on["fold_in"] == off["fold_in"]
f_q3 = jax.jit(lambda x: x + jax.random.normal(k0, x.shape))
assert jnp.array_equal(f_q3(jnp.zeros(2)), f_q3(jnp.zeros(2))) # Question 3
kr8 = jax.random.split(jax.random.key(0, impl="rbg"), 8)
assert jnp.array_equal(jax.vmap(lambda k_: jax.random.normal(k_))(kr8), jax.random.normal(kr8[0], (8,))) # Question 4
assert np.array_equal(g_shared, ref42) and not np.array_equal(g_bad, ref42) # Question 5
assert bool(jax.random.key(2**32 + 5) == jax.random.key(5)) # Question 6
print("✓ The answers to all 6 quiz questions match the measurements")
Chapter summary¶
🟢 Must-read
- Design: JAX's PRNG is a counter-based pure function: random bits = hash(key, counter). Explicit keys buy you reproducibility, parallelism (each element depends only on its global index), and compatibility with
jit/vmap. The price is that you manage keys yourself. - Representation: the
shapeof a typed key (key<fry>etc.) is the number of keys, and the internal bits live in a trailing dimension. Get them out withkey_dataand wrap them back withwrap_key_data(..., impl=). Check whether something is a key withjnp.issubdtype(x.dtype, jax.dtypes.prng_key). Reshaping, indexing, and similar operations are legal; arithmetic, reductions, and type conversions raise errors. In 32-bit mode, large seeds are silently truncated. - Algorithms: 0.12 has 6 implementations. The default
threefry2x32is consistent across backends, exact undervmap, and shards efficiently.rbgis fast on TPU and still uses threefry for split.unsafe_rbguses RBG even for deriving keys. The rbg family uses only the first key undervmap. Measured on this machine's CPU, threefry generates slowest and philox fastest (CPU only). - partitionable:
Trueby default in 0.12 (since 0.5.0). This switch changes the random bits andsplit, but notfold_in. When on, elements depend only on the global index, and sharded generation on 8 devices needs no communication at all. When off, you need either all-gather / collective-permute, or every device redundantly computes the whole array. The change of default is the classic example of "random numbers aren't guaranteed to match across versions". It explains the difference between2.6585and9.7387, but not the10.81in the Chapter 1 example (none of 24,000 exhaustively tried combinations match). - Management patterns: overwrite the old key after split; for several at once,
split(key, n); derive keys for steps, indices, and layer indices withfold_in(with the default config,split(k, n)[i] == fold_in(k, i)); inscan, put the key in the carry or split ahead of time;vmapover a key array; global arrays share a key, process-local randomness usesfold_in(key(seed), process_index());nnx.Rngs= fixed key + counter. - Pitfalls: feeding the same key to different distributions makes the results deterministically correlated (Spearman = 1). Keys captured by a closure and Python / NumPy random numbers both get frozen into constants inside jit. The result of
splitis a key array, not a single key. - Testing: standard errors of the mean and variance, KS, chi-square, the correlation matrix of child keys, plus a counterexample to confirm the test really catches problems.
Cheat sheet¶
⚪ Optional: API reference table for lookup
| API | Purpose |
|---|---|
jax.random.key(seed, impl=None) | Create a typed key (optionally choosing the algorithm) |
jax.random.PRNGKey(seed) | Old-style raw uint32 key (for compatibility only) |
jax.random.key_data(keys) | Get the underlying uint32 data (one extra trailing dimension) |
jax.random.wrap_key_data(raw, impl=...) | Wrap raw data into a typed key |
jax.random.key_impl(key) | Check a key's algorithm |
jnp.issubdtype(x.dtype, jax.dtypes.prng_key) | Check whether something is a typed key |
jax.random.split(key, n) / split(key, (a, b)) | Derive a child key array of n keys / any shape |
jax.random.fold_in(key, i) | Mix an integer (uint32 range) into a key to derive a new key |
jax.random.bits(key, shape) | Raw random bits |
jax.random.normal / uniform / bernoulli / randint / exponential / permutation | Common sampling functions |
jax.config.update("jax_default_prng_impl", name) / JAX_DEFAULT_PRNG_IMPL | Global default algorithm |
with jax.default_prng_impl(name): | Temporarily switch the default algorithm |
jax.config.jax_threefry_partitionable / JAX_THREEFRY_PARTITIONABLE | How counters are assigned (default True in 0.12) |
with jax.threefry_partitionable(flag): | Temporarily switch partitionable |
with jax.legacy_prng_key("error"): / jax_legacy_prng_key | Warn / raise when a raw key is passed to a random function |
jax.jit(f, out_shardings=NamedSharding(mesh, spec)) | Generate a sharded random array directly |
f.lower(*args).compile().as_text() | Inspect the compiled HLO (check for collectives) |
jax.extend.random.threefry_2x32 / threefry2x32_p | threefry hash primitives (for studying implementation details) |
jax.process_index() | Current process index (for deriving process-local keys) |
nnx.Rngs(params=0, dropout=1) / rngs.dropout() | Flax NNX named random streams: return fold_in(key, count) and increment the count |
nnx.Dropout(rate, rngs=rngs), model.train() / model.eval() | NNX dropout and training / eval modes |
scipy.stats.kstest / chisquare / spearmanr | Distribution test / frequency test / rank correlation |
Appendix: reference solutions¶
Write your own solution first in the "✍️ Your answer" cell in the main text and check it with "🧪 Self-check". Come here to compare once the self-check passes or you're really stuck. Running the solutions below in order overwrites any functions with the same names that you defined in the answer cells.
Exercise 1¶
See "Exercises" in the main text for the problem.
# Reference solution: Exercise 1
def upgrade_keys(raw, impl="threefry2x32"):
"""Wrap a raw uint32 key array (last dimension is the key data) into a typed key array."""
return jax.random.wrap_key_data(jnp.asarray(raw, jnp.uint32), impl=impl)
legacy = jax.random.split(jax.random.PRNGKey(7), 4)
legacy_rbg = jax.random.key_data(jax.random.split(jax.random.key(7, impl="rbg"), 4))
keys_fry = upgrade_keys(legacy)
keys_rbg = upgrade_keys(legacy_rbg, impl="rbg")
print(keys_fry.shape, keys_fry.dtype, "|", keys_rbg.shape, keys_rbg.dtype)
assert keys_fry.shape == (4,) and str(keys_fry.dtype) == "key<fry>"
assert keys_rbg.shape == (4,) and str(keys_rbg.dtype) == "key<rbg>"
for i in range(4):
assert jnp.array_equal(jax.random.normal(keys_fry[i], (5,)), jax.random.normal(legacy[i], (5,)))
assert bool(jnp.all(keys_rbg == jax.random.split(jax.random.key(7, impl="rbg"), 4)))
try:
upgrade_keys(legacy_rbg) # forgot impl
raised = False
except TypeError as e:
raised = True
print("Without impl →", err_line(e))
assert raised
print("✓ Exercise 1 passed")
Exercise 2¶
See "Exercises" in the main text for the problem.
# Reference solution: Exercise 2
from functools import partial
def walk(key, T):
def body(carry, _):
key, pos = carry
key, sub = jax.random.split(key)
pos = pos + jax.random.normal(sub)
return (key, pos), pos
_, path = lax.scan(body, (key, jnp.float32(0.0)), None, length=T)
return path
# 1) Cross-check against a Python loop
T = 50
path = walk(jax.random.key(0), T)
kk, pos, ref_path = jax.random.key(0), 0.0, []
for _ in range(T):
kk, sub = jax.random.split(kk)
pos = pos + jax.random.normal(sub)
ref_path.append(pos)
assert jnp.allclose(path, jnp.stack(ref_path), atol=1e-5)
# 2) vmap over 4000 walkers
trace_walk = 0
@partial(jax.jit, static_argnames="T")
def many_walks(keys, T):
global trace_walk
trace_walk += 1
return jax.vmap(lambda k_: walk(k_, T))(keys)
paths = many_walks(jax.random.split(jax.random.key(1), 4000), T=T)
paths2 = many_walks(jax.random.split(jax.random.key(2), 4000), T=T)
final_var = float(paths[:, -1].var())
print(f"Variance of final positions = {final_var:.2f} (theory {T}) | compile count = {trace_walk}")
assert paths.shape == (4000, T)
assert abs(final_var - T) / T < 0.10
assert len({np.asarray(p).tobytes() for p in paths[:100]}) == 100
assert trace_walk == 1 # 3) traced / compiled only once
print("✓ Exercise 2 passed")
Exercise 3¶
See "Exercises" in the main text for the problem.
# Reference solution: Exercise 3
def layer_key(base, step, layer):
return jax.random.fold_in(jax.random.fold_in(base, step), layer)
base = jax.random.key(123)
all_k = [layer_key(base, s, l) for s in range(10) for l in range(3)]
uniq = {tuple(np.asarray(jax.random.key_data(x)).tolist()) for x in all_k}
assert len(uniq) == 30 # 1) all distinct
assert bool(layer_key(base, 7, 2) == all_k[7 * 3 + 2]) # 2) reproducible
assert not bool(layer_key(base, 1, 2) == layer_key(base, 2, 1)) # 3) order matters
keep_ratio = float(jnp.mean(jnp.stack([jax.random.bernoulli(x, 0.8, (256, 256)) for x in all_k])))
print(f"Overall keep fraction of the 30 masks = {keep_ratio:.4f}")
assert abs(keep_ratio - 0.8) < 0.005 # 4)
print("✓ Exercise 3 passed")
Exercise 4¶
See "Exercises" in the main text for the problem.
# Reference solution: Exercise 4
def check_sharded_rng(spec, shape=(16, 512), partitionable=True):
mesh8 = jax.make_mesh((8,), ("x",))
key0 = jax.random.key(0)
with jax.threefry_partitionable(partitionable):
ref = jax.jit(lambda kk: jax.random.uniform(kk, shape))(key0)
f = jax.jit(lambda kk: jax.random.uniform(kk, shape), out_shardings=NamedSharding(mesh8, spec))
out = f(key0)
n_comm = sum(collectives(f.lower(key0).compile().as_text()).values())
return bool(jnp.array_equal(out, ref)), n_comm, np.asarray(out)
res = {(str(s), flag): check_sharded_rng(s, partitionable=flag)
for s in (P("x", None), P(None, "x")) for flag in (True, False)}
for (s, flag), (eq, nc, _) in res.items():
print(f"spec={s:14s} partitionable={flag!s:5s} equals unsharded={eq} collectives={nc}")
assert all(res[(s, True)][0] and res[(s, True)][1] == 0 for s in ("P('x', None)", "P(None, 'x')")) # 1)
assert res[("P('x', None)", False)][0] and res[("P('x', None)", False)][1] > 0 # 2)
assert not np.array_equal(res[("P('x', None)", True)][2], res[("P('x', None)", False)][2]) # 3)
print("✓ Exercise 4 passed")