第 1 章 高级变换与控制流

课程:「JAX 高级编程:性能、优化与规模化」第 1 章(课程主页) 运行环境:JAX / jaxlib 0.12.0.1,Python 3.14,仅 CPU。本教程里的所有结论都在这个版本上跑过、验证过。

阅读说明:本教程按课程第 1 章的 8 个网页整理,讲解和代码都用中文重新写过,并做了补充。过时或容易出错的写法放在 # ❌ 单元里,后面紧跟 # ✅ 推荐写法。

学习目标

学完本章,你应该能够:

  1. 说清 jit / grad / vmap 的追踪模型,以及它们组合起来时的语义;
  2. 熟练使用 lax.scan(包括 reverse、unroll、length、xs=None),能读懂 carry 类型不一致时的报错;
  3. 根据场景选择 lax.cond / lax.switch / lax.select_n / jnp.where,并知道 cond 在 vmap 下会退化成 select;
  4. 用 lax.while_loop 写动态终止的循环,知道它不支持反向模式求导,以及该换成什么写法;
  5. 掌握控制流与 jit / grad / vmap / jax.checkpoint 组合时的行为和代价;
  6. 用 padding + mask、jax.ops.segment_sum 处理变长数据;
  7. 理解闭包与暂存(staging):常量怎样嵌入程序、make_jaxpr 里的 constvars 是什么、为什么不要在闭包里捕获大数组;
  8. 用 lax.scan 实现 GRU、LSTM 和 ODE 积分器,并和 Python 循环版本逐数值对拍。

小节目录

小节课程页面
1.1 核心转换回顾:jit, grad, vmapreview-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
补充练习 / 测验 / 更新说明 / 小结 / 速查表—

与入门课的关联

⚠️ 本机环境提醒: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))
jax 0.12.0.1 | 设备: [CpuDevice(id=0)]

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")
结果一致: True
逐操作执行(eager): 1.807 ms | jit: 1.261 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)
PRNGKey 仍可运行 → dtype = uint32 , shape = (2,)
# ✅ 推荐写法: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)))
dtype = key<fry> , shape = ()

🔍 补充: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))
关于 w 的梯度: [-0.92 -3.68]
关于 b 的梯度: -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, 3)

课程代码 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,))
w 的逐样本梯度形状: (16, 2) | b 的逐样本梯度形状: (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))
平均损失的梯度: [2.4389482  0.36758864] -1.4717047

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)
n 为 traced 值: TracerIntegerConversionError
静态 n 时 jaxpr 的方程数: {3: 3, 30: 30}

核心思想与签名

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])
最终 carry: 15 | 序列: [ 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])
W_xh == W_hh 展平后的前 15 个元素: True
报错: ValueError - normal accepts a single key, but was given a key array of shape (2, 2) != (). Use jax.vmap
# ✅ 推荐写法: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 循环结果一致 ✓")
输入: (4, 3) 初始: (5,) 最终: (5,) 序列: (4, 5)
与 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 循环的好处(课程要点):

  1. 不展开:循环体只追踪一次,编译成 XLA 的 While 循环,编译时间和程序体积基本与步数无关;
  2. 内存效率:XLA 可以在各次迭代之间复用缓冲区;
  3. 循环体优化: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)])
最终值: 1024 | 序列: [  1   2   4   8  16  32  64 128 256 512]
# ❌ 错误写法:以为 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)
报错: TypeError - <lambda>() takes 1 positional argument but 2 were given
# ✅ 推荐写法:循环体始终接收两个参数,不用的那个写成 _
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, _) 正常 ✓")
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]))
reverse=True: [15 14 12  9  5] (后缀和,ys[t] 对应 xs[t])

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])
unroll=   1:   0.157 ms
unroll=   4:   0.146 ms
unroll=  16:   0.129 ms
unroll=True:   0.110 ms
['unroll=4']

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))
  py_loop: 追踪+编译    94.0 ms, jaxpr 方程数 = 1000
scan_loop: 追踪+编译    10.2 ms, jaxpr 方程数 = 1

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]))
length 不一致: scan got `length` argument of 4 which disagrees with leading axis sizes [5].
final: [6. 6.] | sums: [ 0.  2.  6. 12.] | 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)
[dtype] The input carry c has type int32[] but the corresponding output carry component has type float32[], so the dtypes do not match.
[形状] The input carry c has type float32[] but the corresponding output carry component has type float32[2], so the shapes do not match.
[结构] The input carry c is a <class 'jax._src.core.ShapedArray'> but the corresponding component of the carry output is a <class 'tuple'>, so their Python types differ.
init=0(弱类型)→ final carry dtype: 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))
并行前缀: [ 0.        1.        2.9       5.61      9.049    13.1441   17.829689
 23.04672 ]

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))
TracerBoolConversionError | 是 ConcretizationTypeError 的子类: True
TracerBoolConversionError | 是 ConcretizationTypeError 的子类: True

⚠️ 注意:很多资料说这里会出现 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]))
第一次调用(正数):
  [追踪] true_branch
  [追踪] false_branch
  结果: [1. 4. 9.]
第二次调用(负数,形状相同 → 复用编译结果,不再打印追踪信息):
  结果: [ -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"))
多操作数: 3.0
分支 dtype 不一致: cond branches must have equal output types but they differ.
pred 非标量: Pred must be a scalar, got [ True False] of shape (2,).

与其他变换的关系:

  • vmap:从结果上看,批次里每个元素按自己的条件「独立选择分支」。但实现方式和直觉不同,见下面的补充;
  • grad:梯度只沿实际执行的分支传播。

⚠️ 注意:容易以为 pred 依赖可微变量时,梯度也会流经它。其实 pred 来自比较运算(>、>= 等),比较运算输出布尔值,不可微,所以梯度不会流经 pred。对决定分支的阈值参数求导,结果恒为 0。真正的问题在于:分支切换点处函数可能不连续,而梯度看不到「换分支」带来的变化。需要对阈值求导时,要用 sigmoid 这类平滑松弛。1.5 节会用 assert 证明阈值的梯度为 0。

lax.cond vs jnp.where:

lax.condjnp.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)))
switch index 0,1,2,5,-3 → [4.0, 6.0, -3.0, -3.0, 4.0]
select_n(int) : [0. 1. 2. 1.]
select_n(bool): [1. 0. 1. 0.] ← True 选第 1 个 case(这里是 ones)

🔍 补充: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 原语保留 ✓")
—— vmap 后(pred 被批处理)的 jaxpr ——
 { lambda ; a:f32[3]. let
    b:bool[3] = gt a 0.0:f32[]
    c:i32[3] = convert_element_type[new_dtype=int32 weak_type=False] b
    d:bool[3] = eq c 0:i32[]
    e:f32[3] = stop_gradient a
    f:f32[3] = select_n d e a
    g:f32[3] = neg f
    h:bool[3] = eq c 1:i32[]
    i:f32[3] = stop_gradient a
    j:f32[3] = select_n h i a
    k:f32[3] = mul j 2.0:f32[]
    l:f32[3] = select_n c g k
  in (l,) }
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
最小的 2 的幂 >= 100: 128 | JIT 结果: 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.))
x^5 at 2 = 32.0 | 前向模式导数 5x^4 = 80.0
grad(while_loop): Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values. Try using lax.scan, or using fori_loop with static start/stop.

固定步数时应该用什么?

场景推荐能否反向求导
步数是 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 上下界不可 ✓")
静态上下界 → 原语: scan
动态上下界 → 原语: while
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(冻结已完成元素)✓")
各元素的 Collatz 步数: [  0   1   7   8  16 111] → 整个批次要循环 111 次
HLO 中可以看到 reduce-or(“任一元素未完成”)和 select(冻结已完成元素)✓

1.5 结合控制流与变换

JAX 的强大之处在于:lax 的控制流原语可以和 jit / grad / vmap 任意组合。这一节逐个看组合后的行为和代价。

与 jit

jit 会把 scan / while 的循环体,以及 cond 的每个分支都追踪成 jaxpr,再由 XLA 编译成 HLO。循环体只编译一次;cond 的两个分支都会被编译,运行时根据谓词选择执行其中一个。

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]))
Result 1: [2. 4. 6.] | Result 2: [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
手推公式: 1.0749918 | 中心差分: 1.0749918

⚠️ 注意:这里 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.] | 负例梯度: [-1.00001 -1.00001] | 理论值 1/(-1+1e-5) = -1.000010000100001

⚠️ 注意:负例的梯度是 $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)]))
批量输出形状: (4,)

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]]))
[[ 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])
报错: TypeError - Gradient only defined for scalar-output functions. Output had shape: (2, 3). To get the gradient, reduce the o
# ✅ 推荐写法:先把 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)
对阈值参数的梯度: [0. 0.]

⚠️ 注意:直接写 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]))
[[ True  True  True False]
 [ True  True False False]
 [ True  True  True  True]]
求和: [ 6.  9. 30.] | 平均: [2.  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)
[[ True  True  True False]
 [ True  True False False]
 [ True  True  True  True]]

课程代码:注意力分数的掩码

把填充位置的分数设成一个很大的负数,softmax 之后这些位置的权重就会接近 0。

# ❌ 旧写法(错误):jnp.randn 不是 JAX API(JAX 的随机数必须显式传入 key)
try:
    scores = jnp.randn(3, 4)
except AttributeError as e:
    print("报错:", type(e).__name__, "-", e)
报错: AttributeError - module 'jax.numpy' has no attribute 'randn'
# ✅ 推荐写法: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))
[[0.38114452 0.570094   0.04876155 0.        ]
 [0.75917834 0.24082164 0.         0.        ]
 [0.17176017 0.03417814 0.7815508  0.01251089]]

🔍 补充:「整行都被屏蔽」时,-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
-1e9 → [0.25 0.25 0.25 0.25] | -inf → [nan nan nan nan]
where= → [0. 0. 0. 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)
乘法求和: [ 6.  9. 30.] | 乘法平均: [2.  4.5 7.5]
v*m = [ 1. nan] | where(m>0, v, 0) = [1. 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("填充 + 掩码的结果与逐条真实长度运行一致 ✓")
最终状态: [0.561  0.86   2.6244]
填充 + 掩码的结果与逐条真实长度运行一致 ✓

⚠️ 注意:一种常见的错误写法是:把 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 四个函数,用的时候注意下面几点:

  1. num_segments 在 jit 下必须是静态值。不给的话,默认值是 max(segment_ids)+1,需要一个具体数值,在 jit 中会抛出 ConcretizationTypeError;
  2. segment_ids 中 ≥ num_segments 的 id 会被悄悄丢弃,负数 id 也一样;
  3. 空段的结果是归约运算的单位元:sum 为 0,prod 为 1,max 为 -inf,min 为 +inf(以浮点为例;整数 dtype 下 max / min 的空段是这个类型的最小 / 最大值);
  4. 如果已知 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: [ 3. 12.  6.] | segment_max(含空段): [  2.   5.   6. -inf]
越界 id 被丢弃: [ 3. 12.  0.]
jit 下省略 num_segments: ConcretizationTypeError
分段平均: [1.5 4.  6. ]

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)]))
梯度: [  1.   1.   1.  10.  10. 100. 100. 100. 100.]

1.7 了解闭包和 JAX 暂存

Python 闭包回顾

嵌套函数引用了外层作用域的变量,就构成闭包:内层函数「捕获」这些变量,外层函数返回以后仍然可以访问它们。每次调用工厂函数都会创建一个新的闭包,各自持有自己捕获的值。

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)
multiply_by_2(5) = 10 | 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))
闭包捕获数组:
 { lambda a:f32[3,3]; b:f32[3]. let
    c:f32[3] = dot_general[
      dimension_numbers=(([1], [0]), ([], []))
      preferred_element_type=float32
    ] a b
  in (c,) }
作为参数传入:
 { lambda ; a:f32[3,3] b:f32[3]. let
    c:f32[3] = dot_general[
      dimension_numbers=(([1], [0]), ([], []))
      preferred_element_type=float32
    ] a b
  in (c,) }
Python 标量:
 { lambda ; a:f32[3]. let b:f32[3] = mul 2.0:f32[] a in (b,) }

对 JIT 的三个影响(课程要点)

  1. 常量特化:被捕获的值在编译后的代码里是常量,编译结果是针对追踪时的那个值特化的;
  2. 陈旧值:jit 之后再修改被捕获的变量,编译后的函数看不到这个变化;
  3. 重新编译:每个不同的闭包(例如 create_multiplier(5) 和 create_multiplier(10))都是不同的函数对象,会各自追踪、编译一次。如果在循环里不断创建新闭包并 jit,每次都要重新编译,jit 的收益就完全被抵消了。

课程代码:陈旧值演示

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]))
第一次: [0. 2. 4.]
将 scale_factor 更改为 100.0
同一个 jit 函数: [0. 2. 4.] (不是 [0, 100, 200])
# ❌ 常见误解:以为重新执行 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)
新包一层 jax.jit(apply_scale): [0. 2. 4.]
与预期不符: 仍然是旧值
# ✅ 推荐写法:会变化的值作为显式参数传入(值变化不会触发重编译,只有形状或 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] ✓")
[0. 2. 4.] [  0. 100. 200.] | 编译次数: 1
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」来刷新全局变量,应该把它作为参数传入。

最佳实践(课程要点)

  1. 动态的值用显式参数:依赖关系一目了然,不会出现陈旧值,值变化也不需要重新编译;
  2. 闭包只用来承载静态配置:层的大小、激活函数、固定超参数这类在编译函数生命周期内不变的值;
  3. 小心可变对象: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))
修改 dict 前后: [0. 2. 4.] [0. 2. 4.]

⚠️ 注意:这段 Dense 层代码用到了 jnp.dot。单独运行时,除了 import jax 和 import jax.nn as nn,还要写 import jax.numpy as jnp,否则会报 NameError。上面的单元用的是开头已经导入的 jnp。

🔍 补充:闭包捕获大数组的四个问题

🟢 必读

把大数组放在闭包里(「反正它不变」)看起来很自然,但实际用起来会有四个问题:

  1. 程序体积和编译时间暴涨:常量会原样写进 HLO。下面会看到,一个 300×300 的随机矩阵会让 lowering 文本从几百字节变成约 700 KB。参数规模到 GB 级时,编译会极慢甚至失败;
  2. 换权重要么不生效,要么就得重新编译:训练时权重每一步都在变。权重如果是闭包常量,沿用同一个 jit 函数会一直用旧值(陈旧值,见上文);每步新建闭包才能用上新值,但每一步都要重新追踪、编译;
  3. grad 看不到它:常量不是函数的输入,也就无法对它求导。训练时权重必须作为参数传入;
  4. 本机 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)
lowering 文本长度:闭包 720,462 字符 | 参数 427 字符
# 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")
使用泄漏的 tracer: 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))
输入序列形状: (10, 5)
初始隐藏状态形状: (8,)
最终隐藏状态形状: (8,)
隐藏状态输出序列形状: (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()))
scan 版 GRU 与 Python 循环版一致 ✓  最大误差: 4.6566128730773926e-09

与 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))
运行 JIT 编译版本:
最终隐藏状态形状 (JIT): (8,) | 输出序列形状 (JIT): (10, 8)
批量最终状态: (32, 8) | 批量序列: (32, 10, 8)

也可以用 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))
{'Uh': (8, 8), 'Ur': (8, 8), 'Uz': (8, 8), 'Wh': (8, 5), 'Wr': (8, 5), 'Wz': (8, 5), 'bh': (8,), 'br': (8,), 'bz': (8,)}
autodiff: -1.541541e-05 | 中心差分: -1.541540e-05

综合实践 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 逐条循环一致 ✓")
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")
checkpoint 前后梯度一致;padding 位置的输入梯度为 0 ✓
计时(只比较,不断言):grad 不加 checkpoint 0.112 ms | 加 checkpoint 0.094 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 全部通过 ✓")
x(T): scan=-0.975077  numpy=-0.975077  解析=-0.975077
∂x(T)/∂v0: autodiff=-0.272011  解析=-0.272011
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 自检通过")
⏳ 练习 1 尚未作答:name 'discounted_returns' is not defined

练习 2:可以反向求导的「有上限的 while」——牛顿法开平方

lax.while_loop 不能用 grad。请用 lax.scan 跑固定的最大步数(例如 30),用掩码让「已收敛」的元素停止更新,实现 $\sqrt a$ 的牛顿迭代 $x\leftarrow \tfrac12(x + a/x)$,收敛条件为 $|x^2-a|<10^{-6}a$。要求:

  1. 结果与 jnp.sqrt 一致;
  2. 同时返回实际迭代次数,并与 while_loop 版本的迭代次数一致;
  3. 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 尚未作答:name 'newton_sqrt_scan' is not defined
练习 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 自检通过")
⏳ 练习 3 尚未作答:name 'segment_softmax' is not defined

练习 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 自检通过")
⏳ 练习 4 尚未作答:name 'piecewise' is not defined

第 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 验证过。

编号小节要点位置
11.1生成随机数 key 用 jax.random.key(0)(带类型的 key),不再用旧的 jax.random.PRNGKey(0)1.1「随机数 key」对照单元
21.1线性模型 grad 示例的正确结果是 w 梯度 [-0.92, -3.68]、b 梯度 -4.6(残差 −2.3),不是 [-0.392 -1.568] 与 -1.961.1 课程代码 2
31.1闭包或全局变量的值不在 jit 缓存键里,值变化后不重编译,而是沿用旧值1.1 讲解;1.7 证明
41.1/1.5vmap 的「向量化」是 vectorization;quantization 是「量化」1.1 讲解
51.2jit 里的 Python for 循环只有长度是静态值时才会展开;长度是 traced 值时 range(n) 抛 TracerIntegerConversionError1.2 开头
61.2W_hh、W_xh 共用一个 w_key 时,W_xh 等于 W_hh 的前 15 个元素;x_key = random.split(key) 直接传给 normal 会抛 ValueError。每个用途要用独立的子 key1.2 课程代码 2
71.2xs=None 时 scan 仍以两个参数调用循环体,只写 f(carry) 会抛 TypeError1.2 课程代码 3
81.2/1.8推荐把权重作为 jit 函数的参数传入,不要靠闭包捕获(避免常量嵌入,也避开本机 CPU bug)1.2、1.7、1.8
91.3jit 里对 tracer 用 Python if,实际抛出 TracerBoolConversionError(是 ConcretizationTypeError 的子类)1.3 课程代码
101.3比较运算不可微,梯度不流经 cond 的 pred,对阈值的梯度恒为 01.3 讲解;1.5 证明
111.4/1.5while_loop 不支持反向模式(抛 ValueError),只支持 jvp/jacfwd;固定步数用 fori_loop(静态上下界)或 scan1.4「while_loop 不能做反向模式求导」
121.5scan RNN 示例的 dL/dh0 ≈ 1.0749918(手推公式与中心差分一致),不是 0.3131.5「与 grad」
131.5conditional_loss 负例梯度是 [-1.00001 -1.00001](= 1/(−1+1e−5))1.5「与 grad」
141.5jax.grad(jax.vmap(threshold_from_params)) 中 vmap 输出是 (2,3),grad 抛 TypeError;要先求和,而且梯度恒为 01.5「组合多个变换」
151.6JAX 没有 jnp.randn,随机分数要用 jax.random.normal(jax.random.key(0), (3, 4))1.6 注意力掩码
161.6scan 掩码要作为 xs 的一部分逐步读取,不要放进 carry;这样写的结果与逐条按真实长度运行对拍通过1.6 掩码与控制流
171.7重新执行 jax.jit(apply_scale) 仍得到 [0. 2. 4.](命中同一追踪缓存);要 clear_caches() 或改为参数传入1.7 陈旧值对照单元
181.7Dense 层片段用到 jnp.dot,需要 import jax.numpy as jnp1.7 最佳实践
191.8GRU 示例用 jax.random.key;vmap 生成批数据前先 split,不要直接复用 key1.8 课程代码
201.8补充了对 GRU 参数求 grad 的代码、float64 中心差分验证,以及与 Python 循环的数值对拍1.8
211.4cond_fun 永不为 False 时,Ctrl-C 通常也中断不了编译好的 XLA 循环(试过发送 SIGINT 后进程仍在运行,未在 notebook 中断言),只能重启内核或杀进程;应在 carry 里加最大步数1.4 注意事项
221.70.12 中 ClosedJaxpr 已并入 Jaxpr:make_jaxpr 直接返回 Jaxpr,常量在 .consts1.7 暂存与值捕获

本章小结

  1. 追踪模型是一切的基础:jit 把 Python 函数追踪成 jaxpr。参数变成输入变量;闭包或全局值在追踪时被冻结,成为字面量或 constvars。缓存键包含函数对象、参数的抽象信息(形状、dtype 等)和静态参数的值,不包含闭包值。
  2. lax.scan 是序列计算的主力:循环体只追踪一次,编译成 XLA 循环,可以对它反向求导(BPTT),也可以用 vmap 批处理。要牢记 carry 类型必须一致,reverse 不会翻转输出,unroll 是需要实测的性能旋钮。
  3. 条件分支按场景选:标量谓词、分支开销大时用 lax.cond/lax.switch;逐元素选择用 jnp.where/lax.select_n。cond 一旦进入 vmap 批维就会变成 select,两个分支都要计算。梯度不流经谓词。
  4. while_loop 只支持前向模式:需要反向求导时,改用静态上下界的 fori_loop,或者「scan 跑到上限 + 掩码」。在 vmap 下,while_loop 会一直运行到批内最慢的那个元素。
  5. 变长数据的两种方案:padding + mask(规则、易并行,padding 部分的计算被浪费),以及 segment_*(不浪费,但 num_segments 必须是静态值,越界 id 会被悄悄丢弃)。掩码优先用 where 而不是乘法,全空行要特别处理。
  6. 长序列反向的内存问题:用 jax.checkpoint 包住 scan 的循环体,以重算换内存,梯度不变。
  7. 工程纪律:权重和数据都作为参数传入;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.split0.12 推荐的随机数 key API
jax.tree.map / jax.tree.leaves / jax.tree.structurepytree 工具(替代旧的 jax.tree_util.tree_map;迁移表见 入门 06 状态管理)
print(f"本 notebook 共通过 {N_ASSERT} 个 assert ✓")
本 notebook 共通过 106 个 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 循环一致 ✓")
折扣回报与 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 报错 ✓")
sqrt: [  1.4142135   3.        100.          0.5      ] | 迭代次数 scan: [ 4  5 10  4] while: [ 4  5 10  4]
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 ✓")
分段 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)
f: [4.         0.25       0.29552022 0.9092974  0.35840726 2.8584073 ] 
f': [-4.         -1.          0.9553365  -0.41614684  1.          1.        ]