第 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再求和,结果会算错。所以本章的权重和数据一律作为函数参数传入。
学习目标
- 说清 JAX 为什么不用全局随机状态:可复现、可并行、可
vmap/jit这三条要求,怎样推出「显式 key + 计数器式(counter-based)PRNG」的设计; - 掌握类型化 key 的形状语义、
key_data/wrap_key_data/jax.dtypes.prng_key,知道哪些数组操作对 key 合法、哪些会报错; - 认识 0.12 的 6 种 PRNG 实现(threefry2x32 / threefry4x32 / philox2x32 / philox4x32 / rbg / unsafe_rbg),会用
impl=和jax_default_prng_impl选择,了解它们在速度、split 质量、vmap语义上的差别; - 理解
jax_threefry_partitionable:默认值是什么,开关前后数值为什么不同,它怎样让分片数组在本地生成、不用通信,并能在 HLO 里验证; - 熟练使用 7 种 key 管理模式(split 后丢弃旧 key、
split(key, n)、fold_in、scancarry、vmapkey 数组、多进程派生、dropout /nnx.Rngs),避开 4 类常见陷阱; - 会用均值、方差、KS 检验、相关系数对随机数做简单的统计检验。
目录
| 节 | 内容 |
|---|---|
| 0 | 公共设置(8 个模拟设备、计时 / HLO / 子进程工具) |
| 1 | 设计理念:为什么不用全局状态 |
| 2 | key 的两种表示:类型化 key 与原始 uint32 key |
| 3 | PRNG 算法:threefry、philox、rbg、unsafe_rbg |
| 4 | jax_threefry_partitionable:默认值、数值差异、分片生成、跨版本一致性与 10.81 |
| 5 | key 管理模式(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("公共工具已定义")
1 设计理念:为什么不用全局状态¶
🟢 必读
NumPy 的经典接口 np.random.seed(0); np.random.normal() 背后是一个隐藏的全局生成器,每调用一次,它的内部状态就悄悄往前推一步。在单线程脚本里这很方便,但会带来三个问题:
- 结果依赖调用顺序:同一个种子、同样的函数,只要调用顺序变了(哪怕只是中间多插了一句打印随机样本的调试代码),后面所有的随机数都会跟着变;
- 没法并行:多个线程或设备共用一个状态时,谁先拿到哪一段随机数要看调度,结果没法复现。想让每个设备各自生成,又得先想清楚「状态怎么切」;
- 和函数变换冲突:
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 / jit | key 是数组: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 全局状态:结果依赖调用顺序")
# 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 作为参数不触发重新编译")
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 当数组用的时候,改形状、挑元素的操作都合法,因为它们不碰 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))
# ❌ 错误写法:对类型化 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))
# ✅ 正确写法:派生新 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,)
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)
# ✅ 正确写法:保存 (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
🔍 补充:两个和 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,)
3 PRNG 算法:threefry、philox、rbg、unsafe_rbg¶
key 的 dtype 里带着算法信息(key<fry> 里的 fry 就是 threefry)。0.12 里可选的实现有 6 种(见 jax.random 模块文档的 Advanced RNG configuration 一节):
impl= | dtype | key 数据 | 生成随机比特 | 派生 key(split / fold_in) | 说明 |
|---|---|---|---|---|---|
threefry2x32(默认) | key<fry> | 2 × u32 | Threefry 哈希 | Threefry | 64 位 key 空间、64 位计数器;各后端逐位一致 |
threefry4x32 | key<fry4> | 4 × u32 | Threefry | Threefry | 128 位 key / 计数器 |
philox2x32 / philox4x32 | key<phx2> / key<phx4> | 1 / 2 × u32 | Philox 哈希 | Philox | 同为计数器式 PRNG(Salmon et al. 2011) |
rbg(实验性) | key<rbg> | 4 × u32 | XLA RngBitGenerator | Threefry | TPU 上最快;派生 key 仍用 threefry 的质量 |
unsafe_rbg(实验性) | key<urbg> | 4 × u32 | XLA RngBitGenerator | RBG 当哈希用 | 派生 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"])
# 全局默认算法:环境变量(子进程,从启动时生效)与上下文管理器(本进程,临时)
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>"
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")
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
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 与逐个调用一致”时用 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)
官方文档的性质汇总(* 表示 jax_threefry_partitionable=True,0.5.0 起为默认;** 表示设置了 XLA 标志 --xla_tpu_spmd_rng_bit_generator_unsafe=1):
| 性质 | Threefry | Threefry* | Philox | rbg | unsafe_rbg | rbg** | 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
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 相同;各自可复现")
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
最后一列是个顺带的好处:默认配置下
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'))切分。在开、关两种设置下比较三件事:
- 结果和不分片时是否逐位相同;
- 编译后的 HLO 里有没有集合通信;
- 每个设备实际生成了多少个随机值。这个数从 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✓ 开启:本地生成、零通信;关闭:要么通信,要么每个设备冗余地算整个数组")
结论和这个配置项的帮助文字完全吻合: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)
结论: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)
# ✅ 正确写法:每次 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)
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,)))
# ✅ 正确写法:从实验的主 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(预热后中位数,只打印)")
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)
# ✅ 正确写法:在 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))
# ❌ 错误写法: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]))
# ✅ 正确写法 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
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]))
# ✅ 正确写法: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])
5.6 多进程:为每个进程派生 key¶
⚪ 选读:多主机 / 多进程训练时
多主机训练时,每个进程跑的是同一份脚本。随机数分两类,处理方式正好相反:
| 用途 | key 怎么来 | 原因 |
|---|---|---|
全局数组(jit 生成的分片参数初始化、全局 batch 上的 dropout) | 所有进程用同一个 key | jit 把非全局数组的输入视为「各进程相同」;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
# ✅ 正确写法:实验种子决定主 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 个进程,看「全局数组用错 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"]
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)
# ✅ 正确写法:每层从 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) # 评估模式不变
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")
# ❌ 错误写法:创建 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)
# ✅ 正确写法:把 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("✓ 评估模式下输出确定、不消耗随机数")
# ❌ 错误写法:同一个 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)
# ✅ 正确写法:每种用途一个子 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)
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)
# ✅ 正确写法: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
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"]
# ✅ 正确写法:解包;或者对 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)
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
# ✅ 正确写法:用 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
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
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)
相邻种子
key(0..63)在这个检验里也没问题。threefry 是密码学风格的哈希,相邻输入的输出看不出相关性。所以 5.2 节反对key(seed + i),理由不是统计质量,而是语义上的撞车(不同实验共用了随机流)。unsafe_rbg 过了这个粗略的检验,也不代表它的派生质量有保证,官方明确说过它没有经过系统的随机性测试。
8 实战规则清单¶
🟢 必读
| # | 规则 | 依据 |
|---|---|---|
| 1 | 新代码只用 jax.random.key(seed);迁移旧代码时可设 jax_legacy_prng_key='error' 揪出原始 key | 2.2 |
| 2 | key, sub = split(key):用 sub,新 key 往下传,旧 key 不再出现 | 5.1 |
| 3 | 一次要多个 key 用 split(key, n);不要用 key(seed + i) | 5.2 |
| 4 | 训练步、数据下标、层号用 fold_in 派生;步数作为动态参数传进 jit | 5.3、5.7 |
| 5 | scan 里要么把 key 放进 carry 并每步 split,要么预先 split(key, T) 作为 xs | 5.4 |
| 6 | 逐样本随机性:split(key, B) 后 vmap 映射 key 数组;不要广播同一个 key | 5.5 |
| 7 | 生成全局分片数组时所有进程用同一个 key;进程本地随机性用 fold_in(key(seed), process_index()) | 5.6 |
| 8 | 一种用途一个 key:噪声、掩码、初始化不要共用 key,哪怕分布不同 | 6.1 |
| 9 | key 永远作为参数传入 jit,不要闭包捕获;jit 里不用 Python random / NumPy 随机数 | 6.2、6.4 |
| 10 | 保存 key 时存 (impl, key_data),恢复用 wrap_key_data(..., impl=);大整数种子拆开再 fold_in | 2.2 |
| 11 | 默认用 threefry2x32 并保持 jax_threefry_partitionable=True;只有 TPU 上 profile 证明 RNG 是瓶颈时才考虑 rbg | 3、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 数组,并验证:
- 两种情况下返回的形状都是
(4,),dtype 分别是key<fry>、key<rbg>; - 对 threefry:用升级后的
keys[i]采样,结果与旧代码直接用legacy[i]采样逐位相同; - 对 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 自检通过")
练习 2:scan + vmap 模拟随机游走
写 walk(key, T):从 0 出发,每步加一个标准正态增量,用 lax.scan 实现,key 放在 carry 里,返回长度 T 的位置序列。然后:
- 验证
walk(key(0), 50)与等价的 Python 循环(同样的 split 顺序)逐位相同; - 用
vmap对split(key(1), 4000)并行模拟 4000 个游走者,验证终点位置的方差接近T(容差 10%),且游走者两两不同; - 整个
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 自检通过")
练习 3:每步、每层的 dropout key
写 layer_key(base, step, layer),用两次 fold_in 派生「第 step 步、第 layer 层」的 key。验证:
- 10 步 × 3 层 = 30 个 key 互不相同;
- 可复现:重新计算第 7 步第 2 层的 key 得到同一个值;
layer_key(base, 1, 2)与layer_key(base, 2, 1)不同(顺序有意义);- 用这些 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 自检通过")
练习 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 条)。验证:
- 默认设置下,
P('x', None)和P(None, 'x')两种切法都与不分片结果相同,且集合通信数为 0; - 关闭 partitionable 时,按行切分仍然结果相同,但集合通信数大于 0;
- 关闭 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 自检通过")
测验¶
⚪ 选读:自测用
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 道测验题的答案全部与实测一致")
本章小结¶
🟢 必读
- 设计: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_p | threefry 哈希原语(研究实现细节用) |
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 通过")
练习 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 通过")
练习 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 通过")
练习 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 通过")