第 1 章 高级变换与控制流
课程:「JAX 高级编程:性能、优化与规模化」第 1 章(课程主页) 运行环境:JAX / jaxlib 0.12.0.1,Python 3.14,仅 CPU。本教程里的所有结论都在这个版本上跑过、验证过。
阅读说明:本教程按课程第 1 章的 8 个网页整理,讲解和代码都用中文重新写过,并做了补充。过时或容易出错的写法放在 # ❌ 单元里,后面紧跟 # ✅ 推荐写法。
学习目标¶
学完本章,你应该能够:
- 说清
jit/grad/vmap的追踪模型,以及它们组合起来时的语义; - 熟练使用
lax.scan(包括reverse、unroll、length、xs=None),能读懂 carry 类型不一致时的报错; - 根据场景选择
lax.cond/lax.switch/lax.select_n/jnp.where,并知道cond在vmap下会退化成select; - 用
lax.while_loop写动态终止的循环,知道它不支持反向模式求导,以及该换成什么写法; - 掌握控制流与
jit/grad/vmap/jax.checkpoint组合时的行为和代价; - 用 padding + mask、
jax.ops.segment_sum处理变长数据; - 理解闭包与暂存(staging):常量怎样嵌入程序、
make_jaxpr里的constvars是什么、为什么不要在闭包里捕获大数组; - 用
lax.scan实现 GRU、LSTM 和 ODE 积分器,并和 Python 循环版本逐数值对拍。
小节目录¶
| 小节 | 课程页面 |
|---|---|
| 1.1 核心转换回顾:jit, grad, vmap | review-core-transformations |
| 1.2 精通 lax.scan 处理序列操作 | mastering-lax-scan |
| 1.3 使用 lax.cond 进行条件执行 | conditional-execution-lax-cond |
| 1.4 使用 lax.while_loop 进行循环 | looping-lax-while-loop |
| 1.5 结合控制流与变换 | combining-control-flow-transformations |
| 1.6 高级掩码技术 | advanced-masking-techniques |
| 1.7 了解闭包和 JAX 暂存 | closures-jax-staging |
| 1.8 实践:实现复杂的循环逻辑 | practical-complex-recurrent-logic |
| 补充练习 / 测验 / 更新说明 / 小结 / 速查表 | — |
与入门课的关联¶
- 追踪、重编译、静态参数:入门 02 JIT编译。其中 2.4 节已经介绍过
fori_loop、lax.switch、while_loop的最小示例,本章会深入讲。 - 反向模式、
jvp/vjp、jnp.where的 NaN 梯度和「双 where」技巧:入门 03 自动微分grad in_axes/out_axes与批处理语义:入门 04 自动向量化vmap- 随机数 key 的管理、纯函数式状态:入门 01 JAX简介、入门 06 状态管理
⚠️ 本机环境提醒:jaxlib 0.12.0.1 的 CPU 后端有一个能稳定复现的 bug。jit 函数通过闭包捕获一个常量矩阵,拿它当左操作数做 matmul(
W @ x)再 reduce,结果会悄悄算错。触发阈值随形状变化,128×128 就会触发。所以本教程里,权重和数据一律作为函数参数传入,关键数值都用 NumPy float64 对拍。现象、触发边界和规避方法见 入门 00 目录与导读 的「本环境已知问题」,根因分析见 进阶第 2 章 §2.3。
# 公共导入与工具函数
import time, io, contextlib, functools
import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
jax.config.update("jax_pprint_use_color", False) # 打印 jaxpr 时不带 ANSI 颜色码,便于阅读和字符串匹配
print("jax", jax.__version__, "| 设备:", jax.devices())
N_ASSERT = 0 # 统计本 notebook 中通过的 assert 数量
def check(cond, msg=""):
"""包装 assert:失败就抛错,成功就计数。"""
global N_ASSERT
assert cond, msg
N_ASSERT += 1
def bench(f, *args, n=20):
"""计时工具:先预热(包括编译),再取 n 次的中位数,单位为毫秒。"""
jax.block_until_ready(f(*args))
ts = []
for _ in range(n):
t0 = time.perf_counter()
jax.block_until_ready(f(*args))
ts.append(time.perf_counter() - t0)
return 1e3 * float(np.median(ts))
1.1 核心转换回顾:jit, grad, vmap¶
jax.jit、jax.grad、jax.vmap 是 JAX 最基础的三个函数变换:输入一个 Python 函数,输出一个新函数。本章要讲的 lax.scan、lax.cond、lax.while_loop 都建立在它们的追踪机制之上,所以先把这套机制回顾一遍。
jax.jit:追踪 + XLA 编译。 函数第一次以某种输入形状和 dtype 被调用时,JAX 用抽象的 tracer 代替真实数组执行一遍 Python 代码,把遇到的基本操作记录成中间表示 jaxpr,再交给 XLA 编译成 CPU、GPU 或 TPU 上的机器码。之后只要调用的形状和 dtype 匹配,就直接执行缓存好的编译结果,Python 函数体不会再运行。所以:
- 函数必须是纯函数。
print、修改全局变量这类副作用只在追踪时发生一次; - 输入的形状或 dtype 变了,或者静态参数(
static_argnums)的值变了,都会触发重新追踪和编译。
⚠️ 注意:容易误以为闭包里的 Python 常量一变,就会触发重编译。其实被闭包捕获、或者作为全局变量读取的值,不在 jit 的缓存键里。这些值变了,jit 不会重新编译,而是继续用追踪时的旧值,这就是陈旧值问题。只有新建一个函数对象(例如新的闭包)才会重新追踪。1.7 节会用
assert证明这一点。
jax.grad:反向模式自动微分。 被求导的函数必须返回标量。grad 返回一个计算梯度的新函数,默认对第一个参数求导,可以用 argnums 指定其他参数。它内部基于 VJP(向量-雅可比积),嵌套几次就能得到高阶导数。输出不是标量时,要先归约成标量,或者改用 jax.jacrev / jax.jacfwd,也可以结合 vmap 算雅可比矩阵或海森矩阵。
jax.vmap:自动向量化。 它把处理单个样本的函数变成处理一批样本的函数,不需要写 Python 循环。签名是 jax.vmap(fun, in_axes=0, out_axes=0)。in_axes 里的整数表示沿这个轴映射;None 表示这个参数不映射,整个批次共用一份(相当于广播)。out_axes 指定映射轴放在输出的哪个位置。
⚠️ 注意:vmap 的「向量化」对应的英文是 vectorization。有的中文资料把它写成 quantization,那是「量化」,完全是另一回事。
组合。 三个变换可以任意嵌套,例如 jit(grad(f))、vmap(jit(f))、jit(vmap(grad(f)))。嵌套顺序决定了含义。比如 vmap(grad(f)) 得到每个样本的梯度,grad(lambda p: mean(vmap(f)(p))) 得到平均损失的梯度。
课程代码 1:jit 加速¶
def slow_f(x):
return jnp.sin(x) * x + jnp.log(x + 1)
fast_f = jax.jit(slow_f)
key = jax.random.key(0) # 0.12 推荐:带类型的 key
x = jax.random.uniform(key, (1000, 1000)) # 取 [0,1) 区间,保证 log(x+1) 有定义
y_eager = slow_f(x).block_until_ready()
y_jit = fast_f(x).block_until_ready() # 第一次调用:追踪 + 编译
print("结果一致:", bool(jnp.allclose(y_eager, y_jit)))
check(jnp.allclose(y_eager, y_jit))
print(f"逐操作执行(eager): {bench(slow_f, x):.3f} ms | jit: {bench(fast_f, x):.3f} ms")
随机数 key:旧写法与推荐写法¶
# ❌ 旧写法(过时):jax.random.PRNGKey 生成的是“裸”uint32 数组形式的旧式 key
try:
old_key = jax.random.PRNGKey(0)
print("PRNGKey 仍可运行 → dtype =", old_key.dtype, ", shape =", old_key.shape)
except Exception as e:
print("报错:", type(e).__name__, e)
# ✅ 推荐写法:jax.random.key 生成带类型的 key(dtype 为 key<fry>,shape 为 ())
new_key = jax.random.key(0)
print("dtype =", new_key.dtype, ", shape =", new_key.shape)
# 两者底层的位数据相同,只是新式 key 不会被误当成普通整数数组参与运算
check(np.array_equal(jax.random.key_data(new_key), jax.random.PRNGKey(0)))
🔍 补充:jit 示例的数据怎么选。 ⚪ 选读:要自己写 eager 与 jit 的数值对拍时再看。
如果用正态分布
random.normal生成数据,大约 16% 的元素小于 −1,log(x+1)在这些位置会得到 NaN。jnp.allclose默认equal_nan=False,只要比较的数组里有 NaN 就返回 False。就算改用equal_nan=True把 NaN 排除掉,还有一个问题:有些位置两项相互抵消,函数值接近 0(这组数据里有一处,x≈3.58)。在这种位置rtol几乎不起作用,eager 和 jit 之间正常的舍入差异也可能超过默认的atol=1e-8。所以对拍时要保证输入落在函数的定义域里,上面改用了uniform。
课程代码 2:grad 求线性模型的梯度¶
模型和损失函数为
$$\hat y = \mathbf w^\top \mathbf x + b,\qquad L(\mathbf w,b)=(\hat y-y)^2,$$
解析梯度为 $\partial L/\partial \mathbf w = 2(\hat y-y)\,\mathbf x$,$\partial L/\partial b = 2(\hat y-y)$。
def predict(params, x):
w, b = params
return jnp.dot(w, x) + b
def loss_fn(params, x, y_true):
y_pred = predict(params, x)
return jnp.mean((y_pred - y_true) ** 2)
w_init = jnp.array([1.5, -0.5]); b_init = 0.3
params_init = (w_init, b_init)
x_data = jnp.array([0.2, 0.8]); y_target = 2.5
grad_loss_fn = jax.grad(loss_fn, argnums=0)
gw, gb = grad_loss_fn(params_init, x_data, y_target)
print("关于 w 的梯度:", gw)
print("关于 b 的梯度:", gb)
# 用 NumPy float64 按解析公式对拍
w64, x64 = np.array([1.5, -0.5]), np.array([0.2, 0.8])
r = w64 @ x64 + 0.3 - 2.5 # 残差 = 0.2 - 2.5 = -2.3
check(np.allclose(gw, 2 * r * x64) and np.isclose(gb, 2 * r))
check(np.allclose(gw, [-0.92, -3.68]) and np.isclose(gb, -4.6))
⚠️ 注意:按这组数据,$\hat y = 1.5\times0.2-0.5\times0.8+0.3 = 0.2$,残差是 $-2.3$,所以 $\partial L/\partial\mathbf w=[-0.92,\,-3.68]$,$\partial L/\partial b=-4.6$。如果在别处看到 w 的梯度是
[-0.392 -1.568]、b 的梯度是-1.96,那是算错了。上面的单元已经用解析公式加assert验证过。
课程代码 3:vmap 批处理仿射变换¶
def simple_affine(w, b, x):
return jnp.dot(w, x) + b
w = jnp.array([[1., 2.], [3., 4.], [5., 6.]]) # (3, 2)
b = jnp.array([0.1, 0.2, 0.3]) # (3,)
x_batch = jnp.arange(8.).reshape(4, 2) # (4, 2)
batched_affine = jax.vmap(simple_affine, in_axes=(None, None, 0))
out = batched_affine(w, b, x_batch)
print("输出形状:", out.shape)
check(out.shape == (4, 3))
check(np.allclose(out, np.asarray(x_batch) @ np.asarray(w).T + np.asarray(b)))
课程代码 4:jit(vmap(grad)) 计算每个样本的梯度¶
key = jax.random.key(0)
key, kx, ky = jax.random.split(key, 3) # 一次切出需要的所有子 key,不复用
x_batch_data = jax.random.normal(kx, (16, 2))
y_batch_target = jax.random.normal(ky, (16,))
batched_grad_loss_fn = jax.jit(jax.vmap(grad_loss_fn, in_axes=(None, 0, 0)))
gw_b, gb_b = batched_grad_loss_fn(params_init, x_batch_data, y_batch_target)
print("w 的逐样本梯度形状:", gw_b.shape, "| b 的逐样本梯度形状:", gb_b.shape)
check(gw_b.shape == (16, 2) and gb_b.shape == (16,))
🔍 补充:组合顺序 = 数学语义。 ⚪ 选读:已学过入门第 4 章「逐样本梯度」专题的可以跳过。
vmap(grad(loss))得到每个样本的梯度(形状(B, ...)),grad(lambda p: mean(vmap(loss)(p)))得到平均损失的梯度。求导是线性运算,所以后者就等于前者在批维上取平均,下面的单元验证了这一点。原理与用途(差分隐私、逐样本裁剪等)见 入门 04 自动向量化vmap §4.6 的逐样本梯度专题。
def mean_loss(params, xs, ys):
return jnp.mean(jax.vmap(loss_fn, in_axes=(None, 0, 0))(params, xs, ys))
gw_mean, gb_mean = jax.jit(jax.grad(mean_loss))(params_init, x_batch_data, y_batch_target)
print("平均损失的梯度:", gw_mean, gb_mean)
check(np.allclose(gw_mean, gw_b.mean(0), atol=1e-6) and np.allclose(gb_mean, gb_b.mean(), atol=1e-6))
1.2 精通 lax.scan 处理序列操作¶
jit、grad、vmap 擅长处理「整批」的数据。但很多算法带有时间步之间的依赖,比如 RNN、累加、ODE 积分、强化学习里的折扣回报:第 $t$ 步要用到第 $t-1$ 步的结果。jax.lax.scan 就是专门为这类计算设计的原语。
为什么不直接在 jit 里写 Python for 循环? 追踪时 Python 循环会被原样执行。如果循环次数是 Python 整数(静态值),每一轮的操作都会被展开(unroll)写进 jaxpr:循环 1000 次,计算图里就有 1000 份循环体,导致编译慢、内存占用大。如果循环次数是 traced 值,Python 的 range() 根本无法执行,会直接报错。lax.scan 则把循环体只追踪一次,编译成 XLA 里的一个循环原语。
⚠️ 注意:常有人说「在 jit 里用 Python for 循环、长度又依赖运行时的值时,JAX 会展开循环」。其实只有长度是静态 Python 整数时才会展开。长度依赖运行时(traced)的值时,
range(n)会直接抛出TracerIntegerConversionError,根本走不到展开这一步。下面的单元验证了这两种情况。
def py_loop_sum(x, n):
s = 0.0
for _ in range(n): # n 必须是 Python 整数
s = s + x
return s
# 情况 1:n 是 traced 值 → 报错
try:
jax.jit(py_loop_sum)(1.0, 3)
raised = None
except Exception as e:
raised = type(e).__name__
print("n 为 traced 值:", raised)
check(raised == "TracerIntegerConversionError")
# 情况 2:n 是静态值 → 展开,jaxpr 中的 add 数量随 n 线性增长
n_eqns = {n: len(jax.make_jaxpr(py_loop_sum, static_argnums=1)(1.0, n).eqns) for n in (3, 30)}
print("静态 n 时 jaxpr 的方程数:", n_eqns)
check(n_eqns[30] > n_eqns[3] * 5)
核心思想与签名¶
final_carry, ys = jax.lax.scan(f, init, xs=None, length=None, reverse=False, unroll=1)
f(carry, x) -> (new_carry, y):循环体。carry是在各步之间传递的状态,x是xs沿第 0 轴取出的当前切片,y是这一步的输出;init:初始 carry。它的 pytree 结构、每个叶子的形状和 dtype,都必须和f返回的new_carry完全一致;xs:可选,可以是任意 pytree,所有叶子沿第 0 轴同步迭代。为None时,循环体收到的x是None;length:迭代次数。通常可以从xs推断,xs=None时必须给出。给出时必须与xs的第 0 维相等;reverse:为True时从最后一个元素向前扫描,输出ys仍按原索引位置存放;unroll:性能调优参数,表示把循环体复制几份放进一次 XLA 循环迭代中。可以是正整数,也可以是True(完全展开);- 返回值:
final_carry和ys。ys是把每步的y沿新的第 0 轴堆叠起来的结果,可以是 pytree,y为None时ys也是None。
用纯 Python 写出来,它的语义是:
def scan(f, init, xs, length=None, reverse=False):
if xs is None:
xs = [None] * length
carry, ys = init, []
for x in (reversed(xs) if reverse else xs):
carry, y = f(carry, x)
ys.append(y)
if reverse:
ys = ys[::-1]
return carry, np.stack(ys)
写成递推式:$c_t = f_c(c_{t-1}, x_t)$,$y_t = f_y(c_{t-1}, x_t)$,$t=1,\dots,T$。
课程代码 1:累加和¶
def cumulative_sum_step(carry, x):
new_carry = carry + x
return new_carry, new_carry # 输出“截至当前”的累加和
xs = jnp.array([1, 2, 3, 4, 5])
final, ys = lax.scan(cumulative_sum_step, 0, xs)
print("最终 carry:", final, "| 序列:", ys)
check(int(final) == 15 and ys.tolist() == [1, 3, 6, 10, 15])
课程代码 2:用 scan 实现简单 RNN¶
$$h_t = \tanh\left(W_{hh}\,h_{t-1} + W_{xh}\,x_t + b_h\right)$$
carry 是隐状态 $h$,xs 是输入序列。每一步把新的隐状态同时作为 carry 和输出。这里 key 的用法有两个容易踩的坑,先看对照:
# ❌ 旧写法(错误):W_hh 与 W_xh 共用同一个 w_key;x_key = random.split(key) 得到的是 key 数组
from jax import random
key = random.PRNGKey(0)
key, w_key, b_key = random.split(key, 3)
W_hh = random.normal(w_key, (5, 5)) * 0.1
W_xh = random.normal(w_key, (5, 3)) * 0.1 # 与 W_hh 用的是同一个 key!
# 运行可见:W_xh 的 15 个元素恰好就是 W_hh 展平后的前 15 个元素,两者根本不独立
same = bool(np.allclose(np.ravel(W_xh), np.ravel(W_hh)[:15]))
print("W_xh == W_hh 展平后的前 15 个元素:", same)
check(same)
try:
x_key = random.split(key) # 形状 (2, 2) 的 key 数组,不是单个 key
input_sequence = random.normal(x_key, (4, 3))
print("未报错")
except Exception as e:
print("报错:", type(e).__name__, "-", str(e)[:90])
# ✅ 推荐写法:jax.random.key + 每个用途一个独立子 key;参数作为函数参数传入(不靠闭包)
input_features, hidden_features, sequence_length = 3, 5, 4
k_hh, k_xh, k_b, k_x = jax.random.split(jax.random.key(0), 4)
rnn_params = {
"W_hh": jax.random.normal(k_hh, (hidden_features, hidden_features)) * 0.1,
"W_xh": jax.random.normal(k_xh, (hidden_features, input_features)) * 0.1,
"b_h": jax.random.normal(k_b, (hidden_features,)) * 0.1,
}
input_sequence = jax.random.normal(k_x, (sequence_length, input_features))
h_initial = jnp.zeros((hidden_features,))
def rnn_step(params, h_prev, x_t):
h_new = jnp.tanh(params["W_hh"] @ h_prev + params["W_xh"] @ x_t + params["b_h"])
return h_new, h_new # (新 carry, 本步输出)
@jax.jit
def run_rnn_scan(params, h0, xs):
# 在 jit 内部,params 已经是 tracer,lambda 捕获它不会产生嵌入常量
return lax.scan(lambda h, x: rnn_step(params, h, x), h0, xs)
h_final, h_seq = run_rnn_scan(rnn_params, h_initial, input_sequence)
print("输入:", input_sequence.shape, "初始:", h_initial.shape, "最终:", h_final.shape, "序列:", h_seq.shape)
check(h_final.shape == (5,) and h_seq.shape == (4, 5))
# 与 NumPy float64 的 Python 循环对拍
P = {k: np.asarray(v, np.float64) for k, v in rnn_params.items()}
h = np.zeros(5); ref = []
for x_t in np.asarray(input_sequence, np.float64):
h = np.tanh(P["W_hh"] @ h + P["W_xh"] @ x_t + P["b_h"]); ref.append(h)
check(np.allclose(h_seq, np.stack(ref), atol=1e-6) and np.allclose(h_final, h, atol=1e-6))
print("与 Python 循环结果一致 ✓")
⚠️ 注意:如果用同一个
w_key同时生成W_hh和W_xh,W_xh会恰好等于W_hh展平后的前 15 个元素,两个矩阵完全相关。另外,x_key = random.split(key)得到的是 key 数组,把它传给random.normal会抛出ValueError: normal accepts a single key...。正确做法是key, x_key = split(key),或者一次split(key, n)后逐个解包。还有一种常见写法:把参数定义在
rnn_step外面,让 JAX 通过闭包拿到它。这样能运行,但权重会作为常量嵌入编译后的程序,原因见 1.7 节。本机 jaxlib 0.12.0.1 上,闭包捕获大矩阵还会悄悄算错。所以推荐写法把参数作为 jit 函数的参数传入。
scan 相对 Python 循环的好处(课程要点):
- 不展开:循环体只追踪一次,编译成 XLA 的 While 循环,编译时间和程序体积基本与步数无关;
- 内存效率:XLA 可以在各次迭代之间复用缓冲区;
- 循环体优化:XLA 可以对循环体内部做算子融合等优化。
与其他变换的关系:放在 jit 里用效果最好。grad 可以对 init、xs 以及被循环体捕获的参数求导,相当于 BPTT(随时间反向传播)。vmap 可以并行处理一批序列,比如 jax.vmap(lambda seq: lax.scan(step, h0, seq))(batch)。如果每个序列的初始状态也不同,就把 h0 一起放进 in_axes。
课程代码 3:xs=None 与 length¶
def generate_powers_of_2(carry, _): # 第二个参数会收到 None
return carry * 2, carry # 新 carry 翻倍,输出当前值
final_val, powers = lax.scan(generate_powers_of_2, 1, xs=None, length=10)
print("最终值:", final_val, "| 序列:", powers)
check(int(final_val) == 1024 and powers.tolist() == [2 ** i for i in range(10)])
# ❌ 错误写法:以为 xs 为 None 时,循环体可以只写成 f(carry)
try:
lax.scan(lambda c: (c * 2, c), 1, xs=None, length=3)
print("未报错")
except Exception as e:
print("报错:", type(e).__name__, "-", e)
# ✅ 推荐写法:循环体始终接收两个参数,不用的那个写成 _
final_val, _ = lax.scan(lambda c, _: (c * 2, None), 1, xs=None, length=3) # y 为 None 时 ys 也是 None
check(int(final_val) == 8)
try:
lax.scan(lambda c: (c * 2, c), 1, xs=None, length=3); ok = False
except TypeError:
ok = True
check(ok)
print("f(carry) 形式会抛 TypeError;f(carry, _) 正常 ✓")
⚠️ 注意:
xs为None时,循环体也不能只写成f(carry)。scan 总是用f(carry, x)两个参数调用循环体,xs=None时x就是None。只写一个参数会抛出TypeError: <lambda>() takes 1 positional argument but 2 were given。
🔍 补充:reverse、unroll、length 与 pytree 的细节¶
⚪ 选读:用到
reverse、unroll或 pytree 形式的 xs / ys 时再看。
reverse=True 从后往前扫描,但 ys[t] 仍然对应 xs[t],输出不会被翻转。它相当于「先翻转 xs,正向扫描,再把 ys 翻回来」。典型用途有强化学习的折扣回报 $G_t = r_t + \gamma G_{t+1}$、双向 RNN 的反向分支,以及后缀和。
f = lambda c, x: (c + x, c + x)
xs = jnp.arange(1, 6)
_, ys_rev = lax.scan(f, 0, xs, reverse=True)
_, ys_flip = lax.scan(f, 0, xs[::-1])
print("reverse=True:", ys_rev, "(后缀和,ys[t] 对应 xs[t])")
check(ys_rev.tolist() == [15, 14, 12, 9, 5])
check(np.array_equal(ys_rev, ys_flip[::-1]))
unroll 不改变计算结果,只改变编译后的程序结构。unroll=k 表示 XLA 循环每迭代一次执行 k 个时间步,True 表示完全展开。循环体很小时(比如标量运算或小矩阵运算),每次迭代固定的循环开销占比高,适当 unroll(2、4、8)可能更快。循环体很大时,unroll 只会让编译时间和代码体积膨胀。一定要自己测。下面是本机 CPU 上的一组数据(只做比较,不做断言)。多跑几次会发现,这个例子里各种 unroll 设置的耗时差别都不大,基本在计时噪声范围内,完全展开有时还更慢。这说明这里的瓶颈不在循环开销:
def make_scan(unroll):
@jax.jit
def run(x0, xs):
return lax.scan(lambda c, x: (jnp.sin(c) * 0.9 + x, None), x0, xs, unroll=unroll)[0]
return run
xs_long = jnp.linspace(0., 1., 10_000)
results = {}
for u in (1, 4, 16, True):
fn = make_scan(u)
results[u] = fn(0.0, xs_long)
print(f"unroll={str(u):>4}: {bench(fn, 0.0, xs_long):7.3f} ms")
check(all(np.allclose(results[1], v, atol=1e-5) for v in results.values())) # 语义不变
# jaxpr 中可以看到 unroll 参数被记录下来
print([l.strip() for l in str(jax.make_jaxpr(make_scan(4))(0.0, xs_long)).splitlines() if "unroll" in l])
scan 与 Python 循环的编译时间对比(同样 500 步,只打印不断言):
def py_loop(x):
for _ in range(500):
x = jnp.sin(x) + 0.1
return x
def scan_loop(x):
return lax.scan(lambda c, _: (jnp.sin(c) + 0.1, None), x, None, length=500)[0]
for fn in (py_loop, scan_loop):
t0 = time.perf_counter(); jax.jit(fn).lower(1.0).compile()
print(f"{fn.__name__:>9}: 追踪+编译 {1e3 * (time.perf_counter() - t0):7.1f} ms, "
f"jaxpr 方程数 = {len(jax.make_jaxpr(fn)(1.0).eqns)}")
check(np.allclose(jax.jit(py_loop)(1.0), jax.jit(scan_loop)(1.0), atol=1e-5))
length 与 xs 的长度不一致会在追踪时报错。pytree 形式的 xs 和 ys 很常用:多个序列可以打包成字典或元组同步迭代。
try:
lax.scan(lambda c, x: (c + x, None), 0, jnp.arange(5), length=4); msg = None
except ValueError as e:
msg = str(e)
print("length 不一致:", msg)
check(msg is not None and "disagrees" in msg)
# pytree xs / ys:字典输入,元组输出
xs_tree = {"a": jnp.arange(4.), "b": jnp.ones((4, 2))}
def body(c, x):
c = c + x["a"] * x["b"] # carry 形状 (2,)
return c, (c.sum(), x["a"] ** 2)
c_final, (sums, sq) = lax.scan(body, jnp.zeros(2), xs_tree)
print("final:", c_final, "| sums:", sums, "| sq:", sq)
check(np.allclose(c_final, [6., 6.]) and np.allclose(sq, [0, 1, 4, 9]))
🔍 补充:读懂 carry 类型不一致的报错¶
🟢 必读
scan 要求 init 和循环体返回的 new_carry 类型完全一致,包括 pytree 结构、形状和 dtype。这是最常见的 scan 报错,JAX 0.12 会精确指出是哪一项不一致:
| 不一致的项 | 报错关键句 |
|---|---|
dtype(如 int32 → float32) | carry input and carry output must have equal types … so the dtypes do not match |
形状(如 () → (2,)) | … so the shapes do not match |
| pytree 结构(如数组 → 元组) | must have the same pytree structure … their Python types differ |
有个容易踩的坑:init=0(Python int,弱类型)配合浮点 xs 不会报错,JAX 会把弱类型的 carry 提升为 float32。但如果写成 jnp.int32(0)(强类型),就会报 dtype 不一致。所以最稳妥的做法是显式写出 init 的 dtype,例如 jnp.zeros((), xs.dtype)。
def scan_err(init, body, xs):
try:
lax.scan(body, init, xs); return "(未报错)"
except TypeError as e:
return str(e).split("\n\n")[1] # 取出中间那句具体说明
xs_f = jnp.arange(5.)
m_dtype = scan_err(jnp.int32(0), lambda c, x: (c + x, c), xs_f)
m_shape = scan_err(jnp.float32(0), lambda c, x: (jnp.stack([c, c]), c), xs_f)
m_tree = scan_err(jnp.float32(0), lambda c, x: ((c, c), c), xs_f)
for name, m in [("dtype", m_dtype), ("形状", m_shape), ("结构", m_tree)]:
print(f"[{name}] {m}")
check("dtypes do not match" in m_dtype and "shapes do not match" in m_shape and "Python types differ" in m_tree)
# 弱类型 init 不报错,carry 被提升为 float32
c_weak, _ = lax.scan(lambda c, x: (c + x, None), 0, xs_f)
print("init=0(弱类型)→ final carry dtype:", c_weak.dtype)
check(c_weak.dtype == jnp.float32)
🔍 补充:
lax.associative_scan。 ⚪ 选读:做线性 RNN、SSM 这类满足结合律的长序列递推时再看。如果递推满足结合律,比如累加、累乘、线性递推 $h_t = a_t h_{t-1} + b_t$,可以用
lax.associative_scan做并行前缀扫描。它的深度是 $O(\log T)$,在 GPU/TPU 上处理长序列时,通常比顺序执行的scan快很多(本机只有 CPU,没有测加速比)。线性 RNN 和 SSM(如 S5、Mamba 的并行模式)就是这个原理。普通的非线性 RNN 不满足结合律,一般只能用scan。
x = jnp.arange(1., 9.)
check(np.allclose(lax.associative_scan(jnp.add, x), jnp.cumsum(x)))
# 线性递推 h_t = a_t * h_{t-1} + b_t,二元组合算子 (a1,b1)∘(a2,b2) = (a1*a2, a2*b1 + b2)
a = jnp.full(8, 0.9); bb = jnp.arange(8.)
comb = lambda l, r: (l[0] * r[0], r[0] * l[1] + r[1])
_, h_par = lax.associative_scan(comb, (a, bb))
_, h_seq = lax.scan(lambda h, ab: (ab[0] * h + ab[1],) * 2, 0.0, (a, bb))
print("并行前缀:", h_par)
check(np.allclose(h_par, h_seq, atol=1e-5))
1.3 使用 lax.cond 进行条件执行¶
在 jit 追踪期间,Python 的 if 只能根据 Python 层面的具体值选择分支。如果条件依赖 JAX 数组的运行时值(traced 值),if 就得把 tracer 转换成 bool,追踪会失败。lax.cond 则把条件逻辑写进计算图里:
jax.lax.cond(pred, true_fun, false_fun, *operands)
pred:标量布尔值,形状为()。可以是 traced 值,也可以是 Python bool;true_fun/false_fun:两个分支函数,都接收*operands;*operands:传给分支的输入,可以是多个数组,也可以是任意 pytree。
关键约束:两个分支的输出必须具有完全相同的 pytree 结构、形状和 dtype,因为编译后程序的输出类型必须在编译期确定。
底层机制:追踪时两个分支都会被追踪,用来检查并确定输出类型。编译出的程序包含真正的运行时分支,pred 不带 vmap 批维时只执行被选中的那一支(带批维的情况见 1.3 节补充)。
课程代码:Python if vs lax.cond¶
@jax.jit
def python_conditional_process(x):
if jnp.sum(x) > 0: # 需要把 tracer 转成 bool
return x * x
return x * x * x
for data in (jnp.array([1., 2., 3.]), jnp.array([-1., -2., -3.])):
try:
python_conditional_process(data); err = None
except Exception as e:
err = e
print(type(err).__name__, "| 是 ConcretizationTypeError 的子类:",
isinstance(err, jax.errors.ConcretizationTypeError))
check(isinstance(err, jax.errors.TracerBoolConversionError))
⚠️ 注意:很多资料说这里会出现
ConcretizationTypeError。JAX 0.12 实际抛出的是更具体的TracerBoolConversionError。它是ConcretizationTypeError的子类,所以用except ConcretizationTypeError照样能捕获到。
def lax_conditional_process(x):
def true_branch(operand):
print(" [追踪] true_branch")
return operand * operand
def false_branch(operand):
print(" [追踪] false_branch")
return operand * operand * operand
pred = jnp.sum(x) > 0
return lax.cond(pred, true_branch, false_branch, x)
jit_cond = jax.jit(lax_conditional_process)
print("第一次调用(正数):")
r_pos = jit_cond(jnp.array([1., 2., 3.]))
print(" 结果:", r_pos)
print("第二次调用(负数,形状相同 → 复用编译结果,不再打印追踪信息):")
r_neg = jit_cond(jnp.array([-1., -2., -3.]))
print(" 结果:", r_neg)
check(np.allclose(r_pos, [1, 4, 9]) and np.allclose(r_neg, [-1, -8, -27]))
多个操作数直接依次传入即可;分支输出类型不一致、pred 不是标量都会在追踪时报错:
print("多操作数:", lax.cond(True, lambda a, b: a + b, lambda a, b: a - b, 1., 2.))
def cond_err(*args):
try:
lax.cond(*args); return None
except TypeError as e:
return str(e).splitlines()[0]
m1 = cond_err(True, lambda x: x, lambda x: x.astype(jnp.int32), 1.)
m2 = cond_err(jnp.array([True, False]), lambda x: x, lambda x: -x, 1.)
print("分支 dtype 不一致:", m1)
print("pred 非标量:", m2)
check(m1.startswith("cond branches must have equal output types") and m2.startswith("Pred must be a scalar"))
与其他变换的关系:
vmap:从结果上看,批次里每个元素按自己的条件「独立选择分支」。但实现方式和直觉不同,见下面的补充;grad:梯度只沿实际执行的分支传播。
⚠️ 注意:容易以为 pred 依赖可微变量时,梯度也会流经它。其实
pred来自比较运算(>、>=等),比较运算输出布尔值,不可微,所以梯度不会流经pred。对决定分支的阈值参数求导,结果恒为 0。真正的问题在于:分支切换点处函数可能不连续,而梯度看不到「换分支」带来的变化。需要对阈值求导时,要用sigmoid这类平滑松弛。1.5 节会用assert证明阈值的梯度为 0。
lax.cond vs jnp.where:
lax.cond | jnp.where(c, x, y) | |
|---|---|---|
| 条件 | 单个标量 | 逐元素布尔数组,可广播 |
| 计算量 | 只执行一个分支(pred 不带 vmap 批维时) | x 和 y 都完整计算,再逐元素挑选 |
| 适合 | 两条根本不同、而且开销大的计算路径 | 按掩码逐元素选值 |
🔍 补充:lax.switch 与 lax.select_n¶
🟢 必读
lax.switch(index, branches, *operands):多分支版的 cond,index是整数标量。越界的 index 会被截断(clamp)到[0, len(branches)-1],不会报错。这个行为很容易掩盖 bug,必要时自己加检查;lax.select_n(which, *cases):逐元素的多路选择,是jnp.where的底层原语。它不做广播,所有 case 的形状必须相同。which为 bool 时,False 选第 0 个、True 选第 1 个,顺序和jnp.where(c, x, y)相反。which为整数时选第which个 case。
branches = [lambda x: x + 1, lambda x: x * 2, lambda x: -x]
out = [float(lax.switch(i, branches, 3.0)) for i in (0, 1, 2, 5, -3)]
print("switch index 0,1,2,5,-3 →", out)
check(out == [4.0, 6.0, -3.0, -3.0, 4.0]) # 5 被截断成 2,-3 被截断成 0
z, o, t = jnp.zeros(4), jnp.ones(4), 2 * jnp.ones(4)
s_int = lax.select_n(jnp.array([0, 1, 2, 1]), z, o, t)
s_bool = lax.select_n(jnp.array([True, False, True, False]), z, o)
print("select_n(int) :", s_int)
print("select_n(bool):", s_bool, "← True 选第 1 个 case(这里是 ones)")
check(np.allclose(s_int, [0, 1, 2, 1]) and np.allclose(s_bool, [1, 0, 1, 0]))
# 与 jnp.where 的顺序对照:where(c, x, y) == select_n(c, y, x)
c = jnp.array([True, False, True, False])
check(np.allclose(jnp.where(c, o, z), lax.select_n(c, z, o)))
🔍 补充:cond 在 vmap 下会变成 select¶
🟢 必读
vmap 把整批数据当作一个数组、用同一段程序同时处理,没办法让批内不同元素各走各的分支。这是 vmap 的变换规则,跟后端无关,下面在 CPU 上看到的 jaxpr 就是这样。所以如果 vmap 的批维进入了 pred,JAX 会把 cond 改写成:两个分支都对整个批次算一遍,再用 select_n 逐元素挑选。这时 cond「只算一个分支」的优势就没了,运算量等于两个分支加起来。分支里有副作用(如 jax.debug.print)或开销很大时尤其要注意。
如果 pred 没有被批处理,比如只有 operand 带批维,cond 会保留下来,仍然只执行一个分支。
def g(x):
return lax.cond(x > 0, lambda v: v * 2, lambda v: -v, x)
jp_scalar = str(jax.make_jaxpr(g)(1.0))
jp_batched_pred = str(jax.make_jaxpr(jax.vmap(g))(jnp.arange(3.)))
print("—— vmap 后(pred 被批处理)的 jaxpr ——\n", jp_batched_pred)
check("cond[" in jp_scalar)
check("cond[" not in jp_batched_pred and "select_n" in jp_batched_pred)
def g2(p, x):
return lax.cond(p > 0, lambda v: v * 2, lambda v: -v, x)
jp_unbatched_pred = str(jax.make_jaxpr(jax.vmap(g2, in_axes=(None, 0)))(1.0, jnp.arange(3.)))
check("cond[" in jp_unbatched_pred)
print("pred 不带批维时,cond 原语保留 ✓")
🔍 补充:
jnp.where与梯度中的 NaN。 ⚪ 选读:已掌握入门第 3 章「双 where」的可以跳过。
jnp.where两边都会计算,未被选中一边的 NaN/Inf 照样会污染梯度,例如where(x>0, sqrt(x), 0)在x=0处的梯度是 NaN。解决办法是「双 where」:先把非法输入替换成安全值再计算。原理与演示见 入门 03 自动微分grad §3.8,进阶讨论见 入门 07 实战专题 专题 2。
1.4 使用 lax.while_loop 进行循环¶
lax.scan 的步数在编译期就固定了。有些迭代要根据运行时的中间结果决定何时停止,比如牛顿法收敛、二分查找、Collatz 序列,这时就需要 lax.while_loop:
final_val = jax.lax.while_loop(cond_fun, body_fun, init_val)
cond_fun(carry) -> bool 标量:为 True 时继续循环;body_fun(carry) -> new_carry:返回的新状态必须与输入结构、形状、dtype 完全一致;init_val:初始状态,可以是标量、数组或 pytree。
执行顺序为 cond(init) → body(init) → cond(next) → …,直到 cond 返回 False,然后返回让 cond 变成 False 的那个状态。它等价于下面的 Python 代码:
val = init_val
while cond_fun(val):
val = body_fun(val)
return val
状态是函数式的:body 不修改输入,而是返回新状态。cond_fun 和 body_fun 都只追踪一次,carry 的形状和 dtype 必须是静态的,但它的值可以是动态的,整个循环编译成一个 XLA While 操作。
与 scan 对比:scan 的步数固定,并且可以收集每一步的输出 ys;while_loop 的步数可变,只返回最终状态。
注意事项:cond_fun 最终必须返回 False,否则编译后的程序会无限循环,进程挂起。而且 Ctrl-C 通常也中断不了,因为控制权在编译好的 XLA 循环里,回不到 Python(试过向进程发送 SIGINT,10 秒后它还在运行)。这时只能重启内核或杀掉进程。实际写代码时,应该在 carry 里加一个迭代计数器,设一个最大步数作为保险。
课程代码:求不小于 n 的最小 2 的幂¶
def find_power_of_two(n):
def cond_fun(current_power):
return current_power < n
def body_fun(current_power):
return current_power * 2
init_val = 1
return lax.while_loop(cond_fun, body_fun, init_val)
target_number = 100
r1 = find_power_of_two(target_number)
r2 = jax.jit(find_power_of_two)(target_number)
print("最小的 2 的幂 >= 100:", r1, "| JIT 结果:", r2)
check(int(r1) == 128 and int(r2) == 128)
# 循环轨迹:cond(1)…cond(64) 为真,body(64)=128,cond(128) 为假 → 返回 128
while_loop 不能做反向模式求导¶
⚠️ 注意:常能看到「
while_loop支持jax.grad,反向传播会展开已执行的迭代」这种说法,但它不成立。反向模式(grad/vjp)对lax.while_loop会直接报错:ValueError: Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values。原因是反向传播要保存每一步的中间值(残差),而 while_loop 的步数在编译期不知道,XLA 没法预先分配缓冲区。前向模式(jvp/jacfwd)不需要保存历史,可以正常用。
def power_loop(x):
# 计算 x**5:计数器 i 从 0 到 5
return lax.while_loop(lambda c: c[0] < 5, lambda c: (c[0] + 1, c[1] * x), (0, 1.0))[1]
val, tangent = jax.jvp(power_loop, (2.0,), (1.0,))
print("x^5 at 2 =", val, "| 前向模式导数 5x^4 =", tangent)
check(np.isclose(val, 32.) and np.isclose(tangent, 80.))
try:
jax.grad(power_loop)(2.0); msg = None
except ValueError as e:
msg = str(e)
print("grad(while_loop):", msg)
check(msg is not None and "Reverse-mode differentiation does not work" in msg)
# jacfwd(前向模式构造的雅可比)可以用
check(np.isclose(jax.jacfwd(power_loop)(2.0), 80.))
固定步数时应该用什么?
| 场景 | 推荐 | 能否反向求导 |
|---|---|---|
| 步数是 Python 常量 | lax.fori_loop(0, N, ...) 或 lax.scan | ✅ fori_loop 的上下界是静态值时,会被改写为 scan |
| 步数是 traced 值,但有上限 | 用 lax.scan 跑到上限,再用掩码冻结已完成的步,见补充练习 2 | ✅ |
| 步数完全由收敛条件决定 | lax.while_loop | ❌ 只能用前向模式;或者用隐函数定理 / custom_vjp 自己定义梯度 |
fori_loop 的这种「双重身份」可以直接从 jaxpr 中看到:
def fl_static(x):
return lax.fori_loop(0, 5, lambda i, c: c * x, 1.0)
def fl_dynamic(x, n):
return lax.fori_loop(0, n, lambda i, c: c * x, 1.0)
jp_s = str(jax.make_jaxpr(fl_static)(2.0))
jp_d = str(jax.make_jaxpr(fl_dynamic)(2.0, 5))
print("静态上下界 → 原语:", "scan" if "scan[" in jp_s else "while")
print("动态上下界 → 原语:", "scan" if "scan[" in jp_d else "while")
check("scan[" in jp_s and "while[" in jp_d)
check(np.isclose(jax.grad(fl_static)(2.0), 80.)) # 静态 → 可以反向求导
try:
jax.jit(jax.grad(fl_dynamic))(2.0, 5); ok = False # jit 下 n 是 traced 值 → 动态
except ValueError:
ok = True
check(ok)
print("fori_loop:静态上下界可 grad,traced 上下界不可 ✓")
🔍 补充:一个细节。 ⚪ 选读:写库代码、需要让 fori_loop 可反向求导时参考。
在
jit外面调用jax.grad(fl_dynamic)(2.0, 5)是可以成功的,因为这时n=5是具体的 Python 整数,fori_loop会把它当作静态值。同一段代码一加上jit就会报错。所以在写库代码时,不要依赖「碰巧是具体值」的行为,要么把步数声明为static_argnums,要么直接改用scan。
🔍 补充:while_loop 在 vmap 下运行到「最慢的那个元素」¶
⚪ 选读:对一批数据跑 while_loop、而且各元素迭代次数差异大时再看。
如果 vmap 的批维进入了 cond_fun,不同元素需要的迭代次数就可能不同。JAX 的做法是:只要还有一个元素需要继续,整个批次就都继续循环。在 lowering 后的 HLO 里,这表现为对谓词做一次 reduce or。已经结束的元素用 select 冻结状态,它们仍然参与计算,只是结果被丢掉。所以批内迭代次数差异很大时,浪费的计算会很多,可以考虑按预计步数分桶(bucketing)。
def collatz_steps(n):
def body(c):
v, k = c
return jnp.where(v % 2 == 0, v // 2, 3 * v + 1), k + 1
return lax.while_loop(lambda c: c[0] > 1, body, (n, 0))[1]
ns = jnp.array([1, 2, 3, 6, 7, 27])
steps = jax.vmap(collatz_steps)(ns)
print("各元素的 Collatz 步数:", steps, "→ 整个批次要循环", int(steps.max()), "次")
def collatz_py(n):
k = 0
while n > 1:
n = n // 2 if n % 2 == 0 else 3 * n + 1; k += 1
return k
check(steps.tolist() == [collatz_py(int(n)) for n in ns])
hlo = jax.jit(jax.vmap(collatz_steps)).lower(ns).as_text()
check("stablehlo.or" in hlo and "stablehlo.select" in hlo)
print("HLO 中可以看到 reduce-or(“任一元素未完成”)和 select(冻结已完成元素)✓")
def conditional_computation(x, threshold=5.0):
total = jnp.sum(x)
return lax.cond(total >= threshold, lambda o: o * 2.0, lambda o: o / 2.0, x)
jit_cc = jax.jit(conditional_computation)
r1 = jit_cc(jnp.array([1., 2., 3.])) # 和为 6 ≥ 5 → ×2
r2 = jit_cc(jnp.array([1., 1., 1.])) # 和为 3 < 5 → ÷2
print("Result 1:", r1, "| Result 2:", r2)
check(np.allclose(r1, [2, 4, 6]) and np.allclose(r2, [0.5, 0.5, 0.5]))
与 grad¶
scan:反向传播时按时间倒序应用链式法则,也就是 BPTT。正向传播每一步的中间值都要保存下来供反向使用,所以内存随序列长度线性增长。长序列可以用梯度检查点(jax.checkpoint)缓解,见本节后面的补充。
课程示例:标量 RNN,$h_t = \tanh(W x_t + U h_{t-1} + b)$,其中 $W=0.5,\ U=0.8,\ b=0.1$,损失为 $L=\sum_t h_t$,对初始隐状态 $h_0$ 求导。手动推导得到:
$$\frac{\partial L}{\partial h_0}=\sum_{t=1}^{T}\prod_{k=1}^{t} U\,(1-h_k^2).$$
def simple_rnn_step(carry, x_t):
W, U, b = 0.5, 0.8, 0.1 # Python 标量常量:作为字面量嵌入,没有问题
new_hidden = jnp.tanh(W * x_t + U * carry + b)
return new_hidden, new_hidden
def run_rnn(initial_hidden, inputs):
_, outputs = lax.scan(simple_rnn_step, initial_hidden, inputs)
return jnp.sum(outputs)
grad_run_rnn = jax.grad(run_rnn, argnums=0)
input_sequence_s = jnp.array([1.0, -0.5, 2.0])
g_h0 = grad_run_rnn(0.0, input_sequence_s)
print("dL/dh0 =", g_h0)
# NumPy float64 对拍:手动推导公式 + 中心差分
xs64 = np.array([1.0, -0.5, 2.0])
def rnn_np(h0):
h, hs = h0, []
for x_t in xs64:
h = np.tanh(0.5 * x_t + 0.8 * h + 0.1); hs.append(h)
return np.array(hs)
hs = rnn_np(0.0)
analytic = sum(np.prod(0.8 * (1 - hs[:t + 1] ** 2)) for t in range(3))
fd = (rnn_np(1e-6).sum() - rnn_np(-1e-6).sum()) / 2e-6
print(f"手推公式: {analytic:.7f} | 中心差分: {fd:.7f}")
check(np.isclose(g_h0, analytic, atol=1e-5) and np.isclose(analytic, fd, atol=1e-6))
check(abs(float(g_h0) - 0.313) > 0.5) # 和常见的错误数值 0.313 明显不同
⚠️ 注意:这里
dL/dh0≈ 1.0749918,和手推公式、float64 中心差分完全一致。有的资料给出约0.313...,这个数不对。
cond:梯度只经过实际执行的分支。在课程示例里,sum(x)>0 时损失为 $\sum p_i x_i$,梯度为 $x$;否则损失为 $\sum p_i/(x_i+10^{-5})$,梯度为 $1/(x+10^{-5})$。
def conditional_loss(params, x):
pred = jnp.sum(x) > 0
def loss1(p): return jnp.sum(p * x)
def loss2(p): return jnp.sum(p / (x + 1e-5))
return lax.cond(pred, loss1, loss2, params)
params = jnp.array([0.5, -0.5])
g_pos = jax.grad(conditional_loss)(params, jnp.array([1.0, 1.0]))
g_neg = jax.grad(conditional_loss)(params, jnp.array([-1.0, -1.0]))
print("正例梯度:", g_pos, "| 负例梯度:", g_neg, "| 理论值 1/(-1+1e-5) =", 1 / (-1 + 1e-5))
check(np.allclose(g_pos, [1., 1.]))
check(np.allclose(g_neg, 1 / (-1 + 1e-5), rtol=1e-6) and float(g_neg[0]) < -1.0)
⚠️ 注意:负例的梯度是 $1/(-1+10^{-5}) = -1/0.99999 \approx$
-1.00001,绝对值大于 1。不要误写成[-0.99999 -0.99999]。
while_loop:不支持反向模式,见 1.4 节。
与 vmap¶
scan:批里的每个序列独立扫描。vmap 会把 carry 和 xs 都加上批维,扫描仍然只有一个循环,每一步处理整批数据。
batched_run_rnn = jax.vmap(run_rnn, in_axes=(0, 0))
batch_hidden_init = jnp.zeros(4)
batch_input_sequence = jnp.arange(12, dtype=jnp.float32).reshape((4, 3))
out_b = batched_run_rnn(batch_hidden_init, batch_input_sequence)
print("批量输出形状:", out_b.shape)
check(out_b.shape == (4,))
check(np.allclose(out_b, [run_rnn(h, s) for h, s in zip(batch_hidden_init, batch_input_sequence)]))
cond:谓词依赖映射轴时,JAX 在整个批次上同时计算两个分支,再按每个元素的谓词挑选结果(也就是 1.3 节补充里的 select_n)。代价是两个分支的运算量加起来。
vmapped_conditional = jax.vmap(conditional_computation, in_axes=(0, None))
batch = jnp.array([[1., 2., 3.], [1., 1., 1.], [10., 1., 1.]])
out_v = vmapped_conditional(batch, 5.0)
print(out_v)
check(np.allclose(out_v, [[2, 4, 6], [0.5, 0.5, 0.5], [20, 2, 2]]))
while_loop:不同元素需要的迭代次数不同时,整批会一直运行到批内最大迭代次数,已结束的元素用掩码冻结,见 1.4 节的 Collatz 示例。
组合多个变换¶
可以写出 jax.grad(jax.vmap(...))、jax.jit(jax.grad(conditional_loss)) 这样的多层组合。不过下面这个示例有两处要注意:
# ❌ 旧写法(错误):grad 直接套在 vmap 外面,但 vmap 的输出形状是 (2, 3),不是标量
def threshold_from_params(params, x):
threshold = jnp.mean(params)
return conditional_computation(x, threshold)
batch_data = jnp.array([[1., 2., 3.], [1., 1., 1.]])
params_t = jnp.array([4.0, 6.0]) # 均值为 5.0
grad_vmap_fn = jax.jit(jax.grad(jax.vmap(threshold_from_params, in_axes=(None, 0)), argnums=0))
try:
print(grad_vmap_fn(params_t, batch_data))
except Exception as e:
print("报错:", type(e).__name__, "-", str(e)[:110])
# ✅ 推荐写法:先把 vmap 的输出归约成标量,再求 grad
def total_output(params, xs):
return jnp.sum(jax.vmap(threshold_from_params, in_axes=(None, 0))(params, xs))
g_thr = jax.jit(jax.grad(total_output))(params_t, batch_data)
print("对阈值参数的梯度:", g_thr)
check(np.allclose(g_thr, 0.0)) # 阈值只通过比较运算进入 pred → 梯度恒为 0
try:
jax.grad(jax.vmap(threshold_from_params, in_axes=(None, 0)))(params_t, batch_data); ok = False
except TypeError as e:
ok = "scalar-output" in str(e)
check(ok)
⚠️ 注意:直接写
jax.grad(jax.vmap(threshold_from_params, ...))跑不起来,会抛出TypeError: Gradient only defined for scalar-output functions. Output had shape: (2, 3)。改成先求和再求 grad,就能得到结果,但对params的梯度恒为 0。因为阈值只通过>=比较影响分支选择,而比较运算不可微,这也印证了 1.3 节的结论。
🔍 补充:jax.checkpoint 与 scan——用计算换内存¶
⚪ 选读:长序列反向传播内存不够时再看;完整内容在进阶第 6 章 §6.6。
对 scan 求反向梯度时,JAX 默认保存每一步循环体里的所有中间值(残差)。写成 lax.scan(jax.checkpoint(step), init, xs) 后,正向只保存每步的 carry,反向时再把这一步的中间值重算一遍。梯度不变,代价是大约多一次正向计算(1.8 节综合实践 A 的 LSTM 用 assert 验证了加不加 checkpoint 梯度都一样)。用 print_saved_residuals 查看残差、policy 策略、内存实测对比,以及「scan 堆叠层 + 检查点」的标准结构,见 进阶第 6 章 §6.6。
1.6 高级掩码技术¶
XLA 要求静态形状,但真实数据往往长短不一:长度不同的句子、提前结束的 episode、大小不同的图。标准做法是填充(padding)到统一长度,再配一个布尔掩码标出哪些位置有效。掩码运算是逐元素的,没有分支,在 jit 下编译效率很高,vmap 和 grad 也都能直接处理它。
为什么需要掩码?
- 求平均值、注意力权重时,必须把填充位置排除在外,否则结果会被填充值「稀释」;
- 在
scan里,较短的序列提前结束后,它的隐状态和输出不应该再被更新。
核心 API 是 jnp.where(condition, x, y):condition 为 True 的位置取 x,否则取 y,三者之间可以广播。
课程代码:填充与序列掩码¶
把 [1,2,3]、[4,5]、[6,7,8,9] 用 0 填充到长度 4:
| 序列 | 填充后 | 掩码 |
|---|---|---|
[1,2,3] | [1,2,3,0] | T T T F |
[4,5] | [4,5,0,0] | T T F F |
[6,7,8,9] | [6,7,8,9] | T T T T |
带掩码的平均值为 $\bar x_i = \dfrac{\sum_j m_{ij}x_{ij}}{\max(1,\sum_j m_{ij})}$。分母里的 $\max(1,\cdot)$ 用来防止全空的行出现除以零。
padded_sequences = jnp.array([[1, 2, 3, 0], [4, 5, 0, 0], [6, 7, 8, 9]], dtype=jnp.float32)
mask = padded_sequences != 0
print(mask)
# 例 1:掩码求和
masked_sum = jnp.sum(jnp.where(mask, padded_sequences, 0), axis=-1)
# 例 2:掩码平均
num_valid_elements = jnp.sum(mask, axis=-1)
masked_avg = masked_sum / jnp.maximum(num_valid_elements, 1)
print("求和:", masked_sum, "| 平均:", masked_avg)
check(np.allclose(masked_sum, [6, 9, 30]) and np.allclose(masked_avg, [2.0, 4.5, 7.5]))
🔍 补充:别用「值 != 0」生成掩码。 🟢 必读
上面用
padded != 0生成掩码只是为了演示。真实数据里 0 可能是合法值,比如 token id 0,或者特征值恰好为 0。实际工程中应该根据长度数组生成掩码:mask = jnp.arange(T)[None, :] < lengths[:, None]。
lengths = jnp.array([3, 2, 4])
mask_from_len = jnp.arange(4)[None, :] < lengths[:, None]
check(np.array_equal(mask_from_len, mask))
print(mask_from_len)
课程代码:注意力分数的掩码¶
把填充位置的分数设成一个很大的负数,softmax 之后这些位置的权重就会接近 0。
# ❌ 旧写法(错误):jnp.randn 不是 JAX API(JAX 的随机数必须显式传入 key)
try:
scores = jnp.randn(3, 4)
except AttributeError as e:
print("报错:", type(e).__name__, "-", e)
# ✅ 推荐写法:jax.random.normal + 显式 key
scores = jax.random.normal(jax.random.key(0), (3, 4))
masked_scores = jnp.where(mask, scores, -1e9)
attention_weights = jax.nn.softmax(masked_scores, axis=-1)
print(attention_weights)
check(np.allclose(np.asarray(attention_weights)[~np.asarray(mask)], 0.0))
check(np.allclose(attention_weights.sum(-1), 1.0, atol=1e-6))
# 0.12 中 softmax 自带 where= 参数,效果相同,且被屏蔽的位置输出恰为 0
aw2 = jax.nn.softmax(scores, axis=-1, where=mask)
check(np.allclose(aw2, attention_weights, atol=1e-6))
🔍 补充:「整行都被屏蔽」时,
-1e9和-inf的表现不同。 🟢 必读因果掩码叠加 padding,或者 batch 中出现空序列时,可能整行都是 False:
- 用
-1e9填充:softmax 得到均匀分布[0.25, 0.25, ...],不报错,但这些权重没有意义,通常需要在后面再乘一次掩码;- 用
-inf填充:数学上是 0/0;jax.nn.softmax实现上先减去行最大值,得到 $-\infty-(-\infty)$,结果同样是 NaN,而且 NaN 会继续传播到梯度;- 用
jax.nn.softmax(..., where=mask):全屏蔽的行输出全 0,不会出现 NaN(下面的单元验证)。所以工程上常用一个有限的大负数,例如
jnp.finfo(dtype).min,或者-1e9。低精度下要注意这个值本身会不会溢出:例如-1e9转成 float16 会变成-inf(float16 最大约 65504),bfloat16 的指数范围与 float32 相同,不会溢出。
mask_empty = mask.at[1].set(False) # 第 1 行全部屏蔽
row_big = jax.nn.softmax(jnp.where(mask_empty, scores, -1e9), axis=-1)[1]
row_inf = jax.nn.softmax(jnp.where(mask_empty, scores, -jnp.inf), axis=-1)[1]
print("-1e9 →", row_big, "| -inf →", row_inf)
check(np.allclose(row_big, 0.25) and np.isnan(row_inf).all())
row_where = jax.nn.softmax(scores, axis=-1, where=mask_empty)[1]
print("where= →", row_where)
check(np.allclose(row_where, 0.0)) # where= 时全屏蔽行输出全 0
课程代码:算术掩码¶
把掩码转成浮点数后与数据相乘,同样可以实现掩码求和与掩码平均。课程特别提醒:乘以 0 并不总是安全的。被屏蔽的位置如果是 inf 或 NaN,$0\times\infty=\text{NaN}$,结果照样会被污染。在对数空间里,log(0) 也会出问题。所以应该优先用 jnp.where。
mask_float = mask.astype(padded_sequences.dtype)
mult_sum = jnp.sum(padded_sequences * mask_float, axis=-1)
mult_avg = mult_sum / jnp.maximum(jnp.sum(mask_float, axis=-1), 1e-9)
print("乘法求和:", mult_sum, "| 乘法平均:", mult_avg)
check(np.allclose(mult_sum, [6, 9, 30]) and np.allclose(mult_avg, [2, 4.5, 7.5]))
# 乘法掩码的陷阱:被屏蔽位置为 inf 时
v = jnp.array([1.0, jnp.inf]); m = jnp.array([1.0, 0.0])
print("v*m =", v * m, "| where(m>0, v, 0) =", jnp.where(m > 0, v, 0.0))
check(np.isnan((v * m)[1]) and float(jnp.where(m > 0, v, 0.0)[1]) == 0.0)
掩码与控制流、变换的配合¶
jit:掩码运算就是普通的逐元素运算,编译没有任何障碍;vmap:掩码运算会被自动向量化;grad:梯度只流过jnp.where选中的那一边,未选中一边的梯度为 0。但要当心 NaN,见 入门 03 自动微分grad §3.8 的「双 where」;scan:在每个时间步用掩码决定「更新状态」还是「保持原状态」。
在 scan 里用掩码,有一个容易写错的地方:掩码是随时间步变化的,应该和输入一起放进 xs 逐步读取,而不是放进 carry,因为 carry 在各步之间只是原样传递。下面是完整写法,并验证了「填充后用掩码扫描」和「只扫描真实长度」得到的最终状态完全一致。
def masked_scan_body(hidden_state, inp):
input_element, current_mask = inp # 掩码从 xs 中逐步读取
potential_new_state = hidden_state * 0.9 + input_element * 0.1
new_state = jnp.where(current_mask, potential_new_state, hidden_state) # 无效步:保持不变
output = jnp.where(current_mask, new_state * 2.0, 0.0) # 无效步:输出 0
return new_state, output
@jax.jit
def masked_run(inputs, masks):
# vmap 处理批维;scan 处理时间维
return jax.vmap(lambda xs, ms: lax.scan(masked_scan_body, 0.0, (xs, ms)))(inputs, masks)
finals, outs = masked_run(padded_sequences, mask)
print("最终状态:", finals)
# 对拍:每条序列只在真实长度上运行 Python 循环(float64)
seqs = [[1, 2, 3], [4, 5], [6, 7, 8, 9]]
ref = []
for s in seqs:
h = 0.0
for x_t in s:
h = h * 0.9 + x_t * 0.1
ref.append(h)
check(np.allclose(finals, ref, atol=1e-6))
check(np.allclose(np.asarray(outs)[~np.asarray(mask)], 0.0))
print("填充 + 掩码的结果与逐条真实长度运行一致 ✓")
⚠️ 注意:一种常见的错误写法是:把
current_mask放进 carry,注释里又写xs=(inputs, masks),循环体还把x当成单个输入来用。这三处互相矛盾,代码跑不起来。正确做法是让掩码作为xs的一部分逐步读取,也就是xs=(inputs, masks),循环体解包成(input_element, current_mask)。这样写的结果已经和逐条序列的 Python 循环对拍通过。
性能权衡(课程要点):掩码会对所有位置都做计算,然后再挑选结果。在 GPU/TPU 上,这通常比条件分支更快,因为不会造成线程发散。代价是被排除的元素也白算了一遍。如果某个分支开销很大、又经常被跳过,而且谓词是标量(不在 vmap 批维上),lax.cond 可能更快。具体以 profiling 结果为准。
🔍 补充:分段运算 jax.ops.segment_sum¶
⚪ 选读:图神经网络、长度差异极大的变长数据,或做练习 3 之前再看。
padding + mask 浪费的计算量,和「最长长度 − 平均长度」成正比。如果数据长度差异极大,比如图神经网络里节点的度数、推荐系统里用户的行为数,可以换个思路,不填充:把所有元素拼成一条长数组,给每个元素标上它所属的「段 id」,再按段做归约:
$$\text{out}[k] = \sum_{i:\ \text{seg}[i]=k} \text{data}[i].$$
jax.ops 提供了 segment_sum / segment_max / segment_min / segment_prod 四个函数,用的时候注意下面几点:
num_segments在 jit 下必须是静态值。不给的话,默认值是max(segment_ids)+1,需要一个具体数值,在 jit 中会抛出ConcretizationTypeError;segment_ids中 ≥ num_segments 的 id 会被悄悄丢弃,负数 id 也一样;- 空段的结果是归约运算的单位元:sum 为 0,prod 为 1,max 为
-inf,min 为+inf(以浮点为例;整数 dtype 下 max / min 的空段是这个类型的最小 / 最大值); - 如果已知 id 有序,可以传
indices_are_sorted=True,某些后端会因此更快。
data = jnp.array([1., 2., 3., 4., 5., 6.])
seg = jnp.array([0, 0, 1, 1, 1, 2])
ssum = jax.ops.segment_sum(data, seg, num_segments=3)
smax = jax.ops.segment_max(data, seg, num_segments=4) # 段 3 为空
print("segment_sum:", ssum, "| segment_max(含空段):", smax)
check(np.allclose(ssum, [3, 12, 6]))
check(np.allclose(smax[:3], [2, 5, 6]) and np.isneginf(smax[3]))
# id 越界:被静默丢弃(6.0 不见了)
dropped = jax.ops.segment_sum(data, jnp.array([0, 0, 1, 1, 1, 5]), num_segments=3)
print("越界 id 被丢弃:", dropped)
check(np.allclose(dropped, [3, 12, 0]))
# jit 下不给 num_segments → 报错;用 static_argnames 或 functools.partial 固定它
try:
jax.jit(lambda d, s: jax.ops.segment_sum(d, s))(data, seg); err = None
except jax.errors.ConcretizationTypeError as e:
err = type(e).__name__
print("jit 下省略 num_segments:", err)
check(err == "ConcretizationTypeError")
seg_mean = jax.jit(lambda d, s: jax.ops.segment_sum(d, s, num_segments=3)
/ jnp.maximum(jax.ops.segment_sum(jnp.ones_like(d), s, num_segments=3), 1))
print("分段平均:", seg_mean(data, seg))
check(np.allclose(seg_mean(data, seg), [1.5, 4.0, 6.0]))
segment_sum 本质上是 scatter-add,可以和 grad 配合使用:对 data 的梯度等于把上游梯度按 id gather 回去。用在本节的变长序列上,同一个「求和」问题就有了两种写法:
flat = jnp.concatenate([jnp.array(s, jnp.float32) for s in seqs]) # 不填充,长度 9
ids = jnp.concatenate([jnp.full(len(s), i) for i, s in enumerate(seqs)])
check(np.allclose(jax.ops.segment_sum(flat, ids, num_segments=3), masked_sum))
# 梯度:d(sum_k w_k * segsum_k) / d data_i = w_{seg[i]}
wts = jnp.array([1., 10., 100.])
g = jax.grad(lambda d: jnp.dot(wts, jax.ops.segment_sum(d, ids, num_segments=3)))(flat)
print("梯度:", g)
check(np.allclose(g, np.asarray(wts)[np.asarray(ids)]))
def create_multiplier(factor):
def multiplier(x):
return x * factor
return multiplier
multiply_by_2 = create_multiplier(2)
multiply_by_10 = create_multiplier(10)
print(f"multiply_by_2(5) = {multiply_by_2(5)}", f"| multiply_by_10(5) = {multiply_by_10(5)}")
check(multiply_by_2(5) == 10 and multiply_by_10(5) == 50)
暂存(staging)与值捕获¶
jit、vmap、grad 不会每次都直接运行 Python 代码,而是先暂存:用 tracer 把函数运行一遍,把基本操作记录成 jaxpr,再去编译或变换。在这个过程中,函数的参数变成 jaxpr 的输入变量。被闭包捕获、或者作为全局变量读取的值不是 tracer,它们在追踪的那一刻就被读取并冻结,嵌进程序里。嵌入的方式有两种:
- Python 标量(int / float):作为字面量直接写进方程,例如
mul 2.0:f32[] a; - 数组(
jax.Array/np.ndarray):作为constvars出现,也就是 jaxpr 开头{ lambda a:f32[3,3]; b:f32[3]. ...}中分号之前的变量。它们的值保存在make_jaxpr返回对象的.consts里,编译时作为常量嵌入 HLO。(0.12 中旧的ClosedJaxpr已并入Jaxpr:make_jaxpr直接返回Jaxpr,它的.jaxpr就是它自身,jax.extend.core.ClosedJaxpr只是别名。)
下面直接查看 jaxpr:
W_small = jnp.ones((3, 3))
jx_const = jax.make_jaxpr(lambda x: W_small @ x)(jnp.ones(3)) # 闭包捕获数组
jx_arg = jax.make_jaxpr(lambda W, x: W @ x)(W_small, jnp.ones(3)) # 作为参数传入
jx_lit = jax.make_jaxpr(lambda x: 2.0 * x)(jnp.ones(3)) # Python 标量
print("闭包捕获数组:\n", jx_const)
print("作为参数传入:\n", jx_arg)
print("Python 标量:\n", jx_lit)
check(len(jx_const.jaxpr.constvars) == 1 and len(jx_const.consts) == 1)
check(type(jx_const).__name__ == "Jaxpr" and jx_const.jaxpr is jx_const) # 0.12:ClosedJaxpr 已并入 Jaxpr
check(len(jx_arg.jaxpr.constvars) == 0 and len(jx_arg.jaxpr.invars) == 2)
check(len(jx_lit.jaxpr.constvars) == 0 and "2.0" in str(jx_lit))
scale_factor = 2.0
def apply_scale(x):
return x * scale_factor # 读取全局变量
jitted_apply_scale = jax.jit(apply_scale)
input_array = jnp.arange(3.)
r_first = jitted_apply_scale(input_array)
print("第一次:", r_first)
print("将 scale_factor 更改为 100.0")
scale_factor = 100.0
r_stale = jitted_apply_scale(input_array)
print("同一个 jit 函数:", r_stale, "(不是 [0, 100, 200])")
check(np.allclose(r_first, [0, 2, 4]) and np.allclose(r_stale, [0, 2, 4]))
# ❌ 常见误解:以为重新执行 jax.jit(apply_scale) 就能拿到新值 [0, 100, 200]
jitted_apply_scale_new = jax.jit(apply_scale)
r_new = jitted_apply_scale_new(input_array)
print("新包一层 jax.jit(apply_scale):", r_new)
try:
assert np.allclose(r_new, [0, 100, 200]), "仍然是旧值"
print("与预期一致")
except AssertionError as e:
print("与预期不符:", e)
# ✅ 推荐写法:会变化的值作为显式参数传入(值变化不会触发重编译,只有形状或 dtype 变化才会)
@jax.jit
def apply_scale_arg(x, factor):
return x * factor
r_a, r_b = apply_scale_arg(input_array, 2.0), apply_scale_arg(input_array, 100.0)
print(r_a, r_b, "| 编译次数:", apply_scale_arg._cache_size())
check(np.allclose(r_a, [0, 2, 4]) and np.allclose(r_b, [0, 100, 200]))
check(apply_scale_arg._cache_size() == 1)
# 证明:jax.jit(f) 新包一层仍然命中同一个追踪缓存(缓存键是底层函数对象,不包括全局变量的值)
check(np.allclose(jax.jit(apply_scale)(input_array), [0, 2, 4]))
jax.clear_caches() # 清空缓存后,重新追踪才会读到新值
check(np.allclose(jax.jit(apply_scale)(input_array), [0, 100, 200]))
print("clear_caches() 之后才得到 [0, 100, 200] ✓")
⚠️ 注意:重新执行
jitted_apply_scale_new = jax.jit(apply_scale)再调用,结果仍然是[0. 2. 4.],不会变成[0. 100. 200.]。JAX 的追踪缓存以底层 Python 函数对象(加上参数的形状、dtype 等信息)为键。对同一个函数重新包一层jax.jit,会命中已有的缓存,而全局变量的值不在缓存键里。只有调用jax.clear_caches(),或者换一个新的函数对象(例如新的 lambda 或闭包),才会重新追踪。所以别指望靠「重新 jit」来刷新全局变量,应该把它作为参数传入。
最佳实践(课程要点)¶
- 动态的值用显式参数:依赖关系一目了然,不会出现陈旧值,值变化也不需要重新编译;
- 闭包只用来承载静态配置:层的大小、激活函数、固定超参数这类在编译函数生命周期内不变的值;
- 小心可变对象:jit 之后修改被捕获的 list 或 dict 的内容,编译后的函数看不到。被捕获的值应该视为不可变。
# 课程代码:用闭包承载静态配置(jnp 已在开头导入)
def create_dense_layer(output_size):
def apply_layer(params, x):
assert params["W"].shape[1] == output_size # 形状是静态值,追踪期即可检查
y = jnp.dot(x, params["W"]) + params["b"]
return jax.nn.relu(y) # 激活函数也被隐式捕获
return apply_layer
layer10 = jax.jit(create_dense_layer(10))
kW, _ = jax.random.split(jax.random.key(1))
dense_params = {"W": jax.random.normal(kW, (4, 10)), "b": jnp.zeros(10)} # 权重作为参数传入
out = layer10(dense_params, jnp.ones((2, 4)))
check(out.shape == (2, 10) and bool((out >= 0).all()))
# 可变对象:修改被捕获 dict 的内容,jit 函数看不到
cfg = {"scale": 2.0}
jh = jax.jit(lambda x: x * cfg["scale"])
before = jh(input_array); cfg["scale"] = 5.0; after = jh(input_array)
print("修改 dict 前后:", before, after)
check(np.allclose(before, after))
⚠️ 注意:这段 Dense 层代码用到了
jnp.dot。单独运行时,除了import jax和import jax.nn as nn,还要写import jax.numpy as jnp,否则会报NameError。上面的单元用的是开头已经导入的jnp。
🔍 补充:闭包捕获大数组的四个问题¶
🟢 必读
把大数组放在闭包里(「反正它不变」)看起来很自然,但实际用起来会有四个问题:
- 程序体积和编译时间暴涨:常量会原样写进 HLO。下面会看到,一个 300×300 的随机矩阵会让 lowering 文本从几百字节变成约 700 KB。参数规模到 GB 级时,编译会极慢甚至失败;
- 换权重要么不生效,要么就得重新编译:训练时权重每一步都在变。权重如果是闭包常量,沿用同一个 jit 函数会一直用旧值(陈旧值,见上文);每步新建闭包才能用上新值,但每一步都要重新追踪、编译;
grad看不到它:常量不是函数的输入,也就无法对它求导。训练时权重必须作为参数传入;- 本机 jaxlib 0.12.0.1 的 CPU bug:闭包捕获的常量矩阵做
W @ x再 reduce,会悄悄算错。现象、触发边界与规避方法见 入门 00 目录与导读 的「本环境已知问题」,根因分析见 进阶第 2 章 §2.3。
最后一个单元演示了另一种闭包错误:在追踪期间把 tracer 保存到外部变量里(tracer 泄漏),之后再使用它会抛出 UnexpectedTracerError。
rng = np.random.default_rng(0)
B_np = rng.standard_normal((300, 300)).astype(np.float32)
B = jnp.asarray(B_np)
txt_closure = jax.jit(lambda x: B @ x).lower(jnp.ones(300)).as_text()
txt_arg = jax.jit(lambda b, x: b @ x).lower(B, jnp.ones(300)).as_text()
print(f"lowering 文本长度:闭包 {len(txt_closure):,} 字符 | 参数 {len(txt_arg):,} 字符")
check(len(txt_closure) > 100 * len(txt_arg))
check("stablehlo.constant dense<" in txt_closure)
# tracer 泄漏:在追踪期间把 tracer 保存到外部
leaked = []
@jax.jit
def leaky(x):
leaked.append(x) # ❌ 把 tracer 放到函数外
return x + 1
leaky(1.0)
try:
leaked[0] + 1; err = None
except Exception as e:
err = type(e).__name__
print("使用泄漏的 tracer:", err)
check(err == "UnexpectedTracerError")
1.8 实践:实现复杂的循环逻辑¶
本节用 lax.scan 实现一个简化的 GRU(门控循环单元),演示如何在循环体里写比较复杂的计算。之后又补充了两个综合实践:带变长掩码的 LSTM 和 RK4 ODE 积分器,结果都和 Python 循环版本逐数值对拍。
简化 GRU 的公式¶
记 $x_t$ 为第 $t$ 步的输入,$h_{t-1}$ 为上一步的隐状态,$\sigma$ 为 sigmoid,$\odot$ 为逐元素乘法:
$$ \begin{aligned} z_t &= \sigma(W_z x_t + U_z h_{t-1} + b_z) &&\text{更新门:新状态里候选状态占多少}\\ r_t &= \sigma(W_r x_t + U_r h_{t-1} + b_r) &&\text{重置门:计算候选状态时用到多少旧状态}\\ \tilde h_t &= \tanh\big(W_h x_t + U_h (r_t\odot h_{t-1}) + b_h\big) &&\text{候选隐状态}\\ h_t &= (1-z_t)\odot h_{t-1} + z_t\odot \tilde h_t &&\text{在旧状态与候选状态之间插值} \end{aligned} $$
注意:这里的约定是 $z_t\to 1$ 时取候选状态。PyTorch 的
nn.GRU恰好相反,写作 $h_t=(1-z_t)\odot\tilde h_t + z_t\odot h_{t-1}$。两种写法表达能力相同,但移植权重时要注意。
用 scan 实现时的设计:carry 保存 $h_t$,xs 是输入序列 $(x_1,\dots,x_T)$。单步函数 gru_step 实现上面四个公式,返回 (h_t, h_t):前一个作为下一步的 carry,后一个作为输出收集起来。
课程代码:GRU(已改用 0.12 推荐写法)¶
sigmoid = jax.nn.sigmoid
tanh = jnp.tanh
def initialize_gru_params(key, input_dim, hidden_dim):
"""初始化简化 GRU 单元的参数。"""
keys = jax.random.split(key, 6) # 3 对 (W, U) 需要 6 个 key;偏置全零
s = 0.01
return {
"Wz": jax.random.normal(keys[0], (hidden_dim, input_dim)) * s,
"Uz": jax.random.normal(keys[1], (hidden_dim, hidden_dim)) * s,
"bz": jnp.zeros((hidden_dim,)),
"Wr": jax.random.normal(keys[2], (hidden_dim, input_dim)) * s,
"Ur": jax.random.normal(keys[3], (hidden_dim, hidden_dim)) * s,
"br": jnp.zeros((hidden_dim,)),
"Wh": jax.random.normal(keys[4], (hidden_dim, input_dim)) * s,
"Uh": jax.random.normal(keys[5], (hidden_dim, hidden_dim)) * s,
"bh": jnp.zeros((hidden_dim,)),
}
def gru_step(params, h_prev, x_t):
"""简化 GRU 的单步计算。"""
z_t = sigmoid(jnp.dot(params["Wz"], x_t) + jnp.dot(params["Uz"], h_prev) + params["bz"])
r_t = sigmoid(jnp.dot(params["Wr"], x_t) + jnp.dot(params["Ur"], h_prev) + params["br"])
h_tilde_t = tanh(jnp.dot(params["Wh"], x_t) + jnp.dot(params["Uh"], r_t * h_prev) + params["bh"])
h_t = (1 - z_t) * h_prev + z_t * h_tilde_t
return h_t, h_t # (carry, 输出)
def gru_sequence(params, initial_h, inputs):
"""用 lax.scan 把 GRU 单元应用到整条序列。"""
scan_fn = lambda carry, x: gru_step(params, carry, x)
final_h, outputs_h = lax.scan(scan_fn, initial_h, inputs)
return final_h, outputs_h # 最终隐状态,以及 [h_1, ..., h_T]
key = jax.random.key(0) # 旧写法是 random.PRNGKey(0)
seq_len, input_dim, hidden_dim = 10, 5, 8
key, k_param, k_in = jax.random.split(key, 3)
gru_params = initialize_gru_params(k_param, input_dim, hidden_dim)
input_sequence = jax.random.normal(k_in, (seq_len, input_dim))
initial_hidden_state = jnp.zeros((hidden_dim,))
final_state, hidden_states_sequence = gru_sequence(gru_params, initial_hidden_state, input_sequence)
print("输入序列形状:", input_sequence.shape)
print("初始隐藏状态形状:", initial_hidden_state.shape)
print("最终隐藏状态形状:", final_state.shape)
print("隐藏状态输出序列形状:", hidden_states_sequence.shape)
check(final_state.shape == (8,) and hidden_states_sequence.shape == (10, 8))
与 Python 循环(NumPy float64)对拍:这是检验 scan 实现是否正确的标准做法。
def gru_numpy(params, h0, xs):
P = {k: np.asarray(v, np.float64) for k, v in params.items()}
sig = lambda a: 1 / (1 + np.exp(-a))
h, hs = np.asarray(h0, np.float64), []
for x_t in np.asarray(xs, np.float64):
z = sig(P["Wz"] @ x_t + P["Uz"] @ h + P["bz"])
r = sig(P["Wr"] @ x_t + P["Ur"] @ h + P["br"])
ht = np.tanh(P["Wh"] @ x_t + P["Uh"] @ (r * h) + P["bh"])
h = (1 - z) * h + z * ht
hs.append(h)
return h, np.stack(hs)
h_ref, hs_ref = gru_numpy(gru_params, initial_hidden_state, input_sequence)
check(np.allclose(final_state, h_ref, atol=1e-6) and np.allclose(hidden_states_sequence, hs_ref, atol=1e-6))
print("scan 版 GRU 与 Python 循环版一致 ✓ 最大误差:", float(np.abs(hidden_states_sequence - hs_ref).max()))
与 JAX 变换集成¶
scan 版的 gru_sequence 可以直接与 jit、vmap、grad 组合使用。
🔍 补充: ⚪ 选读:拿不准「jit 里的闭包捕获参数是否安全」时再看。
gru_sequence内部的scan_fn用闭包捕获了params。在jit(gru_sequence)里,params是 jit 函数的参数,已经是 tracer。闭包捕获的是 tracer,不会把权重嵌入成常量,所以这样写是安全的。真正要避免的是在 jit 函数外面持有一份具体的权重数组,然后在 jit 函数体内引用它。
jit_gru_sequence = jax.jit(gru_sequence)
key, sk = jax.random.split(key)
input_sequence_2 = jax.random.normal(sk, (seq_len, input_dim))
final_state_jit, hidden_states_sequence_jit = jit_gru_sequence(gru_params, initial_hidden_state, input_sequence_2)
print("运行 JIT 编译版本:")
print("最终隐藏状态形状 (JIT):", final_state_jit.shape, "| 输出序列形状 (JIT):", hidden_states_sequence_jit.shape)
check(np.allclose(final_state_jit, gru_numpy(gru_params, initial_hidden_state, input_sequence_2)[0], atol=1e-6))
# vmap:先 split 出新的 key 再生成批数据,不要直接复用 key
batch_size = 32
key, kb = jax.random.split(key)
batched_inputs = jax.random.normal(kb, (batch_size, seq_len, input_dim))
batched_initial_h = jnp.zeros((batch_size, hidden_dim))
batched_gru = jax.jit(jax.vmap(gru_sequence, in_axes=(None, 0, 0))) # 参数在批内共享
final_states_batch, hidden_sequences_batch = batched_gru(gru_params, batched_initial_h, batched_inputs)
print("批量最终状态:", final_states_batch.shape, "| 批量序列:", hidden_sequences_batch.shape)
check(final_states_batch.shape == (32, 8) and hidden_sequences_batch.shape == (32, 10, 8))
check(np.allclose(final_states_batch[7], gru_numpy(gru_params, batched_initial_h[7], batched_inputs[7])[0], atol=1e-6))
也可以用 jax.grad 对 gru_params 求梯度。下面对一个简单的损失函数求梯度,再用 float64 中心差分抽查一个参数元素。
def gru_loss(params, h0, xs):
final_h, _ = gru_sequence(params, h0, xs)
return jnp.sum(final_h ** 2)
grads = jax.jit(jax.grad(gru_loss))(gru_params, initial_hidden_state, input_sequence)
print({k: v.shape for k, v in grads.items()})
check(jax.tree.structure(grads) == jax.tree.structure(gru_params))
# float64 中心差分抽查 Wz[0, 0]
eps = 1e-5
def loss_np(delta):
p = {k: np.asarray(v, np.float64).copy() for k, v in gru_params.items()}
p["Wz"][0, 0] += delta
return float(np.sum(gru_numpy(p, initial_hidden_state, input_sequence)[0] ** 2))
fd = (loss_np(eps) - loss_np(-eps)) / (2 * eps)
print(f"autodiff: {float(grads['Wz'][0, 0]):.6e} | 中心差分: {fd:.6e}")
check(np.isclose(float(grads["Wz"][0, 0]), fd, rtol=1e-2, atol=1e-8))
综合实践 A:带变长掩码的 LSTM(scan + vmap + grad + checkpoint)¶
LSTM 的单步计算(把四个门的权重堆叠成 $W\in\mathbb R^{4H\times D}$、$U\in\mathbb R^{4H\times H}$,一次矩阵乘法算完):
$$ \begin{aligned} [i_t; f_t; o_t; g_t] &= W x_t + U h_{t-1} + b,\\ c_t &= \sigma(f_t)\odot c_{t-1} + \sigma(i_t)\odot \tanh(g_t),\\ h_t &= \sigma(o_t)\odot \tanh(c_t). \end{aligned} $$
要求:
- carry 是 pytree
(h, c); - 一个 batch 中序列长度不同,用 padding + mask 处理,padding 步保持
(h, c)不变; - 权重作为参数传入;
- 结果与「每条序列只在真实长度上运行的 NumPy float64 循环」对拍;
- 对比加与不加
jax.checkpoint的梯度。
def init_lstm(key, D, H):
k1, k2 = jax.random.split(key)
return {"W": jax.random.normal(k1, (4 * H, D)) / np.sqrt(D),
"U": jax.random.normal(k2, (4 * H, H)) / np.sqrt(H),
"b": jnp.zeros(4 * H).at[H:2 * H].set(1.0)} # 遗忘门偏置初始化为 1(常用技巧)
def lstm_cell(params, carry, x_t):
h, c = carry
i, f, o, g = jnp.split(params["W"] @ x_t + params["U"] @ h + params["b"], 4)
c_new = jax.nn.sigmoid(f) * c + jax.nn.sigmoid(i) * jnp.tanh(g)
h_new = jax.nn.sigmoid(o) * jnp.tanh(c_new)
return h_new, c_new
def lstm_masked(params, xs, mask, use_ckpt=False):
"""单条序列:xs (T, D),mask (T,) bool。返回最终 h 和每步 h(padding 步输出 0)。"""
H = params["U"].shape[1]
def step(carry, inp):
x_t, m_t = inp
new = lstm_cell(params, carry, x_t)
carry = jax.tree.map(lambda n, o: jnp.where(m_t, n, o), new, carry) # padding 步保持不变
return carry, jnp.where(m_t, carry[0], 0.0)
body = jax.checkpoint(step) if use_ckpt else step
(h, c), hs = lax.scan(body, (jnp.zeros(H), jnp.zeros(H)), (xs, mask))
return h, hs
D, H, T = 3, 6, 7
key, kp, kx = jax.random.split(jax.random.key(42), 3)
lstm_params = init_lstm(kp, D, H)
lens = jnp.array([7, 4, 1, 5])
xs_b = jax.random.normal(kx, (4, T, D))
mask_b = jnp.arange(T)[None, :] < lens[:, None]
xs_b = jnp.where(mask_b[..., None], xs_b, 0.0) # padding 位置填 0
run_batch = jax.jit(jax.vmap(lstm_masked, in_axes=(None, 0, 0)))
h_final_b, hs_b = run_batch(lstm_params, xs_b, mask_b)
def lstm_numpy(params, xs):
P = {k: np.asarray(v, np.float64) for k, v in params.items()}
sig = lambda a: 1 / (1 + np.exp(-a))
h = np.zeros(P["U"].shape[1]); c = np.zeros_like(h)
for x_t in xs:
i, f, o, g = np.split(P["W"] @ x_t + P["U"] @ h + P["b"], 4)
c = sig(f) * c + sig(i) * np.tanh(g)
h = sig(o) * np.tanh(c)
return h
for bi, L in enumerate(np.asarray(lens)):
ref_h = lstm_numpy(lstm_params, np.asarray(xs_b[bi, :L], np.float64))
check(np.allclose(h_final_b[bi], ref_h, atol=1e-5), f"序列 {bi} 不一致")
check(np.allclose(np.asarray(hs_b)[~np.asarray(mask_b)], 0.0))
print("4 条变长序列的最终隐状态全部与 NumPy 逐条循环一致 ✓")
# 梯度:padding 步不应影响结果;checkpoint 不改变梯度
def lstm_loss(params, xs, mask, use_ckpt):
h, _ = jax.vmap(lambda x, m: lstm_masked(params, x, m, use_ckpt))(xs, mask)
return jnp.mean(h ** 2)
grad_lstm = jax.jit(jax.grad(lstm_loss), static_argnums=3)
g_plain = grad_lstm(lstm_params, xs_b, mask_b, False)
g_ckpt = grad_lstm(lstm_params, xs_b, mask_b, True)
check(all(np.allclose(a, b, atol=1e-6) for a, b in zip(jax.tree.leaves(g_plain), jax.tree.leaves(g_ckpt))))
# 对输入求梯度:padding 位置的梯度必须恰好为 0
g_x = jax.grad(lambda x: lstm_loss(lstm_params, x, mask_b, False))(xs_b)
check(np.allclose(np.asarray(g_x)[~np.asarray(mask_b)], 0.0))
print("checkpoint 前后梯度一致;padding 位置的输入梯度为 0 ✓")
t_plain = bench(grad_lstm, lstm_params, xs_b, mask_b, False)
t_ckpt = bench(grad_lstm, lstm_params, xs_b, mask_b, True)
print(f"计时(只比较,不断言):grad 不加 checkpoint {t_plain:.3f} ms | 加 checkpoint {t_ckpt:.3f} ms")
综合实践 B:RK4 ODE 积分器¶
经典四阶 Runge–Kutta 法,对 $\dot y = f(t, y)$ 做时间推进:
$$ \begin{aligned} k_1 &= f(t_n, y_n), & k_2 &= f(t_n+\tfrac h2,\ y_n+\tfrac h2 k_1),\\ k_3 &= f(t_n+\tfrac h2,\ y_n+\tfrac h2 k_2), & k_4 &= f(t_n+h,\ y_n+h k_3),\\ y_{n+1} &= y_n + \tfrac h6 (k_1+2k_2+2k_3+k_4). \end{aligned} $$
以简谐振子 $\ddot x = -\omega^2 x$ 为例,令 $y=(x, v)$,解析解为 $x(t)=x_0\cos\omega t + \frac{v_0}{\omega}\sin\omega t$。我们用三种方式验证 scan 版积分器:与 Python 循环(float64)对拍、与解析解对比、对初值求导。解析的敏感度为 $\partial x(T)/\partial v_0 = \sin(\omega T)/\omega$。
def rk4_step(f, t, y, h, args):
k1 = f(t, y, args)
k2 = f(t + h / 2, y + h / 2 * k1, args)
k3 = f(t + h / 2, y + h / 2 * k2, args)
k4 = f(t + h, y + h * k3, args)
return y + h / 6 * (k1 + 2 * k2 + 2 * k3 + k4)
def odeint_rk4(f, y0, t0, h, n_steps, args):
"""固定步长 RK4。args(如物理参数)作为参数传入,不放在闭包里。返回 (y_T, 轨迹)。"""
def body(carry, _):
t, y = carry
y_new = rk4_step(f, t, y, h, args)
return (t + h, y_new), y_new
(tT, yT), traj = lax.scan(body, (t0, y0), None, length=n_steps)
return yT, traj
def oscillator(t, y, omega):
x, v = y
return jnp.array([v, -omega ** 2 * x])
@functools.partial(jax.jit, static_argnums=(3,))
def solve(y0, omega, h, n_steps):
return odeint_rk4(oscillator, y0, 0.0, h, n_steps, omega)
y0 = jnp.array([1.0, 0.5]); omega = 2.0; h = 0.01; n = 500 # 积分到 T = 5
yT, traj = solve(y0, omega, h, n)
# 1) Python 循环(NumPy float64)对拍
def rk4_numpy(y, omega, h, n):
f = lambda y: np.array([y[1], -omega ** 2 * y[0]])
out = []
for _ in range(n):
k1 = f(y); k2 = f(y + h / 2 * k1); k3 = f(y + h / 2 * k2); k4 = f(y + h * k3)
y = y + h / 6 * (k1 + 2 * k2 + 2 * k3 + k4); out.append(y)
return y, np.stack(out)
yT_np, traj_np = rk4_numpy(np.array([1.0, 0.5]), 2.0, 0.01, 500)
check(np.allclose(traj, traj_np, atol=1e-5))
# 2) 解析解
T = h * n
x_exact = 1.0 * np.cos(omega * T) + 0.5 / omega * np.sin(omega * T)
print(f"x(T): scan={float(yT[0]):.6f} numpy={yT_np[0]:.6f} 解析={x_exact:.6f}")
check(abs(float(yT[0]) - x_exact) < 1e-4)
# 3) 对初值求导(通过 scan 的 BPTT)
dx_dv0 = jax.grad(lambda y0: solve(y0, omega, h, n)[0][0])(y0)[1]
print(f"∂x(T)/∂v0: autodiff={float(dx_dv0):.6f} 解析={np.sin(omega * T) / omega:.6f}")
check(abs(float(dx_dv0) - np.sin(omega * T) / omega) < 1e-4)
# 4) vmap:一批不同的初值并行积分
y0s = jnp.stack([jnp.array([a, 0.0]) for a in (0.5, 1.0, 2.0)])
xT_b = jax.vmap(lambda y: solve(y, omega, h, n)[0][0])(y0s)
check(np.allclose(xT_b, np.array([0.5, 1.0, 2.0]) * np.cos(omega * T), atol=1e-4))
print("RK4:Python 循环对拍、解析解、梯度、vmap 全部通过 ✓")
补充练习¶
下面 4 道练习都附有参考答案和 assert 验证。建议先自己写,再去看参考答案对照。
练习 1:用反向 scan 计算带终止标志的折扣回报(强化学习)¶
给定奖励 $r_t$、终止标志 $d_t\in\{0,1\}$ 和折扣因子 $\gamma$,计算
$$G_t = r_t + \gamma\,(1-d_t)\,G_{t+1},\qquad G_{T}=0.$$
要求:用 lax.scan(..., reverse=True) 实现,支持 vmap 处理多条轨迹,结果与 Python 循环对拍。
自检接口:定义 discounted_returns(rewards, dones, gamma):输入长度为 T 的一维 rewards、dones 和标量 gamma,返回长度为 T 的 $G$;要能直接套 jax.vmap(..., in_axes=(0, 0, None)) 和 jax.jit。
# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
discounted_returns
except NameError as e:
print(f"⏳ 练习 1 尚未作答:{e}")
else:
def _returns_loop(r, d, gamma): # 逐步递推的参考实现(float64)
G, g = np.zeros(len(r)), 0.0
for t in reversed(range(len(r))):
g = r[t] + gamma * (1 - d[t]) * g
G[t] = g
return G
_rng = np.random.default_rng(2024)
for _T, _gamma in [(1, 0.9), (13, 0.99), (40, 0.5)]:
_r = _rng.uniform(-1, 1, _T).astype(np.float32)
_d = (_rng.uniform(size=_T) < 0.2).astype(np.float32)
_out = np.asarray(discounted_returns(jnp.asarray(_r), jnp.asarray(_d), _gamma))
assert _out.shape == (_T,), f"输出形状应为 ({_T},),实际是 {_out.shape}"
assert np.allclose(_out, _returns_loop(_r, _d, _gamma), atol=1e-5), f"T={_T}、gamma={_gamma} 时与逐步递推的结果不一致"
_out = np.asarray(discounted_returns(jnp.array([1., 2., 3., 4.]), jnp.array([0., 1., 0., 1.]), 0.5))
assert np.allclose(_out, [2., 2., 5., 4.]), f"d_t=1 时应截断之后的回报,期望 [2, 2, 5, 4],实际 {_out}"
_jp = str(jax.make_jaxpr(lambda r, d: discounted_returns(r, d, 0.9))(jnp.ones(50), jnp.zeros(50)))
assert "scan[" in _jp, "jaxpr 中没有 scan:题目要求用 lax.scan 实现,而不是 Python 循环(会按 T 展开)"
_R = jnp.asarray(_rng.uniform(size=(3, 9)), jnp.float32)
_G = jax.jit(jax.vmap(discounted_returns, in_axes=(0, 0, None)))(_R, jnp.zeros((3, 9)), 0.9)
for _i in range(3):
assert np.allclose(_G[_i], _returns_loop(np.asarray(_R[_i]), np.zeros(9), 0.9), atol=1e-5), f"vmap 后第 {_i} 条轨迹的结果不对"
print("✅ 练习 1 自检通过")
练习 2:可以反向求导的「有上限的 while」——牛顿法开平方¶
lax.while_loop 不能用 grad。请用 lax.scan 跑固定的最大步数(例如 30),用掩码让「已收敛」的元素停止更新,实现 $\sqrt a$ 的牛顿迭代 $x\leftarrow \tfrac12(x + a/x)$,收敛条件为 $|x^2-a|<10^{-6}a$。要求:
- 结果与
jnp.sqrt一致; - 同时返回实际迭代次数,并与
while_loop版本的迭代次数一致; jax.grad可以运行,梯度等于 $1/(2\sqrt a)$。
自检接口:定义 newton_sqrt_scan(a) 和 newton_sqrt_while(a),输入标量 a,都返回 (x, k):x 是 $\sqrt a$ 的近似,k 是实际迭代次数(整数)。
# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
newton_sqrt_scan, newton_sqrt_while
except NameError as e:
print(f"⏳ 练习 2 尚未作答:{e}")
else:
_a = jnp.array([3.0, 0.5, 123.4, 7e3, 42.0])
_xs, _ks = jax.vmap(newton_sqrt_scan)(_a)
_xw, _kw = jax.vmap(newton_sqrt_while)(_a)
assert np.allclose(_xs, np.sqrt(np.asarray(_a, np.float64)), rtol=1e-6), f"scan 版结果与 √a 不一致:{_xs}"
assert np.allclose(_xw, np.sqrt(np.asarray(_a, np.float64)), rtol=1e-6), f"while 版结果与 √a 不一致:{_xw}"
assert jnp.issubdtype(jnp.asarray(_ks).dtype, jnp.integer), f"迭代次数应为整数,实际 dtype 是 {jnp.asarray(_ks).dtype}"
assert np.array_equal(_ks, _kw), f"scan 版迭代次数 {_ks} 与 while 版 {_kw} 不一致:已收敛的元素是否还在计数?"
assert bool(((np.asarray(_ks) > 0) & (np.asarray(_ks) < 30)).all()), f"迭代次数应在 1~29 之间(收敛后不再增加),实际 {_ks}"
_g = jax.vmap(jax.grad(lambda a: newton_sqrt_scan(a)[0]))(_a)
assert bool(jnp.isfinite(_g).all()), "scan 版的梯度出现了 nan/inf"
assert np.allclose(_g, 0.5 / np.sqrt(np.asarray(_a, np.float64)), rtol=1e-4), f"梯度应为 1/(2√a),实际 {_g}"
print("✅ 练习 2 自检通过")
练习 2 答案解析
💡 迭代次数恰好一致,是因为 scan 版在已收敛后只是「空转」,
k不再增加。代价是总共固定执行 30 步。更高级的做法是利用不动点的隐函数定理,用jax.custom_vjp直接给出梯度 $\partial x^*/\partial a$,这样既能保留 while_loop 的提前退出,又能反向求导。
练习 3:分段 softmax(图注意力的基本操作)¶
给定拼接后的分数 scores(长度 N)和段 id seg(共 K 段),对每一段分别做 softmax:
$$\text{out}_i = \frac{\exp(s_i - m_{k})}{\sum_{j:\,\text{seg}_j=k}\exp(s_j - m_k)},\quad k=\text{seg}_i,\ m_k=\max_{j:\,\text{seg}_j=k}s_j.$$
要求:只使用 segment_max、segment_sum 和索引,减去段内最大值保证数值稳定,并用 jit 编译。与「按段切片后逐段计算 softmax」的 NumPy 结果对拍。
自检接口:定义 segment_softmax(scores, seg, num_segments),num_segments 作为静态参数(以关键字形式传入),返回与 scores 同形状的结果。
# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
segment_softmax
except NameError as e:
print(f"⏳ 练习 3 尚未作答:{e}")
else:
def _seg_softmax_np(s, ids, k):
s = np.asarray(s, np.float64); ids = np.asarray(ids); out = np.empty_like(s)
for j in range(k):
m = ids == j
e = np.exp(s[m] - s[m].max()); out[m] = e / e.sum()
return out
_rng = np.random.default_rng(7)
for _ids, _k in [([0, 1, 1, 2, 2, 2, 3, 4, 4, 4, 4, 4], 5), ([0, 0, 0, 1, 1], 2)]:
_ids = jnp.array(_ids)
_s = jnp.asarray(_rng.normal(size=len(_ids)) * 50 + 200, jnp.float32) # 分数很大:不减最大值就会溢出
_out = segment_softmax(_s, _ids, num_segments=_k)
assert _out.shape == _s.shape, f"输出形状应为 {_s.shape},实际是 {_out.shape}"
assert bool(jnp.isfinite(_out).all()), "结果出现 nan/inf:是否在指数之前减去了段内最大值?"
assert np.allclose(_out, _seg_softmax_np(_s, _ids, _k), atol=1e-6), "与逐段计算的 softmax 不一致"
_sums = np.asarray(jax.ops.segment_sum(_out, _ids, num_segments=_k))
assert np.allclose(_sums, 1.0, atol=1e-6), f"每一段之和应为 1,实际 {_sums}"
print("✅ 练习 3 自检通过")
练习 4:用 lax.switch 写一个分段函数,并说明它在 vmap 下的代价¶
实现
$$f(x)=\begin{cases}x^2 & x<0\\ \sin x & 0\le x<\pi\\ x-\pi & x\ge\pi\end{cases}$$
要求:(a) 用 lax.switch 处理标量;(b) 用 vmap 处理向量,并通过 jaxpr 确认 switch 被改写成了 select_n,三个分支都会计算;(c) 用 jnp.select 写一个逐元素版本,两者结果一致;(d) 对 vmap 版求梯度,与解析导数对比。
自检接口:定义 piecewise(x)(标量输入,用 lax.switch 实现)和 piecewise_select(x)(逐元素版本)。
# ✍️ 作答区:练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 4
try:
piecewise, piecewise_select
except NameError as e:
print(f"⏳ 练习 4 尚未作答:{e}")
else:
_x = jnp.array([-3.0, -1e-3, 0.0, 1.0, 3.0, jnp.pi, 4.2, 10.0]) # 含两个分界点 0 和 π
_x64 = np.asarray(_x, np.float64)
_f_ref = np.where(_x64 < 0, _x64 ** 2, np.where(_x64 < np.pi, np.sin(_x64), _x64 - np.pi))
_df_ref = np.where(_x64 < 0, 2 * _x64, np.where(_x64 < np.pi, np.cos(_x64), 1.0))
_y1 = jax.vmap(piecewise)(_x)
assert np.allclose(_y1, _f_ref, atol=1e-6), f"vmap(piecewise) 的结果不对:{_y1}"
assert np.allclose(piecewise_select(_x), _f_ref, atol=1e-6), "piecewise_select 的结果不对"
assert "cond[" in str(jax.make_jaxpr(piecewise)(1.0)), "标量版 jaxpr 中没有 cond:题目要求用 lax.switch"
_jp = str(jax.make_jaxpr(jax.vmap(piecewise))(_x))
assert "cond[" not in _jp and "select_n" in _jp, "vmap 之后 switch 应被改写为 select_n(三个分支都计算)"
_dy = jax.vmap(jax.grad(piecewise))(_x)
assert np.allclose(_dy, _df_ref, atol=1e-6), f"梯度与解析导数不一致:{_dy}"
print("✅ 练习 4 自检通过")
第 1 章测验¶
下面 8 道题的答案,都能在本章前面的运行结果里找到依据。
1. lax.scan(f, 0, jnp.arange(5.)) 能运行,lax.scan(f, jnp.int32(0), jnp.arange(5.)) 却报 carry 类型不一致,其中 f = lambda c, x: (c + x, c)。为什么?
答案与解析
Python 整数 0 是弱类型,与 float32 的 x 相加时会被提升为 float32,JAX 允许弱类型的 carry 这样提升。jnp.int32(0) 是强类型的 int32,而循环体返回的是 float32,dtype 不一致,所以报错 carry input and carry output must have equal types ... dtypes do not match。最稳妥的做法是显式写出 init 的 dtype。
2. lax.scan(f, 0, xs, reverse=True) 返回的 ys,与 lax.scan(f, 0, xs[::-1]) 返回的 ys 是什么关系?
答案与解析
前者等于后者翻转后的结果,也就是 ys_rev == ys_flip[::-1]。reverse=True 从后向前计算,但 ys[t] 仍然对应 xs[t] 的位置(见 1.2 节补充)。
3. 下面哪些写法可以在 jit 中被 jax.grad 反向求导?(a) lax.while_loop;(b) lax.fori_loop(0, 10, ...);(c) lax.fori_loop(0, n, ...),其中 n 是 jit 的普通参数;(d) lax.scan(..., length=10)。
答案与解析
(b) 和 (d)。fori_loop 的上下界是静态值时会被改写为 scan,所以可以求导。(a) 和 (c) 都会被转换成 XLA while,反向模式会报 Reverse-mode differentiation does not work for lax.while_loop...。它们只能用 jvp / jacfwd 做前向模式求导。
4. 对 jax.vmap(lambda x: lax.cond(x > 0, expensive_a, expensive_b, x)) 而言,运行时执行了几个分支?如果 pred 来自一个不被映射的参数呢?
答案与解析
pred 带批维时,cond 被改写成 select_n,两个分支都对整个批次计算,再逐元素挑选。pred 不带批维时,cond 原语会保留下来,只执行一个分支(见 1.3 节补充中的 jaxpr)。
5. 某段代码先 jf = jax.jit(f),f 读取全局变量 s。调用 jf 后把 s 改掉,再执行 jf2 = jax.jit(f) 并调用 jf2。在 JAX 0.12 中,jf2 用的是 s 的新值还是旧值?
答案与解析
旧值。追踪缓存以底层函数对象为键,jax.jit(f) 新包一层仍然命中缓存。要读到新值,需要 jax.clear_caches() 或者换一个新的函数对象。正确的做法是把 s 作为参数传入(见 1.7 节)。
6. jax.nn.softmax(jnp.where(mask, s, -jnp.inf)),在某一行 mask 全为 False 时会得到什么?换成 -1e9 呢?
答案与解析
-inf 会得到整行 NaN,因为 0/0;-1e9 会得到均匀分布(每个元素为 1/n)。两者都需要额外处理:通常再乘一次 mask,或者在后续计算中把全空的行排除掉。
7. 在 jit 中调用 jax.ops.segment_sum(data, ids),不传 num_segments,会发生什么?ids 中出现 >= num_segments 的值时又会怎样?
答案与解析
不传时,默认值需要计算 max(ids)+1,这要求一个具体值,在 jit 中会抛出 ConcretizationTypeError,所以 num_segments 必须是静态值。越界的 id 会被悄悄丢弃,不会报错,容易藏 bug。
8. 为什么对一个很长的 scan 使用 lax.scan(jax.checkpoint(step), ...) 能降低反向传播的内存?代价是什么?梯度会变吗?
答案与解析
不加 checkpoint 时,每一步的所有中间值(残差)都要保存下来供反向使用。加上之后,每步只保存 carry 等输入,反向时重新计算这一步的中间值。代价是大约多一次正向计算。梯度在数学上完全相同,见 1.8 节综合实践 A 中的梯度 assert;残差和内存的实测对比见进阶第 6 章 §6.6。
本章更新说明¶
下面汇总本章在 JAX 0.12 下需要注意的写法和结论,每条都在正文对应位置用 assert 验证过。
| 编号 | 小节 | 要点 | 位置 |
|---|---|---|---|
| 1 | 1.1 | 生成随机数 key 用 jax.random.key(0)(带类型的 key),不再用旧的 jax.random.PRNGKey(0) | 1.1「随机数 key」对照单元 |
| 2 | 1.1 | 线性模型 grad 示例的正确结果是 w 梯度 [-0.92, -3.68]、b 梯度 -4.6(残差 −2.3),不是 [-0.392 -1.568] 与 -1.96 | 1.1 课程代码 2 |
| 3 | 1.1 | 闭包或全局变量的值不在 jit 缓存键里,值变化后不重编译,而是沿用旧值 | 1.1 讲解;1.7 证明 |
| 4 | 1.1/1.5 | vmap 的「向量化」是 vectorization;quantization 是「量化」 | 1.1 讲解 |
| 5 | 1.2 | jit 里的 Python for 循环只有长度是静态值时才会展开;长度是 traced 值时 range(n) 抛 TracerIntegerConversionError | 1.2 开头 |
| 6 | 1.2 | W_hh、W_xh 共用一个 w_key 时,W_xh 等于 W_hh 的前 15 个元素;x_key = random.split(key) 直接传给 normal 会抛 ValueError。每个用途要用独立的子 key | 1.2 课程代码 2 |
| 7 | 1.2 | xs=None 时 scan 仍以两个参数调用循环体,只写 f(carry) 会抛 TypeError | 1.2 课程代码 3 |
| 8 | 1.2/1.8 | 推荐把权重作为 jit 函数的参数传入,不要靠闭包捕获(避免常量嵌入,也避开本机 CPU bug) | 1.2、1.7、1.8 |
| 9 | 1.3 | jit 里对 tracer 用 Python if,实际抛出 TracerBoolConversionError(是 ConcretizationTypeError 的子类) | 1.3 课程代码 |
| 10 | 1.3 | 比较运算不可微,梯度不流经 cond 的 pred,对阈值的梯度恒为 0 | 1.3 讲解;1.5 证明 |
| 11 | 1.4/1.5 | while_loop 不支持反向模式(抛 ValueError),只支持 jvp/jacfwd;固定步数用 fori_loop(静态上下界)或 scan | 1.4「while_loop 不能做反向模式求导」 |
| 12 | 1.5 | scan RNN 示例的 dL/dh0 ≈ 1.0749918(手推公式与中心差分一致),不是 0.313 | 1.5「与 grad」 |
| 13 | 1.5 | conditional_loss 负例梯度是 [-1.00001 -1.00001](= 1/(−1+1e−5)) | 1.5「与 grad」 |
| 14 | 1.5 | jax.grad(jax.vmap(threshold_from_params)) 中 vmap 输出是 (2,3),grad 抛 TypeError;要先求和,而且梯度恒为 0 | 1.5「组合多个变换」 |
| 15 | 1.6 | JAX 没有 jnp.randn,随机分数要用 jax.random.normal(jax.random.key(0), (3, 4)) | 1.6 注意力掩码 |
| 16 | 1.6 | scan 掩码要作为 xs 的一部分逐步读取,不要放进 carry;这样写的结果与逐条按真实长度运行对拍通过 | 1.6 掩码与控制流 |
| 17 | 1.7 | 重新执行 jax.jit(apply_scale) 仍得到 [0. 2. 4.](命中同一追踪缓存);要 clear_caches() 或改为参数传入 | 1.7 陈旧值对照单元 |
| 18 | 1.7 | Dense 层片段用到 jnp.dot,需要 import jax.numpy as jnp | 1.7 最佳实践 |
| 19 | 1.8 | GRU 示例用 jax.random.key;vmap 生成批数据前先 split,不要直接复用 key | 1.8 课程代码 |
| 20 | 1.8 | 补充了对 GRU 参数求 grad 的代码、float64 中心差分验证,以及与 Python 循环的数值对拍 | 1.8 |
| 21 | 1.4 | cond_fun 永不为 False 时,Ctrl-C 通常也中断不了编译好的 XLA 循环(试过发送 SIGINT 后进程仍在运行,未在 notebook 中断言),只能重启内核或杀进程;应在 carry 里加最大步数 | 1.4 注意事项 |
| 22 | 1.7 | 0.12 中 ClosedJaxpr 已并入 Jaxpr:make_jaxpr 直接返回 Jaxpr,常量在 .consts | 1.7 暂存与值捕获 |
本章小结¶
- 追踪模型是一切的基础:jit 把 Python 函数追踪成 jaxpr。参数变成输入变量;闭包或全局值在追踪时被冻结,成为字面量或
constvars。缓存键包含函数对象、参数的抽象信息(形状、dtype 等)和静态参数的值,不包含闭包值。 lax.scan是序列计算的主力:循环体只追踪一次,编译成 XLA 循环,可以对它反向求导(BPTT),也可以用 vmap 批处理。要牢记 carry 类型必须一致,reverse不会翻转输出,unroll是需要实测的性能旋钮。- 条件分支按场景选:标量谓词、分支开销大时用
lax.cond/lax.switch;逐元素选择用jnp.where/lax.select_n。cond 一旦进入 vmap 批维就会变成 select,两个分支都要计算。梯度不流经谓词。 while_loop只支持前向模式:需要反向求导时,改用静态上下界的fori_loop,或者「scan 跑到上限 + 掩码」。在 vmap 下,while_loop 会一直运行到批内最慢的那个元素。- 变长数据的两种方案:padding + mask(规则、易并行,padding 部分的计算被浪费),以及
segment_*(不浪费,但num_segments必须是静态值,越界 id 会被悄悄丢弃)。掩码优先用where而不是乘法,全空行要特别处理。 - 长序列反向的内存问题:用
jax.checkpoint包住 scan 的循环体,以重算换内存,梯度不变。 - 工程纪律:权重和数据都作为参数传入;scan 实现要与 Python 循环(float64)对拍;梯度要用有限差分抽查。
速查表¶
| API | 作用 |
|---|---|
lax.scan(f, init, xs, length, reverse, unroll) | 带 carry 的循环,收集每步输出;f(c, x) -> (c', y) |
lax.associative_scan(op, xs) | 满足结合律的并行前缀扫描,深度 O(log T) |
lax.fori_loop(lo, hi, body, init) | 计数循环;上下界静态时改写为 scan(可反向求导),否则为 while |
lax.while_loop(cond, body, init) | 动态终止循环;只支持前向模式(jvp/jacfwd) |
lax.cond(pred, t, f, *ops) | 标量谓词二选一;vmap 批维进入 pred 时变成 select |
lax.switch(i, branches, *ops) | 多分支;越界 index 被截断到合法范围 |
lax.select_n(which, *cases) | 逐元素多路选择;bool 时 False→case0、True→case1 |
jnp.where(c, x, y) | 逐元素选择,两边都计算;注意 NaN 梯度,用「双 where」 |
jnp.select(conds, choices, default) | 逐元素多条件选择 |
jax.nn.softmax(x, where=mask) | 带掩码的 softmax |
jax.ops.segment_sum/max/min/prod(data, ids, num_segments) | 按段归约;jit 下 num_segments 必须静态 |
jax.checkpoint(f, policy=...) / jax.remat | 梯度检查点,以重算换内存(详见进阶第 6 章 §6.6) |
jax.ad_checkpoint.print_saved_residuals(f, *args) | 查看反向传播保存了哪些残差 |
jax.checkpoint_policies.* | 检查点策略,如 dots_saveable、nothing_saveable |
jax.make_jaxpr(f)(*args) | 查看 jaxpr;.jaxpr.constvars / .consts 是嵌入的常量 |
jax.jit(f).lower(*args).as_text() | 查看 StableHLO,检查常量嵌入、循环结构 |
jitted._cache_size() | 查看 jit 函数缓存了几份编译结果 |
jax.clear_caches() | 清空追踪和编译缓存 |
jax.random.key / jax.random.split | 0.12 推荐的随机数 key API |
jax.tree.map / jax.tree.leaves / jax.tree.structure | pytree 工具(替代旧的 jax.tree_util.tree_map;迁移表见 入门 06 状态管理) |
print(f"本 notebook 共通过 {N_ASSERT} 个 assert ✓")
练习 1¶
题目见正文「练习 1:用反向 scan 计算带终止标志的折扣回报(强化学习)」。
# 参考答案
def discounted_returns(rewards, dones, gamma):
def body(g_next, inp):
r, d = inp
g = r + gamma * (1.0 - d) * g_next
return g, g
_, G = lax.scan(body, jnp.zeros((), rewards.dtype), (rewards, dones), reverse=True)
return G
def returns_py(r, d, gamma):
G, g = np.zeros(len(r)), 0.0
for t in reversed(range(len(r))):
g = r[t] + gamma * (1 - d[t]) * g; G[t] = g
return G
k1, k2 = jax.random.split(jax.random.key(7))
R = jax.random.uniform(k1, (5, 20))
Dn = (jax.random.uniform(k2, (5, 20)) < 0.15).astype(jnp.float32)
G = jax.jit(jax.vmap(discounted_returns, in_axes=(0, 0, None)))(R, Dn, 0.95)
for i in range(5):
check(np.allclose(G[i], returns_py(np.asarray(R[i], np.float64), np.asarray(Dn[i]), 0.95), atol=1e-5))
print("折扣回报与 Python 循环一致 ✓")
练习 2¶
题目见正文「练习 2:可以反向求导的「有上限的 while」——牛顿法开平方」。
# 参考答案
def newton_sqrt_while(a):
def cond(c):
x, k = c
return (jnp.abs(x * x - a) >= 1e-6 * a) & (k < 30)
return lax.while_loop(cond, lambda c: (0.5 * (c[0] + a / c[0]), c[1] + 1), (a, 0))
def newton_sqrt_scan(a, max_iter=30):
def body(c, _):
x, k = c
active = jnp.abs(x * x - a) >= 1e-6 * a # 与 while 版 cond 相同
x_new = jnp.where(active, 0.5 * (x + a / x), x) # 已收敛则冻结
return (x_new, k + active.astype(jnp.int32)), None
(x, k), _ = lax.scan(body, (a, jnp.int32(0)), None, length=max_iter)
return x, k
a_vals = jnp.array([2.0, 9.0, 1e4, 0.25])
x_s, k_s = jax.vmap(newton_sqrt_scan)(a_vals)
x_w, k_w = jax.vmap(newton_sqrt_while)(a_vals)
print("sqrt:", x_s, "| 迭代次数 scan:", k_s, "while:", k_w)
check(np.allclose(x_s, jnp.sqrt(a_vals), rtol=1e-6) and np.array_equal(k_s, k_w))
g = jax.vmap(jax.grad(lambda a: newton_sqrt_scan(a)[0]))(a_vals)
check(np.allclose(g, 0.5 / np.sqrt(np.asarray(a_vals)), rtol=1e-4))
try:
jax.grad(lambda a: newton_sqrt_while(a)[0])(2.0); ok = False
except ValueError:
ok = True
check(ok)
print("scan 版可以求导,梯度 = 1/(2√a) ✓;while 版 grad 报错 ✓")
练习 3¶
题目见正文「练习 3:分段 softmax(图注意力的基本操作)」。
# 参考答案
@functools.partial(jax.jit, static_argnames="num_segments")
def segment_softmax(scores, seg, num_segments):
m = jax.ops.segment_max(scores, seg, num_segments=num_segments)
e = jnp.exp(scores - m[seg]) # gather 回每个元素
z = jax.ops.segment_sum(e, seg, num_segments=num_segments)
return e / z[seg]
k1 = jax.random.key(3)
seg_ids = jnp.array([0, 0, 0, 1, 2, 2, 2, 2, 3, 3])
sc = jax.random.normal(k1, (10,)) * 30.0 # 数值很大,测试数值稳定性
out = segment_softmax(sc, seg_ids, num_segments=4)
sc64, ids64 = np.asarray(sc, np.float64), np.asarray(seg_ids)
ref = np.empty_like(sc64)
for k in range(4):
s = sc64[ids64 == k]; e = np.exp(s - s.max()); ref[ids64 == k] = e / e.sum()
check(np.allclose(out, ref, atol=1e-6))
check(np.allclose(jax.ops.segment_sum(out, seg_ids, num_segments=4), 1.0, atol=1e-6))
print("分段 softmax 正确,且每段之和为 1 ✓")
练习 4¶
题目见正文「练习 4:用 lax.switch 写一个分段函数,并说明它在 vmap 下的代价」。
# 参考答案
def piecewise(x):
idx = jnp.where(x < 0, 0, jnp.where(x < jnp.pi, 1, 2))
return lax.switch(idx, [lambda v: v ** 2, jnp.sin, lambda v: v - jnp.pi], x)
def piecewise_select(x):
return jnp.select([x < 0, x < jnp.pi], [x ** 2, jnp.sin(x)], x - jnp.pi)
xv = jnp.array([-2.0, -0.5, 0.3, 2.0, 3.5, 6.0])
y1 = jax.vmap(piecewise)(xv); y2 = piecewise_select(xv)
check(np.allclose(y1, y2))
jp = str(jax.make_jaxpr(jax.vmap(piecewise))(xv))
check("cond[" not in jp and "select_n" in jp and "sin" in jp) # 三个分支都在批上计算
dy = jax.vmap(jax.grad(piecewise))(xv)
xv64 = np.asarray(xv, np.float64)
dy_ref = np.where(xv64 < 0, 2 * xv64, np.where(xv64 < np.pi, np.cos(xv64), 1.0))
check(np.allclose(dy, dy_ref, atol=1e-6))
print("f:", y1, "\nf':", dy)