第 2 章 用 JIT 编译加速函数

课程:「JAX 入门」(apxml.com)第 2 章 · 个人学习笔记 原课程章节页:https://apxml.com/zh/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit 运行环境:JAX 0.12.0.1 · Python 3.14 · 仅 CPU。这里所有的结论和输出,都以这个环境下的实际运行结果为准。

学习目标

  1. 理解为什么逐行(eager)执行 JAX 代码不够快,编译能带来哪些好处;
  2. 掌握 jax.jit 的两种用法(装饰器、高阶函数);
  3. 理解 JIT 的内部流程:追踪(tracing)→ Jaxpr → 降级(lowering)→ XLA 编译 → 缓存;
  4. 理解追踪对 Python 控制流的限制,学会用 lax.cond / switch / fori_loop / while_loop / scan 写出能编译的控制流;
  5. 分清静态值和追踪值,会用 static_argnums / static_argnames,知道什么情况会触发重新编译;
  6. 认识 jit 的常见陷阱:副作用、全局变量、动态形状、参数捐赠等;
  7. 学会正确地测性能:先预热,再用 block_until_ready() 计时。

小节目录

小节原课程链接
2.1 速度提升:为何需要编译?why-compile-jax
2.2 介绍 jax.jitintroducing-jax-jit
2.3 JIT 工作原理:追踪与编译how-jit-works
2.4 Python 控制流与 jitjit-control-flow
2.5 静态值与跟踪值jit-static-vs-traced
2.6 jit 的常见问题jit-common-pitfalls
2.7 动手实践:应用 jithands-on-applying-jit
补充练习 · 测验 · 小结 · 速查表quiz

阅读说明:普通段落是按课程内容整理的讲解。过时的写法放在 ❌ 单元里,后面紧跟 ✅ 推荐写法,并用 assert 验证。需要特别留意的地方用「⚠️ 注意」标出。🔍 补充 单元是延伸内容。章末的「本章更新说明」做了汇总。

讲解按课程要点用中文重新组织过。示例代码尽量沿用课程的变量名和数值,全部在本机实际运行过。

# 公共导入与工具函数(后面所有单元都依赖这里)
import time
import timeit
import logging
import re
import contextlib
import warnings
from functools import partial

import numpy as np
import jax
import jax.numpy as jnp
from jax import lax

print("JAX 版本:", jax.__version__)
print("设备列表:", jax.devices())
print("数组所在设备:", jnp.ones(1).device)   # 0.12 推荐用 x.device 属性


def bench(f, *args, warmup=2, runs=20, **kwargs):
    """计时工具:先预热(触发编译),再多次运行取平均。

    用 jax.block_until_ready 等待结果真正算完——它能处理任意 PyTree,
    也能处理非 JAX 返回值(原样返回)。返回 (平均秒数, 标准差)。
    """
    for _ in range(warmup):
        jax.block_until_ready(f(*args, **kwargs))
    ts = []
    for _ in range(runs):
        t0 = time.perf_counter()
        jax.block_until_ready(f(*args, **kwargs))
        ts.append(time.perf_counter() - t0)
    return float(np.mean(ts)), float(np.std(ts))


def assert_faster(fast, slow, factor=1.0, tries=3):
    """计时断言:fast() 的耗时 × factor < slow() 的耗时(两个参数都是返回耗时的无参函数)。
    计时受机器负载影响(例如 CI 上几个 notebook 并行),不满足时重新测量,最多 tries 次。"""
    for _ in range(tries):
        tf, ts = fast(), slow()
        if tf * factor < ts:
            return tf, ts
    raise AssertionError(f"重测 {tries} 次仍不满足:{tf:.3g} × {factor} ≥ {ts:.3g}")

def expect_error(fn, exc_type=Exception):
    """断言 fn() 一定抛出 exc_type:打印异常类型和第一行信息,并返回异常对象。
    若没有报错则抛出 AssertionError——用来在 notebook 里“自证”某段代码确实会失败。"""
    try:
        out = fn()
    except exc_type as e:
        first = str(e).strip().splitlines()[0]
        print(f"❌ {type(e).__name__}: {first[:160]}")
        return e
    raise AssertionError(f"预期抛出 {exc_type.__name__},实际没有报错,结果: {out}")


class _CompileLog(logging.Handler):
    """收集 jax_log_compiles 产生的 “Compiling jit(<函数名>)” 日志。"""

    def __init__(self, echo):
        super().__init__()
        self.names, self.echo = [], echo

    def emit(self, record):
        m = re.match(r"Compiling jit\((.*?)\)", record.getMessage())
        if m:
            self.names.append(m.group(1))
            if self.echo:
                print(f"    [编译] jit({m.group(1)})", flush=True)

    def count(self, name=None):
        return sum(1 for n in self.names if name is None or n == name)


@contextlib.contextmanager
def count_compiles(echo=True):
    """上下文管理器:统计块内真正发生的 XLA 编译次数(可按函数名过滤)。

    原理:临时打开 jax_log_compiles,并在 "jax" logger 上挂一个收集器。
    用法:with count_compiles() as log: ...;  assert log.count("函数名") == k
    """
    h = _CompileLog(echo)
    logger = logging.getLogger("jax")
    logger.addHandler(h)
    old = jax.config.jax_log_compiles
    jax.config.update("jax_log_compiles", True)
    try:
        yield h
    finally:
        jax.config.update("jax_log_compiles", old)
        logger.removeHandler(h)
JAX 版本: 0.12.0.1
设备列表: [CpuDevice(id=0)]
数组所在设备: cpu:0

2.1 速度提升:为何需要编译?

原文:https://apxml.com/zh/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/why-compile-jax

问题:直接运行 JAX 并不一定快。 第 1 章里,我们像用 NumPy 一样一行行地调用 jnp 函数,这种方式叫 eager(急切)执行。这时 JAX 的速度通常和 NumPy 差不多,有时还更慢。原因有两个:

  1. 解释器与调度开销。 Python 解释器一行一行地执行代码,每个 jnp 操作都要单独走一遍 Python → JAX → 后端的调度(dispatch)。操作本身越小,这部分固定开销占比就越高。
  2. 加速器的启动延迟。 在 GPU/TPU 上,CPU 要把每个小操作逐个发给加速器。每次“发令”都有延迟,加速器经常是在等指令,而不是在计算。

解决办法:编译。 与其一行行地解释,不如把整个函数(或者其中的关键部分)一次性转换成针对目标硬件(CPU/GPU/TPU)优化过的底层表示。在 JAX 里,就是交给 XLA 编译器去生成代码(比如 XLA 的 HLO 中间表示)。这个编译是**即时(Just-In-Time)**发生的:函数第一次以某种输入类型和形状被调用时才编译。

编译后的函数主要有三个好处:

  • 减少解释器开销:整个计算图作为一个整体执行,不用每个操作都回一趟 Python。
  • 算子融合(fusion):编译器把多个操作合并成一个内核,中间结果不用写回内存再读出来。比如逐元素运算 $$y = a\cdot x + b$$ 不编译时是“乘法一个内核 + 加法一个内核”,中间数组 $a\cdot x$ 要落到内存里;融合之后只需要一次内核启动、一次读写。
  • 利用硬件并行:编译器能针对目标硬件安排向量化和并行执行。

打个比方:逐行解释执行,就像拿着扩音器一次只指挥一名工人做一个动作。编译则是先把完整的施工蓝图交给整个施工队,让大家照着图高效配合。

课程原页面这里配了一张示意图:同一个数值任务在不同执行方式下的相对加速(纵轴是对数刻度),并提醒实际加速比和任务、硬件关系很大。

结论:JAX 不会自动编译你的函数。想要性能,得显式请求编译,主要工具就是 jax.jit。可以把它看成“蓝图生成器”。

🔍 补充:亲手验证“调度开销”和“算子融合”

⚪ 选读:想亲眼确认 jit 为什么快(调度开销、算子融合)时看。

下面用两段代码亲手验证一下:

  1. 写一个由 50 个很小的逐元素操作组成的函数,在小数组上分别直接执行和用 jit 执行。两者的差距几乎全部来自调度开销。
  2. 打印编译后的 HLO,可以直接看到 XLA 把 a*x+b 和 tanh **融合(fusion)**成了一个内核。

还有一点要知道:在 CPU 上,单个大操作(比如大矩阵乘法)不加 jit 时本来就会调用高度优化的库,jit 帮不了多少。jit 真正省时间的场景,是很多个小操作连在一起。

def many_small_ops(x):
    # 50 个微小的逐元素操作:每个都要单独调度一次
    for _ in range(50):
        x = x * 1.0001 + 0.001
    return x

x_small = jnp.ones(100)
many_small_ops_jit = jax.jit(many_small_ops)

t_eager, _ = bench(many_small_ops, x_small)
t_jit, _ = bench(many_small_ops_jit, x_small)
print(f"eager: {t_eager*1e6:8.1f} µs")
print(f"jit  : {t_jit*1e6:8.1f} µs   加速约 {t_eager/t_jit:.0f} 倍")
# jit 版本必然更快(这里主要省掉的是 50 次调度开销);计时受负载影响,assert_faster 不满足时会重测
assert_faster(lambda: bench(many_small_ops_jit, x_small)[0], lambda: bench(many_small_ops, x_small)[0])
eager:   1218.3 µs
jit  :     22.5 µs   加速约 54 倍
(8.518750837538391e-06, 0.001112206349716871)
# 查看 XLA 编译后的 HLO,寻找 fusion(融合内核)
fused = jax.jit(lambda a, x, b: jnp.tanh(a * x + b))
hlo = fused.lower(2.0, jnp.ones(1000), 1.0).compile().as_text()
fusion_lines = [line.strip() for line in hlo.splitlines() if "fusion(" in line]
for line in fusion_lines:
    print(line[:160])
assert len(fusion_lines) == 1   # 乘、加、tanh 只剩一条 fusion 指令
# 只有一条 fusion 指令:乘、加、tanh 被合并为一个循环内核(kind=kLoop)
ROOT %add_tanh_fusion = f32[1000]{0} fusion(%b.1, %x.1, %a.1), kind=kLoop, calls=%fused_computation, metadata={op_name="jit(<lambda>)/tanh" stack_frame_id=19}

2.2 介绍 jax.jit

原文:https://apxml.com/zh/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/introducing-jax-jit

jax.jit 是 JAX 最主要的编译变换。你给它一个处理 JAX 数组的普通 Python 函数,它返回这个函数的编译版本。编译版本会被转换成针对 CPU/GPU/TPU 优化、融合过的操作序列。

两种用法:

  1. 装饰器:在函数定义前写 @jax.jit。最常用,可读性也最好;
  2. 高阶函数:compiled = jax.jit(fn),返回编译版本。适合“函数不是你定义的”(比如来自某个库),或者想同时保留原函数的场景。

两种写法效果完全一样,只是风格不同。

下面是课程的示例:一次矩阵乘法加偏置,经过 tanh,再取均值。

# 课程示例:一个“稍复杂”的计算
def complex_computation(x, weight, bias):
    y = jnp.dot(x, weight) + bias
    z = jnp.tanh(y)
    return jnp.mean(z)

# 生成数据:用 jax.random.key 并为每个数组 split 出独立子 key(新旧写法的对照见本节后面)
key = jax.random.key(0)
kx, kw, kb = jax.random.split(key, 3)
x_data = jax.random.normal(kx, (1000, 500))
weight_data = jax.random.normal(kw, (500, 200))
bias_data = jax.random.normal(kb, (200,))

# 用法 1:装饰器
@jax.jit
def compiled_computation_decorator(x, weight, bias):
    y = jnp.dot(x, weight) + bias
    z = jnp.tanh(y)
    return jnp.mean(z)

# 用法 2:高阶函数
compiled_computation_functional = jax.jit(complex_computation)
# 计时流程(用更精确的 time.perf_counter,并统一调用 block_until_ready)

# 原始函数:先预热一次,再计时
_ = complex_computation(x_data, weight_data, bias_data).block_until_ready()
t0 = time.perf_counter()
result_original = complex_computation(x_data, weight_data, bias_data).block_until_ready()
print(f"原始函数时间:                      {time.perf_counter() - t0:.6f} 秒")

# 装饰器版本:第一次调用包含 追踪 + 编译
t0 = time.perf_counter()
result_compiled_decorator = compiled_computation_decorator(x_data, weight_data, bias_data).block_until_ready()
print(f"编译函数(装饰器)首次调用(含编译): {time.perf_counter() - t0:.6f} 秒")

# 第二次调用:命中缓存
t0 = time.perf_counter()
result_compiled_decorator = compiled_computation_decorator(x_data, weight_data, bias_data).block_until_ready()
print(f"编译函数(装饰器)第二次调用(缓存): {time.perf_counter() - t0:.6f} 秒")

print("结果匹配:", jnp.allclose(result_original, result_compiled_decorator))
assert jnp.allclose(result_original, result_compiled_decorator)

# 函数式版本:先调用一次触发编译,再计时
_ = compiled_computation_functional(x_data, weight_data, bias_data).block_until_ready()
t0 = time.perf_counter()
_ = compiled_computation_functional(x_data, weight_data, bias_data).block_until_ready()
print(f"编译函数(函数式)后续调用:          {time.perf_counter() - t0:.6f} 秒")
原始函数时间:                      0.000934 秒
编译函数(装饰器)首次调用(含编译): 0.018722 秒
编译函数(装饰器)第二次调用(缓存): 0.000854 秒
结果匹配: True
编译函数(函数式)后续调用:          0.001013 秒

计时要点:JAX 是异步调度的。 调用一个 JAX 函数时,Python 拿到返回的数组对象就继续往下走了,真正的计算可能还在后台跑。如果不调用 .block_until_ready(),测到的只是“把任务排进队列”的时间。

运行后可以看到:

  • 原始函数每次都在 Python 里逐个操作执行;
  • 编译函数的第一次调用通常比原始函数还慢,因为里面包含了追踪和 XLA 编译;
  • 之后用相同形状和 dtype 调用时明显更快,因为直接复用了缓存的编译结果。

(这个例子的主体是一个大矩阵乘法,在 CPU 上不加 jit 也已经很快,所以 jit 的加速比不会很夸张。这正好印证了 2.1 补充里的说法。)

随机数 key:旧写法与推荐写法

⚠️ 注意:旧代码常用 jax.random.PRNGKey(0) 生成 key,还会把同一个 key 同时用在 x_data、weight_data、bias_data 三个数组上。PRNGKey 在 JAX 0.12 里还能用,但已经是旧接口了,返回的是没有类型信息的 uint32[2] 原始 key。复用同一个 key 的问题更大:不同数组会共用同一串随机比特,生成的序列前缀完全一样。本教程统一用 jax.random.key + jax.random.split。key 和 PRNGKey 的完整对比见 01 §1.5。

# ❌ 旧写法(过时):PRNGKey 返回无类型的 uint32 原始 key;且同一个 key 被复用于多个数组
try:
    key_old = jax.random.PRNGKey(0)
    a_old = jax.random.normal(key_old, (6,))
    b_old = jax.random.normal(key_old, (3,))
    print("key =", key_old, " dtype =", key_old.dtype)
    print("a[:3] =", a_old[:3])
    print("b     =", b_old)
    print("两个数组前缀相同:", bool(jnp.allclose(a_old[:3], b_old)))
except Exception as e:
    print("报错:", type(e).__name__, e)
key = [0 0]  dtype = uint32
a[:3] = [ 1.6226422   2.0252647  -0.43359444]
b     = [ 1.6226422   2.0252647  -0.43359444]
两个数组前缀相同: True
# ✅ 推荐写法:jax.random.key 返回带类型的 key;每个用途 split 出独立的子 key
key = jax.random.key(0)
k_a, k_b = jax.random.split(key)
a_new = jax.random.normal(k_a, (6,))
b_new = jax.random.normal(k_b, (3,))
print("key dtype =", key.dtype)
print("a[:3] =", a_new[:3])
print("b     =", b_new)

assert jnp.issubdtype(key.dtype, jax.dtypes.prng_key)     # 新式 key 是专门的 key 类型
assert not jnp.allclose(a_new[:3], b_new)                 # 独立子 key → 序列不再相同
# 反证:复用同一个 key,前缀必然相同(这正是要避免的)
assert jnp.allclose(jax.random.normal(key, (6,))[:3], jax.random.normal(key, (3,)))
print("✅ 断言通过")
key dtype = key<fry>
a[:3] = [ 1.0040143 -0.9063372 -0.7481722]
b     = [-2.4424558  -2.0356805   0.20554423]
✅ 断言通过

🔍 补充:jit 的几种写法与一个高频陷阱

🟢 必读

  • 需要给 jit 传参数(比如 static_argnames)时,装饰器常配合 functools.partial 来写:@partial(jax.jit, static_argnames=["n"])。
  • 编译缓存挂在“被包装的 Python 函数对象”上。对同一个函数多次 jax.jit(f),会共享缓存。但如果在循环里每次都 jax.jit(lambda x: ...),每个 lambda 都是新对象,每次都会重新编译。这是实战中非常常见的性能问题。正确做法是在循环外 jit 一次(2.3 总表 E7 会用编译计数再验证一次)。
def double(x):
    return x * 2

# 同一个函数对象 → 共享编译缓存
a_jit = jax.jit(double)
b_jit = jax.jit(double)
a_jit(1.0).block_until_ready()
t0 = time.perf_counter(); b_jit(1.0).block_until_ready()
print(f"另一个 jax.jit(double) 首次调用: {(time.perf_counter()-t0)*1e3:.3f} ms(命中共享缓存)")

# 循环里每次新建 lambda → 每次都重新编译
for i in range(3):
    t0 = time.perf_counter()
    jax.jit(lambda x: x * 2)(1.0).block_until_ready()
    print(f"第 {i} 次新 lambda: {(time.perf_counter()-t0)*1e3:.3f} ms(每次都在编译)")

# partial 写法传 jit 参数
@partial(jax.jit, static_argnames=["n"])
def power(x, n):
    return x ** n

print("power(2.0, n=3) =", power(2.0, n=3))
assert power(2.0, n=3) == 8.0
另一个 jax.jit(double) 首次调用: 0.117 ms(命中共享缓存)
第 0 次新 lambda: 14.728 ms(每次都在编译)
第 1 次新 lambda: 14.705 ms(每次都在编译)
第 2 次新 lambda: 17.125 ms(每次都在编译)
power(2.0, n=3) = 8.0

2.3 JIT 工作原理:追踪与编译

原文:https://apxml.com/zh/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/how-jit-works

jit 分两个阶段:先追踪,再编译。

① 追踪(Tracing) 第一次调用 jit 函数时,JAX 并不按普通方式执行你的 Python 代码。它会用特殊的追踪器(tracer)对象代替真实数组,把函数“走一遍”。追踪器只带形状和 dtype,不带具体数值。JAX 记下作用在追踪器上的每个 JAX 操作,就得到了整个计算的结构。

打个比方:让人把烤蛋糕的步骤写下来,而不是真的去烤一个蛋糕。

正因为追踪器没有具体数值,在它身上用普通的 Python 运算或控制流就可能出问题(2.4–2.6 详细讨论)。

追踪的产物叫 Jaxpr(JAX Program Representation),是一种简单、纯函数式、带显式类型的中间语言。用 jax.make_jaxpr 可以直接看到它:

def my_simple_func(x, y):
    a = jnp.sin(x)
    b = jnp.cos(y)
    return a + b

x_example = jnp.ones(3)
y_example = jnp.zeros(3)

print(jax.make_jaxpr(my_simple_func)(x_example, y_example))

# 输入的数值不会出现在 Jaxpr 里;代码里写死的常量会以字面量出现
jp = str(jax.make_jaxpr(lambda x: x * 2.0)(jnp.array([7.5, 8.5])))
assert "2.0" in jp and "7.5" not in jp and "8.5" not in jp
{ lambda ; a:f32[3] b:f32[3]. let
    c:f32[3] = sin a
    d:f32[3] = cos b
    e:f32[3] = add c d
  in (e,) }

怎么读:lambda ; a:f32[3] b:f32[3] 是两个输入(类型 float32、形状 [3]);let ... in (e,) 中间是三个操作 sin、cos、add,最后返回 e。注意,Jaxpr 里没有输入的具体数值,只有类型和操作。不过代码里写死的常量(比如 x * 2.0 里的 2.0)会以字面量的形式出现在 Jaxpr 里(下方单元验证)。

② 编译(Compilation) Jaxpr 会交给 XLA(Accelerated Linear Algebra)编译器,生成针对目标硬件优化过的机器码。XLA 做的优化包括:

  • 算子融合(Fusion):多个操作合并成一个内核;
  • 内核生成(Kernel Generation):为目标硬件生成专门的高效内核;
  • 内存布局优化(Memory Layout Optimization):安排数据在内存里的布局,提高访存效率。

编译通常是第一次调用里最耗时的部分。

③ 缓存(Caching) 某种“输入形状 + dtype(+ 静态参数值)”组合完成追踪和编译后,机器码会被缓存起来。之后签名匹配的调用直接执行缓存的代码,跳过 Python 解释、追踪和 XLA 编译。

要分清楚:加速本身来自编译后的代码(消除逐操作调度开销、算子融合,见 2.1);缓存保证这份代码只编译一次、之后反复复用。如果每次调用都要重新编译,编译的耗时就会把收益吃掉。

整个 JIT 流程可以画成下面这样:

首次以某签名调用 ──► 追踪(tracer) ──► Jaxpr ──► XLA 编译 ──► 机器码 ──► 存入缓存 ──► 执行
后续同签名调用 ─────────────────────────────────────────────► 查缓存命中 ──► 执行

下面这个课程示例演示了“首次调用 / 缓存调用 / 新形状 / 回到旧形状”四种情况的耗时:

@jax.jit
def slow_function(x):
    # 循环 5 次(Python 循环在追踪时会被展开)
    for _ in range(5):
        if x.shape[0] == x.shape[1]:
            # 方阵:矩阵乘法
            x = jnp.dot(x, x.T) + 0.5 * x
        else:
            # 非方阵:逐元素运算
            x = x * x + 0.5 * x
    return x

key = jax.random.key(0)
data = jax.random.normal(key, (100, 100))

t0 = time.perf_counter(); result1 = slow_function(data).block_until_ready()
print(f"首次调用(编译)耗时:    {time.perf_counter() - t0:.4f} 秒")

t0 = time.perf_counter(); result2 = slow_function(data).block_until_ready()
print(f"第二次调用(缓存)耗时:  {time.perf_counter() - t0:.4f} 秒")

data_different_shape = jax.random.normal(key, (150, 150))
t0 = time.perf_counter(); result3 = slow_function(data_different_shape).block_until_ready()
print(f"使用新形状调用耗时:      {time.perf_counter() - t0:.4f} 秒")

t0 = time.perf_counter(); result4 = slow_function(data).block_until_ready()
print(f"再次使用原始形状调用耗时:{time.perf_counter() - t0:.4f} 秒")
首次调用(编译)耗时:    0.0298 秒
第二次调用(缓存)耗时:  0.0004 秒
使用新形状调用耗时:      0.0441 秒
再次使用原始形状调用耗时:0.0005 秒

什么时候会重新追踪/编译?

常见的有四种情况:

  1. 输入形状改变,比如 (100, 100) → (150, 150);
  2. 输入 dtype 改变,比如 float32 → int32。float32 → float64 只有在开启 64 位模式(jax_enable_x64=True)后才算 dtype 改变;
  3. 输入 PyTree 结构改变,比如给输入字典加一个键;
  4. 用了 static_argnums / static_argnames 时,静态参数的值改变。

⚠️ 注意:别拿“float32 换成 float64”当 dtype 改变的例子。默认配置下(JAX 0.12)64 位是关闭的,请求 float64 会被截断成 float32(还会给出警告),所以不会重新编译;只有开启 x64 之后才会。见下方总表 E2 的 assert。

每一种不同的组合都会单独追踪 + 编译一次,各占一个缓存条目。除了这四条,实战中还有几个因素同样会影响缓存(或者看起来会、其实不会),下面的总表把它们放在了一起。

🔍 补充:上面示例里值得注意的细节

⚪ 选读:想看形状分支和循环展开在 Jaxpr 里的样子时看;要点在 2.5 和总表里还会出现。

  • if x.shape[0] == x.shape[1] 在 jit 里能正常工作。形状在追踪时是已知的 Python 整数(静态信息),所以基于 shape / ndim / dtype 的 Python 分支完全合法。这一点很重要,它是“静态信息可以用 Python 控制流”的典型例子。
  • 这个例子里两种输入都是方阵,else 分支其实从来没执行过,也根本不会出现在编译结果里。追踪时只记录实际走过的分支。
  • for _ in range(5) 会被展开成 5 份重复的操作(数一下下面 Jaxpr 里 dot_general 出现的次数就能验证)。
jaxpr = jax.make_jaxpr(slow_function.__wrapped__)(data)  # __wrapped__ 取回未 jit 的原函数
print("dot_general 出现次数:", str(jaxpr).count("dot_general"))
assert str(jaxpr).count("dot_general") == 5   # range(5) 被展开成 5 份
dot_general 出现次数: 5

重新编译触发条件总表

缓存键 ≈ 被包装的 Python 函数对象 + 每个追踪参数的 形状 / dtype / 弱类型标记 + PyTree 结构 + 静态参数的值 + 参数的传入方式(位置 / 关键字 / 省略默认值)。下表每一行都配了一个验证单元(E1–E8):用开头定义的 count_compiles()(基于 jax_log_compiles)统计实际的编译次数,再 assert。

#因素例子是否重新编译要点
E1形状f(ones(3)) → f(ones(4))✅ 会只改数值不会;改形状一定会
E2dtypefloat32 → int32;float32 → float64✅ 会 / ⚠️ 默认不会x64 关闭时 float64 被截断为 float32,不重新编译;开启 x64 后才会
E3弱类型f(1.0) vs f(jnp.float32(1.0)) vs f(1)✅ 会Python 标量、jnp.array(5.0)、jnp.full(3, 7.0) 都是弱类型
E4静态参数值n=2 → n=3 → n=2✅ 每个新值一次已经见过的值命中缓存
E5PyTree 结构{"a": x} → {"a": x, "b": x};(x,) → [x]✅ 会键、嵌套方式、容器类型、长度都属于结构
E6位置 vs 关键字传参g(x, 2.0) vs g(x, t=2.0);省略默认参数✅ 会省略的默认值被当作常量烘焙进编译结果
E7每次新建 jit 对象循环里 jax.jit(lambda ...)✅ 每次都会对同一个函数对象多次 jax.jit(f) 则共享缓存
E8全局变量修改函数读取的全局变量❌ 不会不重新编译,于是继续使用旧值(悄悄出错)

说明:f._cache_size() 是私有 API,而且它统计的是同一个 Python 函数在所有 jit 包装下的条目总数,所以这里统一用编译日志计数。

先看一眼原始的编译日志长什么样(打开 jax_log_compiles 后,每次真正编译都会在 stderr 输出 Compiling ...):

# 原始编译日志:先把输入建好——jnp.ones 本身在 eager 模式下也会编译小内核,会混进日志
a3, b3, a4 = jnp.ones(3), jnp.ones(3) * 7, jnp.ones(4)
g = jax.jit(lambda x: x * 2)
jax.config.update("jax_log_compiles", True)
print("调用 1:f32[3]", flush=True); g(a3)               # 编译
print("调用 2:f32[3] 不同数值", flush=True); g(b3)      # 命中缓存,没有日志
print("调用 3:f32[4]", flush=True); g(a4)               # 新形状 → 编译
jax.config.update("jax_log_compiles", False)
调用 1:f32[3]
Finished tracing <lambda> for jit in 0.000270844 sec
Compiling jit(<lambda>) with global shapes and types (ShapedArray(float32[3]),). Argument mapping: (UnspecifiedValue,).
Finished jaxpr to MLIR module conversion jit(<lambda>) in 0.000735998 sec
Finished XLA compilation of jit(<lambda>) in 0.014306068 sec
调用 2:f32[3] 不同数值
调用 3:f32[4]
Finished tracing <lambda> for jit in 0.000692844 sec
Compiling jit(<lambda>) with global shapes and types (ShapedArray(float32[4]),). Argument mapping: (UnspecifiedValue,).
Finished jaxpr to MLIR module conversion jit(<lambda>) in 0.000663042 sec
Finished XLA compilation of jit(<lambda>) in 0.013445139 sec
# E1 形状:只改数值不重新编译;改形状重新编译
def e1(x):
    return x * 2

x3a, x3b, x4 = jnp.ones(3), jnp.arange(3.0), jnp.ones(4)   # 先建好输入
e1_jit = jax.jit(e1)
with count_compiles() as log:
    e1_jit(x3a)
    e1_jit(x3b)                 # 同形状、不同数值
    n_same_shape = log.count("e1")
    e1_jit(x4)                  # 新形状
assert n_same_shape == 1
assert log.count("e1") == 2
print("E1 ✅ 同形状只编译 1 次;换形状后共编译", log.count("e1"), "次")
    [编译] jit(e1)
    [编译] jit(e1)
E1 ✅ 同形状只编译 1 次;换形状后共编译 2 次
# E2 dtype:int32 会重新编译;默认配置下 float64 被截断为 float32,不会重新编译
def e2(x):
    return x + 1

xf32 = jnp.ones(3, dtype=jnp.float32)
xi32 = jnp.ones(3, dtype=jnp.int32)
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    x64_req = jnp.ones(3, dtype=jnp.float64)     # x64 默认关闭:被截断
assert x64_req.dtype == jnp.float32 and len(w) >= 1
print("请求 float64,实际得到:", x64_req.dtype, "| 警告:", str(w[0].message)[:60], "...")

e2_jit = jax.jit(e2)
with count_compiles() as log:
    e2_jit(xf32)
    e2_jit(x64_req)
    n_after_f64 = log.count("e2")
    e2_jit(xi32)
assert n_after_f64 == 1        # float32 与“float64”共用一份编译结果
assert log.count("e2") == 2    # int32 才触发第二次编译

# 对照:用上下文管理器临时开启 x64,float64 才是真正不同的 dtype(不影响后续单元)
def e2_x64(x):
    return x + 1

with jax.enable_x64(True):
    y32 = jnp.ones(3, dtype=jnp.float32)
    y64 = jnp.ones(3, dtype=jnp.float64)
    assert y64.dtype == jnp.float64
    e2_x64_jit = jax.jit(e2_x64)
    with count_compiles() as log64:
        e2_x64_jit(y32)
        e2_x64_jit(y64)
assert log64.count("e2_x64") == 2
print("E2 ✅ 默认配置:float32/“float64” 共 1 次编译,加 int32 共 2 次;开启 x64 后 float32/float64 各编译 1 次")
请求 float64,实际得到: float32 | 警告: Explicitly requested dtype float64 requested in ones is not  ...
    [编译] jit(e2)
    [编译] jit(e2)
    [编译] jit(e2_x64)
    [编译] jit(e2_x64)
E2 ✅ 默认配置:float32/“float64” 共 1 次编译,加 int32 共 2 次;开启 x64 后 float32/float64 各编译 1 次
# E3 弱类型:Python 标量是“弱类型”,与同 dtype 的强类型数组签名不同
def e3(x):
    return x + 1

e3_jit = jax.jit(e3)
x_strong = jnp.float32(1.0)
with count_compiles() as log:
    e3_jit(1.0)
    e3_jit(2.0)          # Python float:弱类型 f32[],只改数值 → 命中
    e3_jit(x_strong)     # 强类型 f32[] → 新签名
    e3_jit(1)            # Python int:弱类型 i32[] → 新签名
assert log.count("e3") == 3

# 由 Python 标量“派生”出来的数组往往也是弱类型
for name, arr in [("jnp.array(5.0)", jnp.array(5.0)), ("jnp.full(3, 7.0)", jnp.full(3, 7.0)),
                  ("jnp.ones(3)", jnp.ones(3)), ("jnp.float32(1.0)", jnp.float32(1.0))]:
    print(f"{name:18s} weak_type={arr.weak_type}")
assert jnp.array(5.0).weak_type and jnp.full(3, 7.0).weak_type
assert not jnp.ones(3).weak_type and not jnp.float32(1.0).weak_type
print("E3 ✅ 弱/强类型、float/int 标量共编译", log.count("e3"), "次")
    [编译] jit(e3)
    [编译] jit(e3)
    [编译] jit(e3)
jnp.array(5.0)     weak_type=True
jnp.full(3, 7.0)   weak_type=True
jnp.ones(3)        weak_type=False
jnp.float32(1.0)   weak_type=False
E3 ✅ 弱/强类型、float/int 标量共编译 3 次
# E4 静态参数值:每个新值编译一次,见过的值命中缓存
def e4(x, n):
    for _ in range(n):
        x = jnp.sin(x)
    return x

e4_jit = jax.jit(e4, static_argnames="n")
xe4 = jnp.ones(3)
with count_compiles() as log:
    for n in [2, 3, 2, 3]:
        e4_jit(xe4, n=n)
assert log.count("e4") == 2
print("E4 ✅ n 取 2,3,2,3 共编译", log.count("e4"), "次")
    [编译] jit(e4)
    [编译] jit(e4)
E4 ✅ n 取 2,3,2,3 共编译 2 次
# E5 PyTree 结构:键、容器类型、长度都属于结构
def e5(d):
    return d["a"] * 2

def e5_seq(t):
    return t[0] * 2

d1 = {"a": jnp.ones(3)}
d2 = {"a": jnp.zeros(3)}                     # 同结构,不同数值
d3 = {"a": jnp.ones(3), "b": jnp.ones(3)}    # 多了一个键
xs5 = jnp.ones(3)
e5_jit, e5_seq_jit = jax.jit(e5), jax.jit(e5_seq)
with count_compiles() as log:
    e5_jit(d1)
    e5_jit(d2)
    n_same_tree = log.count("e5")
    e5_jit(d3)
    e5_seq_jit((xs5,))       # tuple
    e5_seq_jit([xs5])        # list:容器类型不同 → 新结构
assert n_same_tree == 1 and log.count("e5") == 2
assert log.count("e5_seq") == 2
print("E5 ✅ 字典加键 → 重新编译;tuple 与 list → 各编译一次")
    [编译] jit(e5)
    [编译] jit(e5)
    [编译] jit(e5_seq)
    [编译] jit(e5_seq)
E5 ✅ 字典加键 → 重新编译;tuple 与 list → 各编译一次
# E6 位置 vs 关键字传参,以及省略默认参数
def e6(x, t):
    return x * t

e6_jit = jax.jit(e6, static_argnums=(1,))
x6 = jnp.ones(3)
with count_compiles() as log:
    e6_jit(x6, 2.0)          # 位置传入
    e6_jit(x6, t=2.0)        # 关键字传入:同样是静态,但缓存键不同
assert log.count("e6") == 2


def e6_default(x, tol=1e-6):
    return x + tol

e6d_jit = jax.jit(e6_default)
print(jax.make_jaxpr(e6_default)(x6))     # 省略时:默认值作为常量写进 Jaxpr(1e-6 的 float32 近似值显示为 9.99…e-07)
with count_compiles() as log:
    e6d_jit(x6)              # 省略:默认值被烘焙为常量
    e6d_jit(x6, 1e-3)        # 位置传入:tol 变成追踪参数 → 新条目
    e6d_jit(x6, 1e-4)        # 位置传入、换值 → 命中
    e6d_jit(x6, tol=1e-4)    # 关键字传入 → 新条目
assert log.count("e6_default") == 3
print("E6 ✅ 位置/关键字各占一个条目;省略/位置/关键字三种传法共编译 3 次")
    [编译] jit(e6)
    [编译] jit(e6)
{ lambda ; a:f32[3]. let b:f32[3] = add a 9.999999974752427e-07:f32[] in (b,) }
    [编译] jit(e6_default)
    [编译] jit(e6_default)
    [编译] jit(e6_default)
E6 ✅ 位置/关键字各占一个条目;省略/位置/关键字三种传法共编译 3 次
# E7 每次新建 jit 对象:同一个函数对象共享缓存;每次新建 lambda 则每次都编译
def e7(x):
    return x * 2

x7 = jnp.ones(3)
with count_compiles() as log:
    for _ in range(3):
        jax.jit(e7)(x7)                  # 同一个函数对象 e7 → 共享缓存
    n_same_fn = log.count("e7")
    for _ in range(3):
        jax.jit(lambda x: x * 2)(x7)     # 每次都是新的 lambda 对象
assert n_same_fn == 1
assert log.count("<lambda>") == 3
print("E7 ✅ jax.jit(e7) ×3 只编译 1 次;新 lambda ×3 编译 3 次 → 在循环外 jit 一次")
    [编译] jit(e7)
    [编译] jit(<lambda>)
    [编译] jit(<lambda>)
    [编译] jit(<lambda>)
E7 ✅ jax.jit(e7) ×3 只编译 1 次;新 lambda ×3 编译 3 次 → 在循环外 jit 一次
# E8 全局变量:修改它不会触发重新编译 → 继续使用旧值
scale_e8 = 1.0

def e8(x):
    return x * scale_e8

e8_jit = jax.jit(e8)
x8 = jnp.ones(2)
with count_compiles() as log:
    r1 = e8_jit(x8)
    scale_e8 = 5.0               # 修改全局变量
    r2 = e8_jit(x8)
assert log.count("e8") == 1                 # 没有重新编译……
assert jnp.allclose(r2, 1.0)                # ……所以结果仍按旧值 1.0 计算
print("E8 ✅ 改全局变量后结果仍是", r2, "(更隐蔽的“新旧值并存”见 2.6 问题 5)")
    [编译] jit(e8)
E8 ✅ 改全局变量后结果仍是 [1. 1.] (更隐蔽的“新旧值并存”见 2.6 问题 5)

🔍 补充:AOT(提前编译)API —— 把 jit 的各阶段拆开看

⚪ 选读:需要单独测编译耗时、提前编译部署或检查 XLA 优化结果时看。

前面把 jit 讲成“追踪 + 编译”两步。其实 JAX 把它拆成了更细的阶段,可以一步步观察和控制:

jax.jit(f).trace(*args)   → Traced     (.jaxpr 查看 Jaxpr)
         .lower(*args)    → Lowered    (.as_text() 查看 StableHLO,交给 XLA 前的 IR)
         .compile()       → Compiled   (.as_text() 查看优化后 HLO,.cost_analysis() 查看 FLOPs 估计)

用途:单独测量编译耗时、在部署前提前编译、检查 XLA 做了哪些优化。注意,Compiled 对象只接受和编译时完全相同的形状/dtype。

def affine_sin(x):
    return jnp.sin(x) * 2

jf = jax.jit(affine_sin)
xs = jnp.ones(3)

print("== trace → Jaxpr ==")
print(jf.trace(xs).jaxpr)

lowered = jf.lower(xs)
print("== lower → StableHLO ==")
print(lowered.as_text())

t0 = time.perf_counter()
compiled = lowered.compile()
print(f"== compile 耗时 {(time.perf_counter()-t0)*1e3:.2f} ms ==")
print("执行结果:", compiled(xs))
assert jnp.allclose(compiled(xs), jnp.sin(xs) * 2)
ca = compiled.cost_analysis()
print("cost_analysis:", {k: ca[k] for k in ("flops", "transcendentals", "bytes accessed")})

# Compiled 对象不能处理不同形状
expect_error(lambda: compiled(jnp.ones(4)), TypeError)
== trace → Jaxpr ==
{ lambda ; a:f32[3]. let b:f32[3] = sin a; c:f32[3] = mul b 2.0:f32[] in (c,) }
== lower → StableHLO ==
module @jit_affine_sin attributes {mhlo.num_partitions = 1 : i32, mhlo.num_replicas = 1 : i32} {
  func.func public @main(%arg0: tensor<3xf32>) -> (tensor<3xf32> {jax.result_info = "result"}) {
    %0 = stablehlo.sine %arg0 : tensor<3xf32>
    %cst = stablehlo.constant dense<2.000000e+00> : tensor<f32>
    %1 = stablehlo.broadcast_in_dim %cst, dims = [] : (tensor<f32>) -> tensor<3xf32>
    %2 = stablehlo.multiply %0, %1 : tensor<3xf32>
    return %2 : tensor<3xf32>
  }
}

== compile 耗时 13.17 ms ==
执行结果: [1.6829419 1.6829419 1.6829419]
cost_analysis: {'flops': 3.0, 'transcendentals': 3.0, 'bytes accessed': 24.0}
❌ TypeError: Argument types differ from the types for which this computation was compiled. Perhaps you are calling the compiled executable with a different enable_x64 mode t
TypeError("Argument types differ from the types for which this computation was compiled. Perhaps you are calling the compiled executable with a different enable_x64 mode than when it was AOT compiled? The mismatches are:\nArgument 'x' compiled with float32[3] and called with float32[4]")

2.4 Python 控制流与 jit

原文:https://apxml.com/zh/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/jit-control-flow

jax.jit 用抽象值(tracer)追踪函数,再编译得到的 Jaxpr,通常每个函数签名只追踪一次。可是 Python 原生的 if、for、while 需要具体的值,才能决定走哪条路、循环几次。追踪器恰恰没有具体值,所以在追踪值上用 Python 控制流就会出错。

条件执行

先看一个普通的 Python 函数:

def conditional_func_py(x):
    if x > 0:
        return x * 2
    else:
        return x / 2

print(conditional_func_py(5.0))    # 10.0
print(conditional_func_py(-4.0))   # -2.0

# 尝试 jit:x 被替换为追踪器,`if x > 0` 无法得到 True/False
jitted_conditional_py = jax.jit(conditional_func_py)
expect_error(lambda: jitted_conditional_py(jnp.array(5.0)), jax.errors.ConcretizationTypeError)
10.0
-2.0
❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
jax.errors.TracerBoolConversionError('Attempted boolean conversion of traced array with shape bool[].\nThe error occurred while tracing the function conditional_func_py at /tmp/ipykernel_86888/1042175847.py:1 for jit. This concrete value was not available in Python because it depends on the value of the argument x.\nSee https://docs.jax.dev/en/latest/101/errors.html#jax.errors.TracerBoolConversionError')

解决方法:jax.lax.cond。 它把两个分支都编译进计算图,运行时再根据谓词选择执行哪一个:

lax.cond(pred, true_fun, false_fun, *operands)
  • pred:标量布尔值(可以是追踪值);
  • true_fun / false_fun:两个分支函数,必须接受相同结构的输入,并返回相同形状、dtype、结构的输出;
  • operands:传给分支函数的操作数。
def conditional_func_lax(x):
    def true_branch(val):
        return val * 2

    def false_branch(val):
        return val / 2

    return lax.cond(x > 0, true_branch, false_branch, x)

jitted_conditional_lax = jax.jit(conditional_func_lax)
print(jitted_conditional_lax(jnp.array(5.0)))    # 10.0
print(jitted_conditional_lax(jnp.array(-4.0)))   # -2.0
assert jitted_conditional_lax(jnp.array(5.0)) == 10.0 and jitted_conditional_lax(jnp.array(-4.0)) == -2.0
10.0
-2.0

注意:如果 Python if 只依赖静态值(比如编译时已知的 Python 常量、形状,或者被标记为静态的参数),它可以正常编译。但静态值一变就会触发重新编译。所以,只要条件依赖 JAX 数组的数值,就应该用 lax.cond。

循环

情况 1:迭代次数是静态的。 Python for 循环在追踪时会被展开(unroll),也就是把循环体原样复制 n 份。下面用 static_argnums=1 把 n 标记为静态:

def sum_first_n_py(arr, n):
    total = 0.0
    for i in range(n):
        total = total + arr[i]
    return total

jitted_sum_first_3 = jax.jit(sum_first_n_py, static_argnums=1)
my_array = jnp.arange(10.0)
print(jitted_sum_first_3(my_array, 3))   # 3.0 = 0 + 1 + 2
assert jitted_sum_first_3(my_array, 3) == 3.0

# 看看展开后的样子:n=3 时出现 3 次取元素 + 3 次加法
print(jax.make_jaxpr(sum_first_n_py, static_argnums=1)(my_array, 3))
3.0
{ lambda ; a:f32[10]. let
    b:f32[1] = slice[limit_indices=(1,) start_indices=(0,) strides=None] a
    c:f32[] = squeeze[dimensions=(0,)] b
    d:f32[] = add 0.0:f32[] c
    e:f32[1] = slice[limit_indices=(2,) start_indices=(1,) strides=None] a
    f:f32[] = squeeze[dimensions=(0,)] e
    g:f32[] = add d f
    h:f32[1] = slice[limit_indices=(3,) start_indices=(2,) strides=None] a
    i:f32[] = squeeze[dimensions=(0,)] h
    j:f32[] = add g i
  in (j,) }

展开是有代价的:n 很大时计算图会变得非常大,编译时间随 n 增长(见下方补充的实测)。

情况 2:迭代次数或继续条件依赖追踪值。 这时 Python 循环同样会报错(range(n) 需要一个具体的整数)。

解决方法:用 jax.lax 的结构化循环原语:

原语签名适用场景
lax.fori_loopfori_loop(lower, upper, body_fun, init_val),body_fun(i, val) -> val已知范围的计数循环
lax.while_loopwhile_loop(cond_fun, body_fun, init_val)终止条件依赖运行时数值
lax.scanscan(f, init, xs),f(carry, x) -> (carry, y)带状态遍历序列、需要收集每步输出(第 6 章状态管理会详细讲)

用 fori_loop 重写求和,n 现在可以是 JAX 数组(追踪值)了:

def sum_first_n_lax(arr, n):
    def body_fun(i, current_total):
        return current_total + arr[i]

    initial_val = 0.0
    final_total = lax.fori_loop(0, n, body_fun, initial_val)
    return final_total

jitted_sum_lax = jax.jit(sum_first_n_lax)
my_array = jnp.arange(10.0)
print(jitted_sum_lax(my_array, jnp.array(3)))   # 3.0  = 0+1+2
print(jitted_sum_lax(my_array, jnp.array(5)))   # 10.0 = 0+1+2+3+4
assert jitted_sum_lax(my_array, jnp.array(3)) == 3.0 and jitted_sum_lax(my_array, jnp.array(5)) == 10.0
3.0
10.0

两种循环可以这样对比:一种是 jit 里被展开的 Python 循环,要求追踪时迭代次数固定、已知;另一种是 lax 循环原语,在计算图里只是一个循环节点,即使迭代次数依赖追踪值也能用。(课程原页面这里有一张对比图。)

小结

  • 依赖追踪值的条件:lax.cond(多分支用 lax.switch);
  • 依赖追踪值的循环:lax.fori_loop / lax.while_loop / lax.scan;
  • 只有当控制流只依赖静态值(Python 常量、形状、static_argnums/static_argnames 标记的参数)时,才适合直接用 Python 控制流。

🔍 补充:错误类型的名字(以 JAX 0.12 实测为准)

⚪ 选读:需要在代码里用 except 捕获追踪相关错误时看。

这类错误常被统称为 ConcretizationTypeError。但在 JAX 0.12 里,实际抛出的是几种更具体的异常,而且它们并不都继承自 ConcretizationTypeError:

场景实际异常直接父类
if tracer: / bool(tracer)TracerBoolConversionErrorConcretizationTypeError
range(tracer) / 用追踪值做 Python 索引TracerIntegerConversionErrorJAXTypeError
float(tracer)ConcretizationTypeErrorJAXTypeError
np.asarray(tracer)TracerArrayConversionErrorJAXTypeError

⚠️ 注意:别以为它们都继承自 ConcretizationTypeError。只有 TracerBoolConversionError 是它的子类,TracerIntegerConversionError 和 TracerArrayConversionError 直接继承 JAXTypeError(见 进阶 08 调试工具链)。

所以 except jax.errors.ConcretizationTypeError 捕获不到整数转换和数组转换错误。想统一捕获,请用它们共同的父类 jax.errors.JAXTypeError(它也是 TypeError 的子类)。下一格用 assert 验证这一点。

try:
    jax.jit(conditional_func_py)(jnp.array(5.0))
except jax.errors.ConcretizationTypeError as e:
    print("类型:", type(e).__name__)
    print("继承链:", [c.__name__ for c in type(e).__mro__[:3]])

def loop_n(x, n):
    for _ in range(n):
        x = x + 1
    return x

expect_error(lambda: jax.jit(loop_n)(1.0, 3), jax.errors.TracerIntegerConversionError)

# 验证继承关系:只有 Bool 是 ConcretizationTypeError 的子类,四者的共同父类是 JAXTypeError
E = jax.errors
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.TracerBoolConversionError,
            E.TracerIntegerConversionError, E.TracerArrayConversionError))
print("继承关系已验证:统一捕获请用 jax.errors.JAXTypeError")
类型: TracerBoolConversionError
继承链: ['TracerBoolConversionError', 'ConcretizationTypeError', 'JAXTypeError']
❌ TracerIntegerConversionError: The __index__() method was called on traced array with shape int32[]
继承关系已验证:统一捕获请用 jax.errors.JAXTypeError

🔍 补充:fori_loop 的两种“身份”,以及对求导的影响

⚪ 选读:需要对循环求导时看;第 3 章 3.7 会用表格再讲一遍。

fori_loop 会根据上下界是不是静态值,降级成不同的原语:

  • 上下界都是 Python 整数(静态)→ 降级为 scan:支持反向模式求导(jax.grad);
  • 上下界里有追踪值 → 降级为 while_loop:可以在 jit 里运行,但不支持反向模式求导。

(while_loop 本身也不支持反向求导,因为反向传播需要事先知道迭代次数,才能保存中间结果。需要求导时,用固定长度的 scan。)

body = lambda i, s: s + i
print("静态上界 → ", "scan" if "scan[" in str(jax.make_jaxpr(lambda s: lax.fori_loop(0, 3, body, s))(0.0)) else "while")
print("追踪上界 → ", "while" if "while[" in str(jax.make_jaxpr(lambda n: lax.fori_loop(0, n, body, 0.0))(3)) else "scan")

# 静态上界:可以求导。d/dx x^3 在 x=2 处 = 12
g_static = jax.grad(lambda x: lax.fori_loop(0, 3, lambda i, s: s * x, 1.0))(2.0)
print("grad(静态 fori):", g_static)
assert g_static == 12.0

# while_loop:反向求导报错
expect_error(lambda: jax.grad(lambda x: lax.while_loop(lambda s: s < 10, lambda s: s * x, 1.0))(2.0), ValueError)
静态上界 →  scan
追踪上界 →  while
grad(静态 fori): 12.0
❌ ValueError: Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values. Try using lax.scan, or using fori_loop with stati
ValueError('Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values. Try using lax.scan, or using fori_loop with static start/stop.')

🔍 补充:lax.switch、while_loop、scan 的最小示例

⚪ 选读:第一次在 jit 里写多分支或循环、想照着可运行示例改时看。

下面是可以直接运行的示例。要点:

  • lax.switch(index, branches, *operands):index 越界时会被夹到 [0, len-1],不会报错;
  • while_loop 的状态(carry)在循环前后必须保持相同的结构/形状/dtype;
  • scan 返回 (最终 carry, 堆叠起来的每步输出)。
branches = [lambda x: x, lambda x: x * 10, lambda x: -x]
sw = jax.jit(lambda i, x: lax.switch(i, branches, x))
print("switch(1):", sw(1, 3.0), "  switch(99) 被夹到最后一个分支:", sw(99, 3.0))

# while_loop:从 1 开始不断翻倍,直到 >= 10,同时统计步数
final, steps = lax.while_loop(lambda s: s[0] < 10, lambda s: (s[0] * 2, s[1] + 1), (1, 0))
print("while_loop:", final, "步数", steps)

# scan:累加和
carry, ys = lax.scan(lambda c, x: (c + x, c + x), 0.0, jnp.arange(1.0, 5.0))
print("scan: 最终 carry =", carry, " 每步输出 =", ys)

# 分支输出类型不一致会报错
expect_error(lambda: lax.cond(True, lambda x: x, lambda x: jnp.int32(1), 1.0), TypeError)
switch(1): 30.0   switch(99) 被夹到最后一个分支: -3.0
while_loop: 16 步数 4
scan: 最终 carry = 10.0  每步输出 = [ 1.  3.  6. 10.]
❌ TypeError: cond branches must have equal output types but they differ.
TypeError('cond branches must have equal output types but they differ.\n\ntrue_fun is <lambda> at /tmp/ipykernel_86888/2033780279.py:14\nfalse_fun is <lambda> at /tmp/ipykernel_86888/2033780279.py:14\n\nThe output of true_fun has type float32[] but the corresponding output of false_fun has type int32[], so the dtypes do not match.\n\nRevise true_fun and/or false_fun so that all output types match.')

🔍 补充:逐元素条件优先用 jnp.where,但要小心梯度里的 NaN

🟢 必读

  • lax.cond 的谓词必须是标量。对数组做“逐元素”的条件选择,应该用 jnp.where(cond, a, b);
  • jnp.where 会把两边都算出来再选。如果被屏蔽的那一侧在该位置的导数是无穷(比如 sqrt 在 0 处),梯度会变成 NaN,要用「双 where」来修。原理、常见场景和修法见 03 §3.8 的 NaN 专题,进阶内容见 07 专题 2。
  • 另外,在 vmap 下,带批量谓词的 lax.cond 也会被转换成两边都算的 select,所以别指望 cond 在批处理时省计算。
relu2 = jax.jit(lambda x: jnp.where(x > 0, x * 2, x))
print("jnp.where 逐元素:", relu2(jnp.array([-1.0, 2.0])))
assert jnp.array_equal(relu2(jnp.array([-1.0, 2.0])), jnp.array([-1.0, 4.0]))

# vmap 下带批量谓词的 lax.cond 会变成 select_n:两个分支都会计算
jaxpr_v = str(jax.make_jaxpr(jax.vmap(lambda x: lax.cond(x > 0, lambda v: v * 2, lambda v: -v, x)))(jnp.ones(3)))
print("vmap(cond) 的 Jaxpr 中有 select_n:", "select_n" in jaxpr_v, "| 还有 cond:", "cond[" in jaxpr_v)
assert "select_n" in jaxpr_v and "cond[" not in jaxpr_v
jnp.where 逐元素: [-1.  4.]
vmap(cond) 的 Jaxpr 中有 select_n: True | 还有 cond: False

🔍 补充:Python 循环展开 vs fori_loop 的编译时间实测

⚪ 选读:循环次数很多、编译明显变慢时看。

下面比较循环 10 / 100 / 1000 次时,两种写法的编译耗时(用 AOT API 只测编译)。先扣掉每次编译都有的固定开销,也就是 n=10 时两种写法都要花的那部分时间。剩下的部分里,展开版的编译时间随 n 大致线性增长,fori_loop 基本不变。

经验法则:循环超过几十次,或者循环体比较大时,优先用 lax 循环原语。循环很短时,展开反而可能让 XLA 做更多优化(scan 也提供了 unroll= 参数来折中)。

def unrolled(x, n):
    for _ in range(n):
        x = jnp.sin(x) + 1
    return x

def rolled(x, n):
    return lax.fori_loop(0, n, lambda i, x: jnp.sin(x) + 1, x)

for n in [10, 100, 1000]:
    t0 = time.perf_counter(); jax.jit(unrolled, static_argnums=1).lower(1.0, n).compile(); t_u = time.perf_counter() - t0
    t0 = time.perf_counter(); jax.jit(rolled, static_argnums=1).lower(1.0, n).compile(); t_r = time.perf_counter() - t0
    print(f"n={n:5d}  展开: {t_u*1e3:7.1f} ms   fori_loop: {t_r*1e3:6.1f} ms")
n=   10  展开:    19.5 ms   fori_loop:   16.2 ms
n=  100  展开:    42.9 ms   fori_loop:   17.7 ms
n= 1000  展开:   285.8 ms   fori_loop:   16.0 ms

2.5 静态值与跟踪值

原文:https://apxml.com/zh/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/jit-static-vs-traced

jit 用占位符对象(tracer)把函数执行一次,记录下操作,构建出 Jaxpr,再交给 XLA 编译。在这个过程中,函数里的值分成两类:

  • 跟踪值(traced):通常是 JAX 数组参数(以及所有没被标记为静态的参数)。追踪时只知道它的形状和 dtype,不知道具体数值。打个比方:tracer 就像一份“蓝图”,只写明了尺寸和材料,没有具体内容。
  • 静态值(static):编译时就已知的值,比如函数内部的 Python 整数、布尔值、字符串常量,以及被显式标记为静态的参数。追踪时能拿到它们的具体值,所以可以用在 Python 控制流里。

问题示例

def conditional_double(x, threshold):
    if x > threshold:
        return x * 2
    else:
        return x

# 不标记静态参数:x 和 threshold 都被追踪,if 无法得到具体的 True/False
jitted_conditional_double = jax.jit(conditional_double)
expect_error(lambda: jitted_conditional_double(jnp.array(5.0), threshold=0.0), jax.errors.ConcretizationTypeError)
❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
jax.errors.TracerBoolConversionError('Attempted boolean conversion of traced array with shape bool[].\nThe error occurred while tracing the function conditional_double at /tmp/ipykernel_86888/3905137792.py:1 for jit. This concrete value was not available in Python because it depends on the values of the arguments x and threshold.\nSee https://docs.jax.dev/en/latest/101/errors.html#jax.errors.TracerBoolConversionError')

把 threshold 标记为静态,能解决吗?

可以用 static_argnums=(1,)(按位置)或 static_argnames=('threshold',)(按名字)把 threshold 标记为静态,这样它在追踪时就是一个具体的 Python 浮点数。但这解决不了本例的问题:x 仍然是追踪值,x > threshold 的结果仍然是追踪器,if 还是没法把它转成 Python bool。只把比较的一侧设成静态,一点用都没有。

⚠️ 注意:很容易以为加上 static_argnums=(1,) 或 static_argnames=('threshold',) 就能跑通,并分别输出 10.0 和 -2.0。实际上(JAX 0.12)两种写法都会抛 TracerBoolConversionError(ConcretizationTypeError 的子类)。

正确的修法分两种情况:

  1. 条件依赖追踪值(本例) → 用 lax.cond 或 jnp.where,threshold 是不是静态都可以;
  2. 条件只依赖静态参数(比如 if mode == "train"、if double:)→ 这时 static_argnums / static_argnames 才真正管用。
# ❌ 只把 threshold 标记为静态:并不能分别输出 10.0 和 -2.0,而是报错
print("static_argnums=(1,)      →", end=" ")
expect_error(lambda: jax.jit(conditional_double, static_argnums=(1,))(jnp.array(5.0), threshold=0.0),
             jax.errors.ConcretizationTypeError)
print("static_argnames=threshold →", end=" ")
expect_error(lambda: jax.jit(conditional_double, static_argnames=("threshold",))(jnp.array(-2.0), threshold=0.0),
             jax.errors.ConcretizationTypeError)
static_argnums=(1,)      → ❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
static_argnames=threshold → ❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
jax.errors.TracerBoolConversionError('Attempted boolean conversion of traced array with shape bool[].\nThe error occurred while tracing the function conditional_double at /tmp/ipykernel_86888/3905137792.py:1 for jit. This concrete value was not available in Python because it depends on the value of the argument x.\nSee https://docs.jax.dev/en/latest/101/errors.html#jax.errors.TracerBoolConversionError')
# ✅ 修法 1:条件依赖 x 的值 → lax.cond 或 jnp.where(threshold 可以是追踪值,换值不重新编译)
@jax.jit
def conditional_double_cond(x, threshold):
    return lax.cond(x > threshold, lambda v: v * 2, lambda v: v, x)

@jax.jit
def conditional_double_where(x, threshold):
    return jnp.where(x > threshold, x * 2, x)

for fn in (conditional_double_cond, conditional_double_where):
    assert fn(jnp.array(5.0), 0.0) == 10.0
    assert fn(jnp.array(-2.0), 0.0) == -2.0
print("修法 1 ✅ lax.cond:", conditional_double_cond(jnp.array(5.0), 0.0), conditional_double_cond(jnp.array(-2.0), 0.0))
修法 1 ✅ lax.cond: 10.0 -2.0
# ✅ 修法 2:只有当 if 的条件完全只依赖静态参数时,static_argnames 才真正有效
@partial(jax.jit, static_argnames=["double"])
def maybe_double(x, double):
    if double:            # double 是 Python bool,追踪时已知
        return x * 2
    return x

assert maybe_double(jnp.array(5.0), double=True) == 10.0
assert maybe_double(jnp.array(5.0), double=False) == 5.0
print("修法 2 ✅ 静态开关:", maybe_double(jnp.array(5.0), double=True), maybe_double(jnp.array(5.0), double=False))
修法 2 ✅ 静态开关: 10.0 5.0

重新编译的权衡

  • 对跟踪参数:只要形状和 dtype 不变,换什么数值都复用同一份编译结果;
  • 对静态参数:每个不同的值都会触发一次重新追踪和重新编译。

缓存的键可以理解为:Python 函数对象的身份 + 静态参数的值 + 跟踪参数的形状/dtype(以及 PyTree 结构)。完整清单见 2.3 的“重新编译触发条件总表”。

下面用 4 次调用演示这一点(threshold 是静态的):第 1 次为 threshold=0.0 编译;第 2 次只换了 x 的值,复用;第 3 次 threshold=10.0,重新编译;第 4 次复用 threshold=10.0 的结果。

⚠️ 注意:这里不能沿用上面那个会报错的 if 版本。下面把函数体换成可以编译的 jnp.where,调用方式不变,再用编译计数验证。

def conditional_double_static(x, threshold):
    return jnp.where(x > threshold, x * 2, x)

jitted_func = jax.jit(conditional_double_static, static_argnames=("threshold",))
x5, x1 = jnp.array(5.0), jnp.array(1.0)      # 先建好输入
with count_compiles() as log:
    print("第 1 次调用 (x=5, threshold=0.0)", flush=True);  r_a = jitted_func(x5, threshold=0.0)    # 编译
    print("第 2 次调用 (x=1, threshold=0.0)", flush=True);  r_b = jitted_func(x1, threshold=0.0)    # 复用
    print("第 3 次调用 (x=5, threshold=10.0)", flush=True); r_c = jitted_func(x5, threshold=10.0)   # 重新编译
    print("第 4 次调用 (x=1, threshold=10.0)", flush=True); r_d = jitted_func(x1, threshold=10.0)   # 复用
assert log.count("conditional_double_static") == 2
assert [float(r) for r in (r_a, r_b, r_c, r_d)] == [10.0, 2.0, 5.0, 1.0]
print("✅ 4 次调用共编译", log.count("conditional_double_static"), "次")
第 1 次调用 (x=5, threshold=0.0)
    [编译] jit(conditional_double_static)
第 2 次调用 (x=1, threshold=0.0)
第 3 次调用 (x=5, threshold=10.0)
    [编译] jit(conditional_double_static)
第 4 次调用 (x=1, threshold=10.0)
✅ 4 次调用共编译 2 次

何时使用静态参数

  1. Python 控制流依赖这个参数的值(比如 if mode == "train"、for i in range(num_layers));
  2. 函数的行为由它决定,比如网络层数、激活函数类型、某个配置字符串;
  3. 需要常量的场合,比如数组切片的起止位置、reshape 的目标形状这类必须在编译时确定的量。

一般原则:数值数据一律作为跟踪参数。只有决定计算结构、而且调用之间很少变化的值,才谨慎地设成静态。动态的控制流请用 lax.cond、lax.switch、lax.scan 这些能编译的写法。

🔍 补充:静态参数的硬性要求与细节

🟢 必读

  1. 静态参数必须可哈希(因为要拿来当缓存键)。list、dict、JAX/NumPy 数组都不行,要用 tuple、int、str、frozenset、可哈希的自定义对象等。
  2. 如果静态参数是自定义类,缓存能不能命中,由它的 __hash__ / __eq__ 决定。普通类默认按对象身份哈希,于是每个新实例都会重新编译(哪怕内容完全一样)。用 @dataclass(frozen=True) 就能按值哈希。反过来,如果 __hash__ 写得不完整,还可能出现“该重新编译时没编译”的隐蔽 bug。
  3. 在 JAX 0.12 里,static_argnums=(1,) 会自动对应到这个位置参数的名字,所以按关键字传入同样被当成静态的。但位置和关键字两种传法各占一个缓存条目。省略的默认参数会被当作常量烘焙进 Jaxpr(见 2.3 总表 E6)。
  4. 形状本身就是静态信息:x.shape、x.ndim、x.dtype 可以直接用在 Python if / range 里,不需要任何标记。
from dataclasses import dataclass

# 1) 数组、list 不能作为静态参数;tuple 可以
expect_error(lambda: jax.jit(lambda x, t: x * t, static_argnums=1)(1.0, jnp.array(2.0)), ValueError)
expect_error(lambda: jax.jit(lambda x, shape: x.reshape(shape), static_argnums=1)(jnp.arange(6.0), [2, 3]), ValueError)
reshaped = jax.jit(lambda x, shape: x.reshape(shape), static_argnums=1)(jnp.arange(6.0), (2, 3))
assert reshaped.shape == (2, 3)

# 2) 自定义类作静态参数:按身份哈希 vs 按值哈希
class CfgById:                     # 默认 __hash__/__eq__:按对象身份
    def __init__(self, k):
        self.k = k

@dataclass(frozen=True)
class CfgByValue:                  # frozen dataclass:按字段值哈希
    k: int

def scale_by_id(x, cfg):
    return x * cfg.k

def scale_by_value(x, cfg):
    return x * cfg.k

xc = jnp.ones(3)
j_id = jax.jit(scale_by_id, static_argnums=1)
j_val = jax.jit(scale_by_value, static_argnums=1)
with count_compiles(echo=False) as log:
    j_id(xc, CfgById(2)); j_id(xc, CfgById(2))          # 内容相同但是两个对象
    j_val(xc, CfgByValue(2)); j_val(xc, CfgByValue(2))  # 值相等 → 命中缓存
assert log.count("scale_by_id") == 2
assert log.count("scale_by_value") == 1
print("按身份哈希的配置类编译", log.count("scale_by_id"), "次;frozen dataclass 编译", log.count("scale_by_value"), "次")

# 4) 形状信息是静态的
@jax.jit
def sum_rows_or_all(x):
    return x.sum() if x.ndim == 1 else x.sum(axis=0)

assert sum_rows_or_all(jnp.ones(3)) == 3.0
assert jnp.array_equal(sum_rows_or_all(jnp.ones((2, 3))), jnp.full(3, 2.0))
print("基于 ndim 的 Python if ✅", sum_rows_or_all(jnp.ones(3)), sum_rows_or_all(jnp.ones((2, 3))))
❌ ValueError: Non-hashable static arguments are not supported. An error occurred while trying to hash an object of type <class 'jaxlib._jax.ArrayImpl'>, 2.0. The error was:
❌ ValueError: Non-hashable static arguments are not supported. An error occurred while trying to hash an object of type <class 'list'>, [2, 3]. The error was:
按身份哈希的配置类编译 2 次;frozen dataclass 编译 1 次
基于 ndim 的 Python if ✅ 3.0 [2. 2. 2.]

2.6 jit 的常见问题

原文:https://apxml.com/zh/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/jit-common-pitfalls

前提是:JAX 用抽象追踪器(只有形状和 dtype,没有具体值)来执行函数。下面五类问题都是从这里来的,每类都给出“问题示例 + 解决方法”。

问题 1:依赖追踪值的 Python if

@jax.jit
def condition_on_value(x):
    if x > 0:
        return x * 2
    else:
        return x / 2

expect_error(lambda: condition_on_value(jnp.array(5.0)), jax.errors.ConcretizationTypeError)

# 解决:lax.cond(谓词, 真分支, 假分支, 操作数)
@jax.jit
def condition_on_value_lax(x):
    def true_fun(operand):
        return operand * 2

    def false_fun(operand):
        return operand / 2

    return lax.cond(x > 0, true_fun, false_fun, x)

print(condition_on_value_lax(jnp.array(5.0)))    # 10.0
print(condition_on_value_lax(jnp.array(-4.0)))   # -2.0
assert condition_on_value_lax(jnp.array(5.0)) == 10.0 and condition_on_value_lax(jnp.array(-4.0)) == -2.0
❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
10.0
-2.0

注意:基于静态值的 if 没有问题。

问题 2:迭代次数可变的循环

def variable_loop(x, n):
    total = x
    for i in range(n):
        total = total + i
    return total

# n 是追踪值(JAX 数组),range(n) 无法确定迭代次数
expect_error(lambda: jax.jit(variable_loop)(10.0, jnp.array(3)), jax.errors.TracerIntegerConversionError)

# 解决 1:lax.fori_loop(这里配合 static_argnums 把 n 设为静态)
def fixed_loop_fori(x, n_static):
    def body_fun(i, current_total):
        return current_total + i
    return lax.fori_loop(0, n_static, body_fun, x)

jitted_fori = jax.jit(fixed_loop_fori, static_argnums=(1,))
print(jitted_fori(jnp.array(10.0), 3))   # 13.0 = 10 + 0 + 1 + 2
assert jitted_fori(jnp.array(10.0), 3) == 13.0

# 解决 2:lax.scan(带状态的循环,这里求累加和)
@jax.jit
def cumulative_sum_scan(xs):
    def scan_op(carry, x):
        new_carry = carry + x
        return new_carry, new_carry   # (下一步的 carry, 本步输出)
    _, ys = lax.scan(scan_op, 0.0, xs)
    return ys

print(cumulative_sum_scan(jnp.array([1.0, 2.0, 3.0, 4.0])))   # [ 1.  3.  6. 10.]
assert jnp.array_equal(cumulative_sum_scan(jnp.array([1.0, 2.0, 3.0, 4.0])), jnp.array([1.0, 3.0, 6.0, 10.0]))
❌ TracerIntegerConversionError: The __index__() method was called on traced array with shape int32[]
13.0
[ 1.  3.  6. 10.]

关于 fori_loop 的上下界:上面的示例把 n_static 标记成了静态。这样写没错,但不是必须的。fori_loop 的上下界可以是追踪值(2.4 节的 sum_first_n_lax 就是这么用的),这时它在内部降级为 while_loop,换 n 也不会重新编译。唯一的代价是:上下界是追踪值时不能反向求导(jax.grad)。需要求导时,再用静态上界或 scan。

⚠️ 注意:fori_loop 的上界不一定要是编译期常量,传追踪值也可以。下面用 assert 验证:结果是对的,而且换几个不同的 n 也只编译一次。

# ✅ n 作为追踪值传入:结果正确,且不同的 n 共用一份编译结果
def loop_add(x, n):
    return lax.fori_loop(0, n, lambda i, t: t + i, x)

loop_add_jit = jax.jit(loop_add)
x10 = jnp.array(10.0)
with count_compiles() as log:
    results = [float(loop_add_jit(x10, n)) for n in (3, 5, 100)]
print("n=3, 5, 100 →", results)
assert results == [13.0, 20.0, 10.0 + sum(range(100))]
assert log.count("loop_add") == 1
print("✅ 三个不同的 n 只编译了", log.count("loop_add"), "次")
    [编译] jit(loop_add)
n=3, 5, 100 → [13.0, 20.0, 4960.0]
✅ 三个不同的 n 只编译了 1 次

问题 3:副作用

print、写文件、改全局列表、time.sleep 这些 Python 副作用只在追踪时执行。每种输入签名追踪一次;换形状等情况触发重新追踪时,会再执行一次。之后的调用直接运行编译好的代码,这些 Python 语句根本不会再执行。

@jax.jit
def function_with_side_effect(x):
    print(f"TRACE: Running Python code for x={x}")   # 只在追踪时执行
    time.sleep(0.1)                                    # 同样只在追踪时执行
    return x * x

print(function_with_side_effect(jnp.array(3.0)))   # 打印 TRACE(x 是追踪器)
print(function_with_side_effect(jnp.array(4.0)))   # 无 TRACE,也不 sleep
print(function_with_side_effect(jnp.array(5.0)))
TRACE: Running Python code for x=JitTracer(~float32[])
9.0
16.0
25.0

建议:jit 函数里别写副作用;状态用显式传参、显式返回的方式来管理(第 6 章“状态管理”会讲);调试时可以临时关掉 jit。

🔍 补充:调试打印用 jax.debug.print,调试逻辑用 jax.disable_jit()

🟢 必读

  • print 打印出来的是追踪器(JitTracer(~float32[])),而且只打印一次;
  • jax.debug.print("{x}", x=x) 会被编译进计算图,每次运行都打印实际数值(注意用 {} 占位符,不要用 f-string);
  • with jax.disable_jit(): 会让被 jit 的函数按普通 Python 来执行。这时可以用 print、断点,甚至 Python if。适合用来定位逻辑错误,但别留在生产代码里。
@jax.jit
def debug_demo(x):
    print("普通 print(仅追踪时):", x)
    jax.debug.print("jax.debug.print(每次运行): x = {x}", x=x)
    return x * x

debug_demo(jnp.array(3.0)).block_until_ready()
debug_demo(jnp.array(4.0)).block_until_ready()

# disable_jit:函数里 x 是具体数值,Python if 也能用
with jax.disable_jit():
    print("disable_jit 下:", condition_on_value(jnp.array(5.0)))
普通 print(仅追踪时): JitTracer(~float32[])
jax.debug.print(每次运行): x = 3.0
jax.debug.print(每次运行): x = 4.0
disable_jit 下: 10.0

问题 4:动态形状与 dtype

输入形状一变就会触发重新编译。课程示例给出的参考耗时是:形状 (3,) 首次调用约 0.15 秒、之后约 0.0001 秒;形状 (4,) 首次调用约 0.09 秒(重新编译)、之后又很快。下面在本机跑一遍:

@jax.jit
def process_data(x):
    return jnp.sum(x * 2.0)

inputs = [jnp.ones(s) for s in [(3,), (3,), (4,), (4,)]]   # 先建好输入,只计 process_data 本身
with count_compiles(echo=False) as log:
    for x in inputs:
        t0 = time.perf_counter()
        process_data(x).block_until_ready()
        print(f"shape={x.shape}: {time.perf_counter() - t0:.4f} 秒")
assert log.count("process_data") == 2     # (3,) 一次,(4,) 一次
print("✅ 共编译", log.count("process_data"), "次")
shape=(3,): 0.0176 秒
shape=(3,): 0.0000 秒
shape=(4,): 0.0201 秒
shape=(4,): 0.0000 秒
✅ 共编译 2 次

解决思路:

  1. **填充(padding)**到统一的形状;
  2. 对决定形状的参数用 static_argnums / static_argnames(只适合取值很少的情况);
  3. 分批处理,让每批的形状一致;
  4. 重新想一想,这段代码是不是真的适合 jit。

🔍 补充:分桶填充(bucketing)+ 掩码的实战写法

⚪ 选读:输入长度经常变化(变长序列、变长批次)导致反复重编译时看。

真实场景里(变长序列、变长批次)常用“分桶”:把长度向上取整到少数几个桶(比如 2 的幂),再配合掩码保证结果正确。这样不管输入长度怎么变,最多只编译“桶的个数”次。

@jax.jit
def masked_mean(x_padded, length):
    # length 是追踪值:用掩码代替切片 x[:length](切片长度必须是静态的)
    mask = jnp.arange(x_padded.shape[0]) < length
    return jnp.sum(jnp.where(mask, x_padded, 0.0)) / length

def bucket_size(n):
    return max(8, 1 << (n - 1).bit_length())   # 向上取整到 2 的幂,最小 8

def mean_any_length(x):
    n = x.shape[0]
    padded = jnp.pad(x, (0, bucket_size(n) - n))
    return masked_mean(padded, n)

lengths = [3, 5, 7, 9, 12, 16, 20]
data_by_len = [jnp.arange(1.0, n + 1) for n in lengths]    # 先建好输入
with count_compiles(echo=False) as log:
    for x in data_by_len:
        assert jnp.allclose(mean_any_length(x), x.mean())
assert log.count("masked_mean") == 3
print("7 种长度结果全部正确;masked_mean 只编译了", log.count("masked_mean"), "次(桶: 8, 16, 32)")
7 种长度结果全部正确;masked_mean 只编译了 3 次(桶: 8, 16, 32)

问题 5:全局变量

jit 函数在追踪时读取全局变量,并把当时的值作为常量“烘焙”进编译结果。之后再改全局变量,不会触发重新编译(2.3 总表 E8),缓存里的编译结果用的还是旧值。

learning_rate = 0.01

@jax.jit
def update_weights(params, grads):
    return params - learning_rate * grads   # learning_rate 在追踪时被固化

params = jnp.array([1.0, 2.0])
grads = jnp.array([0.5, -0.1])

r1 = update_weights(params, grads)
print(r1)                              # [0.995 2.001]
learning_rate = 1000.0                 # 修改全局变量
r2 = update_weights(params, grads)
print(r2)                              # 仍是 [0.995 2.001]:用的是旧值 0.01
assert np.allclose(r1, [0.995, 2.001]) and np.allclose(r2, r1)
[0.995 2.001]
[0.995 2.001]

解决方法:把超参数作为参数显式传进去。

⚠️ 注意:update_weights_explicit(params, grads, 1000.0) 的输出是 [-499. 102.]。第二个分量是 $2 - 1000 \times (-0.1) = 102$。有的资料里印成了 [-499. 120.],那是笔误。

# ✅ 超参数作为参数传入
@jax.jit
def update_weights_explicit(params, grads, lr):
    return params - lr * grads

r_small = update_weights_explicit(params, grads, 0.01)
r_big = update_weights_explicit(params, grads, 1000.0)
print(r_small)    # [0.995 2.001]
print(r_big)      # [-499.  102.]

course_printed = np.array([-499.0, 120.0])      # ❌ 容易抄错的数值(120 是笔误)
assert np.allclose(r_small, [0.995, 2.001])
assert np.allclose(r_big, [-499.0, 102.0])      # ✅ 正确值
assert not np.allclose(r_big, course_printed)
print("✅ 断言通过:正确结果是 [-499. 102.]")
[0.995 2.001]
[-499.  102.]
✅ 断言通过:正确结果是 [-499. 102.]

🔍 补充:全局变量的“新旧值并存”,以及大数组也要作为参数传入

🟢 必读

  1. 新旧值并存:全局值只在追踪时读取。如果之后因为新形状触发了重新追踪,新的编译结果会读到新值,而旧形状的缓存还在用旧值。结果就是同一个函数,对不同形状的输入用了不同的超参数。这类 bug 极难排查。所以 jit 函数应该是纯函数,所有会变的量都通过参数传进去。
  2. 大数组(比如模型权重)也别靠闭包捕获,要作为参数传进去。闭包捕获的数组会作为常量嵌进编译结果,增加编译时间和内存,改了之后也不会生效。另外,本环境(jaxlib 0.12.0.1 CPU)有一个已知 bug:jit 闭包捕获大数组后做 matmul 再归约,会悄悄算错。复现和规避方法见 00 · 本环境已知问题,根因分析见 进阶 02 §2.3。本教程的所有示例和 benchmark 都把权重作为参数传入。
scale = 1.0

@jax.jit
def scaled(x):
    return x * scale

s_old = scaled(jnp.ones(2))   # 追踪,固化 scale=1.0
scale = 5.0
s_new = scaled(jnp.ones(3))   # 新形状 → 重新追踪,读到 scale=5.0
s_again = scaled(jnp.ones(2)) # 旧缓存,仍是 scale=1.0 !
print("shape (2,):", s_old, " shape (3,):", s_new, " 再次 shape (2,):", s_again)
assert jnp.allclose(s_old, 1.0) and jnp.allclose(s_new, 5.0) and jnp.allclose(s_again, 1.0)
shape (2,): [1. 1.]  shape (3,): [5. 5. 5.]  再次 shape (2,): [1. 1.]

🔍 补充:donate_argnums —— 让 XLA 复用输入缓冲区

⚪ 选读:显存/内存吃紧、想让参数更新复用输入缓冲区时看。

训练循环里常见 params = update(params, ...):旧的 params 用完就扔了。用 donate_argnums 告诉 XLA“这个输入之后不会再用”,它就可以把输出直接写进输入的内存,降低峰值显存/内存占用。

  • 被捐赠的数组之后不能再用,否则会报 RuntimeError: Array has been deleted;
  • JAX 0.12 的 CPU 后端也支持捐赠(旧版本在 CPU 上会提示“donation 不可用”然后忽略);
  • 只有输入和某个输出的形状/dtype 匹配时,捐赠才真正生效。
@partial(jax.jit, donate_argnums=0)
def sgd_step(params, grads, lr):
    return params - lr * grads

p = jnp.ones(4)
new_p = sgd_step(p, jnp.full(4, 0.5), 0.1)
print("新参数:", new_p)
print("旧 p 是否已被删除:", p.is_deleted())
expect_error(lambda: p + 1, RuntimeError)   # 再使用被捐赠的数组 → 报错
assert p.is_deleted() and jnp.allclose(new_p, 0.95)
新参数: [0.95 0.95 0.95 0.95]
旧 p 是否已被删除: True
❌ RuntimeError: Array has been deleted with shape=float32[4].

2.7 动手实践:应用 jit

原文:https://apxml.com/zh/courses/getting-started-with-jax/chapter-2-accelerating-functions-jit/hands-on-applying-jit

这一节把前面的内容串起来:写一个计时工具,比较原始函数和 jit 版本,再练一练控制流和静态参数。

再强调一次:JAX 是异步执行的,计时前必须等结果真正算完;首次调用包含编译开销,要先预热再计时。

准备:计时辅助函数和测试数据

print("可用设备:", jax.devices())

def time_func(func, *args, num_runs=10, warmup_runs=2):
    """常见的计时写法:预热 + 多次计时,返回 (平均值, 标准差)。"""
    # 预热(包括首次编译)
    for _ in range(warmup_runs):
        result = func(*args)
        if isinstance(result, jax.Array):
            result.block_until_ready()
    times = []
    for _ in range(num_runs):
        start = timeit.default_timer()
        result = func(*args)
        if isinstance(result, jax.Array):
            result.block_until_ready()
        times.append(timeit.default_timer() - start)
    return np.mean(times), np.std(times)

key = jax.random.key(0)
kx, ky = jax.random.split(key)
x_jnp = jax.random.normal(kx, (1000, 1000))
y_jnp = jax.random.normal(ky, (1000, 1000))
可用设备: [CpuDevice(id=0)]

⚠️ 注意:上面的 time_func 只在返回值是单个 jax.Array 时才调用 block_until_ready()。如果函数返回元组或字典(PyTree),它根本不会等计算完成,测到的只是异步排队的时间。改法是用 jax.block_until_ready(result),它能处理任意 PyTree(本 notebook 开头的 bench 就是这么写的)。下面实际对比一下。

# ❌ 把 time_func 用在返回元组的函数上:元组不是 jax.Array,于是跳过了阻塞
@jax.jit
def heavy_tuple(a):
    c = a @ a
    for _ in range(3):
        c = jnp.tanh(c @ a)
    return c, c.sum()          # 返回元组(PyTree)

A_big = jax.random.normal(jax.random.key(7), (1500, 1500))
t_course, _ = time_func(heavy_tuple, A_big)
print(f"原 time_func:   {t_course*1e3:8.3f} ms   ← 只是排队时间")
原 time_func:      0.012 ms   ← 只是排队时间
# ✅ 修正:jax.block_until_ready 能等待任意 PyTree
def time_func_fixed(func, *args, num_runs=10, warmup_runs=2):
    for _ in range(warmup_runs):
        jax.block_until_ready(func(*args))
    times = []
    for _ in range(num_runs):
        start = timeit.default_timer()
        jax.block_until_ready(func(*args))
        times.append(timeit.default_timer() - start)
    return np.mean(times), np.std(times)

t_fixed, _ = time_func_fixed(heavy_tuple, A_big)
print(f"修正版:         {t_fixed*1e3:8.3f} ms   ← 真实计算时间(约为原版的 {t_fixed/t_course:.0f} 倍)")
assert t_fixed > 10 * t_course
修正版:           30.614 ms   ← 真实计算时间(约为原版的 2615 倍)

示例 1:一个“计算较重”的函数

def compute_heavy(a, b):
    c = jnp.dot(a, b)
    d = jnp.sin(c)
    e = jnp.log(jnp.abs(d) + 1e-6)
    f = jnp.sum(e)
    return f

mean_time_orig, std_time_orig = time_func(compute_heavy, x_jnp, y_jnp)
print(f"原始函数:  {mean_time_orig*1e3:.3f} ± {std_time_orig*1e3:.3f} ms")

compute_heavy_jit = jax.jit(compute_heavy)
mean_time_jit, std_time_jit = time_func(compute_heavy_jit, x_jnp, y_jnp)
print(f"JIT 版本:  {mean_time_jit*1e3:.3f} ± {std_time_jit*1e3:.3f} ms")
print(f"加速比:    {mean_time_orig / mean_time_jit:.2f}x")
assert jnp.allclose(compute_heavy(x_jnp, y_jnp), compute_heavy_jit(x_jnp, y_jnp), rtol=1e-4)   # 结果一致
原始函数:  6.194 ± 1.023 ms
JIT 版本:  2.657 ± 0.605 ms
加速比:    2.33x

加速多少,取决于硬件、数组大小和函数的复杂程度。这个例子的主体是 1000×1000 矩阵乘法(CPU 上已经由优化库完成),jit 主要省下的是后面 sin/abs/log/sum 几步中间数组的读写(融合)。

下面把结果画成柱状图(误差棒是标准差)。纵轴用从 0 开始的线性刻度:柱状图在对数轴上没有真正的零基线,柱高的比例会误导人。只有两者相差几个数量级时,才值得改用对数轴。

import matplotlib.pyplot as plt

labels = ["Original Function", "JIT Compiled"]
mean_times = [mean_time_orig, mean_time_jit]
std_devs = [std_time_orig, std_time_jit]

fig, ax = plt.subplots(figsize=(5, 3.2))
ax.bar(labels, [t * 1e3 for t in mean_times], yerr=[s * 1e3 for s in std_devs],
       width=0.45, color="#2a78d6", capsize=4, error_kw={"elinewidth": 1})
ax.set_ylabel("Mean time per call (ms)")
ax.set_ylim(0, max(t + sd for t, sd in zip(mean_times, std_devs)) * 1e3 * 1.25)   # 顶部留出标签空间
ax.set_title("compute_heavy: original vs jax.jit (CPU)")
for i, (t, sd) in enumerate(zip(mean_times, std_devs)):
    # 标签放在误差棒上端之上,避免与误差棒重叠
    ax.annotate(f"{t*1e3:.2f} ms", (i, (t + sd) * 1e3), textcoords="offset points",
                xytext=(0, 6), ha="center", fontsize=9)
ax.spines[["top", "right"]].set_visible(False)
ax.grid(axis="y", which="major", alpha=0.3)
plt.tight_layout()
plt.show()
No description has been provided for this image

示例 2:JIT 与 Python 控制流

先看一个用 Python if 写的函数,条件依赖 jnp.sum(x)。条件依赖追踪值,所以 jit 版本在追踪阶段就一定会抛 TracerBoolConversionError。正确写法是 lax.cond。

⚠️ 注意:有一种说法是,Python if 版本“可能只编译首次遇到的分支、可能重新编译,也可能失败,取决于版本”。这不对。在 JAX 0.12 里,只要条件依赖追踪值,就必然报错,不存在“只编译一个分支”的情况。

# ❌ 旧写法:条件依赖追踪值的 Python if
def conditional_computation(x, threshold):
    if jnp.sum(x) > threshold:
        return jnp.dot(x, x.T) * 2
    else:
        return jnp.dot(x, x.T) / 2

conditional_computation_jit = jax.jit(conditional_computation)
x_small = jnp.array([1.0, 2.0, 3.0])

expect_error(lambda: conditional_computation_jit(x_small, 5.0), jax.errors.ConcretizationTypeError)
expect_error(lambda: conditional_computation_jit(x_small, 10.0), jax.errors.ConcretizationTypeError)
❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
❌ TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
jax.errors.TracerBoolConversionError('Attempted boolean conversion of traced array with shape bool[].\nThe error occurred while tracing the function conditional_computation at /tmp/ipykernel_86888/1021126837.py:2 for jit. This concrete value was not available in Python because it depends on the values of the arguments x and threshold.\nSee https://docs.jax.dev/en/latest/101/errors.html#jax.errors.TracerBoolConversionError')
# ✅ 用 lax.cond(x 是一维数组,x.T 不改变它,jnp.dot(x, x.T) 就是内积 14.0)
@jax.jit
def conditional_computation_lax(x, threshold):
    return lax.cond(
        jnp.sum(x) > threshold,
        lambda op: op * 2,
        lambda op: op / 2,
        jnp.dot(x, x.T),
    )

r5 = conditional_computation_lax(x_small, 5.0)     # sum=6 > 5  → 14 * 2
r10 = conditional_computation_lax(x_small, 10.0)   # sum=6 < 10 → 14 / 2
print("threshold=5.0 :", r5)
print("threshold=10.0:", r10)
assert r5 == 28.0 and r10 == 7.0
threshold=5.0 : 28.0
threshold=10.0: 7.0

示例 3:静态参数

apply_n_times(x, n) 用 Python for 循环做 n 次矩阵乘。range(n) 需要一个具体的整数,所以:

  • 不标记静态:n 是追踪值,range(n) 直接抛 TracerIntegerConversionError;
  • static_argnums=(1,):每个不同的 n 编译一次(n=2、3 各一次,再次 n=2 时命中缓存);
  • 如果希望 n 变化时完全不重新编译:把循环改成 lax.fori_loop(0, n, ...),n 作为追踪值传入。

⚠️ 注意:不标记静态时,并不是“循环按追踪时的 n 展开,换 n 会重新编译”。在 JAX 0.12 里,不标记静态根本没法追踪,会直接报 TracerIntegerConversionError。

# ❌ 旧写法:不标记静态,n 是追踪值
def apply_n_times(x, n):
    y = x
    for _ in range(n):
        y = jnp.dot(y, x)
    return y

x_matrix = jax.random.normal(jax.random.key(1), (50, 50))
apply_n_times_jit = jax.jit(apply_n_times)
expect_error(lambda: time_func(apply_n_times_jit, x_matrix, 2), jax.errors.TracerIntegerConversionError)
❌ TracerIntegerConversionError: The __index__() method was called on traced array with shape int32[]
jax.errors.TracerIntegerConversionError('The __index__() method was called on traced array with shape int32[]\nThe error occurred while tracing the function apply_n_times at /tmp/ipykernel_86888/2158930015.py:2 for jit. This concrete value was not available in Python because it depends on the value of the argument n.\nSee https://docs.jax.dev/en/latest/101/errors.html#jax.errors.TracerIntegerConversionError')
# ✅ 修正 1:n 静态 —— 每个新值编译一次
apply_n_times_static_jit = jax.jit(apply_n_times, static_argnums=(1,))
with count_compiles() as log:
    for n in [2, 3, 2]:
        m, s = time_func(apply_n_times_static_jit, x_matrix, n)
        print(f"静态 n={n}: {m*1e6:.1f} µs", flush=True)
assert log.count("apply_n_times") == 2     # n=2、n=3 各一次;第二次 n=2 命中缓存
    [编译] jit(apply_n_times)
静态 n=2: 53.9 µs
    [编译] jit(apply_n_times)
静态 n=3: 53.0 µs
静态 n=2: 52.5 µs
# ✅ 修正 2:fori_loop + 追踪的 n —— 换 n 完全不重新编译
@jax.jit
def apply_n_times_fori(x, n):
    return lax.fori_loop(0, n, lambda i, y: jnp.dot(y, x), x)

with count_compiles() as log:
    outs = {n: apply_n_times_fori(x_matrix, n) for n in [2, 3, 5]}
assert log.count("apply_n_times_fori") == 1
for n in [2, 3]:   # 与静态展开版本结果一致
    assert jnp.allclose(outs[n], apply_n_times_static_jit(x_matrix, n), rtol=1e-3)
print("✅ fori_loop 版:n=2,3,5 只编译 1 次,结果与静态版一致")
    [编译] jit(apply_n_times_fori)
✅ fori_loop 版:n=2,3,5 只编译 1 次,结果与静态版一致

本节总结:计时一定要等结果算完(用 block_until_ready();返回 PyTree 时用 jax.block_until_ready),并留意首次编译的开销。jit 是 JAX 性能优化的基础工具,但要配合 lax 控制流和静态参数,才能写出既正确又高效的代码。


补充练习

下面 4 道补充练习,覆盖控制流原语、静态参数、重新编译分析和纯函数设计。先自己做,再看参考答案。

练习 1:分段函数(lax.switch / jnp.select)

实现一个可 jit 的个人所得税计算函数 tax(income)(标量),按累进税率:

收入区间税率
[0, 10000)0%
[10000, 40000)10%
[40000, 100000)20%
≥ 10000030%

要求:(a) 用 lax.switch 写标量版;(b) 再用 jnp.select 或 jnp.where 写一个能直接处理数组的版本;(c) 用 NumPy 参考实现验证两者结果一致。

自检接口:定义标量版 tax_switch(income) 和数组版 tax_vectorized(income)。

# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
    tax_switch, tax_vectorized
except NameError as e:
    print(f"⏳ 练习 1 尚未作答:{e}")
else:
    def _tax_ref(inc):                 # 逐档累加:每档只对落在该档内的那部分收入收税
        edges, r = [0.0, 10000.0, 40000.0, 100000.0, np.inf], [0.0, 0.10, 0.20, 0.30]
        return sum(max(0.0, min(inc, hi) - lo) * rr for lo, hi, rr in zip(edges[:-1], edges[1:], r))
    _inc = [0.0, 5000.0, 9999.5, 10000.0, 10001.0, 39999.0, 40000.0, 55555.0, 99999.0, 100000.0, 123456.0, 1e6]
    _ref = np.array([_tax_ref(v) for v in _inc])
    _sw = np.array([float(tax_switch(jnp.float32(v))) for v in _inc])
    assert np.allclose(_sw, _ref, rtol=1e-5, atol=1e-2), f"tax_switch 结果不对:\n  期望 {_ref}\n  实际 {_sw}"
    assert "cond" in str(jax.make_jaxpr(tax_switch)(1.0)), "tax_switch 应使用 lax.switch(它的 jaxpr 里应出现 cond)"
    _vec = np.asarray(jax.jit(tax_vectorized)(jnp.array(_inc)))
    assert _vec.shape == (len(_inc),), f"tax_vectorized 应逐元素计算,输出形状应为 {(len(_inc),)},实际 {_vec.shape}"
    assert np.allclose(_vec, _ref, rtol=1e-5, atol=1e-2), f"tax_vectorized 结果不对:\n  期望 {_ref}\n  实际 {_vec}"
    assert jax.jit(tax_vectorized)(jnp.array(_inc).reshape(3, 4)).shape == (3, 4), "tax_vectorized 应保持输入的形状"
    print("✅ 练习 1 自检通过")
⏳ 练习 1 尚未作答:name 'tax_switch' is not defined

练习 2:用 lax.while_loop 实现牛顿法开平方

实现 newton_sqrt(a, tol=1e-6):从 $x_0 = a$(若 $a<1$ 则 $x_0=1$)出发迭代 $$x_{k+1} = \tfrac12\left(x_k + \frac{a}{x_k}\right)$$ 直到 $|x_{k+1}-x_k| < \text{tol}$,返回 (结果, 迭代次数)。要求整个函数被 jit,tol 作为追踪参数传入(换 tol 不重新编译),并与 jnp.sqrt 对比。 思考:为什么这里必须用 while_loop 而不能用 Python while?

自检接口:定义 newton_sqrt(a, tol) -> (结果, 迭代次数),并用 jax.jit 包装。

# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
    newton_sqrt
except NameError as e:
    print(f"⏳ 练习 2 尚未作答:{e}")
else:
    assert hasattr(newton_sqrt, "lower"), "newton_sqrt 应被 jax.jit 包装"
    for _a in [0.01, 0.5, 3.0, 50.0]:
        _r, _k = newton_sqrt(_a, 1e-6)
        assert np.isclose(float(_r), np.sqrt(_a), rtol=1e-5), f"newton_sqrt({_a}) = {float(_r)},应约为 {np.sqrt(_a):.6f}"
        assert int(_k) >= 1, "迭代次数应至少为 1"
    _, _k_loose = newton_sqrt(5.0, 1e-1)
    _, _k_tight = newton_sqrt(5.0, 1e-6)
    assert int(_k_tight) > int(_k_loose), f"tol 越小迭代应越多,实际 tol=1e-1 时 {int(_k_loose)} 次、1e-6 时 {int(_k_tight)} 次"
    with count_compiles(echo=False) as _log:
        for _a, _tol in [(2.0, 1e-2), (7.0, 1e-4), (11.0, 1e-5)]:
            newton_sqrt(_a, _tol)
    assert _log.count("newton_sqrt") <= 1, f"换 a、tol 不应重新编译,实际编译了 {_log.count('newton_sqrt')} 次(tol 是不是成了静态参数?)"
    print("✅ 练习 2 自检通过")
⏳ 练习 2 尚未作答:name 'newton_sqrt' is not defined

练习 3:重新编译侦探

不运行代码,先预测下面 8 次调用一共会触发几次编译,并说出每次调用是否触发编译、为什么。然后运行验证(用开头定义的 count_compiles 计数)。

@partial(jax.jit, static_argnames=["mode"])
def detective(x, mode="a"):
    return x * 2 if mode == "a" else x + 1

detective(jnp.ones(3))                       # ①
detective(jnp.ones(3) * 5)                   # ②
detective(jnp.ones(4))                       # ③
detective(jnp.ones(3), mode="b")             # ④
detective(jnp.ones(3, dtype=jnp.int32))      # ⑤
detective(1.0)                               # ⑥
detective(jnp.float32(1.0))                  # ⑦
detective(jnp.ones(3), mode="a")             # ⑧

自检接口:把你的预测写成 predictions:长度为 8 的 bool 列表,第 k 个元素表示第 k 次调用是否触发编译(True 表示编译)。

# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
    predictions
except NameError as e:
    print(f"⏳ 练习 3 尚未作答:{e}")
else:
    assert len(predictions) == 8, f"predictions 应有 8 个元素,实际 {len(predictions)} 个"

    @partial(jax.jit, static_argnames=["mode"])
    def _detective_check(x, mode="a"):     # 与题目中的 detective 完全相同,换个名字以便单独计数
        return x * 2 if mode == "a" else x + 1

    _args = [((jnp.ones(3),), {}), ((jnp.ones(3) * 5,), {}), ((jnp.ones(4),), {}),
             ((jnp.ones(3),), {"mode": "b"}), ((jnp.ones(3, dtype=jnp.int32),), {}),
             ((1.0,), {}), ((jnp.float32(1.0),), {}), ((jnp.ones(3),), {"mode": "a"})]
    _actual = []
    with count_compiles(echo=False) as _log:
        for _a, _kw in _args:
            _before = _log.count("_detective_check")
            _detective_check(*_a, **_kw)
            _actual.append(_log.count("_detective_check") > _before)
    _wrong = ["①②③④⑤⑥⑦⑧"[k] for k in range(8) if bool(predictions[k]) != _actual[k]]
    assert not _wrong, f"这几次调用的预测不对:{'、'.join(_wrong)}。可以对照 2.3 的重新编译总表再想想"
    print("✅ 练习 3 自检通过")
⏳ 练习 3 尚未作答:name 'predictions' is not defined
练习 3 答案与解析

一共编译 7 次,只有 ② 命中缓存。

  • ② 只是数值不同,命中 ①;
  • ③ 形状不同;④ 静态参数值不同;⑤ dtype 不同;
  • ⑥ 和 ⑦ 都是 float32 标量,但 Python 1.0 是弱类型,jnp.float32(1.0) 是强类型,签名不同(2.3 总表 E3);
  • ⑧ 最容易猜错:mode="a" 和默认值一样,但缓存键会区分“省略默认参数”和“显式传入同样的值”(总表 E6),所以 ⑧ 还会再编译一次。排查“为什么我的函数编译了两次”时,这是一个很少有人提到的原因。统一调用方式就能避免。

练习 4:把“带全局状态的训练循环”改写成纯函数 + fori_loop

下面的代码用全局变量和 Python 循环训练一维线性回归 $y = wx + b$(均方误差,梯度手写),每步都单独调用 jit 函数且依赖全局学习率:

lr = 0.1
@jax.jit
def step(w, b, x, y):
    err = w * x + b - y
    return w - lr * jnp.mean(2 * err * x), b - lr * jnp.mean(2 * err)

for _ in range(200):
    w, b = step(w, b, x, y)

请改写为:(a) lr 和步数 num_steps 都作为参数;(b) 整个 200 步训练用 lax.fori_loop 放进一个 jit 函数;(c) 用 jax.random.key 生成数据(真实 $w=3, b=-1$,加噪声),验证学到的参数;(d) 用 bench 比较“Python 循环调用 200 次 step”和“一次 fori_loop 训练”的耗时。

自检接口:定义单步 step(w, b, x, y, lr)、Python 循环版 train_python_loop(w, b, x, y, lr, num_steps) 和 jit + fori_loop 版 train_fori(w, b, x, y, lr, num_steps),后两者返回 (w, b)。

# ✍️ 作答区:练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 4
try:
    step, train_python_loop, train_fori
except NameError as e:
    print(f"⏳ 练习 4 尚未作答:{e}")
else:
    _k1, _k2 = jax.random.split(jax.random.key(7))
    _x = jax.random.uniform(_k1, (300,), minval=-1.0, maxval=1.0)
    _y = -2.0 * _x + 0.5 + 0.05 * jax.random.normal(_k2, (300,))
    _w0 = _b0 = jnp.float32(0.0)

    _xn, _yn = np.asarray(_x, np.float64), np.asarray(_y, np.float64)
    _e = -_yn                                              # w=b=0 时的残差
    _w1, _b1 = step(_w0, _b0, _x, _y, 0.3)
    assert np.isclose(float(_w1), -0.3 * np.mean(2 * _e * _xn), atol=1e-5) and np.isclose(float(_b1), -0.3 * np.mean(2 * _e), atol=1e-5), \
        "step 走一步的结果与题目给出的梯度下降公式不符"

    _wf, _bf = train_fori(_w0, _b0, _x, _y, 0.5, 300)
    assert abs(float(_wf) + 2.0) < 0.05 and abs(float(_bf) - 0.5) < 0.05, f"train_fori 没学到真实参数 (-2, 0.5),得到 ({float(_wf):.3f}, {float(_bf):.3f})"
    _wp, _bp = train_python_loop(_w0, _b0, _x, _y, 0.5, 300)
    assert np.allclose([float(_wf), float(_bf)], [float(_wp), float(_bp)], atol=1e-4), "train_fori 与 train_python_loop 的结果应一致"
    _wz, _bz = train_fori(jnp.float32(1.0), jnp.float32(2.0), _x, _y, 0.0, 50)
    assert np.allclose([float(_wz), float(_bz)], [1.0, 2.0]), "lr=0 时参数不应变化:lr 要作为参数真正用上,不能依赖全局变量"
    assert not np.isclose(float(train_fori(_w0, _b0, _x, _y, 0.5, 5)[0]), float(train_fori(_w0, _b0, _x, _y, 0.5, 6)[0])), \
        "num_steps 不同,结果也应不同"
    assert hasattr(train_fori, "lower"), "train_fori 应是一个 jit 函数"
    assert "while" in train_fori.lower(_w0, _b0, _x, _y, 0.5, 300).as_text(), "train_fori 里应使用 lax.fori_loop,而不是在追踪时用 Python 循环展开"
    print("✅ 练习 4 自检通过")
⏳ 练习 4 尚未作答:name 'step' is not defined

第 2 章测验

下面 8 道题帮你检查本章的掌握情况,答案和解析在每题下方(可折叠)。

1. 关于 eager 模式下的 JAX,下面哪项说法最准确? A. JAX 会自动把所有函数编译成 XLA 代码 B. 每个 jnp 操作都要单独调度,小操作多时开销显著 C. eager 模式在 GPU 上一定比 jit 快 D. eager 模式不能在 CPU 上运行

答案与解析

B。不加 jit 时,JAX 一个个地调度操作,固定开销在小操作上占比很大。JAX 不会自动编译整个函数(A 错),jit 的目的正是比 eager 更快(C 错)。

2. 对 @jax.jit 函数第一次以 f32[100,100] 调用、随后以 f32[100,100] 的不同数值再次调用,第二次调用会: A. 重新追踪并重新编译 B. 只重新追踪 C. 直接使用缓存的编译结果 D. 报错

答案与解析

C。缓存键由跟踪参数的形状、dtype、弱类型标记、PyTree 结构以及静态参数值等组成(完整清单见 2.3 总表),和具体数值无关。

3. 下面函数被 jax.jit 包装后用 f(jnp.array(3.0)) 调用,会怎样?

def f(x):
    if x.ndim == 0:
        return x + 1
    return x.sum()

A. 抛出 TracerBoolConversionError B. 正常返回 4.0 C. 每次调用都重新编译 D. 返回追踪器对象

答案与解析

B。x.ndim 是形状信息,追踪时是已知的 Python 整数,基于它的 if 完全合法。只有依赖数值的条件才会报错。

4. 函数中有 if x > threshold:,x 是 JAX 数组。下面哪种做法能让它在 jit 下正确工作? A. static_argnames=("threshold",) B. static_argnums=(0,) C. 改写成 lax.cond(x > threshold, ...) 或 jnp.where D. 把 threshold 改成 Python int

答案与解析

C。只要比较的一侧是追踪值,结果就是追踪器,if 用不了(A、D 都不行,见 2.5 节)。B 把数组设成静态,会因为数组不可哈希而报错。

5. 关于 print 和 jax.debug.print,正确的是: A. 两者在 jit 函数中都每次调用时打印实际数值 B. print 只在追踪时执行一次并打印追踪器;jax.debug.print 每次运行都打印实际值 C. jax.debug.print 只能在 disable_jit 下使用 D. print 会导致 jit 报错

答案与解析

B。print 是 Python 副作用,只在追踪阶段执行;jax.debug.print 会作为一个操作编进计算图。

6. 在 jit 函数中需要循环 n 次,n 每次调用都不同,且只做前向计算,最合适的写法是: A. Python for i in range(n) 并把 n 设为静态 B. lax.fori_loop(0, n, body, init),n 作为追踪值传入 C. Python while 循环 D. 每次调用前重新 jax.jit

答案与解析

B。上界是追踪值的 fori_loop 只编译一次。A 每遇到一个新的 n 就重新编译,还会展开计算图;C 会报错;D 每次都编译。(注意:如果需要反向求导,上界是追踪值的 fori_loop 不支持,要改用静态上界或 scan。)

7. 下列哪个不能作为 static_argnums 指定的参数值? A. 3 B. "relu" C. (2, 3) D. jnp.array([2, 3])

答案与解析

D。静态参数必须可哈希,JAX 数组不可哈希,会报 ValueError: Non-hashable static arguments are not supported。

8. 关于计时 jit 函数,哪种做法正确? A. 直接计第一次调用的时间作为函数的运行时间 B. 不需要 block_until_ready,因为 JAX 是同步的 C. 先调用一次预热(触发编译),之后计时并对结果调用 block_until_ready() D. 用 time.sleep 等待计算完成

答案与解析

C。第一次调用包含追踪和编译;JAX 是异步调度的,不阻塞的话只能测到排队时间。


本章小结

  • 为什么编译:eager 模式逐个调度操作,存在解释器/调度开销;编译后可以消除这些开销、融合算子、利用硬件并行。在 CPU 上,jit 对“大量小操作”的提升最明显。
  • jax.jit 用法:装饰器 @jax.jit 或 jax.jit(f);带参数时用 @partial(jax.jit, ...)。缓存挂在函数对象上——不要在循环里反复 jit 新的 lambda。
  • 工作流程:追踪(tracer 只有形状/dtype)→ Jaxpr → 降级(StableHLO)→ XLA 编译 → 缓存。可以用 make_jaxpr、.trace()、.lower().as_text()、.compile() 逐阶段观察。
  • 重新编译的触发条件:形状、dtype、弱类型、静态参数值、PyTree 结构、传参方式(位置/关键字/省略默认值)、每次新建 jit 对象;修改全局变量不会触发(于是悄悄使用旧值)。默认关闭 x64 时 float64 会被截断为 float32,不会因此重编译。用 jax_log_compiles 观察(见 2.3 总表)。
  • 控制流:依赖追踪数值的 if/for/while 会报 TracerBoolConversionError / TracerIntegerConversionError(共同父类是 jax.errors.JAXTypeError,只有前者是 ConcretizationTypeError 的子类)。条件用 lax.cond/lax.switch/jnp.where,循环用 lax.fori_loop/while_loop/scan。依赖形状或静态参数的 Python 控制流是合法的。
  • 静态参数:必须可哈希;每个新值都会编译一次;只把“决定计算结构、很少变化”的值设为静态。只把比较的一侧设为静态解决不了 if x > t 的问题。
  • 常见陷阱:副作用只在追踪时执行(调试用 jax.debug.print / jax.disable_jit());全局变量在追踪时被固化,且重新追踪后可能“新旧值并存”;大数组/权重要作为参数传入而不是闭包捕获;动态形状导致频繁重编译(用分桶填充 + 掩码);donate_argnums 可复用缓冲区,但被捐赠的数组不能再用。
  • 性能测量:先预热,再计时,并用 block_until_ready() / jax.block_until_ready() 等待结果(返回 PyTree 时必须用后者)。
  • fori_loop 与求导:静态上下界 → scan,可反向求导;追踪上下界 → while_loop,不可反向求导。

速查表

API作用
jax.jit(f) / @jax.jit即时编译函数,按输入签名缓存
jax.jit(f, static_argnums=..., static_argnames=...)指定静态参数(必须可哈希,值变化即重编译)
jax.jit(f, donate_argnums=...)捐赠输入缓冲区,减少内存占用
@partial(jax.jit, ...)带参数的装饰器写法
jax.make_jaxpr(f)(*args)查看追踪得到的 Jaxpr
jax.jit(f).trace(*args).jaxprAOT:追踪阶段
jax.jit(f).lower(*args).as_text()AOT:查看降级后的 StableHLO
.lower(...).compile() / .as_text() / .cost_analysis()AOT:编译、查看优化后 HLO、估算开销
x.block_until_ready() / jax.block_until_ready(tree)等待异步计算完成(计时必用)
jax.config.update("jax_log_compiles", True)每次编译时打印日志
jax.config.update("jax_enable_x64", True) / with jax.enable_x64(True):全局 / 临时启用 64 位类型
jax.disable_jit()上下文管理器:临时禁用 jit 以便调试
jax.debug.print("{x}", x=x)在编译后的代码中打印运行时数值
lax.cond(pred, true_fun, false_fun, *ops)标量谓词的二分支条件
lax.switch(index, branches, *ops)多分支条件(index 越界会被夹紧)
jnp.where(c, a, b) / jnp.select(conds, choices)逐元素条件选择(两侧都会计算)
lax.fori_loop(lo, hi, body, init)计数循环;边界可为追踪值;静态边界才可反向求导
lax.while_loop(cond, body, init)条件循环;不支持反向求导
lax.scan(f, init, xs)带状态遍历序列,返回 (最终 carry, 堆叠输出)
jax.random.key(seed) / jax.random.split(key, n)新式随机数 key 与拆分(替代 PRNGKey)
x.device / x.weak_type查看数组所在设备 / 是否为弱类型
jax.errors.ConcretizationTypeError / jax.errors.JAXTypeError追踪值被当作具体值使用时的错误;前者并不是所有这类错误的基类(见 2.4 补充),统一捕获用后者

本章更新说明

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

编号小节要点位置
12.2生成 key 推荐用 jax.random.key(0)(带类型的 key),不再用 jax.random.PRNGKey(0)2.2 介绍 jax.jit → 随机数 key:旧写法与推荐写法
22.2 / 2.3 / 2.7同一个 key 不要复用于多个随机数组(复用时序列前缀完全相同);用 jax.random.split 为每个数组生成独立子 key2.2 介绍 jax.jit → 随机数 key:旧写法与推荐写法
32.3默认 x64 关闭时,float32 → float64 不会触发重新编译(float64 被截断为 float32);开启 x64 后才会2.3 JIT 工作原理:追踪与编译 → 重新编译触发条件总表(E2)
42.3重新编译的触发条件除了形状 / dtype / PyTree / 静态参数,还有弱类型、位置 vs 关键字传参、省略默认参数、每次新建 jit 对象;修改全局变量不触发。每条都用编译计数验证2.3 JIT 工作原理:追踪与编译 → 重新编译触发条件总表(E1–E8)
52.4在 Python 控制流里误用追踪值时,JAX 0.12 抛 TracerBoolConversionError(ConcretizationTypeError 的子类)或 TracerIntegerConversionError(不是其子类),两者的共同父类是 JAXTypeError2.4 Python 控制流与 jit → 错误类型的名字
62.4fori_loop 静态边界降级为 scan,可反向求导;追踪边界降级为 while_loop,不可反向求导2.4 Python 控制流与 jit → fori_loop 的两种“身份”
72.5conditional_double 只把 threshold 设为静态,仍会报 TracerBoolConversionError;正确修法是 lax.cond / jnp.where,或者让条件只依赖静态参数2.5 静态值与跟踪值 → 把 threshold 标记为静态,能解决吗?
82.5演示静态参数重编译的 4 次调用,函数体要用 jnp.where(if 版本会报错),共编译 2 次2.5 静态值与跟踪值 → 重新编译的权衡
92.5静态参数必须可哈希:数组和 list 会报错;按身份哈希的类每个实例都重新编译,frozen dataclass 按值命中缓存2.5 静态值与跟踪值 → 静态参数的硬性要求与细节
102.6fori_loop 的上界可以是追踪值(降级为 while_loop),换 n 不重新编译,只是不能反向求导2.6 jit 的常见问题 → 问题 2:迭代次数可变的循环
112.6update_weights_explicit(params, grads, 1000.0) 的输出是 [-499. 102.]2.6 jit 的常见问题 → 问题 5:全局变量
122.6全局变量在重新追踪后会出现“新旧值并存”;大数组/权重也应该作为参数传入,不要靠闭包捕获2.6 jit 的常见问题 → 全局变量的“新旧值并存”
132.7计时要用 jax.block_until_ready(result) 才能等任意 PyTree 算完;只对单个 jax.Array 阻塞的 time_func 遇到元组时只测到排队时间2.7 动手实践:应用 jit → 准备:计时辅助函数和测试数据
142.7条件依赖追踪值的 Python if 在 jit 下必然抛 TracerBoolConversionError,要改用 lax.cond2.7 动手实践:应用 jit → 示例 2:JIT 与 Python 控制流
152.7range(n) 里的 n 不标记静态时,会直接抛 TracerIntegerConversionError;要么用 static_argnums,要么改用 fori_loop 并传入追踪的 n2.7 动手实践:应用 jit → 示例 3:静态参数
162.7耗时柱状图用从 0 开始的线性纵轴(对数轴上柱高的比例会误导人)2.7 动手实践:应用 jit → 示例 1:一个“计算较重”的函数
172.3Jaxpr 里没有的是输入的具体数值;代码里写死的常量会以字面量出现(如 mul a 2.0:f32[])2.3 JIT 工作原理:追踪与编译
182.3jit 的加速来自编译后的代码(消除调度开销、算子融合);缓存保证只编译一次、反复复用,两者缺一不可2.3 JIT 工作原理:追踪与编译

附录:练习参考答案

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

练习 1

题目见正文「练习 1:分段函数(lax.switch / jnp.select)」。

# 参考答案
brackets = jnp.array([0.0, 10000.0, 40000.0, 100000.0])
rates = jnp.array([0.0, 0.10, 0.20, 0.30])
# 每个档位起点之前已累计的税额:0, 0, 3000, 15000
base_tax = jnp.concatenate([jnp.zeros(1), jnp.cumsum(jnp.diff(brackets) * rates[:-1])])

@jax.jit
def tax_switch(income):
    idx = jnp.searchsorted(brackets, income, side="right") - 1    # 所在档位 0..3(追踪值)
    branches = [
        (lambda inc, k=k: base_tax[k] + (inc - brackets[k]) * rates[k]) for k in range(4)
    ]
    return lax.switch(idx, branches, income)

@jax.jit
def tax_vectorized(income):
    conds = [income < 10000, income < 40000, income < 100000]
    choices = [jnp.zeros_like(income),
               (income - 10000) * 0.10,
               3000 + (income - 40000) * 0.20]
    return jnp.select(conds, choices, default=15000 + (income - 100000) * 0.30)

def tax_numpy(inc):
    if inc < 10000: return 0.0
    if inc < 40000: return (inc - 10000) * 0.10
    if inc < 100000: return 3000 + (inc - 40000) * 0.20
    return 15000 + (inc - 100000) * 0.30

incomes = jnp.array([0.0, 9999.0, 10000.0, 25000.0, 40000.0, 70000.0, 100000.0, 250000.0])
ref = np.array([tax_numpy(float(v)) for v in incomes])
sw = np.array([float(tax_switch(v)) for v in incomes])
vec = np.asarray(tax_vectorized(incomes))
print("switch :", sw)
print("select :", vec)
print("一致:", np.allclose(sw, ref), np.allclose(vec, ref))
assert np.allclose(sw, ref) and np.allclose(vec, ref)
switch : [    0.     0.     0.  1500.  3000.  9000. 15000. 60000.]
select : [    0.     0.     0.  1500.  3000.  9000. 15000. 60000.]
一致: True True

练习 2

题目见正文「练习 2:用 lax.while_loop 实现牛顿法开平方」。

# 参考答案
@jax.jit
def newton_sqrt(a, tol):
    x0 = jnp.where(a < 1.0, 1.0, a)

    def cond_fun(state):
        x, x_prev, k = state
        return (jnp.abs(x - x_prev) >= tol) & (k < 100)   # 加上最大步数,防止死循环

    def body_fun(state):
        x, _, k = state
        return 0.5 * (x + a / x), x, k + 1

    # 初始 x_prev 设为 x0 + 1,保证至少迭代一次
    x, _, k = lax.while_loop(cond_fun, body_fun, (x0, x0 + 1.0, 0))
    return x, k

with count_compiles(echo=False) as log:
    for a in [2.0, 9.0, 0.25, 12345.0]:
        r, k = newton_sqrt(a, 1e-6)
        print(f"a={a:>8}: newton={float(r):.6f}  jnp.sqrt={float(jnp.sqrt(a)):.6f}  迭代 {int(k)} 次")
        assert jnp.allclose(r, jnp.sqrt(a), rtol=1e-6)
    newton_sqrt(2.0, 1e-3)
    newton_sqrt(2.0, 1e-9)
assert log.count("newton_sqrt") == 1
print("✅ 4 个 a、3 种 tol 只编译了 1 次(a 与 tol 都是追踪值)")
# 答:循环是否继续取决于 x 的数值(追踪值),Python while 需要具体 bool,会抛 TracerBoolConversionError。
a=     2.0: newton=1.414214  jnp.sqrt=1.414214  迭代 5 次
a=     9.0: newton=3.000000  jnp.sqrt=3.000000  迭代 6 次
a=    0.25: newton=0.500000  jnp.sqrt=0.500000  迭代 5 次
a= 12345.0: newton=111.108055  jnp.sqrt=111.108055  迭代 12 次
✅ 4 个 a、3 种 tol 只编译了 1 次(a 与 tol 都是追踪值)

练习 3

题目见正文「练习 3:重新编译侦探」。

# 参考答案(运行验证)
@partial(jax.jit, static_argnames=["mode"])
def detective(x, mode="a"):
    return x * 2 if mode == "a" else x + 1

ones3, ones3x5, ones4 = jnp.ones(3), jnp.ones(3) * 5, jnp.ones(4)
ones3_i32, f32_scalar = jnp.ones(3, dtype=jnp.int32), jnp.float32(1.0)
calls = [
    ("① f32[3], 默认 mode",          lambda: detective(ones3),            True),
    ("② 同形状不同值",               lambda: detective(ones3x5),          False),
    ("③ f32[4] 新形状",              lambda: detective(ones4),            True),
    ("④ mode='b' 静态值变化",        lambda: detective(ones3, mode="b"),  True),
    ("⑤ i32[3] 新 dtype",            lambda: detective(ones3_i32),        True),
    ("⑥ Python float(弱类型标量)", lambda: detective(1.0),              True),
    ("⑦ jnp.float32 强类型标量",     lambda: detective(f32_scalar),       True),
    ("⑧ 显式 mode='a'",              lambda: detective(ones3, mode="a"),  True),
]
predictions = [expect for _, _, expect in calls]   # 自检接口:8 次调用各自是否触发编译
with count_compiles(echo=False) as log:
    for name, call, expect_compile in calls:
        before = log.count("detective")
        call()
        compiled = log.count("detective") > before
        print(f"{name:28s} → {'编译' if compiled else '命中缓存'}")
        assert compiled == expect_compile, name
assert log.count("detective") == 7
print("✅ 共编译", log.count("detective"), "次")
① f32[3], 默认 mode            → 编译
② 同形状不同值                     → 命中缓存
③ f32[4] 新形状                 → 编译
④ mode='b' 静态值变化             → 编译
⑤ i32[3] 新 dtype             → 编译
⑥ Python float(弱类型标量)        → 编译
⑦ jnp.float32 强类型标量          → 编译
⑧ 显式 mode='a'                → 编译
✅ 共编译 7 次

练习 4

题目见正文「练习 4:把“带全局状态的训练循环”改写成纯函数 + fori_loop」。

# 参考答案
k1, k2 = jax.random.split(jax.random.key(42))
x_lr = jax.random.uniform(k1, (256,), minval=-1.0, maxval=1.0)
y_lr = 3.0 * x_lr - 1.0 + 0.1 * jax.random.normal(k2, (256,))

@jax.jit
def step(w, b, x, y, lr):
    err = w * x + b - y
    return w - lr * jnp.mean(2 * err * x), b - lr * jnp.mean(2 * err)

def train_python_loop(w, b, x, y, lr, num_steps):
    for _ in range(num_steps):
        w, b = step(w, b, x, y, lr)
    return w, b

@jax.jit
def train_fori(w, b, x, y, lr, num_steps):
    def body(i, wb):
        return step(wb[0], wb[1], x, y, lr)   # jit 函数内部调用 jit 函数:会被内联
    return lax.fori_loop(0, num_steps, body, (w, b))

w0, b0 = jnp.float32(0.0), jnp.float32(0.0)
w_fit, b_fit = train_fori(w0, b0, x_lr, y_lr, 0.5, 200)
print(f"fori_loop 训练结果: w={float(w_fit):.3f}, b={float(b_fit):.3f}  (真实 3, -1)")

w_py, b_py = train_python_loop(w0, b0, x_lr, y_lr, 0.5, 200)
print("两种方式结果一致:", bool(jnp.allclose(w_fit, w_py, atol=1e-5) & jnp.allclose(b_fit, b_py, atol=1e-5)))
assert jnp.allclose(w_fit, w_py, atol=1e-5) and jnp.allclose(b_fit, b_py, atol=1e-5)
assert abs(float(w_fit) - 3.0) < 0.05 and abs(float(b_fit) + 1.0) < 0.05

t_py, _ = bench(train_python_loop, w0, b0, x_lr, y_lr, 0.5, 200, runs=5)
t_fo, _ = bench(train_fori, w0, b0, x_lr, y_lr, 0.5, 200, runs=5)
print(f"Python 循环 200 次调用: {t_py*1e3:.2f} ms")
print(f"单个 fori_loop 训练:    {t_fo*1e3:.2f} ms  (约 {t_py/t_fo:.0f} 倍)")
assert_faster(lambda: bench(train_fori, w0, b0, x_lr, y_lr, 0.5, 200, runs=5)[0],
              lambda: bench(train_python_loop, w0, b0, x_lr, y_lr, 0.5, 200, runs=5)[0])
fori_loop 训练结果: w=2.997, b=-1.006  (真实 3, -1)
两种方式结果一致: True
Python 循环 200 次调用: 2.76 ms
单个 fori_loop 训练:    0.13 ms  (约 21 倍)
(9.768299933057278e-05, 0.0035141917993314563)