第 8 章 调试工具链

前面的章节里,调试手段散落在好几处:入门第 2 章 讲了 jax_log_compiles 和 jax.debug.print,入门第 7 章 讲了 jax_debug_nans、checkify 和编译预算,进阶第 2 章 讲了 jax_explain_cache_misses 和 profiler。这一章把它们按「症状 → 工具 → 用法」重新组织,再补上其他常用工具:debug.callback、debug.breakpoint、checkify 的各类检查、tracer 泄漏检查、traceback 过滤、live_arrays、transfer_guard、jax_debug_key_reuse 等。最后用一个「带 3 个隐藏 bug 的训练脚本」把整套流程走一遍。

环境:JAX / jaxlib 0.12.0.1,Python 3.14,仅 CPU。本章每个结论都在这个环境里跑过,并用 assert 验证;计时只打印,不做断言。前面章节已经讲透的内容只给结论和链接,不再重复。

学习目标

学完本章,你应该能够:

  1. 看到一个症状(NaN、数值不对、反复编译、Tracer 报错、内存上涨、变慢、多设备不一致),立刻知道先用哪个工具;
  2. 在 jit / vmap / grad 里看到运行时的值:jax.debug.print、jax.debug.callback、jax.debug.breakpoint、jax.disable_jit(),并清楚它们在各种变换下的行为;
  3. 看到程序本身:jaxpr、StableHLO、优化后 HLO,用 jax.named_scope 在 HLO 里定位自己的代码;
  4. 用 jax_debug_nans、checkify、check_grads 抓数值问题,知道 0.12.0.1 中 jax_debug_nans 的漏检问题怎么规避;
  5. 认出每一种 Tracer 类错误,给出标准修法;用泄漏检查抓 tracer 泄漏;
  6. 读懂 JAX 的报错栈,会切换 JAX_TRACEBACK_FILTERING,会检查当前配置;
  7. 用 memory_analysis、live_arrays、delete、clear_caches 排查内存;用 jax.typeof 和分片检查工具排查多设备问题。

目录

#小节主要工具
0症状速查表 · 公共设置—
1看值jax.debug.print / callback / breakpoint、jax.disable_jit()
2看程序make_jaxpr、lower().as_text()、compile().as_text()、jax.stages、jax.named_scope
3数值问题jax_debug_nans / jax_debug_infs、checkify、check_grads
4Tracer 类错误大全jax.errors.*、jax.checking_leaks()
5编译与速度jax_log_compiles、jax_explain_cache_misses、_cache_size、编译计数、transfer_guard
6报错信息与配置JAX_TRACEBACK_FILTERING、jax.config、jax.print_environment_info()
7内存memory_analysis()、save_device_memory_profile、jax.live_arrays()、x.delete()、jax.clear_caches()
8多设备jax.typeof、visualize_array_sharding 及其替代、inspect_array_sharding
9综合演练:带 3 个隐藏 bug 的训练脚本以上全部
—练习 · 测验 · 小结与速查表—

0 症状速查表

🟢 必读

先看症状,再找工具。右边一列是前面章节里已经讲透的部分,这里不再重复。

症状先做什么再用什么本章已有笔记
输出出现 NaN / inf每步在主机端检查 loss 是否有限checkify(float_checks)定位原语;或在 with jax.debug_nans(True): 里重跑出事的那一步(用 config.update 全局打开时要先 jax.clear_caches())§3、§9入门 07 专题 2
结果数值不对(不报错)用 NumPy float64 对拍jax.disable_jit() 二分:eager 对、jit 错 → 编译层面问题;再看 jaxpr / HLO§1.4、§200 已知问题 1、进阶 02 · 2.3
编译太慢 / 反复编译数编译次数(CompileCounter)jax_log_compiles 看形状,jax_explain_cache_misses 看原因,_cache_size() 看缓存条目§502 · 2.3 总表、入门 07 专题 1
报 Tracer 错误读报错里「The error occurred while tracing … argument x」这一句按 §4 的表换写法:jnp.where / lax.cond / 静态参数 / size=§402 · 2.4–2.6
UnexpectedTracerError找函数里把中间值存到外部的地方jax.checking_leaks() 在追踪结束时直接报出引用链§4.6进阶 01
内存爆了compile().memory_analysis() 预估jax.live_arrays() 找谁没释放;x.delete()、jax.clear_caches();save_device_memory_profile 看分配位置§7进阶 02 · 2.1、入门 07 专题 6
速度慢先排除重复编译,计时记得 block_until_readyjax.stages 拆分编译 / 运行时间;transfer_guard 抓隐式传输;profiler§5.5进阶 02 · 2.1、2.7
多设备结果不一致jax.typeof(x) 看分片类型检查各副本(addressable_shards)是否一致;shard_map 里用 debug.print 看每个设备的局部块§805 · 5.7、进阶 03
随机性不对(每步 dropout 一样)打印每步 key 的 key_datajax_debug_key_reuse 直接报出复用的 key§901 · 1.5 随机数、进阶 10 随机数与分片

⚠️ 本环境已知问题(→ 00_目录与导读):jaxlib 0.12.0.1 的 CPU 后端在「jit 闭包捕获常量矩阵 → W @ x → 求和」时会悄悄算错。所以本章所有权重和数据一律作为函数参数传入。这个 bug 本身就是「结果数值不对」的典型案例,§1.4 会用它演示二分定位。

公共设置

⚪ 选读:本章共用的小工具(子进程、日志收集、编译计数),用到时回来看定义即可

下面的单元定义全章共用的小工具:

  • show_error(fn):运行 fn,打印异常类型和第一行信息,并返回异常对象,方便后面 assert isinstance(...);
  • capture_stdout(fn):收集 fn 运行期间打印到 stdout 的内容(包括 jax.debug.print 的输出),用于断言;
  • capture_jax_logs:临时接管 jax logger,把编译日志收集起来(写法同 入门 07 专题 1.4);
  • CompileCounter:基于 jax.monitoring 事件的编译计数器(原理见 入门 07 专题 1.4,这里是精简版);
  • run_py(code, ...):在全新的子进程里运行一段脚本。XLA_FLAGS、JAX_* 环境变量和某些全局状态只能在子进程里干净地演示。
import os, sys, io, re, gc, time, json, logging, subprocess, textwrap, traceback, contextlib, functools, warnings
from functools import partial
from collections import Counter

import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
import jax.monitoring as monitoring
from jax.experimental import checkify
import jax.test_util                      # 必须显式导入(见第 3 章),只 import jax 时访问 jax.test_util 会 AttributeError
from jax.test_util import check_grads

print("JAX", jax.__version__, "| 后端", jax.default_backend(), "| 设备", jax.devices())
CONFIG_AT_START = dict(jax.config.values)   # 记下开头的全部配置,最后检查调试开关是否都已复原


def to64(a):
    """转成 NumPy float64,用于对拍。"""
    return np.asarray(a, dtype=np.float64)


def show_error(fn):
    """运行 fn;报错时打印「类型: 第一行信息」并返回异常对象,不报错时返回 None。"""
    try:
        out = fn()
    except Exception as e:
        first = str(e).strip().splitlines()[0] if str(e).strip() else ""
        print(f"  → {type(e).__name__}: {first[:150]}")
        return e
    print("  → 没有报错,结果:", out)
    return None


def capture_stdout(fn):
    """运行 fn 并收集 stdout。jax.debug.print 是异步回调,所以最后要 effects_barrier() 等它打印完。"""
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        jax.block_until_ready(fn())
        jax.effects_barrier()
    return buf.getvalue()


class capture_jax_logs:
    """临时接管 'jax' logger,把日志收集到 self.messages(不再打印到 stderr)。"""
    def __enter__(self):
        self.logger = logging.getLogger("jax")
        self.messages = []
        self.handler = logging.Handler()
        self.handler.emit = lambda record: self.messages.append(record.getMessage())
        self._old_propagate = self.logger.propagate
        self.logger.addHandler(self.handler)
        self.logger.propagate = False
        return self

    def __exit__(self, *exc):
        self.logger.removeHandler(self.handler)
        self.logger.propagate = self._old_propagate
        return False


class CompileCounter:
    """统计 with 块内的 XLA 后端编译次数;name="f" 时只数 jit(f)。"""
    EVENT = "/jax/core/compile/backend_compile_duration"

    def __init__(self, name=None):
        self.name, self.events = name, []
        self._listener = self._on_event          # 保存同一个绑定方法对象,注销时才找得到

    def _on_event(self, event, duration, **kw):
        fun = kw.get("fun_name", "?")
        if event == self.EVENT and (self.name is None or fun == f"jit({self.name})"):
            self.events.append(fun)

    def __enter__(self):
        monitoring.register_event_duration_secs_listener(self._listener)
        return self

    def __exit__(self, *exc):
        monitoring.unregister_event_duration_listener(self._listener)
        return False

    @property
    def count(self):
        return len(self.events)


def run_py(code, *args, env=None, stdin=None, timeout=300):
    """在全新的 Python 子进程里运行 code,返回 CompletedProcess。env 中的键会覆盖当前环境变量。"""
    full_env = {k: v for k, v in os.environ.items() if not k.startswith(("XLA_FLAGS", "JAX_"))}
    full_env["PYTHON_COLORS"] = "0"            # Python 3.14 的彩色 traceback 会在输出里混入 ANSI 转义码
    full_env.update(env or {})
    return subprocess.run([sys.executable, "-c", textwrap.dedent(code), *args], input=stdin,
                          capture_output=True, text=True, env=full_env, timeout=timeout)


def result_lines(proc):
    """取出子进程 stdout 中 'RESULT ' 之后的内容(它前面可能紧跟着调试器提示符 '(jdb) '),其他输出当作噪声忽略。"""
    return [l.split("RESULT ", 1)[1] for l in proc.stdout.splitlines() if "RESULT " in l]
JAX 0.12.0.1 | 后端 cpu | 设备 [CpuDevice(id=0)]

1 看值:在编译后的代码里看到运行时数值

jit 里的普通 print 只在追踪时执行一次,打印出来的是 tracer(02 · 2.6 补充 里演示过)。要看到运行时的值,有四种工具:

工具适合代价
jax.debug.print快速打印几个中间量一次主机回调;不影响编译结果的数值
jax.debug.callback把值交给任意 Python 函数(收集统计量、写日志、做断言)同上;回调必须返回 None
jax.debug.breakpoint停在某处,交互式查看变量需要 stdin(交互终端)
jax.disable_jit()整段代码按普通 Python 跑,能用 print、pdb、Python if很慢;vmap 仍然会追踪

1.1 jax.debug.print

🟢 必读

基础用法({} 占位符、不要用 f-string、每次运行都打印)见 02 · 2.6。下面再补充四件事:格式说明符、推荐的两段式写法、ordered=True,以及在 vmap / grad / cond 下的行为。

格式化:格式串按 str.format 的规则处理,所以 {m:.3f}、位置参数 {}、关键字参数 {name} 都可以用。格式化发生在运行时的主机端,拿到的是具体数值。

@jax.jit
def stats(x):
    jax.debug.print("均值={m:.3f}  最大值={mx:.2f}  元素数={n}", m=x.mean(), mx=x.max(), n=x.size)
    jax.debug.print("位置参数:首={} 尾={}", x[0], x[-1])
    return x * 2

out = capture_stdout(lambda: stats(jnp.array([1.0, 2.0, 4.5])))
print(out)
assert "均值=2.500" in out and "最大值=4.50" in out and "元素数=3" in out
assert "位置参数:首=1.0 尾=4.5" in out
位置参数:首=1.0 尾=4.5
均值=2.500  最大值=4.50  元素数=3

# ❌ 错误写法:f-string 在追踪时就被求值,打印出来的是 tracer,而且之后每次运行都打印这同一行
@jax.jit
def fstring_print(x):
    jax.debug.print(f"x = {x}")
    return x

try:
    out_bad = capture_stdout(lambda: fstring_print(jnp.float32(3.0)))
    print("实测输出:", out_bad.strip())
except Exception as e:                     # 如果 tracer 的字符串里恰好有花括号,还会直接报格式错误
    out_bad = f"{type(e).__name__}: {e}"
    print("实测报错:", out_bad)
assert "Tracer" in out_bad and "3.0" not in out_bad
实测输出: x = JitTracer(float32[])
# ✅ 正确写法:格式串是普通字符串,数值作为参数传进去
@jax.jit
def good_print(x):
    jax.debug.print("x = {x}", x=x)
    return x

out_good = capture_stdout(lambda: good_print(jnp.float32(3.0)))
print("实测输出:", out_good.strip())
assert out_good.strip() == "x = 3.0"
实测输出: x = 3.0

两段式写法与 ordered=True。0.12 的文档推荐把选项和格式参数分开写,也就是「两段式」:jax.debug.print(ordered=True)("step {i}", i=i)。把 ordered= 和格式参数混在一次调用里的写法被标为 soft deprecated(目前用了也不会发警告)。

默认情况下,不同的 debug.print 之间不保证顺序。XLA 可以重排没有数据依赖的操作,在加速器上尤其明显。在本机上,上面第一个例子的两行输出就可能和代码顺序相反。ordered=True 会给这些打印加上一条隐式的「令牌」依赖链,保证它们按程序顺序输出,跨多次 jit 调用也一样。代价是多了一条串行依赖,所以只在顺序确实重要时使用。

log_order = jax.debug.print(ordered=True)          # 第一段:只传选项,得到一个打印函数

@jax.jit
def stage_a(x):
    for i in range(3):                              # Python 循环在追踪时展开成 3 个打印
        log_order("A{i}", i=i)
    return x + 1

@jax.jit
def stage_b(x):
    log_order("B x={x}", x=x)
    return x * 2

out = capture_stdout(lambda: stage_b(stage_a(jnp.float32(1.0))))
print(out)
assert out.split() == ["A0", "A1", "A2", "B", "x=2.0"]
A0
A1
A2
B x=2.0

jax.debug.log:签名和 debug.print 完全相同,区别是输出走 Python 的 logging(logger 名为 jax._src.debugging,INFO 级别),而不是 stdout。训练脚本里已经配置好日志系统时,用它可以让调试输出和其他日志进同一个地方、带上时间戳,也方便按级别关掉。

dbg_logger = logging.getLogger("jax._src.debugging")
old_level = dbg_logger.level
dbg_logger.setLevel(logging.INFO)              # 默认级别下 INFO 不会输出
try:
    with capture_jax_logs() as logs:
        jax.block_until_ready(jax.jit(lambda x: (jax.debug.log("loss = {x:.2f}", x=x), x * 2)[1])(jnp.float32(0.25)))
        jax.effects_barrier()
finally:
    dbg_logger.setLevel(old_level)
print("logging 收到:", logs.messages)
assert logs.messages == ["loss = 0.25"]
logging 收到: ['loss = 0.25']

在变换下的行为(下一个单元逐条验证):

场景行为
vmap按每个样本各打印一次,看到的是单个样本的值
grad / jit(grad)只在前向执行,看到的是 primal 值;反向传播里的梯度看不到(要看梯度,见下面的补充)
lax.cond只打印实际执行的分支
vmap 里的 lax.condcond 被改写成 select,两个分支都执行,每个样本两个分支各打印一次
vmap + ordered=True0.12 中可以正常使用
def body(x):
    jax.debug.print("看到 x = {x}", x=x)
    return jnp.sin(x) * x

def branchy(x):
    return lax.cond(x > 0,
                    lambda x: (jax.debug.print("正分支 {x}", x=x), x)[1],
                    lambda x: (jax.debug.print("负分支 {x}", x=x), -x)[1], x)

out_v = capture_stdout(lambda: jax.vmap(body)(jnp.array([1.0, 2.0, 3.0])))
out_g = capture_stdout(lambda: jax.grad(body)(2.0))
out_jg = capture_stdout(lambda: jax.jit(jax.grad(body))(2.0))
out_c = capture_stdout(lambda: jax.jit(branchy)(jnp.float32(1.0)))
out_vc = capture_stdout(lambda: jax.vmap(branchy)(jnp.array([1.0, -1.0])))
out_vo = capture_stdout(lambda: jax.vmap(lambda x: (jax.debug.print(ordered=True)("有序 {x}", x=x), x)[1])(jnp.arange(2.0)))
for name, o in [("vmap", out_v), ("grad", out_g), ("jit(grad)", out_jg), ("cond", out_c),
                ("vmap(cond)", out_vc), ("vmap + ordered", out_vo)]:
    print(f"{name:15s}", o.strip().replace("\n", " | "))

assert out_v.splitlines() == ["看到 x = 1.0", "看到 x = 2.0", "看到 x = 3.0"]
assert out_g.splitlines() == ["看到 x = 2.0"] and out_jg.splitlines() == ["看到 x = 2.0"]
assert out_c.splitlines() == ["正分支 1.0"]
assert sorted(out_vc.splitlines()) == sorted(["正分支 1.0", "正分支 -1.0", "负分支 1.0", "负分支 -1.0"])
assert out_vo.splitlines() == ["有序 0.0", "有序 1.0"]
vmap            看到 x = 1.0 | 看到 x = 2.0 | 看到 x = 3.0
grad            看到 x = 2.0
jit(grad)       看到 x = 2.0
cond            正分支 1.0
vmap(cond)      负分支 1.0 | 负分支 -1.0 | 正分支 1.0 | 正分支 -1.0
vmap + ordered  有序 0.0 | 有序 1.0

🔍 补充:看反向传播里的梯度——用 custom_vjp 做一个「梯度探针」

⚪ 选读:需要观察某个中间量收到的梯度时

debug.print 放在前向代码里只能看到 primal 值。想看某个中间量收到的梯度,可以写一个恒等函数,用 custom_vjp 在它的反向规则里打印(或回调)余切值。它不影响前向,也不影响梯度,只是在旁边「听」。下面用 jax.debug.callback 把梯度收集起来,和解析式 $\partial L/\partial h = 2h$ 对拍。

GRAD_TAPS = []

@partial(jax.custom_vjp, nondiff_argnums=(1,))
def grad_tap(x, name):
    """恒等函数;反向传播经过这里时,把收到的梯度交给主机端记录下来。"""
    return x

def _grad_tap_fwd(x, name):
    return x, None

def _grad_tap_bwd(name, _, g):
    jax.debug.callback(lambda g: GRAD_TAPS.append((name, np.asarray(g))), g)
    return (g,)

grad_tap.defvjp(_grad_tap_fwd, _grad_tap_bwd)

def tap_loss(w, x):
    h = grad_tap(jnp.tanh(w * x), "h")
    return jnp.sum(h ** 2)

w_t, x_t = jnp.array([1.0, 2.0]), jnp.array([0.5, 0.1])
g_w = jax.jit(jax.grad(tap_loss))(w_t, x_t)
jax.effects_barrier()
name, g_h = GRAD_TAPS[-1]
print(f"{name} 收到的梯度:", g_h, "| 对 w 的梯度:", g_w)
h64 = np.tanh(to64(w_t) * to64(x_t))
assert name == "h" and np.allclose(g_h, 2 * h64, atol=1e-6)                       # ∂L/∂h = 2h
assert np.allclose(g_w, 2 * h64 * (1 - h64 ** 2) * to64(x_t), atol=1e-6)          # 探针不改变梯度
h 收到的梯度: [0.92423433 0.39475057] | 对 w 的梯度: [0.36343098 0.03793723]

1.2 jax.debug.callback

⚪ 选读:要把运行时的值交给任意 Python 函数(统计、日志、主机端断言)时

debug.print 本质上就是 debug.callback 加格式化。直接用 callback 可以把值交给任意 Python 函数:收集统计量、写日志、在主机端做断言。回调收到的是 NumPy 数组,必须返回 None。要把结果送回设备,得用 jax.pure_callback,见 进阶 05 · 5.3,那里有三种回调在 jit / vmap / grad / DCE 下的完整对照表。它和 debug.print 一样支持两段式写法 jax.debug.callback(ordered=True)(fn, *args)。

下面写一个「激活值统计记录器」:在 jit 编译的前向里记录每层输出的均值和标准差。调试训练发散时,这比打印整个数组有用得多。

class ActivationLog:
    """用 jax.debug.callback 在 jit 内记录每层激活值的统计量。"""
    def __init__(self):
        self.records = []

    def record(self, name, x):
        def _host(m, s, frac_dead):              # 在主机端执行,参数是 NumPy 数组
            self.records.append({"layer": name, "mean": float(m), "std": float(s), "dead": float(frac_dead)})
        jax.debug.callback(_host, x.mean(), x.std(), jnp.mean(x <= 0))
        return x

act_log = ActivationLog()

def mlp_forward(params, x):
    for i, (w, b) in enumerate(params):
        x = x @ w + b
        if i < len(params) - 1:
            x = act_log.record(f"relu{i}", jax.nn.relu(x))
    return x

k1, k2, k3 = jax.random.split(jax.random.key(0), 3)
params_cb = [(jax.random.normal(k1, (8, 16)) / jnp.sqrt(8.0), jnp.zeros(16)),
             (jax.random.normal(k2, (16, 16)) / 4.0, jnp.zeros(16)),
             (jax.random.normal(k3, (16, 1)) / 4.0, jnp.zeros(1))]
x_cb = jax.random.normal(jax.random.key(1), (32, 8))
jax.block_until_ready(jax.jit(mlp_forward)(params_cb, x_cb))
jax.effects_barrier()
for r in act_log.records:
    print(f"  {r['layer']}: mean={r['mean']:.3f} std={r['std']:.3f} 死亡比例={r['dead']:.2f}")

# 对拍:eager 下用 NumPy 重新算第一层的统计量
h0 = np.maximum(to64(x_cb) @ to64(params_cb[0][0]), 0)
assert [r["layer"] for r in act_log.records] == ["relu0", "relu1"]
assert abs(act_log.records[0]["mean"] - h0.mean()) < 1e-5 and abs(act_log.records[0]["std"] - h0.std()) < 1e-5

# vmap 下:回调按样本逐个执行(4 个样本 × 2 层 = 8 条记录)
act_log.records.clear()
jax.block_until_ready(jax.vmap(mlp_forward, in_axes=(None, 0))(params_cb, x_cb[:4]))
jax.effects_barrier()
print("vmap 下的记录条数:", len(act_log.records))
assert len(act_log.records) == 8
  relu0: mean=0.483 std=0.742 死亡比例=0.52
  relu1: mean=0.236 std=0.447 死亡比例=0.60
vmap 下的记录条数: 8

1.3 jax.debug.breakpoint

⚪ 选读:需要在 jit 内部交互式查看变量时

jax.debug.breakpoint() 会在运行时停下来,进入一个叫 jdb 的调试器,可以查看当前帧里的变量(已经是具体数值)。常用命令:

命令作用
p 表达式 / pp 表达式打印 / 美化打印
u(up)/ d(down)在调用栈中上下移动
l(list)/ w(where)显示源码 / 显示调用栈
c(continue)继续运行
q(quit)退出(会抛异常中止程序)

在 notebook 里直接运行时,它会等你在输入框里敲命令:

# 需要交互,未执行(nbclient 自动执行时没有可交互的 stdin)
@jax.jit
def f(x):
    y = jnp.sin(x) * 2
    jax.debug.breakpoint()      # 停在这里:(jdb) p y
    return y + 1
f(jnp.arange(3.0))

为了在自动执行的 notebook 里也能演示,下面把同样的代码放进子进程,通过 stdin 依次喂入 p y、p x、c 三条命令。

BP_SCRIPT = """
import jax, jax.numpy as jnp

@jax.jit
def f(x):
    y = jnp.sin(x) * 2
    jax.debug.breakpoint()
    return y + 1

print("RESULT", f(jnp.arange(3.0)).tolist())
"""
proc = run_py(BP_SCRIPT, stdin="p y\np x\nc\n")
print(proc.stdout)
assert "Entering jdb" in proc.stdout and "(jdb)" in proc.stdout
assert "1.6829419" in proc.stdout                                   # p y:sin(1)*2,已经是具体数值
assert "Array([0., 1., 2.], dtype=float32)" in proc.stdout          # p x
res = json.loads(result_lines(proc)[0])                               # 输入 c 后程序继续执行完
assert np.allclose(res, np.sin(np.arange(3.0)) * 2 + 1, atol=1e-6)
Entering jdb:
(jdb) Array([0.       , 1.6829419, 1.8185948], dtype=float32)
(jdb) Array([0., 1., 2.], dtype=float32)
(jdb) RESULT [1.0, 2.6829419136047363, 2.8185949325561523]

条件断点:断点无条件触发时,训练循环里每一步都会停。实战中更常见的是「出现 NaN 时才停」:把 breakpoint 放进 lax.cond 的一个分支里。注意要把想查看的变量作为分支函数的参数传进去,否则 jdb 所在的那一帧(分支函数)里看不到它们。比如用 lambda 闭包引用的话,p y 会报 NameError。

COND_BP_SCRIPT = """
import jax, jax.numpy as jnp
from jax import lax

def stop_if_nan(x, y):            # 想看的变量作为参数传进来
    jax.debug.breakpoint()

@jax.jit
def f(x):
    y = jnp.log(x)
    lax.cond(jnp.any(jnp.isnan(y)), stop_if_nan, lambda x, y: None, x, y)
    return y

print("RESULT ok-run", f(jnp.array([1.0, 2.0])).tolist())     # 不停
print("RESULT nan-run", f(jnp.array([1.0, -2.0])).tolist())   # 停下来,查看后继续
"""
proc = run_py(COND_BP_SCRIPT, stdin="p y\np x\nc\n")
print(proc.stdout)
assert proc.stdout.count("Entering jdb") == 1                        # 只有含 NaN 的那次停下
assert "Array([ 0., nan], dtype=float32)" in proc.stdout and "Array([ 1., -2.], dtype=float32)" in proc.stdout
assert [l.split()[0] for l in result_lines(proc)] == ["ok-run", "nan-run"]
RESULT ok-run [0.0, 0.6931471824645996]
Entering jdb:
(jdb) Array([ 0., nan], dtype=float32)
(jdb) Array([ 1., -2.], dtype=float32)
(jdb) RESULT nan-run [0.0, nan]

1.4 jax.disable_jit():既是调试手段,也是二分工具

🟢 必读

基础用法见 02 · 2.6。它具体会做这几件事(下一个单元验证):

  • 被 jax.jit 包装的函数直接按 Python 执行,参数是具体数组,Python if、print、pdb 都能用;
  • lax.scan / lax.fori_loop / lax.cond 也退化为 Python 实现:循环体逐步执行,看到的是具体值,fori_loop 的下标甚至是 Python int;
  • 但 vmap 不受影响,函数里看到的仍然是 BatchTracer,所以 vmap 里依赖数值的 Python if 照样报错。
seen = []
with jax.disable_jit():
    @jax.jit
    def jitted_branch(x):
        seen.append(("jit 内参数类型", type(x).__name__))
        return x if x > 0 else -x                       # Python if:只有 disable_jit 下才合法
    r1 = jitted_branch(jnp.array(-3.0))
    lax.scan(lambda c, x: (seen.append(("scan 体内 carry", type(c).__name__)) or (c + x, c)), jnp.float32(0), jnp.arange(2.0))
    lax.fori_loop(0, 2, lambda i, v: (seen.append(("fori 下标", type(i).__name__)) or v + i), 0)
    jax.vmap(lambda x: seen.append(("vmap 内参数", type(x).__name__)) or x)(jnp.arange(2.0))
for s in seen:
    print("  ", *s)
kinds = dict(seen)
assert float(r1) == 3.0 and kinds["jit 内参数类型"] == "ArrayImpl"
assert kinds["fori 下标"] == "int" and kinds["vmap 内参数"] == "BatchTracer"
   jit 内参数类型 ArrayImpl
   scan 体内 carry ArrayImpl
   scan 体内 carry ArrayImpl
   fori 下标 int
   fori 下标 int
   vmap 内参数 BatchTracer

用 disable_jit 二分「结果数值不对」。数值可疑时,同一个函数算三遍:jit 版、disable_jit 版、NumPy float64 参考。

  • jit 错、disable_jit 对 → 问题出在编译(融合、常量折叠、后端 bug),接下来看 HLO(§2);
  • 两个都错 → 问题在你的算法或数据;
  • 两个都对、只是和参考差一点 → 多半是 float32 精度,考虑换算法或局部用 float64。

下面用本环境的已知 CPU bug(→ 00_目录与导读,根因分析见 进阶 02 · 2.3)做演示:同样的公式,闭包捕获权重时 jit 结果大错,disable_jit 却是对的,矛头直接指向编译器。

def bisect_numeric(fn, *args, ref):
    """同一个函数分别在 jit / disable_jit 下运行,和参考值比较最大误差。"""
    err_jit = float(np.abs(to64(jax.jit(fn)(*args)) - ref).max())
    with jax.disable_jit():
        err_eager = float(np.abs(to64(fn(*args)) - ref).max())
    print(f"    jit 误差 = {err_jit:.3g} | disable_jit 误差 = {err_eager:.3g}")
    return err_jit, err_eager

W_bug = jax.random.normal(jax.random.key(1), (128, 128)) / jnp.sqrt(128.0)
X_bug = jax.random.normal(jax.random.key(2), (50, 128))
ref_bug = (to64(W_bug) @ to64(X_bug).T).sum(0)                     # NumPy float64 参考值
# ❌ 错误写法:权重被 jit 闭包捕获为常量(jaxlib 0.12 在 macOS / Apple Silicon 的 CPU 后端上会悄悄算错)
def closure_version(X):
    return jax.vmap(lambda x: jnp.sum(W_bug @ x))(X)

try:
    err_jit, err_eager = bisect_numeric(closure_version, X_bug, ref=ref_bug)
except Exception as e:
    print("实测报错:", type(e).__name__, e)
    raise
assert err_eager < 1e-3
import platform, sys
if sys.platform == "darwin" and platform.machine() == "arm64":   # 在 Apple Silicon 上必须复现;不复现说明可能已修复
    assert err_jit > 1.0, "macOS arm64 上没有复现:可能已在新版 jaxlib 修复,请更新相关说明"
if err_jit > 1.0:
    print("jit 错、eager 对 → 问题出在编译这一层")
else:
    print("本机没有触发这个 bug,jit 的结果也是对的(它在 macOS / Apple Silicon 上能复现,在 Linux x86 上测试时没有出现)。二分的思路不变。")
    jit 误差 = 107 | disable_jit 误差 = 5.91e-06
jit 错、eager 对 → 问题出在编译这一层
# ✅ 正确写法:权重作为函数参数传入
def param_version(W, X):
    return jax.vmap(lambda x: jnp.sum(W @ x))(X)

err_jit, err_eager = bisect_numeric(param_version, W_bug, X_bug, ref=ref_bug)
assert err_jit < 1e-3 and err_eager < 1e-3
    jit 误差 = 5.91e-06 | disable_jit 误差 = 5.91e-06

2 看程序:JAX 到底追踪、编译出了什么

数值看不出毛病时,就要看程序本身。一次 jit 调用会经过三层表示,每一层都能打印:

层怎么拿到能回答什么已有笔记
jaxprjax.make_jaxpr(f)(*args)、jax.jit(f).trace(*args).jaxprJAX 认为要算什么:哪些量被当成常量烘焙进去、控制流有没有展开、变换插入了什么进阶 02 · 2.2(遍历、统计原语)
StableHLOjax.jit(f).lower(*args).as_text()交给 XLA 之前的程序;debug_info=True 带源码位置02 · AOT 补充
优化后 HLO….lower(*args).compile().as_text()XLA 实际执行什么:融合、布局、常量;metadata={op_name=...} 可回溯到源码进阶 02 · 2.3(数 fusion、CPU bug 定位)

这一节只讲调试时最常用的两招:从 jaxpr 里看出「被烘焙的常量」,以及用 jax.named_scope 在三层表示里定位自己的代码。

2.1 jaxpr:看出哪些值被当成了常量

🟢 必读

「改了配置,结果却没变」是一类很隐蔽的 bug:jit 函数读取的全局变量在追踪时被烘焙成字面量,之后修改它既不会重新编译,也不会生效(02 · 2.6 问题 5 已实测现象)。jaxpr 能让你直接看到这一点:字面量会以 2.0:f32[] 的形式出现在等式里,而不是作为输入变量。

SCALE = 2.0                                       # 模块级「配置」

def scaled(x):
    return x * SCALE

jaxpr_txt = str(jax.make_jaxpr(scaled)(1.0))
print(jaxpr_txt)
assert "mul a 2.0:f32[]" in jaxpr_txt             # SCALE 以字面量形式出现:它被烘焙进去了
{ lambda ; a:f32[]. let b:f32[] = mul a 2.0:f32[] in (b,) }
# ❌ 错误写法:配置放在全局变量里,jit 之后再改它
scaled_jit = jax.jit(scaled)
before = float(scaled_jit(1.0))
SCALE = 10.0
try:
    after = float(scaled_jit(1.0))
    print(f"修改 SCALE 前 = {before},修改后 = {after}(没有报错,但用的仍是旧值)")
except Exception as e:
    print("实测报错:", type(e).__name__, e)
    raise
assert before == after == 2.0
修改 SCALE 前 = 2.0,修改后 = 2.0(没有报错,但用的仍是旧值)
# ✅ 正确写法:配置作为参数传入;jaxpr 里它变成了输入变量 b,而不是字面量
def scaled_ok(x, scale):
    return x * scale

print(jax.make_jaxpr(scaled_ok)(1.0, SCALE))
scaled_ok_jit = jax.jit(scaled_ok)
assert float(scaled_ok_jit(1.0, 2.0)) == 2.0 and float(scaled_ok_jit(1.0, 10.0)) == 10.0
SCALE = 2.0
{ lambda ; a:f32[] b:f32[]. let c:f32[] = mul a b in (c,) }

2.2 jax.named_scope:给自己的代码贴标签

⚪ 选读:需要在 HLO 或 profiler trace 里定位自己代码时

大模型的 HLO 动辄几千行,找不到「我那一层」对应哪几条指令。with jax.named_scope("名字"): 会把名字加到其中每个操作的名字栈上,它会一路传到:

  1. jaxpr 每个等式的 source_info.name_stack;
  2. StableHLO 的位置信息(要用 as_text(debug_info=True) 才打印出来);
  3. 优化后 HLO 每条指令的 metadata={op_name="jit(f)/名字/..."};
  4. profiler trace 里的操作名(GPU/TPU 上可见,见 进阶 02 · 2.1 补充)。

下面给一个两层网络的每层贴上标签,然后在三层表示里分别把它找出来。

def scoped_mlp(params, x):
    with jax.named_scope("encoder"):
        h = jax.nn.relu(x @ params["w1"])
    with jax.named_scope("head"):
        out = h @ params["w2"]
        loss = jnp.mean(out ** 2)
    return loss

p_ns = {"w1": jnp.ones((4, 8)) * 0.1, "w2": jnp.ones((8, 2)) * 0.1}
x_ns = jnp.ones((3, 4))
jf_ns = jax.jit(scoped_mlp)

# ① jaxpr:每个等式的名字栈
traced = jf_ns.trace(p_ns, x_ns)
stacks = [str(e.source_info.name_stack) for e in traced.jaxpr.jaxpr.eqns]
print("① jaxpr 名字栈:", stacks)

# ② StableHLO:默认文本里没有名字,debug_info=True 才有
lowered = traced.lower()
plain, dbg = lowered.as_text(), lowered.as_text(debug_info=True)
print("② StableHLO 默认文本含 'encoder'?", "encoder" in plain, "| debug_info=True 时:",
      [l.strip() for l in dbg.splitlines() if "encoder/" in l][:2])

# ③ 优化后 HLO:metadata 里的 op_name
compiled = lowered.compile()
def hlo_ops_in_scope(hlo_text, scope):
    """从优化后 HLO 里找出 op_name 含 /scope/ 的指令,返回 (指令名, op_name)。"""
    pat = re.compile(r"%([\w.\-]+) = .*?op_name=\"([^\"]*/" + re.escape(scope) + r"/[^\"]*)\"")
    return [m.groups() for m in pat.finditer(hlo_text)]
enc_ops = hlo_ops_in_scope(compiled.as_text(), "encoder")
print("③ 优化后 HLO 中属于 encoder 的指令:")
for name, op in enc_ops:
    print(f"     %{name:20s} ← {op}")

assert stacks.count("encoder") >= 2 and stacks.count("head") >= 3
assert "encoder" not in plain and "jit(scoped_mlp)/encoder/dot_general" in dbg
assert any(op.endswith("encoder/dot_general") for _, op in enc_ops)
assert np.isclose(float(compiled(p_ns, x_ns)), float(np.mean((np.maximum(to64(x_ns) @ to64(p_ns["w1"]), 0) @ to64(p_ns["w2"])) ** 2)))
① jaxpr 名字栈: ['encoder', 'encoder', 'head', 'head', 'head', 'head']
② StableHLO 默认文本含 'encoder'? False | debug_info=True 时: ['#loc73 = loc("jit(scoped_mlp)/encoder/dot_general"(#loc67))', '#loc74 = loc("jit(scoped_mlp)/encoder/jit(relu)"(#loc68))']
③ 优化后 HLO 中属于 encoder 的指令:
     %dot_general.1        ← jit(scoped_mlp)/encoder/dot_general
     %broadcast.2          ← jit(scoped_mlp)/encoder/jit(relu)
     %max.2                ← jit(scoped_mlp)/encoder/jit(relu)/max
     %ynn_fusion.1         ← jit(scoped_mlp)/encoder/dot_general
     %broadcast_maximum_fusion ← jit(scoped_mlp)/encoder/jit(relu)/max

2.3 jax.stages:三个阶段对象

⚪ 选读:调试时常用的 Traced / Lowered / Compiled 属性速查

jit(f).trace(...) → Traced,.lower() → Lowered,.compile() → Compiled,三个类都在 jax.stages 里。它们各自的用途(分阶段计时、AOT 预编译、Compiled 只接受编译时的形状)在 02 · AOT 补充 和 入门 07 专题 1.4 里已经实测过。调试时最常用的几个属性如下:

对象属性 / 方法调试用途
Traced.jaxpr、.out_info、.in_avals不编译就能拿到 jaxpr 和输出形状
Lowered.as_text(debug_info=True)、.cost_analysis()看交给 XLA 的程序及其源码位置
Compiled.as_text()、.memory_analysis()、.cost_analysis()、.input_shardings / .output_shardings看优化结果、内存(§7)、分片(§8)
print("类型:", type(traced).__name__, "→", type(lowered).__name__, "→", type(compiled).__name__)
print("Traced.out_info:", traced.out_info, "| Compiled.input_shardings[0] 的键:", sorted(compiled.input_shardings[0][0]))
assert isinstance(traced, jax.stages.Traced) and isinstance(lowered, jax.stages.Lowered)
assert isinstance(compiled, jax.stages.Compiled)
assert traced.out_info.shape == () and traced.out_info.dtype == jnp.float32
类型: Traced → Lowered → Compiled
Traced.out_info: ShapeDtypeStruct(shape=(), dtype=float32) | Compiled.input_shardings[0] 的键: ['w1', 'w2']

3 数值问题:NaN、inf、越界、除零和错误的梯度

工具机制适合
jax_debug_nans / jax_debug_infs全局开关:每次输出后检查,发现后逐原语重跑,在产生 NaN 的原语处抛 FloatingPointError调试时临时打开,单独重跑出事的那一步
checkify把检查编译进程序,返回一个错误值,由你决定何时抛出长期留在代码里;能和 jit / vmap / grad 组合;还能查越界和除零
jax.test_util.check_grads用有限差分对比 jvp / vjp写了 custom_jvp / custom_vjp 或手写梯度之后

3.1 jax_debug_nans / jax_debug_infs

🟢 必读

各种场景下它能抓到什么、抓不到什么(eager、jit 输出、反向传播、被 where 丢掉的 NaN、只有 inf),入门 07 专题 2.4 已经逐条试过,这里不再重复。这一节补充三点:作用域写法、本环境漏检问题的精确复现,以及反向传播里的 NaN 为什么定位不到源码行。

作用域写法:jax.debug_nans、jax.debug_infs 本身就是上下文管理器,with 块结束时自动复原,比 config.update + finally 更不容易忘记关。

with jax.debug_nans(True):                          # with 写法不受下面的 vmap 漏检问题影响,不用清缓存
    print("块内:", jax.config.jax_debug_nans)
    e_nan = show_error(lambda: jax.jit(lambda x: jnp.log(x))(jnp.array(-1.0)))
with jax.debug_infs(True):
    e_inf = show_error(lambda: jax.jit(lambda x: 1.0 / x)(jnp.array(0.0)))
print("块外:", jax.config.jax_debug_nans, jax.config.jax_debug_infs)
assert isinstance(e_nan, FloatingPointError) and "nan" in str(e_nan)
assert isinstance(e_inf, FloatingPointError) and "inf" in str(e_inf)
assert jax.config.jax_debug_nans is False and jax.config.jax_debug_infs is False
块内: True
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
  → FloatingPointError: invalid value (nan) encountered in log
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
  → FloatingPointError: invalid value (inf) encountered in div
块外: False False

⚠️ 本环境已知问题:全局打开时,调用过 vmap 之后会漏检(→ 00 已知问题 2;这个问题的完整复现和触发条件以本节为准)。下面在全新的子进程里精确复现。开关从一开始就打开,先让一个 jit 函数和一个 eager 原语(log)正常执行一次,然后分四种情况,再喂入会产生 NaN 的输入:

  • none:进程里从没用过 vmap;
  • vmap:中间调用过一次和它们毫不相关的 vmap;
  • clear:调用过 vmap,然后 jax.clear_caches();
  • with:开关不用 config.update,而是用 jax.debug_nans(True) 上下文管理器打开(相当于把整段代码放进 with 块),中间同样调用过 vmap。

前三种都用 jax.config.update("jax_debug_nans", True) 全局打开。

NAN_PROBE = """
import sys, jax, jax.numpy as jnp
mode = sys.argv[1]
if mode == "with":
    jax.debug_nans(True).__enter__()               # 等价于把下面整段放进 with 块
else:
    jax.config.update("jax_debug_nans", True)
f_old = jax.jit(lambda x: jnp.log(x))
f_old(jnp.array(2.0)).block_until_ready()          # 先正常执行一次
jnp.log(jnp.array(2.0))                            # eager 的 log 也先执行一次
if mode in ("vmap", "clear", "with"):
    jax.vmap(lambda x: x * 2)(jnp.ones(3))         # 一次无关的 vmap
if mode == "clear":
    jax.clear_caches()                             # 规避方法
cases = {
    "已执行过的 jit 函数": lambda: f_old(jnp.array(-1.0)),
    "已执行过的 eager 原语 log": lambda: jnp.log(jnp.array(-1.0)),
    "没执行过的 eager 原语 sqrt": lambda: jnp.sqrt(jnp.array(-1.0)),
    "新建的 jit 函数": lambda: jax.jit(lambda x: jnp.arccos(x))(jnp.array(3.0)),
}
for name, fn in cases.items():
    try:
        fn(); print("RESULT", name, "漏检")
    except FloatingPointError:
        print("RESULT", name, "捕获")
"""
nan_table = {}
for mode in ["none", "vmap", "clear", "with"]:
    for line in result_lines(run_py(NAN_PROBE, mode)):
        name, verdict = line.rsplit(" ", 1)
        nan_table.setdefault(name, {})[mode] = verdict
print(f"{'场景':24s} {'没用过 vmap':10s} {'用过 vmap':10s} {'vmap+clear':10s} {'with 写法+vmap':10s}")
for name, row in nan_table.items():
    print(f"{name:22s} {row['none']:12s} {row['vmap']:12s} {row['clear']:12s} {row['with']}")

assert all(row["none"] == row["clear"] == row["with"] == "捕获" for row in nan_table.values())
assert nan_table["已执行过的 jit 函数"]["vmap"] == "漏检" and nan_table["已执行过的 eager 原语 log"]["vmap"] == "漏检"
assert nan_table["没执行过的 eager 原语 sqrt"]["vmap"] == "捕获" and nan_table["新建的 jit 函数"]["vmap"] == "捕获"
场景                       没用过 vmap   用过 vmap    vmap+clear with 写法+vmap
已执行过的 jit 函数           捕获           漏检           捕获           捕获
已执行过的 eager 原语 log     捕获           漏检           捕获           捕获
没执行过的 eager 原语 sqrt    捕获           捕获           捕获           捕获
新建的 jit 函数             捕获           捕获           捕获           捕获

结论:用 config.update 全局打开时,用过 vmap 之后,已经执行过的计算不再检查。eager 原语也算在内,因为它们在内部也有缓存的 jit。新编译的计算不受影响。jax.clear_caches() 把缓存清掉,迫使它们重新编译,检查就恢复了。用 with jax.debug_nans(True): 打开则完全不受影响,四种场景全部捕获。

所以规避方法按优先级是:① 用 with jax.debug_nans(True): 包住要检查的那一步(本章的写法);② 必须全局打开时,打开后先 jax.clear_caches();③ 长期留在代码里的检查改用 checkify(§3.2)。

🔍 补充:触发漏检的是「追踪 vmap」,不是「执行 vmap」

⚪ 选读:想知道哪些操作会触发漏检时;结论记住「全局打开时,追踪过 vmap 就要清缓存」即可

到底哪些操作会触发漏检?下面在子进程里逐个试:只做一次 make_jaxpr(vmap(...))(只追踪、不执行)就足以触发;grad、scan、checkify、普通 make_jaxpr、重复 config.update 都不会。这说明问题出在 vmap 追踪时改动的某个全局状态和 jit 缓存的交互上,而不是 vmap 的执行本身。实战含义:只要进程里任何地方(包括你用的库)追踪过 vmap,就默认 jax_debug_nans 对旧缓存失效。

TRIGGER_PROBE = """
import sys, jax, jax.numpy as jnp
from jax.experimental import checkify
jax.config.update("jax_debug_nans", True)
f = jax.jit(lambda x: jnp.log(x))
f(jnp.array(2.0)).block_until_ready()
m = sys.argv[1]
if m == "grad": jax.grad(lambda x: x * 2)(1.0)
if m == "scan": jax.lax.scan(lambda c, x: (c + x, x), 0.0, jnp.ones(3))
if m == "make_jaxpr": jax.make_jaxpr(lambda x: x * 2)(1.0)
if m == "checkify": checkify.checkify(lambda x: x * 2)(1.0)
if m == "config.update": jax.config.update("jax_debug_nans", True)
if m == "make_jaxpr(vmap)": jax.make_jaxpr(jax.vmap(lambda x: x * 2))(jnp.ones(3))
if m == "jit(vmap)": jax.jit(jax.vmap(lambda x: x * 2))(jnp.ones(3))
try:
    f(jnp.array(-1.0)); print("RESULT 漏检")
except FloatingPointError:
    print("RESULT 捕获")
"""
trig = {m: result_lines(run_py(TRIGGER_PROBE, m))[0]
        for m in ["grad", "scan", "make_jaxpr", "checkify", "config.update", "make_jaxpr(vmap)", "jit(vmap)"]}
for m, v in trig.items():
    print(f"  先做 {m:18s} → 旧 jit 函数的 NaN:{v}")
assert [m for m, v in trig.items() if v == "漏检"] == ["make_jaxpr(vmap)", "jit(vmap)"]
  先做 grad               → 旧 jit 函数的 NaN:捕获
  先做 scan               → 旧 jit 函数的 NaN:捕获
  先做 make_jaxpr         → 旧 jit 函数的 NaN:捕获
  先做 checkify           → 旧 jit 函数的 NaN:捕获
  先做 config.update      → 旧 jit 函数的 NaN:捕获
  先做 make_jaxpr(vmap)   → 旧 jit 函数的 NaN:漏检
  先做 jit(vmap)          → 旧 jit 函数的 NaN:漏检

反向传播里的 NaN:jax_debug_nans 只能告诉你原语名。 07 的 locate_nan 遇到反向 NaN 时返回「在 JAX 生成的反向计算里」。下面看一个最常见的来源:对全零向量求 jnp.linalg.norm 的梯度($\nabla\lVert b\rVert = b/\lVert b\rVert = 0/0$)。报错只说 invalid value (nan) encountered in mul。traceback 里除了调用处(以及本章的 show_error 包装),没有任何一帧指向出问题的函数,就算设了 JAX_TRACEBACK_FILTERING=off 也一样。这时更有效的做法是逐叶子检查梯度:先找出是哪个参数的梯度坏了,再缩小范围(§9 的演练会完整走一遍)。

def reg_loss(p, x):
    return jnp.sum((x @ p["w"] + p["b"]) ** 2) + 1e-3 * jnp.linalg.norm(p["b"])   # b 初始化为 0

p_reg = {"w": jnp.ones((3, 2)), "b": jnp.zeros(2)}
x_reg = jnp.ones((4, 3))
with jax.debug_nans(True):
    jax.clear_caches()
    err = show_error(lambda: jax.grad(reg_loss)(p_reg, x_reg))
user_frames = [f for f in traceback.extract_tb(err.__traceback__) if "site-packages" not in f.filename]
print("traceback 里的用户代码帧:", [(f.name, f.line) for f in user_frames])

# 逐叶子检查:哪个参数的梯度含 NaN
grads = jax.grad(reg_loss)(p_reg, x_reg)
bad_leaves = [jax.tree_util.keystr(path) for path, g in jax.tree_util.tree_leaves_with_path(grads)
              if not bool(jnp.all(jnp.isfinite(g)))]
print("梯度含 NaN 的叶子:", bad_leaves)
assert isinstance(err, FloatingPointError) and "mul" in str(err)
assert "reg_loss" not in [f.name for f in user_frames]        # 没有一帧落在 reg_loss 里
assert bad_leaves == ["['b']"]
Invalid nan value encountered in the backward pass of a jax.jit function. Calling the de-optimized backward pass.
  → FloatingPointError: invalid value (nan) encountered in mul
traceback 里的用户代码帧: [('show_error', 'out = fn()'), ('<lambda>', 'err = show_error(lambda: jax.grad(reg_loss)(p_reg, x_reg))')]
梯度含 NaN 的叶子: ["['b']"]

3.2 jax.experimental.checkify

🟢 必读

07 演示了 checkify(f, errors=float_checks) 的基本用法,以及它不受 vmap 漏检问题影响。这里系统地看一遍它的四类检查和 checkify.check 自定义断言:

错误集合包含抓什么
checkify.user_checks(默认)FailedCheckError你自己写的 checkify.check(pred, msg, ...)
checkify.nan_checksNaNError任何原语产生 NaN(报告原语名)
checkify.div_checksDivisionByZeroError除数为 0(整数和浮点除法都算)
checkify.index_checksOOBError越界索引(JAX 默认会悄悄夹紧下标!)
checkify.float_checksNaN + 除零常用组合
checkify.all_checks以上全部调试时最省心

checkify.check 的消息支持 {name} 占位符,用关键字参数传入数组,运行时格式化。下面用同一个函数、四种输入,逐个打开不同的检查集合:

def risky(x, i):
    checkify.check(jnp.all(x > 0), "x 必须为正,最小值 = {m}", m=jnp.min(x))
    y = jnp.log(x)                  # x ≤ 0 → NaN
    z = x[i]                        # i 越界 → JAX 默认夹紧下标,不报错
    w = 7 // i                      # i == 0 → 整数除零
    return y.sum() + z + w

x_ok = jnp.array([1.0, 2.0, 3.0])
inputs = {"正常": (x_ok, 1), "x 含负数": (jnp.array([-1.0, 2.0, 3.0]), 1), "下标越界": (x_ok, 5), "除零": (x_ok, 0)}
sets = {"user": checkify.user_checks, "nan": checkify.nan_checks, "index": checkify.index_checks,
        "div": checkify.div_checks, "all": checkify.all_checks}
table = {}
for sname, errs in sets.items():
    checked = jax.jit(checkify.checkify(risky, errors=errs))
    for iname, args in inputs.items():
        err, _ = checked(*args)
        table[(sname, iname)] = err.get()
print(f"{'检查集合':8s}" + "".join(f"{n:>10s}" for n in inputs))
for sname in sets:
    print(f"{sname:10s}" + "".join(f"{'✔ 抓到' if table[(sname, n)] else '—':>10s}" for n in inputs))
print("\n示例消息:")
for key in [("user", "x 含负数"), ("nan", "x 含负数"), ("index", "下标越界"), ("div", "除零")]:
    print(f"  {key}: {table[key].strip()[:90]}")

assert all(table[(s, "正常")] is None for s in sets)
assert "x 必须为正,最小值 = -1.0" in table[("user", "x 含负数")]
assert "nan generated by primitive: log" in table[("nan", "x 含负数")]
assert "out-of-bounds" in table[("index", "下标越界")] and table[("user", "下标越界")] is None
assert "division by zero" in table[("div", "除零")]
assert all(table[("all", n)] is not None for n in inputs if n != "正常")
clamped = float(jax.jit(lambda x, i: x[i])(x_ok, 5))
print("\n不开检查时 x[5] 的实际结果:", clamped, "(下标被夹紧成 2,静默错误)")
assert clamped == 3.0
检查集合            正常     x 含负数      下标越界        除零
user               —      ✔ 抓到         —         —
nan                —      ✔ 抓到         —         —
index              —         —      ✔ 抓到         —
div                —         —         —      ✔ 抓到
all                —      ✔ 抓到      ✔ 抓到      ✔ 抓到

示例消息:
  ('user', 'x 含负数'): x 必须为正,最小值 = -1.0 (`check` failed)
  ('nan', 'x 含负数'): nan generated by primitive: log.
  ('index', '下标越界'): out-of-bounds indexing for array of shape (3,): index 5 is out of bounds for axis 0 with s
  ('div', '除零'): division by zero

不开检查时 x[5] 的实际结果: 3.0 (下标被夹紧成 2,静默错误)
# ❌ 错误写法:在 jit 里直接用 checkify.check,却没有用 checkify.checkify 包装
def check_only(x):
    checkify.check(x > 0, "x 必须为正")
    return jnp.log(x)

try:
    jax.jit(check_only)(1.0)
    raised = None
except Exception as e:
    raised = e
    print(f"实测报错: {type(e).__name__}: {str(e).splitlines()[0][:120]}")
assert isinstance(raised, ValueError) and "not functionalized" in str(raised)
实测报错: ValueError: Cannot abstractly evaluate a checkify.check which was not functionalized. This probably means you tried to stage (jit/sc
# ✅ 正确写法:先 checkify 再 jit;拿到 err 后,用 err.get() 查看或 err.throw() 抛出
checked_only = jax.jit(checkify.checkify(check_only))
err_ok, _ = checked_only(1.0)
err_bad, _ = checked_only(-1.0)
print("正常输入:", err_ok.get(), "| 坏输入:", err_bad.get())
try:
    err_bad.throw()
except Exception as e:
    thrown = e
    print(f"err.throw() → {type(e).__name__}(继承链 {[c.__name__ for c in type(e).__mro__[:3]]})")
assert err_ok.get() is None and "x 必须为正" in err_bad.get()
assert isinstance(thrown, checkify.JaxRuntimeError) and isinstance(thrown, ValueError)
正常输入: None | 坏输入: x 必须为正 (`check` failed)
err.throw() → JaxRuntimeError(继承链 ['JaxRuntimeError', 'ValueError', 'Exception'])

与 jit / vmap / grad 的组合(下一个单元逐条验证):

组合行为
jit(checkify(f)) 与 checkify(jit(f))结果相同;一般写前者
vmap(checkify(f))返回批量化的错误,get() 列出每个出错样本的下标(at mapped index 1: ...)
checkify(vmap(f))只报告一个出错样本,不告诉你是第几个
checkify(grad(f), errors=nan_checks)能抓到反向传播里产生的 NaN(例如双 where 漏写一个时的 mul)
grad(lambda x: checkify(f)(x)[1])能求导,但错误值被丢掉了——检查要放在 grad 外面
lax.scan 循环体里的 check正常工作,报告第一个失败的迭代的值
float_checks 与 inf不抓 inf(exp(1000.) 不报错);inf 要自己写 check(jnp.isfinite(...))
checkify.debug_check没有被 checkify 包装时是空操作(不报错,也不检查);包装后等同于 check
def pos_log(x):
    checkify.check(x > 0, "x 必须为正,实际为 {v}", v=x)
    return jnp.log(x)

xs_mixed = jnp.array([1.0, -2.0, 3.0, -4.0])
r = {}
r["jit(checkify)"] = jax.jit(checkify.checkify(pos_log))(jnp.float32(-1.0))[0].get()
r["checkify(jit)"] = checkify.checkify(jax.jit(pos_log))(jnp.float32(-1.0))[0].get()
r["vmap(checkify)"] = jax.vmap(checkify.checkify(pos_log))(xs_mixed)[0].get()
r["checkify(vmap)"] = checkify.checkify(jax.vmap(pos_log))(xs_mixed)[0].get()
bad_where = lambda x: jnp.sum(jnp.where(x > 0, jnp.sqrt(x), 0.0))          # 单 where:反向会出 NaN
r["checkify(grad)"] = checkify.checkify(jax.grad(bad_where), errors=checkify.nan_checks)(jnp.array([0.0, 1.0]))[0].get()
g_inside = jax.grad(lambda x: checkify.checkify(pos_log)(x)[1])(jnp.float32(-1.0))
def scan_body(c, x):
    checkify.check(x < 10, "第 {i} 步的值太大:{x}", i=c[1], x=x)
    return (c[0] + x, c[1] + 1), None
r["scan"] = jax.jit(checkify.checkify(lambda xs: lax.scan(scan_body, (0.0, 0), xs)[0]))(jnp.array([1.0, 20.0, 30.0]))[0].get()
r["float_checks 与 inf"] = checkify.checkify(jnp.exp, errors=checkify.float_checks)(jnp.float32(1000.0))[0].get()
@jax.jit
def dbg_fn(x):
    checkify.debug_check(x > 0, "debug_check 失败:{x}", x=x)
    return x * 2
dbg_plain = float(dbg_fn(-1.0))
r["debug_check + checkify"] = checkify.checkify(dbg_fn)(-1.0)[0].get()
for k, v in r.items():
    print(f"  {k:22s} → {v.strip() if v else None}".replace("\n", " ⏎ "))
print("  grad(checkify(f)[1]) 的梯度:", g_inside, "| debug_check 未包装时的结果:", dbg_plain)

assert r["jit(checkify)"] == r["checkify(jit)"] and "-1.0" in r["jit(checkify)"]
assert "at mapped index 1" in r["vmap(checkify)"] and "at mapped index 3" in r["vmap(checkify)"]
assert "mapped index" not in r["checkify(vmap)"] and r["checkify(vmap)"].count("必须为正") == 1
assert "nan generated by primitive: mul" in r["checkify(grad)"]
assert float(g_inside) == -1.0                                  # 能求导,但检查被丢掉了
assert "第 1 步的值太大:20.0" in r["scan"]
assert r["float_checks 与 inf"] is None
assert dbg_plain == -2.0 and "debug_check 失败" in r["debug_check + checkify"]
  jit(checkify)          → x 必须为正,实际为 -1.0 (`check` failed)
  checkify(jit)          → x 必须为正,实际为 -1.0 (`check` failed)
  vmap(checkify)         → at mapped index 1: x 必须为正,实际为 -2.0 (`check` failed) ⏎ at mapped index 3: x 必须为正,实际为 -4.0 (`check` failed)
  checkify(vmap)         → x 必须为正,实际为 -4.0 (`check` failed)
  checkify(grad)         → nan generated by primitive: mul.
  scan                   → 第 1 步的值太大:20.0 (`check` failed)
  float_checks 与 inf     → None
  debug_check + checkify → debug_check 失败:-1.0 (`check` failed)
  grad(checkify(f)[1]) 的梯度: -1.0 | debug_check 未包装时的结果: -2.0

🔍 补充:checkify.check 的格式参数必须是数组

⚪ 选读:在 eager 下用 checkify.check 报 TypeError 时

在 jit 里,checkify.check(..., v=x) 的 x 是 tracer,没有问题。但在 eager 下直接传 Python 标量会报 TypeError: Formatting arguments to checkify.check need to be PyTrees of arrays。写成 v=jnp.asarray(x) 就可以两种情况通吃。

e_py = show_error(lambda: checkify.checkify(pos_log)(-1.0))
e_arr, _ = checkify.checkify(pos_log)(jnp.asarray(-1.0))
assert isinstance(e_py, TypeError) and "PyTrees of arrays" in str(e_py)
assert "实际为 -1.0" in e_arr.get()
  → TypeError: Formatting arguments to checkify.check need to be PyTrees of arrays, but got -1.0 of type <class 'float'>.

3.3 梯度检查:jax.test_util.check_grads

⚪ 选读:写了 custom_vjp 或手写梯度、需要验证时

基础用法(必须显式 import jax.test_util、order=2 查二阶、手写中心差分)见 03 · 有限差分检验梯度,那里用 custom_jvp 演示了它抓错误导数。这里补充 custom_vjp 的情况:

  • 只能用 modes=["rev"];modes=["fwd"] 会报 TypeError: can't apply forward-mode autodiff (jvp) to a custom_vjp function;
  • 失败时抛 AssertionError,信息里有 VJP cotangent projection 和实际 / 期望值;
  • float32 下默认容差约 2e-3,必要时用 atol / rtol / eps 调整,或在 with jax.enable_x64(True): 里用 float64 输入检查。
# ❌ 错误写法:custom_vjp 的反向规则写错了符号(softplus 的导数应为 sigmoid(x))
@jax.custom_vjp
def softplus_bad(x):
    return jnp.logaddexp(x, 0.0)
softplus_bad.defvjp(lambda x: (softplus_bad(x), x), lambda x, g: (g * jax.nn.sigmoid(-x),))

x_cg = jnp.array([-1.0, 0.5, 2.0])
try:
    check_grads(softplus_bad, (x_cg,), order=1, modes=["rev"])
    cg_err = None
except AssertionError as e:
    cg_err = e
    print("check_grads 发现错误:", " | ".join(str(e).strip().splitlines()[:2]))
e_fwd = show_error(lambda: check_grads(softplus_bad, (x_cg,), order=1, modes=["fwd"]))
assert cg_err is not None and "VJP" in str(cg_err)
assert isinstance(e_fwd, TypeError) and "custom_vjp" in str(e_fwd)
check_grads 发现错误: Not equal to tolerance rtol=0.002, atol=0.002 | VJP cotangent projection
  → TypeError: can't apply forward-mode autodiff (jvp) to a custom_vjp function.
# ✅ 正确写法:反向规则用 sigmoid(x),check_grads 安静通过(通过时不输出任何东西)
@jax.custom_vjp
def softplus_ok(x):
    return jnp.logaddexp(x, 0.0)
softplus_ok.defvjp(lambda x: (softplus_ok(x), x), lambda x, g: (g * jax.nn.sigmoid(x),))

check_grads(softplus_ok, (x_cg,), order=1, modes=["rev"])
assert np.allclose(jax.grad(lambda x: softplus_ok(x).sum())(x_cg), 1 / (1 + np.exp(-to64(x_cg))), atol=1e-6)
print("check_grads 通过 ✔")
check_grads 通过 ✔

4 Tracer 类错误大全

在 jit / vmap 里,函数的参数是只知道形状和 dtype 的 tracer。凡是需要「具体数值」的 Python 操作(if、range、float()、np.asarray、按值决定形状……)都会报错。下表是在 0.12.0.1 上跑出来的结果,每一行后面都有对应的 ❌ / ✅ 单元:

触发写法实际异常标准修法
if x > 0:、while x > 1:、and / orTracerBoolConversionErrorjnp.where、lax.cond、lax.while_loop
range(n)、[a, b, c][i](__index__)TracerIntegerConversionErrorstatic_argnames;lax.fori_loop;先 jnp.asarray(list)[i]
np.sin(x)、np.asarray(x)(__array__)TracerArrayConversionError换成 jnp.*;确实要调 NumPy / 外部库时用 jax.pure_callback
float(x)、int(x)、x.item()、jnp.arange(n)、jnp.nonzero(x)ConcretizationTypeError静态参数;固定大小 + 掩码;jnp.nonzero(x, size=k)
x[x > 0](布尔掩码索引)NonConcreteBooleanIndexError(IndexError 子类)jnp.where(mask, x, 0) + 按掩码求和
jnp.zeros(n)、x.reshape(n, -1)(形状里有 tracer)普通 TypeError: Shapes must be 1D sequences of concrete values形状参数设为静态
x[i:i+2](切片边界是 tracer)普通 IndexErrorlax.dynamic_slice(x, (i,), (2,))

⚠️ 常见的误解:很容易以为这三个子类都继承自 ConcretizationTypeError,可以一起捕获(另见 02 · 2.4 补充)。其实在 0.12.0.1 里(下一个单元验证),只有 TracerBoolConversionError 是它的子类。TracerIntegerConversionError 和 TracerArrayConversionError 直接继承 JAXTypeError(它也是 TypeError)。想统一捕获,要用 jax.errors.JAXTypeError 或 TypeError。

E = jax.errors
for cls in [E.ConcretizationTypeError, E.TracerBoolConversionError, E.TracerIntegerConversionError,
            E.TracerArrayConversionError, E.NonConcreteBooleanIndexError, E.UnexpectedTracerError]:
    print(f"  {cls.__name__:30s} → {[c.__name__ for c in cls.__mro__[1:4]]}")
assert issubclass(E.TracerBoolConversionError, E.ConcretizationTypeError)
assert not issubclass(E.TracerIntegerConversionError, E.ConcretizationTypeError)
assert not issubclass(E.TracerArrayConversionError, E.ConcretizationTypeError)
assert all(issubclass(c, E.JAXTypeError) and issubclass(c, TypeError) for c in
           [E.ConcretizationTypeError, E.TracerIntegerConversionError, E.TracerArrayConversionError, E.UnexpectedTracerError])
assert issubclass(E.NonConcreteBooleanIndexError, IndexError)
  ConcretizationTypeError        → ['JAXTypeError', '_JAXErrorMixin', 'TypeError']
  TracerBoolConversionError      → ['ConcretizationTypeError', 'JAXTypeError', '_JAXErrorMixin']
  TracerIntegerConversionError   → ['JAXTypeError', '_JAXErrorMixin', 'TypeError']
  TracerArrayConversionError     → ['JAXTypeError', '_JAXErrorMixin', 'TypeError']
  NonConcreteBooleanIndexError   → ['JAXIndexError', '_JAXErrorMixin', 'IndexError']
  UnexpectedTracerError          → ['JAXTypeError', '_JAXErrorMixin', 'TypeError']

怎么读 Tracer 报错:信息很长,但关键的只有两句。下面把它们提取出来:

  1. 第一行:哪种转换失败了(Attempted boolean conversion of traced array with shape bool[]);
  2. The error occurred while tracing the function <名字> at <文件>:<行> for jit. This concrete value was not available in Python because it depends on the value of the argument <参数名>.(涉及多个参数时写成 the values of the arguments x and limit)。这句直接告诉你是哪个函数、哪个参数惹的祸。修法通常就两种:要么让这个参数变成静态的,要么把依赖它数值的 Python 控制流换成 JAX 原语。
def clip_positive(x, limit):
    if x > limit:                  # 依赖 x 和 limit 的数值
        return limit
    return x

e = show_error(lambda: jax.jit(clip_positive)(jnp.float32(3.0), 1.0))
msg = " ".join(str(e).split())                       # 原信息有换行,先压成一行再匹配
m = re.search(r"The error occurred while tracing the function (\S+) at \S+ for jit\..*?"
              r"depends on the values? of the arguments? (.+?)\.", msg)
print("出问题的函数:", m.group(1), "| 罪魁祸首参数:", m.group(2))
assert isinstance(e, E.TracerBoolConversionError)
assert m.group(1) == "clip_positive" and m.group(2) == "x and limit"   # 两个参数都参与了比较
  → TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
出问题的函数: clip_positive | 罪魁祸首参数: x and limit

4.1 TracerBoolConversionError

🟢 必读

# ❌ 错误写法:Python if 依赖追踪值
@jax.jit
def relu_if(x):
    if x > 0:
        return x
    return 0.0 * x

try:
    relu_if(jnp.float32(2.0))
    caught = None
except Exception as e:
    caught = e
    print(f"实测报错: {type(e).__name__}: {str(e).splitlines()[0]}")
assert isinstance(caught, E.TracerBoolConversionError)
实测报错: TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
# ✅ 正确写法:逐元素用 jnp.where;需要只执行一个分支时用 lax.cond
@jax.jit
def relu_where(x):
    return jnp.where(x > 0, x, 0.0)

@jax.jit
def relu_cond(x):
    return lax.cond(x > 0, lambda x: x, lambda x: 0.0 * x, x)

xs = jnp.array([-2.0, 0.0, 3.0])
assert np.array_equal(relu_where(xs), [0.0, 0.0, 3.0])
assert [float(relu_cond(v)) for v in xs] == [0.0, 0.0, 3.0]
print("jnp.where:", relu_where(xs), "| lax.cond:", [float(relu_cond(v)) for v in xs])
jnp.where: [0. 0. 3.] | lax.cond: [-0.0, 0.0, 3.0]

只用 grad(不加 jit)时 Python if 不会报错,因为 grad 的 tracer 带着具体值(见 00 目录与导读)。但同一个函数一放进 vmap 或 jit 就会报错。别被 eager + grad 的测试骗了。

4.2 TracerIntegerConversionError

⚪ 选读:遇到这个报错时按表查修法

# ❌ 错误写法:用追踪值做 range 的次数、做 Python 列表的下标
def repeat_add(x, n):
    for _ in range(n):
        x = x + 1.0
    return x

def pick(i):
    return [10.0, 20.0, 30.0][i]

for name, fn in [("range(n)", lambda: jax.jit(repeat_add)(1.0, 3)), ("list[i]", lambda: jax.jit(pick)(1))]:
    try:
        fn()
        caught = None
    except Exception as e:
        caught = e
        print(f"{name:9s} 实测报错: {type(e).__name__}: {str(e).splitlines()[0]}")
    assert isinstance(caught, E.TracerIntegerConversionError)
range(n)  实测报错: TracerIntegerConversionError: The __index__() method was called on traced array with shape int32[]
list[i]   实测报错: TracerIntegerConversionError: The __index__() method was called on traced array with shape int32[]
# ✅ 正确写法:次数决定计算结构 → 静态参数;或用 fori_loop;列表先转成数组再索引
repeat_static = jax.jit(repeat_add, static_argnames="n")
repeat_fori = jax.jit(lambda x, n: lax.fori_loop(0, n, lambda _, v: v + 1.0, x))
pick_arr = jax.jit(lambda i: jnp.asarray([10.0, 20.0, 30.0])[i])
assert float(repeat_static(1.0, n=3)) == 4.0 and float(repeat_fori(1.0, 3)) == 4.0 and float(pick_arr(1)) == 20.0
print("static:", repeat_static(1.0, n=3), "| fori_loop:", repeat_fori(1.0, 3), "| 数组索引:", pick_arr(1))
static: 4.0 | fori_loop: 4.0 | 数组索引: 20.0

4.3 TracerArrayConversionError

⚪ 选读:遇到这个报错时按表查修法

# ❌ 错误写法:在 jit 里调用 NumPy 函数(会尝试把 tracer 转成 np.ndarray)
@jax.jit
def np_inside(x):
    return np.sin(x) * 2

try:
    np_inside(jnp.ones(3))
    caught = None
except Exception as e:
    caught = e
    print(f"实测报错: {type(e).__name__}: {str(e).splitlines()[0]}")
assert isinstance(caught, E.TracerArrayConversionError)
实测报错: TracerArrayConversionError: The numpy.ndarray conversion method __array__() was called on traced array with shape float32[3]
# ✅ 正确写法:换成 jnp;必须调用 NumPy / 外部库时,用 pure_callback 声明输出形状(详见进阶 05)
@jax.jit
def jnp_inside(x):
    return jnp.sin(x) * 2

@jax.jit
def via_callback(x):
    out_type = jax.ShapeDtypeStruct(x.shape, x.dtype)
    return jax.pure_callback(lambda v: (np.sin(v) * 2).astype(v.dtype), out_type, x)

ref = np.sin(np.ones(3)) * 2
assert np.allclose(jnp_inside(jnp.ones(3)), ref) and np.allclose(via_callback(jnp.ones(3)), ref)
print("jnp:", jnp_inside(jnp.ones(3)), "| pure_callback:", via_callback(jnp.ones(3)))
jnp: [1.6829419 1.6829419 1.6829419] | pure_callback: [1.682942 1.682942 1.682942]

4.4 ConcretizationTypeError(基类本身)

⚪ 选读:遇到这个报错时按表查修法

# ❌ 错误写法:float() / .item() / 按数值决定大小的 arange 和 nonzero
cases_bad = {
    "float(x)": lambda: jax.jit(lambda x: float(x) * 2)(jnp.float32(1.0)),
    "x.item()": lambda: jax.jit(lambda x: x.item())(jnp.float32(1.0)),
    "jnp.arange(n)": lambda: jax.jit(lambda n: jnp.arange(n))(3),
    "jnp.nonzero(x)": lambda: jax.jit(lambda x: jnp.nonzero(x)[0])(jnp.array([1.0, 0.0, 2.0])),
}
for name, fn in cases_bad.items():
    try:
        fn()
        caught = None
    except Exception as e:
        caught = e
        print(f"{name:15s} 实测报错: {type(e).__name__}: {str(e).splitlines()[0][:80]}")
    assert type(caught) is E.ConcretizationTypeError
float(x)        实测报错: ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: traced array
x.item()        实测报错: ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: traced array
jnp.arange(n)   实测报错: ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: traced array
jnp.nonzero(x)  实测报错: ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: traced array
# ✅ 正确写法:留在 JAX 里计算;按数值决定的大小改为静态参数,或给出固定上限 size=
f_float = jax.jit(lambda x: x * 2)                                    # 不需要 float(),直接算
f_arange = jax.jit(lambda n: jnp.arange(n), static_argnames="n")
f_nonzero = jax.jit(lambda x: jnp.nonzero(x, size=3, fill_value=-1)[0])  # 固定输出长度,不足的用 -1 填充
assert float(f_float(jnp.float32(1.0))) == 2.0
assert f_arange(n=3).tolist() == [0, 1, 2]
assert f_nonzero(jnp.array([1.0, 0.0, 2.0])).tolist() == [0, 2, -1]
print("arange(static n):", f_arange(n=3), "| nonzero(size=3):", f_nonzero(jnp.array([1.0, 0.0, 2.0])))
arange(static n): [0 1 2] | nonzero(size=3): [ 0  2 -1]

4.5 布尔掩码索引、形状和切片里的 tracer

🟢 必读

这三类错误不在 ConcretizationTypeError 家族里,但原因相同:输出形状依赖数值。jit 要求所有形状在追踪时确定。

# ❌ 错误写法:布尔掩码索引 / 形状参数是 tracer / 切片边界是 tracer
cases_shape = {
    "x[x > 0]": (lambda: jax.jit(lambda x: x[x > 0].sum())(jnp.array([1.0, -1.0, 2.0])), E.NonConcreteBooleanIndexError),
    "jnp.zeros(n)": (lambda: jax.jit(lambda n: jnp.zeros(n))(3), TypeError),
    "x[i:i+2]": (lambda: jax.jit(lambda x, i: x[i:i + 2])(jnp.arange(5.0), 1), IndexError),
}
for name, (fn, expected) in cases_shape.items():
    try:
        fn()
        caught = None
    except Exception as e:
        caught = e
        print(f"{name:13s} 实测报错: {type(e).__name__}: {str(e).splitlines()[0][:90]}")
    assert isinstance(caught, expected)
x[x > 0]      实测报错: NonConcreteBooleanIndexError: Array boolean indices must be concrete; got bool[3]
jnp.zeros(n)  实测报错: TypeError: Shapes must be 1D sequences of concrete values of integer type, got (JitTracer(~int32[]),)
x[i:i+2]      实测报错: IndexError: Array slice indices must have static start/stop/step to be used with NumPy indexing syntax
# ✅ 正确写法:掩码求和代替布尔索引;形状设为静态;动态切片用 lax.dynamic_slice(长度必须固定)
pos_sum = jax.jit(lambda x: jnp.sum(jnp.where(x > 0, x, 0.0)))
zeros_n = jax.jit(lambda n: jnp.zeros(n), static_argnums=0)
window = jax.jit(lambda x, i: lax.dynamic_slice(x, (i,), (2,)))
assert float(pos_sum(jnp.array([1.0, -1.0, 2.0]))) == 3.0
assert zeros_n(3).shape == (3,)
assert window(jnp.arange(5.0), 1).tolist() == [1.0, 2.0]
print("掩码求和:", pos_sum(jnp.array([1.0, -1.0, 2.0])), "| dynamic_slice:", window(jnp.arange(5.0), 1))
掩码求和: 3.0 | dynamic_slice: [1. 2.]

4.6 UnexpectedTracerError:tracer 泄漏

🟢 必读

追踪期间把中间值(tracer)存到函数外面,比如全局列表、对象属性、闭包里的可变容器,追踪结束后再去用它,就会得到 UnexpectedTracerError(进阶 01 演示过最简单的情况)。麻烦的是:报错发生在「使用」的地方,而不是「泄漏」的地方,两者可能相隔很远。

# ❌ 错误写法:在 jit 函数里把中间结果存到对象属性上,之后在外面使用
class Model:
    def __call__(self, x):
        h = jnp.tanh(x)
        self.last_hidden = h                 # 泄漏:h 是 tracer
        return h * 2

m_leak = Model()
jax.jit(m_leak)(jnp.ones(3))                 # 这一步不会报错
try:
    jnp.sum(m_leak.last_hidden)              # 很久以后才用到它
    caught = None
except Exception as e:
    caught = e
    print(f"实测报错: {type(e).__name__}: {str(e).splitlines()[0][:110]}")
assert isinstance(caught, E.UnexpectedTracerError)
实测报错: UnexpectedTracerError: Encountered an unexpected tracer. A function transformed by JAX had a side effect, allowing for a reference to
# ✅ 正确写法:需要的中间值作为返回值显式返回
class ModelOK:
    def __call__(self, x):
        h = jnp.tanh(x)
        return h * 2, h                      # (输出, 辅助信息)

out, hidden = jax.jit(ModelOK())(jnp.ones(3))
assert np.allclose(hidden, np.tanh(1.0)) and np.allclose(out, 2 * np.tanh(1.0))
print("显式返回的 hidden:", hidden)
显式返回的 hidden: [0.7615942 0.7615942 0.7615942]

泄漏检查:在泄漏发生的那一刻报错。 0.12 中有三种等价的写法:

写法说明
with jax.checking_leaks():functools.partial(jax.check_tracer_leaks, True),最简洁
with jax.check_tracer_leaks(True):配置项 jax_check_tracer_leaks 的上下文管理器
jax.config.update("jax_check_tracer_leaks", True)全局打开

打开后,每次追踪结束时都会检查有没有 tracer 还被外部引用。如果有,立即抛出异常(类型是普通的 Exception),信息里给出引用链:哪个 tracer、被谁引用(<list> is referred to by __main__.leaked)。它会关闭一部分缓存,带来额外开销,所以只在调试时用。

leaked = []
def leaky_fn(x):
    leaked.append(x)              # 泄漏到全局列表
    return x + 1

jax.clear_caches()
try:
    with jax.checking_leaks():
        jax.jit(leaky_fn)(jnp.float32(1.0))
    leak_err = None
except Exception as e:
    leak_err = e
msg_lines = str(leak_err).splitlines()
print("类型:", type(leak_err).__name__)
for l in msg_lines[:1] + [l for l in msg_lines if "referred to by" in l][:2]:
    print("   ", l[:120])
assert type(leak_err) is Exception and msg_lines[0].startswith("Leaked trace")
assert any("is referred to by" in l and "leaked" in l for l in msg_lines)
leaked.clear()
类型: Exception
    Leaked trace DynamicJaxprTrace. Leaked tracer(s):
    <DynamicJaxprTracer 4663257104> is referred to by <list 4665787840>[0]
    <list 4665787840> is referred to by __main__.leaked

⚠️ 泄漏检查只在「追踪」时进行。 如果函数已经被追踪并缓存过,再打开检查调用它,不会重新追踪,也就不会报错。所以排查前要先 jax.clear_caches()(或者换一个新的输入签名)。

leaked2 = []
def leaky_cached(x):
    leaked2.append(x)
    return x * 2

leaky_cached_jit = jax.jit(leaky_cached)
leaky_cached_jit(jnp.float32(1.0))                         # 先在没开检查时调用一次(已缓存)

def try_with_check():
    try:
        with jax.checking_leaks():
            leaky_cached_jit(jnp.float32(2.0))
        return "没有报错"
    except Exception as e:
        return f"报错:{str(e).splitlines()[0]}"

r_cached = try_with_check()
jax.clear_caches()
r_cleared = try_with_check()
print("命中缓存时:", r_cached)
print("clear_caches 后:", r_cleared)
assert r_cached == "没有报错" and r_cleared.startswith("报错:Leaked trace")
leaked2.clear()
命中缓存时: 没有报错
clear_caches 后: 报错:Leaked trace DynamicJaxprTrace. Leaked tracer(s):

5 编译问题与速度

重新编译的触发条件(形状、dtype、弱类型、静态参数值、PyTree 结构、传参方式、每次新建 jit 对象)在 02 · 2.3 总表 里逐条试过。治理手段(分桶、按值哈希的静态配置、AOT)和编译预算工具 CompileBudget 在 入门 07 专题 1。这一节只讲诊断工具本身的细节:每个工具输出什么,什么时候会「失灵」。

工具回答的问题本节补充
jax_log_compiles编译了什么、花了多久一次编译会输出 4 条日志;jnp.ones 这类 eager 操作也会出现在日志里
jax_explain_cache_misses为什么没命中缓存静态参数值变化时只说 "explanation unavailable"
f._cache_size()缓存里有几个条目私有 API;同一个 Python 函数的所有 jit 包装共享计数
CompileCounter / CompileBudget精确次数,可写进 assert见 入门 07 专题 1.6

5.1 jax_log_compiles:一次编译 = 4 条日志

⚪ 选读:需要解析编译日志、统计每个函数的编译耗时时

除了 config.update,jax.log_compiles() 也是上下文管理器。每次真正发生的编译会输出 4 条 WARNING 日志:追踪耗时、Compiling ... with global shapes and types(带全部参数的形状)、jaxpr → MLIR 耗时、XLA 编译耗时。下面把它们解析成一张「每个函数的编译耗时表」。注意 jnp.ones(...) 这种 eager 调用本身也会编译小函数(jit(broadcast_in_dim) 等),所以要按函数名过滤。

def mlp_step(w, x):
    return jnp.tanh(x @ w).sum()

mlp_step_jit = jax.jit(mlp_step)
jax.clear_caches()                          # 清空缓存,让下面的 eager 操作也重新编译,便于观察
with capture_jax_logs() as logs, jax.log_compiles():
    w5 = jnp.ones((64, 64))                 # eager 操作,也会产生编译日志
    mlp_step_jit(w5, jnp.ones((8, 64)))
    mlp_step_jit(w5, jnp.ones((8, 64)))     # 命中缓存:没有日志
    mlp_step_jit(w5, jnp.ones((16, 64)))    # 新形状:再编译一次

pat_xla = re.compile(r"Finished XLA compilation of (jit\(.+?\)) in ([\d.]+) sec")
pat_shape = re.compile(r"Compiling (jit\(.+?\)) with global shapes and types (.+?)\. Argument")
xla_times = [(m.group(1), float(m.group(2))) for m in map(pat_xla.search, logs.messages) if m]
shapes = [(m.group(1), m.group(2)) for m in map(pat_shape.search, logs.messages) if m]
print("日志行的开头(前两个词):", sorted({m.split(" ")[0] + " " + m.split(" ")[1] for m in logs.messages}))
for (fn, t), (_, sh) in zip(xla_times, shapes):
    print(f"  {fn:28s} XLA 编译 {t * 1e3:6.1f} ms  参数 {sh[:70]}")
mine = [s for fn, s in shapes if fn == "jit(mlp_step)"]
assert len(mine) == 2 and "float32[16,64]" in mine[1]
assert any(fn != "jit(mlp_step)" for fn, _ in shapes)             # 混进了 eager 操作的编译
assert {"Finished tracing", "Compiling jit(mlp_step)", "Finished jaxpr", "Finished XLA"} <= \
       {" ".join(m.split(" ")[:2]) for m in logs.messages}
日志行的开头(前两个词): ['Compiling jit(broadcast_in_dim)', 'Compiling jit(convert_element_type)', 'Compiling jit(mlp_step)', 'Finished XLA', 'Finished jaxpr', 'Finished tracing']
  jit(convert_element_type)    XLA 编译    4.9 ms  参数 (ShapedArray(float32[]),)
  jit(broadcast_in_dim)        XLA 编译   25.5 ms  参数 (ShapedArray(float32[]),)
  jit(broadcast_in_dim)        XLA 编译   25.6 ms  参数 (ShapedArray(float32[]),)
  jit(mlp_step)                XLA 编译   23.0 ms  参数 (ShapedArray(float32[64,64]), ShapedArray(float32[8,64]))
  jit(broadcast_in_dim)        XLA 编译   19.9 ms  参数 (ShapedArray(float32[]),)
  jit(mlp_step)                XLA 编译   23.3 ms  参数 (ShapedArray(float32[64,64]), ShapedArray(float32[16,64]))

5.2 jax_explain_cache_misses:三种解释,一种「失灵」

🟢 必读

它会对比最接近的旧缓存键,说明是哪个参数的什么属性变了(形状、弱类型、PyTree 结构的例子见 入门 07 专题 1.4 和 进阶 02 · 2.5)。在 0.12 里,它的输出分三种:

情况输出
第一次调用never seen function: <名字> id=... defined at <文件:行>
输入类型 / PyTree 结构变了different input types: * at x, now i32[5] and before f32[5],或 different input pytree: ...
静态参数的值变了只有 cache miss explanation unavailable

最后一种最容易让人困惑:工具「什么都没说」。实战经验是:看到 explanation unavailable,就去查静态参数(以及闭包、全局配置这类不在参数列表里的东西)。

@partial(jax.jit, static_argnames=("step",))
def decay_step(w, x, step):
    return w - 0.1 * 0.99 ** step * x

w0, x0 = jnp.ones(3), jnp.ones(3)
with capture_jax_logs() as logs, jax.explain_cache_misses():
    decay_step(w0, x0, step=0)                       # 第一次
    decay_step(w0, x0, step=1)                       # 静态参数值变了
    decay_step(w0.astype(jnp.int32), x0, step=1)     # 输入类型变了
misses = [m for m in logs.messages if "CACHE MISS" in m]
for m in misses:
    body_lines = [l.strip() for l in m.splitlines()[1:] if not l.strip().startswith(("for ", "all previously"))]
    print("  •", " / ".join(body_lines)[:120])
assert len(misses) == 3
assert "never seen function" in misses[0]
assert "explanation unavailable" in misses[1]
assert "different input types" in misses[2] and "at w, now i32[3] and before f32[3]" in misses[2]
  • never seen function: / decay_step id=4653816064 defined at /tmp/ipykernel_82
  • cache miss explanation unavailable
  • different input types: / * at w, now i32[3] and before f32[3]

5.3 f._cache_size()(私有 API)

⚪ 选读:临时查看缓存条目数;私有 API,不要写进测试

jax.jit(f)._cache_size() 返回缓存条目数。它是私有 API(下划线开头,随时可能改名或删除),只适合在调试时临时看一眼,不要写进正式的测试。它有三个特点:

  1. 缓存挂在底层 Python 函数上:对同一个 f 再 jax.jit(f) 一次,新包装器的 _cache_size() 一开始就不是 0;
  2. 带不同 jit 选项的包装器也共享这个计数;
  3. jax.clear_caches() 之后归零。

正因为第 1 点,它不能回答「这个包装器编译了几次」,精确计数请用编译日志或 CompileCounter(02 · 2.3 的说明相同)。

def cache_demo(x):
    return x * 2

j1 = jax.jit(cache_demo)
sizes = {"新建": j1._cache_size()}
j1(1.0); j1(2.0); j1(jnp.ones(3))
sizes["3 次调用(2 种签名)后"] = j1._cache_size()
j2 = jax.jit(cache_demo)
sizes["第二个包装器(还没调用)"] = j2._cache_size()
j2(jnp.ones(4))
sizes["j2 编译一次后,j1 看到的"] = j1._cache_size()
j3 = jax.jit(cache_demo, inline=True)
sizes["带不同选项的包装器"] = j3._cache_size()
jax.clear_caches()
sizes["clear_caches() 后"] = j1._cache_size()
for k, v in sizes.items():
    print(f"  {k:24s} {v}")
assert list(sizes.values()) == [0, 2, 2, 3, 3, 0]
  新建                       0
  3 次调用(2 种签名)后            2
  第二个包装器(还没调用)             2
  j2 编译一次后,j1 看到的          3
  带不同选项的包装器                3
  clear_caches() 后         0

5.4 编译计数:写进断言

⚪ 选读:把编译次数写进断言的最小用法,完整工具见入门 07 专题 1.6

日志给人看,计数器给测试用。公共设置里的 CompileCounter 数的是真正的后端编译事件,可以按函数名过滤;把它包成「超出预算就报错」的 CompileBudget / @compile_budget,见 入门 07 专题 1.6。下面是最小用法,§9 的演练会用它证明修复有效。

with CompileCounter("mlp_step") as cc:
    for n in [8, 8, 16, 16, 8]:
        mlp_step_jit(w5, jnp.ones((n, 64)))
print("5 次调用、2 种形状 → 编译", cc.count, "次")       # 上面 clear_caches 过,所以两种形状都要重新编译
assert cc.count == 2
5 次调用、2 种形状 → 编译 2 次

5.5 速度慢:排查顺序

🟢 必读

  1. 是不是在反复编译? 用 5.1–5.4 的工具。这是 CPU 上「突然变慢」的头号原因。
  2. 计时对不对? JAX 异步派发,没有 block_until_ready 的计时只量到了派发时间(进阶 02 · 2.7)。
  3. 时间花在编译还是运行? 用 jax.stages 把一次调用拆成 trace / lower / compile / run 分别计时(下一个单元)。
  4. 运行时间花在哪个算子上? 用 jax.profiler.trace 抓 trace(进阶 02 · 2.1),用 §2.2 的 named_scope 对上号。
  5. 有没有隐式的主机 ↔ 设备传输? 用 jax.transfer_guard(下面的补充)。
def deep(ws, x):
    for w in ws:                                    # Python 循环展开:层数越多,编译越慢
        x = jnp.tanh(x @ w)
    return x.sum()

ws_deep = [jax.random.normal(k, (32, 32)) / 6.0 for k in jax.random.split(jax.random.key(5), 40)]
x_deep = jnp.ones((8, 32))
jd = jax.jit(deep)
t0 = time.perf_counter(); tr = jd.trace(ws_deep, x_deep)
t1 = time.perf_counter(); lo = tr.lower()
t2 = time.perf_counter(); co = lo.compile()
t3 = time.perf_counter(); jax.block_until_ready(co(ws_deep, x_deep))
t4 = time.perf_counter()
for _ in range(20):
    jax.block_until_ready(co(ws_deep, x_deep))
t5 = time.perf_counter()
print(f"trace {1e3*(t1-t0):.1f} ms | lower {1e3*(t2-t1):.1f} ms | compile {1e3*(t3-t2):.1f} ms | "
      f"首次运行 {1e3*(t4-t3):.2f} ms | 稳态运行 {1e3*(t5-t4)/20:.3f} ms/次")
ref_deep = to64(x_deep)
for w in ws_deep:
    ref_deep = np.tanh(ref_deep @ to64(w))
assert np.isclose(float(co(ws_deep, x_deep)), ref_deep.sum(), rtol=1e-4)
trace 3.6 ms | lower 2.1 ms | compile 46.0 ms | 首次运行 16.26 ms | 稳态运行 0.448 ms/次

🔍 补充:jax.transfer_guard 抓隐式数据传输

⚪ 选读:在 GPU/TPU 上怀疑有隐式主机-设备传输时

把 NumPy 数组或 Python 标量直接传给 jit 函数,每次调用都会隐式做一次主机 → 设备传输。在 GPU/TPU 上这可能是主要瓶颈,而且代码里完全看不出来。with jax.transfer_guard("disallow"): 让隐式传输直接报错("log" 只记日志,"allow" 是默认值),显式的 jax.device_put 不受影响。

在本机 CPU 上试一下(下一个单元):主机 → 设备方向(NumPy 数组、Python 标量参数)能抓到;设备 → 主机方向(float(x))不会报错,因为 CPU 后端的数组本来就在主机内存里。按文档的说法,在 GPU 上设备 → 主机方向的隐式传输同样会被拦下,这一点本机没法验证。

f_tg = jax.jit(lambda x: x * 2)
x_dev = jnp.ones(3)
f_tg(x_dev)
tg = {}
with jax.transfer_guard("disallow"):
    for name, fn in [("设备数组参数", lambda: f_tg(x_dev)),
                     ("NumPy 数组参数", lambda: f_tg(np.ones(3, np.float32))),
                     ("Python 标量参数", lambda: f_tg(1.0)),
                     ("显式 device_put", lambda: f_tg(jax.device_put(np.ones(3, np.float32)))),
                     ("float(结果)(设备→主机)", lambda: float(f_tg(x_dev).sum()))]:
        try:
            fn(); tg[name] = "允许"
        except Exception as e:
            tg[name] = f"拦截({type(e).__name__})"
for k, v in tg.items():
    print(f"  {k:22s} {v}")
assert tg["设备数组参数"] == tg["显式 device_put"] == "允许"
assert tg["NumPy 数组参数"].startswith("拦截") and tg["Python 标量参数"].startswith("拦截")
assert tg["float(结果)(设备→主机)"] == "允许"          # CPU 特有:在 GPU 上会被拦截
  设备数组参数                 允许
  NumPy 数组参数             拦截(JaxRuntimeError)
  Python 标量参数            拦截(JaxRuntimeError)
  显式 device_put          允许
  float(结果)(设备→主机)       允许

6 报错信息与配置

6.1 JAX_TRACEBACK_FILTERING:控制报错栈里显示多少 JAX 内部帧

⚪ 选读:报错栈看不懂,或要给 JAX 提 issue 时

JAX 的一次 jit 调用会经过几十层内部函数。为了让报错栈好读,JAX 默认会把这些内部帧藏起来。行为由配置项 jax_traceback_filtering(环境变量 JAX_TRACEBACK_FILTERING)控制。下表是 0.12.0.1 的合法取值,是从配置定义里读出来、又逐个试过的。值写错时,import jax 会直接报 ValueError: Invalid value ... for JAX flag jax_traceback_filtering。

取值行为
auto(默认)在 IPython ≥ 7.17 里等于 tracebackhide,否则等于 quiet_remove_frames
tracebackhide帧都保留,但给内部帧加 __tracebackhide__ 标记,IPython / Jupyter 显示时会折叠成 [... skipping hidden N frame]
remove_frames真正删掉内部帧;完整的原始栈作为 __cause__(UnfilteredStackTrace)附在异常上
quiet_remove_frames删掉内部帧,只追加一条简短的提示(For simplicity, JAX has removed its internal frames ...)
off不过滤,显示全部帧。怀疑是 JAX 自身的 bug、要提 issue 时用它

这个配置在抛出异常的时刻读取,所以可以在进程里随时切换。下面用同一个错误在 5 种模式下各触发一次,统计 traceback 里的帧数、__cause__ 和附加的 note:

def inner_reshape(x):
    return x.reshape(3, 5)            # 4 个元素 reshape 成 3×5:报错

outer_jit = jax.jit(lambda x: inner_reshape(x) * 2)

def tb_profile(mode):
    jax.config.update("jax_traceback_filtering", mode)
    try:
        outer_jit(jnp.ones(4))
    except Exception as e:
        frames = traceback.extract_tb(e.__traceback__)
        return {"帧数": len(frames),
                "含 JAX 内部帧": sum("/jax/_src/" in f.filename for f in frames),
                "__cause__": type(e.__cause__).__name__ if e.__cause__ else "—",
                "note": len(getattr(e, "__notes__", [])),
                "用户帧": [f.name for f in frames if "site-packages" not in f.filename]}
    finally:
        jax.config.update("jax_traceback_filtering", "auto")

tb = {mode: tb_profile(mode) for mode in ["auto", "tracebackhide", "off", "remove_frames", "quiet_remove_frames"]}
for mode, r in tb.items():
    print(f"  {mode:20s} 帧数 {r['帧数']:2d} | JAX 内部帧 {r['含 JAX 内部帧']:2d} | __cause__ {r['__cause__']:20s} | note {r['note']}")
assert tb["auto"]["帧数"] == tb["tracebackhide"]["帧数"] == tb["off"]["帧数"]       # Jupyter 里 auto = tracebackhide
assert tb["remove_frames"]["帧数"] < tb["off"]["帧数"]
assert tb["remove_frames"]["__cause__"] == "UnfilteredStackTrace" and tb["quiet_remove_frames"]["note"] == 1
assert all("inner_reshape" in r["用户帧"] for r in tb.values())                       # 你自己的帧永远保留
assert jax.config.jax_traceback_filtering == "auto"
  auto                 帧数 11 | JAX 内部帧  8 | __cause__ —                    | note 0
  tracebackhide        帧数 11 | JAX 内部帧  8 | __cause__ —                    | note 0
  off                  帧数 11 | JAX 内部帧  8 | __cause__ —                    | note 0
  remove_frames        帧数  6 | JAX 内部帧  3 | __cause__ UnfilteredStackTrace | note 0
  quiet_remove_frames  帧数  6 | JAX 内部帧  3 | __cause__ —                    | note 1

在普通 Python 进程(不是 Jupyter)里,auto 等于 quiet_remove_frames:报错末尾会多出一段提示。用环境变量 JAX_TRACEBACK_FILTERING=off 可以关掉过滤:

TB_SCRIPT = """
import jax, jax.numpy as jnp
jax.jit(lambda x: x.reshape(3, 5))(jnp.ones(4))
"""
tb_auto = run_py(TB_SCRIPT).stderr
tb_off = run_py(TB_SCRIPT, env={"JAX_TRACEBACK_FILTERING": "off"}).stderr
tb_bad = run_py("import jax", env={"JAX_TRACEBACK_FILTERING": "bogus"}).stderr
print("auto 模式的结尾:\n   ", "\n    ".join(tb_auto.strip().splitlines()[-3:]))
print(f"stderr 行数:auto {len(tb_auto.splitlines())} 行,off {len(tb_off.splitlines())} 行")
print("非法取值:", tb_bad.strip().splitlines()[-1])
assert "JAX has removed its internal frames" in tb_auto and "JAX has removed" not in tb_off
assert len(tb_off.splitlines()) > len(tb_auto.splitlines())
assert 'Invalid value "bogus" for JAX flag jax_traceback_filtering' in tb_bad
auto 模式的结尾:
    TypeError: cannot reshape array of shape (4,) (size 4) into shape (3, 5) (size 15)
    --------------------
    For simplicity, JAX has removed its internal frames from the traceback of the following exception. Set JAX_TRACEBACK_FILTERING=off to include these.
stderr 行数:auto 18 行,off 28 行
非法取值: ValueError: Invalid value "bogus" for JAX flag jax_traceback_filtering

怎么读 JAX 的报错栈(从下往上读):

  1. 最后一行:异常类型和信息。Tracer 错误先找 §4 里讲的「The error occurred while tracing the function … argument …」这一句;
  2. 往上找第一条你自己的文件:那就是出问题的代码行。在 jit 里,用户帧会出现两次:一次是调用 jit 函数的地方,一次是被追踪的函数内部(例子里的 inner_reshape);
  3. 中间的 jax/_src/... 帧通常不用看;[... skipping hidden N frame] 就是被 tracebackhide 折叠的部分;
  4. 运行时错误(JaxRuntimeError、XlaRuntimeError)发生在编译后的程序里,栈里只有调用点,没有函数内部的行,这时要配合 checkify 或 debug.print 缩小范围;
  5. 只有在怀疑是 JAX 自身的问题、需要给维护者完整信息时,才用 JAX_TRACEBACK_FILTERING=off。

下面这个小工具把异常栈里属于你自己代码的帧挑出来,适合在 except 里快速定位:

def user_frames(exc, skip=("site-packages", "<frozen")):
    """返回异常栈中不属于第三方库的帧:[(函数名, 行号, 代码)]。"""
    return [(f.name, f.lineno, (f.line or "").strip()) for f in traceback.extract_tb(exc.__traceback__)
            if not any(s in f.filename for s in skip)]

try:
    outer_jit(jnp.ones(4))
except Exception as e:
    uf = user_frames(e)
for name, lineno, line in uf:
    print(f"  {name:15s} 第 {lineno} 行: {line}")
assert uf[-1][0] == "inner_reshape" and "reshape(3, 5)" in uf[-1][2]
  <module>        第 7 行: outer_jit(jnp.ones(4))
  <lambda>        第 4 行: outer_jit = jax.jit(lambda x: inner_reshape(x) * 2)
  inner_reshape   第 2 行: return x.reshape(3, 5)            # 4 个元素 reshape 成 3×5:报错

6.2 检查 jax.config 的当前取值

⚪ 选读:怀疑调试开关忘了关时

调试开关忘了关,是「代码莫名变慢」的常见原因(jax_debug_nans、jax_check_tracer_leaks 都会关闭部分缓存)。读取配置有下面几种方式:

写法说明
jax.config.jax_debug_nans最常用,属性访问
jax.debug_nans.value有同名上下文管理器的配置项都可以这样读
jax.config.values一个 dict,包含全部配置项(0.12.0.1 中有 140 多项)
jax.config.read("jax_debug_nans")⚠️ 对有上下文管理器的配置项会报 AttributeError,不要用
环境变量 JAX_DEBUG_NANS=1 等进程启动时读取,效果等同于 config.update

下面写一个 nondefault_flags():在干净的子进程里读出默认配置,再和当前进程对比,列出所有被改动过的项。本章最后会用它确认所有调试开关都已复原。

_DEFAULTS_SCRIPT = """
import jax, json
print("RESULT " + json.dumps({k: repr(v) for k, v in jax.config.values.items()}))
"""
DEFAULT_FLAGS = json.loads(result_lines(run_py(_DEFAULTS_SCRIPT))[0])

def nondefault_flags():
    """当前进程中与默认值不同的配置项 {名字: (默认值, 当前值)}。"""
    return {k: (DEFAULT_FLAGS.get(k), repr(v)) for k, v in jax.config.values.items() if DEFAULT_FLAGS.get(k) != repr(v)}

print("配置项总数:", len(jax.config.values))
print("属性 / .value:", jax.config.jax_debug_nans, jax.debug_nans.value)
e_read = show_error(lambda: jax.config.read("jax_debug_nans"))
with jax.debug_nans(True), jax.log_compiles(True):
    inside = nondefault_flags()
print("with 块内被改动的配置:", inside)
print("with 块外:", nondefault_flags())
assert len(jax.config.values) > 100 and isinstance(e_read, AttributeError)
assert set(inside) == {"jax_debug_nans", "jax_log_compiles"} and nondefault_flags() == {}

# 环境变量与 config.update 等效
env_proc = run_py("""
import jax
print("RESULT", jax.config.jax_debug_nans, jax.config.jax_log_compiles, jax.config.jax_traceback_filtering)
""", env={"JAX_DEBUG_NANS": "1", "JAX_LOG_COMPILES": "true", "JAX_TRACEBACK_FILTERING": "off"})
print("用环境变量启动的子进程:", result_lines(env_proc))
assert result_lines(env_proc) == ["True True off"]
配置项总数: 144
属性 / .value: False False
  → AttributeError: For flags with a corresponding contextmanager, read their value via e.g. `config.jax_debug_nans` rather than `config.FLAGS.jax_debug_nans`.
with 块内被改动的配置: {'jax_debug_nans': ('False', 'True'), 'jax_log_compiles': ('False', 'True')}
with 块外: {}
用环境变量启动的子进程: ['True True off']

6.3 jax.print_environment_info()

⚪ 选读:提 issue 或求助时

提 issue 或求助时,第一件事是贴环境信息(01 · 1.4 补充 介绍过)。return_string=True 可以拿到字符串而不打印。输出里的 platform 行带有主机名,公开贴出去之前可以把它去掉:

env_info = jax.print_environment_info(return_string=True)
safe_info = re.sub(r"node='[^']*'", "node='<已隐去>'", env_info)
print(safe_info)
assert f"jax:    {jax.__version__}" in env_info and "jaxlib:" in env_info and "device info:" in env_info
assert "node='<已隐去>'" in safe_info
jax:    0.12.0.1
jaxlib: 0.12.0.1
numpy:  2.5.4
python: 3.14.7 (main, Aug  5 2026, 10:29:49) [Clang 21.0.0 (clang-2100.1.1.101)]
device info: cpu-1, 1 local devices"
process_count: 1
platform: uname_result(system='Darwin', node='<已隐去>', release='27.0.0', version='Darwin Kernel Version 27.0.0: Tue Aug 11 21:06:40 PDT 2026; root:xnu-13432.1.9~1/RELEASE_ARM64_T6050', machine='arm64')

7 内存:预估、找到占用者、释放

内存问题分两类,工具也不同:

问题工具
单次计算太大(编译后的程序需要的临时缓冲区超过显存)运行前用 compile().memory_analysis() 预估;jax.checkpoint、分块、donate_argnums 降低峰值(进阶 06、入门 07 专题 6)
存活的数组越来越多(泄漏)jax.live_arrays() 看谁还活着;save_device_memory_profile 看它们是在哪一行分配的;x.delete() 立刻释放;jax.clear_caches() 释放被 jit 缓存当作常量保留的数组

在本机 CPU 上,jax.devices()[0].memory_stats() 返回 None。在 GPU/TPU 上它返回 bytes_in_use、peak_bytes_in_use 等字段,是看实时占用最直接的办法。

7.1 memory_analysis():运行之前就知道要多少内存

⚪ 选读:上线前预估显存、排查 OOM 时

cost_analysis() / memory_analysis() 的字段含义和融合对临时缓冲区的影响见 进阶 02 · 2.3。这里用它做一个排错场景:逐样本梯度把内存撑爆。vmap(grad) 的输出是 $B\times P$ 个数(入门 04 / 07 专题 4 讲过怎么估算)。memory_analysis 不需要分配任何真实内存,只用 ShapeDtypeStruct 描述输入,就能给出准确的字节数。上线前想检查「换成更大的 batch 会不会 OOM」,用它正合适。

def per_example_loss(w, x, y):
    return (jnp.dot(x, w) - y) ** 2

P_dim = 4096
spec_w = jax.ShapeDtypeStruct((P_dim,), jnp.float32)          # 只描述形状,不分配内存
rows = []
for B in [64, 256, 1024]:
    spec_x, spec_y = jax.ShapeDtypeStruct((B, P_dim), jnp.float32), jax.ShapeDtypeStruct((B,), jnp.float32)
    batch_grad = jax.jit(jax.grad(lambda w, x, y: jnp.mean(jax.vmap(per_example_loss, (None, 0, 0))(w, x, y))))
    per_ex_grad = jax.jit(jax.vmap(jax.grad(per_example_loss), in_axes=(None, 0, 0)))
    ma_b = batch_grad.lower(spec_w, spec_x, spec_y).compile().memory_analysis()
    ma_p = per_ex_grad.lower(spec_w, spec_x, spec_y).compile().memory_analysis()
    rows.append((B, ma_b.output_size_in_bytes, ma_p.output_size_in_bytes, ma_p.temp_size_in_bytes))
    print(f"  B={B:5d} | 平均梯度输出 {ma_b.output_size_in_bytes / 2**10:8.1f} KiB | "
          f"逐样本梯度输出 {ma_p.output_size_in_bytes / 2**20:6.1f} MiB | 临时缓冲 {ma_p.temp_size_in_bytes / 2**20:5.1f} MiB")
for B, out_b, out_p, _ in rows:
    assert out_b == P_dim * 4                 # 平均梯度:P 个 float32,与 B 无关
    assert out_p == B * P_dim * 4             # 逐样本梯度:B × P 个 float32
  B=   64 | 平均梯度输出     16.0 KiB | 逐样本梯度输出    1.0 MiB | 临时缓冲   0.0 MiB
  B=  256 | 平均梯度输出     16.0 KiB | 逐样本梯度输出    4.0 MiB | 临时缓冲   0.0 MiB
  B= 1024 | 平均梯度输出     16.0 KiB | 逐样本梯度输出   16.0 MiB | 临时缓冲   0.0 MiB

7.2 jax.profiler.save_device_memory_profile:谁在占内存

⚪ 选读:需要按分配位置查看内存占用时

它把当前存活的设备缓冲区按分配时的 Python 调用栈汇总,写成 pprof 格式(gzip 压缩的 protobuf),用 pprof -http=:8080 文件 查看火焰图;jax.profiler.device_memory_profile() 返回同样内容的 bytes,不落盘。用法和查看方法见 进阶 02 · 2.1,CPU 上也能用。下面只确认一点:存活的大数组越多,快照就越大,删掉之后回落。

import gzip, tempfile
def profile_size():
    return len(gzip.decompress(jax.profiler.device_memory_profile()))

s0 = profile_size()
hogs = [jnp.ones((256, 256)) * i for i in range(20)]              # 20 个从同一行分配的数组
jax.block_until_ready(hogs)
s1 = profile_size()
mem_path = os.path.join(tempfile.mkdtemp(), "memory.prof")
jax.profiler.save_device_memory_profile(mem_path)
del hogs; gc.collect()
s2 = profile_size()
print(f"快照大小(解压后):之前 {s0} 字节 → 分配 20 个数组后 {s1} → 删除后 {s2};文件 {os.path.getsize(mem_path)} 字节")
assert s1 > s0 and s2 < s1 and open(mem_path, "rb").read(2) == b"\x1f\x8b"
快照大小(解压后):之前 17482 字节 → 分配 20 个数组后 17814 → 删除后 17723;文件 6215 字节

7.3 jax.live_arrays():找出没释放的数组

🟢 必读

jax.live_arrays() 返回当前进程里所有还活着的 jax.Array。把它按 (形状, dtype) 汇总,在训练循环前后各拍一次「快照」,多出来的就是嫌疑对象。注意:保存 live_arrays() 结果的列表本身就会让数组继续存活,用完要立刻丢掉(下面的 live_summary 只返回统计数字)。

def live_summary():
    """当前存活数组的 Counter{(shape, dtype): 个数} 和总字节数(不保留对数组的引用)。"""
    gc.collect()
    arrs = jax.live_arrays()
    summary = Counter((a.shape, str(a.dtype)) for a in arrs), sum(a.nbytes for a in arrs)
    del arrs
    return summary

def leak_report(before, after):
    """两次快照之间多出来的数组。"""
    return {k: v for k, v in (after[0] - before[0]).items()}
# ❌ 错误写法:为了「调试方便」把每步的中间激活值存进 Python 列表,忘了清空
debug_cache = []

@jax.jit
def fwd_step(w, x):
    h = jnp.tanh(x @ w)
    return h, (h ** 2).mean()

w7 = jax.random.normal(jax.random.key(7), (128, 512)) / 12.0
x7 = jax.random.normal(jax.random.key(8), (64, 128))
before = live_summary()
try:
    for _ in range(10):
        h, loss = fwd_step(w7, x7)
        debug_cache.append(h)                 # 每步多留下一个 64×512 的数组
    after = live_summary()
    leaked = leak_report(before, after)
    print("多出来的存活数组:", leaked, f"| 增加 {(after[1] - before[1]) / 2**20:.2f} MiB")
except Exception as e:
    print("实测报错:", type(e).__name__, e)
    raise
assert leaked.get(((64, 512), "float32"), 0) == 10
多出来的存活数组: {((), 'float32'): 1, ((64, 512), 'float32'): 10} | 增加 1.25 MiB
# ✅ 正确写法:只把需要的统计量转成 Python 数值保存;不再引用的数组会被自动回收
debug_cache.clear()
stats_log = []
before = live_summary()
for _ in range(10):
    h, loss = fwd_step(w7, x7)
    stats_log.append(float(loss))             # 只保存标量
del h, loss
after = live_summary()
print("多出来的存活数组:", leak_report(before, after), "| 记录的 loss 条数:", len(stats_log))
assert leak_report(before, after) == {} and len(stats_log) == 10
多出来的存活数组: {} | 记录的 loss 条数: 10

7.4 x.delete():立刻释放,不等垃圾回收

⚪ 选读:需要立刻释放大数组时

del x 只是删掉一个 Python 名字,如果别处还有引用(列表、闭包、调试器……),缓冲区不会释放。x.delete() 则直接释放设备缓冲区,不管还有多少引用。释放之后:

  • 之后 x.is_deleted() 为 True;
  • 任何使用(包括通过别名 y = x 使用)都会报 RuntimeError: Array has been deleted with shape=...;
  • 但 print(x) / repr(x) 不报错,显示成 Array(shape=(...), dtype=...),注意别被它骗了。
big = jnp.ones((1000, 1000))
alias = big                                   # 另一个引用
n_before = live_summary()[1]
big.delete()
n_after = live_summary()[1]
print(f"delete 前后存活字节:{n_before / 2**20:.2f} MiB → {n_after / 2**20:.2f} MiB")
print("is_deleted:", alias.is_deleted(), "| repr:", repr(alias))
e_del = show_error(lambda: alias + 1)
assert n_before - n_after == 1000 * 1000 * 4
assert alias.is_deleted() and isinstance(e_del, RuntimeError) and "deleted" in str(e_del)
del big, alias
delete 前后存活字节:4.36 MiB → 0.54 MiB
is_deleted: True | repr: Array(shape=(1000, 1000), dtype=float32)
  → RuntimeError: Array has been deleted with shape=float32[1000,1000].

7.5 jax.clear_caches():释放被 jit 缓存「偷偷」留住的数组

⚪ 选读:全局大数组被 jit 缓存当常量留住、内存不降时

jit 函数读取的全局数组会被当作常量留在编译缓存里(§2.1)。把全局变量重新赋值之后,旧数组已经没有 Python 引用了,但缓存里的可执行程序仍然持有它,live_arrays() 里还能看到。只要这个 jit 函数还活着,旧数组就不会释放,只有 jax.clear_caches() 能让它走。这也是「大数组要作为参数传入」的又一个理由(另外三个见 进阶 01)。

before = live_summary()
TABLE = jnp.ones((512, 512))                    # 全局「查找表」
def lookup(x):
    return (x + TABLE).sum()
lookup_jit = jax.jit(lookup)
lookup_jit(jnp.float32(1.0))                    # TABLE 作为常量进入编译缓存
TABLE = jnp.zeros((256, 256))                   # 重新赋值:旧的 512×512 已经没有 Python 引用了
mid = live_summary()
jax.clear_caches()
end = live_summary()
key_old = ((512, 512), "float32")
print("重新赋值后仍存活的旧 TABLE 个数:", mid[0][key_old] - before[0][key_old],
      "| clear_caches 之后:", end[0][key_old] - before[0][key_old])
assert mid[0][key_old] - before[0][key_old] == 1 and end[0][key_old] - before[0][key_old] == 0
del TABLE
重新赋值后仍存活的旧 TABLE 个数: 1 | clear_caches 之后: 0

8 多设备:看清数组在哪、各设备算出了什么

说明:本机只有 1 个 CPU 设备。模拟多设备要在 import jax 之前设置 XLA_FLAGS=--xla_force_host_platform_device_count=4,而这个 notebook 的内核早就初始化了 JAX。所以这一节的代码都在子进程里运行:每个单元把脚本交给 run_py,子进程启动时设置好 4 个模拟设备,结果通过 RESULT 行传回来,再 assert。如果你想在自己的 notebook 里直接交互,把 os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=4" 放在第一个单元、import jax 之前就行(05、进阶 03 就是这样做的)。

工具作用备注
jax.typeof(x)数组的抽象类型;显式分片模式下带分片信息,如 float32[8@data,4@model]在 jit 里追踪时也能用
x.sharding、x.sharding.spec分片对象与 PartitionSpec
x.sharding.devices_indices_map(x.shape)每个设备持有哪一块(切片)下面的 ascii_sharding 就是基于它
jax.debug.visualize_array_sharding(x)画出分片布局依赖 rich
jax.debug.inspect_array_sharding(x, callback=...)在 jit 内部查看中间值的分片编译时回调一次
x.addressable_shards每个本地设备上的实际数据检查「复制」的数组各副本是否真的一致
DEV4 = {"XLA_FLAGS": "--xla_force_host_platform_device_count=4"}

SHARDING_TOOLS = '''
import json, sys
import numpy as np
import jax, jax.numpy as jnp
from jax.sharding import NamedSharding, PartitionSpec as P

def ascii_sharding(x):
    """不依赖 rich 的分片示意图(1 维或 2 维数组):每个格子列出持有这一块的设备编号。"""
    idx = x.sharding.devices_indices_map(x.shape)
    shape2 = x.shape if x.ndim == 2 else (1,) + x.shape
    def norm(sl, n):
        return (sl.start or 0, n if sl.stop is None else sl.stop)
    blocks = {}
    for dev, sl in idx.items():
        sl2 = sl if x.ndim == 2 else (slice(None),) + tuple(sl)
        key = tuple(norm(s, n) for s, n in zip(sl2, shape2))
        blocks.setdefault(key, []).append(dev.id)
    rows = sorted({k[0] for k in blocks}); cols = sorted({k[1] for k in blocks})
    cells = [["CPU " + ",".join(map(str, sorted(blocks[(r, c)]))) for c in cols] for r in rows]
    w = max(len(s) for row in cells for s in row) + 2
    line = "+" + "+".join("-" * w for _ in cols) + "+"
    out = [line]
    for row in cells:
        out.append("|" + "|".join(s.center(w) for s in row) + "|")
        out.append(line)
    return "\\n".join(out)
'''

8.1 jax.typeof、visualize_array_sharding 和不依赖 rich 的替代

⚪ 选读:多设备:查看数组的分片布局时

visualize_array_sharding 依赖 rich 库。本环境里 rich 已经作为 flax 的依赖装上了(下面用 importlib.metadata 确认,Required-by: flax),所以直接调用能成功,05 · 查看分片布局 里保存的也是正常画出的布局。为了演示没有 rich 时会怎样,第二个子进程在导入 JAX 之前用 sys.modules["rich"] = None 把它屏蔽掉,得到的就是没装 rich 时的真实报错。两种情况下都可以用基于 devices_indices_map 的 ascii_sharding 代替。

SCRIPT_81 = SHARDING_TOOLS + '''
if sys.argv[1] == "no-rich":
    sys.modules["rich"] = None                 # 模拟没有安装 rich
mesh = jax.make_mesh((2, 2), ("data", "model"))
A = jnp.arange(32.0).reshape(8, 4)
x = jax.device_put(A, NamedSharding(mesh, P("data", "model")))
r = jax.device_put(A, NamedSharding(mesh, P()))                       # 完全复制
c = jax.device_put(A, NamedSharding(mesh, P(None, "model")))          # 只切列
print("RESULT " + json.dumps({
    "devices": len(jax.devices()),
    "typeof": [str(jax.typeof(v)) for v in (x, r, c, jnp.ones(3))],
    "spec": str(x.sharding.spec),
    "local_shape": list(x.addressable_shards[0].data.shape),
}))
try:
    jax.debug.visualize_array_sharding(x)
    print("RESULT visualize=ok")
except Exception as e:
    print("RESULT visualize=" + type(e).__name__ + ": " + str(e))
print("ASCII P('data','model')\\n" + ascii_sharding(x))
print("ASCII P(None,'model')\\n" + ascii_sharding(c))
print("ASCII P()\\n" + ascii_sharding(r))
'''
import importlib.util, importlib.metadata
rich_installed = importlib.util.find_spec("rich") is not None
flax_needs_rich = any(r.split()[0].startswith("rich") for r in (importlib.metadata.requires("flax") or []))
print("rich 已安装?", rich_installed, f"(版本 {importlib.metadata.version('rich')})" if rich_installed else "",
      "| flax 的依赖里有 rich?", flax_needs_rich)
p_rich = run_py(SCRIPT_81, "with-rich", env=DEV4)
p_norich = run_py(SCRIPT_81, "no-rich", env=DEV4)
info = json.loads(result_lines(p_rich)[0])
vis_rich, vis_norich = result_lines(p_rich)[1], result_lines(p_norich)[1]
print("设备数:", info["devices"], "| typeof:", info["typeof"], "| spec:", info["spec"], "| 局部块:", info["local_shape"])
print("有 rich 时:", vis_rich, "\n没有 rich 时:", vis_norich)
print("\n有 rich 时 visualize_array_sharding 画出的图:")
print("\n".join(l for l in p_rich.stdout.split("ASCII")[0].splitlines() if "RESULT" not in l))
print("没有 rich 时的替代函数 ascii_sharding:")
print("\n".join(l for l in p_norich.stdout.splitlines() if "RESULT" not in l))
assert info["devices"] == 4 and info["local_shape"] == [4, 2]
assert info["typeof"] == ["float32[8@data,4@model]", "float32[8,4]", "float32[8,4@model]", "float32[3]"]
assert vis_norich == "visualize=ValueError: `visualize_sharding` requires `rich` to be installed."
assert vis_rich == ("visualize=ok" if rich_installed else vis_norich)   # 装了 rich 就能画;没装则与上面同样报错
assert "CPU 0,2" in p_norich.stdout and "CPU 0,1,2,3" in p_norich.stdout
rich 已安装? True (版本 15.0.0) | flax 的依赖里有 rich? True
设备数: 4 | typeof: ['float32[8@data,4@model]', 'float32[8,4]', 'float32[8,4@model]', 'float32[3]'] | spec: P('data', 'model') | 局部块: [4, 2]
有 rich 时: visualize=ok 
没有 rich 时: visualize=ValueError: `visualize_sharding` requires `rich` to be installed.

有 rich 时 visualize_array_sharding 画出的图:
                  
                  
  CPU 0    CPU 1  
                  
                  
                  
                  
                  
  CPU 2    CPU 3  
                  
                  
                  
没有 rich 时的替代函数 ascii_sharding:
ASCII P('data','model')
+-------+-------+
| CPU 0 | CPU 1 |
+-------+-------+
| CPU 2 | CPU 3 |
+-------+-------+
ASCII P(None,'model')
+---------+---------+
| CPU 0,2 | CPU 1,3 |
+---------+---------+
ASCII P()
+-------------+
| CPU 0,1,2,3 |
+-------------+

读图:P("data", "model") 把 8×4 的数组切成 2×2 块,每个设备一块;P(None, "model") 只切列,所以同一列块由两个设备(CPU 0,2)各持有一份副本;P() 完全复制,4 个设备都持有整块。jax.typeof 的写法与之一一对应:8@data 表示第 0 维沿 data 轴切分,没有 @ 的维度不切分。

8.2 在 jit 内部查看分片

⚪ 选读:多设备:查看中间结果的分片时

数组进入 jit 之后,中间结果的分片由编译器决定。jax.typeof 在追踪时就能读到(显式分片模式下),jax.debug.inspect_array_sharding(x, callback=...) 则在编译时把编译器选定的分片交给回调。

SCRIPT_82 = SHARDING_TOOLS + '''
mesh = jax.make_mesh((4,), ("d",))
x = jax.device_put(jnp.arange(16.0).reshape(8, 2), NamedSharding(mesh, P("d")))
seen = {}
@jax.jit
def f(a):
    seen["trace_typeof"] = str(jax.typeof(a))               # 追踪时读取
    b = jnp.sin(a) * 2
    jax.debug.inspect_array_sharding(b, callback=lambda s: seen.__setitem__("inspect", str(s.spec)))
    return b.sum(axis=1)
out = f(x)
seen["out_typeof"] = str(jax.typeof(out))
seen["out_spec"] = str(out.sharding.spec)
print("RESULT " + json.dumps(seen))
'''
seen82 = json.loads(result_lines(run_py(SCRIPT_82, env=DEV4))[0])
for k, v in seen82.items():
    print(f"  {k:13s} {v}")
assert seen82["trace_typeof"] == "float32[8@d,2]" and seen82["inspect"] == "P('d',)"
assert seen82["out_typeof"] == "float32[8@d]"
  trace_typeof  float32[8@d,2]
  inspect       P('d',)
  out_typeof    float32[8@d]
  out_spec      P('d',)

8.3 「多设备结果不一致」:检查副本、看每个设备的局部值

⚪ 选读:多设备:shard_map 结果各设备不一致时

shard_map 里每个设备只看到自己的局部块。最常见的错误是忘了做集合通信:每个设备算的是局部和,却当成了全局结果。0.12 默认的 check_vma=True 会在追踪时拦住「声明为复制、实际上各设备不同」的输出。但如果为了绕过别的报错把它关掉(check_vma=False),程序会悄悄返回设备 0 的值,而且各设备上的「副本」其实各不相同。另一个相关陷阱是把 pmean(grad) 原样搬到 shard_map 导致梯度变成 N 倍,见 05 · shard_map 梯度 N 倍专题。

排查工具:① 在 shard_map 里用 jax.debug.print 打印 lax.axis_index 和局部块;② 写一个 replicas_consistent(x),逐个比较 x.addressable_shards 上的数据。

SCRIPT_83 = SHARDING_TOOLS + '''
from jax import lax
mesh = jax.make_mesh((4,), ("d",))
x = jax.device_put(jnp.arange(16.0), NamedSharding(mesh, P("d")))

def replicas_consistent(arr):
    """对声明为复制的数组:所有设备上的副本是否逐位相同。"""
    datas = [np.asarray(s.data) for s in arr.addressable_shards]
    return all(np.array_equal(datas[0], d) for d in datas[1:]), [d.tolist() for d in datas]

def local_sum(xb):
    jax.debug.print("设备 {i} 的局部块 {b}", i=lax.axis_index("d"), b=xb)
    return xb.sum()

res = {}
try:                                                     # ❌ 默认检查:追踪时就报错
    jax.jit(jax.shard_map(local_sum, mesh=mesh, in_specs=P("d"), out_specs=P()))(x)
    res["checked"] = "no error"
except Exception as e:
    res["checked"] = type(e).__name__ + ": " + str(e).splitlines()[0][:80]
bad = jax.jit(jax.shard_map(local_sum, mesh=mesh, in_specs=P("d"), out_specs=P(), check_vma=False))(x)
jax.effects_barrier()
res["bad_value"] = float(bad); res["bad_consistent"], res["bad_replicas"] = replicas_consistent(bad)
good = jax.jit(jax.shard_map(lambda xb: lax.psum(xb.sum(), "d"), mesh=mesh, in_specs=P("d"), out_specs=P()))(x)
res["good_value"] = float(good); res["good_consistent"], res["good_replicas"] = replicas_consistent(good)
print("RESULT " + json.dumps(res, ensure_ascii=False))
'''
p83 = run_py(SCRIPT_83, env=DEV4)
res83 = json.loads(result_lines(p83)[0])
print("\n".join(sorted(l for l in p83.stdout.splitlines() if l.startswith("设备"))))
for k, v in res83.items():
    print(f"  {k:16s} {v}")
assert res83["checked"].startswith("ValueError") and "out_specs" in res83["checked"]
assert res83["bad_value"] == 6.0 and res83["bad_consistent"] is False            # 返回设备 0 的局部和,各副本不同
assert sorted(r for r in res83["bad_replicas"]) == [6.0, 22.0, 38.0, 54.0]
assert res83["good_value"] == float(np.arange(16.0).sum()) and res83["good_consistent"] is True
设备 0 的局部块 [0. 1. 2. 3.]
设备 1 的局部块 [4. 5. 6. 7.]
设备 2 的局部块 [ 8.  9. 10. 11.]
设备 3 的局部块 [12. 13. 14. 15.]
  checked          ValueError: shard_map applied to the function 'local_sum' was given out_specs which require 
  bad_value        6.0
  bad_consistent   False
  bad_replicas     [6.0, 22.0, 38.0, 54.0]
  good_value       120.0
  good_consistent  True
  good_replicas    [120.0, 120.0, 120.0, 120.0]

9 综合演练:带 3 个隐藏 bug 的训练脚本

下面是一个看起来很普通的训练脚本:二分类数据、带 dropout 的 3 层 MLP、对偏置加了一点 L2 范数正则、学习率按步数指数衰减。它有 3 个隐藏的 bug。我们假装不知道 bug 在哪,只根据症状,用前面的工具逐个定位并修好,每一步都用 assert 证明修复有效。

按本章约定,参数和数据都作为函数参数传入 jit 函数(避开本环境的 CPU bug)。

# ===================== 训练脚本 v0(原样保留,先别找 bug) =====================
def make_data(key, n=512, d=16):
    kx, kw = jax.random.split(key)
    X = jax.random.normal(kx, (n, d))
    w_true = jax.random.normal(kw, (d,))
    return X, (X @ w_true > 0).astype(jnp.int32)

def init_params(key, sizes):
    keys = jax.random.split(key, len(sizes) - 1)
    return [{"w": jax.random.normal(k, (m, n)) * jnp.sqrt(2.0 / m), "b": jnp.zeros(n)}
            for k, m, n in zip(keys, sizes[:-1], sizes[1:])]

def forward(params, x, key=None, rate=0.2):
    for i, layer in enumerate(params):
        x = x @ layer["w"] + layer["b"]
        if i < len(params) - 1:
            x = jax.nn.relu(x)
            if key is not None:                                    # 训练时做 dropout
                keep = jax.random.bernoulli(jax.random.fold_in(key, i), 1 - rate, x.shape)
                x = jnp.where(keep, x / (1 - rate), 0.0)
    return x

def cross_entropy(params, x, y, key):
    logits = forward(params, x, key)
    return -jnp.mean(jnp.take_along_axis(jax.nn.log_softmax(logits), y[:, None], axis=1))

def loss_v0(params, x, y, key):
    reg = sum(jnp.linalg.norm(layer["b"]) for layer in params)     # 偏置的 L2 范数正则
    return cross_entropy(params, x, y, key) + 1e-3 * reg

def make_train_step_v0(loss_fn):
    @partial(jax.jit, static_argnames=("step",))
    def train_step(params, x, y, key, step):
        lr = 0.5 * 0.98 ** step                                     # 学习率按步数衰减
        loss, grads = jax.value_and_grad(loss_fn)(params, x, y, key)
        return jax.tree.map(lambda p, g: p - lr * g, params, grads), loss
    return train_step

def train_v0(step_fn, params, X, Y, n_steps, seed=0):
    key = jax.random.key(seed)
    losses = []
    for step in range(n_steps):
        step_key = jax.random.split(key)[0]                         # 每步取一个子 key 做 dropout
        params, loss = step_fn(params, X, Y, step_key, step)
        losses.append(float(loss))
    return params, losses

def accuracy(params, X, Y):
    return float(jnp.mean(jnp.argmax(forward(params, X), axis=-1) == Y))

X_tr, Y_tr = make_data(jax.random.key(42))
params0 = init_params(jax.random.key(1), [16, 64, 64, 2])
print("数据:", X_tr.shape, "| 正类比例:", float(Y_tr.mean()), "| 参数:", [jax.tree.map(jnp.shape, l) for l in params0])
数据: (512, 16) | 正类比例: 0.55078125 | 参数: [{'b': (64,), 'w': (16, 64)}, {'b': (64,), 'w': (64, 64)}, {'b': (2,), 'w': (64, 2)}]

9.0 先跑一遍,记录症状

⚪ 选读:综合演练:按症状定位 3 个隐藏 bug 的完整过程

N_DRILL = 8
step_v0 = make_train_step_v0(loss_v0)
t0 = time.perf_counter()
with CompileCounter("train_step") as cc_v0:
    _, losses_v0 = train_v0(step_v0, params0, X_tr, Y_tr, N_DRILL)
print(f"loss: {[round(l, 4) for l in losses_v0]}")
print(f"{N_DRILL} 步耗时 {time.perf_counter() - t0:.2f} s,train_step 编译 {cc_v0.count} 次")
assert np.isfinite(losses_v0[0]) and all(np.isnan(l) for l in losses_v0[1:])   # 症状 1:第 2 步起 loss 变 NaN
assert cc_v0.count == N_DRILL                                                   # 症状 2:每一步都在编译
loss: [0.9846, nan, nan, nan, nan, nan, nan, nan]
8 步耗时 1.33 s,train_step 编译 8 次

两个症状已经很明显:loss 从第 2 步起变成 NaN,每一步都编译一次。第三个 bug 不产生明显症状(训练「能跑」),我们最后用专门的工具把它揪出来。

9.1 症状 1:NaN —— 定位

⚪ 选读:综合演练:定位反向传播里的 NaN

按 速查表:第一步 loss 还是有限的,第二步就 NaN 了,说明第一次参数更新引入了 NaN,嫌疑对象是梯度。

  1. 用 checkify(不受 vmap 漏检问题影响)检查第 0 步的「前向」和「梯度」,看 NaN 出现在哪一边;
  2. 逐叶子检查梯度,看是哪些参数的梯度坏了;
  3. 把损失拆成两项,分别求梯度,锁定罪魁祸首。
k_dbg = jax.random.key(123)
err_fwd, loss_at0 = jax.jit(checkify.checkify(loss_v0, errors=checkify.nan_checks))(params0, X_tr, Y_tr, k_dbg)
err_bwd, grads_at0 = jax.jit(checkify.checkify(jax.grad(loss_v0), errors=checkify.nan_checks))(params0, X_tr, Y_tr, k_dbg)
print("① 前向:", err_fwd.get(), "| 梯度:", err_bwd.get())

bad = [jax.tree_util.keystr(p) for p, g in jax.tree_util.tree_leaves_with_path(grads_at0) if not bool(jnp.all(jnp.isfinite(g)))]
print("② 梯度含 NaN 的叶子:", bad)

g_ce = jax.grad(cross_entropy)(params0, X_tr, Y_tr, k_dbg)
g_reg = jax.grad(lambda p: sum(jnp.linalg.norm(l["b"]) for l in p))(params0)
finite = lambda t: all(bool(jnp.all(jnp.isfinite(g))) for g in jax.tree.leaves(t))
print("③ 交叉熵的梯度有限?", finite(g_ce), "| 正则项的梯度有限?", finite(g_reg), "| 偏置的初值:", params0[0]["b"][:4])

assert err_fwd.get() is None and "nan generated by primitive" in err_bwd.get()
assert bad == ["[0]['b']", "[1]['b']", "[2]['b']"]
assert finite(g_ce) and not finite(g_reg)
① 前向: None | 梯度: nan generated by primitive: mul.
② 梯度含 NaN 的叶子: ["[0]['b']", "[1]['b']", "[2]['b']"]
③ 交叉熵的梯度有限? True | 正则项的梯度有限? False | 偏置的初值: [0. 0. 0. 0.]

诊断:前向没问题,NaN 只出现在梯度里,而且只出现在偏置上,来源是正则项。偏置初始化为全零,而 $\nabla_b \lVert b\rVert = b / \lVert b\rVert$ 在 $b=0$ 处是 $0/0$。这就是 §3.1 里 jax_debug_nans 只能报出 mul、定位不到源码行的那种情况,「逐叶子检查 + 拆分损失」比开全局开关更快。

修复:用双 where 写一个在 0 处梯度为 0 的安全范数(原理见 入门 07 专题 2.2)。

def safe_norm(v):
    """‖v‖;v = 0 时值为 0、梯度为 0(双 where)。"""
    ss = jnp.sum(v ** 2)
    ok = ss > 0
    return jnp.where(ok, jnp.sqrt(jnp.where(ok, ss, 1.0)), 0.0)

def loss_v1(params, x, y, key):
    reg = sum(safe_norm(layer["b"]) for layer in params)
    return cross_entropy(params, x, y, key) + 1e-3 * reg

# 验证 1:0 处梯度为 0;非 0 处与 jnp.linalg.norm 的梯度一致;整个 loss 的梯度有限
v_test = jnp.array([3.0, -4.0])
assert np.array_equal(jax.grad(safe_norm)(jnp.zeros(3)), np.zeros(3))
assert np.allclose(jax.grad(safe_norm)(v_test), jax.grad(jnp.linalg.norm)(v_test)) and float(safe_norm(v_test)) == 5.0
err_v1, g_v1 = jax.jit(checkify.checkify(jax.grad(loss_v1), errors=checkify.nan_checks))(params0, X_tr, Y_tr, k_dbg)
assert err_v1.get() is None and finite(g_v1)

# 验证 2:训练不再出现 NaN(其他两个 bug 还在)
step_v1 = make_train_step_v0(loss_v1)
with CompileCounter("train_step") as cc_v1:
    _, losses_v1 = train_v0(step_v1, params0, X_tr, Y_tr, N_DRILL)
print("修复 NaN 后的 loss:", [round(l, 4) for l in losses_v1], "| 编译次数:", cc_v1.count)
assert all(np.isfinite(losses_v1)) and losses_v1[-1] < losses_v1[0]
assert cc_v1.count == N_DRILL                    # 症状 2 依旧
修复 NaN 后的 loss: [0.9846, 1.2764, 6.8963, 1.0985, 0.5132, 0.4795, 0.4538, 0.4297] | 编译次数: 8

9.2 症状 2:每步都重新编译 —— 定位

⚪ 选读:综合演练:定位静态参数导致的重复编译

按 §5 的顺序:先看编译日志里的形状有没有变,再看 explain_cache_misses 的原因。

step_dbg = make_train_step_v0(loss_v1)
with capture_jax_logs() as logs, jax.log_compiles(), jax.explain_cache_misses():
    p_ = params0
    for s in range(3):
        p_, _ = step_dbg(p_, X_tr, Y_tr, jax.random.key(s), s)
compile_lines = [m for m in logs.messages if m.startswith("Compiling jit(train_step)")]
miss_lines = [m for m in logs.messages if "CACHE MISS" in m and "train_step" in m]
shapes_seen = {m.split("with global shapes and types")[1].split(". Argument")[0] for m in compile_lines}
print("编译次数:", len(compile_lines), "| 参数形状的种类:", len(shapes_seen))
for m in miss_lines:
    reason = [l.strip() for l in m.splitlines()[1:] if not l.strip().startswith(("for ", "all previously", "train_step id="))]
    print("  •", " / ".join(reason))
print("_cache_size()(私有 API):", step_dbg._cache_size())
assert len(compile_lines) == 3 and len(shapes_seen) == 1                  # 形状完全没变,却编译了 3 次
assert "never seen function" in miss_lines[0]
assert all("explanation unavailable" in m for m in miss_lines[1:])        # → 嫌疑:静态参数
assert step_dbg._cache_size() == 3
编译次数: 3 | 参数形状的种类: 1
  • never seen function:
  • cache miss explanation unavailable
  • cache miss explanation unavailable
_cache_size()(私有 API): 3

诊断:三次编译的参数形状完全相同,explain_cache_misses 只说 explanation unavailable。按 §5.2 的经验,这几乎就是静态参数的值在变。回头看代码:step 被声明成了 static_argnames,每一步都是一个新值。

修复:step 只用来算学习率,不决定计算结构,应该作为普通(追踪)参数传入。而且要统一成强类型的 int32 数组,免得弱类型和强类型之间再触发一次编译(02 · E3)。

def make_train_step_v2(loss_fn):
    @jax.jit
    def train_step(params, x, y, key, step):          # step 不再是静态参数
        lr = 0.5 * 0.98 ** step
        loss, grads = jax.value_and_grad(loss_fn)(params, x, y, key)
        return jax.tree.map(lambda p, g: p - lr * g, params, grads), loss
    return train_step

def train_v2(step_fn, params, X, Y, n_steps, seed=0):
    key = jax.random.key(seed)
    losses = []
    for step in range(n_steps):
        step_key = jax.random.split(key)[0]
        params, loss = step_fn(params, X, Y, step_key, jnp.int32(step))   # 统一为 int32 数组
        losses.append(float(loss))
    return params, losses

step_v2 = make_train_step_v2(loss_v1)
N_LONG = 60
with CompileCounter("train_step") as cc_v2:
    params_v2, losses_v2 = train_v2(step_v2, params0, X_tr, Y_tr, N_LONG)
_, losses_v1_long = train_v0(make_train_step_v0(loss_v1), params0, X_tr, Y_tr, N_LONG)
print(f"{N_LONG} 步编译 {cc_v2.count} 次 | 与静态 step 版本的 loss 最大差 {np.abs(np.array(losses_v2) - losses_v1_long).max():.2e}")
assert cc_v2.count == 1                                                       # 修复有效:只编译一次
assert np.allclose(losses_v2, losses_v1_long, atol=1e-5)                       # 数学上与修复前完全一样
60 步编译 1 次 | 与静态 step 版本的 loss 最大差 1.19e-07

9.3 隐藏的 bug 3:随机 key 复用

⚪ 选读:综合演练:用 jax_debug_key_reuse 抓 key 复用

前两个 bug 修完,训练「看起来正常」了。但 dropout 有没有真的每步换一个掩码?这类 bug 不会报错,只会让正则化效果变差。工具:

  1. jax_debug_key_reuse(0.12 中可用的配置项):打开后,类型化 key(jax.random.key 创建的)每被「消耗」一次就打上标记,再次使用同一个 key 会立刻抛 jax.errors.KeyReuseError,并指出是哪个函数的第几个参数。旧式 PRNGKey 生成的原始 uint32 key 不受检查。这又是一个该用 jax.random.key 的理由。
  2. 旁听:包一层 step_fn,记录每步收到的 key 的 jax.random.key_data,看它们是不是都一样。
def try_train(train_fn, n=4):
    try:
        with jax.debug_key_reuse(True):
            train_fn(step_v2, params0, X_tr, Y_tr, n)
        return None
    except jax.errors.KeyReuseError as e:
        return e

e_reuse = try_train(train_v2)
print("jax_debug_key_reuse:", type(e_reuse).__name__, "→", str(e_reuse).splitlines()[0])

seen_keys = []
def spy(params, x, y, key, step):
    seen_keys.append(tuple(np.asarray(jax.random.key_data(key)).tolist()))
    return step_v2(params, x, y, key, step)
train_v2(spy, params0, X_tr, Y_tr, 5)
print("5 步里不同的 dropout key 个数:", len(set(seen_keys)))
assert isinstance(e_reuse, jax.errors.KeyReuseError) and "random_split" in str(e_reuse)
assert len(set(seen_keys)) == 1                                   # 每一步用的都是同一个 key

# 旧式 PRNGKey 同样的复用,检查器看不到
with jax.debug_key_reuse(True):
    raw = jax.random.PRNGKey(0)
    same = bool(jnp.all(jax.random.normal(raw, (3,)) == jax.random.normal(raw, (3,))))
print("PRNGKey 复用两次:没有报错,两次结果相同 =", same)
assert same
jax_debug_key_reuse: KeyReuseError → Previously-consumed key passed to random_split at index 0
5 步里不同的 dropout key 个数: 1
PRNGKey 复用两次:没有报错,两次结果相同 = True

诊断:train_v2 里每步都对同一个 key 做 split(key)[0],key 本身从不更新,所以每一步拿到的子 key 完全相同,dropout 掩码也完全相同。修复:每步 key, step_key = jax.random.split(key),把新的 key 带到下一步。

def train_v3(step_fn, params, X, Y, n_steps, seed=0):
    key = jax.random.key(seed)
    losses = []
    for step in range(n_steps):
        key, step_key = jax.random.split(key)                     # 每步推进 key
        params, loss = step_fn(params, X, Y, step_key, jnp.int32(step))
        losses.append(float(loss))
    return params, losses

assert try_train(train_v3) is None                                # 打开检查也不再报错
seen_keys.clear()
train_v3(spy, params0, X_tr, Y_tr, 5)
print("修复后 5 步里不同的 dropout key 个数:", len(set(seen_keys)))
assert len(set(seen_keys)) == 5

with CompileCounter("train_step") as cc_v3:
    params_v3, losses_v3 = train_v3(step_v2, params0, X_tr, Y_tr, N_LONG)
acc_v2, acc_v3 = accuracy(params_v2, X_tr, Y_tr), accuracy(params_v3, X_tr, Y_tr)
print(f"最终版:{N_LONG} 步,编译 {cc_v3.count} 次(step_v2 已经编译过)| loss {losses_v3[0]:.3f} → {losses_v3[-1]:.3f}")
print(f"训练集准确率:固定掩码 {acc_v2:.3f} | 每步新掩码 {acc_v3:.3f}")
assert cc_v3.count == 0 and all(np.isfinite(losses_v3)) and losses_v3[-1] < 0.5 * losses_v3[0]
assert acc_v3 > 0.9
修复后 5 步里不同的 dropout key 个数: 5
最终版:60 步,编译 0 次(step_v2 已经编译过)| loss 0.971 → 0.205
训练集准确率:固定掩码 0.955 | 每步新掩码 0.973

9.4 演练小结

🟢 必读

bug症状定位工具修复验证
零向量的范数求导第 2 步起 loss 为 NaNcheckify(grad) → 逐叶子检查 → 拆分损失双 where 的 safe_norm0 处梯度为 0;与 jnp.linalg.norm 梯度一致;训练全程有限
step 是静态参数每步编译一次CompileCounter、log_compiles(形状不变)、explain_cache_misses(explanation unavailable)step 作为 int32 追踪参数60 步只编译 1 次,loss 与修复前逐步一致
key 不推进无明显症状(正则化变弱)jax_debug_key_reuse → KeyReuseError;记录 key_datakey, sub = split(key)检查不再报错;5 步 5 个不同 key

注意顺序:先修 NaN(它让其他一切症状都失去意义),再修性能,最后查「没有报错但不对」的逻辑问题。每修一个都立刻用 assert 固定下来,避免改了后面的又把前面的弄坏。

🏋️ 练习(含参考答案)

练习主题对应小节
1Tracer 错误修复四连:先实测报什么错,再改到能 jit§4
2用 checkify 写一个 nan_guard 装饰器§3.2、§5.4
3用 jax.live_arrays() 写泄漏探测器§7.3
4自动定位「第一次出现 NaN 的那一步」和产生 NaN 的原语§3.1、§9

练习 1:Tracer 错误修复四连

⚪ 选读:练习:巩固 §4

下面的题目代码给出了 4 个函数,它们在 eager 下都能正常工作,但套上 jax.jit 后都会报错。

要求:

  1. 对每个函数实测 jax.jit(f)(*args) 抛出的异常类型,写成一个字典 {函数名: 异常类名},并断言它们分别是:TypeError(mode 是字符串,不是合法的 JAX 类型)、IndexError、NonConcreteBooleanIndexError、TracerBoolConversionError;
  2. 给每个函数写一个能被 jit 编译的版本(可以改函数体,也可以用 static_argnames),调用方式不变;
  3. 用 NumPy float64 参考值断言结果正确。

自检接口:ex1_errors:{函数名: 异常类名字符串};fixed:{函数名: 修好并用 jax.jit 包装后的函数},调用方式与原函数相同(例如 fixed["ex1_topk_mean"](x, 2))。

# 练习 1 题目代码
def ex1_normalize(x, mode):            # mode 为 "l1" 或 "l2"
    if mode == "l1":
        n = jnp.sum(jnp.abs(x))
    else:
        n = jnp.sqrt(jnp.sum(x ** 2))
    return x / n

def ex1_topk_mean(x, k):               # 最大的 k 个数的平均值
    return jnp.mean(jnp.sort(x)[::-1][:k])

def ex1_count_pos(x):                  # 正数的个数
    return x[x > 0].shape[0]

def ex1_clip_by_norm(x, max_norm):     # 范数超过 max_norm 时等比缩小
    n = jnp.linalg.norm(x)
    if n > max_norm:
        x = x * max_norm / n
    return x

ex1_x = jnp.array([3.0, -1.0, 4.0, -1.5, 5.0])
ex1_calls = {"ex1_normalize": (ex1_normalize, (ex1_x, "l1")), "ex1_topk_mean": (ex1_topk_mean, (ex1_x, 2)),
             "ex1_count_pos": (ex1_count_pos, (ex1_x,)), "ex1_clip_by_norm": (ex1_clip_by_norm, (ex1_x, 2.0))}
# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
    ex1_errors, fixed
except NameError as e:
    print(f"⏳ 练习 1 尚未作答:{e}")
else:
    assert ex1_errors == {"ex1_normalize": "TypeError", "ex1_topk_mean": "IndexError",
                          "ex1_count_pos": "NonConcreteBooleanIndexError",
                          "ex1_clip_by_norm": "TracerBoolConversionError"}, f"实测的异常类型不对:{ex1_errors}"
    _missing = set(ex1_calls) - set(fixed)
    assert not _missing, f"fixed 里缺少:{sorted(_missing)}"
    for _name in ex1_calls:
        assert hasattr(fixed[_name], "lower"), f"fixed['{_name}'] 应该是 jax.jit 包装后的函数"
    _x = jnp.array([-2.0, 0.5, 7.0, 1.0, -3.0, 2.5])               # 换一组新输入
    _x64 = to64(_x)
    _cases = [
        ("ex1_normalize", (_x, "l1"), _x64 / np.abs(_x64).sum()),
        ("ex1_normalize", (_x, "l2"), _x64 / np.linalg.norm(_x64)),
        ("ex1_topk_mean", (_x, 3), np.sort(_x64)[::-1][:3].mean()),
        ("ex1_count_pos", (_x,), 4),
        ("ex1_count_pos", (-jnp.abs(_x),), 0),
        ("ex1_clip_by_norm", (_x, 1.0), _x64 / np.linalg.norm(_x64)),
        ("ex1_clip_by_norm", (_x, 100.0), _x64),
    ]
    for _name, _args, _ref in _cases:
        _out = fixed[_name](*_args)
        assert np.allclose(to64(_out), _ref, atol=1e-5), \
            f"{_name}{tuple(a if isinstance(a, (str, int, float)) else 'x' for a in _args)} 的结果不对:{np.asarray(_out)},应为 {_ref}"
    print("✅ 练习 1 自检通过")
⏳ 练习 1 尚未作答:name 'ex1_errors' is not defined

练习 2:nan_guard 装饰器

⚪ 选读:练习:巩固 §3.2、§5.4

写一个装饰器 nan_guard(fn):

  1. 内部用 jax.jit(checkify.checkify(fn, errors=...)) 编译,检查 NaN、除零以及 fn 里自己写的 checkify.check;
  2. 调用时如果检查失败,抛出 FloatingPointError,信息以 "<函数名>: " 开头,后面是 checkify 的错误信息;否则返回 fn 的输出;
  3. 用下面的 ex2_layer 测试:正常输入返回正确结果;含负数的输入触发 log 的 NaN;scale=0 触发自定义检查;
  4. 用 CompileCounter 断言:同一形状下调用 5 次(包括出错的调用),只编译 1 次。

自检接口:nan_guard(fn):返回包装后的函数,调用方式与 fn 相同;检查失败时抛出 FloatingPointError,信息以 "<函数名>: " 开头。

# 练习 2 题目代码
def ex2_layer(x, scale):
    checkify.check(scale != 0, "scale 不能为 0")
    return jnp.log(x) * scale
# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
    nan_guard
except NameError as e:
    print(f"⏳ 练习 2 尚未作答:{e}")
else:
    def _probe(x, s):                                           # 换一个新函数来测
        checkify.check(s > 0, "s 必须为正")
        return jnp.sqrt(x) * s
    _guarded = nan_guard(_probe)
    _good, _bad = jnp.array([1.0, 4.0, 9.0]), jnp.array([1.0, -4.0, 9.0])
    _results = []
    with CompileCounter() as _cc:
        for _xx, _s in [(_good, 2.0), (_bad, 2.0), (_good, -1.0), (_good, 3.0), (_bad, 3.0)]:
            try:
                _results.append(("ok", _guarded(_xx, jnp.float32(_s))))
            except FloatingPointError as e:
                _results.append(("err", str(e)))
    _kinds = [k for k, _ in _results]
    assert _kinds == ["ok", "err", "err", "ok", "err"], f"哪些调用应该报错不对:{_kinds}(应为 ok/err/err/ok/err)"
    assert np.allclose(to64(_results[0][1]), [2.0, 4.0, 6.0]) and np.allclose(to64(_results[3][1]), [3.0, 6.0, 9.0]), \
        "正常输入时应返回 fn 的输出"
    assert _results[1][1].startswith("_probe: ") and "nan" in _results[1][1] and "sqrt" in _results[1][1], \
        f"NaN 的报错信息应以 '_probe: ' 开头并指出 sqrt:{_results[1][1]!r}"
    assert _results[2][1].startswith("_probe: ") and "s 必须为正" in _results[2][1], \
        f"自定义检查的报错信息不对:{_results[2][1]!r}"
    assert _cc.count == 1, f"同一形状调用 5 次应只编译 1 次,实际编译了 {_cc.count} 次"
    print("✅ 练习 2 自检通过")
⏳ 练习 2 尚未作答:name 'nan_guard' is not defined

练习 3:泄漏探测器

⚪ 选读:练习:巩固 §7.3

写 track_new_arrays(fn, *args):运行 fn(*args),返回 (fn 的返回值, Counter),其中 Counter 统计调用结束后仍然存活、而且是这次调用新产生的数组,按 (形状, dtype) 计数(不能把 fn 的返回值本身算进去)。提示:在调用前后用 jax.live_arrays() 记下数组的 id,注意不要让你自己的快照列表把数组留住。

然后:

  1. 对下面 ex3_Trainer 的 run(5) 调用,断言探测到 5 个泄漏的 (32, 16) 数组(来自 self.history);
  2. 写一个修好的 ex3_TrainerFixed(只记录梯度范数这个 Python 浮点数),断言探测结果为空,且两个版本最后的参数相同。

自检接口:track_new_arrays(fn, *args):返回 (fn 的返回值, Counter),Counter 的键是 (形状元组, dtype);ex3_TrainerFixed(w, x):构造方式和 run(n) 与 ex3_Trainer 相同,history 里存 Python 浮点数。

# 练习 3 题目代码
@jax.jit
def ex3_step(w, x):
    g = jax.grad(lambda w: jnp.mean(jnp.tanh(x @ w) ** 2))(w)
    return w - 0.1 * g, g

class ex3_Trainer:
    def __init__(self, w, x):
        self.w, self.x, self.history = w, x, []

    def run(self, n):
        for _ in range(n):
            self.w, g = ex3_step(self.w, self.x)
            self.history.append(g)            # 记录梯度用来「以后分析」
        return self.w

ex3_w0 = jax.random.normal(jax.random.key(30), (32, 16)) / 6.0
ex3_x = jax.random.normal(jax.random.key(31), (8, 32))
# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
    track_new_arrays, ex3_TrainerFixed
except NameError as e:
    print(f"⏳ 练习 3 尚未作答:{e}")
else:
    _norm = lambda c: Counter({(tuple(k[0]), str(k[1])): v for k, v in c.items()})
    _keep = []
    def _leaky(n):                                              # 换一个新的「泄漏」函数来测
        for i in range(n):
            _keep.append(jnp.ones((3, i + 1)))                  # 存进外部列表:调用结束后仍然存活
            _tmp = jnp.zeros((5,)) + i                          # 临时数组:调用结束就释放
        return jnp.arange(7.0)
    _out, _cnt = track_new_arrays(_leaky, 3)
    assert np.array_equal(np.asarray(_out), np.arange(7.0)), "第一个返回值应该是 fn 的返回值"
    assert _norm(_cnt) == Counter({((3, 1), "float32"): 1, ((3, 2), "float32"): 1, ((3, 3), "float32"): 1}), \
        f"泄漏统计不对:{dict(_cnt)}(应为 (3,1)、(3,2)、(3,3) 各 1 个,返回值和临时数组都不算)"
    _, _cnt2 = track_new_arrays(lambda: jnp.ones((4, 4)) * 2)
    assert not _cnt2, f"没有泄漏时应返回空 Counter(返回值本身不算),实际是 {dict(_cnt2)}"
    del _keep
    _w0 = jax.random.normal(jax.random.key(9030), (32, 16)) / 6.0
    _xx = jax.random.normal(jax.random.key(9031), (8, 32))
    _t = ex3_TrainerFixed(_w0, _xx)
    _wf, _leak = track_new_arrays(_t.run, 3)
    assert not _leak, f"ex3_TrainerFixed 仍有泄漏:{dict(_leak)}"
    assert len(_t.history) == 3 and all(isinstance(v, float) for v in _t.history), \
        f"history 应记录 3 个 Python 浮点数,实际是 {[type(v).__name__ for v in _t.history]}"
    assert np.array_equal(np.asarray(_wf), np.asarray(ex3_Trainer(_w0, _xx).run(3))), "修好的版本参数应与原版完全相同"
    print("✅ 练习 3 自检通过")
⏳ 练习 3 尚未作答:name 'track_new_arrays' is not defined

练习 4:自动定位第一个 NaN

⚪ 选读:练习:巩固 §3.1、§9

下面的 ex4_step 是一个 jit 编译的训练步,数据流 ex4_batches 中第 5 个 batch(下标 5) 混入了一个负数,会让 log 产生 NaN。写 first_nan_step(step_fn, w, batches):

  1. 正常训练(不要一直开着 jax_debug_nans),每步在主机端检查 loss 是否有限;
  2. 一旦发现非有限值,停下来,用出事前的参数和这一步的 batch,在 with jax.debug_nans(True): 里单独重跑这一步。with 写法不受 vmap 漏检问题影响;如果改用 config.update 全局打开,本进程已经用过 vmap,必须先 jax.clear_caches()(§3.1);
  3. 返回 (步数, 原语名),原语名从 FloatingPointError 的信息 invalid value (nan) encountered in <原语> 中解析;全程没有 NaN 时返回 None;
  4. 断言结果是 (5, "log"),并断言函数返回后 jax_debug_nans 已复原为 False。

自检接口:first_nan_step(step_fn, w, batches):step_fn(w, batch) 返回 (新参数, loss);返回 (步数, 原语名),没有 NaN 时返回 None。

# 练习 4 题目代码
@jax.jit
def ex4_step(w, batch):
    loss = jnp.mean(jnp.log(batch) * w)
    return w - 0.01 * jax.grad(lambda w: jnp.mean(jnp.log(batch) * w))(w), loss

ex4_batches = [jnp.full(4, 1.0 + i) for i in range(8)]
ex4_batches[5] = jnp.array([1.0, 2.0, -3.0, 4.0])
# ✍️ 作答区:练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 4
try:
    first_nan_step
except NameError as e:
    print(f"⏳ 练习 4 尚未作答:{e}")
else:
    @jax.jit
    def _probe_step(w, b):                                     # 换一个新的训练步:NaN 来自 sqrt
        loss = jnp.mean(jnp.sqrt(b) * w)
        return w * 0.9 + 0.1, loss
    _bs = [jnp.full(3, 1.0 + i) for i in range(6)]
    _bs[3] = jnp.array([4.0, -1.0, 9.0])
    _log = []                                                  # 记录每次调用时 debug_nans 的开关和传入的参数
    def _spy(w, b):
        _log.append((bool(jax.config.jax_debug_nans), float(w)))
        return _probe_step(w, b)
    _r = first_nan_step(_spy, jnp.float32(2.0), _bs)
    assert _r == (3, "sqrt"), f"应返回 (3, 'sqrt'),实际是 {_r}(若原语名是 None,检查重跑前是否清了缓存)"
    assert jax.config.jax_debug_nans is False, "返回后 jax_debug_nans 应复原为 False"
    _flags = [f for f, _ in _log]
    assert _flags[:4] == [False] * 4 and len(_flags) > 4 and all(_flags[4:]), \
        f"前 4 步应在关闭 debug_nans 时运行,发现 NaN 后才打开它重跑;实际开关记录:{_flags}"
    assert _log[-1][1] == _log[3][1], f"重跑时应使用出事前的参数 {_log[3][1]},实际用了 {_log[-1][1]}"
    assert first_nan_step(_spy, jnp.float32(2.0), _bs[:3]) is None, "全程没有 NaN 时应返回 None"
    print("✅ 练习 4 自检通过")
⏳ 练习 4 尚未作答:name 'first_nan_step' is not defined

📝 测验(6 题)

⚪ 选读:自测用

1. 在 JAX 0.12.0.1 中,下面哪个异常类是 jax.errors.ConcretizationTypeError 的子类?

  • A. TracerIntegerConversionError B. TracerArrayConversionError C. TracerBoolConversionError D. 三个都是

2. jax.vmap(f)(jnp.array([1.0, -1.0])),其中 f 内部是 lax.cond(x > 0, 分支A, 分支B, x),两个分支里各有一条 jax.debug.print。一共会打印几行?

  • A. 2 行 B. 4 行 C. 1 行 D. 0 行(vmap 里不能用 debug.print)

3. 本环境里,进程早先追踪过一次 vmap,然后你用 jax.config.update("jax_debug_nans", True) 全局打开检查,并调用一个之前已经执行过的 jit 函数,它产生了 NaN。会发生什么?怎么办?

  • A. 抛出 FloatingPointError,不用做任何事
  • B. 悄悄返回 NaN;先调用 jax.clear_caches() 再重跑,或改用 with jax.debug_nans(True):、checkify
  • C. 抛出 UnexpectedTracerError
  • D. 进程崩溃

4. 开着 jax_explain_cache_misses,日志里只有一句 cache miss explanation unavailable,而 jax_log_compiles 显示每次编译的参数形状都一样。最可能的原因是?

  • A. 输入的 dtype 在变 B. 某个静态参数的值在变 C. PyTree 结构在变 D. 打开了 jax_debug_nans

5. 你怀疑某个 jit 函数有 tracer 泄漏,于是包上 with jax.checking_leaks(): 再调用一次,结果没有报错。下面哪种解释最可能成立?

  • A. jax.checking_leaks 在 0.12 中已被移除,什么都不做
  • B. 这个函数之前已经用同样的输入签名调用过,这次命中缓存、没有重新追踪,而泄漏检查只在追踪结束时进行
  • C. 泄漏检查只对 vmap 有效
  • D. 泄漏检查只在 GPU 上有效

6. 关于 JAX_TRACEBACK_FILTERING 的默认值 auto,正确的是?

  • A. 在 Jupyter(IPython ≥ 7.17)里等于 tracebackhide,在普通 Python 脚本里等于 quiet_remove_frames
  • B. 永远等于 off
  • C. 永远等于 remove_frames,原始栈放在 __cause__ 里
  • D. 只影响 jax.grad 的报错
点击查看答案与解析
  1. C。在 0.12.0.1 里,只有 TracerBoolConversionError 继承 ConcretizationTypeError;另外两个直接继承 JAXTypeError。统一捕获要用 jax.errors.JAXTypeError 或 TypeError(§4)。
  2. B。vmap 把 cond 改写成 select,两个分支对每个样本都执行:2 个样本 × 2 个分支 = 4 行(§1.1)。
  3. B。这是 0.12.0.1 的已知问题:全局打开时,追踪过 vmap 后,已缓存的计算不再检查。jax.clear_caches() 让它们重新编译,检查就恢复了;with jax.debug_nans(True): 写法和 checkify 都不受影响(§3.1)。
  4. B。0.12 中静态参数值变化时,explain_cache_misses 给不出具体原因;形状不变却反复编译,几乎就是静态参数(或闭包、全局配置)在变(§5.2、§9.2)。
  5. B。泄漏检查在追踪结束时进行;命中缓存就不会追踪。排查前先 jax.clear_caches()(§4.6)。
  6. A。在 notebook 里内部帧被标记后折叠显示;在脚本里被删掉,并追加一条「For simplicity, JAX has removed its internal frames」的提示(§6.1)。

本章小结

🟢 必读

  1. 先看症状,再选工具:NaN 先在主机端检查 loss、再用 checkify 或 with jax.debug_nans(True): 重跑出事的那一步;数值不对先用 float64 对拍,再用 disable_jit 二分;反复编译先数次数,再看日志和缓存未命中解释。
  2. 看值:jax.debug.print 用 str.format 语法,推荐两段式 jax.debug.print(ordered=True)(fmt, ...);在 vmap 下逐样本打印,在 grad 下只看到 primal 值(看梯度用 custom_vjp 探针),vmap 里的 cond 两个分支都会打印。jax.debug.callback 把 NumPy 数组交给任意 Python 函数。jax.debug.breakpoint 需要 stdin,条件断点要把变量作为分支参数传入。disable_jit 让 jit / scan / fori_loop / cond 都退化成 Python,但 vmap 仍会追踪。
  3. 看程序:jaxpr 里的字面量说明值被烘焙成了常量;jax.named_scope 的名字会出现在 jaxpr 名字栈、StableHLO(debug_info=True)和优化后 HLO 的 op_name 里。
  4. 数值:jax.debug_nans / jax.debug_infs 可以当上下文管理器用;0.12.0.1 中用 config.update 全局打开时,追踪过 vmap 后已缓存的计算会漏检(with 写法不受影响);反向传播里的 NaN 只能报出原语名。checkify 的四类检查分别抓自定义断言、NaN、除零、越界(JAX 默认会悄悄夹紧越界下标);vmap(checkify) 报告每个出错样本,checkify(vmap) 只报一个;float_checks 不抓 inf。check_grads 对 custom_vjp 只能用 modes=["rev"]。
  5. Tracer 错误:只有 TracerBoolConversionError 是 ConcretizationTypeError 的子类;报错里「depends on the value of the argument …」直接点名参数。泄漏检查(jax.checking_leaks())只在追踪时进行,要先清缓存。
  6. 编译与速度:一次编译输出 4 条日志;静态参数值变化时缓存未命中解释为 explanation unavailable;_cache_size() 是私有 API,同一个 Python 函数的所有 jit 包装共享计数;transfer_guard("disallow") 抓隐式的主机 → 设备传输。
  7. 报错与配置:JAX_TRACEBACK_FILTERING 有 5 个取值,auto 在 Jupyter 里是 tracebackhide、在脚本里是 quiet_remove_frames;读配置用 jax.config.<名字> 或 jax.config.values,不要用 config.read。
  8. 内存:memory_analysis() 配合 ShapeDtypeStruct 不分配内存就能预估;live_arrays() 前后快照找泄漏;x.delete() 立即释放(之后使用报 RuntimeError,但 repr 不报错);被 jit 缓存当作常量的旧全局数组只有 clear_caches() 能释放。
  9. 多设备:jax.typeof 显示 float32[8@data,4@model] 这样的分片类型;visualize_array_sharding 需要 rich(本环境里它已作为 flax 的依赖装上),没有时可用基于 devices_indices_map 的替代函数;check_vma=False 的 shard_map 可能返回各设备不一致的「副本」,用 addressable_shards 逐个比较。
  10. 随机数:jax_debug_key_reuse 能抓住类型化 key 的复用,旧式 PRNGKey 不受检查。

速查表

⚪ 选读:查阅用的 API 对照表

API作用
jax.debug.print("x={x:.3f}", x=x)运行时打印(str.format 语法,不要用 f-string)
jax.debug.print(ordered=True)(fmt, ...)推荐的两段式写法;ordered=True 保证多条打印的顺序
jax.debug.log(fmt, ...)同 debug.print,但输出到 Python logging(logger jax._src.debugging,INFO 级)
jax.debug.callback(fn, *args)把运行时值(NumPy 数组)交给主机端 Python 函数,fn 返回 None
jax.debug.breakpoint()运行时停下,进入 jdb(p / pp / u / d / l / w / c / q)
jax.effects_barrier()等所有已派发的回调(打印)执行完
with jax.disable_jit():jit / scan / fori_loop / cond 按 Python 执行(vmap 除外)
jax.make_jaxpr(f)(*a) / jit(f).trace(*a).jaxpr查看 jaxpr;eqn.source_info.name_stack 是名字栈
jit(f).lower(*a).as_text(debug_info=True)StableHLO,带源码位置和 named_scope
jit(f).lower(*a).compile().as_text()优化后 HLO,metadata={op_name=...}
jax.stages.Traced / Lowered / Compiled三个阶段对象
with jax.named_scope("名字"):给其中的操作加名字前缀,便于在 HLO / trace 中定位
with jax.debug_nans(True): / jax.debug_infs(True)作用域内检查 NaN / inf,抛 FloatingPointError
jax.clear_caches()清空编译缓存:规避全局 debug_nans 的漏检、让泄漏检查重新追踪、释放被缓存的常量
checkify.checkify(f, errors=...)函数化检查,返回 (err, out);err.get() / err.throw()
checkify.check(pred, "msg {v}", v=x)自定义运行时断言(必须被 checkify 包装)
checkify.user_checks / nan_checks / div_checks / index_checks / float_checks / all_checks错误集合,可用按位或运算符组合(如 float_checks 并 user_checks)
checkify.debug_check(...)未被 checkify 包装时是空操作
import jax.test_util; check_grads(f, args, order, modes)有限差分检查梯度;custom_vjp 只能 modes=["rev"]
jax.errors.TracerBoolConversionError 等Tracer 错误类;统一捕获用 jax.errors.JAXTypeError
with jax.checking_leaks(): / jax.check_tracer_leaks(True)追踪结束时检查 tracer 泄漏,报引用链
with jax.log_compiles():每次编译输出 4 条日志(含形状和耗时)
with jax.explain_cache_misses():说明缓存未命中的原因
jit_f._cache_size()私有 API:缓存条目数(同一 Python 函数的所有包装共享)
jax.monitoring.register_event_duration_secs_listener本章 CompileCounter 的基础(/jax/core/compile/backend_compile_duration)
with jax.transfer_guard("disallow"):隐式主机 ↔ 设备传输直接报错
JAX_TRACEBACK_FILTERING=off(另有 tracebackhide / remove_frames / quiet_remove_frames / auto)控制报错栈过滤;也可 jax.config.update("jax_traceback_filtering", ...)
jax.config.jax_debug_nans / jax.debug_nans.value / jax.config.values读取配置
jax.print_environment_info(return_string=True)版本与设备信息(贴 issue 用)
compiled.memory_analysis()参数 / 输出 / 临时缓冲区 / 峰值字节数
jax.profiler.save_device_memory_profile(path) / device_memory_profile()存活缓冲区按分配位置汇总(pprof 格式)
jax.live_arrays()当前所有存活的 jax.Array
x.delete() / x.is_deleted()立即释放设备缓冲区 / 是否已释放
device.memory_stats()GPU/TPU 上的实时显存统计(CPU 上为 None)
jax.typeof(x)抽象类型,含分片信息(如 float32[8@data,4@model])
x.sharding.devices_indices_map(x.shape)每个设备持有的切片
jax.debug.visualize_array_sharding(x)画分片布局(需要 rich)
jax.debug.inspect_array_sharding(x, callback=...)在 jit 内查看编译器选定的分片
x.addressable_shards各本地设备上的实际数据(检查副本是否一致)
with jax.debug_key_reuse(True):类型化 key 复用时抛 jax.errors.KeyReuseError
jax.random.key_data(key)取出 key 的原始数据(比较两个 key 是否相同)

最后确认:本章打开过的所有调试开关都已经复原(与干净子进程中的默认配置逐项比较)。

leftover = nondefault_flags()
print("与默认值不同的配置项:", leftover or "无")
assert leftover == {}
assert jax.config.jax_debug_nans is False and jax.config.jax_traceback_filtering == "auto"
与默认值不同的配置项: 无

附录:练习参考答案

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

练习 1

题目见正文「练习 1:Tracer 错误修复四连」。

# 参考答案:练习 1
ex1_errors = {}
for name, (fn, args) in ex1_calls.items():
    try:
        jax.jit(fn)(*args)
        ex1_errors[name] = None
    except Exception as e:
        ex1_errors[name] = type(e).__name__
print("实测异常:", ex1_errors)
assert ex1_errors == {"ex1_normalize": "TypeError", "ex1_topk_mean": "IndexError",
                      "ex1_count_pos": "NonConcreteBooleanIndexError", "ex1_clip_by_norm": "TracerBoolConversionError"}

# 修法:字符串 / 决定形状的整数 → 静态参数;布尔索引 → 掩码求和;依赖数值的 if → jnp.where
fixed = {
    "ex1_normalize": jax.jit(ex1_normalize, static_argnames="mode"),
    "ex1_topk_mean": jax.jit(ex1_topk_mean, static_argnames="k"),
    "ex1_count_pos": jax.jit(lambda x: jnp.sum(x > 0)),
    "ex1_clip_by_norm": jax.jit(lambda x, max_norm: x * jnp.minimum(1.0, max_norm / jnp.linalg.norm(x))),
}
x64 = to64(ex1_x)
refs = {"ex1_normalize": x64 / np.abs(x64).sum(), "ex1_topk_mean": np.sort(x64)[::-1][:2].mean(),
        "ex1_count_pos": (x64 > 0).sum(), "ex1_clip_by_norm": x64 * min(1.0, 2.0 / np.linalg.norm(x64))}
for name, (_, args) in ex1_calls.items():
    out = fixed[name](*args)
    print(f"  {name:17s} → {np.round(np.asarray(out), 4)}")
    assert np.allclose(out, refs[name], atol=1e-6)
assert np.allclose(fixed["ex1_normalize"](ex1_x, "l2"), x64 / np.linalg.norm(x64), atol=1e-6)   # 另一个分支
assert np.allclose(fixed["ex1_clip_by_norm"](ex1_x, 100.0), x64)                                # 不需要缩放时原样返回
实测异常: {'ex1_normalize': 'TypeError', 'ex1_topk_mean': 'IndexError', 'ex1_count_pos': 'NonConcreteBooleanIndexError', 'ex1_clip_by_norm': 'TracerBoolConversionError'}
  ex1_normalize     → [ 0.2069 -0.069   0.2759 -0.1034  0.3448]
  ex1_topk_mean     → 4.5
  ex1_count_pos     → 3
  ex1_clip_by_norm  → [ 0.8222 -0.2741  1.0963 -0.4111  1.3704]

练习 2

题目见正文「练习 2:nan_guard 装饰器」。

# 参考答案:练习 2
def nan_guard(fn):
    checked = jax.jit(checkify.checkify(fn, errors=checkify.float_checks | checkify.user_checks))

    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        err, out = checked(*args, **kwargs)
        msg = err.get()
        if msg is not None:
            raise FloatingPointError(f"{fn.__name__}: {msg.strip()}")
        return out
    wrapper.checked = checked
    return wrapper

guarded = nan_guard(ex2_layer)
good_x, bad_x = jnp.array([1.0, 2.0, 4.0]), jnp.array([1.0, -2.0, 4.0])
s1, s0 = jnp.float32(2.0), jnp.float32(0.0)
outcomes = []
with CompileCounter() as cc_ex2:
    for x, s in [(good_x, s1), (bad_x, s1), (good_x, s0), (good_x, s1), (bad_x, s1)]:
        try:
            outcomes.append(("ok", guarded(x, s)))
        except FloatingPointError as e:
            outcomes.append(("err", str(e)))
for kind, v in outcomes:
    print(f"  {kind:3s} {v}")
print("编译次数:", cc_ex2.count)
assert [k for k, _ in outcomes] == ["ok", "err", "err", "ok", "err"]
assert np.allclose(outcomes[0][1], np.log(to64(good_x)) * 2.0)
assert outcomes[1][1].startswith("ex2_layer: nan generated by primitive: log")
assert outcomes[2][1].startswith("ex2_layer: scale 不能为 0")
assert cc_ex2.count == 1
  ok  [0.        1.3862944 2.7725887]
  err ex2_layer: nan generated by primitive: log.
  err ex2_layer: scale 不能为 0 (`check` failed)
  ok  [0.        1.3862944 2.7725887]
  err ex2_layer: nan generated by primitive: log.
编译次数: 1

练习 3

题目见正文「练习 3:泄漏探测器」。

# 参考答案:练习 3
def track_new_arrays(fn, *args):
    gc.collect()
    before_ids = {id(a) for a in jax.live_arrays()}          # 只保存 id,不保存数组本身
    out = fn(*args)
    jax.block_until_ready(out)
    gc.collect()
    out_ids = {id(a) for a in jax.tree.leaves(out)}
    new = Counter((a.shape, str(a.dtype)) for a in jax.live_arrays()
                  if id(a) not in before_ids and id(a) not in out_ids)
    return out, new

class ex3_TrainerFixed(ex3_Trainer):
    def run(self, n):
        for _ in range(n):
            self.w, g = ex3_step(self.w, self.x)
            self.history.append(float(jnp.linalg.norm(g)))   # 只留一个 Python 浮点数
        return self.w

t_bad, t_ok = ex3_Trainer(ex3_w0, ex3_x), ex3_TrainerFixed(ex3_w0, ex3_x)
w_bad, leaks_bad = track_new_arrays(t_bad.run, 5)
w_ok, leaks_ok = track_new_arrays(t_ok.run, 5)
print("有泄漏的版本:", dict(leaks_bad))
print("修好的版本:", dict(leaks_ok), "| 梯度范数记录:", [round(v, 4) for v in t_ok.history])
assert leaks_bad == Counter({((32, 16), "float32"): 5})
assert leaks_ok == Counter() and len(t_ok.history) == 5
assert np.array_equal(w_bad, w_ok)
有泄漏的版本: {((32, 16), 'float32'): 5}
修好的版本: {} | 梯度范数记录: [0.281, 0.2793, 0.2773, 0.2751, 0.2728]

练习 4

题目见正文「练习 4:自动定位第一个 NaN」。

# 参考答案:练习 4
def first_nan_step(step_fn, w, batches):
    for i, batch in enumerate(batches):
        w_new, loss = step_fn(w, batch)
        if not np.isfinite(float(loss)):
            with jax.debug_nans(True):                 # with 写法不受 vmap 漏检问题影响(§3.1)
                try:
                    jax.block_until_ready(step_fn(w, batch))   # 用出事前的参数重跑这一步
                    return i, None
                except FloatingPointError as e:
                    m = re.search(r"encountered in (\w+)", str(e))
                    return i, m.group(1) if m else str(e)
        w = w_new
    return None

result = first_nan_step(ex4_step, jnp.float32(1.0), ex4_batches)
print("第一个 NaN:", result)
assert result == (5, "log") and jax.config.jax_debug_nans is False
assert first_nan_step(ex4_step, jnp.float32(1.0), ex4_batches[:5]) is None      # 前 5 步没有 NaN
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
Invalid nan value encountered in the output of a jax.jit function. Calling the de-optimized version.
第一个 NaN: (5, 'log')