第 7 章 实战专题
前 6 章已经把 jit、grad、vmap、pmap、状态管理过了一遍,各章的「🔍 补充」里也零散提到了不少实战问题。这一章不重复那些内容,而是挑出训练代码里最常出问题的 7 个主题,每个都往深里讲一遍,最后用一个从零写的 MLP 分类器把它们串起来。
每个专题都按同一个结构组织:
问题动机 → 原理 → ❌ 常见错误写法与实测 → ✅ 正确写法 →
assert自证 → 💡 一句话实战规则
文中的「→ 基础见第 N 章 x.x」指向前面章节里对应的基础内容,建议先回顾再往下读。
所有结论都在 JAX 0.12.0.1 / Python 3.14 / 仅 CPU 上跑过。计时数字跟机器有关,看相对大小就好。
目录¶
| # | 专题 | 你会得到什么 |
|---|---|---|
| 1 | 重新编译:触发条件、诊断与治理 | 8 类触发因素实测表、4 种诊断手段、分桶 padding、AOT 预编译、编译预算工具 |
| 2 | 数值稳定与 NaN 梯度 | 双 where 的通用写法、稳定的 logsumexp / softmax / log_softmax、jax_debug_nans 的 0.12 实际行为、custom_jvp 修梯度 |
| 3 | jacfwd / jacrev / jvp / vjp 选型 | 决策表、维度扫描计时图、HVP 三种实现的内存与速度对比 |
| 4 | 逐样本梯度 | vmap(grad) 与循环对拍、B×P 内存与分块、完整 DP-SGD(裁剪 + 加噪) |
| 5 | 手写优化器 | 仿 Optax 的 GradientTransformation、SGD/动量/Nesterov/Adam/AdamW、chain、warmup + cosine |
| 6 | donate_argnums 与内存 | 别名机制、CPU 实测、捐两次的坑、训练 step 标准模板 |
| 7 | PyTree 新 API 与自定义容器 | 迁移表补测(完整表见第 6 章)、jax.tree.* 全家、dataclass / NamedTuple 作 TrainState、参数表 |
| ★ | 综合项目:螺旋数据 MLP 分类器 | 以上全部串起来,断言准确率 > 95%、训练函数只编译 1 次 |
| 🏋️ | 补充练习(6 道,含参考答案) | 编译预算测试、安全余弦相似度、牛顿-CG、找标签错误样本、Lion 优化器(与 optax.lion 对拍)、冻结层 + 捐赠 |
| 📝 | 第 7 章测验(8 道) | 答案折叠在 <details> 里,部分题目附代码验证 |
| ✔ | 实战规则清单 | 每个专题 2–3 条 |
| 🗂️ | 速查表 | 本章用到的关键 API → 作用,按专题分组 |
学习目标¶
学完本章,你应该能够:
- 说清楚 jit 缓存键里有什么,看到「训练变慢」时用日志 / 计数器 /
jax.stages定位是哪个参数触发了重新编译,并用编译预算把它写进测试; - 写出不会产生 NaN 梯度的
where、范数、softmax 交叉熵,知道jax_debug_nans能抓什么、抓不到什么; - 根据输入/输出维度选对
jacfwd、jacrev、jvp、vjp,不构造 Hessian 也能做二阶计算; - 高效计算逐样本梯度,估算它的内存,并实现 DP-SGD;
- 不依赖 Optax,用 PyTree 写出可组合的优化器和学习率调度,并用 NumPy 参考值验证;
- 用
donate_argnames写训练 step,避开捐赠相关的运行时错误; - 熟练使用
jax.tree.*新 API,用 dataclass 定义 TrainState,打印参数表。 - 把以上内容组合成一个完整的训练项目,并能独立完成本章的 6 道补充练习。
0 公共设置¶
⚪ 选读:本章共用的计时、报错打印和编译计数工具,用到时回来看定义即可
下面这个单元定义了全章都会用到的工具:
bench:计时工具,先预热(触发编译)再计时,每次都调用jax.block_until_ready;show_error:故意演示错误时,只打印异常类型和第一行信息;CompileCounter:基于jax.monitoring的编译计数器,统计with块里真正发生的 XLA 编译,可以按函数名过滤。专题 1 会详细讲它。这里先定义好,后面每个专题都会用它来证明「没有多余的编译」。
⚠️ 本环境已知问题(→ 00 目录与导读 · 本环境已知问题;原因分析见 进阶 02 · 2.3):jaxlib 0.12.0.1 的 CPU 后端有个 bug,被 jit 闭包捕获的较大常量矩阵做完 matmul 再 reduce,结果会悄悄算错,也不报错。所以本章所有权重和数据都作为函数参数传入,关键数值用 NumPy float64 对拍。
# 公共导入与工具(后面所有单元都依赖这里)
import time, math, logging, warnings, functools, dataclasses
from dataclasses import dataclass, field
from functools import partial
from typing import NamedTuple, Any, Callable
import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
import jax.monitoring as monitoring
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["PingFang SC", "Heiti SC", "Arial Unicode MS", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
# 图表配色(固定顺序的分类色)
C_BLUE, C_ORANGE, C_AQUA, C_VIOLET, C_RED = "#2a78d6", "#eb6834", "#1baf7a", "#6250d6", "#e34948"
print("JAX", jax.__version__, "| 后端", jax.default_backend(), "| 设备", jax.devices())
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
def assert_faster(fast, slow, factor=1.0, tries=3):
"""计时断言:fast() 的耗时 × factor < slow() 的耗时(两个参数都是返回耗时的无参函数)。
计时受机器负载影响(例如 CI 上几个 notebook 并行),不满足时重新测量,最多 tries 次。"""
for _ in range(tries):
tf, ts = fast(), slow()
if tf * factor < ts:
return tf, ts
raise AssertionError(f"重测 {tries} 次仍不满足:{tf:.3g} × {factor} ≥ {ts:.3g}")
def show_error(fn):
"""运行 fn;若报错,只打印异常类型和第一行信息(用于演示错误写法)。"""
try:
out = fn()
print("(没有报错)结果:", out)
except Exception as e:
first = str(e).strip().splitlines()[0] if str(e).strip() else ""
print(f"❌ {type(e).__name__}: {first[:160]}")
_COMPILE_EVENT = "/jax/core/compile/backend_compile_duration"
class CompileCounter:
"""统计 with 块内发生的 XLA 后端编译次数。
name=None 统计所有编译;name="f" 只统计 jit(f) 的编译。
原理:JAX 每完成一次后端编译,都会通过 jax.monitoring 发出一个
'/jax/core/compile/backend_compile_duration' 事件,附带 fun_name。
"""
def __init__(self, name=None):
self.name = name
self.events = [] # [(fun_name, 秒)]
self._listener = self._on_event # 保存同一个绑定方法对象,注销时才能找到它
def _on_event(self, event, duration, **kw):
if event != _COMPILE_EVENT:
return
fun = kw.get("fun_name", "?")
if self.name is None or fun == f"jit({self.name})":
self.events.append((fun, duration))
def __enter__(self):
monitoring.register_event_duration_secs_listener(self._listener)
return self
def __exit__(self, *exc):
monitoring.unregister_event_duration_listener(self._listener)
return False
@property
def count(self):
return len(self.events)
def wpad(s, width):
"""按显示宽度左对齐(一个中文字符占 2 列),用于打印对齐的表格。"""
import unicodedata
w = sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in str(s))
return str(s) + " " * max(0, width - w)
def to64(x):
"""转成 NumPy float64,用于和高精度参考值对拍。"""
return np.asarray(x, dtype=np.float64)
# 自检:计数器能数到编译,命中缓存时不计数
_x = jnp.ones(3)
def _probe(x):
return x * 2
with CompileCounter("_probe") as _c:
jax.jit(_probe)(_x); jax.jit(_probe)(_x)
assert _c.count == 1, _c.events
print("CompileCounter 自检通过:两次调用只编译 1 次")
专题 1 重新编译:触发条件、诊断与治理¶
1.1 问题动机¶
🟢 必读
典型症状是:模型不大,训练却越跑越慢,或者每隔几步就卡一下。CPU 占满了,真正的计算却没几步。罪魁祸首通常是重新编译:每次 jit 缓存没命中,JAX 都要重新追踪 Python 函数、生成 StableHLO、交给 XLA 优化编译。一个中等规模的训练 step 编译一次要几百毫秒到几十秒,而执行一次可能只要几毫秒。
先看一个直观的对比:同一个函数,用 20 种不同的 batch 大小调用,和用同一个 batch 大小调用 20 次,耗时差多少。
→ 基础见 第 2 章 2.3(追踪与编译、重新编译触发条件总表、AOT API)、2.5(静态参数)、2.6 问题 4(动态形状)、练习 3(重新编译侦探)。
def mlp_loss(w1, w2, x):
# 一个小 MLP 的前向 + 均方值,权重和数据都作为参数传入
h = jnp.tanh(x @ w1)
return jnp.mean((h @ w2) ** 2)
k1, k2, k3 = jax.random.split(jax.random.key(0), 3)
W1 = jax.random.normal(k1, (64, 256)) / 8.0
W2 = jax.random.normal(k2, (256, 10)) / 16.0
X_all = jax.random.normal(k3, (600, 64))
grad_fn = jax.jit(jax.grad(mlp_loss, argnums=(0, 1)))
sizes = [100 + 7 * i for i in range(20)] # 20 种不同的 batch 大小
batches = [X_all[:n] for n in sizes] # 先切好,避免把切片耗时算进去
same = [X_all[:100]] * 20
def run(xs):
t0 = time.perf_counter()
for x in xs:
jax.block_until_ready(grad_fn(W1, W2, x))
return (time.perf_counter() - t0) * 1e3
with CompileCounter("mlp_loss") as c_var:
t_var = run(batches)
grad_fn(W1, W2, same[0]) # 预热:保证下面只测执行
with CompileCounter("mlp_loss") as c_same:
t_same = run(same)
print(f"20 种 batch 大小:{t_var:7.1f} ms,编译 {c_var.count} 次")
print(f"同一 batch 大小 :{t_same:7.1f} ms,编译 {c_same.count} 次")
print(f"平均每次编译约 {np.mean([d for _, d in c_var.events]) * 1e3:.1f} ms,"
f"每次执行约 {t_same / 20:.2f} ms")
assert c_var.count == 20 and c_same.count == 0
1.2 原理:jit 缓存键里有什么¶
🟢 必读
每次调用 jax.jit(f)(*args),JAX 都会算出一个缓存键。命中就直接执行编译好的程序,没命中就重新追踪、编译。缓存键大致由下面几样拼成:被包装的函数对象(按对象身份)+ 参数的 pytree 结构(容器类型、dict 的键、None 的位置)+ 每个叶子的 shape / dtype / weak_type(以及 sharding)+ 静态参数的值(按 __hash__ / __eq__ 比较)+ 调用方式(位置传、关键字传、省略默认参数)+ 影响追踪的全局配置(如 jax_enable_x64)。每一项的例子和验证见 第 2 章 2.3「重新编译触发条件总表」(E1–E8),这里不再重复。
这里补充总表里没展开的两类「对象身份」问题:一是 jax.jit(self.method) 这样的绑定方法,二是按 id 哈希的静态配置类。
不会触发重新编译的情况有:同形状、同 dtype 下只改数值;同一个模块级函数被 jax.jit(f) 包装好几次(它们共享缓存);默认配置下请求 float64(会被截断成 float32,→ 见第 2 章 2.3 补充)。
下面把总表的基本项和这两类一起再测一遍(后面各专题都会用到这里定义的 count_compiles)。每一行都用一个新定义的函数,用 CompileCounter 数它被编译了几次,再用 assert 核对预期。
1.3 ❌ 常见触发因素:逐条实测¶
⚪ 选读:想逐条确认每类触发因素(含绑定方法、按 id 哈希的配置类)时看;后文用到的 count_compiles 在这里定义
x3, x4 = jnp.ones(3), jnp.ones(4)
x3_i = jnp.ones(3, jnp.int32)
def count_compiles(name, calls):
"""依次执行 calls 中的每个无参函数,返回 jit(name) 被编译的次数。"""
with CompileCounter(name) as c:
for call in calls:
jax.block_until_ready(call())
return c.count
rows = [] # (触发因素, 调用方式, 实测编译次数, 预期次数)
# ① 形状
def t_shape(x): return x * 2
f = jax.jit(t_shape)
rows.append(("形状", "f32[3] → f32[4]", count_compiles("t_shape", [lambda: f(x3), lambda: f(x4)]), 2))
# ② dtype
def t_dtype(x): return x * 2
f = jax.jit(t_dtype)
rows.append(("dtype", "f32[3] → i32[3]", count_compiles("t_dtype", [lambda: f(x3), lambda: f(x3_i)]), 2))
# ③ 弱类型:Python 标量是“弱类型”,数组是“强类型”
def t_weak(x, lr): return x * lr
f = jax.jit(t_weak)
rows.append(("弱类型", "lr=0.1 → lr=jnp.float32(0.1)",
count_compiles("t_weak", [lambda: f(x3, 0.1), lambda: f(x3, jnp.float32(0.1))]), 2))
# ④ 静态参数的值
def t_static(x, n): return x ** n
f = jax.jit(t_static, static_argnames="n")
rows.append(("静态参数值", "n=2 → n=3", count_compiles("t_static", [lambda: f(x3, n=2), lambda: f(x3, n=3)]), 2))
# ⑤ pytree 结构:多一个键 / list 换成 tuple / 叶子换成 None
def t_tree(s): return jax.tree.map(lambda a: a + 1, s)
f = jax.jit(t_tree)
rows.append(("pytree 结构", "{'a'} → {'a','b'} → [x] → (x,) → {'a', 'b':None}",
count_compiles("t_tree", [lambda: f({"a": x3}), lambda: f({"a": x3, "b": x3}),
lambda: f([x3]), lambda: f((x3,)), lambda: f({"a": x3, "b": None})]), 5))
# ⑥ 新建 jit 对象:每次调用都创建一个新的内部函数(闭包)
def make_and_call(x):
def t_inner(y): return y + 1 # 每次调用 make_and_call 都是一个新的函数对象
return jax.jit(t_inner)(x)
rows.append(("新建 jit 对象(闭包)", "循环里调用 3 次",
count_compiles("t_inner", [lambda: make_and_call(x3)] * 3), 3))
# ⑦ 新建 jit 对象:jit 一个绑定方法(obj.method 每次访问都生成新的绑定方法对象)
class Model:
def t_method(self, x): return x * 3
def predict(self, x): return jax.jit(self.t_method)(x)
m = Model()
rows.append(("新建 jit 对象(绑定方法)", "m.predict 调用 3 次",
count_compiles("t_method", [lambda: m.predict(x3)] * 3), 3))
# ⑧ 静态参数按 id 比较:自定义类没有 __eq__/__hash__,每个新实例都是新键
class Cfg:
def __init__(self, n): self.n = n
def t_cfg(x, cfg): return x * cfg.n
f = jax.jit(t_cfg, static_argnums=1)
rows.append(("静态参数按 id 哈希", "Cfg(2) 新建 3 次", count_compiles("t_cfg", [lambda: f(x3, Cfg(2))] * 3), 3))
# ⑨ 调用方式:省略默认参数 vs 显式传入相同的值
def t_default(x, scale=2.0): return x * scale
f = jax.jit(t_default)
rows.append(("调用方式", "f(x) → f(x, 2.0) → f(x, scale=2.0)",
count_compiles("t_default", [lambda: f(x3), lambda: f(x3, 2.0), lambda: f(x3, scale=2.0)]), 3))
# —— 对照组:这些不会触发重新编译 ——
def t_value(x): return x * 2
f = jax.jit(t_value)
rows.append(("(对照)只改数值", "ones(3) → ones(3)*5", count_compiles("t_value", [lambda: f(x3), lambda: f(x3 * 5)]), 1))
def t_shared(x): return x * 2
rows.append(("(对照)多次 jax.jit(f)", "同一个模块级函数包装 3 次",
count_compiles("t_shared", [lambda: jax.jit(t_shared)(x3)] * 3), 1))
print(wpad("触发因素", 26) + wpad("调用", 50) + "实测 预期")
print("-" * 80)
for name, how, got, exp in rows:
print(wpad(name, 26) + wpad(how, 50) + f"{got:>4}{exp:>6}")
for name, how, got, exp in rows:
assert got == exp, (name, got, exp)
print("\n全部与预期一致 ✔")
几个容易忽略的点:
- ⑤ pytree 结构:
None不是叶子,而是「空子树」,所以{'a': x, 'b': None}和{'a': x, 'b': x}结构不同。训练状态里如果某个字段有时是None(比如第一步还没有动量),就会多编译一次。 - ⑦ 绑定方法:把
jax.jit(self.method)写在方法内部时,每次访问self.method都会生成一个新的绑定方法对象,结果每次调用都重新编译。把 jit 套进面向对象代码时,这是最常见的性能事故。 - ⑧ 静态参数:缓存键用
__hash__+__eq__比较。普通类默认按id比较,所以内容一样的新实例也命中不了缓存。
1.4 诊断手段¶
⚪ 选读:训练变慢、需要定位是哪个参数触发了重新编译时再查
发现问题后,还得能定位是哪个参数导致了重新编译。JAX 提供了 4 类工具,从粗到细:
| 手段 | 怎么用 | 能告诉你什么 |
|---|---|---|
| ① 编译日志 | jax.config.update("jax_log_compiles", True) | 每次编译打印一行:函数名 + 全部参数的形状/dtype |
| ② 缓存未命中解释 | jax.config.update("jax_explain_cache_misses", True) | 为什么没命中:和最接近的旧缓存键相比,哪个参数、哪里不同 |
| ③ 计数器 | 追踪计数器(Python 副作用)、CompileCounter(jax.monitoring 事件) | 精确的次数,可以写进 assert / 单元测试 |
④ jax.stages | jit(f).trace(...) / .lower(...) / .compile() | 各阶段分别耗时多少、程序有多大,并支持提前编译 |
①② 的输出走 Python 的 logging(logger 名以 jax 开头,级别是 WARNING)。为了让输出整洁一些,下面写个小工具,先把这些日志收集起来再打印。
class capture_jax_logs:
"""临时接管 'jax' logger,把日志收集到 self.messages(不再打印到 stderr)。"""
def __enter__(self):
self.logger = logging.getLogger("jax")
self.messages = []
self.handler = logging.Handler()
self.handler.emit = lambda record: self.messages.append(record.getMessage())
self._old_propagate = self.logger.propagate
self.logger.addHandler(self.handler)
self.logger.propagate = False
return self
def __exit__(self, *exc):
self.logger.removeHandler(self.handler)
self.logger.propagate = self._old_propagate
return False
# ① jax_log_compiles:每次真正编译打印一行 “Compiling jit(...)”
def diag_step(params, x):
return jax.tree.map(lambda p: p * x.mean(), params)
f = jax.jit(diag_step)
params_a = {"w": jnp.ones((2, 2)), "b": jnp.zeros(2)}
xs = [jnp.ones(5), jnp.ones(5) * 2, jnp.ones(6)] # 先建好输入,免得混入 jnp.ones 自己的编译日志
jax.config.update("jax_log_compiles", True)
try:
with capture_jax_logs() as logs:
for x in xs:
f(params_a, x)
finally:
jax.config.update("jax_log_compiles", False) # 用 finally 保证一定关掉
compiling = [m for m in logs.messages if m.startswith("Compiling")]
for m in compiling:
print(m[:150], "...")
assert len(compiling) == 2 # 第 2 次只是数值不同,命中缓存
# ② jax_explain_cache_misses:直接告诉你“哪个参数、哪里变了”
def diag_explain(state, lr):
return jax.tree.map(lambda p: p - lr * p, state)
f = jax.jit(diag_explain)
s1 = {"w": jnp.ones(3)}
s2 = {"w": jnp.ones(3), "m": jnp.zeros(3)} # 结构变了
f(s1, 0.1) # 先建立缓存
jax.config.update("jax_explain_cache_misses", True)
try:
with capture_jax_logs() as logs:
f(s1, jnp.float32(0.1)) # 弱类型 → 强类型
f(s2, 0.1) # pytree 结构变化
finally:
jax.config.update("jax_explain_cache_misses", False)
misses = [m for m in logs.messages if "CACHE MISS" in m]
for m in misses:
# 去掉文件路径行,只保留“原因”部分
lines = [l for l in m.splitlines() if not l.strip().startswith(("for ", "TRACING"))]
print("\n".join(lines[:6]), "\n")
assert len(misses) == 2
怎么读:第一条说 lr 这里「different input types」,还给出了新旧类型(弱类型 f32[]{weak_type=True} 变成了强类型 f32[])。第二条说参数的 pytree 结构不同。在真实项目里,打开 ②、跑几步训练,基本就能找到问题。
③ 计数器:日志适合人看,计数器适合写进测试。追踪计数器利用的是「Python 副作用只在追踪时执行」(→ 见第 6 章 6.1 补充),CompileCounter 数的则是真正的后端编译。两者通常相等,但追踪不一定编译:jax.eval_shape、jax.make_jaxpr、.lower() 都只追踪、不编译。
trace_count = 0
def diag_counter(x):
global trace_count
trace_count += 1 # 只在追踪时执行
return jnp.sin(x) * 2
f = jax.jit(diag_counter)
with CompileCounter("diag_counter") as c:
jax.eval_shape(f, x3) # 只追踪,算出输出的形状/dtype
f.lower(x4) # 追踪 + 降级到 StableHLO,不编译
f(x3); f(x3) # 第一次:追踪 + 编译(复用 eval_shape 的追踪结果);第二次:命中
print(f"追踪次数 = {trace_count},后端编译次数 = {c.count}")
assert c.count == 1 and trace_count >= 2
④ jax.stages:把一次 jit 调用拆成 trace → lower → compile 三个阶段,分别计时。下面用一个「Python 循环展开 30 层」的函数,看看编译时间花在哪里,编译产物有多大。Compiled 对象只接受编译时的形状/dtype,传错了会直接报 TypeError,不会偷偷重新编译。「提前编译、上线后零编译」靠的就是这一点。
def deep_unrolled(ws, x):
for w in ws: # Python 循环在追踪时展开成 30 份
x = jnp.tanh(x @ w)
return x.sum()
ws = [jax.random.normal(k, (64, 64)) / 8.0 for k in jax.random.split(jax.random.key(1), 30)]
xb = jnp.ones((16, 64))
jf = jax.jit(deep_unrolled)
t0 = time.perf_counter(); traced = jf.trace(ws, xb); t1 = time.perf_counter()
lowered = traced.lower(); t2 = time.perf_counter()
compiled = lowered.compile(); t3 = time.perf_counter()
print(f"trace {1e3*(t1-t0):6.1f} ms | lower {1e3*(t2-t1):6.1f} ms | compile {1e3*(t3-t2):6.1f} ms")
print("jaxpr 方程数:", len(traced.jaxpr.eqns), "| StableHLO 行数:", len(lowered.as_text().splitlines()))
print("类型:", type(traced).__name__, type(lowered).__name__, type(compiled).__name__,
"| 都在 jax.stages 里:", all(hasattr(jax.stages, n) for n in ("Traced", "Lowered", "Compiled")))
ref = to64(xb) # NumPy float64 参考值
for w in ws:
ref = np.tanh(ref @ to64(w))
assert np.allclose(compiled(ws, xb), ref.sum(), rtol=1e-4, atol=1e-4)
show_error(lambda: compiled(ws, jnp.ones((17, 64)))) # 形状不同 → TypeError
def masked_mse(w, X, y, mask):
r = X @ w - y
return jnp.sum(mask * r ** 2) / jnp.sum(mask) # 分子、分母都只算有效行
def plain_mse(w, X, y):
return jnp.mean((X @ w - y) ** 2)
BUCKETS = (64, 128, 256, 512)
def pad_to_bucket(X, y):
"""在 NumPy 里做 padding(不触发任何 JAX 编译),返回 (Xp, yp, mask)。"""
n = X.shape[0]
b = next(b for b in BUCKETS if b >= n)
Xp = np.zeros((b, X.shape[1]), np.float32); Xp[:n] = X
yp = np.zeros(b, np.float32); yp[:n] = y
mask = np.zeros(b, np.float32); mask[:n] = 1.0
return Xp, yp, mask
kx, ky, kw, kn = jax.random.split(jax.random.key(2), 4)
Xbig = np.asarray(jax.random.normal(kx, (512, 8)))
ybig = np.asarray(jax.random.normal(ky, (512,)))
w0 = jax.random.normal(kw, (8,))
lengths = [int(n) for n in jax.random.randint(kn, (30,), 20, 500)] # 30 个随机长度
vg_plain = jax.jit(jax.value_and_grad(plain_mse))
vg_masked = jax.jit(jax.value_and_grad(masked_mse))
wrong_mean = jax.jit(lambda w, X, y: jnp.mean((X @ w - y) ** 2)) # ❌ padding 了但没 mask
with CompileCounter("plain_mse") as c_plain:
for n in lengths:
vg_plain(w0, Xbig[:n], ybig[:n])
max_err_masked, max_err_wrong = 0.0, 0.0
with CompileCounter("masked_mse") as c_masked:
for n in lengths:
Xp, yp, mask = pad_to_bucket(Xbig[:n], ybig[:n])
loss, g = vg_masked(w0, Xp, yp, mask)
# NumPy float64 参考值
r = Xbig[:n].astype(np.float64) @ to64(w0) - ybig[:n]
ref_loss, ref_g = np.mean(r ** 2), 2 * Xbig[:n].T.astype(np.float64) @ r / n
max_err_masked = max(max_err_masked, abs(float(loss) - ref_loss), np.abs(to64(g) - ref_g).max())
max_err_wrong = max(max_err_wrong, abs(float(wrong_mean(w0, Xp, yp)) - ref_loss))
print(f"不分桶:{len(set(lengths))} 种长度 → 编译 {c_plain.count} 次")
print(f"分桶 :{len(BUCKETS)} 个桶 → 编译 {c_masked.count} 次")
print(f"分桶 + mask 与 NumPy float64 的最大误差:{max_err_masked:.2e}")
print(f"❌ padding 但不 mask 的损失最大误差 :{max_err_wrong:.3f}")
assert c_plain.count == len(set(lengths)) and c_masked.count <= len(BUCKETS)
assert max_err_masked < 1e-4 and max_err_wrong > 0.1
# padding 的代价:平均浪费了多少计算
waste = np.mean([1 - n / next(b for b in BUCKETS if b >= n) for n in lengths])
print(f"2 的幂分桶平均浪费 {waste:.0%} 的计算;桶分得越细,浪费越少,但编译次数越多")
(2) 静态参数:可哈希、按值比较、取值少¶
- 静态参数必须可哈希(list、dict、数组都不行,→ 第 2 章 2.5 补充);
- 自定义配置类要按值比较。可以用
@dataclass(frozen=True)自动生成__eq__和__hash__;也可以用jax.tree_util.register_static把类注册成「没有叶子的 pytree」,这样它不用写进static_argnames,也会被当成静态值; - 静态参数的取值要少:层数、激活函数名、是否训练模式这类可以;学习率、温度、步数这类会变的数值不行(→ 第 6 章 6.6)。
@dataclass(frozen=True) # frozen=True → 自动生成按值比较的 __eq__ 和 __hash__
class CfgOK:
n: int
def t_cfg_ok(x, cfg): return x * cfg.n
f = jax.jit(t_cfg_ok, static_argnums=1)
n_ok = count_compiles("t_cfg_ok", [lambda: f(x3, CfgOK(2))] * 3)
@jax.tree_util.register_static # 注册为静态 pytree:作为普通参数传入即可
@dataclass(frozen=True)
class Activation:
name: str
def t_act(x, act):
return jnp.tanh(x) if act.name == "tanh" else jax.nn.relu(x) # Python if:act 是静态的
f_act = jax.jit(t_act) # 注意:没有写 static_argnums
n_act = count_compiles("t_act", [lambda: f_act(x3, Activation("tanh"))] * 3 + [lambda: f_act(x3, Activation("relu"))])
# 学习率:❌ 静态(每个新值编译一次) vs ✅ 动态(只编译一次)
def t_lr_static(p, lr): return p - lr * p
def t_lr_dynamic(p, lr): return p - lr * p
f_s = jax.jit(t_lr_static, static_argnames="lr")
f_d = jax.jit(t_lr_dynamic)
lrs = [1e-3 * (1 + i) for i in range(8)] # 模拟 8 步学习率调度
n_s = count_compiles("t_lr_static", [lambda lr=lr: f_s(x3, lr=lr) for lr in lrs])
n_d = count_compiles("t_lr_dynamic", [lambda lr=lr: f_d(x3, jnp.float32(lr)) for lr in lrs])
print(f"frozen dataclass 作静态参数,3 个等值实例 → 编译 {n_ok} 次")
print(f"register_static:tanh × 3 + relu × 1 → 编译 {n_act} 次")
print(f"学习率调度 8 步:静态 {n_s} 次 | 动态 {n_d} 次")
assert (n_ok, n_act, n_s, n_d) == (1, 2, 8, 1)
(3) 在循环外创建 jit;统一参数的 dtype 和弱类型¶
- jit 对象在模块级或构造函数里创建一次,不要写在循环体或方法体里。面向对象的代码,可以把要编译的部分写成「参数显式传入的纯函数」,在模块级 jit;
- 传进 jit 的数值统一用
jnp.asarray(v, jnp.float32)这类强类型数组,别「有时是 Python float、有时是数组」。注意,问题出在混用上:每次都传 Python float(哪怕数值每步都变)只编译 1 次。下面一起验证。
def _forward(w, x): # 纯函数:权重显式传入
return jnp.tanh(x @ w)
forward_jit = jax.jit(_forward) # ✅ 模块级创建一次
class ModelOK:
def __init__(self, w):
self.w = w
def predict(self, x):
return forward_jit(self.w, x) # 方法里只调用,不新建 jit
mo = ModelOK(jnp.ones((3, 2)))
n_fwd = count_compiles("_forward", [lambda: mo.predict(x3)] * 5)
def t_lr_mixed(p, lr): return p - lr * p
f_mix = jax.jit(t_lr_mixed)
schedule = [0.1, jnp.float32(0.05), 0.02] # ❌ Python float 和数组混用
n_mix = count_compiles("t_lr_mixed", [lambda lr=lr: f_mix(x3, lr) for lr in schedule])
def t_lr_cast(p, lr): return p - lr * p
f_cast = jax.jit(t_lr_cast)
n_cast = count_compiles("t_lr_cast", [lambda lr=lr: f_cast(x3, jnp.asarray(lr, jnp.float32)) for lr in schedule]) # ✅
def t_lr_py(p, lr): return p - lr * p
f_py = jax.jit(t_lr_py)
n_py = count_compiles("t_lr_py", [lambda lr=lr: f_py(x3, lr) for lr in [0.1, 0.05, 0.02]]) # 全是 Python float:值变了也不重编译
print(f"模块级 jit,predict 调用 5 次 → 编译 {n_fwd} 次")
print(f"学习率混用 Python float / 数组 → 编译 {n_mix} 次;统一转成 float32 数组 → 编译 {n_cast} 次;全用 Python float → 编译 {n_py} 次")
assert (n_fwd, n_mix, n_cast, n_py) == (1, 2, 1, 1)
(4) 提前编译(AOT):上线前把每个桶都编译好¶
分桶之后,第一次遇到某个桶时仍然要编译,在线服务里看到的就是「偶发的长尾延迟」。办法是在启动时用 jax.ShapeDtypeStruct(只描述形状和 dtype,不需要真实数据)把所有桶提前 lower().compile() 好,之后直接调用 Compiled 对象。Compiled 不会再触发任何编译,形状不对时会直接报错,不会悄悄去编译。
def serve_loss(w, X, y, mask):
return masked_mse(w, X, y, mask)
t0 = time.perf_counter()
compiled_by_bucket = {
b: jax.jit(serve_loss).lower(
jax.ShapeDtypeStruct((8,), jnp.float32), jax.ShapeDtypeStruct((b, 8), jnp.float32),
jax.ShapeDtypeStruct((b,), jnp.float32), jax.ShapeDtypeStruct((b,), jnp.float32)).compile()
for b in BUCKETS}
print(f"启动时预编译 {len(BUCKETS)} 个桶:{(time.perf_counter() - t0) * 1e3:.0f} ms")
w0_np = np.asarray(w0)
with CompileCounter() as c_serve: # name=None:统计所有函数的编译
outs = []
for n in lengths:
Xp, yp, mask = pad_to_bucket(Xbig[:n], ybig[:n]) # NumPy 完成 padding
outs.append(compiled_by_bucket[Xp.shape[0]](w0_np, Xp, yp, mask))
jax.block_until_ready(outs)
print(f"服务 {len(lengths)} 个请求期间发生的编译:{c_serve.count} 次")
assert c_serve.count == 0
1.6 编译预算工具¶
⚪ 选读:要把「最多编译几次」写进单元测试或 CI 时
前面的诊断都是「出了问题再查」。更好的做法是把「这个函数最多编译几次」写成断言,放进训练脚本或单元测试。以后谁改代码引入了多余的编译,马上就会报警。下面基于 CompileCounter 实现两种用法:
- 上下文管理器
CompileBudget(max_compiles, name=None, on_exceed="raise"):统计with块内的编译,退出时检查; - 装饰器
@compile_budget(max_compiles):套在 jit 函数外面,累计它整个生命周期的编译次数,每次调用后检查。
on_exceed="raise" 会抛出 CompileBudgetExceeded(适合测试),"warn" 只发警告(适合线上)。报错信息里带着每次编译的函数名和耗时。
class CompileBudgetExceeded(RuntimeError):
pass
class CompileBudget(CompileCounter):
"""编译预算:超过 max_compiles 次就报错(或警告)。"""
def __init__(self, max_compiles, name=None, on_exceed="raise"):
super().__init__(name)
self.max_compiles = max_compiles
self.on_exceed = on_exceed
def check(self):
if self.count > self.max_compiles:
detail = ", ".join(f"{fn}({d * 1e3:.0f}ms)" for fn, d in self.events)
msg = (f"编译预算超支:{self.name or '全部函数'} 允许 {self.max_compiles} 次,"
f"实际 {self.count} 次 → {detail}")
if self.on_exceed == "raise":
raise CompileBudgetExceeded(msg)
warnings.warn(msg, RuntimeWarning, stacklevel=3)
def __exit__(self, exc_type, exc, tb):
super().__exit__(exc_type, exc, tb)
if exc_type is None: # with 块本身出错时不再叠加预算错误
self.check()
return False
def compile_budget(max_compiles, on_exceed="raise"):
"""装饰 jit 函数:累计该函数的编译次数,每次调用后检查预算。"""
def deco(jitted):
budget = CompileBudget(max_compiles, jitted.__name__, on_exceed)
@functools.wraps(jitted)
def wrapper(*args, **kwargs):
monitoring.register_event_duration_secs_listener(budget._listener)
try:
out = jitted(*args, **kwargs)
finally:
monitoring.unregister_event_duration_listener(budget._listener)
budget.check()
return out
wrapper.budget = budget
return wrapper
return deco
# 用法 1:装饰器。允许 2 次编译(比如训练和评估各一种 batch 形状)
@compile_budget(2)
@jax.jit
def budget_step(w, x):
return w - 0.1 * jnp.mean(x) * w
w_ = jnp.ones(4)
for n in [32, 32, 64, 64, 32]: # 只有 2 种形状:在预算内
w_ = budget_step(w_, jnp.ones(n))
print("5 次调用、2 种形状:编译", budget_step.budget.count, "次,未超预算")
assert budget_step.budget.count == 2
try:
budget_step(w_, jnp.ones(48)) # 第 3 种形状 → 超预算
raise AssertionError("应该报错")
except CompileBudgetExceeded as e:
print("❌ 捕获到:", str(e)[:120])
# 用法 2:上下文管理器 + warn 模式
def budget_loop(p, lr): return p - lr * p
f = jax.jit(budget_loop)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
with CompileBudget(1, "budget_loop", on_exceed="warn") as b:
for lr in [0.1, 0.05, jnp.float32(0.01)]: # 混入一个强类型 → 多编译一次
f(x3, lr)
print("⚠️ 警告:", str(caught[0].message)[:110])
assert b.count == 2 and len(caught) == 1
# 用法 3:✅ 写成断言——修好之后在预算内,安静通过
with CompileBudget(1, "budget_loop"):
for lr in [0.1, 0.05, 0.01]:
f(x3, jnp.asarray(lr, jnp.float32)) # 强类型 f32[] 已在上面编译过 → 0 次
print("修复后:在预算内 ✔")
💡 实战规则¶
🟢 必读
一句话:把「编译次数」当成和「损失值」一样需要断言的指标。用
jax_explain_cache_misses找原因,用分桶、按值哈希的静态配置、模块级 jit 和强类型标量来治,最后用编译预算守住。
专题 2 数值稳定与 NaN 梯度¶
2.1 问题动机¶
⚪ 选读:NaN 如何一步污染整组参数的演示;已经知道这一点可以跳过
NaN 的麻烦在于它会传染,而且传得很快。只要一个样本的梯度是 NaN,batch 平均后整个梯度就是 NaN。更新一次参数,所有权重都变成 NaN,之后的损失全是 NaN,训练也就废了。更糟的是 JAX 默认不报错,往往要过几百步、看到 loss 曲线断了才发现。
float32 的范围很窄。exp(x) 在 $x>88.72$ 时就上溢成 inf。另一头,本环境的 JAX(CPU 后端)会把非规格化数直接冲成 0,所以 $x<-87.3$ 左右,exp(x) 就已经是 0 了(NumPy 保留非规格化数,要到 $x<-103.3$ 才下溢成 0)。
→ 基础见 第 3 章 3.8(双
where、logsumexp、custom_jvp的 softplus 例子、check_grads)、第 2 章 2.4 补充(jnp.where的 NaN 梯度)、第 4 章 4.5 补充(距离矩阵中的sqrt)。
# 一个 NaN 样本让整组参数一步报废
def per_sample_loss(w, x):
return jnp.sqrt(jnp.sum((w * x) ** 2)) # ‖w ⊙ x‖:x 全为 0 时梯度是 0/0
w_nan = jnp.ones(4)
X_nan = jnp.stack([jnp.ones(4), 2 * jnp.ones(4), jnp.zeros(4)]) # 第 3 个样本全为 0
g = jax.grad(lambda w: jnp.mean(jax.vmap(per_sample_loss, (None, 0))(w, X_nan)))(w_nan)
w_after = w_nan - 0.1 * g
print("float32 exp 的边界:exp(88.7) =", jnp.exp(88.7), "| exp(89) =", jnp.exp(89.0),
"| exp(-87) =", jnp.exp(-87.0), "| exp(-88) =", jnp.exp(-88.0), "| NumPy exp(-100) =", np.exp(np.float32(-100.0)))
assert float(jnp.exp(-88.0)) == 0.0 and float(jnp.exp(-87.0)) > 0 and np.exp(np.float32(-100.0)) > 0 # JAX 冲零,NumPy 保留非规格化数
print("batch 梯度:", g)
print("更新一步后的参数:", w_after)
assert bool(jnp.all(jnp.isnan(w_after)))
2.2 双 where:原理和通用写法¶
🟢 必读
原理见 第 3 章 3.8 补充「NaN 梯度与双 where 技巧」。where 只挡住了前向的值。反向时,没被选中的分支仍然要乘上它在危险点的导数,而 $0\times\infty=\text{NaN}$。修法是先用第一个 where 把危险输入换成安全值,再用第二个 where 选输出:
x_safe = jnp.where(ok, x, SAFE) # 第 1 个 where:危险输入 → 安全值
y = jnp.where(ok, f(x_safe), FILL) # 第 2 个 where:选择输出
下面把它写成一个通用函数,用到实战里常见的 4 个危险函数上,逐一对比 ❌ / ✅,并和解析导数对拍。
def double_where(fn, x, ok, safe_value, fill):
"""在 ok 为 False 的位置返回 fill,且保证那里的梯度是有限值。"""
x_safe = jnp.where(ok, x, safe_value)
return jnp.where(ok, fn(x_safe), fill)
# ---- 4 个危险函数:❌ 单 where 版 与 ✅ 双 where 版 ----
def norm_bad(v): # 向量范数,零向量处 0/0
return jnp.linalg.norm(v)
def norm_good(v):
sq = jnp.sum(v ** 2)
return double_where(jnp.sqrt, sq, sq > 0, 1.0, 0.0)
def sinc_bad(x): # sin(x)/x,x=0 处补 1
return jnp.where(x != 0, jnp.sin(x) / x, 1.0)
def sinc_good(x):
return double_where(lambda t: jnp.sin(t) / t, x, x != 0, 1.0, 1.0)
def entropy_bad(p): # -Σ p log p,p=0 处约定为 0
return -jnp.sum(jnp.where(p > 0, p * jnp.log(p), 0.0))
def entropy_good(p):
return -jnp.sum(double_where(lambda t: t * jnp.log(t), p, p > 0, 1.0, 0.0))
def logit_bad(p): # log(p/(1-p)),p∈{0,1} 时截断到 ±20
return jnp.where((p > 0) & (p < 1), jnp.log(p / (1 - p)), 20.0 * jnp.sign(p - 0.5))
def logit_good(p):
ok = (p > 0) & (p < 1)
return double_where(lambda t: jnp.log(t / (1 - t)), p, ok, 0.5, 20.0 * jnp.sign(p - 0.5))
cases = [
("范数 ‖v‖ @ v=0", norm_bad, norm_good, jnp.zeros(3)),
("sinc @ x=0", sinc_bad, sinc_good, jnp.array(0.0)),
("熵 @ p=[0, .5, .5]", entropy_bad, entropy_good, jnp.array([0.0, 0.5, 0.5])),
("logit @ p=1", logit_bad, logit_good, jnp.array(1.0)),
]
print(wpad("函数 @ 危险点", 24) + wpad("前向(❌/✅)", 22) + "梯度 ❌ → 梯度 ✅")
for name, bad, good, x in cases:
gb, gg = jax.grad(bad)(x), jax.grad(good)(x)
print(wpad(name, 24) + wpad(f"{float(bad(x)):.3f} / {float(good(x)):.3f}", 22) + f"{gb} → {gg}")
assert np.allclose(bad(x), good(x)) # 前向值完全相同
assert not bool(jnp.all(jnp.isfinite(gb))) # ❌ 梯度里有 NaN
assert bool(jnp.all(jnp.isfinite(gg))) # ✅ 梯度全部有限
# 在正常点上,✅ 版本的梯度必须等于解析导数(NumPy float64 计算)
v, x, p, q = np.array([3.0, 4.0, 12.0]), 0.7, np.array([0.2, 0.3, 0.5]), 0.3
assert np.allclose(jax.grad(norm_good)(jnp.array(v)), v / np.linalg.norm(v), atol=1e-6)
assert np.allclose(jax.grad(sinc_good)(x), (x * np.cos(x) - np.sin(x)) / x ** 2, atol=1e-6)
assert np.allclose(jax.grad(entropy_good)(jnp.array(p)), -(np.log(p) + 1), atol=1e-6)
assert np.allclose(jax.grad(logit_good)(q), 1 / (q * (1 - q)), atol=1e-4)
print("\n正常点上的梯度与解析导数一致 ✔")
安全值怎么选:只要 fn 和它的导数在这一点是有限的就行,具体是多少无所谓(反正结果会被第二个 where 丢掉)。log、sqrt 用 1.0;logit 的定义域是 $(0,1)$,要用 0.5。选一个落在定义域里面的值就可以。
2.3 logsumexp / softmax / log_softmax 的稳定写法¶
🟢 必读
原理:对任意常数 $m$,有 $\log\sum_i e^{x_i} = m + \log\sum_i e^{x_i - m}$。取 $m=\max_i x_i$ 后,指数的参数都 $\le 0$,最大的那一项正好是 $e^0=1$,所以既不会上溢,求和结果也不会下溢成 0。
工程上还有三个细节:
- 对 $m$ 加
lax.stop_gradient。数学上 $m$ 对结果的梯度正好相互抵消,停掉它能省掉一段反向计算,出现并列最大值时也更干净(jax.nn.logsumexp就是这么做的); - 整行都是
-inf(全被 mask 掉)时,$m=-\infty$,$x-m$ 变成-inf - (-inf) = NaN,所以要把非有限的 $m$ 换成 0; - 交叉熵要用
log_softmax,不要用log(softmax):softmax 里很小的概率会下溢成 0,再取 log 就得到-inf。
下面手写这三个函数,和朴素写法、jax.nn 以及 NumPy float64 参考值对比。
def logsumexp_stable(x, axis=-1):
m = jnp.max(x, axis=axis, keepdims=True)
m = lax.stop_gradient(jnp.where(jnp.isfinite(m), m, 0.0)) # 细节 1、2
return jnp.squeeze(m, axis) + jnp.log(jnp.sum(jnp.exp(x - m), axis=axis))
def log_softmax_stable(x, axis=-1):
return x - jnp.expand_dims(logsumexp_stable(x, axis), axis)
def softmax_stable(x, axis=-1):
m = lax.stop_gradient(jnp.max(x, axis=axis, keepdims=True))
e = jnp.exp(x - m)
return e / jnp.sum(e, axis=axis, keepdims=True)
# ❌ 朴素写法
def logsumexp_naive(x): return jnp.log(jnp.sum(jnp.exp(x), axis=-1))
def softmax_naive(x): return jnp.exp(x) / jnp.sum(jnp.exp(x), axis=-1, keepdims=True)
def log_softmax_naive(x): return jnp.log(softmax_naive(x))
def np_log_softmax(x): # NumPy float64 参考
x = np.asarray(x, np.float64)
m = x.max(-1, keepdims=True)
return x - (m + np.log(np.exp(x - m).sum(-1, keepdims=True)))
logits = jnp.array([[1000.0, 0.0, -1000.0], # 上溢
[-1000.0, -1001.0, -1002.0], # 全部下溢
[0.0, -120.0, 3.0], # 有一个概率极小
[1.0, 2.0, 3.0]]) # 普通情况
ref = np_log_softmax(logits)
print("朴素 log(softmax):\n", log_softmax_naive(logits))
print("稳定 log_softmax:\n", log_softmax_stable(logits))
print("朴素 logsumexp:", logsumexp_naive(logits), "\n稳定 logsumexp:", logsumexp_stable(logits))
assert np.allclose(log_softmax_stable(logits), ref, atol=1e-4)
assert np.allclose(log_softmax_stable(logits), jax.nn.log_softmax(logits), atol=1e-5)
assert np.allclose(softmax_stable(logits), np.exp(ref), atol=1e-6)
assert np.allclose(logsumexp_stable(logits), jax.nn.logsumexp(logits, axis=-1), atol=1e-4)
assert not bool(jnp.all(jnp.isfinite(log_softmax_naive(logits))))
print("稳定版与 NumPy float64、jax.nn 一致 ✔")
交叉熵和它的梯度。交叉熵 $\ell = -\log p_y = -\text{log\_softmax}(z)_y$,它对 logits 的梯度有个很干净的解析式:$\partial\ell/\partial z = \text{softmax}(z) - \text{onehot}(y)$。用 log(softmax) 写的版本,在正确类别的概率下溢时,损失是 inf,梯度是 NaN。稳定版在这种情况下损失很大但有限,梯度也是对的。
def ce_naive(z, y):
return -jnp.mean(jnp.take_along_axis(log_softmax_naive(z), y[:, None], axis=1))
def ce_stable(z, y):
return -jnp.mean(jnp.take_along_axis(log_softmax_stable(z), y[:, None], axis=1))
z = jnp.array([[0.0, -120.0, 3.0], [1.0, 2.0, 3.0]])
y = jnp.array([1, 2]) # 第 1 个样本的正确类别概率约为 e^-123
for name, fn in [("❌ log(softmax)", ce_naive), ("✅ log_softmax", ce_stable)]:
loss, gz = jax.value_and_grad(fn)(z, y)
print(f"{name}: loss = {float(loss):.4f},梯度有限 = {bool(jnp.all(jnp.isfinite(gz)))}")
loss, gz = jax.value_and_grad(ce_stable)(z, y)
ref_g = (np.exp(np_log_softmax(z)) - np.eye(3)[np.asarray(y)]) / len(y) # (softmax - onehot) / B
ref_loss = -np.mean(np_log_softmax(z)[np.arange(2), np.asarray(y)])
assert np.allclose(gz, ref_g, atol=1e-6) and np.isclose(float(loss), ref_loss, rtol=1e-5)
assert not np.isfinite(float(ce_naive(z, y)))
print("梯度 = (softmax − onehot)/B,与 NumPy float64 一致 ✔")
mask 与 -inf 的坑。做 attention 或变长分类时,常把无效位置的 logits 设成 -inf。只要有一整行全被 mask(比如 padding 出来的样本),这一行的 softmax / log_softmax 就是 -inf - (-inf) = NaN(jax.nn 的实现也一样)。它会不会污染整个 batch,要看你怎么屏蔽这一行:
- 用浮点权重相乘(
nll * row_weight,row_weight是 0./1.):NaN × 0 = NaN,整个 batch 的 loss 都变成 NaN; - 用
jnp.where(row_mask, nll, 0):loss 侥幸没事,但这一行的概率本身还是 NaN。如果它还要参与后面的计算(比如 attention 的probs @ V),NaN 会继续往下传。
✅ 根治的办法是用一个很大的有限负数(jnp.finfo(dtype).min)代替 -inf。这样全被 mask 的行得到的是均匀分布(无害),再用 mask 把它排除出 loss。
NEG = jnp.finfo(jnp.float32).min
def masked_ce(z, y, valid_cols, row_weight, fill):
z = jnp.where(valid_cols, z, fill) # 屏蔽无效类别
logp = jax.nn.log_softmax(z)
nll = -jnp.take_along_axis(logp, y[:, None], axis=1)[:, 0]
return jnp.sum(nll * row_weight) / jnp.sum(row_weight), jax.nn.softmax(z)
zb = jnp.array([[2.0, 1.0, 0.5], [0.3, 0.2, 0.1]])
yb = jnp.array([0, 0])
valid_cols = jnp.array([[True, True, False], [False, False, False]]) # 第 2 行是 padding:全被 mask
row_weight = jnp.array([1.0, 0.0]) # 浮点权重:padding 行为 0
out = {}
for name, fill in [("❌ 填 -inf", -jnp.inf), ("✅ 填 finfo.min", NEG)]:
(loss, probs), gz = jax.value_and_grad(masked_ce, has_aux=True)(zb, yb, valid_cols, row_weight, fill)
out[name] = (loss, probs, gz)
print(f"{name}: loss = {float(loss)},梯度有限 = {bool(jnp.all(jnp.isfinite(gz)))},"
f"padding 行的概率 = {probs[1]}")
loss_ok, probs_ok, g_ok = out["✅ 填 finfo.min"]
ref = -np_log_softmax(np.array([2.0, 1.0]))[0] # 只有第 1 行、前两个类别有效
assert np.isclose(float(loss_ok), ref, rtol=1e-6) and bool(jnp.all(jnp.isfinite(g_ok)))
assert np.allclose(probs_ok[1], 1 / 3) # 全 mask 行 → 均匀分布,无害
assert np.isnan(float(out["❌ 填 -inf"][0])) and bool(jnp.all(jnp.isnan(out["❌ 填 -inf"][1][1])))
2.4 用 jax_debug_nans 定位 NaN(0.12 实测行为)¶
🟢 必读
打开 jax.config.update("jax_debug_nans", True) 后,JAX 会检查计算结果里有没有 NaN。0.12 里的具体行为如下(后面逐条验证):
| 场景 | 行为 |
|---|---|
| eager(未 jit) | 每个原语执行后都检查,第一个产生 NaN 的原语处立刻抛 FloatingPointError |
| jit 函数输出含 NaN | 先打印 Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.,然后逐原语重跑(de-optimized),在产生 NaN 的那一行抛错 |
| 反向传播产生 NaN | 同样重跑并定位到反向计算中的原语(这里是 div) |
jit 内部有 NaN、但没有流到输出(比如被 where 丢掉了) | 不报错:jit 只检查输出。同一个函数在 eager 下反而会报错 |
产生的是 inf 而不是 NaN | 不报错,要另开 jax_debug_infs |
⚠️ 进程里调用过 vmap 之后,再次调用已经执行过的 jit 函数 / eager 原语 | 不再检查,NaN 悄悄通过(复现与触发条件见 进阶 08 §3.1);jax.clear_caches() 之后恢复 |
最后一条是 0.12.0.1 的已知问题。这一章前面已经用过 vmap,所以下面的 locate_nan 打开开关后,会先调用一次 jax.clear_caches(),保证检查真的生效。FloatingPointError 的 traceback 里有你自己代码的那一行,locate_nan 顺手把它找出来。
def locate_nan(fn, *args, user_funcs=()):
"""开启 jax_debug_nans 运行 fn,返回 (异常信息, 用户代码所在行)。用 finally 保证关闭开关。"""
import traceback
jax.config.update("jax_debug_nans", True)
jax.clear_caches() # 0.12 实测:进程里用过 vmap 后,不清缓存会漏检(见下一个单元)
try:
jax.block_until_ready(fn(*args))
return None, None
except FloatingPointError as e:
frames = [f for f in traceback.extract_tb(e.__traceback__) if f.name in user_funcs]
where_ = f"{frames[-1].name}(): {frames[-1].line.strip()}" if frames else "(在 JAX 生成的反向计算里)"
return str(e).splitlines()[0], where_
finally:
jax.config.update("jax_debug_nans", False)
def encoder(x):
h = x * 2.0
scale = jnp.log(h - 3.0) # h < 3 时 log(负数) = NaN
return jnp.sum(scale * h)
def masked_log(x):
return jnp.where(x > 0, jnp.log(x), 0.0) # 前向的 NaN 被 where 丢掉,不会流到输出
x_bad = jnp.array([1.0, 5.0])
tests = [
("eager", encoder, (x_bad,)),
("jit:输出含 NaN", jax.jit(encoder), (x_bad,)),
("grad(jit):反向 NaN", jax.grad(jax.jit(masked_log)), (jnp.array(0.0),)),
("jit:NaN 没到输出", jax.jit(masked_log), (jnp.array(-1.0),)),
("eager:同上函数", masked_log, (jnp.array(-1.0),)),
("jit:只有 inf", jax.jit(lambda x: 1.0 / x), (jnp.array(0.0),)),
]
results = {}
for name, fn, args in tests:
msg, loc = locate_nan(fn, *args, user_funcs={"encoder", "masked_log"})
results[name] = msg
print(f"[{name}]\n {'未报错' if msg is None else 'FloatingPointError: ' + msg}" + (f"\n 定位到 → {loc}" if loc else ""))
assert results["eager"] and results["jit:输出含 NaN"] and results["grad(jit):反向 NaN"]
assert results["jit:NaN 没到输出"] is None and results["eager:同上函数"] is not None
assert results["jit:只有 inf"] is None
assert jax.config.jax_debug_nans is False # 开关已经恢复
⚠️ 0.12.0.1 的漏检问题:用 jax.config.update 全局打开 jax_debug_nans 时,只要进程里调用过(甚至只是追踪过)一次 vmap,之后已经执行过的 jit 函数和 eager 原语就不再检查,NaN 会悄悄放过去。新编译的计算不受影响,调用 jax.clear_caches() 之后也会恢复。用 with jax.debug_nans(True): 打开则没有这个问题。子进程复现、触发条件和规避方法见 进阶 08 §3.1 和 00 已知问题 2。
所以在 0.12.0.1 里,别指望「整个训练都开着 jax_debug_nans,出事时自动停下」。更可靠的流程是:
- 训练循环里每步在主机端检查一次 loss 是否有限(或者用下面的
checkify); - 发现 NaN 后,保存这一步的输入,在
with jax.debug_nans(True):里单独重跑这一步(如果是用config.update全局打开的,先调用jax.clear_caches()),让FloatingPointError指出到底是哪一行。
# inf 要用 jax_debug_infs 单独抓
jax.config.update("jax_debug_infs", True)
jax.clear_caches()
try:
jax.jit(lambda x: 1.0 / x)(jnp.array(0.0))
caught_inf = None
except FloatingPointError as e:
caught_inf = str(e).splitlines()[0]
finally:
jax.config.update("jax_debug_infs", False)
print("jax_debug_infs:", caught_inf)
assert caught_inf is not None
jax_debug_nans 是全局开关,适合调试时临时打开。如果想在生产代码里对某个函数做 NaN 检查,可以用 jax.experimental.checkify。它把检查编译进程序,返回一个错误对象,什么时候抛出由你决定。它能和 jit 组合,而且不受上面 vmap 问题的影响(下面就是在已经用过 vmap 的这个进程里跑的)。下面第二次调用同一个已编译的函数,检查照样生效。
from jax.experimental import checkify
checked = jax.jit(checkify.checkify(encoder, errors=checkify.float_checks))
err, out = checked(x_bad)
print("checkify 报告:", err.get())
err_ok, _ = checked(jnp.array([3.0, 5.0]))
print("正常输入:", err_ok.get())
err2, _ = checked(x_bad) # 命中缓存的第二次调用
print("再次传入坏输入:", err2.get())
assert err.get() is not None and err_ok.get() is None and err2.get() is not None
try:
err.throw()
except Exception as e:
print(f"err.throw() → {type(e).__name__}")
2.5 用 custom_jvp 修复梯度¶
⚪ 选读:需要自定义导数规则或直通估计(量化训练)时
有时一个函数自动求出来的导数在某些点上没法用,但我们知道它的导数应该是什么。这时就用 custom_jvp 直接告诉 JAX(→ 第 3 章 3.8 已经用 softplus 演示过基本用法)。这里讲两个实战要点:
- ❌ 自定义规则本身也要安全:最常见的错误是把导数公式原样写进规则。比如范数的导数 $x/\lVert x\rVert$,在零点照样得到
0/0。规则里同样要用双where; - 直通估计(straight-through estimator):
round、sign这类函数的导数几乎处处为 0。量化训练里需要「前向取整、反向当作恒等」,用custom_jvp一行就能写出来。
from jax.test_util import check_grads # → 第 3 章 3.8:必须显式导入
# ❌ 规则直接照抄公式
@jax.custom_jvp
def norm_cj_bad(v):
return jnp.sqrt(jnp.sum(v ** 2))
@norm_cj_bad.defjvp
def _norm_bad_jvp(primals, tangents):
(v,), (t,) = primals, tangents
n = norm_cj_bad(v)
return n, jnp.dot(v / n, t) # 零向量处 v/n = 0/0
# ✅ 规则内部也用双 where
@jax.custom_jvp
def norm_cj(v):
return jnp.sqrt(jnp.sum(v ** 2))
@norm_cj.defjvp
def _norm_jvp(primals, tangents):
(v,), (t,) = primals, tangents
n = norm_cj(v)
ok = n > 0
unit = v / jnp.where(ok, n, 1.0) # 零向量处 unit = 0(次梯度取 0)
return n, jnp.dot(jnp.where(ok, unit, 0.0), t)
zero = jnp.zeros(3)
print("❌ 规则照抄公式,零点梯度:", jax.grad(norm_cj_bad)(zero))
print("✅ 规则内用双 where,零点梯度:", jax.grad(norm_cj)(zero))
assert bool(jnp.all(jnp.isnan(jax.grad(norm_cj_bad)(zero))))
assert bool(jnp.all(jax.grad(norm_cj)(zero) == 0))
# 非零点:有限差分检验(前向、反向都查);还能和 jit / vmap 组合
check_grads(norm_cj, (jnp.array([3.0, -4.0, 1.0]),), order=1, modes=["fwd", "rev"])
batch = jnp.array([[3.0, 4.0], [0.0, 0.0], [1.0, 0.0]])
gb = jax.jit(jax.vmap(jax.grad(norm_cj)))(batch)
print("vmap + jit 下逐行梯度:\n", gb)
assert np.allclose(gb, [[0.6, 0.8], [0.0, 0.0], [1.0, 0.0]])
# 直通估计:前向 round,反向当作恒等
@jax.custom_jvp
def round_ste(x):
return jnp.round(x)
@round_ste.defjvp
def _round_ste_jvp(primals, tangents):
(x,), (t,) = primals, tangents
return round_ste(x), t # 导数当作 1
def quant_loss(w, x, target):
wq = round_ste(w * 4.0) / 4.0 # 量化到 0.25 的整数倍
return jnp.sum((wq * x - target) ** 2)
w_q = jnp.array([0.3, -0.7])
x_q, t_q = jnp.array([1.0, 2.0]), jnp.array([0.5, 0.5])
g_plain = jax.grad(lambda w: jnp.sum((jnp.round(w * 4) / 4 * x_q - t_q) ** 2))(w_q)
g_ste = jax.grad(quant_loss)(w_q, x_q, t_q)
wq = np.round(np.asarray(w_q, np.float64) * 4) / 4
ref = 2 * (wq * np.asarray(x_q) - np.asarray(t_q)) * np.asarray(x_q) # 把 round 的导数当作 1 的解析梯度
print("普通 round 的梯度:", g_plain, "(处处为 0,训练不动)")
print("直通估计的梯度 :", g_ste)
assert bool(jnp.all(g_plain == 0)) and np.allclose(g_ste, ref)
💡 实战规则¶
🟢 必读
一句话:凡是
where/mask 里藏着log、sqrt、除法,就用双where;分类损失一律用log_softmax+ 有限的 mask 值;训练时用主机端的isfinite检查或checkify发现 NaN,再对出事的那一步打开jax_debug_nans+jax.clear_caches()重跑定位。记住jax_debug_nans只查 jit 的输出、不查 inf,0.12 里用过 vmap 后还会漏检。
专题 3 jacfwd / jacrev / jvp / vjp 选型¶
3.1 问题动机¶
⚪ 选读:求导 API 的典型应用场景举例
求导 API 选错了,结果不会错,但速度可能差上百倍,内存可能差 $n$ 倍。常见场景有:物理仿真里求「少量参数 → 大量输出」的灵敏度,优化里求 Hessian 或 Hessian-向量积,对抗样本里求输入梯度,隐式层里解线性方程组。每种场景该用哪个 API,要看输入维度 $n$、输出维度 $m$,以及你到底需要 $J$ 的哪一部分。
→ 基础见 第 3 章 3.3 补充(
jvp/vjp与两种模式、jacfwd/jacrev的决策规则与初步计时)、3.5 补充(jax.hessian=jacfwd(jacrev))、练习 C(HVP 的两种写法);第 4 章补充练习 4(批量雅可比)。
3.2 原理与决策表¶
🟢 必读
设 $f:\mathbb R^n\to\mathbb R^m$,记一次前向计算的代价为 $C$。
- 前向模式
jvp:一次得到 $Jv$($J$ 的一个列组合),代价约 $2C\sim3C$,不需要保存中间值; - 反向模式
vjp:一次得到 $u^\top J$($J$ 的一个行组合),代价约 $2C\sim4C$,但要保存前向的中间值(内存随计算深度增长); jacfwd= 对 $n$ 个基向量做vmap(jvp),jacrev= 对 $m$ 个基向量做vmap(vjp)。
| 你需要什么 | 选用 | 代价 | 备注 |
|---|---|---|---|
| 标量损失的梯度($m=1$) | grad / value_and_grad | 约 1 次 VJP | 训练的默认选择 |
| 单个方向导数 $Jv$ | jvp | 约 1 次 JVP | 不要先构造 $J$ 再乘 $v$ |
| 同一点上的多个 $Jv_i$ | jax.linearize 或 vmap(jvp) | 线性化只做一次 | linearize 返回可重复调用的线性函数 |
| 单个 $u^\top J$ | vjp | 约 1 次 VJP | vjp_fn 可以重复调用 |
| 完整 $J$,$n \lesssim m$(高瘦) | jacfwd | $n$ 次 JVP | 例如 4 个参数 → 1000 个输出 |
| 完整 $J$,$n \gtrsim m$(矮胖) | jacrev | $m$ 次 VJP | 例如 1000 个参数 → 4 个输出 |
| 完整 Hessian($n$ 小) | jax.hessian | $n$ 次 HVP | 内存 $O(n^2)$ |
| Hessian-向量积 $Hv$ | jvp(grad(f))(前向套反向) | 约 1 次 HVP | 牛顿-CG、曲率估计、影响函数 |
| 每个样本一个雅可比 | vmap(jacfwd/jacrev) | 同上 × B | → 第 4 章补充练习 4 |
下面用实验把表里的「$n$ 与 $m$ 谁大选谁」量化出来。
3.3 计时实验:扫描输入/输出维度¶
⚪ 选读:想亲眼看到 jacfwd / jacrev 随维度变化的计时曲线时
函数取 $f(x)=\tanh(Ax)$,$A\in\mathbb R^{m\times n}$。它的雅可比有解析式 $J=\mathrm{diag}(1-\tanh^2(Ax))\,A$,可以用 NumPy float64 对拍。按前面的约定,$A$ 作为参数传入,不让闭包捕获。
理论上,jacfwd 把 $n$ 个 JVP 批处理成一次 $(m\times n)\cdot(n\times n)$ 的矩阵乘,大约 $mn^2$ 次乘加。jacrev 是 $(m\times m)\cdot(m\times n)$,大约 $m^2n$ 次。两者之比是 $n/m$。
def tanh_layer(x, A):
return jnp.tanh(A @ x)
def jac_fwd(x, A): return jax.jacfwd(tanh_layer)(x, A) # 只对 x 求雅可比
def jac_rev(x, A): return jax.jacrev(tanh_layer)(x, A)
jac_fwd_jit, jac_rev_jit = jax.jit(jac_fwd), jax.jit(jac_rev)
def np_jac(x, A):
x, A = to64(x), to64(A)
return (1 - np.tanh(A @ x) ** 2)[:, None] * A
dims = [4, 16, 64, 256, 1024, 2048]
FIXED = 256
timing = {"vary_m": {"fwd": [], "rev": []}, "vary_n": {"fwd": [], "rev": []}}
key = jax.random.key(3)
for d in dims:
for sweep, (n, m) in [("vary_m", (FIXED, d)), ("vary_n", (d, FIXED))]:
key, ka, kx = jax.random.split(key, 3)
A = jax.random.normal(ka, (m, n)) / np.sqrt(n)
x = jax.random.normal(kx, (n,))
timing[sweep]["fwd"].append(bench(jac_fwd_jit, x, A, runs=5))
timing[sweep]["rev"].append(bench(jac_rev_jit, x, A, runs=5))
if d in (4, 256): # 抽查几组:两种方法都与解析解一致
assert np.allclose(jac_fwd_jit(x, A), np_jac(x, A), atol=1e-5)
assert np.allclose(jac_rev_jit(x, A), np_jac(x, A), atol=1e-5)
print(wpad("", 16) + "".join(f"{d:>9}" for d in dims))
for sweep, label in [("vary_m", f"n={FIXED}, m=d"), ("vary_n", f"m={FIXED}, n=d")]:
for mode in ("fwd", "rev"):
print(wpad(f"{label} {mode}", 16) + "".join(f"{t:9.3f}" for t in timing[sweep][mode]))
print("(单位 ms)")
fig, axes = plt.subplots(1, 2, figsize=(10, 3.8), sharey=True)
for ax, sweep, xlabel, title in [
(axes[0], "vary_m", "输出维度 m", f"固定 n={FIXED},增大输出维度"),
(axes[1], "vary_n", "输入维度 n", f"固定 m={FIXED},增大输入维度")]:
ax.plot(dims, timing[sweep]["fwd"], "o-", color=C_BLUE, lw=2, ms=6, label="jacfwd")
ax.plot(dims, timing[sweep]["rev"], "s-", color=C_ORANGE, lw=2, ms=6, label="jacrev")
ax.axvline(FIXED, color="#999999", lw=1, ls=":")
ax.text(FIXED * 1.08, 0.04, "n = m", transform=ax.get_xaxis_transform(), color="#666666", fontsize=9)
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel(xlabel); ax.set_title(title, fontsize=11)
ax.grid(True, which="major", color="#e5e5e5", lw=0.8)
ax.legend(frameon=False)
axes[0].set_ylabel("耗时 (ms,对数刻度)")
fig.suptitle("f(x) = tanh(Ax) 的完整雅可比:jacfwd vs jacrev", fontsize=12)
plt.tight_layout(); plt.show()
# 在维度差距最大的两端,结论应该非常明确(理论差距 2048/256 = 8 倍,这里只断言 1.5 倍以防机器抖动)
assert timing["vary_m"]["fwd"][-1] * 1.5 < timing["vary_m"]["rev"][-1] # n=256 ≪ m=2048:jacfwd 快
assert timing["vary_n"]["rev"][-1] * 1.5 < timing["vary_n"]["fwd"][-1] # n=2048 ≫ m=256:jacrev 快
读图:两条曲线在 $n=m$ 附近交叉。左图里输出越多,jacrev 越吃亏;右图里输入越多,jacfwd 越吃亏。维度很小时,耗时都在 0.1 ms 量级,主要是固定开销,所以实际差距远小于理论比值 $n/m$(比如 $n=256,\ m=4$ 时,理论上 jacrev 快 64 倍)。
3.4 ❌ 常见错误:只需要 $Jv$ 却先构造 $J$¶
🟢 必读
最常见的浪费是写成 jax.jacfwd(f)(x) @ v:先花 $n$ 次 JVP 构造整个 $J$,再乘一个向量。其实一次 jvp 就够了。在同一点上要乘很多个向量时,用 jax.linearize 只做一次线性化,之后每次调用只执行线性部分。
n = m = 1024
ka, kx, kv = jax.random.split(jax.random.key(4), 3)
A = jax.random.normal(ka, (m, n)) / np.sqrt(n)
x = jax.random.normal(kx, (n,))
V = jax.random.normal(kv, (8, n)) # 8 个方向
jv_bad = jax.jit(lambda x, A, v: jax.jacfwd(tanh_layer)(x, A) @ v) # ❌
jv_good = jax.jit(lambda x, A, v: jax.jvp(lambda x: tanh_layer(x, A), (x,), (v,))[1]) # ✅
def jv_many_linearize(x, A, V): # ✅ 多个方向:线性化一次,再 vmap
_, f_lin = jax.linearize(lambda x: tanh_layer(x, A), x)
return jax.vmap(f_lin)(V)
jv_many = jax.jit(jv_many_linearize)
t_bad, t_good = bench(jv_bad, x, A, V[0]), bench(jv_good, x, A, V[0])
t_many = bench(jv_many, x, A, V)
print(f"❌ jacfwd(f)(x) @ v :{t_bad:8.3f} ms")
print(f"✅ jvp :{t_good:8.3f} ms (快 {t_bad / t_good:.0f} 倍)")
print(f"✅ linearize + vmap,8 个方向:{t_many:.3f} ms")
ref = np_jac(x, A) @ to64(V).T
assert np.allclose(jv_good(x, A, V[0]), ref[:, 0], atol=1e-4)
assert np.allclose(jv_bad(x, A, V[0]), ref[:, 0], atol=1e-4)
assert np.allclose(jv_many(x, A, V), ref.T, atol=1e-4)
assert_faster(lambda: bench(jv_good, x, A, V[0]), lambda: bench(jv_bad, x, A, V[0]), factor=5) # 负载高时自动重测
3.5 Hessian-向量积(HVP)的三种实现¶
⚪ 选读:做牛顿-CG、曲率估计等二阶方法时
很多二阶方法只需要 $Hv$,不需要完整的 $H$。$Hv$ 是梯度 $\nabla f$ 沿 $v$ 的方向导数,把前向和反向两两组合,有三种写法:
| 写法 | 代码 | 思路 |
|---|---|---|
| 前向套反向(forward-over-reverse) | jvp(grad(f), (x,), (v,)) | 对梯度函数做一次 JVP |
| 反向套反向(reverse-over-reverse) | grad(lambda x: vdot(grad(f)(x), v)) | 先算 $g\cdot v$ 这个标量,再求梯度 |
| 反向套前向(reverse-over-forward) | grad(lambda x: jvp(f, (x,), (v,))[1]) | 先算方向导数 $\nabla f\cdot v$,再求梯度 |
JAX 官方文档推荐第一种。前向模式不需要额外保存中间值,所以内存最省,通常也最快。下面实际比较三者的结果、速度和编译后的临时内存(memory_analysis().temp_size_in_bytes),再和「先构造完整 Hessian 再乘 $v$」对比。
测试函数取逻辑回归式的损失 $f(x)=\sum_i \mathrm{softplus}(a_i^\top x) + \tfrac{\lambda}{2}\lVert x\rVert^2$。它的 Hessian 有解析式 $H=A^\top\mathrm{diag}(\sigma(Ax)(1-\sigma(Ax)))A+\lambda I$,用 NumPy float64 算参考值。
LAM = 0.1
def logistic_obj(x, A):
return jnp.sum(jax.nn.softplus(A @ x)) + 0.5 * LAM * jnp.sum(x ** 2)
def hvp_fwd_rev(x, v, A):
return jax.jvp(lambda x: jax.grad(logistic_obj)(x, A), (x,), (v,))[1]
def hvp_rev_rev(x, v, A):
return jax.grad(lambda x: jnp.vdot(jax.grad(logistic_obj)(x, A), v))(x)
def hvp_rev_fwd(x, v, A):
return jax.grad(lambda x: jax.jvp(lambda x: logistic_obj(x, A), (x,), (v,))[1])(x)
def hvp_full(x, v, A):
return jax.hessian(logistic_obj)(x, A) @ v
def np_hvp(x, v, A):
x, v, A = to64(x), to64(v), to64(A)
s = 1 / (1 + np.exp(-(A @ x)))
return A.T @ (s * (1 - s) * (A @ v)) + LAM * v
methods = {"前向套反向": hvp_fwd_rev, "反向套反向": hvp_rev_rev, "反向套前向": hvp_rev_fwd, "完整 Hessian @ v": hvp_full}
jitted = {k: jax.jit(f) for k, f in methods.items()}
M_ROWS = 512
ns = [128, 512, 2048]
hvp_time = {k: [] for k in methods}
hvp_mem = {k: [] for k in methods}
for n in ns:
ka, kx, kv = jax.random.split(jax.random.key(n), 3)
A = jax.random.normal(ka, (M_ROWS, n)) / np.sqrt(n)
x = jax.random.normal(kx, (n,))
v = jax.random.normal(kv, (n,))
ref = np_hvp(x, v, A)
for name, f in jitted.items():
out = f(x, v, A)
assert np.allclose(out, ref, rtol=1e-4, atol=1e-4), (name, n) # 四种方法都与解析解一致
hvp_time[name].append(bench(f, x, v, A, runs=5))
hvp_mem[name].append(f.lower(x, v, A).compile().memory_analysis().temp_size_in_bytes / 1024)
print(wpad("方法", 20) + "".join(f" n={n:<6}" for n in ns) + " | 临时内存 KiB(同序)")
for name in methods:
print(wpad(name, 20) + "".join(f"{t:9.3f}ms" for t in hvp_time[name]) + " | "
+ " ".join(f"{m:9.1f}" for m in hvp_mem[name]))
print("三种 HVP 与完整 Hessian 的结果、以及 NumPy float64 解析解全部一致 ✔")
fig, axes = plt.subplots(1, 2, figsize=(10, 3.8))
colors = [C_BLUE, C_ORANGE, C_AQUA, "#888888"]
markers = ["o", "s", "^", "x"]
for (name, c, mk) in zip(methods, colors, markers):
ls = ":" if name.startswith("完整") else "-"
axes[0].plot(ns, hvp_time[name], marker=mk, ls=ls, color=c, lw=2, ms=6, label=name)
axes[1].plot(ns, np.maximum(hvp_mem[name], 1e-1), marker=mk, ls=ls, color=c, lw=2, ms=6, label=name)
for ax, yl, t in [(axes[0], "耗时 (ms)", "速度"), (axes[1], "编译后临时内存 (KiB)", "内存")]:
ax.set_xscale("log"); ax.set_yscale("log"); ax.set_xlabel("参数维度 n")
ax.minorticks_off(); ax.set_xticks(ns, [str(n) for n in ns])
ax.set_ylabel(yl); ax.set_title(f"HVP 的{t}(A 为 {M_ROWS}×n)", fontsize=11)
ax.grid(True, color="#e5e5e5", lw=0.8)
axes[0].legend(frameon=False, fontsize=9)
plt.tight_layout(); plt.show()
# 完整 Hessian 的内存按 n² 增长,三种 HVP 都远小于它
for name in ("前向套反向", "反向套反向", "反向套前向"):
assert hvp_mem[name][-1] * 10 < hvp_mem["完整 Hessian @ v"][-1]
怎么读:三种 HVP 的结果完全一致,速度在同一个量级,编译后的临时内存也完全相同(右图三条线重合)。这是因为这个函数结构简单,XLA 能把三种写法优化得很接近。在更深的网络上差距可能会拉开,因为「反向套反向」通常要多保存一层中间值。真正的分水岭是构造完整 Hessian:它要做 $n$ 次 HVP,内存按 $n^2$ 增长。在 $n=2048$ 时,它比 HVP 慢一到两个数量级,临时内存大三个数量级。
💡 实战规则¶
🟢 必读
一句话:需要完整雅可比时比较 $n$ 和 $m$,输入少用
jacfwd,输出少用jacrev。只需要它和少数几个向量的乘积时,不要先构造矩阵,用jvp/vjp/linearize(要乘的向量多到接近 $n$ 个时,构造矩阵才可能划算)。Hessian-向量积首选jvp(grad(f))。
专题 4 逐样本梯度¶
4.1 问题动机¶
⚪ 选读:逐样本梯度的用途举例(DP-SGD、训练诊断等)
普通训练只需要「batch 平均损失的梯度」,但很多场景需要每个样本各自的梯度:
- 差分隐私训练(DP-SGD):要先把每个样本的梯度裁剪到范数 ≤ C,再求和、加噪;
- 训练诊断:梯度范数特别大的样本,往往是难样本、离群点或标注错误;
- 数据价值 / 影响函数、Fisher 信息矩阵的对角线估计等。
JAX 里一行 vmap(grad(loss)) 就能得到逐样本梯度,但真用起来要操心三件事:写法对不对(要和循环实现对拍),内存够不够($B\times P$),内存不够时怎么分块算。
→ 基础见 第 4 章 4.6 专题(逐样本梯度的原理与用途、
grad(vmap(f))为什么报错)、4.7 补充(lax.map与 vmap 的内存取舍)、补充练习 2(DP-SGD 风格的裁剪,没有加噪)。
4.2 vmap(grad) 与循环实现对拍¶
⚪ 选读:标准写法已在第 4 章 4.6 讲过,这里是与循环实现的对拍
模型用一个两层 MLP,参数是嵌套 dict,损失用专题 2 写的稳定版 log_softmax_stable。关键是单样本损失:输入一个样本 x: (d,) 和一个标签 y: (),输出一个标量。vmap 时参数不映射(in_axes=None),数据沿第 0 轴映射。
def init_mlp(key, sizes):
params = {}
for i, (k, (n_in, n_out)) in enumerate(zip(jax.random.split(key, len(sizes) - 1), zip(sizes[:-1], sizes[1:]))):
params[f"dense_{i}"] = {"w": jax.random.normal(k, (n_in, n_out)) * np.sqrt(2.0 / n_in),
"b": jnp.zeros(n_out)}
return params
def mlp_apply(params, x):
n = len(params)
for i in range(n):
layer = params[f"dense_{i}"]
x = x @ layer["w"] + layer["b"]
if i < n - 1:
x = jax.nn.relu(x)
return x
def loss_single(params, x, y):
"""单个样本的交叉熵:x 形状 (d,),y 是整数标签。"""
return -log_softmax_stable(mlp_apply(params, x))[y]
def loss_batch(params, X, Y):
return jnp.mean(jax.vmap(loss_single, in_axes=(None, 0, 0))(params, X, Y))
def tree_allclose(a, b, **kw):
return all(jax.tree.leaves(jax.tree.map(lambda u, v: bool(np.allclose(u, v, **kw)), a, b)))
D_IN, N_CLS, B = 20, 10, 256
kp, kx, ky = jax.random.split(jax.random.key(10), 3)
params_ps = init_mlp(kp, (D_IN, 64, N_CLS))
X_ps = jax.random.normal(kx, (B, D_IN))
Y_ps = jax.random.randint(ky, (B,), 0, N_CLS)
P = sum(l.size for l in jax.tree.leaves(params_ps))
print(f"参数量 P = {P},batch B = {B}")
# ✅ vmap(grad):参数不映射,数据映射
per_sample_grads = jax.jit(jax.vmap(jax.grad(loss_single), in_axes=(None, 0, 0)))
g_vmap = per_sample_grads(params_ps, X_ps, Y_ps)
print("逐样本梯度形状:", jax.tree.map(lambda l: l.shape, g_vmap))
# 参考实现:Python 循环,一个样本一个样本地求梯度
grad_one = jax.jit(jax.grad(loss_single))
def per_sample_loop(params, X, Y):
gs = [grad_one(params, X[i], Y[i]) for i in range(X.shape[0])]
return jax.tree.map(lambda *ls: jnp.stack(ls), *gs)
g_loop = per_sample_loop(params_ps, X_ps, Y_ps)
t_loop = bench(per_sample_loop, params_ps, X_ps, Y_ps, warmup=1, runs=3)
t_vmap = bench(per_sample_grads, params_ps, X_ps, Y_ps)
print(f"循环 {t_loop:.1f} ms | vmap {t_vmap:.3f} ms(快 {t_loop / t_vmap:.0f} 倍)")
assert tree_allclose(g_vmap, g_loop, rtol=1e-5, atol=1e-6) # 与循环逐元素一致
g_mean = jax.tree.map(lambda l: l.mean(0), g_vmap)
assert tree_allclose(g_mean, jax.grad(loss_batch)(params_ps, X_ps, Y_ps), atol=1e-6) # 平均 = 批损失的梯度
print("vmap(grad) 与循环实现一致;逐样本梯度的平均 = 平均损失的梯度 ✔")
4.3 ❌ 常见错误写法¶
🟢 必读
- 忘了写
in_axes:默认in_axes=0会把参数也沿第 0 轴映射(→ 第 4 章 4.4)。轻则报形状不一致,重则在形状碰巧对得上时悄悄算出错误结果; - 对批损失求
grad:得到的是 batch 平均后的一个梯度,不是逐样本梯度,形状里没有 B 这一维; - 在单样本损失里做跨样本的运算(比如对 batch 求均值的归一化):
vmap之后每个样本只能看到自己,意思就变了。
下面演示前两种。第 1 种特意构造了一个「形状碰巧对得上」的例子:batch 大小正好等于第一层的输入维度。
# ❌ 1a:默认 in_axes=0,参数也被映射 → 形状不一致直接报错
show_error(lambda: jax.vmap(jax.grad(loss_single))(params_ps, X_ps, Y_ps))
# ❌ 1b:参数的第 0 维恰好等于 batch 大小(都是 20)时,不报错,但语义完全错了
Xs, Ys = X_ps[:D_IN], Y_ps[:D_IN]
p_tiny = {"w": 0.1 * jnp.ones((D_IN, D_IN))} # 形状 (20, 20)
def loss_tiny(p, x, y):
return jnp.sum((x * p["w"]) ** 2) * (y + 1) # 逐元素乘:w 的一行和整个 w 都能广播
wrong = jax.vmap(jax.grad(loss_tiny))(p_tiny, Xs, Ys) # 静默地把 w 的第 i 行当作第 i 个样本的参数
right = jax.vmap(jax.grad(loss_tiny), in_axes=(None, 0, 0))(p_tiny, Xs, Ys)
print("❌ 默认 in_axes:没有报错,输出形状", wrong["w"].shape, "| ✅ 正确的逐样本梯度形状", right["w"].shape)
assert wrong["w"].shape == (D_IN, D_IN) and right["w"].shape == (D_IN, D_IN, D_IN)
# ❌ 2:对批损失求 grad,只有一个梯度
g_batch = jax.grad(loss_batch)(params_ps, X_ps, Y_ps)
print("❌ grad(loss_batch) 的形状:", g_batch["dense_0"]["w"].shape, "← 没有 batch 维")
assert g_batch["dense_0"]["w"].shape == (D_IN, 64)
4.4 内存:$B \times P$,以及分块计算¶
⚪ 选读:模型较大、逐样本梯度放不进内存时
逐样本梯度是一个 $B\times P$ 的张量。这里 $P\approx 2000$,看不出问题。但换成一个 1 亿参数的模型、batch 256,float32 下就要 100 GB。
很多场景其实不需要把 $B\times P$ 整个存下来:
- 训练诊断只要每个样本的梯度范数($B$ 个数);
- DP-SGD 只要「裁剪后的梯度之和」($P$ 个数)。
所以正确的做法是在块内就完成归约:每次只对 $k$ 个样本算逐样本梯度(内存 $k\times P$),马上算出范数或裁剪后求和,再处理下一块。下面对比四种计算「逐样本梯度范数」的方式,用 memory_analysis() 看编译后的输出内存和临时内存(→ 第 4 章 4.7 补充用过同样的方法)。
def per_sample_norm(params, x, y):
g = jax.grad(loss_single)(params, x, y)
return jnp.sqrt(sum(jnp.sum(l ** 2) for l in jax.tree.leaves(g)))
B_BIG, CHUNK = 4096, 256
kx, ky = jax.random.split(jax.random.key(11))
X_big = jax.random.normal(kx, (B_BIG, D_IN))
Y_big = jax.random.randint(ky, (B_BIG,), 0, N_CLS)
variants = {
"① vmap 存下 B×P 梯度,再求范数": lambda p, X, Y: jax.vmap(
lambda g: jnp.sqrt(sum(jnp.sum(l ** 2) for l in jax.tree.leaves(g))))(
jax.vmap(jax.grad(loss_single), in_axes=(None, 0, 0))(p, X, Y)),
"② vmap(逐样本范数)": lambda p, X, Y: jax.vmap(per_sample_norm, in_axes=(None, 0, 0))(p, X, Y),
f"③ lax.map(batch_size={CHUNK})": lambda p, X, Y: lax.map(lambda xy: per_sample_norm(p, *xy), (X, Y), batch_size=CHUNK),
"④ lax.map(逐个样本)": lambda p, X, Y: lax.map(lambda xy: per_sample_norm(p, *xy), (X, Y)),
}
ref_norms = None
print(wpad("方式", 34) + "临时内存 耗时")
for name, fn in variants.items():
jf = jax.jit(fn)
norms = jf(params_ps, X_big, Y_big)
if ref_norms is None:
ref_norms = norms
assert np.allclose(norms, ref_norms, rtol=1e-5, atol=1e-6), name # 四种方式结果一致
mem = jf.lower(params_ps, X_big, Y_big).compile().memory_analysis()
t = bench(jf, params_ps, X_big, Y_big, runs=3)
print(wpad(name, 34) + f"{mem.temp_size_in_bytes / 2**20:7.2f} MiB {t:8.2f} ms")
print(f"\n理论值:B×P×4 字节 = {B_BIG * P * 4 / 2**20:.1f} MiB;分块后 k×P×4 = {CHUNK * P * 4 / 2**20:.2f} MiB")
print(f"1 亿参数、B=256 时 B×P 需要 {256 * 1e8 * 4 / 2**30:.0f} GiB")
怎么读:①② 的临时内存都在 $B\times P$ 的量级,XLA 不会替你省掉中间的逐样本梯度。③ 按块计算,临时内存降到 $k\times P$ 以内,速度介于 vmap 和逐个样本之间(CPU 上块内的并行度有限;GPU 上块越大越接近 vmap)。④ 内存最小,但完全串行,最慢。经验:先用 vmap,内存不够时改成 lax.map(..., batch_size=k),并且在块内就把逐样本梯度归约掉。
4.5 DP-SGD:裁剪 + 加噪的完整实现¶
⚪ 选读:做差分隐私训练时
DP-SGD(Abadi et al., 2016)每一步的梯度这样算:
- 逐样本梯度 $g_i$;
- 裁剪:$\bar g_i = g_i\cdot\min\!\left(1, \dfrac{C}{\lVert g_i\rVert_2}\right)$,这里的范数是把所有参数拼在一起的全局 L2 范数;
- 求和并加噪:$\tilde g = \dfrac{1}{B}\left(\sum_i \bar g_i + \mathcal N(0, \sigma^2C^2 I)\right)$,噪声对每个参数独立采样;
- 用 $\tilde g$ 做普通的 SGD 更新。
隐私预算 $(\varepsilon,\delta)$ 的计算(privacy accounting)要用专门的会计器,这里不展开,只实现梯度部分。实现上分成两步:clip_and_sum(可以整批算,也可以分块算)和 add_dp_noise,方便分开验证。随机数用 jax.random.key,每片叶子用 split 出来的独立 key。
def clip_and_sum(params, X, Y, clip_norm):
"""整批版本:返回 (Σ_i 裁剪后的 g_i, 每个样本裁剪前的范数)。内存 B×P。"""
g = jax.vmap(jax.grad(loss_single), in_axes=(None, 0, 0))(params, X, Y)
sq = sum(jnp.sum(l.reshape(l.shape[0], -1) ** 2, axis=1) for l in jax.tree.leaves(g))
norms = jnp.sqrt(sq)
factor = jnp.minimum(1.0, clip_norm / (norms + 1e-12)) # 每个样本的缩放系数 (B,)
summed = jax.tree.map(lambda l: jnp.tensordot(factor, l, axes=1), g)
return summed, norms
def clip_and_sum_chunked(params, X, Y, clip_norm, chunk):
"""分块版本:每块 chunk 个样本,内存 chunk×P。要求 B 能被 chunk 整除。"""
Xc = X.reshape(-1, chunk, X.shape[-1])
Yc = Y.reshape(-1, chunk)
def body(acc, xy):
s, norms = clip_and_sum(params, *xy, clip_norm)
return jax.tree.map(jnp.add, acc, s), norms
acc0 = jax.tree.map(jnp.zeros_like, params)
summed, norms = lax.scan(body, acc0, (Xc, Yc))
return summed, norms.reshape(-1)
def add_dp_noise(summed, key, clip_norm, noise_mult, batch_size):
leaves, treedef = jax.tree.flatten(summed)
keys = jax.random.split(key, len(leaves)) # 每片叶子一个独立的 key
noisy = [(l + noise_mult * clip_norm * jax.random.normal(k, l.shape)) / batch_size
for l, k in zip(leaves, keys)]
return jax.tree.unflatten(treedef, noisy)
def dp_grad(params, X, Y, key, clip_norm, noise_mult):
summed, norms = clip_and_sum(params, X, Y, clip_norm)
return add_dp_noise(summed, key, clip_norm, noise_mult, X.shape[0]), norms
# ---------- 自证 ----------
C = 1.0
dp_grad_jit = jax.jit(dp_grad)
# (a) 裁剪阈值无穷大、无噪声 → 退化为普通的平均梯度
g_nonpriv, _ = dp_grad_jit(params_ps, X_ps, Y_ps, jax.random.key(0), 1e9, 0.0)
assert tree_allclose(g_nonpriv, jax.grad(loss_batch)(params_ps, X_ps, Y_ps), atol=1e-6)
# (b) 裁剪后每个样本的范数都 ≤ C
summed, norms = jax.jit(clip_and_sum)(params_ps, X_ps, Y_ps, C)
clipped = norms * jnp.minimum(1.0, C / (norms + 1e-12))
print(f"裁剪前范数:最小 {norms.min():.3f},最大 {norms.max():.3f};裁剪后最大 {clipped.max():.4f}(C = {C})")
assert float(clipped.max()) <= C * (1 + 1e-5) and float(norms.max()) > C # 确实发生了裁剪
# (c) 分块版本与整批版本一致
summed_c, norms_c = jax.jit(clip_and_sum_chunked, static_argnames="chunk")(params_ps, X_ps, Y_ps, C, chunk=64)
assert tree_allclose(summed, summed_c, rtol=1e-5, atol=1e-5) and np.allclose(norms, norms_c, rtol=1e-5)
# (d) 噪声的统计性质:(g̃ - 无噪声结果) × B / (σC) 应服从标准正态
sigma = 1.1
zero_noise = add_dp_noise(summed, jax.random.key(1), C, 0.0, B)
noise_samples = jax.jit(jax.vmap(lambda k: add_dp_noise(summed, k, C, sigma, B)))(jax.random.split(jax.random.key(2), 200))
z = np.concatenate([np.asarray((n - z0[None]) * B / (sigma * C)).ravel()
for n, z0 in zip(jax.tree.leaves(noise_samples), jax.tree.leaves(zero_noise))])
print(f"标准化噪声:均值 {z.mean():+.4f},标准差 {z.std():.4f}(理论 0 和 1,样本数 {z.size})")
assert abs(z.mean()) < 0.01 and abs(z.std() - 1) < 0.01
# (e) 同一个 key 两次结果相同,不同 key 结果不同(随机性完全由 key 决定)
a1, _ = dp_grad_jit(params_ps, X_ps, Y_ps, jax.random.key(5), C, sigma)
a2, _ = dp_grad_jit(params_ps, X_ps, Y_ps, jax.random.key(5), C, sigma)
a3, _ = dp_grad_jit(params_ps, X_ps, Y_ps, jax.random.key(6), C, sigma)
assert tree_allclose(a1, a2) and not tree_allclose(a1, a3)
print("DP-SGD 梯度的 5 项自证全部通过 ✔")
最后用 DP-SGD 真正训练一次。数据是 20 维、10 类的高斯簇。训练循环用 lax.scan 写成一个 jit 程序,key 放在 carry 里逐步 split,再和不加隐私的 SGD 对比。
def make_blobs(key, n, d, k, spread=1.0):
kc, ky, kn = jax.random.split(key, 3)
centers = jax.random.normal(kc, (k, d)) * 2.0
y = jax.random.randint(ky, (n,), 0, k)
X = centers[y] + spread * jax.random.normal(kn, (n, d))
return X, y
Xtr, Ytr = make_blobs(jax.random.key(20), 4096, D_IN, N_CLS)
def accuracy(params, X, Y):
return jnp.mean(jnp.argmax(mlp_apply(params, X), axis=-1) == Y)
def train_dp(params, X, Y, key, lr, clip_norm, noise_mult, steps, batch):
def step(carry, _):
params, key = carry
key, k_idx, k_noise = jax.random.split(key, 3)
idx = jax.random.choice(k_idx, X.shape[0], (batch,), replace=False)
g, _ = dp_grad(params, X[idx], Y[idx], k_noise, clip_norm, noise_mult)
params = jax.tree.map(lambda p, gi: p - lr * gi, params, g)
return (params, key), None
(params, _), _ = lax.scan(step, (params, key), None, length=steps)
return params
train_dp_jit = jax.jit(train_dp, static_argnames=("steps", "batch"))
p0 = init_mlp(jax.random.key(21), (D_IN, 64, N_CLS))
p_dp = train_dp_jit(p0, Xtr, Ytr, jax.random.key(22), 0.5, 1.0, 1.1, steps=300, batch=256)
p_sgd = train_dp_jit(p0, Xtr, Ytr, jax.random.key(22), 0.5, 1e9, 0.0, steps=300, batch=256) # 不裁剪、不加噪
acc_dp, acc_sgd = float(accuracy(p_dp, Xtr, Ytr)), float(accuracy(p_sgd, Xtr, Ytr))
print(f"初始准确率 {float(accuracy(p0, Xtr, Ytr)):.3f} | 普通 SGD {acc_sgd:.3f} | DP-SGD(C=1, σ=1.1) {acc_dp:.3f}")
assert acc_dp > 0.9 and acc_sgd > 0.9
💡 实战规则¶
🟢 必读
一句话:逐样本梯度写成「单样本损失 +
vmap(grad, in_axes=(None, 0, 0))」,并和循环对拍。它的内存是 $B\times P$,大模型要用lax.map(batch_size=k)分块,并且在块内就归约成范数或裁剪后的和。
专题 5 手写优化器¶
5.1 问题动机¶
⚪ 选读:为什么要把优化器拆成可组合的小变换(Optax 的设计思路)
真实项目里直接用 Optax 就好(本环境装有 optax 0.2.9)。但要彻底搞懂优化器,最好的办法是亲手实现一遍。这一节的实现不依赖 Optax,只在最后拿它来对拍。第 6 章已经写过「对任意 PyTree 都适用」的动量 SGD 和 Adam,不过每个优化器都是一整块代码:想加梯度裁剪、权重衰减或学习率调度,就得复制一份再改。Optax 的思路是把优化器拆成可以组合的小变换:
adamw = chain(clip_by_global_norm(1.0), # 梯度裁剪
scale_by_adam(), # Adam 的方向
add_decayed_weights(1e-4), # 解耦的权重衰减
scale_by_learning_rate(schedule)) # 乘以 -lr(t)
每个变换都是一对纯函数:init(params) -> state 和 update(updates, state, params) -> (updates, state)。状态是 PyTree,可以直接穿过 jit / scan / donate。
→ 基础见 第 6 章 6.6(学习率应作为动态参数传入)及其补充(PyTree 版动量 SGD 与 Adam、两种动量公式)、补充练习 3(RMSProp);Optax 的接口介绍见第 6 章末尾的生态库一节。
5.2 原理:GradientTransformation 与各个组件¶
⚪ 选读:想自己实现或读懂 Optax 内部结构时;直接用 Optax 可跳过
| 组件 | 状态 | 作用 |
|---|---|---|
scale(s) | 无 | $u \leftarrow s\,u$ |
scale_by_schedule(f) | 步数 count | $u \leftarrow f(t)\,u$ |
trace(β, nesterov) | 速度 $v$ | $v \leftarrow u + \beta v$;Nesterov 输出 $u + \beta v$,否则输出 $v$ |
scale_by_adam(β₁, β₂, ε) | count、$m$、$v$ | $\hat m/(\sqrt{\hat v}+\varepsilon)$,含偏差修正 |
add_decayed_weights(λ, mask) | 无 | $u \leftarrow u + \lambda\theta$(只对 mask 为真的参数) |
clip_by_global_norm(c) | 无 | $\lVert u\rVert > c$ 时整体缩放到范数 $c$ |
chain(*ts) | 各组件状态组成的 tuple | 按顺序依次应用 |
约定和 Optax 一样:update 输出的是要加到参数上的增量(已经带了负号),最后用 apply_updates(params, updates) 加上去。
class GradientTransformation(NamedTuple):
init: Callable # params -> state
update: Callable # (updates, state, params=None) -> (updates, new_state)
class EmptyState(NamedTuple):
pass
def apply_updates(params, updates):
return jax.tree.map(lambda p, u: p + u, params, updates)
def global_norm(tree):
return jnp.sqrt(sum(jnp.sum(l ** 2) for l in jax.tree.leaves(tree)))
def identity():
return GradientTransformation(lambda params: EmptyState(), lambda u, s, params=None: (u, s))
def scale(step_size):
def update(updates, state, params=None):
return jax.tree.map(lambda g: step_size * g, updates), state
return GradientTransformation(lambda params: EmptyState(), update)
class ScaleByScheduleState(NamedTuple):
count: jax.Array
def scale_by_schedule(schedule):
def init(params):
return ScaleByScheduleState(count=jnp.zeros([], jnp.int32)) # 强类型 int32:不会多编译
def update(updates, state, params=None):
s = schedule(state.count) # 用更新前的步数(第 1 步 count=0)
return jax.tree.map(lambda g: s * g, updates), ScaleByScheduleState(count=state.count + 1)
return GradientTransformation(init, update)
def scale_by_learning_rate(lr):
if callable(lr):
return scale_by_schedule(lambda count: -lr(count))
return scale(-lr)
class TraceState(NamedTuple):
trace: Any
def trace(decay, nesterov=False):
def init(params):
return TraceState(trace=jax.tree.map(jnp.zeros_like, params))
def update(updates, state, params=None):
new_t = jax.tree.map(lambda g, t: g + decay * t, updates, state.trace)
out = jax.tree.map(lambda g, t: g + decay * t, updates, new_t) if nesterov else new_t
return out, TraceState(trace=new_t)
return GradientTransformation(init, update)
class ScaleByAdamState(NamedTuple):
count: jax.Array
mu: Any
nu: Any
def scale_by_adam(b1=0.9, b2=0.999, eps=1e-8):
def init(params):
return ScaleByAdamState(count=jnp.zeros([], jnp.int32),
mu=jax.tree.map(jnp.zeros_like, params), # mu、nu 各自独立创建(见专题 6)
nu=jax.tree.map(jnp.zeros_like, params))
def update(updates, state, params=None):
mu = jax.tree.map(lambda m, g: b1 * m + (1 - b1) * g, state.mu, updates)
nu = jax.tree.map(lambda v, g: b2 * v + (1 - b2) * g * g, state.nu, updates)
count = state.count + 1
t = count.astype(jnp.float32)
bc1, bc2 = 1 - b1 ** t, 1 - b2 ** t # 偏差修正
out = jax.tree.map(lambda m, v: (m / bc1) / (jnp.sqrt(v / bc2) + eps), mu, nu)
return out, ScaleByAdamState(count=count, mu=mu, nu=nu)
return GradientTransformation(init, update)
def add_decayed_weights(weight_decay, mask=None):
"""解耦的权重衰减:u ← u + λθ。mask 可以是与 params 同结构的 bool 树,或返回这种树的函数。"""
def update(updates, state, params):
m = jax.tree.map(lambda _: True, params) if mask is None else (mask(params) if callable(mask) else mask)
return jax.tree.map(lambda g, p, keep: g + weight_decay * p if keep else g, updates, params, m), state
return GradientTransformation(lambda params: EmptyState(), update)
def clip_by_global_norm(max_norm):
def update(updates, state, params=None):
factor = jnp.minimum(1.0, max_norm / (global_norm(updates) + 1e-12))
return jax.tree.map(lambda g: g * factor, updates), state
return GradientTransformation(lambda params: EmptyState(), update)
def chain(*transforms):
def init(params):
return tuple(t.init(params) for t in transforms)
def update(updates, state, params=None):
new_state = []
for t, s in zip(transforms, state):
updates, s = t.update(updates, s, params)
new_state.append(s)
return updates, tuple(new_state)
return GradientTransformation(init, update)
# ---------- 常用别名 ----------
def sgd(lr, momentum=None, nesterov=False):
return chain(trace(momentum, nesterov) if momentum else identity(), scale_by_learning_rate(lr))
def adam(lr, b1=0.9, b2=0.999, eps=1e-8):
return chain(scale_by_adam(b1, b2, eps), scale_by_learning_rate(lr))
def adamw(lr, weight_decay=1e-4, b1=0.9, b2=0.999, eps=1e-8, mask=None):
return chain(scale_by_adam(b1, b2, eps), add_decayed_weights(weight_decay, mask), scale_by_learning_rate(lr))
# ---------- 学习率调度 ----------
def warmup_cosine_decay_schedule(init_value, peak_value, warmup_steps, decay_steps, end_value=0.0):
"""与 Optax 同语义:前 warmup_steps 步线性升到 peak,之后余弦衰减,到第 decay_steps 步(含 warmup)降到 end。"""
def schedule(count):
c = jnp.asarray(count, jnp.float32)
warm = init_value + (peak_value - init_value) * c / warmup_steps
frac = jnp.clip((c - warmup_steps) / (decay_steps - warmup_steps), 0.0, 1.0)
cos = end_value + (peak_value - end_value) * 0.5 * (1 + jnp.cos(jnp.pi * frac))
return jnp.where(c < warmup_steps, warm, cos)
return schedule
print("组件定义完成:", ", ".join(["scale", "scale_by_schedule", "trace", "scale_by_adam",
"add_decayed_weights", "clip_by_global_norm", "chain", "sgd", "adam", "adamw"]))
5.3 assert 自证:和 NumPy float64 手算参考值逐步对拍¶
⚪ 选读:手写优化器的逐步对拍方法,自己实现优化器时参考
测试问题取一个 PyTree 参数上的二次函数 $L(\theta)=\sum \tfrac12 a\,\theta^2 + c\,\theta$,它的梯度 $a\theta + c$ 在 NumPy 里可以直接手算。每个优化器都跑 5 步,每一步的参数都和 NumPy 参考值比较。
rng_keys = jax.random.split(jax.random.key(30), 4)
theta0 = {"w": jax.random.normal(rng_keys[0], (3, 2)), "b": jax.random.normal(rng_keys[1], (2,))}
coef_a = {"w": 1.0 + jax.random.uniform(rng_keys[2], (3, 2)) * 4, "b": jnp.array([0.5, 2.0])}
coef_c = {"w": jax.random.normal(rng_keys[3], (3, 2)), "b": jnp.array([1.0, -1.0])}
def quad_loss(theta, a, c): # 系数作为参数传入,不被闭包捕获
return sum(jnp.sum(0.5 * a[k] * theta[k] ** 2 + c[k] * theta[k]) for k in theta)
def run_jax(opt, steps=5):
@jax.jit
def step(theta, state, a, c):
g = jax.grad(quad_loss)(theta, a, c)
u, state = opt.update(g, state, theta)
return apply_updates(theta, u), state
theta, state, traj = theta0, opt.init(theta0), []
for _ in range(steps):
theta, state = step(theta, state, coef_a, coef_c)
traj.append(jax.tree.map(to64, theta))
return traj
def run_numpy(rule, steps=5):
"""rule(t, theta, g, mem) -> theta_new;t 从 1 开始,mem 是自由使用的字典。"""
theta = jax.tree.map(to64, theta0)
a, c = jax.tree.map(to64, coef_a), jax.tree.map(to64, coef_c)
mem, traj = {}, []
for t in range(1, steps + 1):
g = {k: a[k] * theta[k] + c[k] for k in theta}
theta = {k: rule(t, k, theta[k], g[k], mem) for k in theta}
traj.append(theta)
return traj
def check(name, jax_traj, np_traj, rtol=1e-5, atol=1e-5): # float32 vs float64
for t, (tj, tn) in enumerate(zip(jax_traj, np_traj), 1):
for k in tn:
assert np.allclose(tj[k], tn[k], rtol=rtol, atol=atol), (name, t, k, np.abs(tj[k] - tn[k]).max())
err = max(np.abs(tj[k] - tn[k]).max() for tj, tn in zip(jax_traj, np_traj) for k in tn)
print(f"{wpad(name, 34)} 5 步全部一致,最大误差 {err:.2e}")
LR, B1, B2, EPS = 0.1, 0.9, 0.999, 1e-8
def np_adam(t, k, th, g, mem, wd=0.0):
m = mem.get(("m", k), 0.0) * B1 + (1 - B1) * g
v = mem.get(("v", k), 0.0) * B2 + (1 - B2) * g ** 2
mem[("m", k)], mem[("v", k)] = m, v
direction = (m / (1 - B1 ** t)) / (np.sqrt(v / (1 - B2 ** t)) + EPS)
return th - LR * (direction + wd * th) # AdamW:衰减项不经过 Adam 的归一化
def np_momentum(t, k, th, g, mem, nesterov=False, beta=0.9):
v = beta * mem.get(("v", k), 0.0) + g
mem[("v", k)] = v
return th - LR * ((g + beta * v) if nesterov else v)
check("adam", run_jax(adam(LR)), run_numpy(np_adam))
check("adamw(wd=0.05)", run_jax(adamw(LR, weight_decay=0.05)), run_numpy(partial(np_adam, wd=0.05)))
check("sgd(momentum=0.9)", run_jax(sgd(LR, momentum=0.9)), run_numpy(np_momentum))
check("sgd(momentum=0.9, nesterov=True)", run_jax(sgd(LR, momentum=0.9, nesterov=True)),
run_numpy(partial(np_momentum, nesterov=True)))
check("sgd", run_jax(sgd(LR)), run_numpy(lambda t, k, th, g, mem: th - LR * g))
再和真正的 Optax 对拍一次:同样的超参数、同样的梯度序列,手写版本和 optax 的结果应该每一步都一致(run_jax 只用到 init / update,所以可以直接把 Optax 的优化器传进去)。
import optax
print("optax 版本:", optax.__version__)
opt_pairs = [
("adam", adam(LR), optax.adam(LR)),
("adamw(wd=0.05)", adamw(LR, weight_decay=0.05), optax.adamw(LR, weight_decay=0.05)),
("nesterov", sgd(LR, momentum=0.9, nesterov=True), optax.sgd(LR, momentum=0.9, nesterov=True)),
("clip+adam+schedule", chain(clip_by_global_norm(1.0), adam(warmup_cosine_decay_schedule(0.0, LR, 2, 10, 1e-3))),
optax.chain(optax.clip_by_global_norm(1.0), optax.adam(optax.warmup_cosine_decay_schedule(0.0, LR, 2, 10, 1e-3)))),
]
for name, mine, ref in opt_pairs:
a, b = run_jax(mine), run_jax(ref)
err = max(np.abs(x[k] - y[k]).max() for x, y in zip(a, b) for k in x)
print(f"{wpad(name, 22)} 手写 vs optax,5 步最大差 {err:.2e}")
assert err < 1e-6, name
sched_mine = jax.vmap(warmup_cosine_decay_schedule(0.0, 1e-2, 10, 100, 1e-4))(jnp.arange(120))
sched_ref = jax.vmap(optax.warmup_cosine_decay_schedule(0.0, 1e-2, 10, 100, 1e-4))(jnp.arange(120))
assert np.allclose(sched_mine, sched_ref, rtol=1e-6, atol=1e-9)
print("warmup-cosine 调度与 optax 逐点一致 ✔")
# 学习率调度:与 NumPy 公式逐点对拍,并检查关键点
INIT, PEAK, WARM, DECAY, END = 0.0, 1e-2, 10, 100, 1e-4
sched = warmup_cosine_decay_schedule(INIT, PEAK, WARM, DECAY, END)
def np_sched(c):
if c < WARM:
return INIT + (PEAK - INIT) * c / WARM
frac = min(max((c - WARM) / (DECAY - WARM), 0.0), 1.0)
return END + (PEAK - END) * 0.5 * (1 + np.cos(np.pi * frac))
steps_probe = np.arange(0, 121)
lr_curve = np.asarray(jax.vmap(sched)(jnp.asarray(steps_probe)))
assert np.allclose(lr_curve, [np_sched(c) for c in steps_probe], rtol=1e-5, atol=1e-9)
assert lr_curve[0] == INIT and np.isclose(lr_curve[WARM], PEAK) and np.isclose(lr_curve[DECAY], END)
assert np.all(np.diff(lr_curve[:WARM + 1]) > 0) and np.all(np.diff(lr_curve[WARM:DECAY + 1]) < 0)
print(f"warmup-cosine:第 0 步 {lr_curve[0]},第 {WARM} 步 {lr_curve[WARM]:.4g}(峰值),"
f"第 {DECAY} 步 {lr_curve[DECAY]:.4g},之后保持 {lr_curve[-1]:.4g} ✔")
# 梯度裁剪:超过阈值的被缩放到阈值,没超过的保持不变
clip = clip_by_global_norm(1.0)
big = {"w": jnp.full((3, 2), 3.0), "b": jnp.full(2, 4.0)}
small = jax.tree.map(lambda x: x * 0.01, big)
assert np.isclose(global_norm(clip.update(big, EmptyState())[0]), 1.0, rtol=1e-6)
assert tree_allclose(clip.update(small, EmptyState())[0], small)
# chain(裁剪 + Adam + 调度) 与 NumPy 逐步对拍
def run_numpy_chain(steps=5):
"""NumPy 参考:先按全局范数裁剪,再 Adam,再乘 -lr(t-1)。"""
theta = jax.tree.map(to64, theta0)
a, c = jax.tree.map(to64, coef_a), jax.tree.map(to64, coef_c)
m = {k: 0.0 for k in theta}; v = {k: 0.0 for k in theta}; traj = []
for t in range(1, steps + 1):
g = {k: a[k] * theta[k] + c[k] for k in theta}
factor = min(1.0, 1.0 / (np.sqrt(sum(np.sum(x ** 2) for x in g.values())) + 1e-12))
new = {}
for k in theta:
gk = g[k] * factor
m[k] = B1 * m[k] + (1 - B1) * gk
v[k] = B2 * v[k] + (1 - B2) * gk ** 2
new[k] = theta[k] - np_sched(t - 1) * (m[k] / (1 - B1 ** t)) / (np.sqrt(v[k] / (1 - B2 ** t)) + EPS)
theta = new
traj.append(theta)
return traj
opt_chain = chain(clip_by_global_norm(1.0), scale_by_adam(B1, B2, EPS), scale_by_learning_rate(sched))
check("chain(clip, adam, warmup-cosine)", run_jax(opt_chain), run_numpy_chain())
5.4 ❌ 常见错误写法与实测¶
🟢 必读
错误 1:忘了 Adam 的偏差修正。 $m$、$v$ 都从 0 开始,前几步会严重偏向 0。去掉修正后,第一步的方向是 $\dfrac{(1-\beta_1)g}{\sqrt{(1-\beta_2)g^2}}=\dfrac{1-\beta_1}{\sqrt{1-\beta_2}}\,\mathrm{sign}(g)\approx 3.16\,\mathrm{sign}(g)$。也就是说,第一步的步长变成了设定值的 3 倍多,学习率大的时候,很容易一上来就把训练打崩。
错误 2:把 AdamW 写成「L2 正则 + Adam」。 把 $\lambda\theta$ 加到梯度上再送进 Adam,衰减项会被 $\sqrt{\hat v}$ 归一化。极端情况下(任务梯度为 0),每一步都变成「固定减 lr」,和 λ 的大小几乎没关系。而解耦的 AdamW 是按 $(1-\eta\lambda)$ 的比例收缩。
# 错误 1:去掉偏差修正
def scale_by_adam_no_bc(b1=0.9, b2=0.999, eps=1e-8):
def update(updates, state, params=None):
mu = jax.tree.map(lambda m, g: b1 * m + (1 - b1) * g, state.mu, updates)
nu = jax.tree.map(lambda v, g: b2 * v + (1 - b2) * g * g, state.nu, updates)
out = jax.tree.map(lambda m, v: m / (jnp.sqrt(v) + eps), mu, nu) # ❌ 没有除以 1-β^t
return out, ScaleByAdamState(state.count + 1, mu, nu)
return GradientTransformation(scale_by_adam().init, update)
g_demo = {"w": jnp.array([0.3, -2.0, 50.0])}
p_demo = {"w": jnp.zeros(3)}
for name, tx in [("✅ 有偏差修正", scale_by_adam()), ("❌ 没有偏差修正", scale_by_adam_no_bc())]:
u, _ = tx.update(g_demo, tx.init(p_demo))
print(f"{name}:第一步方向 = {u['w']}")
u_ok, _ = scale_by_adam().update(g_demo, scale_by_adam().init(p_demo))
u_bad, _ = scale_by_adam_no_bc().update(g_demo, scale_by_adam().init(p_demo))
assert np.allclose(np.abs(u_ok["w"]), 1.0, atol=1e-6)
assert np.allclose(np.abs(u_bad["w"]), (1 - 0.9) / np.sqrt(1 - 0.999), rtol=1e-4)
# 错误 2:L2 正则 + Adam vs 解耦的 AdamW。任务梯度恒为 0,只看衰减项的效果
lr, wd, steps = 0.01, 0.1, 20
p_init = {"w": jnp.array([1.0, 10.0])}
zero_grad = {"w": jnp.zeros(2)}
opt_l2 = chain(add_decayed_weights(wd), scale_by_adam(), scale_by_learning_rate(lr)) # ❌ 先加 λθ,再进 Adam
opt_w = adamw(lr, weight_decay=wd) # ✅ 解耦
results_wd = {}
for name, opt in [("❌ L2 + Adam", opt_l2), ("✅ AdamW", opt_w)]:
p, s = p_init, opt.init(p_init)
for _ in range(steps):
u, s = opt.update(zero_grad, s, p)
p = apply_updates(p, u)
results_wd[name] = p["w"]
print(f"{name}:{steps} 步后 w = {p['w']}")
assert np.allclose(results_wd["❌ L2 + Adam"], np.array([1.0, 10.0]) - steps * lr, atol=5e-3) # 每步约减 lr,与 w 大小无关
assert np.allclose(results_wd["✅ AdamW"], np.array([1.0, 10.0]) * (1 - lr * wd) ** steps, rtol=1e-5) # 按比例收缩
L2 + Adam 下,w=1 和 w=10 都减少了大约 $20\times0.01=0.2$:衰减量和参数大小无关,λ 也被归一化掉了。AdamW 则让两者都按 $(1-\eta\lambda)^{20}$ 等比例收缩。
错误 3:调度器带来的重新编译。 有两种写法会踩到专题 1 的坑:一是把学习率设成静态参数(每个新值编译一次);二是在 Python 循环里算好学习率,有时以 Python 浮点数、有时以数组传进 jit(弱类型、强类型各编译一次)。每步都传 Python 浮点数本身不会重新编译(专题 1.5 (3) 验证过),但这样调度逻辑就留在了 jit 外面。这里的 scale_by_schedule 把步数存在状态里(int32 数组),学习率在 jit 内部计算,100 步只编译 1 次:
model_opt = chain(clip_by_global_norm(1.0), scale_by_adam(), scale_by_learning_rate(sched))
def opt_train_step(theta, state, a, c):
loss, g = jax.value_and_grad(quad_loss)(theta, a, c)
u, state = model_opt.update(g, state, theta)
return apply_updates(theta, u), state, loss
opt_train_step_jit = jax.jit(opt_train_step)
theta, state = theta0, model_opt.init(theta0)
with CompileBudget(1, "opt_train_step"):
for _ in range(100):
theta, state, loss = opt_train_step_jit(theta, state, coef_a, coef_c)
print(f"100 步:编译 1 次(预算内),步数计数器 = {int(state[2].count)},loss = {float(loss):.4f}")
assert int(state[2].count) == 100
5.5 在 Rosenbrock 函数上对比各优化器¶
⚪ 选读:直观比较各优化器在病态问题上的收敛表现
最后在经典的病态问题 Rosenbrock 函数 $f(x,y)=(1-x)^2+100(y-x^2)^2$ 上跑 3000 步。每个优化器都用 lax.scan 编译成一个程序,同时画出 warmup-cosine 调度本身的曲线。
def rosenbrock(p):
return (1 - p["x"]) ** 2 + 100 * (p["y"] - p["x"] ** 2) ** 2
def run_opt(opt, p0, steps):
def body(carry, _):
p, s = carry
loss, g = jax.value_and_grad(rosenbrock)(p)
u, s = opt.update(g, s, p)
return (apply_updates(p, u), s), loss
(p, _), losses = lax.scan(body, (p0, opt.init(p0)), None, length=steps)
return p, losses
STEPS = 3000
p_start = {"x": jnp.array(-1.5), "y": jnp.array(2.0)}
cos_sched = warmup_cosine_decay_schedule(0.0, 0.05, 100, STEPS, 1e-3)
optimizers = {
"SGD (lr=5e-4)": sgd(5e-4),
"动量 0.9": sgd(5e-4, momentum=0.9),
"Nesterov 0.9": sgd(5e-4, momentum=0.9, nesterov=True),
"Adam (lr=0.05)": adam(0.05),
"Adam + warmup-cosine": chain(clip_by_global_norm(10.0), scale_by_adam(), scale_by_learning_rate(cos_sched)),
}
curves, finals = {}, {}
for name, opt in optimizers.items():
p_end, losses = jax.jit(partial(run_opt, opt, steps=STEPS))(p_start)
curves[name], finals[name] = np.asarray(losses), p_end
print(f"{wpad(name, 24)} 最终 loss {float(losses[-1]):.2e},终点 ({float(p_end['x']):.4f}, {float(p_end['y']):.4f})")
fig, axes = plt.subplots(1, 2, figsize=(11, 3.8), gridspec_kw={"width_ratios": [2, 1]})
for (name, losses), c in zip(curves.items(), [C_BLUE, C_ORANGE, C_AQUA, C_VIOLET, C_RED]):
axes[0].plot(np.maximum(losses, 1e-12), color=c, lw=1.6, label=name)
axes[0].set_yscale("log"); axes[0].set_xlabel("步数"); axes[0].set_ylabel("loss(对数刻度)")
axes[0].set_title("Rosenbrock 上的收敛曲线", fontsize=11); axes[0].legend(frameon=False, fontsize=9)
axes[0].grid(True, color="#e5e5e5", lw=0.8)
axes[1].plot(np.asarray(jax.vmap(cos_sched)(jnp.arange(STEPS))), color=C_RED, lw=2)
axes[1].set_xlabel("步数"); axes[1].set_ylabel("学习率"); axes[1].set_title("warmup(100) + cosine 调度", fontsize=11)
axes[1].grid(True, color="#e5e5e5", lw=0.8)
plt.tight_layout(); plt.show()
# 注:loss 在 1e-12 处截断以便画对数坐标;动量和 Nesterov 两条曲线几乎重合
for name in ("Nesterov 0.9", "Adam + warmup-cosine"):
assert curves[name][-1] < 1e-3, name # 这两个都能收敛到 (1, 1) 附近
assert curves["Nesterov 0.9"][-1] < curves["SGD (lr=5e-4)"][-1] # 动量类方法比普通 SGD 好
💡 实战规则¶
🟢 必读
一句话:优化器写成
init/update纯函数,再用chain组合;状态(包括步数)放进 PyTree,跟着 jit 走。Adam 别忘了偏差修正,权重衰减要解耦(AdamW),每个组件都用 NumPy 手算值逐步对拍。
专题 6 donate_argnums 与内存¶
6.1 问题动机¶
⚪ 选读:为什么显式状态传递会让峰值内存多出一份
显式传递状态有个隐藏的成本:执行 new_state = step(state, batch) 时,旧状态和新状态同时占着内存。用 Adam 训练时,状态是「参数 + 两份矩」,也就是 3 倍参数量,峰值时还要再多一份。大模型训练里,多出来的这一份往往就决定了是 OOM 还是能跑。
jax.jit(f, donate_argnums=...) / donate_argnames=... 是在告诉 XLA:「这些输入调用完我就不用了,它们的缓冲区可以直接拿来存输出」。
→ 基础见第 2 章 2.6 补充(
donate_argnums的基本用法、is_deleted)、第 6 章 6.6 补充(捐赠 params 和 opt_state、同一个数组出现两次的坑、无法利用时的警告)。
6.2 原理:输入-输出别名(aliasing)¶
⚪ 选读:想在 StableHLO / memory_analysis 层面验证捐赠是否生效时
捐赠说到底,是在编译时声明输入输出别名:输入缓冲区 $i$ 和某个形状、dtype 完全相同的输出 $j$ 共用一块内存。可以从三个层面看到它:
- 降级后的 StableHLO 里,被捐赠的参数带有
tf.aliasing_output = j属性; compiled.memory_analysis()里alias_size_in_bytes不为 0,峰值内存相应下降;- 运行时,输出数组的设备地址(
unsafe_buffer_pointer())和输入相同,输入数组被标记为已删除。
def sgd_update(p, g):
return p - 0.1 * g
N_EL = 8_000_000 # 3200 万字节 ≈ 30.5 MiB 的 float32 数组
donated = jax.jit(sgd_update, donate_argnums=0)
plain = jax.jit(sgd_update)
spec = (jax.ShapeDtypeStruct((N_EL,), jnp.float32),) * 2
hlo_line = [l for l in donated.lower(*spec).as_text().splitlines() if "func.func public @main" in l][0]
print("StableHLO 签名:", hlo_line.strip()[:120], "...")
assert "tf.aliasing_output = 0" in hlo_line
assert "tf.aliasing_output" not in plain.lower(*spec).as_text()
mem = {}
for name, f in [("不捐赠", plain), ("捐赠 p", donated)]:
m = f.lower(*spec).compile().memory_analysis()
mem[name] = m
print(f"{name}: 参数 {m.argument_size_in_bytes / 2**20:5.1f} MiB | 输出 {m.output_size_in_bytes / 2**20:5.1f} MiB | "
f"别名 {m.alias_size_in_bytes / 2**20:5.1f} MiB | 峰值 {m.peak_memory_in_bytes / 2**20:5.1f} MiB")
assert mem["捐赠 p"].alias_size_in_bytes == N_EL * 4
assert mem["不捐赠"].peak_memory_in_bytes - mem["捐赠 p"].peak_memory_in_bytes == N_EL * 4 # 峰值正好省下一份
6.3 CPU 上的实测行为¶
⚪ 选读:捐赠在 CPU 上的地址复用与计时实测
在 JAX 0.12 的 CPU 后端上,捐赠是真的生效的(旧版本在 CPU 上会忽略捐赠并给出警告):输出直接写进输入的缓冲区,输入数组被删除。速度上基本没差别。这次更新本来就是一遍内存读写,省下的是内存,不是时间。
p = jnp.ones(N_EL)
g = jnp.ones(N_EL)
ptr_before = p.unsafe_buffer_pointer()
p_new = donated(p, g)
print("捐赠:输出地址 == 输入地址:", p_new.unsafe_buffer_pointer() == ptr_before, "| 输入已删除:", p.is_deleted())
assert p_new.unsafe_buffer_pointer() == ptr_before and p.is_deleted()
q = jnp.ones(N_EL)
q_new = plain(q, g)
print("不捐赠:输出地址 == 输入地址:", q_new.unsafe_buffer_pointer() == q.unsafe_buffer_pointer(), "| 输入已删除:", q.is_deleted())
assert not q.is_deleted()
def loop(f, x, n=20):
for _ in range(n):
x = f(x, g)
return x
t_don = bench(lambda: loop(donated, jnp.ones(N_EL)), runs=5)
t_pl = bench(lambda: loop(plain, jnp.ones(N_EL)), runs=5)
print(f"20 次更新(含创建初值):捐赠 {t_don:.1f} ms | 不捐赠 {t_pl:.1f} ms")
del p_new, q, q_new
6.4 什么时候有用,什么时候没用¶
⚪ 选读:判断某个参数值不值得捐赠时查表
| 情况 | 捐赠有没有用 |
|---|---|
训练 step 里的 params、opt_state(输出和输入同形状) | ✅ 最典型的用法,峰值内存降一份状态 |
| KV cache、环形缓冲区这类「原地更新」的大数组 | ✅ |
| 每步都换的数据 batch | ❌ 没有同形状的输出可以复用,还会触发警告 |
| 之后还要用的数组(比如要拿去评估、做 checkpoint) | ❌ 捐了就没了;要先复制或者先取到主机 |
| 很小的数组(标量、计数器) | 几乎没有收益,但也没坏处 |
输出里没有可以复用的位置时,JAX 不会报错,只会给出 UserWarning: Some donated buffers were not usable:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
jax.jit(lambda x: x.sum(), donate_argnums=0)(jnp.ones(10)) # 输出是标量,复用不了
print("警告:", str(caught[0].message).splitlines()[0])
assert any("donated buffers were not usable" in str(w.message) for w in caught)
6.5 ❌ 常见错误与实测¶
🟢 必读
- 捐赠后继续使用旧数组 →
RuntimeError: Array has been deleted; - 同一个 buffer 捐两次:同一个数组在被捐赠的参数里出现两次(例如 Adam 的
mu、nu共用一个zeros,或者step(state, state))→Attempt to donate the same buffer twice; - 同一个 buffer 一份捐赠、一份不捐赠:例如
step(state, state.params),第一个参数捐赠,第二个参数又引用了其中的数组 →Attempt to use a buffer that was previously donated; - 还有别的引用指向旧状态:比如为了画图把每一步的
params存进列表,捐赠之后列表里的数组全部被删除。
后三种都是运行时错误,编译时不会报,而且只在开启捐赠后才出现。所以「加个 donate 优化一下」经常会把原本好好的代码弄坏。
@partial(jax.jit, donate_argnums=(0,))
def upd_state(state, extra):
return jax.tree.map(lambda s: s + 1.0, state), extra * 2
# ❌ 1:捐赠后继续使用
s = {"mu": jnp.zeros(3), "nu": jnp.zeros(3)}
s2, _ = upd_state(s, jnp.ones(3))
show_error(lambda: s["mu"] + 1)
import contextlib, os
@contextlib.contextmanager
def quiet_cpp_stderr():
"""XLA 在抛出 Python 异常的同时,还会从 C++ 层往 stderr 打一大段日志;这里临时把 fd 2 指向 /dev/null。"""
saved = os.dup(2)
devnull = os.open(os.devnull, os.O_WRONLY)
try:
os.dup2(devnull, 2)
yield
finally:
os.dup2(saved, 2); os.close(saved); os.close(devnull)
# ❌ 2:同一个数组在被捐赠的 pytree 里出现两次
shared = jnp.zeros(3)
bad_state = {"mu": shared, "nu": shared}
with quiet_cpp_stderr():
show_error(lambda: upd_state(bad_state, jnp.ones(3)))
# ❌ 3:同一个数组一份被捐赠,一份没有被捐赠
s3 = {"mu": jnp.zeros(3), "nu": jnp.zeros(3)}
with quiet_cpp_stderr():
show_error(lambda: upd_state(s3, s3["mu"]))
# ❌ 4:历史记录里存着旧状态的引用
history = []
s4 = {"mu": jnp.zeros(3), "nu": jnp.zeros(3)}
for _ in range(3):
history.append(s4) # 存的是引用
s4, _ = upd_state(s4, jnp.ones(3))
print("❌ 历史记录里的数组是否已删除:", [h["mu"].is_deleted() for h in history])
assert all(h["mu"].is_deleted() for h in history)
# ✅ 修法:独立创建每个数组;要保留的东西先「复制」到主机
host_copy = lambda tree: jax.tree.map(lambda a: np.array(a, copy=True), tree) # 真正的拷贝
history_ok = []
s5 = {"mu": jnp.zeros(3), "nu": jnp.zeros(3)}
for _ in range(3):
history_ok.append(host_copy(s5))
s5, _ = upd_state(s5, jnp.ones(3))
print("✅ 主机端历史:", [float(h["mu"][0]) for h in history_ok])
assert [float(h["mu"][0]) for h in history_ok] == [0.0, 1.0, 2.0]
⚠️ CPU 上的一个隐蔽行为(0.12 实测):在 CPU 后端,jax.device_get(x) 和 np.asarray(x) 返回的是零拷贝视图,和设备数组共用同一块内存。只要这个 NumPy 视图还活着,JAX 就会悄悄跳过这块缓冲区的捐赠:不报错、不警告,输入也不会被删除,只是省内存的效果没了。所以:
- 想保留副本又不影响捐赠,用
np.array(x, copy=True)(上面的host_copy); - 反过来,「捐赠后旧数组居然还能用」不代表捐赠没问题,可能只是某个地方还留着视图。GPU/TPU 上
device_get会真的拷贝到主机,没有这个现象。
f_don = jax.jit(lambda x: x + 1, donate_argnums=0)
view_results = {}
for how in ["不保留", "jax.device_get(视图)", "np.asarray(视图)", "np.array(copy=True)"]:
x = jnp.ones(3)
keep = {"jax.device_get(视图)": jax.device_get, "np.asarray(视图)": np.asarray,
"np.array(copy=True)": lambda a: np.array(a, copy=True)}.get(how, lambda a: None)(x)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
_ = f_don(x)
view_results[how] = x.is_deleted()
print(f"{wpad(how, 26)} 捐赠后输入已删除: {x.is_deleted()!s:5} 警告数: {len(caught)}")
del keep
assert view_results == {"不保留": True, "jax.device_get(视图)": False, "np.asarray(视图)": False, "np.array(copy=True)": True}
6.6 ✅ 训练 step 的标准模板¶
🟢 必读
把前面几个专题的经验合起来,一个「带捐赠的训练 step」应该满足:
- 状态打包成一个 PyTree(这里用 NamedTuple,专题 7 会换成 dataclass),用
donate_argnames=("state",)按名字捐赠,调用时按位置传还是按关键字传都生效; - 状态里每个数组都独立创建,dtype 固定(步数用
int32数组,不用 Python int); - 数据 batch 不捐赠;
- 调用处写成
state, metrics = train_step(state, batch),立即覆盖旧引用; - 用
CompileBudget断言只编译 1 次。
class TrainStateNT(NamedTuple):
step: jax.Array
params: Any
opt_state: Any
tpl_opt = adamw(1e-2, weight_decay=1e-4)
@partial(jax.jit, donate_argnames=("state",))
def train_step_tpl(state, batch):
X, Y = batch
loss, grads = jax.value_and_grad(loss_batch)(state.params, X, Y)
updates, opt_state = tpl_opt.update(grads, state.opt_state, state.params)
new_state = TrainStateNT(step=state.step + 1,
params=apply_updates(state.params, updates),
opt_state=opt_state)
return new_state, {"loss": loss, "grad_norm": global_norm(grads)}
params_t = init_mlp(jax.random.key(40), (D_IN, 64, N_CLS))
state = TrainStateNT(step=jnp.zeros([], jnp.int32), params=params_t, opt_state=tpl_opt.init(params_t))
first_leaf = jax.tree.leaves(state)[1]
batches = [(Xtr[i:i + 256], Ytr[i:i + 256]) for i in range(0, 2048, 256)] # 8 个形状相同的 batch
losses_tpl = []
with CompileBudget(1, "train_step_tpl"):
for epoch in range(3):
for batch in batches:
state, metrics = train_step_tpl(state, batch) # 立即覆盖旧引用
losses_tpl.append(float(metrics["loss"]))
print(f"24 步:loss {losses_tpl[0]:.3f} → {losses_tpl[-1]:.3f},step = {int(state.step)},编译 1 次")
print("初始状态的数组已被捐赠(删除):", first_leaf.is_deleted(), "| 数据 batch 未被删除:", not batches[0][0].is_deleted())
assert first_leaf.is_deleted() and not batches[0][0].is_deleted()
assert int(state.step) == 24 and losses_tpl[-1] < losses_tpl[0]
💡 实战规则¶
🟢 必读
一句话:训练 step 用
donate_argnames=("state",)捐赠整个状态,不捐数据,调用处马上用state = step(state, ...)覆盖。状态里的数组各自独立创建;要保留的东西先用np.array(x, copy=True)拷贝再捐(CPU 上device_get是零拷贝视图,会让捐赠悄悄失效)。
专题 7 PyTree 新 API 与自定义容器¶
7.1 问题动机¶
⚪ 选读:旧 API 为何报错、为什么需要结构化的 TrainState
网上的 JAX 代码大量使用 jax.tree_map、jax.tree_leaves 这类旧写法,在 0.12 里会直接报 AttributeError。另一方面,项目一变大,「参数是嵌套 dict、状态是 tuple」的写法很快就难以维护:字段名打错了只能在运行时发现,静态配置和数组混在一起又容易引发重新编译。这一节把 PyTree 的新 API 系统地过一遍,并给出 TrainState 的推荐写法。
→ 基础见 第 6 章 6.4 补充(PyTree API 迁移表、
None不是叶子、map_with_path)、6.4 补充(自定义状态容器:NamedTuple/register_dataclass/register_pytree_node_class)。
7.2 旧 → 新迁移表(0.12 实测)¶
⚪ 选读:完整迁移表在第 6 章,这里只补测 3 个旧 API
完整的旧 → 新迁移表(逐行实测)见 第 6 章 6.4 补充「PyTree API 迁移表」,这里不再重复。要点只有一条:顶层的 jax.tree_map 等别名在 0.12 已经移除,jax.tree_util.* 还能用。下面只补测那张表里没有的 3 个旧 API,并列出 jax.tree 模块的全部函数。
# 只补测第 6 章迁移表里没有的旧 API(完整表见第 6 章 6.4)
extra_old = [
# (旧写法, 推荐的新写法, (模块, 属性名))
("jax.tree_all", "jax.tree.all", (jax, "tree_all")),
("jax.tree_util.register_keypaths", "register_pytree_with_keys", (jax.tree_util, "register_keypaths")),
("jax.tree_util.build_tree", "jax.tree.unflatten", (jax.tree_util, "build_tree")),
]
print(wpad("旧写法", 36) + wpad("0.12 状态", 12) + "推荐写法")
print("-" * 84)
for old, new, (mod, attr) in extra_old:
print(wpad(old, 36) + wpad("仍可用" if hasattr(mod, attr) else "已移除", 12) + new)
assert not any(hasattr(mod, attr) for _, _, (mod, attr) in extra_old)
assert not hasattr(jax, "tree_map") and hasattr(jax.tree_util, "tree_map") # 第 6 章表格的要点
# jax.tree 模块提供的全部函数(broadcast / reduce_associative / static 等较新,旧教程里没有)
print("\njax.tree 模块:", [n for n in dir(jax.tree) if not n.startswith("_")])
try:
jax.tree_map(lambda x: x, [1])
except AttributeError as e:
print("实测旧写法:", f"AttributeError: {str(e)[:70]}")
tree = {"layer2": {"w": jnp.ones((3, 2)), "b": jnp.zeros(2)},
"layer1": {"w": jnp.ones((4, 3)), "b": jnp.zeros(3)}}
# map:可以同时作用于多棵结构相同的树
doubled = jax.tree.map(lambda a, b: a + b, tree, tree)
assert float(doubled["layer1"]["w"][0, 0]) == 2.0
# leaves / structure / flatten / unflatten:dict 按键排序展平
leaves, treedef = jax.tree.flatten(tree)
assert [l.shape for l in leaves] == [(3,), (4, 3), (2,), (3, 2)] # layer1 在前,b 在 w 前
assert jax.tree.structure(tree) == treedef and jax.tree.leaves(tree)[0].shape == (3,)
rebuilt = jax.tree.unflatten(treedef, [l + 1 for l in leaves])
assert float(rebuilt["layer2"]["w"][0, 0]) == 2.0
# reduce / reduce_associative:把所有叶子归约成一个值
n_params = jax.tree.reduce(lambda acc, l: acc + l.size, tree, 0)
n_params2 = jax.tree.reduce_associative(lambda a, b: a + b, jax.tree.map(lambda l: l.size, tree))
assert n_params == n_params2 == 3 * 2 + 2 + 4 * 3 + 3
# all:所有叶子都为真
assert jax.tree.all(jax.tree.map(lambda l: bool(jnp.all(jnp.isfinite(l))), tree))
# transpose:「列表的 dict」⇄「dict 的列表」(例如把逐步记录的指标转置)
metrics_per_step = [{"loss": 1.0, "acc": 0.5}, {"loss": 0.8, "acc": 0.6}, {"loss": 0.5, "acc": 0.9}]
by_key = jax.tree.transpose(jax.tree.structure([0] * 3), jax.tree.structure({"loss": 0, "acc": 0}), metrics_per_step)
assert by_key == {"loss": [1.0, 0.8, 0.5], "acc": [0.5, 0.6, 0.9]}
# broadcast:把一棵「前缀树」广播到完整结构(例如按层设置不同的学习率倍数)
lr_mult = jax.tree.broadcast({"layer1": 0.1, "layer2": 1.0}, tree)
assert lr_mult["layer1"] == {"b": 0.1, "w": 0.1} and lr_mult["layer2"]["w"] == 1.0
# map_with_path / leaves_with_path / flatten_with_path:带上每个叶子的路径
names = [jax.tree_util.keystr(p, simple=True, separator=".") for p, _ in jax.tree.leaves_with_path(tree)]
assert names == ["layer1.b", "layer1.w", "layer2.b", "layer2.w"]
decay_mask = jax.tree.map_with_path(lambda p, l: p[-1].key == "w", tree) # 只对权重做衰减,不对偏置
assert decay_mask == {"layer1": {"b": False, "w": True}, "layer2": {"b": False, "w": True}}
(paths_leaves, _) = jax.tree.flatten_with_path(tree)
assert len(paths_leaves) == 4 and jax.tree_util.keystr(paths_leaves[0][0]) == "['layer1']['b']"
print("keystr 默认格式:", jax.tree_util.keystr(paths_leaves[3][0]), "| simple 格式:", names[3])
print("按路径生成的权重衰减 mask:", decay_mask)
print("jax.tree.* 全部演示通过 ✔")
上面的 decay_mask 可以直接传给专题 5 的 add_decayed_weights(wd, mask=...),实现「只对权重做衰减」。
7.4 ❌ 常见错误与实测¶
🟢 必读
- 把形状 tuple 当成叶子:
jax.tree.map(jnp.zeros, {"w": (2, 3)})会钻进 tuple,把2和3各当成一个叶子,得到两个一维数组。要用is_leaf告诉它「tuple 就是叶子」; None不是叶子:None是空子树,tree.leaves会跳过它,叶子个数和你想的不一样;- 结构不一致的两棵树做
map:直接报错; - 普通 dataclass 没注册:它会被当成一个不透明的叶子,
tree.leaves只返回对象本身,传进 jit 时报TypeError; - 以为 dict 的叶子按插入顺序排列:其实展平时是按键排序的,自己用
zip(names, leaves)配名字会错位,应该用leaves_with_path。
# ❌ 1:形状 tuple 被当成子树
shapes = {"w": (2, 3), "b": (3,)}
wrong = jax.tree.map(jnp.zeros, shapes)
right = jax.tree.map(jnp.zeros, shapes, is_leaf=lambda x: isinstance(x, tuple))
print("❌ 不加 is_leaf:", jax.tree.map(lambda a: a.shape, wrong))
print("✅ 加 is_leaf :", jax.tree.map(lambda a: a.shape, right))
assert right["w"].shape == (2, 3) and isinstance(wrong["w"], tuple)
# ❌ 2:None 不是叶子
assert len(jax.tree.leaves({"a": 1.0, "b": None})) == 1
# ❌ 3:结构不一致
show_error(lambda: jax.tree.map(jnp.add, {"w": 1.0}, {"w": 1.0, "b": 2.0}))
# ❌ 4:没注册的 dataclass 是一个不透明叶子
@dataclass
class PlainState:
w: jax.Array
ps = PlainState(jnp.ones(2))
print("未注册 dataclass 的叶子:", jax.tree.leaves(ps))
assert jax.tree.leaves(ps) == [ps]
show_error(lambda: jax.jit(lambda s: s.w * 2)(ps))
# ❌ 5:按插入顺序配对名字
ordered = {"z_last": jnp.ones(1), "a_first": jnp.zeros(5)}
naive_pairs = list(zip(ordered.keys(), [l.shape for l in jax.tree.leaves(ordered)]))
path_pairs = [(jax.tree_util.keystr(p, simple=True), l.shape) for p, l in jax.tree.leaves_with_path(ordered)]
print("❌ zip(keys, leaves):", naive_pairs, "\n✅ leaves_with_path :", path_pairs)
assert naive_pairs[0] == ("z_last", (5,)) and path_pairs[0] == ("a_first", (5,))
7.5 TrainState:register_dataclass 与 NamedTuple¶
🟢 必读
TrainState 推荐用 @jax.tree_util.register_dataclass + @dataclass(frozen=True):
- 字段有名字、有类型注解,字段名打错了马上报错;
frozen=True从语法上禁止原地修改,更新用dataclasses.replace(state, ...);- 用
jax.tree.static(...)(等价于field(metadata=dict(static=True)))声明静态字段。它不是叶子,而是 treedef 的一部分,适合放「模型配置」这类很少变的东西。但要记住,静态字段的值一变就会重新编译。
NamedTuple 也能用(不用配置、自带 _replace),缺点是字段可以按位置索引,也没法声明静态字段。
@jax.tree_util.register_dataclass
@dataclass(frozen=True)
class TrainState:
step: jax.Array
params: Any
opt_state: Any
key: jax.Array
model_name: str = jax.tree.static(default="mlp") # 静态字段:不是叶子
ts = TrainState(step=jnp.zeros([], jnp.int32), params={"w": jnp.ones(2)}, opt_state=(),
key=jax.random.key(0))
print("叶子数:", len(jax.tree.leaves(ts)), "| treedef:", jax.tree.structure(ts))
assert len(jax.tree.leaves(ts)) == 3 # step、w、key;model_name 不在其中
# 更新:dataclasses.replace 生成新对象,原对象不变;frozen 禁止原地修改
ts2 = dataclasses.replace(ts, step=ts.step + 1)
assert int(ts.step) == 0 and int(ts2.step) == 1
show_error(lambda: setattr(ts, "step", 5))
# 能直接穿过 jit;但 ❌ 对整个 TrainState 求 grad 会失败:step 是 int32、key 是 PRNG key,都不可微
def ts_loss(state, x):
return jnp.sum((state.params["w"] * x) ** 2)
x_ts = jnp.array([1.0, 2.0])
print("jit 前向:", jax.jit(ts_loss)(ts, x_ts))
show_error(lambda: jax.grad(ts_loss)(ts, x_ts))
# ✅ 只对 params 求导:把 params 作为被求导的参数,其余字段原样带上
g_params = jax.jit(jax.grad(lambda p, s, x: ts_loss(dataclasses.replace(s, params=p), x)))(ts.params, ts, x_ts)
print("✅ 对 params 求导:", g_params)
assert np.allclose(g_params["w"], [2.0, 8.0])
# 静态字段变化 → 重新编译
def ts_step(state):
return dataclasses.replace(state, step=state.step + 1)
f_ts = jax.jit(ts_step)
n_static = count_compiles("ts_step", [lambda: f_ts(ts), lambda: f_ts(ts2),
lambda: f_ts(dataclasses.replace(ts, model_name="cnn"))])
print("同一 model_name 两次 + 换一个 model_name → 编译", n_static, "次")
assert n_static == 2
# NamedTuple 版本作对照
class TrainStateNT2(NamedTuple):
step: jax.Array
params: Any
nt = TrainStateNT2(jnp.zeros([], jnp.int32), {"w": jnp.ones(2)})
assert int(nt._replace(step=nt.step + 1).step) == 1 and nt[0] is nt.step # 可以按位置索引
register_dataclass + frozen | NamedTuple | 普通 dict | |
|---|---|---|---|
| 字段名检查 | ✅ | ✅ | ❌(拼错只会新建一个键) |
| 禁止原地修改 | ✅ frozen=True | ✅ | ❌ |
| 静态字段 | ✅ jax.tree.static | ❌ | ❌ |
| 更新方式 | dataclasses.replace | ._replace | {**d, k: v} |
| 额外依赖 | 无 | 无 | 无 |
7.6 用 keystr + map_with_path 打印参数表¶
⚪ 选读:想打印 PyTorch 风格的参数表时
最后写一个实用工具:给任意参数树打印「名称、形状、dtype、参数量」表格。名称用 keystr(path, simple=True, separator=".") 生成,和 PyTorch 的 named_parameters() 风格一致。
def param_table(params, title="参数表"):
# 第 1 步:map_with_path 把每个叶子换成 (名称, 形状, dtype, 参数量)
info = jax.tree.map_with_path(
lambda p, l: (jax.tree_util.keystr(p, simple=True, separator="."), tuple(l.shape), str(l.dtype), int(l.size)),
params)
# 第 2 步:把这些 tuple 当作叶子取出来(否则 tree.leaves 会钻进 tuple 里面)
rows = jax.tree.leaves(info, is_leaf=lambda x: isinstance(x, tuple))
total = sum(r[3] for r in rows)
width = max(len(r[0]) for r in rows) + 2
print(f"{title}(共 {len(rows)} 个张量,{total:,} 个参数)")
print(wpad("名称", width) + wpad("形状", 16) + wpad("dtype", 10) + wpad("参数量", 10) + "占比")
for name, shape, dtype, size in rows:
print(f"{name:<{width}}{str(shape):<16}{dtype:<10}{size:>8,} {size / total:6.1%}")
return rows, total
rows, total = param_table(params_ps, "第 4 节 MLP 的参数表")
assert total == P == sum(l.size for l in jax.tree.leaves(params_ps))
assert [r[0] for r in rows] == ["dense_0.b", "dense_0.w", "dense_1.b", "dense_1.w"]
assert rows[1][1] == (D_IN, 64)
💡 实战规则¶
🟢 必读
一句话:只用
jax.tree.*(旧的jax.tree_map等已移除)。TrainState 用register_dataclass+frozen=True;静态配置用jax.tree.static,并记住它的值一变就会重新编译。需要「名字」时一律通过*_with_path+keystr拿,别自己按顺序配对。
★ 综合项目:从零写一个螺旋数据 MLP 分类器¶
把 7 个专题串起来:不用 Optax、Flax,只用本章写过的工具,完成一个完整的训练项目。各部分对应的专题如下:
| 组件 | 实现 | 专题 |
|---|---|---|
| 数据 | jax.random 生成三臂螺旋,作为参数传入 jit | 0(环境问题) |
| TrainState | register_dataclass + frozen=True,含 step / params / opt_state / key | 7 |
| 参数表 | map_with_path + keystr | 7 |
| 优化器 | chain(clip_by_global_norm, scale_by_adam, add_decayed_weights(mask), scale_by_learning_rate(warmup-cosine)) | 5 |
| 损失 | 稳定的 log_softmax_stable 交叉熵 | 2 |
| 训练 | jit(donate_argnames=("state",)) 的 train_epoch,内部用 lax.scan 遍历打乱后的 mini-batch | 6、1 |
| 编译预算 | CompileBudget(1, "train_epoch"):150 个 epoch 只允许编译 1 次 | 1 |
| 难样本 | 逐样本梯度范数(lax.map(batch_size=) 分块),取 top-5 | 4 |
| 验证 | NumPy float64 前向对拍;准确率 > 95% | 全部 |
★.1 数据:三臂螺旋¶
⚪ 选读:综合项目:把 7 个专题串起来,学完各专题后作为整体示例
def make_spiral(key, n_per_class, n_classes=3, noise=0.15):
"""经典的螺旋数据:半径 r∈[0,1],第 j 类的角度在 [4j, 4j+4] 之间并加噪声。"""
k_r, k_n = jax.random.split(key)
r = jax.random.uniform(k_r, (n_classes, n_per_class))
cls = jnp.arange(n_classes)[:, None]
theta = 4.0 * cls + 4.0 * r + noise * jax.random.normal(k_n, (n_classes, n_per_class))
X = jnp.stack([r * jnp.sin(theta), r * jnp.cos(theta)], axis=-1).reshape(-1, 2)
y = jnp.broadcast_to(cls, (n_classes, n_per_class)).reshape(-1)
return X.astype(jnp.float32), y.astype(jnp.int32)
k_data, k_split, k_init, k_train = jax.random.split(jax.random.key(2026), 4)
X_all_s, y_all_s = make_spiral(k_data, 600)
perm = jax.random.permutation(k_split, X_all_s.shape[0])
n_test = 360
X_test, y_test = X_all_s[perm[:n_test]], y_all_s[perm[:n_test]]
X_train, y_train = X_all_s[perm[n_test:]], y_all_s[perm[n_test:]]
print(f"训练集 {X_train.shape},测试集 {X_test.shape},类别计数(训练): {np.bincount(np.asarray(y_train))}")
assert X_train.shape == (1440, 2) and X_train.dtype == jnp.float32 and y_train.dtype == jnp.int32
★.2 模型、TrainState 与优化器¶
⚪ 选读:综合项目:模型、TrainState 与优化器的组装
- 模型复用专题 4 的
init_mlp/mlp_apply(ReLU MLP),结构 2 → 64 → 64 → 3; - TrainState 复用专题 7 定义的
TrainStatedataclass。所有数组都独立创建、dtype 固定(step是int32数组,key 是类型化 key),保证train_epoch前后状态的类型签名完全一样; - 优化器:先按全局范数裁剪到 1.0,再做 Adam,再只对权重(
w)做解耦衰减,最后乘以 warmup + cosine 学习率。mask 用map_with_path按路径生成。
BATCH, EPOCHS = 128, 150
STEPS_PER_EPOCH = X_train.shape[0] // BATCH
TOTAL_STEPS = EPOCHS * STEPS_PER_EPOCH
lr_schedule = warmup_cosine_decay_schedule(init_value=0.0, peak_value=1e-2, warmup_steps=100,
decay_steps=TOTAL_STEPS, end_value=1e-4)
weight_mask = lambda params: jax.tree.map_with_path(lambda p, _: p[-1].key == "w", params)
optimizer = chain(clip_by_global_norm(1.0),
scale_by_adam(),
add_decayed_weights(1e-4, mask=weight_mask),
scale_by_learning_rate(lr_schedule))
params_init = init_mlp(k_init, (2, 64, 64, 3))
state = TrainState(step=jnp.zeros([], jnp.int32), params=params_init,
opt_state=optimizer.init(params_init), key=k_train, model_name="spiral-mlp")
_ = param_table(state.params, "螺旋 MLP 参数表")
print("权重衰减 mask:", weight_mask(state.params))
print("TrainState 叶子数:", len(jax.tree.leaves(state)), "| 静态字段 model_name =", state.model_name)
# 状态里不能有共用的数组对象(否则捐赠时报 “donate the same buffer twice”,见专题 6)
ids = [id(l) for l in jax.tree.leaves(state)]
assert len(ids) == len(set(ids))
★.3 损失与训练函数¶
⚪ 选读:综合项目:用 lax.scan 把整个 epoch 编译成一个程序
ce_loss用专题 2 的log_softmax_stable,第一次用之前先和jax.nn.log_softmax对拍;train_epoch(state, X, Y):在 jit 内部用 state 里的 key 打乱数据,切成形状固定的 mini-batch,再用lax.scan依次执行训练 step。整个 epoch 是一个编译好的程序;X、Y作为参数传入(不让闭包捕获,也不捐赠),只捐赠state。
def ce_loss(params, X, Y):
logp = log_softmax_stable(mlp_apply(params, X))
return -jnp.mean(jnp.take_along_axis(logp, Y[:, None], axis=1))
ref_ce = -jnp.mean(jnp.take_along_axis(jax.nn.log_softmax(mlp_apply(params_init, X_train)), y_train[:, None], axis=1))
assert np.isclose(ce_loss(params_init, X_train, y_train), ref_ce, rtol=1e-6)
def train_epoch(state, X, Y):
key, k_perm = jax.random.split(state.key)
idx = jax.random.permutation(k_perm, X.shape[0])[: STEPS_PER_EPOCH * BATCH].reshape(STEPS_PER_EPOCH, BATCH)
def one_step(st, batch_idx):
loss, grads = jax.value_and_grad(ce_loss)(st.params, X[batch_idx], Y[batch_idx])
updates, opt_state = optimizer.update(grads, st.opt_state, st.params)
st = dataclasses.replace(st, step=st.step + 1, params=apply_updates(st.params, updates), opt_state=opt_state)
return st, (loss, global_norm(grads))
state = dataclasses.replace(state, key=key)
state, (losses, gnorms) = lax.scan(one_step, state, idx)
return state, losses, gnorms
train_epoch_jit = jax.jit(train_epoch, donate_argnames=("state",))
@jax.jit
def accuracy_fn(params, X, Y):
return jnp.mean(jnp.argmax(mlp_apply(params, X), axis=-1) == Y)
print(f"每个 epoch {STEPS_PER_EPOCH} 步,共 {EPOCHS} 个 epoch、{TOTAL_STEPS} 步")
★.4 训练:编译预算 = 1¶
⚪ 选读:综合项目:在编译预算下训练并验证捐赠
整个训练循环放在 CompileBudget(1, "train_epoch") 里。不管什么原因(弱类型、dtype 变化、pytree 结构变化……),只要多编译了一次,这里就会直接抛出 CompileBudgetExceeded。
init_leaf = jax.tree.leaves(state.params)[0]
step_losses, step_gnorms, test_acc_curve = [], [], []
t0 = time.perf_counter()
with CompileBudget(1, "train_epoch") as budget:
for epoch in range(EPOCHS):
state, losses, gnorms = train_epoch_jit(state, X_train, y_train) # 立即覆盖旧 state
step_losses.append(losses); step_gnorms.append(gnorms)
if epoch % 10 == 9 or epoch == 0:
test_acc_curve.append((epoch + 1, float(accuracy_fn(state.params, X_test, y_test))))
jax.block_until_ready(state)
train_time = time.perf_counter() - t0
step_losses = np.concatenate([np.asarray(l) for l in step_losses])
step_gnorms = np.concatenate([np.asarray(g) for g in step_gnorms])
n_compiles = budget.count
train_acc = float(accuracy_fn(state.params, X_train, y_train))
test_acc = float(accuracy_fn(state.params, X_test, y_test))
print(f"训练 {EPOCHS} 个 epoch 用时 {train_time:.2f} s;train_epoch 编译次数 = {n_compiles}")
print(f"loss:{step_losses[0]:.4f} → {step_losses[-1]:.4f};step = {int(state.step)}")
print(f"训练集准确率 {train_acc:.4f},测试集准确率 {test_acc:.4f}")
print("初始参数的缓冲区已被捐赠:", init_leaf.is_deleted(), "| 训练数据未被删除:", not X_train.is_deleted())
assert n_compiles == 1
assert int(state.step) == TOTAL_STEPS
assert init_leaf.is_deleted() and not X_train.is_deleted()
assert train_acc > 0.95 and test_acc > 0.95
★.5 用 NumPy float64 对拍¶
⚪ 选读:综合项目:训练结果的独立数值核对
训练用的是 float32,评估结果再用 NumPy float64 独立算一遍:前向 logits、交叉熵、准确率都必须一致。这一步也是防着「本环境 CPU 后端会悄悄算错」的保险。
P64 = jax.tree.map(to64, jax.device_get(state.params))
def np_forward(p, X):
h = np.asarray(X, np.float64)
n = len(p)
for i in range(n):
h = h @ p[f"dense_{i}"]["w"] + p[f"dense_{i}"]["b"]
if i < n - 1:
h = np.maximum(h, 0.0)
return h
logits_np = np_forward(P64, X_test)
logits_jx = np.asarray(mlp_apply(state.params, X_test))
lsm = logits_np - logits_np.max(1, keepdims=True)
lsm = lsm - np.log(np.exp(lsm).sum(1, keepdims=True))
ce_np = -np.mean(lsm[np.arange(n_test), np.asarray(y_test)])
acc_np = np.mean(logits_np.argmax(1) == np.asarray(y_test))
print(f"logits 最大误差 {np.abs(logits_np - logits_jx).max():.2e};"
f"测试交叉熵 JAX {float(ce_loss(state.params, X_test, y_test)):.6f} / NumPy {ce_np:.6f};准确率 NumPy {acc_np:.4f}")
assert np.allclose(logits_np, logits_jx, atol=1e-4), np.abs(logits_np - logits_jx).max()
assert np.isclose(float(ce_loss(state.params, X_test, y_test)), ce_np, rtol=1e-3, atol=1e-6), ce_np
assert np.isclose(acc_np, test_acc, atol=1e-6), (acc_np, test_acc) # 预测类别完全相同
★.6 逐样本梯度:找出 top-5「难样本」¶
⚪ 选读:综合项目:用逐样本梯度范数做训练诊断
用专题 4 的 per_sample_norm(单样本损失的梯度范数),通过 lax.map(batch_size=256) 在整个训练集上分块计算,再用 lax.top_k 取范数最大的 5 个样本。梯度范数大,说明这个样本对参数的「拉力」最大,通常也是模型最拿不准的点。
@jax.jit
def grad_norms_all(params, X, Y):
return lax.map(lambda xy: per_sample_norm(params, *xy), (X, Y), batch_size=256)
norms_train = grad_norms_all(state.params, X_train, y_train)
top_norms, top_idx = lax.top_k(norms_train, 5)
per_loss = jax.vmap(loss_single, in_axes=(None, 0, 0))(state.params, X_train, y_train)
probs_top = jax.nn.softmax(mlp_apply(state.params, X_train[top_idx]))
print(" 排名 样本 坐标 真实类 预测类 p(真实类) 梯度范数 损失")
for rank, i in enumerate(np.asarray(top_idx)):
x, yv = np.asarray(X_train[i]), int(y_train[i])
pr = np.asarray(probs_top[rank])
print(f" {rank + 1} #{i:<5} ({x[0]:+.3f}, {x[1]:+.3f}) {yv} {pr.argmax()} {pr[yv]:.3f} "
f"{float(norms_train[i]):7.3f} {float(per_loss[i]):6.3f}")
# 自证:分块结果与 vmap 全量结果一致;top-5 的范数确实最大;难样本的平均损失远高于整体
full = jax.vmap(per_sample_norm, in_axes=(None, 0, 0))(state.params, X_train, y_train)
assert np.allclose(norms_train, full, rtol=1e-4, atol=1e-5), np.abs(norms_train - full).max()
assert float(top_norms.min()) >= float(jnp.sort(norms_train)[-6]), top_norms
assert float(per_loss[top_idx].mean()) > 10 * float(per_loss.mean()), (per_loss[top_idx].mean(), per_loss.mean())
print(f"\n难样本平均损失 {float(per_loss[top_idx].mean()):.3f},全体平均 {float(per_loss.mean()):.4f}")
r_hard = np.linalg.norm(np.asarray(X_train[top_idx]), axis=1)
wrong_test = np.asarray(jnp.argmax(mlp_apply(state.params, X_test), -1) != y_test)
r_wrong = np.linalg.norm(np.asarray(X_test)[wrong_test], axis=1)
print(f"难样本到原点的距离: {np.round(r_hard, 4)};测试集错分样本 {wrong_test.sum()} 个,到原点距离: {np.round(r_wrong, 3)}")
assert r_hard.max() < 0.05 and (r_wrong < 0.05).all() # 难样本与错分样本都在原点附近
读结果:5 个难样本全都挤在原点附近($r\approx0$)。这正是螺旋数据本身的难点:三条螺旋臂都从原点出发,这里不同类别的点几乎叠在一起,模型给真实类别的概率只有 0.35–0.55,损失比平均值高两个数量级。远处被噪声甩到别的臂上的点反而很少。测试集上唯一分错的样本也在原点旁边(距离约 0.001)。也就是说,这个任务上剩下的误差来自原点附近躲不开的歧义,不是模型容量不够。
★.7 可视化:loss 曲线与决策边界¶
⚪ 选读:综合项目:结果可视化
grid_n = 300
gx, gy = np.meshgrid(np.linspace(-1.15, 1.15, grid_n), np.linspace(-1.15, 1.15, grid_n))
grid = jnp.asarray(np.stack([gx.ravel(), gy.ravel()], axis=1), jnp.float32)
predict = jax.jit(lambda params, X: jnp.argmax(mlp_apply(params, X), axis=-1)) # params 作为参数传入
zz = np.asarray(predict(state.params, grid)).reshape(grid_n, grid_n)
fig = plt.figure(figsize=(12, 4.6))
gs = fig.add_gridspec(2, 2, width_ratios=[1.35, 1], height_ratios=[2.2, 1], hspace=0.08, wspace=0.18)
ax_loss = fig.add_subplot(gs[0, 0])
ax_lr = fig.add_subplot(gs[1, 0], sharex=ax_loss)
ax_db = fig.add_subplot(gs[:, 1])
steps_axis = np.arange(1, len(step_losses) + 1)
ax_loss.plot(steps_axis, step_losses, color=C_BLUE, lw=0.8, alpha=0.35, label="每步 loss")
epoch_mean = step_losses.reshape(EPOCHS, STEPS_PER_EPOCH).mean(1)
ax_loss.plot(np.arange(1, EPOCHS + 1) * STEPS_PER_EPOCH, epoch_mean, color=C_BLUE, lw=2, label="每个 epoch 的平均")
ax_loss.set_yscale("log"); ax_loss.set_ylabel("训练 loss(对数)")
ax_loss.set_title(f"训练过程(测试准确率 {test_acc:.1%},train_epoch 编译 {n_compiles} 次)", fontsize=11)
ax_loss.legend(frameon=False, fontsize=9); ax_loss.grid(True, color="#e5e5e5", lw=0.8)
plt.setp(ax_loss.get_xticklabels(), visible=False)
ax_lr.plot(steps_axis, np.asarray(jax.vmap(lr_schedule)(jnp.arange(len(step_losses)))), color=C_RED, lw=1.8)
ax_lr.set_ylabel("学习率"); ax_lr.set_xlabel("步数"); ax_lr.grid(True, color="#e5e5e5", lw=0.8)
from matplotlib.colors import ListedColormap
class_colors = [C_BLUE, C_ORANGE, C_AQUA]
ax_db.contourf(gx, gy, zz, levels=[-0.5, 0.5, 1.5, 2.5], cmap=ListedColormap(class_colors), alpha=0.18)
ax_db.contour(gx, gy, zz, levels=[0.5, 1.5], colors="#666666", linewidths=0.8)
Xtr_np, ytr_np = np.asarray(X_train), np.asarray(y_train)
for c, col in enumerate(class_colors):
m = ytr_np == c
ax_db.scatter(Xtr_np[m, 0], Xtr_np[m, 1], s=9, color=col, edgecolors="white", linewidths=0.3, label=f"类别 {c}")
hard = Xtr_np[np.asarray(top_idx)]
ax_db.scatter(hard[:, 0], hard[:, 1], s=170, facecolors="none", edgecolors="#111111", linewidths=1.6, label="top-5 难样本")
ax_db.set_aspect("equal"); ax_db.set_xlim(-1.15, 1.15); ax_db.set_ylim(-1.15, 1.15)
# 难样本挤在原点附近,加一个放大的小图
R = 0.012
axins = ax_db.inset_axes([0.62, 0.02, 0.36, 0.36])
lg = np.linspace(-R, R, 200)
lx, ly = np.meshgrid(lg, lg)
lz = np.asarray(predict(state.params, jnp.asarray(np.stack([lx.ravel(), ly.ravel()], 1), jnp.float32))).reshape(200, 200)
axins.contourf(lx, ly, lz, levels=[-0.5, 0.5, 1.5, 2.5], cmap=ListedColormap(class_colors), alpha=0.18)
axins.contour(lx, ly, lz, levels=[0.5, 1.5], colors="#666666", linewidths=0.8)
near = (np.abs(Xtr_np) < R).all(1)
axins.scatter(Xtr_np[near, 0], Xtr_np[near, 1], s=14, c=[class_colors[c] for c in ytr_np[near]],
edgecolors="white", linewidths=0.3)
axins.scatter(hard[:, 0], hard[:, 1], s=120, facecolors="none", edgecolors="#111111", linewidths=1.4)
for r, (hx, hy) in enumerate(hard):
axins.annotate(str(r + 1), (hx, hy), xytext=(5, 5), textcoords="offset points", fontsize=8)
axins.set_xlim(-R, R); axins.set_ylim(-R, R); axins.set_xticks([]); axins.set_yticks([])
axins.set_title("原点附近放大", fontsize=8)
ax_db.indicate_inset_zoom(axins, edgecolor="#666666")
ax_db.set_title("决策边界与难样本(训练集)", fontsize=11)
ax_db.legend(frameon=False, fontsize=8, loc="lower left")
plt.show()
print("测试准确率随训练变化:", ", ".join(f"ep{e}={a:.3f}" for e, a in test_acc_curve))
★.8 项目小结¶
⚪ 选读:综合项目的检查清单与延伸实验
| 检查项 | 结果 |
|---|---|
train_epoch 编译次数 | 1(CompileBudget 断言) |
| 捐赠生效 | 初始参数缓冲区已删除,训练数据未受影响 |
| 数值 | logits / 交叉熵 / 准确率与 NumPy float64 一致 |
| 准确率 | 训练集、测试集都 > 95%(断言) |
| 难样本 | 分块逐样本梯度与全量 vmap 一致;top-5 都集中在三条螺旋臂交汇的原点附近 |
还可以接着试试:
- 把
noise调大,看难样本怎么变; - 把
BATCH改成除不尽的值,看编译预算会不会报警(不会。STEPS_PER_EPOCH是静态的,丢掉的余数不改变形状); - 把学习率改成静态参数(
static_argnames),在循环里传进去,看预算怎么报警。如果只是改成每个 epoch 传一个 Python 浮点数,就不会报警,因为弱类型标量的值变了不会触发重新编译。
练习 1:给服务函数写编译预算测试¶
⚪ 选读:练习:巩固专题 1
下面的 serve 对每个请求做行归一化。它有两个会导致重新编译的问题:一是请求的行数各不相同,二是 eps 有时是 Python 浮点数,有时是 jnp.float32 数组。
要求:
- 写一个测试函数
test_serve_compiles(serve_fn, fun_name, budget):用专题 1 的CompileBudget服务下面准备好的 20 个请求,返回(是否在预算内, 编译次数); - 用它证明原始的
serve在预算 3 次下不通过; - 写
serve_fixed:把行数补齐到桶(32, 64, 128)(在 NumPy 里做 padding),把eps统一成np.float32,使测试通过; - 输出必须与 NumPy float64 参考值一致(去掉 padding 的行之后)。
自检接口:test_serve_compiles(serve_fn, fun_name, budget) 返回的元组前两项是 (是否在预算内, 编译次数);serve_fixed(batches, eps_list) 与 serve 签名相同,返回去掉 padding 后的 NumPy 数组列表。自检会先 jax.clear_caches(),再用新的请求统计编译次数。
# 题目给出的代码
def normalize_rows(x, eps):
return x / (jnp.sqrt(jnp.sum(x ** 2, axis=1, keepdims=True)) + eps)
normalize_jit = jax.jit(normalize_rows)
def serve(batches, eps_list):
return [np.asarray(normalize_jit(b, e)) for b, e in zip(batches, eps_list)]
ex1_lengths = [int(n) for n in jax.random.randint(jax.random.key(70), (20,), 5, 120)]
ex1_batches = [np.asarray(jax.random.normal(k, (n, 8))) for k, n in zip(jax.random.split(jax.random.key(71), 20), ex1_lengths)]
ex1_eps = [1e-6 if i % 2 else jnp.float32(1e-6) for i in range(20)] # Python float 和数组交替出现
print("20 个请求的行数:", ex1_lengths)
# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
test_serve_compiles, serve_fixed
except NameError as e:
print(f"⏳ 练习 1 尚未作答:{e}")
else:
def _selfcheck():
jax.clear_caches() # 清空编译缓存,让统计从零开始
r = test_serve_compiles(serve, "normalize_rows", 3)
assert not r[0] and r[1] > 3, f"原始 serve 应超出 3 次的预算,你的测试返回 {r[:2]}"
jax.clear_caches()
r_big = test_serve_compiles(serve, "normalize_rows", 1000)
assert r_big[0] and r_big[1] == r[1], "预算足够大时测试应判为通过,且编译次数不变"
# 新请求:行数覆盖三个桶的边界,eps 也换一个值,Python float 与 jnp.float32 交替
lengths = [1, 31, 32, 33, 64, 65, 100, 128, 7, 50, 127, 2]
rng = np.random.default_rng(11)
batches = [rng.standard_normal((n, 8)).astype(np.float32) for n in lengths]
eps_list = [1e-3 if i % 2 else jnp.float32(1e-3) for i in range(len(lengths))]
jax.clear_caches()
with CompileCounter() as cc: # 统计所有函数的编译
outs = serve_fixed(batches, eps_list)
assert cc.count <= 3, f"serve_fixed 处理 12 个新请求编译了 {cc.count} 次(应 ≤ 3):{cc.events}"
assert len(outs) == len(batches), "每个请求应有一个输出"
for b, o in zip(batches, outs):
o = np.asarray(o)
assert o.shape == b.shape, f"输出形状 {o.shape} 应与输入 {b.shape} 相同(padding 行要去掉)"
b64 = b.astype(np.float64)
ref = b64 / (np.sqrt((b64 ** 2).sum(1, keepdims=True)) + 1e-3)
assert np.allclose(o, ref, atol=1e-6), "serve_fixed 的输出与 NumPy float64 参考值不一致"
_selfcheck(); del _selfcheck
print("✅ 练习 1 自检通过")
练习 2:安全的余弦相似度¶
⚪ 选读:练习:巩固专题 2 的双 where
只要有一个输入是零向量,$\cos(a,b)=\dfrac{a\cdot b}{\lVert a\rVert\,\lVert b\rVert}$ 就是 $0/0$。很多代码用 jnp.where 把它补成 0,但梯度还是 NaN。
要求:
- 先实测:
cos_sim_where(单where版本)在 $a=0$ 处前向是 0,但jax.grad对 $a$ 的梯度含 NaN; - 用双
where写cos_sim_safe(a, b):任一输入为零向量时返回 0,且对 $a$、$b$ 的梯度都有限; - 在正常点上,梯度要等于解析式 $\dfrac{\partial\cos}{\partial a} = \dfrac{b}{\lVert a\rVert\lVert b\rVert} - \dfrac{(a\cdot b)\,a}{\lVert a\rVert^3\lVert b\rVert}$(NumPy float64),并通过
check_grads; - 对一个含零向量行的 batch 用
vmap(grad)求梯度,结果中不能有 NaN。
自检接口:cos_sim_safe(a, b),a、b 是一维数组,返回标量。
# 题目给出的代码
def cos_sim_where(a, b):
na, nb = jnp.linalg.norm(a), jnp.linalg.norm(b)
ok = (na > 0) & (nb > 0)
return jnp.where(ok, jnp.dot(a, b) / (na * nb), 0.0)
# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
cos_sim_safe
except NameError as e:
print(f"⏳ 练习 2 尚未作答:{e}")
else:
def _selfcheck():
rng = np.random.default_rng(22)
a64, b64 = rng.standard_normal(6), rng.standard_normal(6)
a, b = jnp.asarray(a64, jnp.float32), jnp.asarray(b64, jnp.float32)
na, nb = np.linalg.norm(a64), np.linalg.norm(b64)
c = a64 @ b64 / (na * nb)
assert np.isclose(float(cos_sim_safe(a, b)), c, atol=1e-6), "正常点的余弦值不对"
ga, gb = jax.grad(cos_sim_safe, argnums=(0, 1))(a, b)
assert np.allclose(ga, b64 / (na * nb) - c * a64 / na ** 2, atol=1e-5), "对 a 的梯度与解析式不符"
assert np.allclose(gb, a64 / (na * nb) - c * b64 / nb ** 2, atol=1e-5), "对 b 的梯度与解析式不符"
assert np.isclose(float(cos_sim_safe(1e-3 * a, a)), 1.0, atol=1e-5), "很小但非零的向量不应被当成零向量"
z = jnp.zeros(6)
for name, (u, v) in {"a=0": (z, b), "b=0": (a, z), "a=b=0": (z, z)}.items():
assert float(cos_sim_safe(u, v)) == 0.0, f"{name} 时应返回 0"
gu, gv = jax.grad(cos_sim_safe, argnums=(0, 1))(u, v)
assert bool(jnp.all(jnp.isfinite(gu)) & jnp.all(jnp.isfinite(gv))), f"{name} 时梯度含 NaN/inf"
A = jnp.stack([a, z, a, z]); B = jnp.stack([b, b, z, z])
gbatch = jax.jit(jax.vmap(jax.grad(cos_sim_safe)))(A, B)
assert bool(jnp.all(jnp.isfinite(gbatch))), "含零向量行的 batch 在 vmap(grad) 下出现了 NaN"
_selfcheck(); del _selfcheck
print("✅ 练习 2 自检通过")
练习 3:牛顿-CG——只用 HVP 做二阶优化¶
⚪ 选读:练习:巩固专题 3 的 HVP
牛顿法每步要解 $H d = -\nabla f$。参数维度 $n$ 很大时存不下 $H$,但共轭梯度(CG)只需要「矩阵 × 向量」,正好可以用专题 3 的 HVP。
要求(目标函数用专题 3 的 logistic_obj(x, A),$A$ 作为参数传入):
- 写
cg_solve(matvec, b, iters):用lax.fori_loop迭代固定次数。注意残差变成 0 之后 $\alpha$、$\beta$ 会出现 $0/0$,要用专题 2 的思路防护; - 写 jit 编译的
newton_cg_step(x, A):梯度用grad,矩阵-向量积用hvp_fwd_rev; - 第一步的方向 $d$ 要和 NumPy float64 的
np.linalg.solve(H, -g)一致(相对误差 < 1e-3); - 迭代 6 步,梯度范数降到 1e-3 以下,并观察牛顿法的二次收敛。
自检接口:cg_solve(matvec, b, iters) 返回 $Hx=b$ 的近似解;newton_cg_step(x, A):jit 编译,返回 (新的 x, 梯度 g, 方向 d)。
# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
cg_solve, newton_cg_step
except NameError as e:
print(f"⏳ 练习 3 尚未作答:{e}")
else:
def _selfcheck():
rng = np.random.default_rng(33)
Q, _ = np.linalg.qr(rng.standard_normal((8, 8)))
M64 = Q @ np.diag(np.linspace(1.0, 10.0, 8)) @ Q.T # 对称正定,条件数 10
b64 = rng.standard_normal(8)
M, bj = jnp.asarray(M64, jnp.float32), jnp.asarray(b64, jnp.float32)
# 矩阵作为 jit 的参数传入(不做闭包常量,避开本环境的 CPU bug)
x = jax.jit(lambda M, b: cg_solve(lambda v: M @ v, b, 8))(M, bj)
assert np.allclose(x, np.linalg.solve(M64, b64), rtol=1e-3, atol=1e-4), "cg_solve 迭代 n 次后应得到精确解"
x_more = jax.jit(lambda M, b: cg_solve(lambda v: M @ v, b, 40))(M, bj)
assert bool(jnp.all(jnp.isfinite(x_more))), "迭代次数多于维度时出现 NaN:残差为 0 后要防护 0/0"
x2 = jax.jit(lambda b: cg_solve(lambda v: 2.0 * v, b, 5))(bj) # 第 1 步就收敛,之后残差恰为 0
assert bool(jnp.all(jnp.isfinite(x2))) and np.allclose(x2, b64 / 2, atol=1e-5), \
"H = 2I 时第 1 步就收敛,后续迭代不应产生 NaN"
x0 = jax.jit(lambda M, b: cg_solve(lambda v: M @ v, b, 5))(M, jnp.zeros(8))
assert bool(jnp.all(x0 == 0)), "b = 0 时解应为 0,且不能出现 NaN"
# 牛顿-CG:新问题上与 NumPy float64 的精确牛顿方向对拍
assert hasattr(newton_cg_step, "lower"), "newton_cg_step 应是 jit 编译过的函数"
A64 = rng.standard_normal((120, 16)) / 4.0
x064 = rng.standard_normal(16)
A, xj = jnp.asarray(A64, jnp.float32), jnp.asarray(x064, jnp.float32)
x1, g, d = newton_cg_step(xj, A)
s = 1 / (1 + np.exp(-(A64 @ x064)))
g64 = A64.T @ s + LAM * x064
H64 = A64.T @ ((s * (1 - s))[:, None] * A64) + LAM * np.eye(16)
d_ref = np.linalg.solve(H64, -g64)
assert np.allclose(g, g64, atol=1e-4), "返回的梯度 g 不对"
rel = np.linalg.norm(np.asarray(d, np.float64) - d_ref) / np.linalg.norm(d_ref)
assert rel < 1e-3, f"牛顿方向与 np.linalg.solve(H, -g) 的相对误差 {rel:.2e} ≥ 1e-3"
assert np.allclose(x1, xj + d, atol=1e-6), "新的 x 应等于 x + d"
xi = xj
for _ in range(6):
xi, _, _ = newton_cg_step(xi, A)
gn = float(jnp.linalg.norm(jax.grad(logistic_obj)(xi, A)))
assert gn < 1e-3, f"6 步牛顿-CG 后梯度范数 {gn:.2e} 仍 ≥ 1e-3"
_selfcheck(); del _selfcheck
print("✅ 练习 3 自检通过")
练习 4:用逐样本梯度找出标签错误的样本¶
⚪ 选读:练习:巩固专题 4
真实数据集里总有标错的样本。它们跟同类的其他样本「对着干」,训练一段时间后,它们的逐样本损失和梯度范数会明显偏大。
要求:
- 用专题 4 的
make_blobs生成 2000 个样本(20 维、10 类),随机挑 20 个样本把标签改成另一个类别; - 在带噪声的标签上用专题 5 的
adam训练专题 4 的 MLP(全量 batch,150 步,用lax.scan); - 用
lax.map(..., batch_size=500)分块计算每个样本的梯度范数,取 top-40; - 断言:被翻转的 20 个样本里至少 90% 出现在 top-40 中(召回率 ≥ 0.9)。
自检接口:Xn(2000×20 的样本)、Yn_clean(原始标签)、Yn(翻转后的标签)、flip_idx(被翻转样本的下标)、p_noisy(在 Yn 上训练后的参数)、norms_n(每个样本的梯度范数,形状 (2000,))。
# ✍️ 作答区:练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 4
try:
Xn, Yn_clean, Yn, flip_idx, p_noisy, norms_n
except NameError as e:
print(f"⏳ 练习 4 尚未作答:{e}")
else:
def _selfcheck():
assert Xn.shape == (2000, 20) and Yn.shape == Yn_clean.shape == (2000,), "样本或标签的形状不对"
changed = set(np.flatnonzero(np.asarray(Yn) != np.asarray(Yn_clean)).tolist())
flips = set(np.asarray(flip_idx).tolist())
assert len(flips) == 20, f"应翻转 20 个不同的样本,flip_idx 里有 {len(flips)} 个"
assert changed == flips, "标签发生变化的位置应恰好是 flip_idx(每个被翻转的样本都要换成另一个类别)"
assert int(jnp.min(Yn)) >= 0 and int(jnp.max(Yn)) < N_CLS, "翻转后的标签超出了类别范围"
nn = np.asarray(norms_n)
assert nn.shape == (2000,) and np.all(np.isfinite(nn)) and np.all(nn >= 0), "norms_n 应是 2000 个有限的非负数"
for i in sorted(flips)[:3] + [0, 1, 1999]: # 抽查:与逐样本梯度范数一致
ref = float(per_sample_norm(p_noisy, Xn[i], Yn[i]))
assert np.isclose(nn[i], ref, rtol=1e-4, atol=1e-6), f"norms_n[{i}] = {nn[i]:.5f},按定义应为 {ref:.5f}"
acc = float(accuracy(p_noisy, Xn, Yn_clean))
assert acc > 0.8, f"p_noisy 对真实标签的准确率只有 {acc:.3f},训练可能没有收敛"
top40 = set(np.argsort(-nn)[:40].tolist())
recall = len(top40 & flips) / 20
assert recall >= 0.9, f"梯度范数 top-40 只找回 {recall:.0%} 的翻转样本(要求 ≥ 90%)"
_selfcheck(); del _selfcheck
print("✅ 练习 4 自检通过")
练习 5:实现 Lion 优化器,并与 optax.lion 对拍¶
⚪ 选读:练习:巩固专题 5
Lion(Chen et al., 2023)只维护一份动量 $m$,更新方向是一个符号:
$$ u_t = \mathrm{sign}\big((1-\beta_1)\,g_t + \beta_1\, m_{t-1}\big),\qquad m_t = \beta_2\, m_{t-1} + (1-\beta_2)\, g_t $$
然后和 AdamW 一样做解耦权重衰减、乘以 $-\eta$。
要求:
- 仿照专题 5 的接口写
scale_by_lion(b1=0.9, b2=0.99)(状态含count和mu),再用chain组装出lion(lr, b1, b2, weight_decay, mask); - 用专题 5 的
run_jax在同一个二次函数上跑 5 步,与optax.lion的每一步assert allclose;学习率分别用常数和 warmup-cosine 调度各测一次; - 验证 Lion 的特点:不加权重衰减时,第一步每个坐标的变化量的绝对值都正好是 $\eta$。
自检接口:scale_by_lion(b1=0.9, b2=0.99) 返回 GradientTransformation,状态有 count、mu 两个字段;lion(lr, b1=0.9, b2=0.99, weight_decay=1e-3, mask=None),lr 可以是常数或调度函数。
# ✍️ 作答区:练习 5
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 5
try:
scale_by_lion, lion
except NameError as e:
print(f"⏳ 练习 5 尚未作答:{e}")
else:
def _selfcheck():
import optax
rng = np.random.default_rng(55)
th0 = {"w": jnp.asarray(rng.standard_normal((4, 3)), jnp.float32), "b": jnp.asarray(rng.standard_normal(3), jnp.float32)}
ca = {"w": jnp.asarray(1 + 3 * rng.random((4, 3)), jnp.float32), "b": jnp.asarray(1 + 3 * rng.random(3), jnp.float32)}
cc = {"w": jnp.asarray(rng.standard_normal((4, 3)), jnp.float32), "b": jnp.asarray(rng.standard_normal(3), jnp.float32)}
grad_of = lambda th: jax.tree.map(lambda t, a, c: a * t + c, th, ca, cc) # 二次函数的梯度
def run(opt, steps=6):
th, st, traj = th0, opt.init(th0), []
for _ in range(steps):
u, st = opt.update(grad_of(th), st, th)
th = jax.tree.map(lambda p, v: p + v, th, u)
traj.append(th)
return traj
sched = lambda count: 0.02 * (count + 1) / 6.0
mask = {"w": True, "b": False}
cases = [("常数 lr、b1=0.8、b2=0.95、wd=0.05", lion(0.02, b1=0.8, b2=0.95, weight_decay=0.05),
optax.lion(0.02, b1=0.8, b2=0.95, weight_decay=0.05)),
("调度函数 lr", lion(sched, weight_decay=0.01), optax.lion(sched, weight_decay=0.01)),
("weight_decay 带 mask", lion(0.02, weight_decay=0.1, mask=mask), optax.lion(0.02, weight_decay=0.1, mask=mask))]
for name, mine, ref in cases:
for t, (x, y) in enumerate(zip(run(mine), run(ref)), 1):
for k in x:
assert np.allclose(x[k], y[k], atol=1e-6), f"{name}:第 {t} 步参数 {k} 与 optax.lion 不一致"
# 逐步对拍 optax.scale_by_lion:第 2 步的梯度取在符号分界点附近,能区分「先算方向」和「先更新动量」
t_mine, t_ref = scale_by_lion(), optax.scale_by_lion()
s_mine, s_ref = t_mine.init({"w": jnp.zeros(4)}), t_ref.init({"w": jnp.zeros(4)})
for t, g in enumerate(([1.0, 1.0, 1.0, 1.0], [-0.085, -0.095, -0.08, 0.5], [0.3, -0.2, 0.0, -1.0]), 1):
g = {"w": jnp.array(g)}
u_mine, s_mine = t_mine.update(g, s_mine)
u_ref, s_ref = t_ref.update(g, s_ref)
assert np.array_equal(u_mine["w"], u_ref["w"]), \
f"scale_by_lion 第 {t} 步的方向 {u_mine['w']} 与 optax 的 {u_ref['w']} 不同(注意方向与动量的更新顺序)"
st = scale_by_lion().init(th0)
assert hasattr(st, "count") and hasattr(st, "mu"), "scale_by_lion 的状态应有 count 和 mu 两个字段"
_, st1 = scale_by_lion().update(grad_of(th0), st, th0)
assert int(st1.count) == 1, "每次 update 后 count 应加 1"
u, _ = lion(0.03, weight_decay=0.0).update(grad_of(th0), lion(0.03, weight_decay=0.0).init(th0), th0)
assert all(np.allclose(np.abs(l), 0.03) for l in jax.tree.leaves(u)), "不加权重衰减时,第一步每个坐标的变化量绝对值应等于 lr"
_selfcheck(); del _selfcheck
print("✅ 练习 5 自检通过")
练习 6:冻结部分层 + 捐赠状态的训练 step¶
⚪ 选读:练习:巩固专题 6、7
微调时常常要冻结一部分层,只训练其余的层。
要求:
- 写一个梯度变换
freeze(mask):mask 为真的叶子,更新量置为 0。mask 用map_with_path按路径生成:dense_0下的所有参数冻结; - 用专题 7 的
TrainState写一个donate_argnames=("state",)的训练 step,优化器为chain(adamw(...), freeze(mask)),在专题 4 的Xtr, Ytr上训练 20 步,并用CompileBudget断言只编译 1 次; - 训练前用
np.array(x, copy=True)保存一份参数的主机副本(为什么不用jax.device_get?见专题 6.5),训练后断言dense_0与它逐位相同、dense_1已经改变; - 演示一个坑:如果训练前只是保存了
state.params["dense_0"]["w"]这个设备数组的引用,即使这一层被冻结、数值没变,它的缓冲区也已经被捐赠、不能再用。
自检接口:freeze(mask) 返回 GradientTransformation(mask 是与参数同结构的布尔树,或由参数生成这种树的函数);frozen_mask(冻结 dense_0 的 mask);ex6_opt(chain(adamw(...), freeze(...)));finetune_step(state, X, Y):捐赠 state,返回 (新的 state, loss)。
# ✍️ 作答区:练习 6
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 6
try:
freeze, frozen_mask, ex6_opt, finetune_step
except NameError as e:
print(f"⏳ 练习 6 尚未作答:{e}")
else:
def _selfcheck():
upd = {"a": jnp.ones(3), "b": {"c": 2 * jnp.ones(2), "d": -jnp.ones(4)}}
m_tree = {"a": True, "b": {"c": False, "d": True}}
try:
tr = freeze(m_tree); out, _ = tr.update(upd, tr.init(upd), upd)
except Exception: # 也接受「mask 是函数」的实现
tr = freeze(lambda p: m_tree); out, _ = tr.update(upd, tr.init(upd), upd)
assert np.all(np.asarray(out["a"]) == 0) and np.all(np.asarray(out["b"]["d"]) == 0), "mask 为真的叶子更新量应为 0"
assert np.array_equal(out["b"]["c"], upd["b"]["c"]), "mask 为假的叶子更新量应原样保留"
p = init_mlp(jax.random.key(2024), (D_IN, 64, N_CLS))
m = frozen_mask(p) if callable(frozen_mask) else frozen_mask
assert all(jax.tree.leaves(m["dense_0"])) and not any(jax.tree.leaves(m["dense_1"])), \
"frozen_mask 应冻结 dense_0 下的全部参数,且只冻结它们"
st = TrainState(step=jnp.zeros([], jnp.int32), params=p, opt_state=ex6_opt.init(p), key=jax.random.key(1))
host = jax.tree.map(lambda a: np.array(a, copy=True), st.params)
ref = st.params["dense_1"]["w"]
with CompileBudget(1, "finetune_step"):
for _ in range(3):
st, loss = finetune_step(st, Xtr[:512], Ytr[:512])
assert int(st.step) == 3 and np.isfinite(float(loss)), "3 步后 step 应为 3,loss 应有限"
after = jax.tree.map(lambda a: np.array(a, copy=True), st.params)
assert all(np.array_equal(after["dense_0"][k], host["dense_0"][k]) for k in ("w", "b")), \
"dense_0 应逐位不变(权重衰减也要被挡住)"
assert not np.allclose(after["dense_1"]["w"], host["dense_1"]["w"]), "dense_1 应该在训练"
assert ref.is_deleted(), "finetune_step 应捐赠 state:训练前的参数缓冲区应已被删除"
_selfcheck(); del _selfcheck
print("✅ 练习 6 自检通过")
¶
📝 第 7 章测验¶
⚪ 选读:自测用,检验是否掌握各专题的故障模式
说明:下面 8 道题都围绕本章内容,侧重常见的故障模式和设计取舍。答案折叠在最后,下方代码单元对其中几题做了验证。
1. 下面哪种写法每次调用都会重新编译?
- A. 模块级
f_jit = jax.jit(f),之后在循环里调用f_jit(x) - B. 在类的方法里写
return jax.jit(self.forward)(x) - C. 用
@dataclass(frozen=True)定义配置类,作为static_argnums传入,每次新建内容相同的实例 - D. 同一个模块级函数
f,在两个地方分别写jax.jit(f)(x)
2. jax.grad(lambda x: jnp.where(x > 0, jnp.log(x), 0.0))(0.0) 的结果是?
- A.
0.0B.infC.nanD. 抛出FloatingPointError
3. 在 JAX 0.12.0.1 中打开 jax_debug_nans 后,下面哪种情况一定会抛出 FloatingPointError?
- A. jit 函数内部产生了 NaN,但被
jnp.where丢弃,没有出现在输出里 - B. jit 函数产生了
inf - C. 未 jit 的 eager 代码里,
jnp.log(jnp.array(-1.0))第一次执行 - D. 进程里先调用过
vmap,之后再次调用一个已经执行过的 jit 函数并产生 NaN
4. $f:\mathbb R^3\to\mathbb R^{5000}$,要计算完整的雅可比矩阵,最合适的是?
- A.
jax.jacrev(f)B.jax.jacfwd(f)C.jax.hessian(f)D. 对 5000 个输出分别jax.grad
5. 一个 1000 万参数的模型,batch = 512,用 vmap(grad) 一次性求出全部逐样本梯度(float32),这个张量大约多大?
- A. 40 MB B. 2 GB C. 20 GB D. 200 GB
6. 关于 AdamW 和「L2 正则 + Adam」,正确的是?
- A. 两者完全等价,只是写法不同
- B. AdamW 把 $\lambda\theta$ 加到梯度上再送进 Adam
- C. 「L2 + Adam」里衰减项会被 $\sqrt{\hat v}$ 归一化,衰减效果与参数大小、λ 的关系都被扭曲;AdamW 是解耦的按比例收缩
- D. AdamW 不需要偏差修正
7. step = jax.jit(update, donate_argnums=0),调用 step(state, state["params"]) 会怎样?
- A. 正常运行,第二个参数自动复制一份
- B. 运行时报错:同一个缓冲区在一次调用里既被捐赠又被使用
- C. 编译时报错
- D. 只给出 “Some donated buffers were not usable” 警告
8. jax.tree.map(jnp.zeros, {"w": (2, 3)}) 返回什么?
- A.
{"w": 形状为 (2, 3) 的全零数组} - B.
{"w": (形状为 (2,) 的全零数组, 形状为 (3,) 的全零数组)} - C. 抛出
TypeError - D.
{"w": (0, 0)}
点击展开:答案与解析
- B。每次访问
self.forward都会生成一个新的绑定方法对象,而 jit 缓存是按函数对象区分的,所以每次都重新编译(专题 1 里 3 次调用编译了 3 次)。C 的 frozen dataclass 按值比较,只编译一次;D 用的是同一个函数对象,共享缓存。 - C。
where的两个分支都会求值。没被选中的log分支在 0 处的局部导数是inf,和上游传下来的 0 相乘,得到0 × inf = NaN。修法是双where(专题 2)。 - C。eager 下每个原语第一次执行时都会检查。A:jit 只检查输出;B:inf 要开
jax_debug_infs;D:这是 0.12.0.1 里的漏检问题,要jax.clear_caches()才能恢复检查(专题 2.4)。 - B。输入只有 3 维,
jacfwd只需 3 次 JVP;jacrev要 5000 次 VJP(专题 3 的计时图)。 - C。$512 \times 10^7 \times 4$ 字节 $\approx 2.0\times10^{10}$ 字节,约 20 GB。所以大模型要分块,并在块内归约(专题 4.4)。
- C。专题 5.4 演示过:梯度为 0 时,「L2 + Adam」每步固定减少约 lr,跟 w 的大小无关;AdamW 按 $(1-\eta\lambda)$ 等比例收缩。
- B。报错信息是
Attempt to use a buffer that was previously donated in the same call to Execute()。这类错误只在运行时出现(专题 6.5)。 - B。tuple 是 pytree 容器,
(2, 3)会被拆成两个叶子2和3,分别得到形状(2,)和(3,)的数组;要传is_leaf=lambda x: isinstance(x, tuple)(专题 7.4)。
# 测验实测验证(第 1、2、5、8 题)
class _QuizModel:
def q1_forward(self, x): return x + 1
def predict(self, x): return jax.jit(self.q1_forward)(x)
_qm = _QuizModel()
assert count_compiles("q1_forward", [lambda: _qm.predict(x3)] * 3) == 3 # 第 1 题:B 每次都编译
assert bool(jnp.isnan(jax.grad(lambda x: jnp.where(x > 0, jnp.log(x), 0.0))(0.0))) # 第 2 题:C
assert abs(512 * 10_000_000 * 4 / 1e9 - 20.48) < 1e-9 # 第 5 题:约 20 GB
q8 = jax.tree.map(jnp.zeros, {"w": (2, 3)})
assert isinstance(q8["w"], tuple) and [a.shape for a in q8["w"]] == [(2,), (3,)] # 第 8 题:B
print("第 1、2、5、8 题答案实测验证通过 ✔(第 3、4、6、7 题见对应专题的实测)")
✔ 实战规则清单¶
🟢 必读
| 专题 | 规则 |
|---|---|
| 1 重新编译 | ① 把「编译次数」当成需要断言的指标:关键函数套上 CompileBudget / @compile_budget,写进测试。② 定位原因先开 jax_explain_cache_misses;最常见的三个元凶是变长形状、弱类型标量、新建 jit 对象(循环里的 lambda / 闭包 / jax.jit(self.method))。③ 变长输入用分桶 padding + mask(分子分母都要 mask);静态参数必须可哈希、按值比较、取值少,会变的数值(学习率)一律动态传入。 |
| 2 数值稳定 | ① where / mask 里只要藏着 log、sqrt、除法,就用双 where;custom_jvp 的规则本身也要安全。② 分类损失一律 log_softmax(max-shift + stop_gradient),不要 log(softmax);mask 用 finfo.min 而不是 -inf。③ jax_debug_nans 只查 jit 的输出、不查 inf,且 0.12 里用过 vmap 后会漏检:训练时用 isfinite/checkify 发现问题,再打开开关 + jax.clear_caches() 重跑那一步定位。 |
| 3 求导选型 | ① 完整雅可比:输入少($n<m$)用 jacfwd,输出少用 jacrev;标量损失永远是 grad。② 只需要 $Jv$ / $u^\top J$ / $Hv$ 时不要构造矩阵: jvp、vjp、linearize;HVP 首选 jvp(grad(f))。 |
| 4 逐样本梯度 | ① 写「单样本损失」+ vmap(grad, in_axes=(None, 0, 0)),并用循环实现对拍;忘写 in_axes 可能悄悄出错。② 内存是 $B\times P$:大模型用 lax.map(batch_size=k) 或 scan 分块,并在块内直接归约成范数 / 裁剪后的和。③ DP-SGD:按全局范数逐样本裁剪 → 求和 → 加 $\mathcal N(0,\sigma^2C^2)$ → 除以 B;噪声的 key 每步 split、每片叶子独立。 |
| 5 手写优化器 | ① 写成 init/update 纯函数 + chain 组合,状态(包括 int32 的步数)是 PyTree,学习率调度在 jit 内部计算。② Adam 必须做偏差修正;权重衰减要解耦(AdamW),并用 mask 排除偏置。 ③ 每个组件都用 NumPy float64 手算值逐步对拍。 |
| 6 donate | ① 训练 step 用 donate_argnames=("state",),只捐状态、不捐数据,调用处立刻 state = step(state, ...)。② 状态里的每个数组独立创建;不要 step(state, state.params);要保留的东西先 np.array(x, copy=True) 拷贝(CPU 上 device_get / np.asarray 是零拷贝视图,视图存活时捐赠会被悄悄跳过)。③ 捐赠省的是峰值内存( memory_analysis().peak_memory_in_bytes 可验证),CPU 上速度几乎不变。 |
| 7 PyTree | ① 只用 jax.tree.*;jax.tree_map 等顶层别名在 0.12 已移除。② TrainState 用 register_dataclass + frozen=True,静态配置用 jax.tree.static(值一变就重新编译);只对 params 求导。③ 需要名字时用 *_with_path + keystr;形状 tuple 要配 is_leaf。 |
| 环境 | 本环境 jaxlib 0.12.0.1 CPU:权重、数据一律作为参数传入 jit,不要闭包捕获;关键数值用 NumPy float64 对拍。 |
¶
🗂️ 速查表¶
⚪ 选读:查阅用的 API 对照表
专题 1 重新编译
| API | 作用 |
|---|---|
jax.config.update("jax_log_compiles", True) | 每次真正编译时输出 Compiling jit(f) with ... 日志(WARNING 级别) |
jax.config.update("jax_explain_cache_misses", True) | 缓存未命中时说明原因:哪个参数的类型 / 结构变了 |
jax.monitoring.register_event_duration_secs_listener(cb) | 监听 /jax/core/compile/backend_compile_duration 等事件,带 fun_name;本章 CompileCounter 的基础 |
jax.monitoring.unregister_event_duration_listener(cb) | 注销监听(要传同一个函数对象) |
jax.jit(f).trace(*a) → .lower() → .compile() | jax.stages 的三个阶段:Traced / Lowered / Compiled |
jax.ShapeDtypeStruct(shape, dtype) | 只描述形状和 dtype,用于不带真实数据的 lower(AOT 预编译) |
compiled.cost_analysis() / .memory_analysis() | 编译产物的 FLOPs 估计 / 参数、输出、临时、别名、峰值内存 |
jax.eval_shape(f, *a) | 只追踪不编译,得到输出的形状和 dtype |
jax.tree_util.register_static(cls) | 把可哈希的类注册为静态 pytree,不写 static_argnames 也按静态处理 |
@dataclass(frozen=True) | 自动生成按值比较的 __eq__/__hash__,适合作静态配置 |
CompileBudget(n, name) / @compile_budget(n) | 本章工具:编译次数超出预算时报错或警告 |
专题 2 数值稳定
| API | 作用 |
|---|---|
jnp.where(ok, f(jnp.where(ok, x, safe)), fill) | 双 where:保证被屏蔽位置的梯度有限 |
jax.nn.logsumexp / jax.nn.log_softmax / jax.nn.softmax | 稳定实现(max-shift + stop_gradient) |
jax.lax.stop_gradient(x) | 前向不变,反向梯度为 0 |
jnp.finfo(jnp.float32).min | 最小的有限 float32,mask 时代替 -inf |
jax.config.update("jax_debug_nans", True) / "jax_debug_infs" | 检查 NaN / inf 并定位到原语;只查 jit 的输出 |
with jax.debug_nans(True): | 只在块内打开 NaN 检查;0.12.0.1 中不受 vmap 漏检问题影响 |
jax.clear_caches() | 清空编译缓存;0.12 中用于让全局打开的 jax_debug_nans 重新生效 |
jax.experimental.checkify.checkify(f, errors=checkify.float_checks) | 把 NaN/inf 检查编译进程序,返回 (err, out),err.get() / err.throw() |
jax.custom_jvp + @f.defjvp | 自定义导数规则(规则本身也要数值安全) |
jax.test_util.check_grads(f, args, order, modes) | 有限差分检验梯度(需显式 import jax.test_util) |
专题 3 求导选型
| API | 作用 |
|---|---|
jax.jvp(f, (x,), (v,)) | 前向模式,返回 (f(x), J v) |
jax.vjp(f, x) → vjp_fn(u) | 反向模式,返回 f(x) 和可重复调用的 u ↦ uᵀJ |
jax.linearize(f, x) | 线性化一次,返回 (f(x), v ↦ J v),适合同一点上的多个方向 |
jax.jacfwd(f) / jax.jacrev(f) | 完整雅可比:输入少用前者,输出少用后者 |
jax.hessian(f) | jacfwd(jacrev(f)),内存 $O(n^2)$ |
jax.jvp(jax.grad(f), (x,), (v,))[1] | Hessian-向量积(前向套反向,首选) |
专题 4 逐样本梯度
| API | 作用 |
|---|---|
jax.vmap(jax.grad(loss), in_axes=(None, 0, 0)) | 逐样本梯度:参数共享、数据按样本映射 |
jax.lax.map(f, xs, batch_size=k) | 分块顺序执行,每块内部 vmap,内存约为 k 个样本 |
jax.lax.scan(body, init, xs) | 分块累加(例如 DP-SGD 的裁剪求和) |
jnp.tensordot(c, g, axes=1) | 按样本加权求和 $\sum_i c_i g_i$ |
jax.lax.top_k(x, k) | 取最大的 k 个值和下标(找难样本) |
jax.random.split(key, n) | 为每片叶子 / 每一步派生独立的 key |
专题 5 手写优化器
| API | 作用 |
|---|---|
GradientTransformation(init, update) | init(params) -> state;update(updates, state, params) -> (updates, state) |
chain(*ts) / apply_updates(p, u) | 依次组合变换 / 把增量加到参数上 |
scale_by_adam / trace(β, nesterov) / scale_by_lion(练习 5) | Adam 方向 / 动量与 Nesterov / Lion 的符号方向 |
add_decayed_weights(λ, mask) / clip_by_global_norm(c) | 解耦权重衰减 / 全局范数裁剪 |
scale_by_learning_rate(lr) / scale_by_schedule(f) | 乘以 -lr(常数或调度函数,步数存在状态里) |
warmup_cosine_decay_schedule(init, peak, warmup, decay, end) | 线性 warmup + 余弦衰减(与 Optax 同语义) |
optax.adam / optax.adamw / optax.lion / optax.chain | 本环境安装的 optax 0.2.9,用于对拍 |
专题 6 donate
| API | 作用 |
|---|---|
jax.jit(f, donate_argnums=(0,)) / donate_argnames=("state",) | 声明输入在调用后不再使用,其缓冲区可复用给同形状的输出 |
x.is_deleted() | 数组是否已被捐赠(删除) |
x.unsafe_buffer_pointer() | 设备缓冲区地址,用于验证是否原地复用 |
jax.device_get(tree) | 取到主机(NumPy);CPU 上是零拷贝视图,视图存活时该缓冲区不会被捐赠 |
np.array(x, copy=True) | 真正拷贝到主机,捐赠前保留副本用它 |
lowered.as_text() 中的 tf.aliasing_output | 编译期的输入-输出别名标记 |
专题 7 PyTree
| API | 作用 |
|---|---|
jax.tree.map / leaves / structure / flatten / unflatten | 基础操作(顶层 jax.tree_map 等已移除) |
jax.tree.reduce / reduce_associative / all | 归约所有叶子 |
jax.tree.transpose(outer, inner, tree) | 「列表的 dict」⇄「dict 的列表」 |
jax.tree.broadcast(prefix, full) | 把前缀树广播到完整结构 |
jax.tree.map_with_path / leaves_with_path / flatten_with_path | 带键路径的操作 |
jax.tree_util.keystr(path, simple=True, separator=".") | 路径转成 dense_0.w 这样的名字 |
jax.tree_util.register_dataclass + jax.tree.static(...) | dataclass 注册为 pytree,并声明静态字段 |
dataclasses.replace(state, ...) / nt._replace(...) | frozen dataclass / NamedTuple 的函数式更新 |
is_leaf=lambda x: isinstance(x, tuple) | 把 tuple 等容器当作叶子 |
# 全章自检:所有 jax 配置开关都已恢复默认
for flag in ("jax_debug_nans", "jax_debug_infs", "jax_log_compiles", "jax_explain_cache_misses"):
assert getattr(jax.config, flag) is False, flag
print("配置开关均已恢复默认;第 7 章全部单元执行完毕 ✔")
练习 1¶
题目见正文「练习 1:给服务函数写编译预算测试」。
# 参考答案:练习 1
EX1_BUCKETS = (32, 64, 128)
def test_serve_compiles(serve_fn, fun_name, budget):
"""编译预算测试:服务全部请求时,jit(fun_name) 的编译次数不得超过 budget。"""
try:
with CompileBudget(budget, fun_name) as b:
outs = serve_fn(ex1_batches, ex1_eps)
return True, b.count, outs
except CompileBudgetExceeded:
return False, b.count, None
def normalize_rows_fixed(x, eps): # 计算与原函数相同,单独命名便于按名字统计编译
return normalize_rows(x, eps)
normalize_fixed_jit = jax.jit(normalize_rows_fixed)
def serve_fixed(batches, eps_list):
outs = []
for b, e in zip(batches, eps_list):
n = b.shape[0]
size = next(s for s in EX1_BUCKETS if s >= n)
padded = np.zeros((size, b.shape[1]), np.float32)
padded[:n] = b # NumPy 里 padding,不触发任何 JAX 编译
out = normalize_fixed_jit(padded, np.float32(e)) # eps 统一为强类型 float32 标量
outs.append(np.asarray(out)[:n]) # 去掉 padding 行(全 0 行归一化后仍是 0,无害)
return outs
ok_orig, n_orig, _ = test_serve_compiles(serve, "normalize_rows", 3)
ok_fixed, n_fixed, outs = test_serve_compiles(serve_fixed, "normalize_rows_fixed", 3)
print(f"原始 serve :{'通过' if ok_orig else '不通过'},编译 {n_orig} 次")
print(f"修复后 serve_fixed:{'通过' if ok_fixed else '不通过'},编译 {n_fixed} 次")
assert not ok_orig and ok_fixed and n_fixed <= len(EX1_BUCKETS)
for b, o in zip(ex1_batches, outs):
b64 = b.astype(np.float64)
ref = b64 / (np.sqrt((b64 ** 2).sum(1, keepdims=True)) + 1e-6)
assert np.allclose(o, ref, atol=1e-6)
print("修复后的输出与 NumPy float64 参考值一致 ✔")
练习 2¶
题目见正文「练习 2:安全的余弦相似度」。
# 参考答案:练习 2
zero3, b_vec = jnp.zeros(3), jnp.array([1.0, 2.0, 2.0])
g_bad = jax.grad(cos_sim_where)(zero3, b_vec)
print("❌ 单 where:前向", float(cos_sim_where(zero3, b_vec)), "| 对 a 的梯度", g_bad)
assert float(cos_sim_where(zero3, b_vec)) == 0.0 and bool(jnp.any(jnp.isnan(g_bad)))
def cos_sim_safe(a, b):
na2, nb2 = jnp.sum(a ** 2), jnp.sum(b ** 2)
ok = (na2 > 0) & (nb2 > 0)
na = jnp.sqrt(jnp.where(ok, na2, 1.0)) # 第 1 个 where:危险输入 → 安全值
nb = jnp.sqrt(jnp.where(ok, nb2, 1.0))
return jnp.where(ok, jnp.dot(a, b) / (na * nb), 0.0) # 第 2 个 where:选输出
ga, gb = jax.grad(cos_sim_safe, argnums=(0, 1))(zero3, b_vec)
print("✅ 双 where:前向", float(cos_sim_safe(zero3, b_vec)), "| 梯度", ga, gb)
assert bool(jnp.all(jnp.isfinite(ga))) and bool(jnp.all(jnp.isfinite(gb)))
ka, kb2 = jax.random.split(jax.random.key(72))
a_r, b_r = jax.random.normal(ka, (5,)), jax.random.normal(kb2, (5,))
a64, b64 = to64(a_r), to64(b_r)
na, nb = np.linalg.norm(a64), np.linalg.norm(b64)
ref_ga = b64 / (na * nb) - a64.dot(b64) * a64 / (na ** 3 * nb)
assert np.allclose(jax.grad(cos_sim_safe)(a_r, b_r), ref_ga, atol=1e-6)
assert np.isclose(float(cos_sim_safe(a_r, b_r)), a64.dot(b64) / (na * nb), atol=1e-6)
check_grads(cos_sim_safe, (a_r, b_r), order=1, modes=["fwd", "rev"])
batch_a = jnp.stack([a_r, jnp.zeros(5), -a_r])
batch_b = jnp.stack([b_r, b_r, jnp.zeros(5)])
gbatch = jax.jit(jax.vmap(jax.grad(cos_sim_safe)))(batch_a, batch_b)
assert bool(jnp.all(jnp.isfinite(gbatch))) and np.allclose(gbatch[1:], 0.0)
print("正常点梯度 = 解析式,check_grads 通过;含零向量的 batch 梯度全部有限 ✔")
练习 3¶
题目见正文「练习 3:牛顿-CG——只用 HVP 做二阶优化」。
# 参考答案:练习 3
def cg_solve(matvec, b, iters):
def body(_, st):
x, r, p, rs = st
Ap = matvec(p)
pAp = jnp.vdot(p, Ap)
alpha = jnp.where(pAp > 0, rs / jnp.where(pAp > 0, pAp, 1.0), 0.0) # 残差为 0 时不再更新
x = x + alpha * p
r = r - alpha * Ap
rs_new = jnp.vdot(r, r)
beta = jnp.where(rs > 0, rs_new / jnp.where(rs > 0, rs, 1.0), 0.0)
return x, r, r + beta * p, rs_new
x, *_ = lax.fori_loop(0, iters, body, (jnp.zeros_like(b), b, b, jnp.vdot(b, b)))
return x
@partial(jax.jit, static_argnames="iters")
def newton_cg_step(x, A, iters=60):
g = jax.grad(logistic_obj)(x, A)
d = cg_solve(lambda v: hvp_fwd_rev(x, v, A), -g, iters)
return x + d, g, d
n_nc = 50
ka, kx = jax.random.split(jax.random.key(73))
A_nc = jax.random.normal(ka, (200, n_nc)) / np.sqrt(n_nc)
x_nc = jax.random.normal(kx, (n_nc,))
# 第一步方向:与 NumPy float64 的精确牛顿方向对拍
_, g0, d0 = newton_cg_step(x_nc, A_nc)
A64, x64 = to64(A_nc), to64(x_nc)
s = 1 / (1 + np.exp(-(A64 @ x64)))
H64 = A64.T @ ((s * (1 - s))[:, None] * A64) + LAM * np.eye(n_nc)
g64 = A64.T @ s + LAM * x64
d_ref = np.linalg.solve(H64, -g64)
rel = np.linalg.norm(to64(d0) - d_ref) / np.linalg.norm(d_ref)
print(f"第 1 步:CG 方向与 np.linalg.solve 的相对误差 {rel:.2e}")
assert np.allclose(g0, g64, atol=1e-5) and rel < 1e-3
x_it = x_nc
for k in range(6):
x_it, g, _ = newton_cg_step(x_it, A_nc)
print(f" 迭代 {k + 1}:更新前 ‖∇f‖ = {float(jnp.linalg.norm(g)):.3e}")
g_final = float(jnp.linalg.norm(jax.grad(logistic_obj)(x_it, A_nc)))
print(f"6 步后 ‖∇f‖ = {g_final:.2e}")
assert g_final < 1e-3
练习 4¶
题目见正文「练习 4:用逐样本梯度找出标签错误的样本」。
# 参考答案:练习 4
N_NOISY, N_FLIP = 2000, 20
Xn, Yn_clean = make_blobs(jax.random.key(80), N_NOISY, D_IN, N_CLS)
flip_idx = jax.random.choice(jax.random.key(81), N_NOISY, (N_FLIP,), replace=False)
offset = jax.random.randint(jax.random.key(82), (N_FLIP,), 1, N_CLS) # 1..9:保证换成别的类
Yn = Yn_clean.at[flip_idx].set((Yn_clean[flip_idx] + offset) % N_CLS)
assert int(jnp.sum(Yn != Yn_clean)) == N_FLIP
ex4_opt = adam(1e-2)
@partial(jax.jit, static_argnames="steps")
def fit_full_batch(params, X, Y, steps):
def body(carry, _):
p, s = carry
loss, g = jax.value_and_grad(loss_batch)(p, X, Y)
u, s = ex4_opt.update(g, s, p)
return (apply_updates(p, u), s), loss
(params, _), losses = lax.scan(body, (params, ex4_opt.init(params)), None, length=steps)
return params, losses
p_noisy, ex4_losses = fit_full_batch(init_mlp(jax.random.key(83), (D_IN, 64, N_CLS)), Xn, Yn, steps=150)
norms_n = jax.jit(lambda p, X, Y: lax.map(lambda xy: per_sample_norm(p, *xy), (X, Y), batch_size=500))(p_noisy, Xn, Yn)
_, top40 = lax.top_k(norms_n, 40)
recall = len(set(np.asarray(top40).tolist()) & set(np.asarray(flip_idx).tolist())) / N_FLIP
acc_clean = float(accuracy(p_noisy, Xn, Yn_clean))
print(f"训练 loss {float(ex4_losses[0]):.3f} → {float(ex4_losses[-1]):.3f};对真实标签的准确率 {acc_clean:.3f}")
print(f"梯度范数 top-40 找回了 {recall:.0%} 的翻转样本"
f"(翻转样本平均范数 {float(norms_n[flip_idx].mean()):.3f},全体 {float(norms_n.mean()):.3f})")
assert recall >= 0.9
练习 5¶
题目见正文「练习 5:实现 Lion 优化器,并与 optax.lion 对拍」。
# 参考答案:练习 5
class ScaleByLionState(NamedTuple):
count: jax.Array
mu: Any
def scale_by_lion(b1=0.9, b2=0.99):
def init(params):
return ScaleByLionState(count=jnp.zeros([], jnp.int32), mu=jax.tree.map(jnp.zeros_like, params))
def update(updates, state, params=None):
direction = jax.tree.map(lambda g, m: jnp.sign((1 - b1) * g + b1 * m), updates, state.mu)
mu = jax.tree.map(lambda m, g: b2 * m + (1 - b2) * g, state.mu, updates) # 先算方向,再更新动量
return direction, ScaleByLionState(count=state.count + 1, mu=mu)
return GradientTransformation(init, update)
def lion(lr, b1=0.9, b2=0.99, weight_decay=1e-3, mask=None):
return chain(scale_by_lion(b1, b2), add_decayed_weights(weight_decay, mask), scale_by_learning_rate(lr))
lion_sched = warmup_cosine_decay_schedule(0.0, 3e-2, 2, 10, 1e-3)
for name, mine, ref in [
("常数 lr=0.03, wd=0.1", lion(0.03, weight_decay=0.1), optax.lion(0.03, weight_decay=0.1)),
("warmup-cosine 调度", lion(lion_sched), optax.lion(optax.warmup_cosine_decay_schedule(0.0, 3e-2, 2, 10, 1e-3)))]:
a_traj, b_traj = run_jax(mine), run_jax(ref)
err = max(np.abs(x[k] - y[k]).max() for x, y in zip(a_traj, b_traj) for k in x)
print(f"{wpad(name, 24)} 手写 Lion vs optax.lion,5 步最大差 {err:.2e}")
assert err < 1e-6
lion0 = lion(0.03, weight_decay=0.0)
u1, _ = lion0.update(jax.grad(quad_loss)(theta0, coef_a, coef_c), lion0.init(theta0), theta0)
assert all(np.allclose(np.abs(l), 0.03) for l in jax.tree.leaves(u1))
print("第一步每个坐标的变化量都是 ±lr ✔")
练习 6¶
题目见正文「练习 6:冻结部分层 + 捐赠状态的训练 step」。
# 参考答案:练习 6
def freeze(mask):
def update(updates, state, params=None):
m = mask(params) if callable(mask) else mask
return jax.tree.map(lambda u, frozen: jnp.zeros_like(u) if frozen else u, updates, m), state
return GradientTransformation(lambda params: EmptyState(), update)
frozen_mask = lambda params: jax.tree.map_with_path(
lambda p, _: jax.tree_util.keystr(p[:1], simple=True) == "dense_0", params)
ex6_opt = chain(adamw(1e-2, weight_decay=1e-4), freeze(frozen_mask)) # freeze 放最后:连权重衰减也挡住
@partial(jax.jit, donate_argnames=("state",))
def finetune_step(state, X, Y):
loss, g = jax.value_and_grad(loss_batch)(state.params, X, Y)
u, opt_state = ex6_opt.update(g, state.opt_state, state.params)
return dataclasses.replace(state, step=state.step + 1, params=apply_updates(state.params, u),
opt_state=opt_state), loss
p6 = init_mlp(jax.random.key(90), (D_IN, 64, N_CLS))
st6 = TrainState(step=jnp.zeros([], jnp.int32), params=p6, opt_state=ex6_opt.init(p6), key=jax.random.key(91))
print("冻结 mask:", frozen_mask(st6.params))
host_params = jax.tree.map(lambda a: np.array(a, copy=True), st6.params) # ✅ 真正的主机拷贝
host_dense0, host_dense1 = host_params["dense_0"], host_params["dense_1"]
device_ref = st6.params["dense_0"]["w"] # ❌ 只是设备数组的引用
with CompileBudget(1, "finetune_step"):
for i in range(20):
st6, loss6 = finetune_step(st6, Xtr[:512], Ytr[:512])
print(f"20 步后 loss = {float(loss6):.4f},step = {int(st6.step)}")
after = jax.tree.map(lambda a: np.array(a, copy=True), st6.params)
assert all(np.array_equal(after["dense_0"][k], host_dense0[k]) for k in ("w", "b")) # 冻结层逐位不变
assert not np.allclose(after["dense_1"]["w"], host_dense1["w"]) # 其余层在训练
print("dense_0 与训练前逐位相同,dense_1 已更新 ✔")
print("❌ 训练前保存的设备引用是否已被删除:", device_ref.is_deleted())
assert device_ref.is_deleted()
show_error(lambda: device_ref + 0)