第 4 章 用 vmap 自动向量化

《JAX 入门》课程个人学习笔记 · 运行环境:Python 3.14 / JAX 0.12.0.1 / CPU

原课程链接:第 4 章总页

数值计算和机器学习里,经常要把同一个函数用到一批数据上,也就是“批处理”。显式写循环,或者专门为批处理重写函数,往往很啰嗦,还得手动管理数组维度。jax.vmap 是 JAX 专门做自动向量化的变换:你按“单个数据点”写函数,它就能把函数用到一批(或多批)数据上,通常不用改函数逻辑。可以把它理解成自动给计算加上一个“批次维度”。

学习目标(按课程列出的 6 条)

  1. 理解向量化的含义,以及它为什么能提升性能;
  2. 用 jax.vmap 对处理单个或多个参数的函数做向量化;
  3. 用 in_axes 和 out_axes 控制映射哪些轴;
  4. 用嵌套 vmap 处理更复杂的批处理结构;
  5. 把 vmap 与 jit、grad 组合使用;
  6. 了解使用 vmap 时的性能与内存注意事项。

小节目录

小节原文链接
4.1 向量化的原理concept-of-vectorization
4.2 介绍 jax.vmapintroducing-jax-vmap
4.3 对特定参数进行映射(in_axes、out_axes)vmap-axes-arguments
4.4 处理多个批处理参数vmap-multiple-arguments
4.5 嵌套 vmapnesting-vmap
4.6 结合 vmap 与 jit 和 gradcombining-vmap-jit-grad
4.7 vmap 的性能考量vmap-performance
4.8 动手实践:函数向量化hands-on-vectorizing-functions
4.9 第 4 章测验quiz

阅读说明

  • 每个小节的结构是:课程讲解(按课程要点重新组织)→ 课程代码(注释译成中文,统一使用 JAX 0.12 的推荐写法)→ 🔍 补充。
  • 容易踩坑的地方用 ⚠️ 注意 标出。涉及代码时,过时或错误的写法放在 ❌ 单元里,后面紧跟 ✅ 推荐写法,结论用 assert 验证。这些注意事项在章末的「本章更新说明」里有汇总。
  • 课程中文页里“向量化”有时被括注成 “(quantization)”,这里说的都是向量化(vectorization),不是量化。
  • 所有代码和结论都在 JAX 0.12.0.1(CPU)上实际运行验证过。

讲解文字按课程要点用中文重新组织;代码示例尽量沿用课程里的变量名和数值,并且都实际运行过。

# 本章通用导入与工具函数
import time
import jax
import jax.numpy as jnp
from jax import lax
import numpy as np

print("JAX 版本:", jax.__version__)
print("设备:", jax.devices())

def bench(fn, *args, n=20):
    # 先预热一次(触发编译),再计时 n 次取平均,单位毫秒
    fn(*args).block_until_ready()
    t0 = time.perf_counter()
    for _ in range(n):
        out = fn(*args)
    out.block_until_ready()
    return (time.perf_counter() - t0) / n * 1e3

def jaxpr_str(closed_jaxpr):
    # 把 jaxpr 转成不带终端颜色码的纯文本,便于用 assert 检查其中的内容
    return closed_jaxpr.pretty_print(use_color=False)
JAX 版本: 0.12.0.1
设备: [CpuDevice(id=0)]

4.1 向量化的原理

问题背景。 科学计算和机器学习里,经常要对很多不同的输入做同一个操作,比如:处理一批图像、用不同参数跑多次模拟、算训练集里每个样本对损失的贡献。

在 Python 里,最直接的做法是写 for 循环:比如有一个处理单张图像的函数 predict(image),就一张张调用它。这样写很直观,但常常会成为性能瓶颈:

  • 每次迭代都有解释器开销;
  • 循环里的操作一次只处理一个样本,没法充分利用 GPU/TPU 的并行能力;
  • Python 是解释执行的,循环比优化过的编译代码慢得多。

什么是向量化? 向量化就是把“针对标量(或尺寸小而固定的输入)的操作”,改造成同时作用于整个数组或整批数据的操作。从高层代码看,它不是在循环里一个个处理元素,而是一次处理所有数据点。

用公式表示:对一批输入 $x_1,\dots,x_N$ 套用同一个函数 $f$,

$$ y_i = f(x_i),\ i = 1,\dots,N \qquad\Longleftrightarrow\qquad Y = F(X),\quad X = \begin{bmatrix} x_1 \\ \vdots \\ x_N \end{bmatrix} $$

左边是“逐个处理”(写循环),右边是“整体处理”:把 $N$ 个样本堆成一个多出一维的数组,用批量版本 $F$ 一次算完。

为什么向量化更快? 主要有三点:

  1. 减少解释器开销:一条高层指令就能触发对整个数组的运算,不用在 Python 里维护循环计数器、一个个取元素。
  2. 调用优化过的底层例程:NumPy 和 JAX 底层用的是高度优化的例程(如 BLAS/LAPACK),专门针对 CPU、GPU、TPU 架构调过。
  3. 利用硬件并行:现代 CPU 有 SIMD 指令,能同时对多个数据做同一个操作。GPU/TPU 更是有成千上万个计算单元。向量化代码能很自然地用上这些硬件。

除了快,向量化代码通常也更简洁:numbers + 10 比显式循环更直接地说明了你想做什么。

JAX 中的向量化。 NumPy 的 np.add、np.sin 这些函数本身就是向量化的,但你得按广播规则来组织输入、写代码。JAX 的 jax.vmap 更进一步:你只管按单个数据点写逻辑(如 predict(image)),vmap 会自动把它变成能高效处理整批数据的函数。批处理逻辑不用手改,循环也不用你管。

课程代码

# 课程代码 1:逐个处理一批图像(predict 只是示意,这里补一个占位实现,方便运行)
def predict(image):
    return jnp.mean(image)          # 占位:对单张图像返回一个分数

def predict_batch_loop(images):
    results = []
    for image in images:
        results.append(predict(image))
    return results                  # 也可以用 jnp.stack 堆成一个数组

images = jnp.ones((3, 8, 8))        # 3 张 8x8 的“图像”
print(predict_batch_loop(images))
[Array(1., dtype=float32), Array(1., dtype=float32), Array(1., dtype=float32)]
# 课程代码 2:纯 Python 循环
numbers = [1, 2, 3, 4, 5]
results = []
for x in numbers:
    results.append(x + 10)
print(results)                      # [11, 12, 13, 14, 15]

# 课程代码 3:向量化写法(NumPy)
import numpy as np
numbers = np.array([1, 2, 3, 4, 5])
results = numbers + 10              # 对整个数组一次完成加法,没有显式 Python 循环
print(results)                      # [11 12 13 14 15]
assert results.tolist() == [11, 12, 13, 14, 15]
[11, 12, 13, 14, 15]
[11 12 13 14 15]

🔍 补充:实测循环与向量化的速度差距

⚪ 选读:想看循环与向量化的实测速度差距时读。

下面实际测一下:1000 个 64 维向量,分别和同一个向量做点积。

key = jax.random.key(0)
k1, k2 = jax.random.split(key)
xs = jax.random.normal(k1, (1000, 64))   # 1000 个样本,每个是 64 维向量
w = jax.random.normal(k2, (64,))

def dot_loop(xs, w):                     # Python 循环:每次迭代都要派发一次设备操作
    return jnp.stack([jnp.dot(x, w) for x in xs])

def dot_manual(xs, w):                   # 手动向量化:一次矩阵-向量乘
    return xs @ w

assert jnp.allclose(dot_loop(xs, w), dot_manual(xs, w), atol=1e-5)
print("Python 循环:  %.2f ms" % bench(dot_loop, xs, w, n=3))
print("手动向量化:  %.3f ms" % bench(dot_manual, xs, w))
Python 循环:  20.94 ms
手动向量化:  0.078 ms

🔍 补充:手动向量化最容易错在哪里

🟢 必读

NumPy 式的向量化要求你按广播规则组织代码,而这恰恰是最容易出 bug 的地方:

  • 同一个函数,输入多一维,含义就变了。例如 jnp.dot(a, b):两个 1 维数组是内积,两个 2 维数组却是矩阵乘法。直接把一批数据喂给单样本代码,常常不报错,但结果是错的。
  • 常见的“隐形错误”还有:x.sum()、jnp.linalg.norm(x) 本想对每个样本单独算,结果把整个批都算了进去。x.T 在 2 维和 3 维数组上的含义也不一样。

下面的例子里,单样本函数把向量归一化成单位长度 $x / \lVert x \rVert$。直接传入整批数据不会报错,但 jnp.linalg.norm 对 2 维数组算的是整个矩阵的 Frobenius 范数,结果每一行都除以了同一个数。

def normalize(x):            # 单样本:x 形状 (3,),返回单位向量
    return x / jnp.linalg.norm(x)

X = jnp.array([[3.0, 4.0, 0.0],
               [1.0, 0.0, 0.0]])    # 2 个样本,正确结果的每一行范数都应为 1

wrong = normalize(X)                # 直接传入整批:不报错,但结果错误
right = jax.vmap(normalize)(X)      # vmap:每个样本各自归一化
print("直接传入整批:\n", wrong, "\n 各行范数:", jnp.linalg.norm(wrong, axis=1))
print("vmap:\n", right, "\n 各行范数:", jnp.linalg.norm(right, axis=1))
assert not jnp.allclose(jnp.linalg.norm(wrong, axis=1), 1.0)     # 直接传整批:结果错误
assert jnp.allclose(jnp.linalg.norm(right, axis=1), 1.0)         # vmap:每行都是单位向量
直接传入整批:
 [[0.5883484  0.78446454 0.        ]
 [0.19611613 0.         0.        ]] 
 各行范数: [0.9805807  0.19611613]
vmap:
 [[0.6 0.8 0. ]
 [1.  0.  0. ]] 
 各行范数: [1. 1.]

4.2 介绍 jax.vmap

把为单个数据点写的函数用到整批数据上,是非常常见的需求。Python 循环或 NumPy 自带的向量化能应付一部分情况,但显式管理维度和循环会把核心逻辑淹没掉。函数一复杂,或者有多个批次输入时,更是这样。

jax.vmap(vectorizing map,“向量化映射”)就是为这件事设计的函数变换。它不用改函数内部的逻辑,就能自动给函数加上一个批次维度。你只要按“单个样本”来想、来写,vmap 负责把它变成能高效处理整批数据的函数。

课程用一个“求向量平方和”的函数演示:先对单个向量调用,再分别用手动循环和 vmap 处理一个 4×3 的批次(每一行是一个要独立处理的向量)。

课程代码

import jax
import jax.numpy as jnp

# 处理单个向量的函数(期望输入是一维数组)
def sum_of_squares(vector):
    print(f"Running sum_of_squares for a vector of shape: {vector.shape}")
    return jnp.sum(vector**2)

single_vector = jnp.array([1., 2., 3.])
result_single = sum_of_squares(single_vector)
print(f"Result for single vector: {result_single}")   # 1^2 + 2^2 + 3^2 = 14.0
Running sum_of_squares for a vector of shape: (3,)
Result for single vector: 14.0
# 一批向量:4 行,每行是一个 3 维向量
batch_of_vectors = jnp.array([
    [1., 2., 3.],
    [4., 5., 6.],
    [7., 8., 9.],
    [0., 1., 0.],
])
print(f"Batch shape: {batch_of_vectors.shape}")      # (4, 3)
Batch shape: (4, 3)
# 手动循环(仅作演示,不推荐在 JAX 中这样写)
manual_results = []
for i in range(batch_of_vectors.shape[0]):
    vector = batch_of_vectors[i]
    manual_results.append(sum_of_squares(vector))
manual_output = jnp.stack(manual_results)
print(f"Manual loop output: {manual_output}")        # 函数里的 print 会执行 4 次
Running sum_of_squares for a vector of shape: (3,)
Running sum_of_squares for a vector of shape: (3,)
Running sum_of_squares for a vector of shape: (3,)
Running sum_of_squares for a vector of shape: (3,)
Manual loop output: [ 14.  77. 194.   1.]
# 使用 vmap 创建向量化版本
vectorized_sum_of_squares = jax.vmap(sum_of_squares)
vmap_output = vectorized_sum_of_squares(batch_of_vectors)
print(f"vmap output: {vmap_output}")                 # 注意:函数里的 print 只执行了 1 次
Running sum_of_squares for a vector of shape: (3,)
vmap output: [ 14.  77. 194.   1.]

要点:

  1. 简洁:没有改写 sum_of_squares,只是套了一层 jax.vmap;
  2. 直接应用:形状为 (4, 3) 的整个批次被直接传进去;
  3. 结果正确:[14. 77. 194. 1.] 和手动循环一致;
  4. 形状推导:原函数接收 (3,)、返回 ()。vmap 沿输入的第 0 轴映射,语义上相当于对 4 个长度为 3 的向量各跑一次,再把 4 个标量结果沿一条新的第 0 轴堆起来,得到 (4,)。
  5. print 只打印了一次:因为 vmap 靠的是追踪。它用一个代表整批数据的抽象值(BatchTracer)调用一次 sum_of_squares,每执行到一个 JAX 基本操作,就当场套用这个操作的“批处理规则”,换成对应的批量版本。Python 的 print 是追踪时执行的副作用,所以只运行一次。它打印的 (3,) 是函数内部看到的单样本形状。想在运行时逐样本打印数值,要用 jax.debug.print。

⚠️ 注意:“print 只打印一次”并不是因为 vmap “理解了批处理模式、优化了执行”,这只是追踪的结果。Python print 在追踪时执行一次;换成 jax.debug.print,就会对每个样本各打印一次(见下方代码)。函数本身并没有被“少执行”。

默认行为:vmap 默认沿所有输入参数的第 0 轴映射,并把结果沿输出的第 0 轴堆起来。这正是机器学习里最常见的批处理约定。

另外,在 JAX 里用 Python 循环反复调用函数、再和 jit 结合,可能导致低效的追踪(见第 2 章:循环会被展开,追踪和编译时间随循环次数增长)。

语义等价(不是实现方式):

jax.vmap(f)(xs)  ≡  jnp.stack([f(x) for x in xs])

🔍 补充:Python print 与 jax.debug.print 的区别

🟢 必读

  • Python print:追踪时执行一次,看到的是 BatchTracer 和单样本形状;
  • jax.debug.print:运行时执行,在 vmap 下对每个样本各打印一次;
  • 推论:函数体里依赖具体数值的 Python 逻辑(if、float(x) 等)在 vmap 下都不能用(见本节后面的补充)。
def show(v):
    print("Python print(追踪时执行):", type(v).__name__, v.shape)
    jax.debug.print("jax.debug.print(运行时执行): {}", v)
    return jnp.sum(v ** 2)

import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):            # 捕获输出,用来验证打印次数
    res = jax.vmap(show)(batch_of_vectors[:3]).block_until_ready()
    jax.effects_barrier()                        # 等待 debug.print 的回调全部完成
captured = buf.getvalue()
print(captured, "结果:", res)

# 验证:Python print 只在追踪时执行 1 次;jax.debug.print 对 3 个样本各执行 1 次
assert captured.count("Python print") == 1
assert captured.count("jax.debug.print") == 3
print("✔ 断言通过:print 只打印一次是追踪造成的,并不是计算被“优化掉”了")
Python print(追踪时执行): BatchTracer (3,)
jax.debug.print(运行时执行): [1. 2. 3.]
jax.debug.print(运行时执行): [4. 5. 6.]
jax.debug.print(运行时执行): [7. 8. 9.]
 结果: [ 14.  77. 194.]
✔ 断言通过:print 只打印一次是追踪造成的,并不是计算被“优化掉”了

🔍 补充:用 jax.make_jaxpr 看清楚 vmap 到底做了什么

⚪ 选读:想看清 vmap 变换后的程序长什么样时读。

jax.make_jaxpr 会打印 JAX 追踪得到的中间表示(jaxpr)。对比 f 和 vmap(f) 的 jaxpr 能看到:vmap(f) 里没有任何循环,只是数组多了一维,各个操作换成了对应的批量版本(例如 reduce_sum 改成沿第 1 轴求和,点积 dot_general 多了批维度)。这直接说明:vmap 变换的是程序本身,而不是替你写了个 for 循环。

def sos(v):                               # 去掉 print 的 sum_of_squares
    return jnp.sum(v ** 2)

print("===== f 的 jaxpr =====")
print(jax.make_jaxpr(sos)(jnp.ones(3)))
print("===== vmap(f) 的 jaxpr =====")
jp_v = jaxpr_str(jax.make_jaxpr(jax.vmap(sos))(batch_of_vectors))
print(jp_v)
# 验证:vmap 后的程序里没有任何循环原语,只是形状多了一维
assert not any(p in jp_v for p in ("while", "scan", "fori")) and "f32[4]" in jp_v

def dot(x, w):
    return jnp.dot(x, w)
print("===== vmap(dot) 的 jaxpr:dot_general 多了批维度 ([0], [0]) =====")
print(jax.make_jaxpr(jax.vmap(dot))(jnp.ones((5, 3)), jnp.ones((5, 3))))
===== f 的 jaxpr =====
{ lambda ; a:f32[3]. let
    b:f32[3] = integer_pow[y=2] a
    c:f32[] = reduce_sum[axes=(0,) out_sharding=None] b
  in (c,) }
===== vmap(f) 的 jaxpr =====
{ lambda ; a:f32[4,3]. let
    b:f32[4,3] = integer_pow[y=2] a
    c:f32[4] = reduce_sum[axes=(np.int64(1),) out_sharding=None] b
  in (c,) }
===== vmap(dot) 的 jaxpr:dot_general 多了批维度 ([0], [0]) =====
{ lambda ; a:f32[5,3] b:f32[5,3]. let
    c:f32[5] = dot_general[
      dimension_numbers=(([1], [1]), ([0], [0]))
      preferred_element_type=float32
    ] a b
  in (c,) }

🔍 补充:vmap 内不能用依赖“批数据”的 Python if

🟢 必读

在 vmap 下,函数内部的值是代表“一整批值”的 tracer,Python 的 if x > 0: 没法决定走哪个分支,会抛出 TracerBoolConversionError(本质上和 jit 下的限制一样)。要改用 jnp.where 或 lax.cond(lax.cond 在批处理下的代价见 4.7)。

def relu_py(x):
    if x > 0:          # Python 控制流依赖了被映射的值
        return x
    return 0.0

try:
    jax.vmap(relu_py)(jnp.array([-1.0, 2.0, 3.0]))
    raised = False
except jax.errors.TracerBoolConversionError as e:
    raised = True
    print(type(e).__name__, ":", str(e).splitlines()[0])
assert raised, "依赖批数据的 Python if 应当报错"

# 正确写法:用 jnp.where 表达逐元素选择
relu = lambda x: jnp.where(x > 0, x, 0.0)
out = jax.vmap(relu)(jnp.array([-1.0, 2.0, 3.0]))
assert jnp.array_equal(out, jnp.array([0.0, 2.0, 3.0]))
print(out)
TracerBoolConversionError : Attempted boolean conversion of traced array with shape bool[].
[0. 2. 3.]

4.3 对特定参数进行映射(in_axes,out_axes)

vmap 默认把每个参数的第 0 轴都当作批次维度。但实际中常常需要更细的控制:可能只有部分参数是批次(典型情况是模型参数共享、只有数据成批),也可能批次维度不在第 0 轴。in_axes 和 out_axes 就是用来精确控制输入和输出怎么处理的。

in_axes:指定每个输入沿哪个轴映射

  • 写成元组时,元组长度必须等于调用时传入的位置参数个数,每个元素对应一个位置参数。关键字参数不归元组管,总是沿第 0 轴映射;
  • 写成单个整数(或 None)时,对所有位置参数统一生效(默认 in_axes=0);
  • 整数 i:这个参数的第 i 轴是批次维度,vmap 沿这个轴逐片处理;
  • None:这个参数不映射,整体广播,每次调用都原样复用;
  • 所有被映射的参数,在映射轴上的大小必须相同,否则会报错;
  • 即使映射的是输入的第 1 轴,输出的批次维度默认仍在第 0 轴。

out_axes:指定批次维度在输出中的位置

  • 默认沿输出第 0 轴堆叠;
  • out_axes=1 表示把批次维度放在输出的第 1 轴;
  • 函数返回多个值(pytree,例如 dict)时,out_axes 也可以写成相同结构的 pytree,为每个返回值分别指定批次轴。

形状示意:f(W, x) 中 W 形状为 $(m, n)$,x 形状为 $(n,)$:

$$ \text{vmap}(f,\ \text{in\_axes}=(\text{None}, 0))\big(W_{(m,n)},\ X_{(B,n)}\big) \to Y_{(B,m)}, \quad Y_b = W x_b $$

把 in_axes 和 out_axes 配合起来用,单样本函数就能适应各种批处理布局,核心逻辑不用改,维度也不用手动重排。

⚠️ 注意:in_axes 元组的长度,要和调用时的位置参数个数匹配,而不是笼统的“函数参数个数”。关键字参数总是沿第 0 轴映射,元组管不到(报错演示见本节末尾)。list 在 0.12 中也能用,但官方文档写的是 tuple,建议统一用 tuple。

课程代码

def add_scalar(vector, scalar):
    return vector + scalar

vectors = jnp.arange(12).reshape(4, 3)     # 4 个向量,每个长度为 3
scalar_val = 100.0

# 映射 vectors 的第 0 轴,广播 scalar(不映射)
vectorized_add_scalar = jax.vmap(add_scalar, in_axes=(0, None))
result = vectorized_add_scalar(vectors, scalar_val)
print("in_axes=(0, None):", result.shape)
print(result)
in_axes=(0, None): (4, 3)
[[100. 101. 102.]
 [103. 104. 105.]
 [106. 107. 108.]
 [109. 110. 111.]]
# 每个向量加上各自的标量:两个参数都沿第 0 轴映射
scalars = jnp.array([100., 200., 300., 400.])
result_batched_scalars = jax.vmap(add_scalar, in_axes=(0, 0))(vectors, scalars)
print("in_axes=(0, 0):", result_batched_scalars.shape)
print(result_batched_scalars)
in_axes=(0, 0): (4, 3)
[[100. 101. 102.]
 [203. 204. 205.]
 [306. 307. 308.]
 [409. 410. 411.]]
# 沿非 0 轴映射:数据转置后,每一列是一个向量
vectors_T = vectors.T                       # 形状 (3, 4)
result_axis1 = jax.vmap(add_scalar, in_axes=(1, None))(vectors_T, scalar_val)
print("in_axes=(1, None):", result_axis1.shape, "(输出的批次维度仍在第 0 轴)")
print(result_axis1)
assert result_axis1.shape == (4, 3) and jnp.array_equal(result_axis1, result)
in_axes=(1, None): (4, 3) (输出的批次维度仍在第 0 轴)
[[100. 101. 102.]
 [103. 104. 105.]
 [106. 107. 108.]
 [109. 110. 111.]]
def process_vector(v):
    return v * 2

# 默认 out_axes=0
out0 = jax.vmap(process_vector, in_axes=0, out_axes=0)(vectors)
print("out_axes=0:", out0.shape)
print(out0)

# out_axes=1:原向量维度(大小 3)成为输出第 0 轴,批次维度(大小 4)放到第 1 轴
out1 = jax.vmap(process_vector, in_axes=0, out_axes=1)(vectors)
print("out_axes=1:", out1.shape)
print(out1)
out_axes=0: (4, 3)
[[ 0  2  4]
 [ 6  8 10]
 [12 14 16]
 [18 20 22]]
out_axes=1: (3, 4)
[[ 0  6 12 18]
 [ 2  8 14 20]
 [ 4 10 16 22]]
# 多个返回值(pytree):为不同输出指定不同的 out_axes
def process_vector_pytree(v):
    return {'sum': v.sum(), 'doubled': v * 2}

res = jax.vmap(process_vector_pytree, in_axes=0, out_axes={'sum': 0, 'doubled': 1})(vectors)
print("sum:", res['sum'].shape, res['sum'])
print("doubled:", res['doubled'].shape)
print(res['doubled'])
sum: (4,) [ 3 12 21 30]
doubled: (3, 4)
[[ 0  6 12 18]
 [ 2  8 14 20]
 [ 4 10 16 22]]

🔍 补充:更典型的场景——共享权重、按样本映射数据

⚪ 选读:想看共享权重矩阵、负数轴的写法时读。

上面的例子里共享的是一个标量,实际中更常见的是共享模型参数。下面的线性层 W @ x 里,W 不映射,x 按样本映射。负数轴(-1 表示最后一轴)也一样能用。

k1, k2 = jax.random.split(jax.random.key(1))
W = jax.random.normal(k1, (3, 4))     # 共享权重
X = jax.random.normal(k2, (5, 4))     # 5 个样本

def linear(W, x):                      # 单样本:矩阵乘向量
    return W @ x

Y = jax.vmap(linear, in_axes=(None, 0))(W, X)
print("Y 形状:", Y.shape, "| 与手写批量版本一致:", jnp.allclose(Y, X @ W.T, atol=1e-5))
print("in_axes=-1(按列存放的数据):", jnp.allclose(jax.vmap(linear, in_axes=(None, -1))(W, X.T), Y))
Y 形状: (5, 3) | 与手写批量版本一致: True
in_axes=-1(按列存放的数据): True

🔍 补充:in_axes 也可以是 pytree(例如 dict)

⚪ 选读:参数是 dict、只有部分字段按样本变化时读。

前面演示了 pytree 形式的 out_axes,其实 in_axes 也支持:每个元素可以是和对应参数结构相同的 pytree,也可以是它的“前缀”。参数是 dict 时,可以让一些字段按样本变化,另一些字段共享。

规则:

  • 某个参数的 in_axes 如果是一个整数或 None,就作用于这个参数整棵 pytree 的所有叶子(“前缀”规则);
  • 写成 dict 时,键必须和参数 dict 的键完全一致,否则报错。
params = {"w": jnp.array([1.0, 2.0, 3.0]),          # 共享权重:形状 (3,)
          "b": jnp.array([0.0, 10.0, 20.0, 30.0])}  # 每个样本各自的偏置:形状 (4,)
xb = jnp.arange(12.0).reshape(4, 3)                 # 4 个样本

def predict_d(p, x):
    return jnp.dot(p["w"], x) + p["b"]

# 对 params 用 dict 形式的 in_axes:w 不映射,b 沿第 0 轴映射
out = jax.vmap(predict_d, in_axes=({"w": None, "b": 0}, 0))(params, xb)
print(out)
print("手动核对:", xb @ params["w"] + params["b"])

# 结构不匹配(键名写错)会报错
try:
    jax.vmap(predict_d, in_axes=({"w": None, "bias": 0}, 0))(params, xb)
except Exception as e:
    print(type(e).__name__, ":", str(e)[:420])
[ 8. 36. 64. 92.]
手动核对: [ 8. 36. 64. 92.]
ValueError : vmap in_axes specification must be a tree prefix of the corresponding value; 
  Mismatch details (1 found):
  pytree structure error: different pytree metadata at key path
    vmap in_axes[0]
At that key path, the prefix pytree vmap in_axes has a subtree of type
    <class 'dict'>
with metadata
    ('bias', 'w')
but at the same key path the full pytree has a subtree of the same type but with metadata
    ('b', 'w')
s

🔍 补充:关键字参数陷阱、out_axes=None、axis_size 与 0.12 新增的 sum_match

⚪ 选读:要给 vmap 后的函数传关键字参数、需要不带批维度的输出,或批大小推断不出来时读。

  1. 关键字参数总是沿第 0 轴映射:元组形式的 in_axes 管不到它们。如果把本该共享的参数用关键字方式传进去,它也会被映射,可能报错,也可能产生不容易发现的错误。建议:给 vmap 后的函数只传位置参数。
  2. out_axes=None:表示这个输出不带批维度。只有它确实不随样本变化时才合法,否则报错。
  3. axis_size:如果所有输入都是 in_axes=None(甚至根本没有输入),vmap 推断不出批大小,要用 axis_size 显式指定。
  4. sum_match(JAX 0.12 的 vmap 签名里新增的参数,旧教程里没有):设为 True 后,out_axes=None 的输出如果随样本变化,会沿批维度求和,而不是报错。这在自动微分里有用,因为“沿映射轴求和”正好是“沿映射轴广播”的转置。
add = lambda a, b: a + b

# 1) 关键字参数:元组 in_axes 不对应关键字参数,会报错并给出提示
try:
    jax.vmap(add, in_axes=(0, None))(jnp.ones(3), b=jnp.ones(3))
    raised = False
except ValueError as e:
    raised = "keyword arguments" in str(e)
    print("关键字参数报错:", str(e)[:260], "...")
assert raised, "元组 in_axes 不对应关键字参数,应当报错"
# 关键字参数会被沿第 0 轴映射:传入长度 3 的 b,得到逐元素相加,而不是“整体广播”
assert jnp.array_equal(jax.vmap(add)(jnp.arange(3.0), b=jnp.arange(3.0)), 2 * jnp.arange(3.0))

# 2) out_axes=None 但输出依赖批数据 -> 报错
try:
    jax.vmap(lambda x: 2 * x, out_axes=None)(jnp.arange(3.0))
except ValueError as e:
    print("out_axes=None 报错:", e)
# 输出不依赖批数据时,out_axes=None 合法
print("out_axes=None 合法用法:", jax.vmap(lambda x, c: (x * c, c), in_axes=(0, None), out_axes=(0, None))(jnp.arange(3.0), 5.0))

# 3) axis_size:没有任何被映射的输入时必须显式给出
try:
    jax.vmap(lambda c: c * jnp.ones(2), in_axes=None)(3.0)
except ValueError as e:
    print("缺少 axis_size 报错:", e)
print("axis_size=4:\n", jax.vmap(lambda c: c * jnp.ones(2), in_axes=None, axis_size=4)(3.0))

# 4) sum_match=True:out_axes=None 的输出沿批维度求和
sm = jax.vmap(lambda x: 2.0 * x, out_axes=None, sum_match=True)(jnp.arange(3.0))
assert sm == 6.0                                  # 2*(0+1+2)
print("sum_match:", sm)
关键字参数报错: vmap in_axes must be an int, None, or a tuple of entries corresponding to the positional arguments passed to the function, but got len(in_axes)=2, len(args)=1. Note that the function was called with keyword arguments ['b'], which the entries of a tuple in_axes ...
out_axes=None 报错: at vmap out_axes, got axis spec None but output was batched on axis 0
out_axes=None 合法用法: (Array([ 0.,  5., 10.], dtype=float32), 5.0)
缺少 axis_size 报错: vmap must have at least one non-None value in in_axes or axis_size must be specified
axis_size=4:
 [[3. 3.]
 [3. 3.]
 [3. 3.]
 [3. 3.]]
sum_match: 6.0

4.4 处理多个批处理参数

需要向量化的函数往往有多个输入,例如:对两批向量逐对求点积,或者把一批向量都乘以同一个矩阵。

默认行为:in_axes 默认是 0,意思是所有位置参数都沿第 0 轴映射。所以只要有一个参数不该被映射(比如一个标量),就得显式写出元组,例如 in_axes=(0, None)。

⚠️ 注意:默认的 in_axes=0 映射的是每个位置参数,而不只是第一个参数。对 scale_vector(batch, 2.0) 直接用 jax.vmap(scale_vector),会因为标量没有第 0 轴而报 ValueError(下方用 assert 验证)。

in_axes 里的每个元素,决定对应参数怎么参与映射:

  • 整数 i:映射这个参数的第 i 轴,这一轴的大小就是批大小。多个参数都用整数 in_axes 时,它们指定的轴大小必须一致;
  • None:不映射,当作常量广播到每次调用里。

vmap 会同时从每个被映射参数中取出第 $i$ 个切片,一起交给 f:

$$ \text{vmap}(f,\ \text{in\_axes}=(0, \text{None}, 0))(X, y, Z)_i = f(X_i,\ y,\ Z_i) $$

课程用一张图说明了这个过程:vmap 从 xs(沿第 0 轴)依次取出 $x_0, x_1, \dots$,从 zs(沿第 0 轴)依次取出 $z_0, z_1, \dots$,把同一个 y_val 传给每一次调用,最后把所有结果沿输出的第 0 轴堆成一批。注意这是按位置配对,不是两两组合(两两组合要用 4.5 的嵌套 vmap)。

常见的有三种场景:

  1. 映射部分参数、广播其余参数:例如 in_axes=(0, None);
  2. 多个参数沿同一轴映射:例如 in_axes=(0, 0),批大小由各参数第 0 轴的大小决定,而且必须相同;
  3. 不同参数沿不同轴映射(进阶):例如 in_axes=(1, 0)。注意:不管输入沿哪个轴映射,批次维度默认都放在输出的第 0 轴。想放到别的位置,要用 out_axes。

⚠️ 注意:在场景 3 的“列缩放”例子里,容易以为 in_axes=(1, 0) 会把映射维度放回第 1 轴,得到 3×4 的布局。其实默认 out_axes=0,结果形状是 (4, 3),正好是 3×4 布局的转置。要得到 3×4,必须加 out_axes=1(下方 ❌/✅ 对照)。

课程代码

# 场景 1:映射部分参数,广播其他参数
def scale_vector(vector, scalar):
    return vector * scalar

vector = jnp.arange(3.)
scalar = 2.0
print("单次缩放:", scale_vector(vector, scalar))          # [0. 2. 4.]

batch_of_vectors = jnp.arange(9.).reshape((3, 3))
single_scalar = 2.0
vectorized_scale = jax.vmap(scale_vector, in_axes=(0, None))
scaled = vectorized_scale(batch_of_vectors, single_scalar)
print("批量缩放:\n", scaled)
assert jnp.array_equal(scaled, batch_of_vectors * 2.0)
单次缩放: [0. 2. 4.]
批量缩放:
 [[ 0.  2.  4.]
 [ 6.  8. 10.]
 [12. 14. 16.]]
# ❌ 错误写法:以为默认只映射第一个参数,于是省略了 in_axes
try:
    jax.vmap(scale_vector)(batch_of_vectors, 2.0)
    raised = False
except ValueError as e:
    raised = True
    print("实测 ValueError:", e)
assert raised, "默认 in_axes=0 也会去映射标量参数,应当报错"
实测 ValueError: vmap was requested to map its argument along axis 0, which implies that its rank should be at least 1, but is only 0 (its shape is ())
# ✅ 推荐写法:默认 in_axes=0 映射所有位置参数,共享的标量必须显式写 None
ok = jax.vmap(scale_vector, in_axes=(0, None))(batch_of_vectors, 2.0)
assert jnp.array_equal(ok, batch_of_vectors * 2.0)

# 验证“默认映射所有位置参数”:两个参数都带批维度时,默认 in_axes 会同时映射两者
def add_vectors(vec1, vec2):
    return vec1 + vec2
b1 = jnp.arange(9.).reshape(3, 3)
b2 = jnp.arange(3.)[:, None] * jnp.ones((3, 3))    # 第 i 行全是 i
default_out = jax.vmap(add_vectors)(b1, b2)
assert jnp.array_equal(default_out, b1 + b2)       # 第 i 个样本 = b1[i] + b2[i],第二个参数也被映射了
print("✔ 断言通过:默认 in_axes=0 映射的是所有位置参数")
✔ 断言通过:默认 in_axes=0 映射的是所有位置参数
# 场景 2:多个参数沿相同轴映射
batch1 = jnp.arange(9.).reshape((3, 3))
batch2 = jnp.ones((3, 3)) * 10
vectorized_add = jax.vmap(add_vectors, in_axes=(0, 0))
added = vectorized_add(batch1, batch2)
print(added)
assert jnp.array_equal(added, batch1 + 10)
[[10. 11. 12.]
 [13. 14. 15.]
 [16. 17. 18.]]
# 场景 3(前半):行 × 滤波向量,映射矩阵的行,广播滤波向量
def apply_filter(row_vector, filter_vector):
    return row_vector * filter_vector

matrix = jnp.arange(12.).reshape((3, 4))
filter_v = jnp.array([1., 10., 100., 1000.])
filtered = jax.vmap(apply_filter, in_axes=(0, None))(matrix, filter_v)
print("行滤波 in_axes=(0, None):\n", filtered)
assert filtered.shape == (3, 4)
行滤波 in_axes=(0, None):
 [[0.0e+00 1.0e+01 2.0e+02 3.0e+03]
 [4.0e+00 5.0e+01 6.0e+02 7.0e+03]
 [8.0e+00 9.0e+01 1.0e+03 1.1e+04]]
# ❌ 容易误解的写法:以为结果是 3x4、“映射维度放回第 1 轴”
def scale_column(column_vector, scalar):
    return column_vector * scalar

scalars = jnp.array([1., 10., 100., 1000.])
if matrix.shape[1] == scalars.shape[0]:
    scaled_cols = jax.vmap(scale_column, in_axes=(1, 0))(matrix, scalars)
    print("实测形状:", scaled_cols.shape)
    print(scaled_cols)

# 验证:默认 out_axes=0,批次维度(4 列)在输出第 0 轴 -> (4, 3),是期望结果的转置
assert scaled_cols.shape == (4, 3)
assert jnp.allclose(scaled_cols, (matrix * scalars).T)
实测形状: (4, 3)
[[0.0e+00 4.0e+00 8.0e+00]
 [1.0e+01 5.0e+01 9.0e+01]
 [2.0e+02 6.0e+02 1.0e+03]
 [3.0e+03 7.0e+03 1.1e+04]]
# ✅ 推荐写法:用 out_axes=1 把批次维度放回第 1 轴,才能得到想要的 3x4 布局
scaled_cols_fixed = jax.vmap(scale_column, in_axes=(1, 0), out_axes=1)(matrix, scalars)
print("修正后形状:", scaled_cols_fixed.shape)
print(scaled_cols_fixed)
assert scaled_cols_fixed.shape == (3, 4)
assert jnp.allclose(scaled_cols_fixed, matrix * scalars)    # 与广播写法一致
print("✔ 断言通过")
修正后形状: (3, 4)
[[0.0e+00 1.0e+01 2.0e+02 3.0e+03]
 [4.0e+00 5.0e+01 6.0e+02 7.0e+03]
 [8.0e+00 9.0e+01 1.0e+03 1.1e+04]]
✔ 断言通过

🔍 补充:两类最常见的报错及排查

⚪ 选读:遇到 vmap 的形状类报错时查阅。

  1. 批大小不一致:两个被映射的参数,在映射轴上长度不同。注意 vmap 不会像 NumPy 广播那样自动扩展长度为 1 的轴。共享的参数要用 None,而不是给它加一个长度为 1 的轴。
  2. in_axes 与参数结构不匹配:元组长度和位置参数个数对不上,或者映射轴超出了数组的维数。

JAX 0.12 的报错信息已经相当具体(会写出是哪个参数、哪个轴、大小多少),排查时先仔细读报错。

f2 = lambda a, b: a + b

cases = {
    "批大小不一致 (3 vs 4)": lambda: jax.vmap(f2)(jnp.ones(3), jnp.ones(4)),
    "长度 1 不会被自动广播": lambda: jax.vmap(f2)(jnp.ones((3, 2)), jnp.ones((1, 2))),
    "in_axes 长度与参数个数不符": lambda: jax.vmap(f2, in_axes=(0,))(jnp.ones(3), jnp.ones(3)),
    "映射轴超出数组维数": lambda: jax.vmap(lambda a: a, in_axes=1)(jnp.ones(3)),
}
n_raised = 0
for name, fn in cases.items():
    try:
        fn()
        print(f"[{name}] 没有报错?!")
    except ValueError as e:
        n_raised += 1
        print(f"[{name}]\n  ValueError: {e}\n")
assert n_raised == len(cases)
[批大小不一致 (3 vs 4)]
  ValueError: vmap got inconsistent sizes for array axes to be mapped:
  * one axis had size 3: axis 0 of argument a of type float32[3];
  * one axis had size 4: axis 0 of argument b of type float32[4]

[长度 1 不会被自动广播]
  ValueError: vmap got inconsistent sizes for array axes to be mapped:
  * one axis had size 3: axis 0 of argument a of type float32[3,2];
  * one axis had size 1: axis 0 of argument b of type float32[1,2]

[in_axes 长度与参数个数不符]
  ValueError: vmap in_axes must be an int, None, or a tuple of entries corresponding to the positional arguments passed to the function, but got len(in_axes)=1, len(args)=2.

[映射轴超出数组维数]
  ValueError: vmap was requested to map its argument along axis 1, which implies that its rank should be at least 2, but is only 1 (its shape is (3,))

4.5 嵌套 vmap

数据可能有多层批次结构。比如一批句子,每个句子又是一串词,要对每个词向量做运算;又比如要算“一批元素里的每一个”和“另一批元素里的每一个”之间的关系。vmap 本身就是函数变换,所以可以再套一层,同时映射多个维度。

为什么需要嵌套? 单层 vmap(f, in_axes=(0, 0)) 只能把两批输入按位置配对。如果想要“每个矩阵 × 每个向量”的所有组合(有点像批次层面的“外积”),就得分别独立地映射矩阵批和向量批,这需要两层 vmap。

怎么读嵌套 vmap(由内向外):

  • 内层 vmap(f, in_axes=(ax1, ax2)) 得到一个新函数,它把 f 映射到自己输入的 ax1、ax2 轴上;
  • 外层 vmap(..., in_axes=(ax3, ax4)) 再把这个新函数映射到外层输入的 ax3、ax4 轴上;
  • 某个参数在某一层取 None,表示它在这一层不映射,原样广播。

vmap(vmap(f, in_axes=(None, 0)), in_axes=(0, None)) 是计算两批元素之间两两关系的经典模式:

$$ M_{ij} = f(x_i, y_j),\qquad X \in \mathbb{R}^{M\times d},\ Y \in \mathbb{R}^{N\times d} \;\Rightarrow\; M \in \mathbb{R}^{M \times N} $$

输出里外层的批维度在前,内层的在后。嵌套 vmap 也能和 jit、grad 组合使用。记住“每个 vmap 引入一层映射”,是写复杂批处理变换的关键。

课程代码

# 单个矩阵乘单个向量
def matvec_multiply(matrix, vector):
    return jnp.dot(matrix, vector)

matrix = jnp.arange(6.).reshape(2, 3)
vector = jnp.arange(3.)
print("单次 matvec:", matvec_multiply(matrix, vector))        # [ 5. 14.],形状 (2,)

# 单层 vmap:矩阵批与向量批按位置配对
batch_of_matrices = jnp.arange(18.).reshape(3, 2, 3)          # 3 个 2x3 矩阵
batch_of_vectors = jnp.arange(9.).reshape(3, 3)               # 3 个 3 维向量
batched_matvec = jax.vmap(matvec_multiply, in_axes=(0, 0))
print("配对 matvec 形状:", batched_matvec(batch_of_matrices, batch_of_vectors).shape)   # (3, 2)
单次 matvec: [ 5. 14.]
配对 matvec 形状: (3, 2)
# 嵌套 vmap:每个矩阵 × 每个向量(所有组合)
batch_of_vectors_outer = jnp.arange(12.).reshape(4, 3)        # 4 个向量

# 内层 in_axes=(None, 0):矩阵固定,遍历所有向量
# 外层 in_axes=(0, None):遍历所有矩阵,向量批整体共享
pairwise_matvec = jax.vmap(
    jax.vmap(matvec_multiply, in_axes=(None, 0)),
    in_axes=(0, None),
)
result = pairwise_matvec(batch_of_matrices, batch_of_vectors_outer)
print("两两 matvec 形状:", result.shape)                       # (3, 4, 2)
assert result.shape == (3, 4, 2)

# 核对:result[i, j] 应该等于第 i 个矩阵乘第 j 个向量
assert jnp.allclose(result[1, 2], matvec_multiply(batch_of_matrices[1], batch_of_vectors_outer[2]))
print("✔ result[1, 2] == 第 1 个矩阵 @ 第 2 个向量")
两两 matvec 形状:
 (3, 4, 2)
✔ result[1, 2] == 第 1 个矩阵 @ 第 2 个向量
# 两两距离(欧氏距离的平方)
def euclidean_distance_sq(point_a, point_b):
    return jnp.sum((point_a - point_b)**2)

points_a = jnp.array([[1., 0.], [0., 1.], [-1., 0.]])                 # (3, 2)
points_b = jnp.array([[2., 2.], [-2., 2.], [2., -2.], [-2., -2.]])    # (4, 2)

inner_map = jax.vmap(euclidean_distance_sq, in_axes=(None, 0))      # 一个 a 对所有 b
pairwise_distance_sq = jax.vmap(inner_map, in_axes=(0, None))       # 所有 a
# 等价的一行写法
pairwise_distance_sq_direct = jax.vmap(
    jax.vmap(euclidean_distance_sq, in_axes=(None, 0)), in_axes=(0, None))

distance_matrix = pairwise_distance_sq(points_a, points_b)
print("距离矩阵形状:", distance_matrix.shape)                       # (3, 4)
print(distance_matrix)
assert jnp.allclose(distance_matrix, pairwise_distance_sq_direct(points_a, points_b))
assert jnp.array_equal(distance_matrix, jnp.array([[5., 13., 5., 13.], [5., 5., 13., 13.], [13., 5., 13., 5.]]))
print("手工核对 [0, 0]:", euclidean_distance_sq(points_a[0], points_b[0]))   # (1-2)^2 + (0-2)^2 = 5
距离矩阵形状: (3, 4)
[[ 5. 13.  5. 13.]
 [ 5.  5. 13. 13.]
 [13.  5. 13.  5.]]
手工核对 [0, 0]: 5.0
# 嵌套 vmap 与 jit、grad 组合
jitted_pairwise_distance_sq = jax.jit(pairwise_distance_sq_direct)
print("jit 版本一致:", jnp.allclose(jitted_pairwise_distance_sq(points_a, points_b), distance_matrix))

def sum_of_distances(p_a, p_b):
    dist_matrix = pairwise_distance_sq_direct(p_a, p_b)
    return jnp.sum(dist_matrix)

grad_sum_dist_wrt_a = jax.grad(sum_of_distances, argnums=0)
gradients_a = grad_sum_dist_wrt_a(points_a, points_b)
print("对 points_a 的梯度形状:", gradients_a.shape)                  # (3, 2),与 points_a 相同
print(gradients_a)
# 解析解核对:d/da_i sum_j |a_i - b_j|^2 = sum_j 2 (a_i - b_j)
assert jnp.allclose(gradients_a, 2 * (points_b.shape[0] * points_a - points_b.sum(0)))
print("✔ 梯度与解析解一致")
jit 版本一致: True
对 points_a 的梯度形状: (3, 2)
[[ 8.  0.]
 [ 0.  8.]
 [-8.  0.]]
✔ 梯度与解析解一致

⚠️ 注意:“两两 matvec”这段代码,容易被理解成“先映射矩阵(轴 0),再映射向量(轴 0)”。它实际的含义是:内层 (None, 0) 遍历向量,外层 (0, None) 遍历矩阵,输出 (矩阵数, 向量数, 2) = (3, 4, 2)。上面的代码注释写明了这一点,并用 assert 核对了形状和元素。

🔍 补充:嵌套顺序决定输出轴顺序;多层批维度

⚪ 选读:需要多层批维度或关心输出轴顺序时读。

交换内外层的映射对象,输出就变成转置。对于 (B, T, d) 这类有两层批维度的数据,两层 vmap 直接得到 (B, T) 的结果。

k1, k2 = jax.random.split(jax.random.key(3))
Xs = jax.random.normal(k1, (4, 3))    # M=4
Ys = jax.random.normal(k2, (5, 3))    # N=5

G = jax.vmap(jax.vmap(jnp.dot, in_axes=(None, 0)), in_axes=(0, None))(Xs, Ys)
G2 = jax.vmap(jax.vmap(jnp.dot, in_axes=(0, None)), in_axes=(None, 0))(Xs, Ys)
print("G:", G.shape, "| 交换顺序后 G2:", G2.shape, "| G2 == G.T:", jnp.allclose(G2, G.T))
print("等于 X @ Y.T:", jnp.allclose(G, Xs @ Ys.T, atol=1e-5))

data = jnp.ones((2, 3, 4))            # (B, T, d)
print("双重批维度,逐位置求范数:", jax.vmap(jax.vmap(jnp.linalg.norm))(data).shape)
G: (4, 5) | 交换顺序后 G2: (5, 4) | G2 == G.T: True
等于 X @ Y.T: True
双重批维度,逐位置求范数: (2, 3)

🔍 补充:距离矩阵的数值稳定性,以及 sqrt 在 0 处的梯度陷阱

⚪ 选读:要对距离矩阵求梯度,或手写展开公式时读。

前面用的是“距离的平方”,所以梯度没问题。但 4.8 的练习用的是真正的欧氏距离 $\sqrt{\cdot}$,这时要注意两点:

  1. 数值稳定性:手工向量化时常用展开式 $\lVert x\rVert^2 + \lVert y\rVert^2 - 2x^\top y$。由于浮点抵消,它可能算出很小的负数,开方后变成 nan,所以要 clip。vmap 版本直接算 $x - y$,没有这个问题(代价是会生成 $(M, N, d)$ 的中间数组,见 4.7 关于内存的讨论)。
  2. 距离为 0 处的梯度是 nan:$\frac{\partial}{\partial x}\sqrt{s} = \frac{x-y}{\sqrt{s}}$,$x=y$ 时是 $0/0$,比如对一组点和自己的距离矩阵(对角线为 0)求梯度。解决办法是“双 where”,原理和通用写法见 第 3 章 3.8 补充「NaN 梯度与双 where」,进阶内容见 07 专题 2。
def dist(x, y):
    return jnp.sqrt(jnp.sum((x - y) ** 2))

pdist = jax.vmap(jax.vmap(dist, in_axes=(None, 0)), in_axes=(0, None))
D = pdist(Xs, Ys)

# 对照:广播写法(展开公式),需要 clip 防止出现负数
sq = jnp.sum(Xs**2, 1)[:, None] + jnp.sum(Ys**2, 1)[None, :] - 2 * Xs @ Ys.T
D_ref = jnp.sqrt(jnp.clip(sq, 0.0))
assert jnp.allclose(D, D_ref, atol=1e-4)
print("距离矩阵形状:", D.shape, "| 与展开公式一致")

# 梯度陷阱:x == y 时梯度为 nan(修法“双 where”见第 3 章 3.8)
g_naive = jax.grad(dist)(jnp.ones(3), jnp.ones(3))
print("朴素 dist 在 x==y 处的梯度:", g_naive)
assert jnp.all(jnp.isnan(g_naive))
距离矩阵形状: (4, 5) | 与展开公式一致
朴素 dist 在 x==y 处的梯度: [nan nan nan]

🔍 补充:用 vmap 实现批量矩阵乘法,并与 jnp.einsum、@ 对比

⚪ 选读:在 vmap、einsum、@ 之间选写法时读。

对一批矩阵 $A_b \in \mathbb{R}^{n\times k}$、$B_b \in \mathbb{R}^{k\times m}$ 分别求 $A_b B_b$,有三种写法:

  • jax.vmap(jnp.matmul)(A, B):只写单个矩阵乘,批处理交给 vmap;
  • jnp.einsum('bnk,bkm->bnm', A, B):用下标把维度关系写清楚;
  • A @ B:matmul 本身就会把前导维度当作批维度。

三者在 jaxpr 层面都是同一个带批维度的 dot_general(下方用 assert 逐字比对了三者的 jaxpr),所以速度基本一样,计时里零点几毫秒以内的差异只是测量噪声。选哪个主要看可读性:维度多、需要转置和收缩时,einsum 更清楚;函数本身很复杂(不只是一个矩阵乘)时,vmap 的优势最大。

k1, k2 = jax.random.split(jax.random.key(4))
Ab = jax.random.normal(k1, (64, 128, 96))
Bb = jax.random.normal(k2, (64, 96, 80))

bmm_vmap = jax.jit(jax.vmap(jnp.matmul))
bmm_einsum = jax.jit(lambda a, b: jnp.einsum("bnk,bkm->bnm", a, b))
bmm_at = jax.jit(lambda a, b: a @ b)

r1, r2, r3 = bmm_vmap(Ab, Bb), bmm_einsum(Ab, Bb), bmm_at(Ab, Bb)
assert jnp.allclose(r1, r2, atol=1e-3) and jnp.allclose(r1, r3, atol=1e-3)
print("结果形状:", r1.shape, "| 三者一致")
for name, fn in [("vmap(matmul)", bmm_vmap), ("einsum", bmm_einsum), ("a @ b", bmm_at)]:
    print(f"{name:14s} {bench(fn, Ab, Bb):.3f} ms")

# 看 jaxpr:都是带批维度的 dot_general
jp_bmm = [jaxpr_str(jax.make_jaxpr(f)(Ab[:2], Bb[:2])) for f in
          (jax.vmap(jnp.matmul), lambda a, b: jnp.einsum("bnk,bkm->bnm", a, b), lambda a, b: a @ b)]
assert jp_bmm[0] == jp_bmm[1] == jp_bmm[2]    # 验证:三种写法的 jaxpr 逐字相同
print(jax.make_jaxpr(jax.vmap(jnp.matmul))(Ab[:2], Bb[:2]))
print(jax.make_jaxpr(lambda a, b: jnp.einsum("bnk,bkm->bnm", a, b))(Ab[:2], Bb[:2]))
结果形状: (64, 128, 80) | 三者一致
vmap(matmul)   0.537 ms
einsum         0.472 ms
a @ b          0.479 ms
{ lambda ; a:f32[2,128,96] b:f32[2,96,80]. let
    c:f32[2,128,80] = dot_general[
      dimension_numbers=(([2], [1]), ([0], [0]))
      preferred_element_type=float32
    ] a b
  in (c,) }
{ lambda ; a:f32[2,128,96] b:f32[2,96,80]. let
    c:f32[2,128,80] = dot_general[
      dimension_numbers=(([2], [1]), ([0], [0]))
      preferred_element_type=float32
    ] a b
  in (c,) }

4.6 结合 vmap 与 jit 和 grad

JAX 的一大优势是函数变换的可组合性:jit(编译)、grad(求导)、vmap(向量化)都是接收函数、返回函数的变换,可以随意组合。组合起来,就能对一批数据求梯度,并把整个计算编译成能在加速器上高效运行的代码。

1. vmap + jit。 推荐写法是 jit(vmap(f))。vmap(f) 先把 f 里的基本操作改写成批量操作,jit 再用 XLA 把这个批量函数整体编译成针对 CPU/GPU/TPU 优化的内核。vmap(jit(f)) 也能用:jit 在 JAX 内部本身就是一个基本操作,vmap 会把批维度推进到 jit 内部,编译出来的计算内核和 jit(vmap(f)) 基本相同。区别只在 Python 端:vmap(jit(f)) 最外层是“即时执行的 vmap”,每次调用都要在 Python 里重新做一遍批处理变换;jit(vmap(f)) 只在第一次调用时追踪。所以把 jit 放在最外层,开销最小。

⚠️ 注意:容易以为 vmap(jit(f)) 是“先编译内部的 f,再对已编译函数的调用做向量化,向量化逻辑在编译内核之外,所以效率可能较低”。实际上 jaxpr 显示,批维度被推进到了 jit 内部(jit[...] 里已经是整批运算)。大批量时两种顺序耗时相同;小批量时 vmap(jit(f)) 稍慢,原因是 Python 端的开销,而不是“向量化在内核之外”(下方有 jaxpr 和计时)。

2. vmap + grad。 机器学习里经常要对一批样本、而不是单个样本求梯度。做法是先用 grad 得到单样本梯度函数,再用 vmap 向量化:vmap(grad(loss), in_axes=(None, 0, 0))(参数共享,数据按样本映射)。逐样本梯度会带上 vmap 加的批次维度。

更常见的需求是批次平均梯度。有两种做法:先算逐样本梯度再取平均,或者先定义批次平均损失再求梯度。两者在数学上等价:

$$ \nabla_\theta \frac{1}{N}\sum_i \ell(\theta; x_i, y_i) = \frac{1}{N}\sum_i \nabla_\theta \ell(\theta; x_i, y_i) $$

怎么选:确实需要逐样本结果时,用 vmap(grad(f), ...),意图更清楚。只需要批次平均梯度时,用 grad(mean_loss_fn),这是梯度下降的标准做法,而且不会生成“批大小 × 参数量”的中间结果。注意 grad 要求输出是标量,所以不能直接写 grad(vmap(loss)),要先求和或求平均。

3. vmap + grad + jit。 这是在 JAX 里高效训练模型的基本模式,分四步:定义损失 → grad 求梯度 → vmap 向量化 → jit 编译。

  • 批次平均梯度:jit(grad(mean_loss_fn));
  • 逐样本梯度:jit(vmap(grad(single_loss_fn)))。

注意事项:

  • 调试:变换一层层叠起来后,报错的回溯信息可能会深入 JAX 内部。可以用 jax.debug.print,或者暂时去掉 jit 来定位问题;
  • 内存:vmap 处理非常大的批次时可能占用大量内存,在显存有限的 GPU/TPU 上尤其要注意批大小(逐样本梯度的内存账见本节专题);
  • 顺序:变换的应用顺序对正确性和性能都有影响。

随机数:本节示例要生成随机数据。JAX 0.12 推荐用 jax.random.key(seed) 创建带类型的 key,并且每个 key 只用一次。需要多组随机数时,先 jax.random.split。

⚠️ 注意:旧代码里常见用 jax.random.PRNGKey(42) 创建 key,再用同一个 key 先后生成 uniform 的 x 和 normal 的噪声。PRNGKey 是旧式写法;更要紧的是,这样得到的两组随机数高度相关(线性相关系数约 0.98,秩相关系数为 1),“噪声”根本不独立(下方 ❌/✅ 对照用 assert 验证)。

课程代码

import time

# 简单线性模型
def predict(params, x):
    w, b = params
    return w * x + b

params = (jnp.array(2.0), jnp.array(1.0))     # (w, b)
batch_x = jnp.arange(10000.0)

# 在 batch_x 的第 0 轴上映射,params 保持不变
batched_predict_vmap = jax.vmap(predict, in_axes=(None, 0))
jitted_batched_predict = jax.jit(batched_predict_vmap)

t0 = time.perf_counter()
result_vmap = batched_predict_vmap(params, batch_x).block_until_ready()
print(f"仅 vmap 的执行时间: {(time.perf_counter() - t0) * 1e3:.3f} ms")

t0 = time.perf_counter()
result_jit_vmap = jitted_batched_predict(params, batch_x).block_until_ready()
print(f"jit(vmap) 执行时间(首次运行,含编译): {(time.perf_counter() - t0) * 1e3:.3f} ms")

t0 = time.perf_counter()
result_jit_vmap = jitted_batched_predict(params, batch_x).block_until_ready()
print(f"jit(vmap) 执行时间(第二次运行): {(time.perf_counter() - t0) * 1e3:.3f} ms")

assert jnp.allclose(result_vmap, result_jit_vmap)
print("结果匹配")

# 上面的单次计时只为展示“首次运行含编译”;比较稳定耗时要预热后多次取平均
print(f"预热后平均: vmap {bench(batched_predict_vmap, params, batch_x):.3f} ms | "
      f"jit(vmap) {bench(jitted_batched_predict, params, batch_x):.3f} ms")
仅 vmap 的执行时间: 34.492 ms
jit(vmap) 执行时间(首次运行,含编译): 16.856 ms
jit(vmap) 执行时间(第二次运行): 0.235 ms
结果匹配
预热后平均: vmap 0.293 ms | jit(vmap) 0.030 ms
# grad 与 vmap:逐样本梯度
def squared_error(params, x, y_true):
    w, b = params
    y_pred = w * x + b
    loss = (y_pred - y_true)**2
    return loss

# 单个 (x, y_true) 的梯度函数:返回 (dL/dw, dL/db)
grad_loss_single = jax.grad(squared_error, argnums=0)

params = (2.0, 1.0)
batch_x = jnp.linspace(0, 1, 5)
batch_y_true = 2.5 * batch_x + 0.5                # 真实关系:w=2.5, b=0.5

grad_loss_batch = jax.vmap(grad_loss_single, in_axes=(None, 0, 0))
batch_gradients = grad_loss_batch(params, batch_x, batch_y_true)

print("params:", params)
print("batch_x:", batch_x)
print("batch_y_true:", batch_y_true)
print("逐样本 dw:", batch_gradients[0], batch_gradients[0].shape)   # (5,)
print("逐样本 db:", batch_gradients[1], batch_gradients[1].shape)   # (5,)
# 解析解核对:dL/dw = 2 (w x + b - y) x,dL/db = 2 (w x + b - y)
resid = 2.0 * batch_x + 1.0 - batch_y_true
assert jnp.allclose(batch_gradients[0], 2 * resid * batch_x) and jnp.allclose(batch_gradients[1], 2 * resid)
params: (2.0, 1.0)
batch_x: [0.   0.25 0.5  0.75 1.  ]
batch_y_true: [0.5   1.125 1.75  2.375 3.   ]
逐样本 dw: [0.     0.1875 0.25   0.1875 0.    ] (5,)
逐样本 db: [1.   0.75 0.5  0.25 0.  ] (5,)
# 批次平均损失 -> 平均梯度
def mean_squared_error(params, batch_x, batch_y_true):
    batch_predict = jax.vmap(predict, in_axes=(None, 0))
    predictions = batch_predict(params, batch_x)
    return jnp.mean((predictions - batch_y_true)**2)

grad_mean_loss = jax.grad(mean_squared_error, argnums=0)
mean_batch_gradient = grad_mean_loss(params, batch_x, batch_y_true)
print("平均梯度:", mean_batch_gradient)
print("形状:", jax.tree.map(jnp.shape, mean_batch_gradient))        # 每个分量都是标量 ()

# 平均梯度 == 逐样本梯度的平均
assert jnp.allclose(mean_batch_gradient[0], jnp.mean(batch_gradients[0]))
assert jnp.allclose(mean_batch_gradient[1], jnp.mean(batch_gradients[1]))
print("✔ 平均损失的梯度 == 逐样本梯度的平均")
平均梯度: (Array(0.125, dtype=float32, weak_type=True), Array(0.5, dtype=float32, weak_type=True))
形状: ((), ())
✔ 平均损失的梯度 == 逐样本梯度的平均
# ❌ 旧写法(过时):PRNGKey 是旧式原始 key;且同一个 key 被用于两次采样,x 与“噪声”高度相关
key = jax.random.PRNGKey(42)
print("PRNGKey 实测:", key.dtype, key.shape, "(原始 uint32 数组,不是带类型的 key)")
large_batch_size = 100000
large_batch_x_bad = jax.random.uniform(key, (large_batch_size,))
noise_bad = jax.random.normal(key, (large_batch_size,))          # 又用了同一个 key
rank = lambda a: np.argsort(np.argsort(np.asarray(a)))
corr = np.corrcoef(np.asarray(large_batch_x_bad), np.asarray(noise_bad))[0, 1]
rank_corr = np.corrcoef(rank(large_batch_x_bad), rank(noise_bad))[0, 1]
print(f"实测:x 与噪声的线性相关系数 {corr:.4f},秩相关系数 {rank_corr:.4f}")
assert corr > 0.95 and rank_corr > 0.999          # 验证:同一个 key 生成的 uniform 与 normal 高度相关
PRNGKey 实测: uint32 (2,) (原始 uint32 数组,不是带类型的 key)
实测:x 与噪声的线性相关系数 0.9775,秩相关系数 1.0000
# ✅ 推荐写法:jax.random.key 创建带类型的 key,先 split 再分别使用
key = jax.random.key(42)
key_x, key_noise = jax.random.split(key)
print("jax.random.key 实测:", key.dtype, key.shape)
large_batch_x = jax.random.uniform(key_x, (large_batch_size,))
noise = jax.random.normal(key_noise, (large_batch_size,))
corr_ok = np.corrcoef(np.asarray(large_batch_x), np.asarray(noise))[0, 1]
print(f"split 之后的相关系数: {corr_ok:.4f}")
assert abs(corr_ok) < 0.02                        # 独立:相关系数接近 0
large_batch_y_true = 2.5 * large_batch_x + 0.5 + 0.1 * noise
jax.random.key 实测: key<fry> ()
split 之后的相关系数: -0.0015
# 课程代码(续):jit(vmap(grad(f))),大批量的逐样本梯度(数据来自上面的推荐写法)
vmap_grad_loss = jax.vmap(grad_loss_single, in_axes=(None, 0, 0))
jit_vmap_grad_loss = jax.jit(vmap_grad_loss)
params = (2.0, 1.0)

t0 = time.perf_counter()
per_example_grads = jit_vmap_grad_loss(params, large_batch_x, large_batch_y_true)
jax.block_until_ready(per_example_grads)
print(f"首次运行(含编译): {(time.perf_counter() - t0) * 1e3:.2f} ms")

t0 = time.perf_counter()
per_example_grads = jit_vmap_grad_loss(params, large_batch_x, large_batch_y_true)
jax.block_until_ready(per_example_grads)
print(f"第二次运行: {(time.perf_counter() - t0) * 1e3:.2f} ms")
print("dw 形状:", per_example_grads[0].shape, "| db 形状:", per_example_grads[1].shape)
assert per_example_grads[0].shape == per_example_grads[1].shape == (large_batch_size,)
首次运行(含编译): 47.93 ms
第二次运行: 4.92 ms
dw 形状: (100000,) | db 形状: (100000,)

🔍 补充:验证 vmap(jit(f)) 中批维度被推进到 jit 内部

⚪ 选读:想用 jaxpr 亲眼验证 vmap(jit(f)) 的行为时读。

f_feat = lambda x: jnp.sin(x) @ jnp.cos(x)
jp = jaxpr_str(jax.make_jaxpr(jax.vmap(jax.jit(f_feat)))(jnp.ones((4, 3))))
print(jp)
# 验证:jit 内部已经是整批 (4, 3) 的运算
inner = jp[jp.index("jaxpr={"):]
assert "f32[4,3] = sin" in inner and "f32[4] = dot_general" in inner

for shape in [(512, 256), (4096, 1024)]:
    xb = jax.random.normal(jax.random.key(6), shape)
    a_ = jax.jit(jax.vmap(f_feat))
    b_ = jax.vmap(jax.jit(f_feat))
    assert jnp.allclose(a_(xb), b_(xb), rtol=1e-4, atol=1e-3)
    print(f"批 {shape}: jit(vmap(f)) {bench(a_, xb):.3f} ms | vmap(jit(f)) {bench(b_, xb):.3f} ms")
{ lambda ; a:f32[4,3]. let
    b:f32[4] = jit[
      name=<lambda>
      jaxpr={ lambda ; a:f32[4,3]. let
          c:f32[4,3] = sin a
          d:f32[4,3] = cos a
          b:f32[4] = dot_general[
            dimension_numbers=(([1], [1]), ([0], [0]))
            preferred_element_type=float32
          ] c d
        in (b,) }
    ] a
  in (b,) }
批 (512, 256): jit(vmap(f)) 0.098 ms | vmap(jit(f)) 0.249 ms
批 (4096, 1024): jit(vmap(f)) 1.261 ms | vmap(jit(f)) 1.354 ms

🔍 专题:逐样本梯度(per-example gradients)

🟢 必读

原理。 设单样本损失为 $\ell_i(\theta) = \ell(\theta; x_i, y_i)$,逐样本梯度就是 $g_i = \nabla_\theta \ell_i(\theta)$。

  • jax.grad(loss_single) 为一个样本构造一段“前向 + 反向(VJP)”程序;
  • jax.vmap(..., in_axes=(None, 0, 0)) 再把这整段程序批处理:参数 $\theta$ 共享(None),数据按样本映射。因为每个样本的梯度取决于各自的 $x_i$,输出的每个梯度叶子都会多出一个批维度,形状从 p.shape 变成 (B, *p.shape);
  • 换个角度看:把 $B$ 个损失排成向量 $L(\theta) \in \mathbb{R}^B$,逐样本梯度就是雅可比矩阵 $J = \partial L / \partial \theta \in \mathbb{R}^{B \times P}$(和 jax.jacrev 的结果相同)。普通的 grad(mean_loss) 只算 $\frac{1}{B}\mathbf{1}^\top J$,也就是一次 VJP,批维度在反向传播里直接被求和消掉,所以内存小得多。

在 PyTorch 里,逐样本梯度早年要靠 Opacus、functorch 这些专门的库(functorch 后来并入了 torch.func,写法同样是 vmap(grad(...)))。在 JAX 里,它从一开始就是两个基本变换的组合,一行 vmap(grad(loss)) 就够了。下面用一个两层 MLP 回归模型,把原理、对拍、计时、应用和内存依次走一遍。

# 准备:两层 MLP + 回归数据,并故意把 5 个样本的标签改坏(后面用来找异常样本)
def init_mlp_params(key, d_in, d_hidden):
    k1, k2 = jax.random.split(key)
    return {"W1": jax.random.normal(k1, (d_hidden, d_in)) / jnp.sqrt(d_in), "b1": jnp.zeros(d_hidden),
            "w2": jax.random.normal(k2, (d_hidden,)) / jnp.sqrt(d_hidden), "b2": jnp.array(0.0)}

def mlp_loss(p, x, y):                               # 单样本损失(标量)
    h = jnp.tanh(p["W1"] @ x + p["b1"])
    return (jnp.dot(p["w2"], h) + p["b2"] - y) ** 2

B, D_IN, H = 256, 8, 32
kp, kx, kn = jax.random.split(jax.random.key(100), 3)
pe_params = init_mlp_params(kp, D_IN, H)
X_pe = jax.random.normal(kx, (B, D_IN))
Y_pe = jnp.sin(X_pe[:, 0]) + 0.1 * jax.random.normal(kn, (B,))
bad_idx = jnp.array([3, 50, 99, 150, 222])           # 被“标错”的样本
Y_pe = Y_pe.at[bad_idx].add(20.0)

P = sum(l.size for l in jax.tree.leaves(pe_params))
print("参数量 P =", P, "| 批大小 B =", B)
参数量 P = 321 | 批大小 B = 256
# 1) 对拍:vmap(grad) vs Python 循环逐个求梯度,并与 jacrev(雅可比视角)比较
per_ex_grad = jax.jit(jax.vmap(jax.grad(mlp_loss), in_axes=(None, 0, 0)))
single_grad = jax.jit(jax.grad(mlp_loss))

def loop_per_ex_grad(p, X, Y):
    gs = [single_grad(p, X[i], Y[i]) for i in range(X.shape[0])]
    return jax.tree.map(lambda *t: jnp.stack(t), *gs)

g_vmap = per_ex_grad(pe_params, X_pe, Y_pe)
g_loop = loop_per_ex_grad(pe_params, X_pe, Y_pe)
g_jac = jax.jacrev(lambda p: jax.vmap(mlp_loss, in_axes=(None, 0, 0))(p, X_pe, Y_pe))(pe_params)

tree_allclose = lambda a, b, tol=1e-5: all(jax.tree.leaves(jax.tree.map(lambda u, v: bool(jnp.allclose(u, v, atol=tol)), a, b)))
assert tree_allclose(g_vmap, g_loop)
assert tree_allclose(g_vmap, g_jac)
print("逐样本梯度形状:", jax.tree.map(jnp.shape, g_vmap))
print("✔ vmap(grad) == 循环逐个求梯度 == jacrev")

t_loop = bench(lambda p, X, Y: loop_per_ex_grad(p, X, Y)["W1"], pe_params, X_pe, Y_pe, n=2)
t_vmap = bench(lambda p, X, Y: per_ex_grad(p, X, Y)["W1"], pe_params, X_pe, Y_pe)
print(f"Python 循环: {t_loop:.2f} ms | jit(vmap(grad)): {t_vmap:.3f} ms | 加速约 {t_loop / t_vmap:.0f} 倍")
逐样本梯度形状: {'W1': (256, 32, 8), 'b1': (256, 32), 'b2': (256,), 'w2': (256, 32)}
✔ vmap(grad) == 循环逐个求梯度 == jacrev
Python 循环: 33.46 ms | jit(vmap(grad)): 0.054 ms | 加速约 617 倍
# 2) 逐样本梯度的均值 == 平均损失的梯度
def mean_mlp_loss(p, X, Y):
    return jnp.mean(jax.vmap(mlp_loss, in_axes=(None, 0, 0))(p, X, Y))

g_mean_loss = jax.jit(jax.grad(mean_mlp_loss))(pe_params, X_pe, Y_pe)
g_mean_of_per_ex = jax.tree.map(lambda t: t.mean(axis=0), g_vmap)
assert tree_allclose(g_mean_loss, g_mean_of_per_ex)
print("✔ mean_i g_i == grad(mean loss)")

# 对照:grad(vmap(loss)) 不能直接用——输出不是标量
try:
    jax.grad(jax.vmap(mlp_loss, in_axes=(None, 0, 0)))(pe_params, X_pe, Y_pe)
    raised = False
except TypeError as e:
    raised = True
    print("grad(vmap(loss)) -> TypeError:", str(e)[:90])
assert raised
✔ mean_i g_i == grad(mean loss)
grad(vmap(loss)) -> TypeError: Gradient only defined for scalar-output functions. Output had shape: (256,). To get the gr
# 3) 应用 A:DP-SGD 式裁剪 —— 每个样本的梯度先按全局 L2 范数裁剪到 C,再平均并加高斯噪声
def per_example_norms(g):
    # 每个叶子展平到 (B, -1) 后求平方和,再把各叶子相加
    return jnp.sqrt(sum(jnp.sum(l.reshape(l.shape[0], -1) ** 2, axis=1) for l in jax.tree.leaves(g)))

def clip_per_example(g, C):
    scale = jnp.minimum(1.0, C / (per_example_norms(g) + 1e-12))
    return jax.tree.map(lambda l: l * scale.reshape((-1,) + (1,) * (l.ndim - 1)), g)

C, sigma = 1.0, 0.5
g_clipped = clip_per_example(g_vmap, C)
norms_before, norms_after = per_example_norms(g_vmap), per_example_norms(g_clipped)
assert jnp.all(norms_after <= C + 1e-5)                            # 裁剪后每个样本的范数都不超过 C
assert jnp.allclose(norms_after, jnp.minimum(norms_before, C), atol=1e-4)

leaves, treedef = jax.tree.flatten(g_clipped)
noise_keys = jax.random.split(jax.random.key(7), len(leaves))      # 每个叶子用独立的 key
dp_grad = jax.tree.unflatten(treedef, [l.mean(0) + sigma * C / B * jax.random.normal(k, l.shape[1:])
                                       for l, k in zip(leaves, noise_keys)])
print(f"裁剪前最大范数 {norms_before.max():.2f} -> 裁剪后最大范数 {norms_after.max():.2f}")
print("DP 梯度的形状与参数相同:", jax.tree.map(jnp.shape, dp_grad) == jax.tree.map(jnp.shape, pe_params))
裁剪前最大范数 202.05 -> 裁剪后最大范数 1.00
DP 梯度的形状与参数相同: True
# 4) 应用 B:用逐样本梯度范数找出异常样本(这里是被“标错”的 5 个样本)
norms = per_example_norms(g_vmap)
top5 = jnp.argsort(norms)[-5:]
print("梯度范数最大的 5 个样本:", jnp.sort(top5), "| 真正标错的样本:", bad_idx)
print("异常样本范数中位数 %.2f vs 正常样本范数中位数 %.2f" % (
    jnp.median(norms[bad_idx]), jnp.median(jnp.delete(norms, bad_idx))))
assert set(np.asarray(top5).tolist()) == set(np.asarray(bad_idx).tolist())
梯度范数最大的 5 个样本: [  3  50  99 150 222] | 真正标错的样本: [  3  50  99 150 222]
异常样本范数中位数 166.16 vs 正常样本范数中位数 6.04
# 5) 应用 C:per-example loss 加权 —— 加权损失的梯度 == 逐样本梯度的加权和
weights = jnp.ones(B).at[bad_idx].set(0.0)            # 例:把找到的异常样本权重设为 0
weights = weights / weights.sum()

def weighted_loss(p, X, Y, w):
    return jnp.sum(w * jax.vmap(mlp_loss, in_axes=(None, 0, 0))(p, X, Y))

g_weighted = jax.jit(jax.grad(weighted_loss))(pe_params, X_pe, Y_pe, weights)
g_weighted_from_per_ex = jax.tree.map(lambda l: jnp.tensordot(weights, l, axes=1), g_vmap)
assert tree_allclose(g_weighted, g_weighted_from_per_ex)
print("✔ grad(sum_i w_i * loss_i) == sum_i w_i * g_i")
print("去掉异常样本前后,平均梯度范数: %.3f -> %.3f" % (
    per_example_norms(jax.tree.map(lambda l: l[None], g_mean_loss))[0],
    per_example_norms(jax.tree.map(lambda l: l[None], g_weighted))[0]))
✔ grad(sum_i w_i * loss_i) == sum_i w_i * g_i
去掉异常样本前后,平均梯度范数: 3.401 -> 2.557

内存注意事项。 逐样本梯度的总大小 = 批大小 $B$ × 参数量 $P$(float32 时每个元素 4 字节),而普通的平均梯度只有 $P$。比如一个 1 亿参数的模型、批大小 32,逐样本梯度就要 $32 \times 10^8 \times 4\ \text{B} \approx 12.8\ \text{GB}$。常见的应对办法:

  • 只需要逐样本范数(如 DP-SGD 裁剪、异常检测)时,用 lax.map(..., batch_size=k) 分块计算,每块只保留范数,不保留整份梯度;
  • 分块计算裁剪后的梯度和,再累加;
  • 大模型可以了解一下 “ghost clipping” 这类不显式生成逐样本梯度的技巧。
# 6) 内存:逐样本梯度大小 = B × P;对比编译后的输出与临时内存
n_elems = sum(l.size for l in jax.tree.leaves(g_vmap))
assert n_elems == B * P
print(f"逐样本梯度: {n_elems} 个元素 = B({B}) × P({P}) ≈ {n_elems * 4 / 2**20:.2f} MiB;平均梯度只有 {P * 4 / 2**10:.2f} KiB")

for name, fn in [("jit(vmap(grad))", per_ex_grad), ("jit(grad(mean))", jax.jit(jax.grad(mean_mlp_loss)))]:
    ma = fn.lower(pe_params, X_pe, Y_pe).compile().memory_analysis()
    print(f"{name:16s} 输出 {ma.output_size_in_bytes / 2**10:8.1f} KiB | 临时 {ma.temp_size_in_bytes / 2**10:8.1f} KiB")

# 只要范数时:lax.map 分块计算,每块只返回范数,峰值内存与块大小成正比
@jax.jit
def norms_chunked(p, X, Y):
    one = lambda xy: per_example_norms(jax.tree.map(lambda l: l[None], jax.grad(mlp_loss)(p, xy[0], xy[1])))[0]
    return lax.map(one, (X, Y), batch_size=32)

nc = norms_chunked(pe_params, X_pe, Y_pe)
assert jnp.allclose(nc, norms, rtol=1e-4, atol=1e-5)
ma = norms_chunked.lower(pe_params, X_pe, Y_pe).compile().memory_analysis()
print(f"lax.map 分块只算范数: 输出 {ma.output_size_in_bytes / 2**10:.1f} KiB | 临时 {ma.temp_size_in_bytes / 2**10:.1f} KiB,结果一致 ✔")
逐样本梯度: 82176 个元素 = B(256) × P(321) ≈ 0.31 MiB;平均梯度只有 1.25 KiB
jit(vmap(grad))  输出    321.0 KiB | 临时      0.0 KiB
jit(grad(mean))  输出      1.3 KiB | 临时     65.0 KiB
lax.map 分块只算范数: 输出 1.0 KiB | 临时 9.4 KiB,结果一致 ✔

🔍 补充:vmap 与随机数——对 key 做映射

🟢 必读

如果想让每个样本用不同的随机数,先 jax.random.split(key, n) 得到 n 个 key,再把 key 当作被映射的参数传给 vmap。千万别让所有样本共享同一个 key(in_axes=None),否则每个样本拿到的“随机数”完全相同。更多 key 管理的写法(fold_in、scan carry 等)见 进阶 10 §5。

def noisy_sample(key, mu):
    return mu + jax.random.normal(key, (2,))

keys = jax.random.split(jax.random.key(7), 4)
good = jax.vmap(noisy_sample, in_axes=(0, None))(keys, 0.0)
bad = jax.vmap(noisy_sample, in_axes=(None, 0))(jax.random.key(7), jnp.zeros(4))
print("每个样本用不同的 key:\n", good)
print("错误示范,共享同一个 key:\n", bad)
assert len(np.unique(np.asarray(good[:, 0]))) == 4      # 4 个样本各不相同
assert jnp.all(bad == bad[0])                            # 共享 key:4 个样本完全相同
每个样本用不同的 key:
 [[ 1.1140472   0.14163129]
 [ 0.4541335   0.9391628 ]
 [-0.8203862  -0.6481581 ]
 [-0.73446035 -0.47739786]]
错误示范,共享同一个 key:
 [[0.45123515 1.9534509 ]
 [0.45123515 1.9534509 ]
 [0.45123515 1.9534509 ]
 [0.45123515 1.9534509 ]]

旧教程里常见的顶层别名 jax.tree_map,在 JAX 0.12 中已经移除(jax.tree_util.tree_map 还能用),本教程统一用 jax.tree.map。完整的新旧对照见 第 6 章 6.4 补充「PyTree API 迁移表」。

# ❌ 旧写法(过时):jax.tree_map 已在 JAX 0.12 中移除
try:
    jax.tree_map(jnp.shape, g_vmap)
    removed = False
except AttributeError as e:
    removed = True
    print("实测 AttributeError:", str(e)[:120])
assert removed
实测 AttributeError: module 'jax' has no attribute 'tree_map'
# ✅ 推荐写法:jax.tree.map
print(jax.tree.map(jnp.shape, g_vmap))
{'W1': (256, 32, 8), 'b1': (256, 32), 'b2': (256,), 'w2': (256, 32)}

4.7 vmap 的性能考量

vmap 是向量化函数的便捷工具,但它不是万能的,也不一定比所有其他写法都快。它在很多常见场景下表现很好,不过性能和内存两方面的代价也要心里有数。

vmap 的工作方式(简述)。 Python for 循环每次迭代都要执行 Python 字节码。vmap 则在执行之前就对函数做变换(追踪一次,再套用批处理规则),把“循环”沉到 JAX 内部,让 XLA 能为整个批量操作生成高度优化的并行代码(在 GPU/TPU 上尤其明显),这样就省掉了重复的 Python 调用和解释器开销。

vmap 与手动向量化。 有两种选择:

  • 手动向量化:用 jax.numpy 重写函数,直接处理批量数组;
  • vmap:为单个样本写函数,交给 vmap 处理批次维度。

配合 jit 时,两者的速度基本没差别,因为 vmap 的批处理规则生成的就是普通的批量运算。对下面的逐元素加法,两者的 jaxpr 甚至逐字相同。对一般的函数,生成的程序可能在细节上和手写版本不同(比如转置放在乘法前面还是后面,下方用 assert 演示了一例),但计算量相当。只有在不 jit 的时候,vmap 每次调用都要在 Python 里重新做一遍变换,才会多出一点固定开销。vmap 真正的价值,在于那些不容易手动向量化的函数:维度复杂、层层嵌套,或者带有用 jnp.where / lax.cond / lax.fori_loop 等写成的控制流。

⚠️ 注意:常听到“手动向量化通常和 vmap 一样快、甚至略快,因为 vmap 有少量变换开销”。这只在没有 jit 时成立。本例中两者的 jaxpr 逐字相同(下方用 assert 验证),jit 之后速度也一样。

vmap 与控制流。 vmap 能处理的控制流有两类:一是静态的 Python 控制流(比如循环次数固定的 for,追踪时会被展开),二是 jnp.where、lax.cond、lax.while_loop 这类 JAX 控制流。依赖批数据取值的 Python if/else 没法被 vmap,会抛出 TracerBoolConversionError。

⚠️ 注意:容易以为 vmap 能直接向量化“包含 Python 控制流(if/else、逐元素 for 循环)的函数”,一次追踪就能为包括控制流在内的整个函数生成批量代码。实际上,依赖数据的 Python if 在 vmap 下会直接报错,必须改写成 jnp.where / lax.cond(下方 ❌/✅ 对照)。

vmap 与 Python 循环。 用 Python 循环反复调用 JAX 函数,解释器开销很大,JAX 也没法跨迭代优化。vmap 把计算表达成更大的融合操作,几乎总是快得多(下方在 CPU 上测出来快两到四个数量级)。

与 jit 配合效果最好。 jax.jit(jax.vmap(f)) 先做向量化变换,再编译得到的批量函数。XLA 可以融合操作、针对硬件优化内存访问模式,并在批次维度上高效并行。编译还省掉了从 Python 发起大量小计算的开销。课程配了一张对数刻度的条形图,比较“两个向量相加”在不同写法下的相对耗时,下方用本机测出的数据重画了这张图。实际结果取决于操作复杂度、批大小和硬件。

内存使用。 vmap 能明显提速,但和顺序循环相比,峰值内存可能更高:JAX 通常要创建能同时装下整批中间结果的数组。如果 f 会产生一个大的中间矩阵,vmap(f) 大约需要 batch_size × 每个中间矩阵的内存,而顺序执行一次只需要一个。应对办法:

  1. 减小批大小,把数据分块处理;
  2. 优化函数本身,减少中间内存;
  3. 用 jax.lax.map(f, xs, batch_size=k):块内向量化、块间顺序执行,在内存和速度之间折中(本节末尾有实测)。

⚠️ 注意:内存受限时,不必退回 Python 循环(内部函数用 jax.jit(f))。lax.map(..., batch_size=k) 的峰值内存同样可控,速度和 vmap 在同一量级,远快于 Python 循环,应该优先用它。

什么时候不用 vmap:

  • 简单、本来就是向量化的操作:手动写可能更简单,性能相当;
  • 内存非常受限:整批中间结果放不下时,改用分块或 lax.map(batch_size=...);
  • 样本之间有顺序依赖:如果第 $i$ 项依赖第 $i-1$ 项的结果,vmap 用不上,应该用 jax.lax.scan。

总结一下:vmap 和 jit 结合时尤其强大,通常比 Python 循环快很多;但要留意内存,不同写法之间用性能分析来比较。

课程代码

下面用前面定义的 bench(先预热,再用 block_until_ready() 计时)实际跑一下。

batch_size = 1000
a = jnp.ones(batch_size)
b = jnp.arange(batch_size, dtype=jnp.float32)

# 手动向量化
def manual_vectorized_add(x_batch, y_batch):
    return jnp.add(x_batch, y_batch)

# 标量函数 + vmap
def scalar_add(x, y):
    return x + y

vmapped_add = jax.vmap(scalar_add)

# Python 循环
def loop_add(x_batch, y_batch):
    results = []
    for i in range(len(x_batch)):
        results.append(scalar_add(x_batch[i], y_batch[i]))
    return jnp.stack(results)

# vmap + jit
jit_vmapped_add = jax.jit(vmapped_add)

assert jnp.allclose(manual_vectorized_add(a, b), vmapped_add(a, b))
assert jnp.allclose(loop_add(a, b), jit_vmapped_add(a, b))
timings = {
    "Python 循环": bench(loop_add, a, b, n=2),
    "手动向量化(未 jit)": bench(manual_vectorized_add, a, b),
    "vmap(未 jit)": bench(vmapped_add, a, b),
    "jit(vmap)": bench(jit_vmapped_add, a, b),
}
for k_, v_ in timings.items():
    print(f"{k_:18s} {v_:10.4f} ms")
Python 循环             77.4470 ms
手动向量化(未 jit)           0.0104 ms
vmap(未 jit)            0.1530 ms
jit(vmap)              0.0063 ms
# 对应课程中的条形图:对数刻度的相对耗时(实测数据)
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["PingFang SC", "Heiti SC", "Arial Unicode MS", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False

fig, ax = plt.subplots(figsize=(6, 3))
names, vals = list(timings.keys()), list(timings.values())
ax.barh(names, vals, color="#4C72B0")
ax.set_xscale("log")
ax.set_xlabel("每次调用耗时(ms,对数刻度)")
ax.set_title("两个长度为 1000 的向量相加(CPU)")
ax.invert_yaxis()
for i, v_ in enumerate(vals):
    ax.text(v_, i, f" {v_:.3g}", va="center")
plt.tight_layout()
plt.show()
No description has been provided for this image

在这个例子里,手动向量化和 vmap 是同一个程序(一般情况下是等价的程序):

jp_manual = jaxpr_str(jax.make_jaxpr(manual_vectorized_add)(a, b))
jp_vmap = jaxpr_str(jax.make_jaxpr(vmapped_add)(a, b))
print(jp_manual)
print(jp_vmap)
assert jp_manual == jp_vmap                       # 验证:两个 jaxpr 逐字相同
# 一般情况下不一定逐字相同:共享权重的线性层,手写 X @ W.T 与 vmap 版的转置位置不同,但结果一致
Wl, Xl = jnp.ones((3, 4)), jnp.arange(20.0).reshape(5, 4)
lin_vmap = jax.vmap(lambda W, x: W @ x, in_axes=(None, 0))
assert jaxpr_str(jax.make_jaxpr(lambda W, X: X @ W.T)(Wl, Xl)) != jaxpr_str(jax.make_jaxpr(lin_vmap)(Wl, Xl))
assert jnp.allclose(Xl @ Wl.T, lin_vmap(Wl, Xl))
jit_manual = jax.jit(manual_vectorized_add)
print(f"jit(手动): {bench(jit_manual, a, b, n=2000):.4f} ms | jit(vmap): {bench(jit_vmapped_add, a, b, n=2000):.4f} ms(微秒级差异属于计时噪声)")
{ lambda ; a:f32[1000] b:f32[1000]. let c:f32[1000] = add a b in (c,) }
{ lambda ; a:f32[1000] b:f32[1000]. let c:f32[1000] = add a b in (c,) }
jit(手动): 0.0045 ms | jit(vmap): 0.0048 ms(微秒级差异属于计时噪声)

vmap 与依赖数据的 Python if:

# ❌ 错误写法:以为 vmap 能直接处理依赖数据的 Python if/else
def piecewise_py(x):
    if x > 0:
        return x ** 2
    return -x

try:
    jax.vmap(piecewise_py)(jnp.array([-2.0, 3.0]))
    raised = False
except jax.errors.TracerBoolConversionError as e:
    raised = True
    print("实测", type(e).__name__, ":", str(e).splitlines()[0])
assert raised, "依赖批数据的 Python if 在 vmap 下应当报错"
实测 TracerBoolConversionError : Attempted boolean conversion of traced array with shape bool[].
# ✅ 推荐写法:用 jnp.where(或 lax.cond)表达依赖数据的分支;静态的 for 循环可以直接用
def piecewise_ok(x):
    return jnp.where(x > 0, x ** 2, -x)

out = jax.vmap(piecewise_ok)(jnp.array([-2.0, 3.0]))
assert jnp.array_equal(out, jnp.array([2.0, 9.0]))
print("jnp.where 版本:", out)

def static_loop(x):               # 循环次数是静态的:可以,追踪时被展开
    for _ in range(3):
        x = jnp.sin(x)
    return x
n_sin = jaxpr_str(jax.make_jaxpr(jax.vmap(static_loop))(jnp.ones(2))).count("sin")
assert n_sin == 3
print("静态 for 循环:", jax.vmap(static_loop)(jnp.array([0.5, 1.0])), "| jaxpr 中展开出", n_sin, "个 sin")
jnp.where 版本: [2. 9.]
静态 for 循环: [0.44508535 0.67843044] | jaxpr 中展开出 3 个 sin

🔍 补充:换一个计算量更大的函数再测一次

⚪ 选读:想看计算量更大时的计时对比时读。

上面的加法太简单,耗时几乎全是调用开销。下面换成一个两层小网络的前向计算,比较 Python 循环、vmap 和 jit(vmap)。

k1, k2 = jax.random.split(jax.random.key(8))
W1 = jax.random.normal(k1, (128, 128)) / jnp.sqrt(128.0)
Xp = jax.random.normal(k2, (2000, 128))

def mlp_single(W, x):                 # 单样本的小型网络前向;权重作为参数传入(原因见后面的补充)
    h = jnp.tanh(W @ x)
    return jnp.sum(jnp.tanh(W @ h) ** 2)

loop_fn = lambda W, X: jnp.stack([mlp_single(W, x) for x in X])
vmap_fn = jax.vmap(mlp_single, in_axes=(None, 0))
jit_vmap_fn = jax.jit(jax.vmap(mlp_single, in_axes=(None, 0)))

# 与 NumPy float64 参考值对拍(顺带确认没有触发下面所说的 YNN bug)
W1n, Xpn = np.asarray(W1, np.float64), np.asarray(Xp[:50], np.float64)
ref_np = np.sum(np.tanh(np.tanh(Xpn @ W1n.T) @ W1n.T) ** 2, axis=1)
assert np.allclose(np.asarray(loop_fn(W1, Xp[:50])), ref_np, rtol=1e-4, atol=1e-4)
assert np.allclose(np.asarray(jit_vmap_fn(W1, Xp[:50])), ref_np, rtol=1e-4, atol=1e-4)
t_loop = bench(loop_fn, W1, Xp, n=1)
t_vmap = bench(vmap_fn, W1, Xp)
t_jv = bench(jit_vmap_fn, W1, Xp)
print(f"Python 循环: {t_loop:9.2f} ms")
print(f"vmap:        {t_vmap:9.3f} ms")
print(f"jit(vmap):   {t_jv:9.3f} ms   (比循环快约 {t_loop / t_jv:.0f} 倍)")
Python 循环:    109.05 ms
vmap:            0.462 ms
jit(vmap):       0.303 ms   (比循环快约 359 倍)

🔍 补充(环境实测):大数组最好作为参数传入,不要被 jit 函数闭包捕获

🟢 必读

上面的计时单元特意把权重 W 当作参数传入,原因有两个:

  1. 通用原因:被 jit 函数闭包捕获的数组,会作为常量嵌进编译后的程序,让程序变大、编译变慢。而且闭包变量之后重新赋值的话,已缓存的版本还在用旧值。模型参数应该作为函数参数传入。
  2. 本环境的已知 bug:jaxlib 0.12.0.1 的 CPU 后端会把“闭包常量矩阵 W @ x 再求和”编译成悄悄算错的代码。像 jax.jit(jax.vmap(lambda x: jnp.sum(jnp.tanh(W @ x))))(W 是闭包变量)这种常见写法正好会撞上。现象、子进程复现和规避方法见 00_目录与导读「本环境已知问题」,根因分析见 进阶第 2 章 §2.3。

本章其余示例都检查过:只要在 jit 里用到大数组,都把它作为函数参数传入(如本节的 W、M、Wm,4.6 专题的 MLP 参数)。关键结果还另用 NumPy float64 参考值或解析解做了 assert 对拍。

🔍 补充:批处理下的 lax.cond 会变成 select,两个分支都会执行

🟢 必读

如果 lax.cond 的判断条件依赖被映射的数据,那么批里不同样本可能走不同的分支,而一个向量化程序没法让“一部分样本走 A、一部分走 B”。所以 vmap 会把 cond 改写成:两个分支都对整批数据计算,再用 select_n 逐元素挑选结果。

后果:

  • 计算量等于两个分支之和,cond 在这种情况下省不了计算;
  • 如果没选中的分支在某些输入上会产生 nan/inf(比如对负数求 log),前向结果不受影响。反向也不受影响,这一点和 jnp.where 不同:批处理后的 cond 会先给未选中样本的输入包上 stop_gradient,再交给分支(见下方 jaxpr 里的 stop_gradient + select_n),所以那里的无穷大导数传不回输入。手写的 jnp.where 没有这层保护,会出现 NaN 梯度(见 第 3 章 3.8);
  • 更隐蔽的是:分支用到的所有操作数都会被广播到批维度(下面会验证)。

只有判断条件不依赖被映射的数据时(比如来自 in_axes=None 的参数),cond 才会保持为真正的条件分支。

def g_cond(x):
    return lax.cond(x > 0, jnp.sin, jnp.cos, x)

print("===== 单样本:真正的 cond =====")
print(jax.make_jaxpr(g_cond)(1.0))
print("===== vmap 后(判断条件被批处理):两个分支都计算,再用 select_n 选择 =====")
jp_c = jaxpr_str(jax.make_jaxpr(jax.vmap(g_cond))(jnp.arange(3.0)))
print(jp_c)
assert "cond[" not in jp_c and "select_n" in jp_c and "sin" in jp_c and "cos" in jp_c   # 两个分支都在

# 判断条件来自共享参数(in_axes=None)时,cond 保持为 cond
g_flag = lambda x, flag: lax.cond(flag, jnp.sin, jnp.cos, x)
jaxpr_txt = jaxpr_str(jax.make_jaxpr(jax.vmap(g_flag, in_axes=(0, None)))(jnp.arange(3.0), True))
assert "cond[" in jaxpr_txt
print("条件不依赖批数据时,jaxpr 中仍包含 cond ✔")

# 与 jnp.where 不同:批处理 cond 给未选中分支的输入加了 stop_gradient,未选中的 log 分支不会让梯度变成 nan
xs_g = jnp.array([-1.0, 0.0, 2.0])
g_c = jax.vmap(jax.grad(lambda x: lax.cond(x > 0, jnp.log, lambda x: 0.0 * x, x)))(xs_g)
g_w = jax.vmap(jax.grad(lambda x: jnp.where(x > 0, jnp.log(x), 0.0)))(xs_g)
print("批处理 cond 的梯度:", g_c, "| jnp.where 的梯度:", g_w)
assert not jnp.any(jnp.isnan(g_c)) and bool(jnp.isnan(g_w[1]))
===== 单样本:真正的 cond =====
{ lambda ; a:f32[]. let
    b:bool[] = gt a 0.0:f32[]
    c:i32[] = convert_element_type[new_dtype=int32 weak_type=False] b
    d:f32[] = cond[
      branches=(
        { lambda ; e:f32[]. let f:f32[] = cos e in (f,) }
        { lambda ; g:f32[]. let h:f32[] = sin g in (h,) }
      )
    ] c a
  in (d,) }
===== vmap 后(判断条件被批处理):两个分支都计算,再用 select_n 选择 =====
{ 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] = cos 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] = sin j
    l:f32[3] = select_n c g k
  in (l,) }
条件不依赖批数据时,jaxpr 中仍包含 cond ✔
批处理 cond 的梯度: [0.  0.  0.5] | jnp.where 的梯度: [-0.   nan  0.5]
# 用计时和内存验证:昂贵分支一次都没被选中,却仍然要付出代价
M = jax.random.normal(jax.random.key(9), (256, 256)) / 16.0

def expensive(M, x):
    return lax.fori_loop(0, 30, lambda i, v: jnp.tanh(M @ v), x)

def cheap(M, x):
    return x * 2.0

def branchy(M, x):
    return lax.cond(jnp.sum(x) > 1e9, expensive, cheap, M, x)    # 条件对所有样本都为假

def branchy_where(M, x):                                          # 显式地“两个都算再选”
    return jnp.where(jnp.sum(x) > 1e9, expensive(M, x), cheap(M, x))

xs_c = jax.random.normal(jax.random.key(10), (512, 256))
fs = {name: jax.jit(jax.vmap(fn, in_axes=(None, 0)))
      for name, fn in [("cheap", cheap), ("expensive", expensive), ("cond", branchy), ("where", branchy_where)]}
assert jnp.allclose(fs["cond"](M, xs_c), fs["cheap"](M, xs_c))
mems = {}
for name, fn in fs.items():
    mem = mems[name] = fn.lower(M, xs_c).compile().memory_analysis().temp_size_in_bytes / 2**20
    print(f"vmap({name:9s}) 耗时 {bench(fn, M, xs_c, n=5):8.3f} ms | 临时内存 {mem:8.2f} MiB")

# 原因:批处理的 cond 把所有操作数(包括共享的 M)都广播到了批维度
jp = jaxpr_str(jax.make_jaxpr(jax.vmap(branchy, in_axes=(None, 0)))(M, xs_c))
assert "f32[512,256,256]" in jp and mems["cond"] > 50 * mems["expensive"]
print("✔ jaxpr 中出现了被广播的 M(f32[512,256,256]),cond 的临时内存是 expensive 的 %.0f 倍" % (mems["cond"] / mems["expensive"]))
vmap(cheap    ) 耗时    0.023 ms | 临时内存     0.00 MiB
vmap(expensive) 耗时    5.933 ms | 临时内存     1.00 MiB
vmap(cond     ) 耗时   76.144 ms | 临时内存   128.50 MiB
vmap(where    ) 耗时    5.927 ms | 临时内存     1.00 MiB
✔ jaxpr 中出现了被广播的 M(f32[512,256,256]),cond 的临时内存是 expensive 的 128 倍

结论(CPU 上的结果):批处理的 cond 不只是“两个分支都执行”。它还会把所有操作数(包括 in_axes=None 的共享权重 M)广播成带批维度的数组,于是共享的矩阵-向量乘变成了 512 个独立的矩阵乘。临时内存从约 1 MiB 涨到约 128 MiB,耗时也比直接执行昂贵分支高出一个数量级。 等价的 jnp.where 写法同样两个分支都算,但共享权重不会被广播,代价基本等于“昂贵分支 + 便宜分支”。 经验:在 vmap 内部,如果判断条件依赖批数据、分支里又用到大的共享参数,优先用 jnp.where(或者把共享计算移到 cond 外面),别想当然地以为 cond 能省计算。

🔍 补充:批处理下的 while_loop 会一直跑到“最慢的样本”结束

⚪ 选读:批内样本的迭代次数差别很大时读。

lax.while_loop 被 vmap 后,循环条件变成“批中任一样本还没结束”(对各样本的条件做 OR 归约),循环体对已经结束的样本,用 select 保持它的状态不变。所以每个样本的结果都是对的,但迭代次数由最慢的样本决定。

下面用 Collatz 序列步数演示,并在编译后的 StableHLO 里找到 reduce ... or(条件归约)和 select(保持已结束样本的状态)。

def collatz_steps(n):
    def cond_fn(s):
        return s[0] != 1
    def body_fn(s):
        v, k = s
        return jnp.where(v % 2 == 0, v // 2, 3 * v + 1), k + 1
    return lax.while_loop(cond_fn, body_fn, (n, 0))[1]

ns = jnp.array([1, 2, 3, 6, 7, 27])
steps = jax.vmap(collatz_steps)(ns)
print("各数的步数:", steps)   # 27 需要 111 步,整个批都要循环 111 次
assert steps.tolist() == [0, 1, 7, 8, 16, 111]

hlo = jax.jit(jax.vmap(collatz_steps)).lower(ns).as_text()
assert "stablehlo.or" in hlo and "stablehlo.select" in hlo
print("✔ 条件中存在跨批维度的 OR 归约;循环体用 select 保持已结束样本")
各数的步数: [  0   1   7   8  16 111]
✔ 条件中存在跨批维度的 OR 归约;循环体用 select 保持已结束样本

🔍 补充:jax.lax.map 与 vmap 在内存和速度上的取舍

⚪ 选读:vmap 内存不够(OOM)时读。

lax.map(f, xs) 的语义和 vmap(f)(xs) 相同,但底层用的是 lax.scan:按顺序逐个样本执行(这是编译进程序里的循环,不是 Python 循环)。

  • vmap:并行度最高、最快,但峰值内存随批大小线性增长;
  • lax.map:峰值内存只有单样本那么多,但完全串行,速度慢;
  • lax.map(f, xs, batch_size=k)(JAX 0.12 支持):折中方案。每次用 vmap 处理 k 个样本,块与块之间用 scan 顺序执行。如果样本数不能被 k 整除,剩下的样本会单独处理(从下面的 jaxpr 能看到)。

下面用编译后的 memory_analysis() 查看临时内存占用,并比较速度。

k1, k2 = jax.random.split(jax.random.key(11))
Wm = jax.random.normal(k1, (512, 512)) / jnp.sqrt(512.0)
Xm = jax.random.normal(k2, (4096, 512))

def heavy(W, x):                       # 单样本会产生 (512,) 的中间结果,批处理后为 (4096, 512);W 作为参数传入
    return jnp.tanh(W @ x) @ W

variants = {
    "vmap": jax.jit(jax.vmap(heavy, in_axes=(None, 0))),
    "lax.map": jax.jit(lambda W, xs: lax.map(lambda x: heavy(W, x), xs)),
    "lax.map(batch_size=256)": jax.jit(lambda W, xs: lax.map(lambda x: heavy(W, x), xs, batch_size=256)),
}
Wmn = np.asarray(Wm, np.float64)
ref = np.tanh(np.asarray(Xm, np.float64) @ Wmn.T) @ Wmn   # NumPy float64 参考值:heavy(x) = tanh(W x) @ W
temps = {}
for name, fn in variants.items():
    mem = fn.lower(Wm, Xm).compile().memory_analysis()
    temp = temps[name] = mem.temp_size_in_bytes / 1024
    assert np.allclose(np.asarray(fn(Wm, Xm)), ref, atol=1e-3)
    print(f"{name:26s} 临时内存 {temp:9.1f} KiB | 耗时 {bench(fn, Wm, Xm, n=5):8.2f} ms | 与 NumPy 参考值一致 ✔")
assert temps["lax.map"] < temps["lax.map(batch_size=256)"] < temps["vmap"]

# 样本数不能被 batch_size 整除时的处理方式
print(jax.make_jaxpr(lambda xs: lax.map(lambda x: x ** 2, xs, batch_size=4))(jnp.arange(10.0)))
vmap                       临时内存    8192.0 KiB | 耗时     3.85 ms | 与 NumPy 参考值一致 ✔
lax.map                    临时内存       4.1 KiB | 耗时   111.26 ms | 与 NumPy 参考值一致 ✔
lax.map(batch_size=256)    临时内存    1024.1 KiB | 耗时     8.42 ms | 与 NumPy 参考值一致 ✔
{ lambda ; a:f32[10]. let
    b:f32[8] = slice[limit_indices=(8,) start_indices=(0,) strides=None] a
    c:f32[2,4] = reshape[dimensions=None new_sizes=(2, 4) sharding=None] b
    d:f32[2] = slice[limit_indices=(10,) start_indices=(8,) strides=None] a
    e:f32[2,4] = scan[
      ft_in=((), (), (None,))
      ft_out=((), (RightsOnly(Right(None)),))
      jaxpr={ lambda ; f:f32[4]. let g:f32[4] = integer_pow[y=2] f in (g,) }
      length=2
      reverse=False
      unroll=1
    ] c
    h:f32[2] = integer_pow[y=2] d
    i:f32[8] = reshape[dimensions=None new_sizes=(8,) sharding=None] e
    j:f32[10] = concatenate[dimension=0] i h
  in (j,) }

怎么看:vmap 的临时内存最大(整批中间结果);lax.map 最小,但慢得多;batch_size 介于两者之间,速度和 vmap 在同一量级,比逐样本的 lax.map 快得多。 具体数字取决于机器,这里是在 CPU 上的结果;在 GPU/TPU 上,vmap 并行带来的优势通常更明显。 经验:先用 vmap,遇到内存不足(OOM)时再改用 lax.map(..., batch_size=k),并调整 k,在内存和速度之间取平衡。

4.8 动手实践:函数向量化

这一节用几个实例练习 jax.vmap:从最简单的函数向量化,到用 in_axes 控制映射方式,再到和 jit、grad 组合。核心思路是:先写清晰的单样本逻辑,再用 vmap 应用到批次上,用 grad 求导,用 jit 编译。

# 环境检查
import jax
import jax.numpy as jnp
import timeit
print("可用设备:", jax.devices())
可用设备: [CpuDevice(id=0)]

课程实例 1:基本向量化

先写一个处理标量的函数,用手动循环处理一批输入,再改用 vmap。Python 循环虽然能用,但通常很慢,批次大、函数复杂或者在加速器上跑时尤其明显。vmap 默认沿输入的第 0 轴映射,输出的第 0 轴对应被映射的那个维度。

def process_scalar(x):
    return x * 2.0 + 1.0

scalar_input = 5.0
scalar_output = process_scalar(scalar_input)
print(f"输入: {scalar_input}, 输出: {scalar_output}")

batch_inputs = jnp.arange(1.0, 6.0)          # Array([1., 2., 3., 4., 5.])
print("批量输入:", batch_inputs)

# 手动循环
manual_outputs = []
for x in batch_inputs:
    manual_outputs.append(process_scalar(x))
manual_outputs = jnp.array(manual_outputs)
print("Manual Loop Outputs:", manual_outputs)

# vmap
vectorized_process = jax.vmap(process_scalar)
vmap_outputs = vectorized_process(batch_inputs)
print("vmap Outputs:", vmap_outputs)
print("输入形状:", batch_inputs.shape, "| 输出形状:", vmap_outputs.shape)
assert jnp.array_equal(manual_outputs, vmap_outputs)
输入: 5.0, 输出: 11.0
批量输入: [1. 2. 3. 4. 5.]
Manual Loop Outputs: [ 3.  5.  7.  9. 11.]
vmap Outputs: [ 3.  5.  7.  9. 11.]
输入形状: (5,) | 输出形状: (5,)

课程实例 2:用 in_axes 控制映射

in_axes 是一个元组,指定每个参数沿哪个轴映射,None 表示这个参数广播、不映射。下面每个向量配一个自己的缩放系数,平移量则所有样本共用。输出形状 (3, 3) 中,第一个 3 是 vmap 引入的批次维度,第二个 3 来自单个向量本身的形状。如果批次维度在最后一轴,可以写 in_axes=(..., -1, ...)。

def scale_and_shift(vector, scale, shift):
    return vector * scale + shift

single_vector = jnp.array([1.0, 2.0, 3.0])
single_scale = 2.0
single_shift = 10.0
print("单个输入:", scale_and_shift(single_vector, single_scale, single_shift))

batch_vectors = jnp.array([[1.0, 2.0, 3.0],
                           [4.0, 5.0, 6.0],
                           [7.0, 8.0, 9.0]])    # 形状 (3, 3)
batch_scales = jnp.array([0.5, 1.0, 1.5])       # 形状 (3,)
fixed_shift = 100.0                             # 所有样本使用相同的平移量

vectorized_scale_shift = jax.vmap(scale_and_shift, in_axes=(0, 0, None))
batch_outputs = vectorized_scale_shift(batch_vectors, batch_scales, fixed_shift)
print("batch_vectors:", batch_vectors.shape, "| batch_scales:", batch_scales.shape)
print(batch_outputs)
print("输出形状:", batch_outputs.shape)
assert jnp.allclose(batch_outputs, batch_vectors * batch_scales[:, None] + fixed_shift)
单个输入: [12. 14. 16.]
batch_vectors: (3, 3) | batch_scales: (3,)
[[100.5 101.  101.5]
 [104.  105.  106. ]
 [110.5 112.  113.5]]
输出形状: (3, 3)

课程实例 3:组合 vmap、jit 和 grad

课程用 simple_activation(params, x) = tanh(params * x) 演示三种变换的组合:先单独 jit,再求关于 params 的梯度,然后把批量输入用两种顺序(先 vmap 后 jit、先 jit 后 vmap)处理,最后向量化梯度函数并计时。

注意 jax.grad 只能对标量输出的函数求导,而 simple_activation(1.5, single_x) 返回的是形状 (5,) 的向量。所以“对 params 求梯度”有两种有意义的写法:

  1. 对输出求和:grad(lambda p, x: jnp.sum(simple_activation(p, x))),得到所有元素对 params 的总梯度;
  2. 逐元素求梯度:单样本函数只处理标量 x,再用 vmap 映射到每个元素(批量数据就用两层 vmap)。这才符合“逐元素激活函数”的语义,也正是 vmap 的用武之地。两者满足“总梯度 = 逐元素梯度之和”。

至于两种组合顺序,结果一样,推荐 jit(vmap(f)),原因见 4.6。计时要先预热再计时,否则测到的主要是编译时间。

⚠️ 注意:不能直接对向量输出调用 jax.grad(simple_activation)(single_param, single_x),对批量数据用 vmap(grad_activation, in_axes=(None, 0)) 也不行。两处都会抛 TypeError: Gradient only defined for scalar-output functions。下方 ❌/✅ 对照给出两种改法,并用解析解 $\partial_p \tanh(p x) = x\,(1-\tanh^2(p x))$ 做 assert 核对。

⚠️ 注意:并不是“先 jit 后 vmap 更好”。两种组合顺序结果相同,编译出的内核也基本相同,把 jit 放在最外层的 Python 开销最小(和 4.6 节的结论一致)。另外,只计时 jit_vectorized_grad 的首次调用,测到的主要是编译时间,所以下方的计时先做了预热。

def simple_activation(params, x):
    return jnp.tanh(params * x)

single_x = jnp.linspace(-2.0, 2.0, 5)
single_param = 1.5
single_output = simple_activation(single_param, single_x)
print("单个输出:", single_output)

# JIT
jit_activation = jax.jit(simple_activation)
jit_output = jit_activation(single_param, single_x)
assert jnp.allclose(single_output, jit_output)
print("JIT 输出与原始输出匹配。")
单个输出: [-0.9950547 -0.9051482  0.         0.9051482  0.9950547]
JIT 输出与原始输出匹配。
# ❌ 错误写法:对向量输出直接 grad
grad_activation = jax.grad(simple_activation, argnums=0)
try:
    single_grad = grad_activation(single_param, single_x)
    raised = False
except TypeError as e:
    raised = True
    print("实测 TypeError:", str(e)[:110])
assert raised
实测 TypeError: Gradient only defined for scalar-output functions. Output had shape: (5,). To get the gradient, reduce the out
# ✅ 推荐写法
# 修正 1:对输出求和,得到标量 -> 总梯度
grad_activation_sum = jax.grad(lambda p, x: jnp.sum(simple_activation(p, x)), argnums=0)
g_sum = grad_activation_sum(single_param, single_x)

# 修正 2:逐元素梯度 —— 单样本函数只处理标量 x(此时输出是标量,grad 合法),再用 vmap 映射到每个元素
grad_activation_elem = jax.grad(simple_activation, argnums=0)
per_elem_grad = jax.vmap(grad_activation_elem, in_axes=(None, 0))(single_param, single_x)

analytic = single_x * (1 - jnp.tanh(single_param * single_x) ** 2)   # d/dp tanh(p x) = x (1 - tanh(p x)^2)
print("修正 1,总梯度:", g_sum, "| 修正 2,逐元素梯度:", per_elem_grad)
assert jnp.allclose(per_elem_grad, analytic, atol=1e-6)               # 修正 2 与解析解一致
assert jnp.allclose(g_sum, analytic.sum(), atol=1e-6)                 # 修正 1 与解析解之和一致
assert jnp.allclose(per_elem_grad.sum(), g_sum, atol=1e-6)            # 总梯度 = 逐元素梯度之和
print("✔ 两种修正都与解析解一致")
修正 1,总梯度: -5.5879354e-09 | 修正 2,逐元素梯度: [-0.0197322  -0.18070672  0.          0.18070672  0.0197322 ]
✔ 两种修正都与解析解一致
# 批量输入
batch_x = jnp.stack([single_x, single_x * 0.5, single_x * -1.0])   # 形状 (3, 5)
print("批量输入形状:", batch_x.shape)

# 选项 1:先 vmap 后 jit(推荐)
vectorized_activation = jax.vmap(simple_activation, in_axes=(None, 0))
jit_vectorized_activation = jax.jit(vectorized_activation)
batch_output_1 = jit_vectorized_activation(single_param, batch_x)

# 选项 2:先 jit 后 vmap
vectorized_jit_activation = jax.vmap(jit_activation, in_axes=(None, 0))
batch_output_2 = vectorized_jit_activation(single_param, batch_x)
assert batch_output_1.shape == batch_output_2.shape == (3, 5)
assert jnp.allclose(batch_output_1, batch_output_2)
print("两种组合顺序的输出都匹配。")
批量输入形状: (3, 5)
两种组合顺序的输出都匹配。
# ❌ 错误写法:对批量数据 vmap(grad_activation),每一行仍是向量输出
try:
    jax.vmap(grad_activation, in_axes=(None, 0))(single_param, batch_x)
    raised = False
except TypeError as e:
    raised = True
    print("实测 TypeError:", str(e)[:110])
assert raised
实测 TypeError: Gradient only defined for scalar-output functions. Output had shape: (5,). To get the gradient, reduce the out
# ✅ 推荐写法:两层 vmap —— 外层遍历行,内层遍历行内的每个标量元素
vectorized_grad = jax.vmap(jax.vmap(grad_activation_elem, in_axes=(None, 0)), in_axes=(None, 0))
batch_grads = vectorized_grad(single_param, batch_x)
print("批量梯度形状:", batch_grads.shape)
print(batch_grads)
assert jnp.allclose(batch_grads, batch_x * (1 - jnp.tanh(single_param * batch_x) ** 2), atol=1e-6)

jit_vectorized_grad = jax.jit(vectorized_grad)
start = timeit.default_timer()
batch_grads_jit = jit_vectorized_grad(single_param, batch_x).block_until_ready()
print(f"首次调用耗时(含编译): {timeit.default_timer() - start:.6f}s")
print(f"预热后平均耗时:         {bench(jit_vectorized_grad, single_param, batch_x) / 1e3:.6f}s")
assert jnp.allclose(batch_grads, batch_grads_jit)
批量梯度形状: (3, 5)
[[-0.0197322  -0.18070672  0.          0.18070672  0.0197322 ]
 [-0.18070672 -0.29829288  0.          0.29829288  0.18070672]
 [ 0.0197322   0.18070672 -0.         -0.18070672 -0.0197322 ]]
首次调用耗时(含编译): 0.016550s
预热后平均耗时:         0.000006s

课程练习:两两欧氏距离

给定两组二维点:

points_a = jnp.array([[0., 0.], [1., 1.]])                # 形状 (2, 2)
points_b = jnp.array([[2., 2.], [3., 3.], [4., 4.]])      # 形状 (3, 2)

先写一个计算两个点之间欧氏距离的函数,再用 vmap(可以嵌套)构造一个距离矩阵,让第 (i, j) 个元素等于 points_a[i] 和 points_b[j] 的距离,预期形状是 (2, 3)。提示:想清楚每一层 vmap 的 in_axes。

自检接口:euclidean_distance(p1, p2) 返回两点间的距离(标量);compute_pairwise_distances(arr_a, arr_b) 对形状 (n, d)、(m, d) 的两组点返回 (n, m) 的距离矩阵,任意 n, m, d 都应适用。

# ✍️ 作答区:课程练习:两两欧氏距离
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:课程练习:两两欧氏距离
try:
    euclidean_distance, compute_pairwise_distances
except NameError as e:
    print(f"⏳ 课程练习 尚未作答:{e}")
else:
    assert abs(float(euclidean_distance(jnp.array([0., 0.]), jnp.array([3., 4.]))) - 5.0) < 1e-5, \
        "euclidean_distance((0, 0), (3, 4)) 应为 5"
    _D = np.asarray(compute_pairwise_distances(jnp.array([[0., 0.], [1., 1.]]),
                                               jnp.array([[2., 2.], [3., 3.], [4., 4.]])))
    assert _D.shape == (2, 3), f"题面数据的距离矩阵形状应为 (2, 3),得到 {_D.shape};检查每层 vmap 的 in_axes"
    # 新数据:点数不同、维度为 3;对照值用 NumPy 广播计算
    _ka, _kb = jax.random.split(jax.random.key(2024))
    _A = np.asarray(jax.random.normal(_ka, (5, 3)))
    _B = np.asarray(jax.random.normal(_kb, (7, 3)))
    _D2 = np.asarray(compute_pairwise_distances(jnp.asarray(_A), jnp.asarray(_B)))
    assert _D2.shape == (5, 7), f"5 个点对 7 个点,形状应为 (5, 7),得到 {_D2.shape}(行对应 arr_a,列对应 arr_b)"
    _ref = np.sqrt(((_A[:, None, :] - _B[None, :, :]) ** 2).sum(-1))
    assert np.allclose(_D2, _ref, atol=1e-5), "第 (i, j) 个元素应为 arr_a[i] 与 arr_b[j] 的欧氏距离"
    print("✅ 课程练习 自检通过")
⏳ 课程练习 尚未作答:name 'euclidean_distance' is not defined

注意:如果之后要对这个距离矩阵求梯度,而两组点里有重合的点,sqrt 在 0 处的梯度会是 nan,要用“双 where”技巧(见 第 3 章 3.8)。


下面是补充练习,难度更高一些。

补充练习 1(a):批量 MLP 预测(dict 参数共享)

给定单样本函数 predict(params, x)(一个两层网络,x 形状 (d,)),用 vmap 写出批量版本 batched_predict(params, X),其中 X 形状 (B, d),参数共享。和手写的批量矩阵运算对比,确认结果一致,再用 jit 编译并计时。

自检接口:predict(params, x) 是两层 ReLU 网络 W2 @ relu(W1 @ x + b1) + b2,params 为含 W1 (h, d)、b1 (h,)、W2 (o, h)、b2 (o,) 的 dict;batched_predict(params, X) 对 X 形状 (B, d) 返回 (B, o)。

# ✍️ 作答区:补充练习 1(a)
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 1(a)
try:
    batched_predict, predict
except NameError as e:
    print(f"⏳ 补充练习 1(a)尚未作答:{e}")
else:
    _k = jax.random.split(jax.random.key(2025), 5)
    _p = {"W1": jax.random.normal(_k[0], (5, 7)), "b1": jax.random.normal(_k[1], (5,)),
          "W2": jax.random.normal(_k[2], (3, 5)), "b2": jax.random.normal(_k[3], (3,))}
    _X = jax.random.normal(_k[4], (11, 7))
    _out = np.asarray(batched_predict(_p, _X))
    assert _out.shape == (11, 3), f"输出形状应为 (B, o) = (11, 3),得到 {_out.shape}"
    _pn = {k: np.asarray(v, np.float64) for k, v in _p.items()}
    _H = np.maximum(np.asarray(_X, np.float64) @ _pn["W1"].T + _pn["b1"], 0)
    assert np.allclose(_out, _H @ _pn["W2"].T + _pn["b2"], atol=1e-4), \
        "批量结果与 NumPy 手写的两层网络不一致(参数应在所有样本间共享)"
    _loop = np.stack([np.asarray(predict(_p, x)) for x in _X])
    assert np.allclose(_out, _loop, atol=1e-4), "批量结果与逐个样本调用 predict 的结果不一致"
    assert np.asarray(batched_predict(_p, _X[:1])).shape == (1, 3), "B = 1 时输出形状应为 (1, 3)"
    print("✅ 补充练习 1(a)自检通过")
⏳ 补充练习 1(a)尚未作答:name 'batched_predict' is not defined

补充练习 1(b):样本按列存放时的 in_axes / out_axes

数据矩阵 Xc 形状为 (d, B)(每一列是一个样本)。要求不转置数据,直接用 in_axes 让 predict 处理每一列,并通过 out_axes 让输出形状为 (d_out, B)。

自检接口:沿用 1(a)的 predict 和 params;Xc 是形状 (d, B) 的数据(每列一个样本),out_cols 是对它的批量预测结果,形状 (d_out, B)。

# ✍️ 作答区:补充练习 1(b)
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 1(b)
try:
    out_cols, Xc, predict, params
except NameError as e:
    print(f"⏳ 补充练习 1(b)尚未作答:{e}")
else:
    _Xc, _o = np.asarray(Xc), np.asarray(out_cols)
    assert _Xc.shape[0] == np.asarray(params["W1"]).shape[1], \
        f"Xc 应为 (d, B),第 0 维是特征维 d = {np.asarray(params['W1']).shape[1]},得到 {_Xc.shape}"
    _ref = np.stack([np.asarray(predict(params, jnp.asarray(_Xc[:, j]))) for j in range(_Xc.shape[1])], axis=1)
    assert _o.shape == _ref.shape, f"输出形状应为 (d_out, B) = {_ref.shape},得到 {_o.shape};检查 out_axes"
    assert np.allclose(_o, _ref, atol=1e-4), "out_cols 的第 j 列应等于 predict(params, Xc[:, j])"
    print("✅ 补充练习 1(b)自检通过")
⏳ 补充练习 1(b)尚未作答:name 'out_cols' is not defined

补充练习 2:DP-SGD 风格的逐样本梯度裁剪

用一个线性回归模型($y = w^\top x + b$,真实 $w = (1, -2, 0.5)$、$b = 0.5$):

  1. 用 vmap(grad) 求出逐样本梯度;
  2. 计算每个样本梯度的全局 L2 范数(把 w 和 b 的梯度合在一起算);
  3. 把范数超过 C 的梯度按比例缩小到范数为 C,然后取平均;
  4. 用它做 200 步梯度下降,验证 w 能收敛到接近 true_w。

(真正的 DP-SGD 还要加高斯噪声,这里只练习向量化部分。)

自检接口:loss_single(theta, x, y) 返回单样本平方误差 $(w^\top x+b-y)^2$,theta = {"w": (d,), "b": 标量};clipped_mean_grad(theta, X, Y, C) 返回 (裁剪后的平均梯度, 逐样本梯度范数),前者是与 theta 同结构的 dict,后者形状 (N,)。

# ✍️ 作答区:补充练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 2
try:
    clipped_mean_grad, loss_single
except NameError as e:
    print(f"⏳ 补充练习 2 尚未作答:{e}")
else:
    _k1, _k2 = jax.random.split(jax.random.key(2026))
    _X = jax.random.normal(_k1, (40, 4)) * 3
    _Y = jax.random.normal(_k2, (40,)) * 3
    _th = {"w": jnp.array([0.3, -0.2, 0.1, 0.5]), "b": jnp.array(-0.4)}
    # 对照值:线性回归逐样本梯度的解析式 d/dw = 2r·x,d/db = 2r(float64)
    _Xn, _Yn = np.asarray(_X, np.float64), np.asarray(_Y, np.float64)
    _r = _Xn @ np.asarray(_th["w"], np.float64) + float(_th["b"]) - _Yn
    assert np.isclose(float(loss_single(_th, _X[0], _Y[0])), _r[0] ** 2, rtol=1e-4), "loss_single 应为单样本平方误差"
    _gw, _gb = 2 * _r[:, None] * _Xn, 2 * _r
    _norms = np.sqrt((_gw ** 2).sum(1) + _gb ** 2)
    for _C in (0.5, 5.0, 1e6):          # 1e6:不触发裁剪,应退化为普通平均梯度
        _s = np.minimum(1.0, _C / _norms)
        _g, _nm = clipped_mean_grad(_th, _X, _Y, _C)
        assert np.asarray(_nm).shape == (40,), f"逐样本范数形状应为 (N,) = (40,),得到 {np.asarray(_nm).shape}"
        assert np.allclose(_nm, _norms, rtol=1e-3), "逐样本范数不对:应把 w 和 b 的梯度合在一起算 L2 范数"
        assert np.allclose(_g["w"], (_s[:, None] * _gw).mean(0), rtol=1e-3, atol=1e-4), f"C = {_C} 时裁剪后平均的 w 梯度不对"
        assert np.isclose(float(_g["b"]), (_s * _gb).mean(), rtol=1e-3, atol=1e-4), f"C = {_C} 时裁剪后平均的 b 梯度不对"
    print("✅ 补充练习 2 自检通过")
⏳ 补充练习 2 尚未作答:name 'clipped_mean_grad' is not defined

补充练习 3:用 vmap 并行模拟多条随机游走(对 key 映射)

写一个单条路径的函数 walk(key, n_steps):每一步以相同概率走 +1 或 -1,返回最终位置。用 vmap 同时模拟 10000 条路径,验证:最终位置的均值约为 0,方差约为 n_steps。

自检接口:walk(key, n_steps) 返回 n_steps 步 ±1 随机游走的最终位置(标量);自检会用新的 key 和 n_steps = 50 调用它。

# ✍️ 作答区:补充练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 3
try:
    walk
except NameError as e:
    print(f"⏳ 补充练习 3 尚未作答:{e}")
else:
    _keys = jax.random.split(jax.random.key(2027), 20000)
    _f = np.asarray(jax.jit(jax.vmap(lambda k: walk(k, 50)))(_keys)).astype(np.float64)
    assert _f.shape == (20000,), f"walk 应返回标量(最终位置),批量后形状应为 (20000,),得到 {_f.shape}"
    assert np.all(np.abs(_f) <= 50) and np.all((_f + 50) % 2 == 0), \
        "50 步 ±1 游走的终点应在 [-50, 50] 内且为偶数;检查每步是否只取 +1 或 -1"
    assert abs(_f.mean()) < 0.3, f"终点均值应约为 0,得到 {_f.mean():.3f}"
    assert abs(_f.var() - 50) < 3, f"终点方差应约为 n_steps = 50,得到 {_f.var():.2f}"
    assert float(walk(_keys[0], 50)) == float(walk(_keys[0], 50)), "同一个 key 应得到相同结果"
    assert len(np.unique(_f[:200])) > 5, "不同 key 应得到不同路径;检查是否真的用了传入的 key"
    print("✅ 补充练习 3 自检通过")
⏳ 补充练习 3 尚未作答:name 'walk' is not defined

补充练习 4:批量计算雅可比矩阵与 jnp.vectorize

对 $f(x) = \big(\sin x_0 \cdot x_1,\ x_0^2 + x_1\big)$:

  1. 用 vmap(jax.jacfwd(f)) 对一批 (B, 2) 的点分别求 2×2 雅可比矩阵,并对第一个点手工核对;
  2. 用 jnp.vectorize(..., signature='(n)->(m,n)') 实现同样的功能。jnp.vectorize 基于 vmap,按 NumPy 广播(gufunc)的签名自动处理任意多个前导批维度。

自检接口:f_vec(x) 对形状 (2,) 的 x 返回题面的 $f(x)$(形状 (2,));jac_vectorized(X) 对形状 (..., 2) 的输入返回 (..., 2, 2) 的雅可比矩阵,前导批维度可以有任意多个。

# ✍️ 作答区:补充练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 4
try:
    jac_vectorized, f_vec
except NameError as e:
    print(f"⏳ 补充练习 4 尚未作答:{e}")
else:
    assert np.allclose(f_vec(jnp.array([0.5, 2.0])), [np.sin(0.5) * 2.0, 0.25 + 2.0], atol=1e-6), \
        "f_vec 与题面公式 (sin x0 · x1, x0² + x1) 不一致"
    _x = np.asarray(jax.random.normal(jax.random.key(2028), (3, 4, 2)))
    _J = np.asarray(jac_vectorized(jnp.asarray(_x)))
    assert _J.shape == (3, 4, 2, 2), f"输入 (3, 4, 2) 时输出应为 (3, 4, 2, 2),得到 {_J.shape};检查 signature"
    _ref = np.empty((3, 4, 2, 2))
    _ref[..., 0, 0] = np.cos(_x[..., 0]) * _x[..., 1]
    _ref[..., 0, 1] = np.sin(_x[..., 0])
    _ref[..., 1, 0] = 2 * _x[..., 0]
    _ref[..., 1, 1] = 1.0
    assert np.allclose(_J, _ref, atol=1e-5), "雅可比矩阵与解析式不一致(第 i 行是第 i 个输出对各输入的偏导)"
    print("✅ 补充练习 4 自检通过")
⏳ 补充练习 4 尚未作答:name 'jac_vectorized' is not defined

4.9 第 4 章测验

下面 8 道题围绕本章内容,帮你检查掌握得怎么样,答案都用代码验证过。

1. 关于 jax.vmap(f)(xs),下列说法正确的是:

  • A. 在运行时用 Python for 循环逐个调用 f
  • B. 语义上等价于 jnp.stack([f(x) for x in xs]),实现上是对追踪得到的程序应用批处理规则
  • C. 会自动启动多线程并行执行 f
  • D. 只能用于逐元素函数

2. f(W, x) 中 W 是共享权重、x 是一批样本(批维度在第 0 轴),应使用:

  • A. in_axes=(0, 0) B. in_axes=(None, 0) C. in_axes=(0, None) D. out_axes=None

3. f(a, b) 中 a.shape == (5, 3)、b.shape == (3, 5),希望 a 的每一行与 b 的每一列配对处理,应使用:

  • A. in_axes=(0, 0) B. in_axes=(1, 0) C. in_axes=(0, 1) D. in_axes=(None, 1)

4. X.shape == (M, d),Y.shape == (N, d),vmap(vmap(f, in_axes=(None, 0)), in_axes=(0, None))(X, Y)(f 返回标量)的输出形状是:

  • A. (M,) B. (N, M) C. (M, N) D. (M, N, d)

5. 已知单样本损失 loss(params, x, y) 返回标量。下列哪个表达式能得到逐样本梯度?

  • A. jax.grad(jax.vmap(loss, in_axes=(None, 0, 0)))
  • B. jax.vmap(jax.grad(loss), in_axes=(None, 0, 0))
  • C. jax.vmap(jax.grad(loss), in_axes=(0, 0, 0))
  • D. jax.jit(jax.grad(loss))

6. 在 vmap 下,如果 lax.cond 的判断条件依赖被映射的数据,会发生什么?

  • A. 报错,vmap 不支持 lax.cond
  • B. 每个样本只执行自己的那个分支
  • C. 两个分支都对整批数据执行,再用 select 逐元素选择结果
  • D. 只执行第一个样本对应的分支

7. jax.vmap(lambda a, b: a + b)(jnp.ones(3), jnp.ones(4)) 的结果是:

  • A. 形状为 (3, 4) 的矩阵 B. 形状为 (4,),较短的数组自动补齐
  • C. 抛出 ValueError:映射轴大小不一致 D. 形状为 (3,),多余元素被截断

8. 批非常大、vmap 导致内存不足时,最合适的替代方案是:

  • A. 改回 Python for 循环
  • B. 用 jax.lax.map(f, xs, batch_size=k) 按块处理
  • C. 去掉 jit
  • D. 把 in_axes 全部设为 None
# 用代码验证部分答案
# 第 3 题
a3, b3 = jnp.ones((5, 3)), jnp.ones((3, 5))
q3 = jax.vmap(jnp.dot, in_axes=(0, 1))(a3, b3).shape; assert q3 == (5,); print("Q3:", q3)
# 第 4 题
fq = lambda x, y: jnp.dot(x, y)
q4 = jax.vmap(jax.vmap(fq, in_axes=(None, 0)), in_axes=(0, None))(jnp.ones((2, 3)), jnp.ones((7, 3))).shape
assert q4 == (2, 7); print("Q4:", q4)
# 第 5 题:A 报错,B 得到逐样本梯度
try:
    jax.grad(jax.vmap(mlp_loss, in_axes=(None, 0, 0)))(pe_params, X_pe, Y_pe)
except TypeError:
    print("Q5: A 报 TypeError(输出不是标量)")
print("Q5: B 的形状", jax.tree.map(jnp.shape, jax.vmap(jax.grad(mlp_loss), in_axes=(None, 0, 0))(pe_params, X_pe, Y_pe)))
# 第 7 题
try:
    jax.vmap(lambda a, b: a + b)(jnp.ones(3), jnp.ones(4))
except ValueError as e:
    print("Q7: ValueError ->", str(e).splitlines()[0])
Q3: (5,)
Q4: (2, 7)
Q5: A 报 TypeError(输出不是标量)
Q5: B 的形状 {'W1': (256, 32, 8), 'b1': (256, 32), 'b2': (256,), 'w2': (256, 32)}
Q7: ValueError -> vmap got inconsistent sizes for array axes to be mapped:
点击查看答案与解析
  1. B。vmap 是函数变换:先追踪 f,再对每个基本操作应用批处理规则,生成批量程序,没有运行时循环(4.2 的 jaxpr 对比可以看到)。它也不限于逐元素函数。
  2. B。None 表示不映射(共享),0 表示沿第 0 轴映射。
  3. C。a 沿第 0 轴(行)、b 沿第 1 轴(列)映射,两者长度都是 5,可以配对。
  4. C。外层批维度(遍历 X,大小 M)在前,内层批维度(遍历 Y,大小 N)在后。
  5. B。参数共享用 None,数据沿 0 轴映射。A 中 vmap(loss) 的输出是向量,grad 会报错;C 会要求 params 也带批维度;D 只是单样本梯度。
  6. C。批处理的 cond 会被改写成 select_n,两个分支都计算(4.7 有 jaxpr 和计时验证)。只有判断条件不依赖批数据时才保持为真正的 cond。
  7. C。被映射的轴大小必须一致,vmap 不做 NumPy 式广播。
  8. B。lax.map 用 scan 顺序执行,batch_size 让每块内部仍然向量化,在内存和速度之间折中。Python 循环最慢,去掉 jit 不能减少中间结果占用的内存。

本章小结

  • 向量化:用整块数组运算替代逐样本循环,避开 Python 开销、利用硬件并行;手动向量化容易在批维度上出错。
  • jax.vmap(f):只写单样本函数,自动得到批量版本。语义上等于“逐个调用再堆叠”,实现上是程序变换(批处理规则),没有运行时循环。
  • in_axes:整数表示映射的轴(可以为负),None 表示共享;可以写成元组或与参数结构一致的 pytree(如 dict)。关键字参数总是沿第 0 轴映射。
  • out_axes:控制输出的批维度放在哪一轴;None 只能用于不随样本变化的输出(或配合 0.12 新增的 sum_match=True)。
  • axis_size:没有任何被映射的输入时,用它指定批大小。
  • 多个批处理参数:各参数一一配对,映射轴大小必须一致,不会自动广播。
  • 嵌套 vmap:每层负责一个批维度,用来实现两两运算(距离矩阵、核矩阵),外层的批维度在输出的前面。
  • 组合变换:jit(vmap(f)) 是推荐顺序(vmap(jit(f)) 内核相同,但 Python 开销更大);grad 需要标量输出。
  • 逐样本梯度:jit(vmap(grad(loss), in_axes=(None, 0, 0))),等价于逐样本损失对参数的雅可比;均值等于平均损失的梯度;可用于 DP-SGD 裁剪、异常样本检测、样本加权;内存为 批大小 × 参数量,只需要范数时用 lax.map(batch_size=...) 分块计算。
  • 随机数:用 jax.random.key,每个 key 只用一次;先 split 出 n 个 key,再对 key 做映射。
  • 环境注意:jaxlib 0.12.0.1 的 CPU 后端有 YNN 融合 bug,大数组要作为参数传给 jit 函数,不要被闭包捕获(详见 00_目录与导读)。
  • 性能:vmap 的速度接近手写批量代码,配合 jit 更好;代价是峰值内存随批大小增长。内存不足时用 lax.map(..., batch_size=k)。
  • 控制流:批处理下 cond 会变成 select(两个分支都执行),while_loop 要跑到最慢的样本结束;Python if 不能依赖批数据。

速查表

API / 写法作用
jax.vmap(f)沿所有位置参数的第 0 轴映射 f
jax.vmap(f, in_axes=(None, 0))第 1 个参数共享,第 2 个参数沿 0 轴映射
in_axes=1 / in_axes=-1沿第 1 轴 / 最后一轴映射
in_axes=({"w": None, "b": 0}, 0)用 pytree(dict)为参数内部的不同字段分别指定映射方式
jax.vmap(f, out_axes=1)输出的批维度放在第 1 轴
out_axes=None输出不带批维度(必须不随样本变化)
jax.vmap(f, axis_size=n)显式指定批大小(所有输入都是 None 时必需)
jax.vmap(f, out_axes=None, sum_match=True)对 out_axes=None 的输出沿批维度求和(JAX 0.12)
jax.vmap(f, axis_name="i") + lax.psum/pmean(x, "i")在批维度上做集合运算(例如批内归一化)
vmap(vmap(f, (None, 0)), (0, None))两两运算,输出 (M, N)
jax.jit(jax.vmap(f))编译后的批量函数(推荐)
jax.vmap(jax.grad(loss), in_axes=(None, 0, 0))逐样本梯度
jax.vmap(sample)(jax.random.split(key, n))每个样本使用独立的随机数
jax.lax.map(f, xs, batch_size=k)按块顺序执行,节省内存
jnp.vectorize(f, signature="(n)->(m,n)")基于 vmap,用 gufunc 签名支持任意多个前导批维度
jnp.einsum("bnk,bkm->bnm", A, B) / A @ B批量矩阵乘(与 vmap(jnp.matmul) 等价)
jax.make_jaxpr(jax.vmap(f))(xs)查看 vmap 变换后的程序
# 速查表补充演示:axis_name + 集合运算(批内中心化)
center = jax.vmap(lambda x: x - lax.pmean(x, axis_name="batch"), axis_name="batch")
print(center(jnp.arange(4.0)))   # [-1.5 -0.5  0.5  1.5]
[-1.5 -0.5  0.5  1.5]

本章更新说明

下面汇总本章在 JAX 0.12 下需要注意的写法和结论,每条都在正文对应位置用 assert 验证过。

编号小节要点位置
14.2vmap 下“print 只打印一次”是追踪造成的,不是 vmap 优化了执行:Python print 在追踪时执行 1 次,jax.debug.print 对每个样本各执行 1 次4.2 介绍 jax.vmap
24.3in_axes 元组的长度要和调用时的位置参数个数匹配;关键字参数总是沿第 0 轴映射,元组管不到4.3 对特定参数进行映射(in_axes,out_axes)
34.4默认的 in_axes=0 映射所有位置参数,不只是第一个;共享的标量要写 None,否则报 ValueError4.4 处理多个批处理参数
44.4列缩放 vmap(scale_column, in_axes=(1, 0)) 的输出是 (4, 3)(批次维度默认在输出第 0 轴);要得到 3×4 布局须加 out_axes=14.4 处理多个批处理参数
54.5两两 matvec 的嵌套 vmap 中,内层 (None, 0) 遍历向量,外层 (0, None) 遍历矩阵,输出 (3, 4, 2)4.5 嵌套 vmap
64.6创建 key 用 jax.random.key(seed)(带类型的 key),jax.random.PRNGKey(seed) 是旧写法4.6 结合 vmap 与 jit 和 grad
74.6同一个 key 先后生成 uniform 的 x 和 normal 的噪声,两者高度相关(相关系数约 0.98、秩相关系数为 1);先 jax.random.split,相关系数约为 04.6 结合 vmap 与 jit 和 grad
84.6vmap(jit(f)) 会把批维度推进到 jit 内部,内核和 jit(vmap(f)) 基本相同,差别只在 Python 端开销;推荐 jit(vmap(f))4.6 结合 vmap 与 jit 和 grad
94.6逐样本梯度专题:原理、与循环/jacrev 对拍、计时、均值性质、DP-SGD 裁剪、异常样本检测、样本加权、内存为 批大小 × 参数量4.6 结合 vmap 与 jit 和 grad
104.6jax.tree_map 在 0.12 中已移除(报 AttributeError),改用 jax.tree.map4.6 结合 vmap 与 jit 和 grad
114.7手动向量化只在未 jit 时比 vmap 略快;本例两者的 jaxpr 逐字相同(一般情况下是等价程序),jit 后速度一样4.7 vmap 的性能考量
124.7依赖数据的 Python if 在 vmap 下抛出 TracerBoolConversionError,要改用 jnp.where / lax.cond;静态 for 会被展开,可以直接用4.7 vmap 的性能考量
134.7内存受限时优先用 lax.map(f, xs, batch_size=k),不必退回 Python 循环:内存可控,速度与 vmap 在同一量级4.7 vmap 的性能考量
144.7批处理下的控制流代价:lax.cond 变成 select,两个分支都执行,且会把共享操作数广播到批维度(内存约增大 128 倍);while_loop 要跑到最慢的样本结束4.7 vmap 的性能考量
154.8实例 3 中,对向量输出直接 jax.grad、对批量数据直接 vmap(grad) 都会抛 TypeError;改为“对输出求和”或“逐元素两层 vmap”,并与解析解对拍4.8 动手实践:函数向量化
164.8jit 与 vmap 两种组合顺序结果相同,推荐 jit(vmap(f)),与 4.6 节一致4.8 动手实践:函数向量化
174.8计时 jit_vectorized_grad 时,首次调用主要是编译时间;要先预热,再多次计时取平均4.8 动手实践:函数向量化
184.3pytree 形式的 in_axes、axis_size、out_axes=None 和 0.12 新增的 sum_match 的用法与演示4.3 对特定参数进行映射(in_axes,out_axes)
194.7jaxlib 0.12.0.1 CPU:jit 函数闭包捕获的常量矩阵 @ 数据再求和,会被 YNN 融合算错;把参数作为函数参数传入,或设置 XLA_FLAGS=--xla_cpu_experimental_ynn_fusion_type=-reduce(子进程复现与 assert 见 00_目录与导读「本环境已知问题」)4.7 vmap 的性能考量

附录:练习参考答案

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

课程练习:两两欧氏距离

题目见正文「课程练习:两两欧氏距离」。

# 参考答案(课程给出的解法)
def euclidean_distance(p1, p2):
    return jnp.sqrt(jnp.sum((p1 - p2)**2))

points_a = jnp.array([[0., 0.], [1., 1.]])
points_b = jnp.array([[2., 2.], [3., 3.], [4., 4.]])

# 提示:先只对 b 映射 -> 一个 a 对所有 b,形状 (3,)
vmap_over_b = jax.vmap(euclidean_distance, in_axes=(None, 0))
print("points_a[0] 到所有 b:", vmap_over_b(points_a[0], points_b))

@jax.jit
def compute_pairwise_distances(arr_a, arr_b):
    vmap_dist_b = jax.vmap(euclidean_distance, in_axes=(None, 0))      # 内层:遍历 b
    vmap_dist_a_b = jax.vmap(vmap_dist_b, in_axes=(0, None))           # 外层:遍历 a
    return vmap_dist_a_b(arr_a, arr_b)

distance_matrix = compute_pairwise_distances(points_a, points_b)
print("points_a:\n", points_a)
print("points_b:\n", points_b)
print("距离矩阵:\n", distance_matrix)
print("形状:", distance_matrix.shape)
# 课程给出的预期输出:[[2.828427, 4.2426405, 5.656854], [1.4142135, 2.828427, 4.2426405]]
assert distance_matrix.shape == (2, 3)
assert jnp.allclose(distance_matrix, jnp.array([[2.828427, 4.2426405, 5.656854], [1.4142135, 2.828427, 4.2426405]]))
print("✔ 与预期输出一致")
points_a[0] 到所有 b: [2.828427  4.2426405 5.656854 ]
points_a:
 [[0. 0.]
 [1. 1.]]
points_b:
 [[2. 2.]
 [3. 3.]
 [4. 4.]]
距离矩阵:
 [[2.828427  4.2426405 5.656854 ]
 [1.4142135 2.828427  4.2426405]]
形状: (2, 3)
✔ 与预期输出一致

补充练习 1(a)

题目见正文「补充练习 1(a):批量 MLP 预测(dict 参数共享)」。

# 参考答案
def init_mlp(key, d_in, d_hidden, d_out):
    k1, k2 = jax.random.split(key)
    return {
        "W1": jax.random.normal(k1, (d_hidden, d_in)) / jnp.sqrt(d_in),
        "b1": jnp.zeros(d_hidden),
        "W2": jax.random.normal(k2, (d_out, d_hidden)) / jnp.sqrt(d_hidden),
        "b2": jnp.zeros(d_out),
    }

def predict(params, x):                       # 单样本:x 形状 (d,)
    h = jax.nn.relu(params["W1"] @ x + params["b1"])
    return params["W2"] @ h + params["b2"]

params = init_mlp(jax.random.key(12), 16, 32, 4)
Xe = jax.random.normal(jax.random.key(13), (256, 16))

batched_predict = jax.jit(jax.vmap(predict, in_axes=(None, 0)))

def manual_batched(params, X):                # 手写批量版本,作为对照
    H = jax.nn.relu(X @ params["W1"].T + params["b1"])
    return H @ params["W2"].T + params["b2"]

print(batched_predict(params, Xe).shape)
assert jnp.allclose(batched_predict(params, Xe), manual_batched(params, Xe), atol=1e-5)
print("✔ 与手写版本一致")
print("jit(vmap) 耗时: %.3f ms" % bench(batched_predict, params, Xe))
(256, 4)
✔ 与手写版本一致
jit(vmap) 耗时: 0.020 ms

补充练习 1(b)

题目见正文「补充练习 1(b):样本按列存放时的 in_axes / out_axes」。

# 参考答案
Xc = Xe.T                                     # (16, 256):样本按列存放
out_cols = jax.vmap(predict, in_axes=(None, 1), out_axes=1)(params, Xc)
print("输出形状:", out_cols.shape)            # (4, 256)
assert out_cols.shape == (4, 256) and jnp.allclose(out_cols, batched_predict(params, Xe).T, atol=1e-5)
print("✔ 与按行版本一致")
输出形状: (4, 256)
✔ 与按行版本一致

补充练习 2

题目见正文「补充练习 2:DP-SGD 风格的逐样本梯度裁剪」。

# 参考答案
k1, k2 = jax.random.split(jax.random.key(15))
Xd = jax.random.normal(k1, (256, 3))
true_w = jnp.array([1.0, -2.0, 0.5])
Yd = Xd @ true_w + 0.5 + 0.1 * jax.random.normal(k2, (256,))

def loss_single(theta, x, y):          # 线性回归的单样本损失(标量)
    pred = jnp.dot(theta["w"], x) + theta["b"]
    return (pred - y) ** 2

def clipped_mean_grad(theta, X, Y, C=1.0):
    g = jax.vmap(jax.grad(loss_single), in_axes=(None, 0, 0))(theta, X, Y)
    # 每个样本的全局范数:对每个叶子在非批维度上求平方和,再把各叶子相加
    sq = sum(jnp.sum(leaf.reshape(leaf.shape[0], -1) ** 2, axis=1) for leaf in jax.tree.leaves(g))
    norms = jnp.sqrt(sq)                                  # 形状 (N,)
    scale = jnp.minimum(1.0, C / (norms + 1e-12))         # 每个样本的缩放系数
    g = jax.tree.map(lambda t: t * scale.reshape((-1,) + (1,) * (t.ndim - 1)), g)
    return jax.tree.map(lambda t: jnp.mean(t, axis=0), g), norms

@jax.jit
def step(theta, X, Y, lr=0.1):
    g, norms = clipped_mean_grad(theta, X, Y)
    return jax.tree.map(lambda p, gi: p - lr * gi, theta, g), jnp.max(norms)

th = {"w": jnp.zeros(3), "b": jnp.array(0.0)}
for i in range(200):
    th, max_norm = step(th, Xd, Yd)
    if i in (0, 199):
        print(f"第 {i} 步: 最大逐样本梯度范数 = {max_norm:.3f}")
print("学到的 w:", th["w"], "| 真实 w:", true_w)
assert jnp.allclose(th["w"], true_w, atol=0.05) and abs(float(th["b"]) - 0.5) < 0.05
print("学到的 b:", th["b"], "| 真实 b: 0.5")
第 0 步: 最大逐样本梯度范数 = 54.118
第 199 步: 最大逐样本梯度范数 = 1.584
学到的 w: [ 1.0031216 -2.0052059  0.5043752] | 真实 w: [ 1.  -2.   0.5]
学到的 b: 0.50516415 | 真实 b: 0.5

补充练习 3

题目见正文「补充练习 3:用 vmap 并行模拟多条随机游走(对 key 映射)」。

# 参考答案
def walk(key, n_steps=100):
    steps = jax.random.choice(key, jnp.array([-1, 1]), shape=(n_steps,))
    return jnp.sum(steps)

keys = jax.random.split(jax.random.key(16), 10000)
finals = jax.jit(jax.vmap(walk))(keys)
print("均值:", jnp.mean(finals.astype(jnp.float32)), "(理论值 0)")
print("方差:", jnp.var(finals.astype(jnp.float32)), "(理论值 100)")
assert abs(float(jnp.mean(finals))) < 0.5 and abs(float(jnp.var(finals.astype(jnp.float32))) - 100) < 5
均值: -0.0212 (理论值 0)
方差: 98.91235 (理论值 100)

补充练习 4

题目见正文「补充练习 4:批量计算雅可比矩阵与 jnp.vectorize」。

# 参考答案
def f_vec(x):
    return jnp.array([jnp.sin(x[0]) * x[1], x[0] ** 2 + x[1]])

pts = jax.random.normal(jax.random.key(17), (5, 2))
J = jax.vmap(jax.jacfwd(f_vec))(pts)
print("雅可比批量形状:", J.shape)                   # (5, 2, 2)

x0 = pts[0]
J0_manual = jnp.array([[jnp.cos(x0[0]) * x0[1], jnp.sin(x0[0])],
                       [2 * x0[0], 1.0]])
assert jnp.allclose(J[0], J0_manual, atol=1e-6)
print("✔ 第一个点与手工推导一致")

jac_vectorized = jnp.vectorize(jax.jacfwd(f_vec), signature="(n)->(m,n)")
assert jnp.allclose(jac_vectorized(pts), J)
print("✔ jnp.vectorize 结果一致")
print("任意多个前导批维度:", jac_vectorized(pts.reshape(5, 1, 2)).shape)
雅可比批量形状: (5, 2, 2)
✔ 第一个点与手工推导一致
✔ jnp.vectorize 结果一致
任意多个前导批维度: (5, 1, 2, 2)