第 10 章 随机数与分片

进阶补充章节 · 环境:Python 3.14 · JAX 0.12.0.1 · 仅 CPU(模拟 8 个设备)。 随机数在前面几章里零散出现过。入门第 1 章 对比了 key 和 PRNGKey,讲了 split、fold_in。入门第 6 章 把 key 当作显式状态。入门第 7 章 在 DP-SGD 和综合项目里把 key 放进 scan 的 carry 和 TrainState。好几章也都提醒过「同一个 key 被复用」的问题。这一章把这些零散的点串起来,重点讲前面没讲过的部分:key 的内部表示、6 种 PRNG 算法、jax_threefry_partitionable 和分片生成,以及它怎样解释第 1 章那个复现不了的 10.81。

所有结论都在 JAX 0.12.0.1 上跑过,并用 assert 验证。计时只打印,不断言。

⚠️ 注意:这一章在第一个代码单元里设置了 XLA_FLAGS(模拟 8 个 CPU 设备),它必须在 import jax 之前生效。如果你已经在这个内核里导入过 jax,请先「重启内核」,再从头运行。

⚠️ 本环境已知问题(见 00_目录与导读,原因分析见 进阶 02 · 2.3):jaxlib 0.12.0.1 的 CPU 后端有个 bug,被 jit 闭包捕获的常量矩阵做完 W @ x 再求和,结果会算错。所以本章的权重和数据一律作为函数参数传入。

学习目标

  1. 说清 JAX 为什么不用全局随机状态:可复现、可并行、可 vmap / jit 这三条要求,怎样推出「显式 key + 计数器式(counter-based)PRNG」的设计;
  2. 掌握类型化 key 的形状语义、key_data / wrap_key_data / jax.dtypes.prng_key,知道哪些数组操作对 key 合法、哪些会报错;
  3. 认识 0.12 的 6 种 PRNG 实现(threefry2x32 / threefry4x32 / philox2x32 / philox4x32 / rbg / unsafe_rbg),会用 impl= 和 jax_default_prng_impl 选择,了解它们在速度、split 质量、vmap 语义上的差别;
  4. 理解 jax_threefry_partitionable:默认值是什么,开关前后数值为什么不同,它怎样让分片数组在本地生成、不用通信,并能在 HLO 里验证;
  5. 熟练使用 7 种 key 管理模式(split 后丢弃旧 key、split(key, n)、fold_in、scan carry、vmap key 数组、多进程派生、dropout / nnx.Rngs),避开 4 类常见陷阱;
  6. 会用均值、方差、KS 检验、相关系数对随机数做简单的统计检验。

目录

节内容
0公共设置(8 个模拟设备、计时 / HLO / 子进程工具)
1设计理念:为什么不用全局状态
2key 的两种表示:类型化 key 与原始 uint32 key
3PRNG 算法:threefry、philox、rbg、unsafe_rbg
4jax_threefry_partitionable:默认值、数值差异、分片生成、跨版本一致性与 10.81
5key 管理模式(7 组 ❌ / ✅ 对照)
6常见陷阱(4 组 ❌ / ✅ 对照)
7统计质量的简单检验
8实战规则清单
—练习(4 道)· 测验(6 道)· 本章小结 · 速查表

0 公共设置

⚪ 选读:8 个模拟设备与计时 / HLO / 子进程工具的定义,用到时回来看

  • 第一行设置 XLA_FLAGS,在 CPU 上模拟 8 个设备(第 4 节的分片演示要用);
  • bench:先预热再计时,取中位数(毫秒),只打印;
  • collectives:数一数编译后的 HLO 里各类集合通信出现了几次;
  • run_py:在全新的子进程里运行一段代码,再把子进程最后一行 JSON: 输出解析回来。有些对比需要从进程启动时就用不同的配置(比如 jax_threefry_partitionable 开和关),这些都放到子进程里做,互不干扰。
# ⚠️ 必须在 import jax 之前设置:在 CPU 上模拟 8 个设备(若已导入过 jax,请重启内核)
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 标准库的 random,起别名以免和 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("设备  ", jax.devices())
assert jax.__version__ == "0.12.0.1"
assert jax.device_count() == 8


def bench(f, *args, warmup=2, runs=10):
    """计时:先预热 warmup 次(含编译),再运行 runs 次取中位数,返回毫秒。只用于打印。"""
    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):
    """统计编译后 HLO 文本中各类集合通信出现的次数(含 *-start 异步形式)。"""
    return {op: len(re.findall(op + r"(?:-start)?\(", hlo_text)) for op in COLLECTIVE_OPS}


def run_py(code, *args, env=None, timeout=600):
    """在全新的 Python 子进程中执行 code,返回它最后一行 'JSON:' 输出解析出的对象。"""
    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):
    """异常的类型 + 第一行信息(演示错误写法时用)。"""
    msg = str(e).strip().splitlines()[0] if str(e).strip() else ""
    return f"{type(e).__name__}: {msg[:170]}"

print("公共工具已定义")
Python 3.14.7 | JAX 0.12.0.1 | Flax 0.12.12
设备   [CpuDevice(id=0), CpuDevice(id=1), CpuDevice(id=2), CpuDevice(id=3), CpuDevice(id=4), CpuDevice(id=5), CpuDevice(id=6), CpuDevice(id=7)]
公共工具已定义

1 设计理念:为什么不用全局状态

🟢 必读

NumPy 的经典接口 np.random.seed(0); np.random.normal() 背后是一个隐藏的全局生成器,每调用一次,它的内部状态就悄悄往前推一步。在单线程脚本里这很方便,但会带来三个问题:

  1. 结果依赖调用顺序:同一个种子、同样的函数,只要调用顺序变了(哪怕只是中间多插了一句打印随机样本的调试代码),后面所有的随机数都会跟着变;
  2. 没法并行:多个线程或设备共用一个状态时,谁先拿到哪一段随机数要看调度,结果没法复现。想让每个设备各自生成,又得先想清楚「状态怎么切」;
  3. 和函数变换冲突:jit 只追踪一次,副作用只在追踪时发生(见 6.4 节)。vmap 把一个函数变成「同时处理 B 个样本」,那全局状态该推进 1 次还是 B 次?怎么定都不合适。

JAX 的办法是:让随机数生成器本身成为纯函数。随机函数的输出只由传入的 key 决定(random_bits = hash(key, 计数器),这种设计叫 counter-based PRNG)。key 就是一个普通的、不可变的数组值。这样一来:

要求显式 key 怎样满足它
可复现同一个 key → 同样的随机数,与调用顺序、调用次数无关
可并行默认配置(jax_threefry_partitionable=True)下,每个元素的随机比特只取决于 (key, 该元素的全局下标),任何设备都可以只算自己那一片(第 4 节)
可 vmap / jitkey 是数组:jit 把它当普通参数,换 key 不会重新编译;vmap 可以直接映射一组 key(5.5 节)

代价是 key 得由你自己管理。第 5、6 节讲的就是这件事。

# NumPy 的全局状态:同一个种子,只是交换了两个函数的调用顺序
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()   # 顺序反过来
print("先 init 再 noise 的权重:", w_a)
print("先 noise 再 init 的权重:", w_b)

assert not np.array_equal(w_a, w_b)       # 权重变了
assert np.array_equal(w_a, n_b)            # 而且“权重”和“噪声”互换了身份
print("✓ NumPy 全局状态:结果依赖调用顺序")
先 init 再 noise 的权重: [1.76405235 0.40015721 0.97873798]
先 noise 再 init 的权重: [ 2.2408932   1.86755799 -0.97727788]
✓ NumPy 全局状态:结果依赖调用顺序
# JAX:随机数只由 key 决定,调用顺序无关
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)      # 顺序反过来
assert jnp.array_equal(w1, w2) and jnp.array_equal(n1, n2)

# key 是普通参数:jit 只追踪 / 编译一次,换 key 不会重新编译
trace_count = 0
def noisy(key, x):
    global trace_count
    trace_count += 1                  # 只在追踪时执行
    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("4 个不同 key 的结果互不相同:", len({tuple(np.asarray(o).tolist()) for o in outs}) == 4,
      "| 追踪次数 =", trace_count)
assert trace_count == 1
assert len({tuple(np.asarray(o).tolist()) for o in outs}) == 4
print("✓ JAX:顺序无关、可复现,key 作为参数不触发重新编译")
4 个不同 key 的结果互不相同: True | 追踪次数 = 1
✓ JAX:顺序无关、可复现,key 作为参数不触发重新编译

NumPy 新式的 np.random.default_rng(seed) 把状态装进一个 Generator 对象,避开了「全局」。但它仍然是可变对象,每次调用都会改动它。放进 jit 后它只在追踪时执行一次(见 6.4 节),一样不适合函数变换。


2 key 的两种表示

第 1 章 1.5 节 已经对比过 jax.random.key(0)(类型化 key,dtype key<fry>,形状 ())和 jax.random.PRNGKey(0)(原始 uint32[2]),也演示了 key + 1 会报错、用 wrap_key_data 互相转换。那张表这里不再重复,只往深里补几点:形状语义、哪些操作合法、怎么序列化、算法信息存在哪里。

2.1 类型化 key 是「一个元素 = 一个 key」的数组

⚪ 选读:想了解 key 数组的形状语义、哪些数组操作对 key 合法时

类型化 key 数组的 shape 只表示「有多少个 key」。每个 key 内部的比特数据(threefry 是 2 个 uint32,rbg 是 4 个)藏在元素类型里,要用 key_data 才看得到,多出来的那一维是尾部维度。

k = jax.random.key(0)
raw = jax.random.PRNGKey(0)

# 判断“是不是类型化 key”的标准写法:jax.dtypes.prng_key 是所有 key dtype 的抽象父类
print("key(0).dtype =", k.dtype, "| 是 prng_key:", jnp.issubdtype(k.dtype, jax.dtypes.prng_key))
print("PRNGKey(0).dtype =", raw.dtype, "| 是 prng_key:", jnp.issubdtype(raw.dtype, jax.dtypes.prng_key))
print("key 记得自己的算法:", 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"

# 形状语义:key.shape 是 key 的个数,key_data 多一个尾部维度
print(f"\n{'表达式':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,)
key(0).dtype = key<fry> | 是 prng_key: True
PRNGKey(0).dtype = uint32 | 是 prng_key: False
key 记得自己的算法: threefry2x32

表达式                           key.shape  key_data.shape
key(0)                               ()            (2,)
split(key, 4)                      (4,)          (4, 2)
split(key, (2, 3))               (2, 3)       (2, 3, 2)
key(0, impl='rbg')                   ()            (4,)
split(rbg_key, (2, 3))           (2, 3)       (2, 3, 4)
key(0, impl='philox2x32')            ()            (1,)

把 key 当数组用的时候,改形状、挑元素的操作都合法,因为它们不碰 key 的内部比特。算术、规约、类型转换都会报错:

ks = jax.random.split(k, 4)
ok_ops = {
    "ks[1:3](切片)":            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(比较)":           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))
✅ ks[1:3](切片)                      → (2,)
✅ ks.reshape(2, 2)                 → (2, 2)
✅ ks.reshape(2, 2).T               → (2, 2)
✅ jnp.stack([k, k])                → (2,)
✅ jnp.concatenate([ks, ks])        → (8,)
✅ jnp.where(mask, ks, ks[::-1])    → (4,)
✅ jnp.broadcast_to(k, (3,))        → (3,)
✅ ks == ks(比较)                     → [True, True, True, True]
# ❌ 错误写法:对类型化 key 做算术 / 规约 / 类型转换(想“手工派生”新 key 或把 key 当数据保存)
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"(没有报错){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)          # 全部报错
assert bad_errors["k + 1"] == "TypeError" and bad_errors["np.asarray(k)"] == "TypeError"
# 对照:原始 uint32 key 做同样的事会“静默成功”,这正是旧写法的隐患
print("\n原始 key:PRNGKey(0) + 1 =", raw + 1, "(静默成功)")
assert jnp.array_equal(raw + 1, jnp.array([1, 1], jnp.uint32))
❌ k + 1                        → TypeError: add does not accept dtypes key<fry>, int32.
❌ k * 2                        → TypeError: multiply does not accept dtypes key<fry>, int32.
❌ k ^ k                        → TypeError: xor does not accept dtype key<fry> at position 0. Accepted dtypes at position 0 are subtypes of integer, bool.
❌ jnp.sum(ks)                  → TypeError: Cannot interpret 'key<fry>' as a data type
❌ jnp.unique(ks)               → TypeError: Cannot interpret 'key<fry>' as a data type
❌ jnp.asarray(k, jnp.uint32)   → ValueError: Cannot convert_element_type from key<fry> to uint32
❌ np.asarray(k)                → TypeError: JAX array with PRNGKey dtype cannot be converted to a NumPy array. Use jax.random.key_data(arr) if you wish to extract the underlying integer array.
原始 key:PRNGKey(0) + 1 = [1 1] (静默成功)
# ✅ 正确写法:派生新 key 用 fold_in / split;取底层比特用 key_data
k_step1 = jax.random.fold_in(k, 1)                  # 代替 “k + 1”
raw_bits = jax.random.key_data(k)                   # 代替 “np.asarray(k)”
print("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,)
fold_in(k, 1) 的底层数据: [ 928981903 3453687069]
key_data(k) = [0 0] uint32

2.2 互转与序列化:key_data / wrap_key_data 要带上 impl

⚪ 选读:要把 key 存进 checkpoint 或跨进程传递时

原始 uint32 数据不记得自己是哪种算法:threefry 的 key 是 2 个字,rbg 是 4 个字,philox2x32 只有 1 个字。所以把 key 存下来(写进 checkpoint、跨进程传递)时,要把算法名和数据一起保存,恢复时用 wrap_key_data(data, impl=...)。

# ❌ 错误写法:只保存了 rbg key 的原始数据,恢复时没有指定 impl(按默认的 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("(没有报错)恢复为", restored.dtype)
    wrap_err = None
except Exception as e:
    wrap_err = e
    print("❌", err_line(e))
assert isinstance(wrap_err, TypeError)
❌ TypeError: JAX encountered invalid PRNG key data: expected key_data.shape to end with (2,); got shape=(4,) for impl=PRNGImpl(key_shape=(2,), seed=<function threefry_seed at 0x115549
# ✅ 正确写法:保存 (impl 名称, key_data),恢复时 wrap_key_data(..., impl=impl)
def save_keys(keys):
    """把一个类型化 key 数组序列化成 bytes:impl 名称 + 原始 uint32 数据。"""
    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} 往返后 dtype={back.dtype}, shape={back.shape}, key 相同={same}, 采样相同={bool(same_samples)}")
    assert same and same_samples
threefry2x32  往返后 dtype=key<fry>, shape=(2, 3), key 相同=True, 采样相同=True
rbg           往返后 dtype=key<rbg>, shape=(2, 3), key 相同=True, 采样相同=True
philox2x32    往返后 dtype=key<phx2>, shape=(2, 3), key 相同=True, 采样相同=True

🔍 补充:两个和 key 有关的「悄悄」行为

⚪ 选读:用大整数(时间戳、哈希值)做种子,或迁移旧 PRNGKey 代码时

(1) 默认 32 位模式下,超过 32 位的种子会被悄悄截断。 没开 jax_enable_x64 时,jax.random.key(seed) 只保留种子的低 32 位,不报错也不警告。拿纳秒时间戳、哈希值当种子时,不同的种子可能得到同一个 key。开启 x64 后,高位会进入 key 数据(下面在子进程里对比)。要用大整数做种子,可以把它拆成两段,用 fold_in 依次混进去。

(2) 用 jax_legacy_prng_key 让「原始 key」直接报错。 它的默认值是 'allow'。设成 'warn' 或 'error' 后,把原始 uint32 数组当 key 传给 jax.random 函数就会警告或报错。迁移旧代码时,用它来揪出残留的 PRNGKey 用法很合适。注意它检查的是「传进随机函数」这一步,PRNGKey(0) 本身不会报错。

# (1) 种子截断
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 位模式:key(2**32 + 5) == key(5)?", same_key, "| 警告数:", 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 模式(子进程):key_data(key(2**32 + 5)) =", x64_res["data"], "| 与 key(5) 相同?", x64_res["same"])
assert x64_res["data"] == [1, 5] and not x64_res["same"]

# 大整数种子的稳妥写法:拆成高低两段依次 fold_in,结果与 x64 设置无关
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("\njax_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("'error' 模式下传入原始 key →", err_line(e))
    ok_typed = jax.random.normal(jax.random.key(0), (2,))      # 类型化 key 不受影响
assert isinstance(legacy_err, ValueError) and ok_typed.shape == (2,)
32 位模式:key(2**32 + 5) == key(5)? True | 警告数: 0
x64 模式(子进程):key_data(key(2**32 + 5)) = [1, 5] | 与 key(5) 相同? False

jax_legacy_prng_key 默认值: allow
'error' 模式下传入原始 key → ValueError: Legacy uint32 key array passed as key to jax.random function. Please create keys using jax.random.key(). If use of a raw key array was intended, set jax_legacy_prng_key="

3 PRNG 算法:threefry、philox、rbg、unsafe_rbg

key 的 dtype 里带着算法信息(key<fry> 里的 fry 就是 threefry)。0.12 里可选的实现有 6 种(见 jax.random 模块文档的 Advanced RNG configuration 一节):

impl=dtypekey 数据生成随机比特派生 key(split / fold_in)说明
threefry2x32(默认)key<fry>2 × u32Threefry 哈希Threefry64 位 key 空间、64 位计数器;各后端逐位一致
threefry4x32key<fry4>4 × u32ThreefryThreefry128 位 key / 计数器
philox2x32 / philox4x32key<phx2> / key<phx4>1 / 2 × u32Philox 哈希Philox同为计数器式 PRNG(Salmon et al. 2011)
rbg(实验性)key<rbg>4 × u32XLA RngBitGeneratorThreefryTPU 上最快;派生 key 仍用 threefry 的质量
unsafe_rbg(实验性)key<urbg>4 × u32XLA RngBitGeneratorRBG 当哈希用派生 key 的统计质量没有经过检验,所以叫 unsafe

有三种选法:

  • 给单个 key 指定:jax.random.key(seed, impl="rbg")(推荐,类型化 key 会一直带着这个信息);
  • 改全局默认:jax.config.update("jax_default_prng_impl", "rbg"),或者设环境变量 JAX_DEFAULT_PRNG_IMPL=rbg;
  • 临时切换:with jax.default_prng_impl("rbg"): ...。

全局配置只影响两类 key:没写 impl= 的新 key,以及原始 uint32 key。原始 key 自己不带算法信息,只能按全局配置来解释。这也是改用类型化 key 的又一个理由。

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())
# 同一个种子,不同算法给出不同的随机数(rbg 与 unsafe_rbg 的“生成”部分相同,只有派生 key 不同)
distinct = {tuple(v.tolist()) for v in samples.values()}
assert len(distinct) == 5 and np.array_equal(samples["rbg"], samples["unsafe_rbg"])
impl          dtype       key_data  normal(key(0), (3,))
threefry2x32  key<fry>        (2,)  [ 1.6226422   2.0252647  -0.43359444]
threefry4x32  key<fry4>       (4,)  [-2.1229682  0.9393411  2.2336702]
philox2x32    key<phx2>       (1,)  [-0.22195154  0.09373897  0.508718  ]
philox4x32    key<phx4>       (2,)  [-0.0657746   0.01706673  0.43954915]
rbg           key<rbg>        (4,)  [-0.25581604  1.177591    0.6301837 ]
unsafe_rbg    key<urbg>       (4,)  [-0.25581604  1.177591    0.6301837 ]
# 全局默认算法:环境变量(子进程,从启动时生效)与上下文管理器(本进程,临时)
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),                      # 原始 key 按全局配置解释
    "explicit": str(jax.random.key(0, impl="threefry2x32").dtype)}))     # 显式 impl 不受影响
""", env={"JAX_DEFAULT_PRNG_IMPL": "rbg"})
print("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("with jax.default_prng_impl('philox4x32') 内:", inside, "| 退出后:", outside)
assert inside == "key<phx4>" and outside == "key<fry>"
JAX_DEFAULT_PRNG_IMPL=rbg 的子进程: {'config': 'rbg', 'key_dtype': 'key<rbg>', 'raw_shape': [4], 'explicit': 'key<fry>'}
with jax.default_prng_impl('philox4x32') 内: key<phx4> | 退出后: key<fry>

3.1 速度对比(本机 CPU,只打印)

⚪ 选读:比较各 PRNG 算法在本机 CPU 上的速度

下面测三个时间:jit 后「生成 400 万个正态随机数」、「split 出 10 万个 key」,以及首次编译。这些数字只代表本机 CPU。 在 CPU 上,rbg 走的是 XLA RngBitGenerator 的 CPU 实现,没什么优势。官方文档说 rbg / unsafe_rbg 在 TPU 上最快,threefry 在 TPU 上编译和执行都相对慢,这一点在 CPU 上验证不了。

print(f"{'impl':13s} {'生成 4M normal':>14s} {'split 10万':>11s} {'首次编译':>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")
impl            生成 4M normal   split 10万      首次编译
threefry2x32        42.09 ms     1.14 ms    201 ms
threefry4x32        12.26 ms     3.83 ms    507 ms
philox2x32           6.31 ms     0.11 ms    109 ms
philox4x32           5.40 ms     0.44 ms    110 ms
rbg                 22.91 ms     2.18 ms    249 ms
unsafe_rbg          24.69 ms     1.87 ms    218 ms

3.2 性质对比:split 质量与 vmap 语义

⚪ 选读:考虑换用 rbg / unsafe_rbg 时必看它们的 vmap 语义差别

派生 key 的实现。 rbg 的 split 把 4 个字拆成两半,各自用 threefry 的 split。unsafe_rbg 则把 RngBitGenerator 迭代 10 次,当作哈希函数来用。下面直接验证一下。

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 = 两个 threefry key 拼起来
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 的 split == 两半各自做 threefry split:", rbg_like_tf)
print("unsafe_rbg 的 split == 同上:", urbg_like_tf, "(它用 RBG 当哈希,质量未经检验)")
assert rbg_like_tf and not urbg_like_tf
rbg 的 split == 两半各自做 threefry split: True
unsafe_rbg 的 split == 同上: False (它用 RBG 当哈希,质量未经检验)

vmap 语义。 对 threefry / philox,vmap(f)(keys) 和逐个调用 f(keys[i]) 逐位相同。rbg / unsafe_rbg 不一样:XLA 的 RBG 不支持高效的批处理,JAX 的变通办法是只用批次里的第一个 key 生成整批随机数,也就是 vmap(normal)(keys) == normal(keys[0], (B,))。

# ❌ 错误写法:用 rbg 时默认“vmap 等于逐个调用”(例如用 vmap 版和循环版对拍)
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("没有报错,但 vmap == 逐个调用?", 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)
没有报错,但 vmap == 逐个调用? False | vmap == normal(keys[0], (8,))? True
# ✅ 正确写法:需要“vmap 与逐个调用一致”时用 threefry / philox(默认的 threefry2x32 即可)
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 == 逐个调用: {bool(jnp.array_equal(v_ok, loop_ok))}")
    assert jnp.array_equal(v_ok, loop_ok)
threefry2x32  vmap == 逐个调用: True
philox4x32    vmap == 逐个调用: True

官方文档的性质汇总(* 表示 jax_threefry_partitionable=True,0.5.0 起为默认;** 表示设置了 XLA 标志 --xla_tpu_spmd_rng_bit_generator_unsafe=1):

性质ThreefryThreefry*Philoxrbgunsafe_rbgrbg**unsafe_rbg**
TPU 上最快✅✅✅✅
可高效分片生成(jit 自动分区)✅✅✅✅
不同分片方式结果相同✅✅✅✅✅
CPU / GPU / TPU 结果相同✅✅✅
vmap 与逐个调用一致✅✅✅

🔍 补充:怎么选

⚪ 选读:需要更换 PRNG 算法时;默认的 threefry2x32 适合绝大多数场景

  • 默认就用 threefry2x32:各后端逐位一致,vmap 语义精确,分片生成高效,可复现性最好。
  • 在 TPU 上大规模训练,而且 profile 显示随机数生成(典型的是 dropout 掩码)确实是瓶颈,这时再考虑 rbg。它生成得快,派生 key 仍有 threefry 的质量。代价是结果会随后端和编译器版本变化,vmap 也只用第一个 key。
  • unsafe_rbg 只在 key 派生也成了瓶颈时才用,前提是你能接受「派生质量没经过检验」。
  • 换算法会改变所有随机数(包括参数初始化),一定要像其他超参数一样记进实验配置里。

4 jax_threefry_partitionable

这是 threefry 实现里的一个开关,它决定计数器怎样分配给输出数组的每个元素。开启后,每个元素的随机比特只取决于 (key, 元素的全局下标)。这样 jit 就能让每个设备只算自己那一片,不需要通信。

4.1 默认值

⚪ 选读:jax_threefry_partitionable 的默认值与三种修改方式

print("jax_threefry_partitionable =", jax.config.jax_threefry_partitionable)
assert jax.config.jax_threefry_partitionable is True
# jax.random 的模块文档写明了从哪个版本起成为默认
assert "As of JAX v.0.5.0, this is the" in jax.random.__doc__
print("jax.random 文档:'As of JAX v.0.5.0, this is the default.' ✓")

# 三种修改方式:jax.config.update / 环境变量 JAX_THREEFRY_PARTITIONABLE / 上下文管理器
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("环境变量 JAX_THREEFRY_PARTITIONABLE=0 →", env_flag, "| 上下文管理器内 →", ctx_flag,
      "| 退出后 →", jax.config.jax_threefry_partitionable)
assert env_flag is False and ctx_flag is False and jax.config.jax_threefry_partitionable is True
jax_threefry_partitionable = True
jax.random 文档:'As of JAX v.0.5.0, this is the default.' ✓
环境变量 JAX_THREEFRY_PARTITIONABLE=0 → False | 上下文管理器内 → False | 退出后 → True

4.2 开和关:同一个 key、同一个形状,结果不同吗?(子进程对比)

⚪ 选读:实现细节:开关前后随机数的差异实测

每种设置各启动两个子进程。进程一启动就用 jax.config.update 设好开关,然后用同一个 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)),      # 第 1 章 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[name]}\n{'':10s} 关: {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]   # 同一设置下可复现
assert on["normal_8"] != off["normal_8"] and on["uniform_3"] != off["uniform_3"]               # 随机比特不同
assert on["split"] != off["split"]                                                              # split 也不同
assert on["fold_in"] == off["fold_in"]                                                          # fold_in 不受影响
# 与第 1 章 1.4 节的实测值一致:开 9.7387…,关 2.6585…
assert np.isclose(on["dot10"], 9.738718032836914) and np.isclose(off["dot10"], 2.6585559844970703)
# 本进程的默认设置与“开”的子进程逐位相同
assert jax.random.normal(jax.random.key(0), (8,)).tolist() == on["normal_8"]
print("✓ 开 / 关 两种设置下:随机比特和 split 都不同,fold_in 相同;各自可复现")
normal_8   开: [1.622642159461975, 2.0252647399902344, -0.4335944354534149, -0.07861734926700592, 0.17609089612960815, -0.9720892310142517, -0.49529874324798584, 0.49437859654426575]
           关: [0.08086783438920975, -0.3862471282482147, -0.3756556510925293, 1.6689739227294922, -1.2758197784423828, 2.1192004680633545, -0.8582122325897217, 1.130593180656433]

uniform_3  开: [0.9476670026779175, 0.9785798788070679, 0.33229148387908936]
           关: [0.96532142162323, 0.31468164920806885, 0.6330299377441406]

split      开: [[1797259609, 2579123966], [928981903, 3453687069]]
           关: [[4146024105, 967050713], [2718843009, 1272950319]]

fold_in    开: [928981903, 3453687069]
           关: [928981903, 3453687069]

dot10      开: 9.738718032836914
           关: 2.6585559844970703

✓ 开 / 关 两种设置下:随机比特和 split 都不同,fold_in 相同;各自可复现

4.3 为什么不同:计数器的分配方式

⚪ 选读:实现内幕:threefry 计数器的两种分配方式

threefry2x32 是一个哈希:输入 2 个 32 位的计数器字,输出 2 个 32 位字。要生成 $n$ 个 32 位随机数,计数器有两种分配办法:

关(原始实现)开(partitionable)
计数器把 iota(n) 劈成前后两半,第 $i$ 个块的输入是 $(i,\ i+n/2)$第 $i$ 个元素用 64 位计数器 $i$,即输入 $(\text{hi}(i),\ \text{lo}(i))$
输出第 $i$ 个块的两个输出字分别给元素 $i$ 和 $i+n/2$两个输出字异或,得到元素 $i$
元素 $i$ 依赖key、$i$、总长度 $n$只有 key 和全局下标 $i$

下面用 jax.extend.random 里的 threefry 原语直接复现这两种分配,再验证它带来的一个后果,叫「前缀性质」:开启时 normal(k, (10,))[:5] == normal(k, (5,)),关闭时不成立。

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))   # 内部把计数器劈成两半
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                                                  # 计数器 = 全局下标 i,两字异或
print("关:bits      =", bits_off)
print("关:手工复现  =", manual_off)
print("开:bits      =", bits_on)
print("开:手工复现  =", 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"开关={flag!s:5}: 前缀性质 1D={prefix_1d}, 2D 行前缀={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
关:bits      = [2285895361 1501764800 1518642379 4090693311  433833334 4221794875
  839183663 3740430601]
关:手工复现  = [2285895361 1501764800 1518642379 4090693311  433833334 4221794875
  839183663 3740430601]
开:bits      = [4070199207 4202968722 1427181096 2012915765 2447653815  710830403
 1332275837 2961296638]
开:手工复现  = [4070199207 4202968722 1427181096 2012915765 2447653815  710830403
 1332275837 2961296638]
开关=True : 前缀性质 1D=True, 2D 行前缀=True, split(k,5)[:3]==split(k,3): True, split(k,n)[i]==fold_in(k,i): True
开关=False: 前缀性质 1D=False, 2D 行前缀=False, split(k,5)[:3]==split(k,3): False, split(k,n)[i]==fold_in(k,i): False

最后一列是个顺带的好处:默认配置下 split(k, n)[i] 和 fold_in(k, i) 逐位相同,子 key 跟 $n$ 无关。所以「先 split(key, 1000) 再取第 37 个」和「直接 fold_in(key, 37)」是一回事,而后者不用生成整个数组。

4.4 对分片生成的影响:8 个模拟设备上实测

⚪ 选读:多设备上生成分片随机数组、关心通信与冗余计算时

在 8 个设备上,用 jit(..., out_shardings=...) 直接生成一个分片的 (8, N) 数组,分别按行(P('x'))和按列(P(None, 'x'))切分。在开、关两种设置下比较三件事:

  1. 结果和不分片时是否逐位相同;
  2. 编译后的 HLO 里有没有集合通信;
  3. 每个设备实际生成了多少个随机值。这个数从 HLO 里 threefry 轮函数的 xor 形状读出来:关闭时每个 threefry 块产出 2 个值,开启时产出 1 个。
mesh = jax.make_mesh((8,), ("x",))
N = 1024
k_sh = jax.random.key(0)

def threefry_values_per_device(hlo_text, flag):
    """从 HLO 中 threefry 轮函数的 xor 形状,估算每个设备生成的随机值个数。"""
    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)          # 不分片的参考结果
        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"{'开关':5s} {'分片':14s} {'与不分片相同':>10s} {'每设备分片':>10s} {'每设备生成':>9s}  集合通信")
for r in rows:
    comm = {kk: v for kk, v in r["comm"].items() if v} or "无"
    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)                                     # 4 种情况结果都与不分片一致
# 开启:两种切法都没有任何集合通信,每个设备只生成自己那 8*N/8 个值
assert all(sum(R[(True, s)]["comm"].values()) == 0 and R[(True, s)]["per_dev"] == N for s in ("P('x',)", "P(None, 'x')"))
# 关闭 + 按行切:需要集合通信(all-gather / collective-permute)把成对的元素换到正确的设备
assert sum(R[(False, "P('x',)")]["comm"].values()) > 0
# 关闭 + 按列切:没有通信,但每个设备都把整个 8*N 数组算了一遍(8 倍冗余计算)
assert sum(R[(False, "P(None, 'x')")]["comm"].values()) == 0 and R[(False, "P(None, 'x')")]["per_dev"] == 8 * N
print("\n✓ 开启:本地生成、零通信;关闭:要么通信,要么每个设备冗余地算整个数组")
开关    分片                 与不分片相同      每设备分片     每设备生成  集合通信
True  P('x',)              True    (1, 1024)      1024  无
True  P(None, 'x')         True     (8, 128)      1024  无
False P('x',)              True    (1, 1024)      1024  {'all-gather': 2, 'collective-permute': 4}
False P(None, 'x')         True     (8, 128)      8192  无

✓ 开启:本地生成、零通信;关闭:要么通信,要么每个设备冗余地算整个数组

结论和这个配置项的帮助文字完全吻合:Without this flag, using the standard jax.random pseudo-random number generation may result in extraneous communication and/or redundant distributed computation.

  • 关闭时:元素 $i$ 和 $i+n/2$ 出自同一个 threefry 块。按行切分时,它们往往落在不同设备上。结果要么得通信(上面看到的 all-gather + collective-permute),要么像按列切分那样,每个设备干脆把整个数组算一遍,再取自己那一片(上面看到每个设备生成了 $8N$ 个值,是实际需要的 8 倍)。
  • 开启时:元素只依赖全局下标,每个设备直接用自己负责的下标生成,零通信、零冗余。
  • 两种设置下,单进程内的结果都和不分片一致。XLA 靠通信或冗余计算保证了正确,代价是性能。真正会得到不一致结果的情况,是多进程里各进程传入了不同的 key,见 5.6 节的 2 进程实验。

进阶第 6 章 6.4 节 正是靠这一点,让 jit + NamedSharding 的「全局视角」dropout 和单设备结果逐位相同。

4.5 「随机数不保证跨版本一致」,以及第 1 章的 10.81

🟢 必读

4.2 节已经看到:jax_threefry_partitionable 的默认值在 JAX 0.5.0 从 False 改成了 True。只因为这一点,同一段代码、同一个种子,在 0.5.0 前后得到的随机数就完全不同。split 也变了,所以凡是由 split 派生出来的东西全都变了。这就是 JAX 只承诺「同一版本、同一配置下可复现」的典型原因。其他原因还有:换了 PRNG 算法、换了采样算法(比如 normal 从均匀分布变换过来的方式)、rbg 依赖编译器实现,等等。

第 1 章 1.4 节 的安装验证脚本算的是 dot(x, x)(x = normal(PRNGKey(0), (10,))),示例输出写的是 10.811179161071777。0.12 默认配置下算出来是 9.7387…,把开关改回 False 是 2.6585…,后者就是 0.5.0 之前的老版本会打印的值。那 10.81 是从哪来的?下面在子进程里做一次穷举:开关 × x64 × 6 种算法 × 种子 0–999。rbg 的批处理语义比较特殊(见 3.2 节),所以这里用 lax.map(顺序执行)而不是 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} {'threefry2x32 种子 0 的 dot(x,x)':>32s}")
for (f, x), res in search.items():
    print(f"{f!s:>10s}, {x!s:<8s} {res['threefry2x32']['seed0']:>32.6f}")
print(f"\n全部 {len(search) * 6 * 1000} 个组合中离 10.811179 最近的: 距离 {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 默认
assert np.isclose(search[(0, 0)]["threefry2x32"]["seed0"], 2.6586, atol=1e-3)     # 0.5.0 之前的默认
assert best[0] > 1e-4          # 没有任何组合能在浮点误差内复现 10.811179(float32 误差约 1e-6)
partitionable, x64       threefry2x32 种子 0 的 dot(x,x)
         1, 0                                9.738718
         1, 1                                6.435121
         0, 0                                2.658556
         0, 1                               15.697673

全部 24000 个组合中离 10.811179 最近的: 距离 3.19e-04(partitionable=1, x64=1, impl=threefry2x32, seed=584)

结论:jax_threefry_partitionable 能解释「同一段代码在不同 JAX 版本上数值不同」。老版本(开关默认 False)打印 2.6585…,0.5.0 起打印 9.7387…。threefry 在 CPU / GPU / TPU 上逐位一致,所以后端也不是原因。但 10.81 本身用它解释不了:穷举了 24000 个组合(开关、x64、6 种算法、1000 个种子),最接近的也差了 $10^{-4}$ 以上,远大于 float32 的舍入误差。所以这个数很可能不是这段代码在标准 JAX 上的真实输出,也许来自别的代码、别的种子或形状,也许只是示意值。对我们来说,要记住的只有一条:测试里不要把随机数的具体数值写死。确实需要逐位复现时,固定 JAX 版本,在代码里显式写出 jax_threefry_partitionable 和 PRNG 算法,或者干脆把生成好的数据存下来。


5 key 管理模式

每种模式都给出 ❌ / ✅ 对照。入门第 6 章 已经讲过「key 是显式状态」这条铁律,这里关注具体场景下该怎么写。

5.1 split 后丢掉旧 key

🟢 必读

key, sub = jax.random.split(key):sub 拿去用,新的 key 覆盖旧的,旧 key 以后再也不用。最常见的错误是忘了把新 key 赋回去。

# ❌ 错误写法:split 后没有更新 key,下一次又从同一个旧 key 拆分 → 两次拿到同一个子 key
key = jax.random.key(0)
try:
    _, k_init = jax.random.split(key)          # 新 key 被丢进了 _
    w_init = jax.random.normal(k_init, (4,))
    _, k_noise = jax.random.split(key)         # 又从旧 key 拆分
    noise = jax.random.normal(k_noise, (4,))
    print("没有报错,但“初始化”和“噪声”完全相同:", bool(jnp.array_equal(w_init, noise)))
except Exception as e:
    print("❌", err_line(e))
assert jnp.array_equal(w_init, noise)
没有报错,但“初始化”和“噪声”完全相同: True
# ✅ 正确写法:每次 split 都把新 key 赋回给 key,旧 key 不再使用
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("初始化与噪声不同:", not bool(jnp.array_equal(w_init, noise)))
assert not jnp.array_equal(w_init, noise) and not bool(k_init == k_noise)
初始化与噪声不同: True

5.2 split(key, n) 一次产生多个

🟢 必读

需要 $n$ 个独立 key(每层一个、每个 worker 一个、每个环境一个)时,用 split(key, n) 一次生成。常见的错误是用「相邻种子」key(seed + i)。单看统计它没问题(7.2 节会测),但不同实验之间会撞车:种子 0 的第 1 个 worker 和种子 1 的第 0 个 worker 用的是同一个 key。

# ❌ 错误写法:用 key(seed + i) 给第 i 个 worker 派生 key → 不同种子的实验之间共享随机数
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("没有报错,但 种子0/worker1 与 种子1/worker0 的 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,)))
没有报错,但 种子0/worker1 与 种子1/worker0 的 key 相同: True
# ✅ 正确写法:从实验的主 key 一次 split 出 n 个
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 个 key,互不相同:", len(set(all_keys)) == 8)
assert len(set(all_keys)) == 8

# 一次 split(key, n) vs Python 循环里 n 次 split(结果都可用,但循环要 n 次派发)
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 先预热,排除首次编译
t_once = bench(lambda kk: jax.random.split(kk, 200), jax.random.key(0))
print(f"200 个 key:Python 循环 split {t_loop:.2f} ms,一次 split(key, 200) {t_once:.3f} ms(预热后中位数,只打印)")
两次实验共 8 个 key,互不相同: True
200 个 key:Python 循环 split 9.93 ms,一次 split(key, 200) 0.085 ms(预热后中位数,只打印)

5.3 fold_in(key, step):给训练步 / 数据下标派生 key

🟢 必读

fold_in(key, i) 把一个整数「混进」key,得到新 key。和「每步 split,再把 key 带进下一步」相比,它有三个好处:

  • 随机访问:第 37 步的 key 就是 fold_in(base, 37),不用从头重放。断点续训时只要保存 base 和步数;
  • 在 jit 里 step 是普通的动态参数:换步数不会重新编译;
  • 给数据下标派生 key 时,每个样本的随机增强只取决于它的下标,跟 batch 怎么组成、顺序如何都没关系。

传给 fold_in 的整数必须能用 uint32 表示(负数会报 OverflowError)。

# ❌ 错误写法:训练循环每一步都用同一个 key 生成 dropout 掩码 → 每步掩码都一样
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("没有报错,但 5 步的掩码全部相同:", 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)
没有报错,但 5 步的掩码全部相同: True
# ✅ 正确写法:在 jit 内用 fold_in(base, step) 派生每一步的 key,step 作为动态参数传入
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("5 步的掩码互不相同:", distinct_masks == 5, "| 追踪次数:", trace_count)
assert distinct_masks == 5 and trace_count == 1
# 随机访问:直接重算第 3 步,不用重放前面的步
assert jnp.array_equal(dropout_mask_at(base, jnp.uint32(3), x_act), masks[3])

# 按数据下标派生:同一个样本无论被分进哪个 batch,增强噪声都相同
k_aug = jax.random.key(7)
def augment(idx, x):                          # x 是单个样本,idx 是它在数据集中的下标
    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("样本 5 在两个不同 batch 中的增强结果相同:", 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 步的掩码互不相同: True | 追踪次数: 1
样本 5 在两个不同 batch 中的增强结果相同: True
fold_in(base, -1) → OverflowError: Python integer -1 out of bounds for uint32

5.4 在 lax.scan 中把 key 放进 carry

🟢 必读

scan 的循环体是一个纯函数 body(carry, x) -> (carry, y)。key 有两种放法。一种是放进 carry,每一步 split 后把新 key 交给下一步。另一种是事先 split(key, T) 出一个 key 数组,当作 xs 一步一步喂进去。入门第 7 章 的 DP-SGD(专题 4)和综合项目的 train_epoch 用的是第一种。

# ❌ 错误写法:key 放进了 carry,但循环体没有更新它 → 每一步的随机数都一样
def body_bad(key, _):
    return key, jax.random.normal(key)             # 原样把旧 key 传给下一步

try:
    _, ys_bad = lax.scan(body_bad, jax.random.key(0), None, length=6)
    print("没有报错,但 6 步的随机数:", ys_bad)
except Exception as e:
    print("❌", err_line(e))
assert bool(jnp.all(ys_bad == ys_bad[0]))
没有报错,但 6 步的随机数: [1.6226422 1.6226422 1.6226422 1.6226422 1.6226422 1.6226422]
# ✅ 正确写法 A:每一步 split,把新 key 作为 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))

# 与等价的 Python 循环逐位相同
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))

# ✅ 正确写法 B:事先 split 出 T 个 key,作为 xs 逐步喂入(carry 里不放 key)
_, ys_b = lax.scan(lambda c, kt: (c, jax.random.normal(kt)), None, jax.random.split(jax.random.key(0), 6))
print("写法 A:", ys_a)
print("写法 B:", ys_b)
assert len(set(np.asarray(ys_a).tolist())) == 6 and len(set(np.asarray(ys_b).tolist())) == 6
写法 A: [-2.4424558  -1.2574776  -1.3877681  -2.3022664  -1.0147804  -0.19817889]
写法 B: [ 1.0040143  -2.4424558   1.2956359  -0.6219436  -1.0208853   0.63737714]

5.5 vmap 时对 key 数组做映射

🟢 必读

每个样本都要有自己的随机数时(逐样本 dropout、每个环境一个随机初始状态),先 split(key, B),再用 vmap 映射这个 key 数组。别把同一个 key 广播给所有样本。

# ❌ 错误写法:vmap 时 key 不映射(in_axes=None)→ 每个样本拿到同一组“随机数”
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("没有报错,但 4 个样本的噪声完全相同:", 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]))
没有报错,但 4 个样本的噪声完全相同: True
# ✅ 正确写法:split 出 B 个 key,与样本一起沿第 0 轴映射
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 结果与逐个调用相同:", 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

# 两层 vmap:split(key, (B, T)) 直接得到二维 key 数组
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])
vmap 结果与逐个调用相同: True

5.6 多进程:为每个进程派生 key

⚪ 选读:多主机 / 多进程训练时

多主机训练时,每个进程跑的是同一份脚本。随机数分两类,处理方式正好相反:

用途key 怎么来原因
全局数组(jit 生成的分片参数初始化、全局 batch 上的 dropout)所有进程用同一个 keyjit 把非全局数组的输入视为「各进程相同」;partitionable 保证每个进程只算自己的分片
进程本地的随机性(本进程的数据打乱、主机上的数据增强)fold_in(key(seed), jax.process_index())各进程需要不同的随机流,且要与种子绑定、可复现

不要直接用 jax.random.key(jax.process_index())。它把进程编号当成了种子,和 5.2 节一样,会在不同实验之间撞车。

# ❌ 错误写法:直接用进程编号当种子(这里用参数模拟 process_index)
def process_key_bad(seed, process_index):
    return jax.random.key(process_index)          # 种子被忽略了!
try:
    collide = bool(process_key_bad(0, 1) == process_key_bad(123, 1))
    print("没有报错,但 种子0 和 种子123 两次实验里 进程1 的 key 相同:", collide)
except Exception as e:
    print("❌", err_line(e))
assert collide
没有报错,但 种子0 和 种子123 两次实验里 进程1 的 key 相同: True
# ✅ 正确写法:实验种子决定主 key,再 fold_in 进程编号
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 次实验 × 4 个进程 = 8 个 key,互不相同:",
      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
2 次实验 × 4 个进程 = 8 个 key,互不相同: True

🔍 补充:在本机真正跑 2 个进程,看「全局数组用错 key」的后果

⚪ 选读:多进程训练时,想亲眼看到用错 key 的后果

照 进阶第 3 章 3.8 节 的做法,用 subprocess 启动 2 个进程(每个模拟 4 个 CPU 设备,一共 8 个全局设备),在 jit 里生成一个按行分片的全局数组 normal(key, (8, 4)):

  • ✅ 两个进程传入同一个 key:每个进程只算自己的 4 行,拼起来和单进程的 normal(key(42), (8, 4)) 逐位相同;
  • ❌ 每个进程传入 fold_in(key, process_index):不报错,但全局数组成了「前 4 行来自 key A、后 4 行来自 key B」的拼接,不等于任何一个 key 生成的数组。「分片随机数不一致」真正长的就是这个样子;
  • 进程本地的 permutation 用 fold_in(key, process_index),两个进程得到不同的打乱顺序,这正是我们想要的。
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 个设备
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):      # 本进程能访问的分片:{起始行号: 数据}
    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)),                                          # ✅ 同一个 key
       "per_process": local_rows(gen(jax.random.fold_in(base, jax.process_index()))),   # ❌ 各进程不同的 key
       "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"进程 {pid}/{r['nproc']}:全局设备 {r['ndev']} 个,本进程持有的行 {sorted(int(i) for i in r['shared'])},本地打乱 {r['local_perm']}")
print("✅ 同一个 key:拼出的全局数组 == 单进程 normal(key(42), (8,4))?", np.array_equal(g_shared, ref42))
print("❌ 各进程不同 key:前 4 行 == key A 的前 4 行?", np.array_equal(g_bad[:4], ref_p0[:4]),
      "| 后 4 行 == key B 的后 4 行?", np.array_equal(g_bad[4:], ref_p1[4:]),
      "| 等于任何一个完整数组?", 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"]
进程 0/2:全局设备 8 个,本进程持有的行 [0, 1, 2, 3],本地打乱 [1, 3, 5, 0, 2, 4]
进程 1/2:全局设备 8 个,本进程持有的行 [4, 5, 6, 7],本地打乱 [2, 0, 4, 5, 3, 1]
✅ 同一个 key:拼出的全局数组 == 单进程 normal(key(42), (8,4))? True
❌ 各进程不同 key:前 4 行 == key A 的前 4 行? True | 后 4 行 == key B 的后 4 行? True | 等于任何一个完整数组? False

5.7 dropout 的 key 管理,以及 Flax nnx.Rngs 的设计

⚪ 选读:手写 dropout 或使用 Flax NNX 时;要点是每层用不同的 key

用纯 JAX 写 dropout 时,key 通常这样来:每一步一个 step key,每一层再从它派生。最容易犯的错是多个 dropout 层共用一个 key。这样形状相同的层会得到一模一样的掩码,两层丢弃的位置完全重合。

def dropout(key, x, rate, train=True):
    """纯函数 dropout:训练时以概率 rate 置零,并按 1/(1-rate) 放大,保持期望不变。"""
    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):
    """前向传播,返回每个隐藏层的 dropout 掩码(是否保留),用于检查。权重和数据都作为参数传入。"""
    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)         # 对全 1 做 dropout,直接读出掩码
        masks.append(h_d > 0)
        h = h * h_d
    return masks
# ❌ 错误写法:两个隐藏层用同一个 key → 形状相同的层掩码完全相同
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("没有报错,但第 1、2 层的 dropout 掩码完全相同:", bool(jnp.array_equal(m1, m2)))
except Exception as e:
    print("❌", err_line(e))
assert jnp.array_equal(m1, m2)
没有报错,但第 1、2 层的 dropout 掩码完全相同: True
# ✅ 正确写法:每层从 step key 派生自己的 key(fold_in 层号,或 split 出 n_layers 个)
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("第 1、2 层掩码不同:", not bool(jnp.array_equal(m1, m2)),
      f"| 保留比例 {float(m1.mean()):.3f}, {float(m2.mean()):.3f}(期望 0.5)")
assert not jnp.array_equal(m1, m2)

# dropout 保持期望:对大数组取均值约等于原值
big = jnp.full((200_000,), 3.0)
mean_after = float(dropout(jax.random.key(3), big, 0.3).mean())
print(f"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)   # 评估模式不变
第 1、2 层掩码不同: True | 保留比例 0.504, 0.484(期望 0.5)
dropout(rate=0.3) 前后均值: 3.0 → 2.9976

Flax NNX 的做法。 nnx.Rngs(params=0, dropout=1) 给每个名字建一条「随机流」RngStream,里面只存两样东西:一个固定不变的 key,和一个计数器 count。每调用一次 rngs.dropout(),它就返回 fold_in(key, count),再把 count 加 1。说白了,它就是把 5.3 节的 fold_in(base, step) 模式包装成了一个「看起来有状态」的对象:

  • 唯一的可变状态是一个整数计数器。它很便宜,可以存进 checkpoint,续训时随机流接着往下走;
  • nnx.jit 会把计数器当作模块状态传进去,再把更新后的值写回来,所以 jit 之后每次调用仍然能拿到新掩码;
  • 评估模式(model.eval())下,Dropout 不消耗随机数,计数器不变。

(进阶第 6 章 已经用 nnx.Rngs 搭过训练循环,这里只看它内部的 key 管理。)

rngs = nnx.Rngs(params=0, dropout=1)
stream = rngs.dropout
c0 = int(stream.count[...])
k_a = rngs.dropout()
k_b = rngs.dropout()
print("dropout 流的 key:", jax.random.key_data(stream.key[...]), "| 计数器:", c0, "→", int(stream.count[...]))
assert bool(stream.key[...] == jax.random.key(1))                    # 流的 key 就是 key(1),从不改变
assert bool(k_a == jax.random.fold_in(jax.random.key(1), 0))         # 第 1 次调用 = fold_in(key, 0)
assert bool(k_b == jax.random.fold_in(jax.random.key(1), 1))         # 第 2 次调用 = fold_in(key, 1)
assert int(stream.count[...]) == c0 + 2
print("✓ rngs.dropout() == fold_in(key(1), count),每次调用 count += 1")
dropout 流的 key: [0 1] | 计数器: 0 → 2
✓ rngs.dropout() == fold_in(key(1), count),每次调用 count += 1
# ❌ 错误写法:创建 nnx.Dropout 时没给 rngs,调用时也没传 → 训练模式下无处取 key
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)
❌ ValueError: `deterministic` is False, but no `rngs` argument was provided to Dropout
# ✅ 正确写法:把 rngs 交给模块(或在调用时传 rngs=...),由随机流管理 key
class DropMLP(nnx.Module):
    def __init__(self, rngs: nnx.Rngs):
        self.lin = nnx.Linear(4, 6, rngs=rngs)            # 用 params 流初始化
        self.drop = nnx.Dropout(0.5, rngs=rngs)           # 从 dropout 流派生出自己的流
    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

# 掩码的来源:bernoulli(fold_in(流的 key, 当前计数), 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))                     # 模块(含计数器)作为参数传入、更新后写回
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 次调用(2 次 eager + 2 次 nnx.jit)结果互不相同:", len(set(outs_nnx)) == 4,
      "| 计数器:", 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("✓ 评估模式下输出确定、不消耗随机数")
训练模式 4 次调用(2 次 eager + 2 次 nnx.jit)结果互不相同: True | 计数器: 0 → 4
✓ 评估模式下输出确定、不消耗随机数

6 常见陷阱

6.1 key 复用导致结果相关

🟢 必读

第 1 章已经演示过:同一个 key 生成两次 normal,结果完全相同。更隐蔽的是把同一个 key 喂给不同的分布。uniform、normal、bernoulli、exponential 都是对同一串随机比特做不同的单调变换,所以它们之间是确定性的关系。看起来不一样,不代表它们独立。

# ❌ 错误写法:同一个 key 分别生成“均匀噪声”“高斯噪声”“dropout 掩码”,以为它们互相独立
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"没有报错,但 corr(uniform, normal): Pearson={pearson:.4f}, Spearman={spearman:.6f}")
    print("bernoulli(k, 0.3) == (uniform(k) < 0.3) 逐元素成立:", bool(np.array_equal(bern, u < 0.3)))
except Exception as e:
    print("❌", err_line(e))
assert pearson > 0.97 and spearman > 0.99999      # 秩完全一致:normal 是 uniform 的单调函数
assert np.array_equal(bern, u < 0.3)
没有报错,但 corr(uniform, normal): Pearson=0.9772, Spearman=1.000000
bernoulli(k, 0.3) == (uniform(k) < 0.3) 逐元素成立: True
# ✅ 正确写法:每种用途一个子 key
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}(阈值 ±{5 / np.sqrt(n_s):.4f})")
assert abs(r_uz) < 5 / np.sqrt(n_s) and abs(r_ub) < 5 / np.sqrt(n_s)
corr(uniform, normal) = +0.0004,corr(uniform, bernoulli) = -0.0014(阈值 ±0.0158)

6.2 在 jit 里闭包捕获 key:随机数被固化成常量

🟢 必读

被 jit 函数闭包引用的 key 不是参数,而是追踪时的常量,会被直接编进程序。之后每次调用用的都是这一个 key,结果永远一样。在 jaxpr 的常量(consts)里能看到它。

# ❌ 错误写法:key 被闭包捕获
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("没有报错,但两次调用结果相同:", bool(jnp.array_equal(r1, r2)))
    print("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)
没有报错,但两次调用结果相同: True
jaxpr 的常量: [key<fry>]
# ✅ 正确写法:key 作为参数传入;调用方负责每次 split
@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("两次调用结果不同:", 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
两次调用结果不同: True

6.3 split(key) 返回的是 key 数组,不能直接当 key 用

⚪ 选读:会直接报 ValueError 的错误,遇到时查

jax.random.split(key) 返回形状为 (2,) 的 key 数组(split(key, n) 是 (n,))。随机函数只接受单个 key(形状 ()),传进 key 数组会报 ValueError。原始 key 也一样,split(PRNGKey(0)) 是 uint32[2, 2]。

# ❌ 错误写法:把 split 的结果整个赋回给 key,然后直接拿去采样
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("(没有报错)", name)
    except Exception as e:
        split_errors.append(type(e).__name__)
        print(f"❌ {name} → {err_line(e)}")
assert split_errors == ["ValueError", "ValueError"]
❌ typed: key = split(key) → ValueError: normal accepts a single key, but was given a key array of shape (2,) != (). Use jax.vmap for batching.
❌ raw:   key = split(PRNGKey(0)) → ValueError: normal accepts a single key, but was given a key array of shape (2, 2) != (). Use jax.vmap for batching.
# ✅ 正确写法:解包;或者对 key 数组用索引 / vmap
key = jax.random.key(0)
key, sub = jax.random.split(key)                     # 解包成两个单独的 key
a1 = jax.random.normal(sub, (3,))
ks4 = jax.random.split(key, 4)
a2 = jax.random.normal(ks4[2], (3,))                 # 取出一个
a3 = jax.vmap(lambda kk: jax.random.normal(kk, (3,)))(ks4)   # 或整组映射
print("sub.shape =", sub.shape, "| ks4[2].shape =", ks4[2].shape, "| vmap 输出", a3.shape)
assert sub.shape == () and a3.shape == (4, 3) and jnp.array_equal(a3[2], a2)
sub.shape = () | ks4[2].shape = () | vmap 输出 (4, 3)

6.4 Python random / NumPy 随机数在 jit 里只在追踪时执行一次

🟢 必读

jit 追踪函数时,Python 层面的代码只执行一次。random.random() 和 np.random.normal() 返回的是普通的 Python / NumPy 值,于是被当作常量编进了程序。之后每次命中缓存的调用,用的都是这个常量。

# ❌ 错误写法:在 jit 里用 Python random / NumPy 随机数
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("没有报错,但 3 次调用结果:", [np.round(np.asarray(o), 4).tolist() for o in outs_bad], "| 追踪次数:", 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
没有报错,但 3 次调用结果: [[1.0566999912261963, -0.04479999840259552, 0.0034000000450760126], [1.0566999912261963, -0.04479999840259552, 0.0034000000450760126], [1.0566999912261963, -0.04479999840259552, 0.0034000000450760126]] | 追踪次数: 1
# ✅ 正确写法:用 jax.random,key 作为参数
@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("3 次调用结果互不相同:", len({np.asarray(o).tobytes() for o in outs_ok}) == 3)
assert len({np.asarray(o).tobytes() for o in outs_ok}) == 3
3 次调用结果互不相同: True

7 统计质量的简单检验

这些检验证明不了 PRNG 是「好的」(那得用 BigCrush 之类的大型测试集),但能很快发现用法上的错误:分布写错、key 复用、子 key 之间相关。随机数由固定的 key 决定,所以下面的 assert 每次运行结果都一样。

7.1 均值、方差与 KS 检验

⚪ 选读:需要对随机数做统计检验、排查分布写错时

对 $n$ 个样本,均值的标准误是 $\sigma/\sqrt n$,方差估计的标准误大约是 $\sigma^2\sqrt{2/n}$(正态情形)。容差取 5 倍标准误。KS 检验比较经验分布和理论分布之间的最大差距,p 值很小,说明样本「不像」这个分布。最后用一个反例确认检验确实能把问题查出来。

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"{'分布':12s} {'均值':>9s} {'理论':>6s} {'方差':>9s} {'理论':>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)   # 指数分布方差估计更散,放宽
    assert p > 1e-3

# randint 用卡方检验:10 个取值的频数应接近均匀
counts = np.bincount(ri, minlength=10)
chi2_p = stats.chisquare(counts).pvalue
print(f"\nrandint(0, 10) 频数 {counts.tolist()},卡方检验 p = {chi2_p:.4f}")
assert chi2_p > 1e-3 and ri.min() == 0 and ri.max() == 9

# 反例:把 uniform 样本当成正态去检验,KS 应该坚决拒绝 → 检验有检出能力
p_wrong = stats.kstest(u, "norm").pvalue
print(f"反例:uniform 样本 vs 正态分布,KS p = {p_wrong:.2e}")
assert p_wrong < 1e-10
分布                  均值     理论        方差      理论   KS p 值
normal        -0.00126  0.000   0.99741  1.0000   0.7238
uniform        0.50018  0.500   0.08328  0.0833   0.8470
exponential    1.00400  1.000   1.01395  1.0000   0.6525

randint(0, 10) 频数 [20020, 19832, 19857, 20203, 19848, 20050, 20000, 20050, 20051, 20089],卡方检验 p = 0.6946
反例:uniform 样本 vs 正态分布,KS p = 0.00e+00

7.2 子 key 之间的独立性

⚪ 选读:想验证子 key 之间是否相关时

用 64 个子 key 各生成 $n$ 个正态样本,算出 $64\times64$ 相关矩阵的非对角元素。如果彼此独立,每个相关系数大约服从 $N(0, 1/n)$,2016 对里最大的那个一般在 $4/\sqrt n$ 左右,这里用 $5/\sqrt n$ 当阈值。依次检验 split、fold_in、相邻种子,以及 unsafe_rbg 的 split,最后拿「复用 key」当反例。另外,用 10 万个子 key 各取第一个样本拼起来,也应该服从 $N(0,1)$(用 KS 检验)。

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) 相邻种子":            jnp.stack([jax.random.key(i) for i in range(K)]),
    "unsafe_rbg: split(key, 64)":     None,       # rbg 系列在 vmap 下只用第一个 key,单独用循环生成
    "❌ 反例:同一个 key 复用 64 次": 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} 最大 |相关系数| = {results_corr[name]:.4f}  (阈值 {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"\n10 万个子 key 各取第一个样本:均值 {first_samples.mean():+.4f},方差 {first_samples.var():.4f},KS p = {p_first:.4f}")
assert p_first > 1e-3 and abs(first_samples.mean()) < 5 / np.sqrt(100_000)
split(key, 64)                   最大 |相关系数| = 0.0251  (阈值 0.0354)
fold_in(key, 0..63)              最大 |相关系数| = 0.0251  (阈值 0.0354)
key(0..63) 相邻种子                  最大 |相关系数| = 0.0243  (阈值 0.0354)
unsafe_rbg: split(key, 64)       最大 |相关系数| = 0.0264  (阈值 0.0354)
❌ 反例:同一个 key 复用 64 次             最大 |相关系数| = 1.0000  (阈值 0.0354)

10 万个子 key 各取第一个样本:均值 -0.0030,方差 1.0097,KS p = 0.5160

相邻种子 key(0..63) 在这个检验里也没问题。threefry 是密码学风格的哈希,相邻输入的输出看不出相关性。所以 5.2 节反对 key(seed + i),理由不是统计质量,而是语义上的撞车(不同实验共用了随机流)。unsafe_rbg 过了这个粗略的检验,也不代表它的派生质量有保证,官方明确说过它没有经过系统的随机性测试。


8 实战规则清单

🟢 必读

#规则依据
1新代码只用 jax.random.key(seed);迁移旧代码时可设 jax_legacy_prng_key='error' 揪出原始 key2.2
2key, sub = split(key):用 sub,新 key 往下传,旧 key 不再出现5.1
3一次要多个 key 用 split(key, n);不要用 key(seed + i)5.2
4训练步、数据下标、层号用 fold_in 派生;步数作为动态参数传进 jit5.3、5.7
5scan 里要么把 key 放进 carry 并每步 split,要么预先 split(key, T) 作为 xs5.4
6逐样本随机性:split(key, B) 后 vmap 映射 key 数组;不要广播同一个 key5.5
7生成全局分片数组时所有进程用同一个 key;进程本地随机性用 fold_in(key(seed), process_index())5.6
8一种用途一个 key:噪声、掩码、初始化不要共用 key,哪怕分布不同6.1
9key 永远作为参数传入 jit,不要闭包捕获;jit 里不用 Python random / NumPy 随机数6.2、6.4
10保存 key 时存 (impl, key_data),恢复用 wrap_key_data(..., impl=);大整数种子拆开再 fold_in2.2
11默认用 threefry2x32 并保持 jax_threefry_partitionable=True;只有 TPU 上 profile 证明 RNG 是瓶颈时才考虑 rbg3、4.4
12测试里不要写死随机数的具体数值;需要逐位复现时固定版本、显式写出 PRNG 相关配置4.5

练习

⚪ 选读:练习:巩固 key 升级、scan 与 vmap、dropout key、分片生成

练习 1:升级旧 checkpoint 里的原始 key

旧代码把 4 个 key 存成了原始 uint32 数组:legacy = jax.random.split(jax.random.PRNGKey(7), 4)(形状 (4, 2));另一个模块用的是 rbg,存成了 legacy_rbg = jax.random.key_data(jax.random.split(jax.random.key(7, impl="rbg"), 4))(形状 (4, 4))。

写函数 upgrade_keys(raw, impl),返回类型化 key 数组,并验证:

  1. 两种情况下返回的形状都是 (4,),dtype 分别是 key<fry>、key<rbg>;
  2. 对 threefry:用升级后的 keys[i] 采样,结果与旧代码直接用 legacy[i] 采样逐位相同;
  3. 对 rbg:用错 impl(不传)会报 TypeError。

自检接口:upgrade_keys(raw, impl="threefry2x32"),impl 不传时按 threefry 解释。

# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
    upgrade_keys
except NameError as e:
    print(f"⏳ 练习 1 尚未作答:{e}")
else:
    _raw = jax.random.split(jax.random.PRNGKey(99), 3)          # 新的旧式 key 数组,形状 (3, 2)
    _k = upgrade_keys(_raw)
    assert _k.shape == (3,) and str(_k.dtype) == "key<fry>", f"threefry:期望形状 (3,)、dtype key<fry>,实际 {_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"升级后的第 {_i} 个 key 采样结果与旧 key 不同"
    _k1 = upgrade_keys(jax.random.PRNGKey(5))                    # 单个 key
    assert _k1.shape == () and str(_k1.dtype) == "key<fry>", f"单个原始 key 应升级为标量 key,实际 {_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:期望形状 (2,)、dtype key<rbg>,实际 {_kr.shape} {_kr.dtype}"
    assert bool(jnp.all(_kr == _orig)), "rbg key 升级后与原 key 不相等"
    try:
        upgrade_keys(jax.random.key_data(_orig))
    except TypeError:
        pass
    else:
        raise AssertionError("rbg 数据不传 impl 时应当报 TypeError")
    print("✅ 练习 1 自检通过")
⏳ 练习 1 尚未作答:name 'upgrade_keys' is not defined

练习 2:scan + vmap 模拟随机游走

写 walk(key, T):从 0 出发,每步加一个标准正态增量,用 lax.scan 实现,key 放在 carry 里,返回长度 T 的位置序列。然后:

  1. 验证 walk(key(0), 50) 与等价的 Python 循环(同样的 split 顺序)逐位相同;
  2. 用 vmap 对 split(key(1), 4000) 并行模拟 4000 个游走者,验证终点位置的方差接近 T(容差 10%),且游走者两两不同;
  3. 整个 vmap(walk) 放在 jit 里,只编译一次。

自检接口:walk(key, T) 返回形状 (T,) 的位置序列;many_walks(keys, T=...):jit 包装、内部对 walk 做 vmap,返回形状 (len(keys), T)。

# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
    walk, many_walks
except NameError as e:
    print(f"⏳ 练习 2 尚未作答:{e}")
else:
    _T = 20
    _path = walk(jax.random.key(5), _T)
    _kk, _pos, _ref = jax.random.key(5), 0.0, []
    for _ in range(_T):                                          # 题目给出的等价 Python 循环
        _kk, _sub = jax.random.split(_kk)
        _pos = _pos + jax.random.normal(_sub)
        _ref.append(_pos)
    assert _path.shape == (_T,), f"walk 应返回形状 ({_T},),实际 {_path.shape}"
    assert jnp.allclose(_path, jnp.stack(_ref), atol=1e-5), "walk 与等价 Python 循环不一致(检查 key 的 split 顺序)"
    assert hasattr(many_walks, "lower"), "many_walks 应当是 jax.jit 包装过的函数"
    _paths = many_walks(jax.random.split(jax.random.key(3), 2000), T=30)
    assert _paths.shape == (2000, 30), f"many_walks 输出形状应为 (2000, 30),实际 {_paths.shape}"
    assert jnp.allclose(_paths[7], walk(jax.random.split(jax.random.key(3), 2000)[7], 30), atol=1e-5), "many_walks 的第 7 条路径与单独调用 walk 不一致"
    _v = float(_paths[:, -1].var())
    assert abs(_v - 30) / 30 < 0.12, f"终点方差应接近 T = 30,实际 {_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, "换一组 key 再调用 many_walks 触发了重新编译"
    print("✅ 练习 2 自检通过")
⏳ 练习 2 尚未作答:name 'walk' is not defined

练习 3:每步、每层的 dropout key

写 layer_key(base, step, layer),用两次 fold_in 派生「第 step 步、第 layer 层」的 key。验证:

  1. 10 步 × 3 层 = 30 个 key 互不相同;
  2. 可复现:重新计算第 7 步第 2 层的 key 得到同一个值;
  3. layer_key(base, 1, 2) 与 layer_key(base, 2, 1) 不同(顺序有意义);
  4. 用这些 key 生成 rate=0.2 的 dropout 掩码(形状 (256, 256)),总体保留比例在 0.8 ± 0.005 之内。

自检接口:layer_key(base, step, layer),step、layer 可以是 Python 整数,也可以是 jit 里的动态整数。

# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
    layer_key
except NameError as e:
    print(f"⏳ 练习 3 尚未作答:{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 步 × 4 层应得到 24 个不同的 key,实际只有 {len(_uniq)} 个"
    assert bool(layer_key(_base, 5, 3) == _ks[5 * 4 + 3]), "同样的 (step, layer) 重新计算得到了不同的 key"
    assert not bool(layer_key(_base, 2, 3) == layer_key(_base, 3, 2)), "(2, 3) 与 (3, 2) 不应得到同一个 key"
    assert not bool(layer_key(jax.random.key(1), 1, 1) == layer_key(_base, 1, 1)), "不同的 base 应得到不同的 key"
    _kj = jax.jit(layer_key)(_base, jnp.int32(4), jnp.int32(2))  # 步数作为动态参数
    assert bool(_kj == _ks[4 * 4 + 2]), "在 jit 里用动态 step / layer 得到的 key 与 Python 整数版本不同"
    _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"rate = 0.2 的掩码总体保留比例应在 0.8 ± 0.005,实际 {_keep:.4f}"
    print("✅ 练习 3 自检通过")
⏳ 练习 3 尚未作答:name 'layer_key' is not defined

练习 4:分片生成的检查工具

写 check_sharded_rng(spec, shape=(16, 512), partitionable=True):在 8 个设备的 mesh 上,用 jit(..., out_shardings=NamedSharding(mesh, spec)) 生成 jax.random.uniform(key(0), shape),返回 (是否与不分片结果相同, HLO 中集合通信总数, 生成的数组)(第三项用于验证第 3 条)。验证:

  1. 默认设置下,P('x', None) 和 P(None, 'x') 两种切法都与不分片结果相同,且集合通信数为 0;
  2. 关闭 partitionable 时,按行切分仍然结果相同,但集合通信数大于 0;
  3. 关闭 partitionable 时,同一个 key 生成的数组与默认设置下的不同。

自检接口:check_sharded_rng(spec, shape=(16, 512), partitionable=True),返回值的前两项是 (是否与不分片结果相同: bool, 集合通信总数: int);自检只检查这两项。

# ✍️ 作答区:练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 4
try:
    check_sharded_rng
except NameError as e:
    print(f"⏳ 练习 4 尚未作答:{e}")
else:
    _shape = (16, 256)                                           # 换一个形状
    for _spec in (P("x", None), P(None, "x")):
        _r = check_sharded_rng(_spec, shape=_shape)
        assert bool(_r[0]) is True, f"默认设置下 {_spec} 应与不分片结果相同"
        assert int(_r[1]) == 0, f"默认设置下 {_spec} 不应有集合通信,实际 {_r[1]}"
    _r = check_sharded_rng(P("x", None), shape=_shape, partitionable=False)
    assert bool(_r[0]) is True, "关闭 partitionable 后按行切分仍应与不分片结果相同"
    assert int(_r[1]) > 0, "关闭 partitionable 后按行切分应当出现集合通信"
    print("✅ 练习 4 自检通过")
⏳ 练习 4 尚未作答:name 'check_sharded_rng' is not defined

测验

⚪ 选读:自测用

1. ks = jax.random.split(jax.random.key(0)) 之后,jax.random.normal(ks, (3,)) 会怎样?jax.random.key_data(ks).shape 是多少?

答案与解析

报 ValueError(normal accepts a single key, but was given a key array of shape (2,))。split 返回的是 2 个 key 组成的 key 数组,要解包 key, sub = split(key) 或索引 ks[1]。key_data(ks).shape == (2, 2):key 数组形状 (2,) 加上 threefry 的 2 个字尾部维度(2.1、6.3 节)。

2. JAX 0.12 中 jax_threefry_partitionable 的默认值是什么?把它关掉后,normal(key(0), (8,))、split(key(0))、fold_in(key(0), 1) 三者哪些会变?

答案与解析

默认是 True(从 0.5.0 开始)。关掉后,normal(随机比特的计数器分配方式变了)和 split 会变,fold_in 不变(见 4.2 节的子进程实验)。所以 0.5.0 前后同一段代码的随机数不同,第 1 章 dot(x, x) 的 2.6585… 和 9.7387… 就是这么来的。

3. 下面的代码调用 3 次,输出有什么特点?

key = jax.random.key(0)
f = jax.jit(lambda x: x + jax.random.normal(key, x.shape))
答案与解析

3 次输出完全相同。key 被闭包捕获,追踪时变成常量编进了程序(在 jaxpr 的 consts 里能看到)。应该把 key 作为参数传入,由调用方每次 split(6.2 节)。

4. 用 impl="rbg" 创建 8 个 key keys,jax.vmap(lambda k: jax.random.normal(k))(keys) 等于什么?

答案与解析

等于 jax.random.normal(keys[0], (8,)),不等于逐个调用 normal(keys[i]) 再拼起来。XLA 的 RBG 不好做批处理,JAX 的变通办法是在 vmap 下只用批次里的第一个 key 生成整批随机数。threefry / philox 没有这个问题(3.2 节)。

5. 2 个进程做数据并行,jit 里对全局分片的激活做 dropout。每个进程应该传入同一个 key,还是 fold_in(key, process_index())?传错会怎样?

答案与解析

传入同一个 key。jit 默认这类输入在各进程里都相同,partitionable 保证每个进程只算自己那一片,拼起来和单进程结果一致。如果各进程传入不同的 key,不会报错,但全局数组成了「每个进程的分片来自不同 key」的拼接,不等于任何一个 key 生成的结果(见 5.6 节的 2 进程实验)。fold_in(key, process_index()) 只用于进程本地的随机性,比如本进程的数据打乱。

6. 默认配置(未开 x64)下,jax.random.key(2**32 + 5) == jax.random.key(5) 的结果是什么?会有警告吗?

答案与解析

True,而且没有警告,因为 32 位模式下种子只保留低 32 位。开启 jax_enable_x64 后两者就不同了(key 数据是 [1, 5])。大整数种子应该拆成高低两段,用 fold_in 依次混进去(见 2.2 节的补充)。

# 验证测验:用代码检查 6 道题的答案
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)          # 第 1 题
assert jax.config.jax_threefry_partitionable is True                                             # 第 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)))                                   # 第 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,)))   # 第 4 题
assert np.array_equal(g_shared, ref42) and not np.array_equal(g_bad, ref42)                      # 第 5 题
assert bool(jax.random.key(2**32 + 5) == jax.random.key(5))                                      # 第 6 题
print("✓ 6 道测验题的答案全部与实测一致")
✓ 6 道测验题的答案全部与实测一致

本章小结

🟢 必读

  • 设计:JAX 的 PRNG 是计数器式的纯函数,随机比特 = hash(key, 计数器)。显式 key 换来了可复现、可并行(每个元素只依赖全局下标)、可 jit / vmap。代价是 key 得自己管。
  • 表示:类型化 key(key<fry> 等)的 shape 是 key 的个数,内部比特在尾部维度。用 key_data 取出,用 wrap_key_data(..., impl=) 包回去。用 jnp.issubdtype(x.dtype, jax.dtypes.prng_key) 判断是不是 key。改形状、索引这类操作合法,算术、规约、类型转换会报错。32 位模式下,大种子会被悄悄截断。
  • 算法:0.12 有 6 种实现。默认的 threefry2x32 各后端一致、vmap 精确、能高效分片。rbg 在 TPU 上快,split 仍用 threefry。unsafe_rbg 连派生 key 都用 RBG。rbg 系列在 vmap 下只用第一个 key。在本机 CPU 上测,threefry 生成最慢,philox 最快(只代表 CPU)。
  • partitionable:0.12 默认 True(从 0.5.0 开始)。这个开关会改变随机比特和 split,不改变 fold_in。开启时元素只依赖全局下标,8 个设备分片生成不需要任何通信。关闭时要么得 all-gather / collective-permute,要么每个设备冗余地算整个数组。默认值的切换是「随机数不保证跨版本一致」的典型例子。它解释了 2.6585 和 9.7387 的差别,但解释不了第 1 章示例里的 10.81(穷举 24000 个组合都对不上)。
  • 管理模式:split 后覆盖旧 key;一次要多个就 split(key, n);步数、下标、层号用 fold_in 派生(默认配置下 split(k, n)[i] == fold_in(k, i));scan 里 key 放进 carry 或预先 split;vmap 映射 key 数组;全局数组共用 key,进程本地用 fold_in(key(seed), process_index());nnx.Rngs = 固定 key + 计数器。
  • 陷阱:同一个 key 喂给不同分布,结果之间是确定性相关的(Spearman = 1)。闭包捕获的 key、Python / NumPy 随机数,在 jit 里都会被固化成常量。split 的结果是 key 数组,不是单个 key。
  • 检验:均值和方差的标准误、KS、卡方、子 key 相关矩阵,再配一个反例,确认检验真能查出问题。

速查表

⚪ 选读:查阅用的 API 对照表

API作用
jax.random.key(seed, impl=None)创建类型化 key(可指定算法)
jax.random.PRNGKey(seed)旧式原始 uint32 key(仅用于兼容)
jax.random.key_data(keys)取出底层 uint32 数据(多一个尾部维度)
jax.random.wrap_key_data(raw, impl=...)把原始数据包装成类型化 key
jax.random.key_impl(key)查看 key 的算法
jnp.issubdtype(x.dtype, jax.dtypes.prng_key)判断是否为类型化 key
jax.random.split(key, n) / split(key, (a, b))派生 n 个 / 任意形状的子 key 数组
jax.random.fold_in(key, i)把整数(uint32 范围)混入 key,派生新 key
jax.random.bits(key, shape)原始随机比特
jax.random.normal / uniform / bernoulli / randint / exponential / permutation常用采样
jax.config.update("jax_default_prng_impl", name) / JAX_DEFAULT_PRNG_IMPL全局默认算法
with jax.default_prng_impl(name):临时切换默认算法
jax.config.jax_threefry_partitionable / JAX_THREEFRY_PARTITIONABLE计数器分配方式(0.12 默认 True)
with jax.threefry_partitionable(flag):临时切换 partitionable
with jax.legacy_prng_key("error"): / jax_legacy_prng_key原始 key 传入随机函数时警告 / 报错
jax.jit(f, out_shardings=NamedSharding(mesh, spec))直接生成分片的随机数组
f.lower(*args).compile().as_text()查看编译后 HLO(检查集合通信)
jax.extend.random.threefry_2x32 / threefry2x32_pthreefry 哈希原语(研究实现细节用)
jax.process_index()当前进程编号(派生进程本地 key)
nnx.Rngs(params=0, dropout=1) / rngs.dropout()Flax NNX 命名随机流:返回 fold_in(key, count) 并递增计数
nnx.Dropout(rate, rngs=rngs)、model.train() / model.eval()NNX dropout 与训练 / 评估模式
scipy.stats.kstest / chisquare / spearmanr分布检验 / 频数检验 / 秩相关

附录:练习参考答案

先在正文的「✍️ 作答区」自己写,用「🧪 自检」检查;自检通过或者实在卡住了,再来这里对照。 下面的答案按顺序运行,会覆盖你在作答区定义的同名函数。

练习 1

题目见正文「练习」。

# 参考答案:练习 1
def upgrade_keys(raw, impl="threefry2x32"):
    """把原始 uint32 key 数组(最后一维是 key 数据)包装成类型化 key 数组。"""
    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)          # 忘了 impl
    raised = False
except TypeError as e:
    raised = True
    print("不传 impl →", err_line(e))
assert raised
print("✓ 练习 1 通过")
(4,) key<fry> | (4,) key<rbg>
不传 impl → TypeError: JAX encountered invalid PRNG key data: expected key_data.shape to end with (2,); got shape=(4, 4) for impl=PRNGImpl(key_shape=(2,), seed=<function threefry_seed at 0x1155
✓ 练习 1 通过

练习 2

题目见正文「练习」。

# 参考答案:练习 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) 与 Python 循环对拍
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 4000 个游走者
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"终点方差 = {final_var:.2f}(理论 {T})| 编译次数 = {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) 只追踪 / 编译一次
print("✓ 练习 2 通过")
终点方差 = 49.91(理论 50)| 编译次数 = 1
✓ 练习 2 通过

练习 3

题目见正文「练习」。

# 参考答案:练习 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) 互不相同
assert bool(layer_key(base, 7, 2) == all_k[7 * 3 + 2])                    # 2) 可复现
assert not bool(layer_key(base, 1, 2) == layer_key(base, 2, 1))           # 3) 顺序有意义

keep_ratio = float(jnp.mean(jnp.stack([jax.random.bernoulli(x, 0.8, (256, 256)) for x in all_k])))
print(f"30 个掩码的总体保留比例 = {keep_ratio:.4f}")
assert abs(keep_ratio - 0.8) < 0.005                                      # 4)
print("✓ 练习 3 通过")
30 个掩码的总体保留比例 = 0.7997
✓ 练习 3 通过

练习 4

题目见正文「练习」。

# 参考答案:练习 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} 与不分片相同={eq}  集合通信={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("✓ 练习 4 通过")
spec=P('x', None)   partitionable=True  与不分片相同=True  集合通信=0
spec=P('x', None)   partitionable=False 与不分片相同=True  集合通信=6
spec=P(None, 'x')   partitionable=True  与不分片相同=True  集合通信=0
spec=P(None, 'x')   partitionable=False 与不分片相同=True  集合通信=0
✓ 练习 4 通过