第 9 章 导出与部署
JAX 学习笔记 · 进阶补充章节。前置:入门课 00–07、进阶课 00–06。 运行环境:Python 3.14 · JAX / jaxlib 0.12.0.1 · Flax 0.12.12 · Optax 0.2.9 · Orbax 0.12.10 · 仅 CPU。
前面的章节讲到"模型训练好"为止。这一章接着讲训练好以后的事:怎么保存,怎么交给别人或别的程序去运行。具体来说,就是把参数存成 checkpoint,把计算图导出成和 Python 源码无关的 StableHLO 程序,然后在一个全新的进程里只靠这两样东西完成推理。
本章的结论都在 JAX 0.12.0.1 上跑过,并用 assert 验证;计时只打印,不 assert。本机只有 CPU,跑不了的内容(GPU/TPU 执行、C++ 运行时、TensorFlow)都标明了「只做 lowering」或「未在本机执行」。
与前面章节的关系(不重复讲,只简单回顾):
- Orbax
StandardCheckpointer的基本保存与恢复、nnx.eval_shape抽象模板、nnx.update写回:见 进阶/06 §6.3「序列化与检查点」。本章从CheckpointManager、保留策略和"断点续训"讲起。 - 持久化编译缓存(
jax_compilation_cache_dir):见 进阶/02 §2.5 补充。它缓存的是本机编译好的机器码,和本章的jax.export(可移植的 StableHLO 程序)是两回事,§1 会对比。 - jaxlib 0.12.0.1 CPU 后端"闭包常量矩阵 + matmul + sum 会悄悄算错"的 bug:现象与规避见 00 目录与导读「本环境已知问题」,根因见 进阶/02 §2.3。§2.2 会看到,这个 bug 会原样跟着导出产物走。
pure_callback的语义(可被 DCE、需要vmap_method、不可微):见 进阶/05 §5.4。§4.4 只讨论它在部署中的取舍。jit(f).lower(...).as_text()看 StableHLO:见入门课 02_JIT编译。
学习目标¶
- 分清两类需要保存的东西:数据(参数、优化器状态,用 Orbax)和程序(计算图,用
jax.export),并知道各自适用的场景。 - 掌握
jax.export.export→Exported→serialize()/deserialize()→.call()的完整流程,并能证明导出产物离开原始 Python 源码也能运行。 - 会用
symbolic_shape做形状多态导出,理解符号维度的约束、整除、条件判断和作用域规则,以及各种报错的含义。 - 会做多平台导出(
platforms=),会导出可求导的函数(vjp_order),知道Exported.call支持哪些变换、不支持哪些。 - 理解三层版本兼容性:序列化格式版本、调用约定版本、StableHLO 可移植产物的兼容承诺;知道含分片函数的导出规则。
- 用 Orbax
CheckpointManager管理多步检查点(max_to_keep、按指标保留最佳、组合保留策略),用抽象模板恢复,配合 Flax NNX 实现可以无缝衔接的断点续训。 - 了解部署出口的现状:StableHLO 交给 C++ 运行时(IREE、PJRT)、
jax2tf、ONNX、pure_callback服务封装。 - 完成一次端到端实践:训练 → checkpoint → 导出 → 全新子进程只加载产物推理 → 对拍。
目录¶
| 节 | 内容 | 本机可运行 |
|---|---|---|
| §0 | 环境说明:flatbuffers 依赖;通用工具 | ✅ |
| §1 | 两类东西分开保存:数据(Orbax)与程序(jax.export) | ✅ |
| §2.1 | jax.export 基础:Exported 的各个字段 | ✅ |
| §2.2 | 序列化到文件,在新子进程中运行;为什么权重必须作为参数 | ✅ |
| §2.3 | 形状多态:symbolic_shape、约束、整除、作用域 | ✅ |
| §2.4 | 多平台导出 platforms=("cpu","cuda","tpu") | ⚠️ 只做 lowering,未在 GPU/TPU 执行 |
| §2.5 | 导出可求导的函数:vjp_order;Exported.call 与 vmap/jvp | ✅ |
| §2.6 | 版本兼容性:序列化版本、调用约定版本、StableHLO 承诺 | ✅ |
| §2.7 | 含分片 / shard_map / pmap 的函数导出(子进程模拟 4 设备) | ✅ |
| §3 | Orbax 进阶:CheckpointManager、保留策略、抽象模板、NNX 断点续训 | ✅ |
| §4 | 部署出口概览:StableHLO → IREE/PJRT、jax2tf、ONNX、pure_callback | ⚠️ C++ 运行时与 TF 未在本机执行 |
| §5 | 综合实践:训练 → checkpoint → 导出 → 子进程独立推理 → 对拍 | ✅ |
| §6 | 实战规则清单 | — |
之后依次是:动手练习(4 道)、测验(6 道)、本章小结、速查表。
官方文档:Exporting and serialization(含 Exporting and serializing staged-out computations 与 Shape polymorphism 两篇)。本章讲的兼容性承诺和调用约定版本,都以它为准。
§0 环境说明与通用工具¶
⚠️ 序列化需要 flatbuffers:Exported.serialize() 和 jax.export.deserialize() 用 FlatBuffers 格式读写导出产物。但 flatbuffers 不是 jax / jaxlib 的依赖(两者的包元数据里都没有它,任何 extra 里也没有),pip install jax 不会顺带装上它,要单独装:
pip install flatbuffers # 纯 Python 包;本环境为 25.12.19,并已写入 requirements.txt
没装它的时候,jax.export.export(...) 本身照常工作(导出只用到 MLIR),要等到调用 serialize() / deserialize() 才报 ImportError。下面先定义全章通用的工具,再用一对 ❌/✅ 单元验证这一点。
通用工具:
run_py(src, *args):在全新的子进程里运行一段脚本。用python -I(隔离模式:不把当前目录和脚本目录加入sys.path,也忽略PYTHONPATH),所以子进程不可能 import 到本 notebook 里定义的任何东西。验证"导出产物离开源码也能运行"、模拟多设备(XLA_FLAGS只在进程启动时读取),都要靠它。sds(tree):把数组 PyTree 转成同结构的jax.ShapeDtypeStruct,导出时只需要形状和 dtype。max_diff、tree_bytes、dir_bytes:误差和大小统计。ART:导出产物目录export_artifacts/(与本 notebook 同级,已被.gitignore排除)。每次运行开头清空重建。
import os, re, sys, json, time, shutil, textwrap, subprocess, tempfile, importlib.util
from importlib.metadata import requires, version
from pathlib import Path
import numpy as np
import jax
import jax.numpy as jnp
from jax import export
import jaxlib, flax, optax
from flax import nnx
import orbax.checkpoint as ocp
import absl.logging
absl.logging.set_verbosity(absl.logging.ERROR) # 只保留 Orbax 的错误日志,避免 INFO/WARNING 刷屏
print("Python", sys.version.split()[0], "| JAX", jax.__version__, "| jaxlib", jaxlib.__version__,
"| Flax", flax.__version__, "| Optax", optax.__version__, "| Orbax", version("orbax-checkpoint"))
print("设备:", jax.devices())
# 导出产物目录:与本 notebook 同级(notebooks/进阶/export_artifacts/),每次运行先清空
ART = Path("export_artifacts").resolve()
if ART.exists():
shutil.rmtree(ART)
ART.mkdir(parents=True)
SCRATCH = Path(tempfile.mkdtemp(prefix="ext03_")) # 中间文件放临时目录,不进仓库
print("产物目录:", ART.name + "/")
def run_py(src, *args, env_extra=None, cwd=None):
"""在全新子进程(python -I 隔离模式)里运行脚本 src,返回 stdout;失败时抛出带 stderr 的异常。"""
env = {**os.environ, **(env_extra or {})}
r = subprocess.run([sys.executable, "-I", "-c", textwrap.dedent(src), *map(str, args)],
capture_output=True, text=True, env=env, cwd=str(cwd or SCRATCH))
if r.returncode != 0:
raise RuntimeError(f"子进程失败(返回码 {r.returncode}):\n{r.stderr[-3000:]}")
return r.stdout
def sds(tree):
"""数组 PyTree → 同结构的 ShapeDtypeStruct(只保留形状与 dtype)。"""
return jax.tree.map(lambda a: jax.ShapeDtypeStruct(np.shape(a), a.dtype), tree)
def max_diff(a, b):
"""两个同结构 PyTree 的最大逐元素绝对误差(float64 比较)。"""
la, lb = jax.tree.leaves(a), jax.tree.leaves(b)
assert len(la) == len(lb)
return max(float(np.max(np.abs(np.asarray(x, np.float64) - np.asarray(y, np.float64)))) for x, y in zip(la, lb))
def tree_bytes(tree):
return sum(np.asarray(x).nbytes for x in jax.tree.leaves(tree))
def dir_bytes(p):
p = Path(p)
return p.stat().st_size if p.is_file() else sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
def main_signature(exp):
"""取出导出模块 @main 的签名行(看参数与返回值类型)。"""
return next(l.strip() for l in exp.mlir_module().splitlines() if "func.func public @main" in l)
# ❌ 错误写法:环境里没装 flatbuffers 就调用 serialize()——jax 不会自动安装它
# 不真的卸载包:在子进程里令 sys.modules["flatbuffers"] = None,此后 import flatbuffers 必然抛 ImportError,以此模拟"未安装"
try:
out = run_py("""
import sys
sys.modules["flatbuffers"] = None
import jax, jax.numpy as jnp
from jax import export
exp = export.export(jax.jit(jnp.sin))(jnp.ones(3))
print("导出本身成功,platforms =", exp.platforms)
try:
exp.serialize()
print("RESULT 序列化成功")
except ImportError as e:
print("RESULT ImportError:", e)
""")
print("实测:\n" + out)
except Exception as e:
print("实测报错:", type(e).__name__, e)
assert "导出本身成功" in out
assert "RESULT ImportError: Please install 'flatbuffers' in order to use Exported serialization" in out
# ✅ 正确写法:单独安装 flatbuffers(pip install flatbuffers),并写进 requirements.txt
import flatbuffers
deps = (requires("jax") or []) + (requires("jaxlib") or [])
print("flatbuffers 版本:", flatbuffers.__version__)
print("jax/jaxlib 声明的依赖中含 flatbuffers 吗?", any(d.lower().startswith("flatbuffers") for d in deps))
assert not any(d.lower().startswith("flatbuffers") for d in deps) # 确实不是 jax 的依赖
blob = export.export(jax.jit(jnp.sin))(jnp.ones(3)).serialize()
print("serialize() 正常:", type(blob).__name__, len(blob), "字节")
assert isinstance(blob, bytearray) and len(blob) > 0
§1 两类东西要分开保存:数据与程序¶
一个训练好的模型 = 程序(前向计算怎么做)+ 数据(权重的具体数值)。JAX 生态把两者交给不同的工具:
| 数据:参数、优化器状态、步数、RNG key | 程序:计算图 | |
|---|---|---|
| 工具 | Orbax(orbax.checkpoint) | jax.export |
| 保存的内容 | 数组(TensorStore/Zarr 格式,可分片、可异步写) | StableHLO 可移植字节码 + 输入输出签名等元数据 |
| 读回时需要 | 模型定义代码(要靠它把数组重新装回模型);恢复模板 | 只需要 jax(或任何能运行 StableHLO 的运行时),不需要模型源码 |
| 典型场景 | 断点续训、微调、换并行策略后重新加载、模型发布(权重) | 推理服务、交给 C++/移动端运行时、跨团队交付"黑盒"函数、冻结一个版本做回归测试 |
| 版本稳定性 | 数组格式稳定;但 PyTree 结构依赖你的代码 | JAX 承诺 6 个月向后兼容(§2.6) |
还有第三种容易混淆的东西:持久化编译缓存(进阶/02 §2.5)。它存的是 XLA 为这台机器、这个 jaxlib 版本、这组编译选项编译出的机器码,缓存键一变就失效。它只用来加速重启,不是交付格式。
| 持久化编译缓存 | jax.export | |
|---|---|---|
| 内容 | 编译后的可执行文件 | 编译之前的 StableHLO |
| 可移植性 | 无:换 jaxlib、换 flags、换硬件都失效 | 跨 jaxlib 版本(兼容窗口内)、可多平台 |
| 读入后还要编译吗 | 不用 | 要(加载后由当前 XLA 编译) |
两者分开保存,最大的好处是可以独立更新:同一份导出的程序可以配不同的权重(重新训练、微调后不必重新导出),同一份权重也可以配不同的程序(换推理批大小、换平台)。下面先用一个最小例子演示"一个程序 + 两份数据",§5 再做完整流程。
def linear_apply(params, x):
"""最小的"模型":程序只描述计算,权重从参数传入。"""
return x @ params["w"] + params["b"]
k1, k2, k3 = jax.random.split(jax.random.key(0), 3)
params_a = {"w": jax.random.normal(k1, (4, 2)), "b": jnp.zeros(2)} # 第一份权重
params_b = {"w": jax.random.normal(k2, (4, 2)), "b": jnp.ones(2)} # 第二份权重(比如重新训练过)
x_demo = jax.random.normal(k3, (3, 4))
# 程序:只导出一次(只用到形状和 dtype)
prog = export.export(jax.jit(linear_apply))(sds(params_a), sds(x_demo))
# 数据:两份权重分别用 Orbax 保存
ckptr = ocp.StandardCheckpointer()
for name, p in [("a", params_a), ("b", params_b)]:
ckptr.save(SCRATCH / f"linear_{name}", p)
ckptr.wait_until_finished()
for name, p in [("a", params_a), ("b", params_b)]:
restored = ckptr.restore(SCRATCH / f"linear_{name}", sds(p)) # 用抽象模板恢复
y = prog.call(restored, x_demo)
print(f"权重 {name}: 导出程序的输出 = {np.round(np.asarray(y[0]), 4)}")
assert max_diff(y, linear_apply(p, x_demo)) < 1e-6
assert max_diff(prog.call(params_a, x_demo), prog.call(params_b, x_demo)) > 0.1
print("同一个导出程序 + 两份 Orbax 权重 → 两个不同模型,都与原函数一致 ✓")
§2 jax.export:把计算图导出成可移植的程序¶
§2.1 基本用法:Exported 对象里有什么¶
🟢 必读
流程只有一行:jax.export.export(jax.jit(f))(*args)。参数可以是真实数组,也可以只是 jax.ShapeDtypeStruct(导出只需要形状和 dtype,不需要真实数据)。得到的 Exported 对象包含:
| 字段 / 方法 | 含义 |
|---|---|
fun_name | 原函数名(只是个名字,不含源码) |
in_tree / in_avals | 输入的 PyTree 结构,以及展平后每个叶子的抽象值(形状 + dtype) |
out_tree / out_avals | 输出的结构与抽象值 |
platforms | 为哪些平台做了 lowering,默认是当前后端 ("cpu",) |
nr_devices | 导出时的设备数(分片函数 > 1,见 §2.7) |
calling_convention_version | 调用约定版本(§2.6) |
mlir_module() | StableHLO 文本(给人看) |
mlir_module_serialized | StableHLO 可移植字节码(真正被保存的程序) |
call(*args) | 调用导出的函数(会在当前进程编译它) |
serialize(vjp_order=0) / jax.export.deserialize(blob) | 存成字节 / 从字节恢复 |
has_vjp() / vjp() | 能否求导 / 取出反向函数的 Exported(§2.5) |
注意 in_avals 是展平后的:字典叶子按键名排序,所以 {"w1","b1","w2","b2"} 展平的顺序是 b1, b2, w1, w2。
def mlp_apply(params, x):
"""两层 MLP 的纯函数版本。权重 params 是参数,不是闭包常量。"""
h = jnp.tanh(x @ params["w1"] + params["b1"])
return h @ params["w2"] + params["b2"]
def init_mlp(key, din=4, dh=8, dout=3):
k1, k2 = jax.random.split(key)
return {"w1": jax.random.normal(k1, (din, dh)) / np.sqrt(din), "b1": jnp.zeros(dh),
"w2": jax.random.normal(k2, (dh, dout)) / np.sqrt(dh), "b2": jnp.zeros(dout)}
params = init_mlp(jax.random.key(0))
x2 = jax.random.normal(jax.random.key(1), (2, 4))
exp = export.export(jax.jit(mlp_apply))(params, x2)
print("fun_name :", exp.fun_name)
print("in_tree :", exp.in_tree)
print("in_avals :", exp.in_avals)
print("out_avals :", exp.out_avals)
print("platforms :", exp.platforms, "| nr_devices:", exp.nr_devices, "| calling_convention_version:", exp.calling_convention_version)
print("@main 签名 :", main_signature(exp)[:200], "...")
assert exp.fun_name == "mlp_apply" and exp.platforms == ("cpu",) and exp.nr_devices == 1
assert [a.shape for a in exp.in_avals] == [(8,), (3,), (4, 8), (8, 3), (2, 4)] # b1, b2, w1, w2, x
assert exp.out_avals[0].shape == (2, 3) and exp.in_tree.num_leaves == 5
assert "stablehlo.dot_general" in exp.mlir_module() and "stablehlo.tanh" in exp.mlir_module()
assert max_diff(exp.call(params, x2), mlp_apply(params, x2)) < 1e-6
# 只给 ShapeDtypeStruct 也能导出,得到完全相同的程序
exp_abs = export.export(jax.jit(mlp_apply))(sds(params), jax.ShapeDtypeStruct((2, 4), jnp.float32))
assert exp_abs.mlir_module_serialized == exp.mlir_module_serialized
print("只用 ShapeDtypeStruct 导出:字节码与用真实数组导出的完全相同 ✓")
# ❌ 错误写法:直接导出普通 Python 函数(没有用 jax.jit 包装)
try:
export.export(mlp_apply)(params, x2)
print("实测:可以运行")
except ValueError as e:
print("实测报错:ValueError:", e)
err_nojit = str(e)
assert "must be the result of `jit`" in err_nojit
# ✅ 正确写法:export.export 只接受 jax.jit 的结果;jit 的 static_argnums、donate 等选项也一并生效
exp_ok = export.export(jax.jit(mlp_apply))(params, x2)
assert max_diff(exp_ok.call(params, x2), mlp_apply(params, x2)) < 1e-6
print("export.export(jax.jit(f)) ✓")
§2.2 序列化到文件,在全新子进程中运行¶
🟢 必读
exp.serialize() 返回 bytearray,直接写文件就行;jax.export.deserialize(blob) 接受 bytes 或 bytearray。
下面把导出产物和权重(这里先用 .npz,§5 换成 Orbax)写到临时目录,然后在一个 python -I 子进程里加载并调用。为了证明子进程没有用到原始 Python 源码,再加两条检查:
- 子进程脚本的文本里不出现
mlp_apply、tanh、@(矩阵乘)等计算相关的东西,它只会"加载 → 调用"; - 隔离模式下子进程的
sys.path不含本 notebook 所在目录,也无法 import 到 notebook 里定义的任何函数。
blob = exp.serialize()
exp_path = SCRATCH / "mlp_static.jaxexport"
exp_path.write_bytes(blob)
io_path = SCRATCH / "mlp_static_io.npz"
np.savez(io_path, x=np.asarray(x2), **{k: np.asarray(v) for k, v in params.items()})
print(f"导出文件 {exp_path.name}: {len(blob)} 字节;文件头 {bytes(blob[:8])!r}")
sub_src = """
import sys, numpy as np, jax
from jax import export
exp = export.deserialize(open(sys.argv[1], "rb").read()) # 只读字节,不 import 任何模型代码
d = np.load(sys.argv[2])
params = {k: d[k] for k in ("w1", "b1", "w2", "b2")}
np.save(sys.argv[3], np.asarray(exp.call(params, d["x"])))
print("子进程 fun_name =", exp.fun_name, "| in_avals =", exp.in_avals)
print("子进程 sys.path[0] =", sys.path[0])
"""
out_path = SCRATCH / "mlp_static_out.npy"
print(run_py(sub_src, exp_path, io_path, out_path))
y_sub = np.load(out_path)
print("子进程输出与原函数的最大差:", max_diff(y_sub, mlp_apply(params, x2)))
assert max_diff(y_sub, mlp_apply(params, x2)) < 1e-6
for token in ("mlp_apply", "tanh", "@", "def "): # 子进程脚本里没有任何计算逻辑
assert token not in sub_src, token
# 同一进程里 deserialize 回来的对象与原对象签名一致
exp_back = export.deserialize(blob)
assert exp_back.in_avals == exp.in_avals and exp_back.out_avals == exp.out_avals and exp_back.in_tree == exp.in_tree
print("序列化 → 文件 → 新子进程 deserialize → call,结果与原函数一致 ✓")
权重为什么必须作为参数,而不是闭包常量¶
如果写成 jax.jit(lambda x: model_with_baked_weights(x)),权重会作为 stablehlo.constant 烘焙进程序。这样做有三个问题:
- 程序和数据绑死:换一份权重就得重新导出;导出文件随参数量线性增大,几十亿参数的模型根本放不进一个 StableHLO 模块(protobuf/FlatBuffer 都有 2GB 量级的上限)。
- 不能独立管理数据:权重失去了 Orbax 提供的分片、异步写、按需加载等能力。
- 会把本机的 CPU bug 带进产物:bug 本身见 00 目录与导读「本环境已知问题」。权重作为常量进了导出产物以后,在 CPU 上调用时同样会悄悄算错。
下面用进阶/02 的同一组数据验证一下:
# ❌ 错误写法:把权重作为闭包常量烘焙进导出程序(程序与数据绑死、文件膨胀,还会触发 CPU 后端 bug)
rng = np.random.default_rng(0)
n = 128
W_np = rng.standard_normal((n, n)).astype(np.float32)
X_np = rng.standard_normal((n, n)).astype(np.float32)
ref = float(np.sum(W_np.astype(np.float64) @ X_np.astype(np.float64))) # float64 参考值
W_const = jnp.asarray(W_np)
try:
exp_closure = export.export(jax.jit(lambda x: jnp.sum(W_const @ x)))(jax.ShapeDtypeStruct((n, n), jnp.float32))
r_closure = float(exp_closure.call(jnp.asarray(X_np)))
size_closure = len(exp_closure.serialize())
print(f"闭包版:导出文件 {size_closure:,} 字节;结果 {r_closure:.4f},参考 {ref:.4f},误差 {abs(r_closure - ref):.2e}")
print("闭包版是否触发了 CPU bug:", abs(r_closure - ref) > 1.0)
except Exception as e:
print("实测报错:", type(e).__name__, e)
# 权重确实以 128×128 的常量形式嵌在 StableHLO 里
assert re.search(r"stablehlo\.constant dense<.*> : tensor<128x128xf32>", exp_closure.mlir_module())
# ✅ 正确写法:权重作为参数传入,导出的程序里不含任何权重
exp_param = export.export(jax.jit(lambda w, x: jnp.sum(w @ x)))(
jax.ShapeDtypeStruct((n, n), jnp.float32), jax.ShapeDtypeStruct((n, n), jnp.float32))
r_param = float(exp_param.call(jnp.asarray(W_np), jnp.asarray(X_np)))
size_param = len(exp_param.serialize())
print(f"传参版:导出文件 {size_param:,} 字节;结果 {r_param:.4f},误差 {abs(r_param - ref):.2e}")
print(f"闭包版文件是传参版的 {size_closure / size_param:.0f} 倍(权重 {W_np.nbytes:,} 字节被烘焙了进去)")
assert abs(r_param - ref) < 1e-2 # 传参版结果正确
assert size_closure > W_np.nbytes > 10 * size_param # 闭包版的大小主要就是那份权重
assert not re.search(r"tensor<128x128xf32> = stablehlo\.constant|constant dense<[^>]{100,}", exp_param.mlir_module())
§2.3 形状多态:一次导出,适配任意 batch¶
🟢 必读
上面的导出产物只接受 x.shape == (2, 4)。推理服务的 batch 大小是变化的,为每个大小导出一份显然不现实。形状多态(shape polymorphism)让某些维度保持为符号:
x_spec = jax.ShapeDtypeStruct(export.symbolic_shape("b, 4"), jnp.float32) # 第 0 维是符号 b
导出时,JAX 用符号 b 追踪函数,StableHLO 里对应的维度显示为 ?(动态维度)。调用时,JAX 根据实参的形状解出 b 的值,再做形状细化(shape refinement),把 ? 换成具体数值,然后编译。模块里还会插入 shape_assertion,调用时检查实参是否满足导出时的假设。
x_poly = jax.ShapeDtypeStruct(export.symbolic_shape("b, 4"), jnp.float32)
print("符号形状:", x_poly.shape, "| 类型:", type(x_poly.shape[0]).__name__, "| is_symbolic_dim:", export.is_symbolic_dim(x_poly.shape[0]))
exp_poly = export.export(jax.jit(mlp_apply))(sds(params), x_poly)
print("in_avals :", exp_poly.in_avals)
print("out_avals:", exp_poly.out_avals)
print("@main 签名:", main_signature(exp_poly)[-120:])
assert str(exp_poly.out_avals[0].shape) == "(b, 3)"
assert "tensor<?x4xf32>" in main_signature(exp_poly) and "shape_assertion" in exp_poly.mlir_module()
exp_poly_back = export.deserialize(exp_poly.serialize()) # 符号形状也能序列化
for batch in [1, 5, 17, 256]:
xb = jax.random.normal(jax.random.key(batch), (batch, 4))
yb = exp_poly_back.call(params, xb)
assert yb.shape == (batch, 3) and max_diff(yb, mlp_apply(params, xb)) < 1e-5
print("一份导出产物,batch = 1 / 5 / 17 / 256 全部与原函数一致 ✓")
调用时的检查:只有符号维度可以变¶
导出时写死的维度(这里的 4)和 dtype 在调用时都会被检查;不匹配直接报 ValueError,不会悄悄广播或类型转换。
# ❌ 错误写法:用不符合导出签名的形状 / dtype 调用
errs_call = {}
for name, bad_x in [("特征维 5 ≠ 4", jnp.ones((3, 5))), ("dtype int32", jnp.ones((3, 4), jnp.int32))]:
try:
exp_poly.call(params, bad_x)
errs_call[name] = "可以运行"
except ValueError as e:
errs_call[name] = f"ValueError: {e}"
print(f"{name}: {errs_call[name]}")
assert "Shape mismatch for args[1].shape[1]" in errs_call["特征维 5 ≠ 4"]
assert "Dtype mismatch for args[1]: expected float32 and called with int32" in errs_call["dtype int32"]
# ✅ 正确写法:在导出边界之外把输入整理成导出签名要求的形状和 dtype
raw = np.arange(12, dtype=np.int64).reshape(3, 4) # 来自外部的数据可能是 int64
y_ok = exp_poly.call(params, jnp.asarray(raw, jnp.float32))
assert y_ok.shape == (3, 3)
print("输入先转成 float32、(b, 4) 再调用 ✓")
符号维度上的运算:整除与条件判断¶
符号维度可以参与加减乘、整除、max/min 等运算,但 JAX 必须能在所有可能的取值下给出确定的结论。两类常见报错:
- 不能整除:
(b, 4)reshape 成(-1, 3),需要4b能被 3 整除,对任意b不成立,导出时就报InconclusiveDimensionOperation。 - 条件判断不确定:Python 的
if x.shape[0] > 2:要求一个确定的布尔值,但b > 2对有的b成立、有的不成立,同样报InconclusiveDimensionOperation。
JAX 默认假设每个维度变量 $\ge 1$,所以 b >= 1 判定为真,b >= 2 则不确定。需要更强的假设时,用 constraints= 显式声明;调用时这些约束会被检查。
# ❌ 错误写法:reshape 成 (-1, 3)——4b 不一定能被 3 整除
try:
export.export(jax.jit(lambda x: x.reshape(-1, 3)))(x_poly)
print("实测:可以导出")
except Exception as e:
err_div = f"{type(e).__name__}: {e}"
print("实测报错:", err_div[:220])
assert err_div.startswith("InconclusiveDimensionOperation") and "Cannot divide evenly" in err_div
# ✅ 正确写法:只做对任意 b 都成立的 reshape;输出形状里出现符号表达式 2*b
exp_r2 = export.export(jax.jit(lambda x: x.reshape(-1, 2)))(x_poly)
print("reshape(-1, 2) 的输出形状:", exp_r2.out_avals[0].shape)
assert str(exp_r2.out_avals[0].shape[0]) == "2*b"
assert exp_r2.call(jnp.ones((5, 4))).shape == (10, 2)
# ❌ 错误写法:对符号维度做 Python 条件判断(b > 2 对有的 b 成立、有的不成立)
def scale_if_large(x):
if x.shape[0] > 2: # 追踪时要求一个确定的 Python bool
return x * 2.0
return x
try:
export.export(jax.jit(scale_if_large))(x_poly)
print("实测:可以导出")
except Exception as e:
err_cmp = f"{type(e).__name__}: {e}"
print("实测报错:", err_cmp.split("\n")[0])
assert err_cmp.startswith("InconclusiveDimensionOperation") and "'b' > '2' is inconclusive" in err_cmp
# ✅ 正确写法:用 constraints 声明 b >= 3,比较就有了确定结论;调用时约束会被检查
x_poly_ge3 = jax.ShapeDtypeStruct(export.symbolic_shape("b, 4", constraints=("b >= 3",)), jnp.float32)
exp_ge3 = export.export(jax.jit(scale_if_large))(x_poly_ge3)
assert max_diff(exp_ge3.call(jnp.ones((5, 4))), 2.0 * jnp.ones((5, 4))) == 0.0
try:
exp_ge3.call(jnp.ones((2, 4))) # b = 2 违反约束
except ValueError as e:
err_ge3 = str(e)
print("b = 2 调用时报错:", err_ge3[:110], "...")
assert "do not match the symbolic shape constraint b >= 3" in err_ge3
print("声明约束后可以导出;违反约束的调用被 shape_assertion 拦下 ✓")
陷阱:floordiv 能导出,调用时却在"形状细化"阶段失败¶
如果函数里写 x.reshape(x.shape[0] // 2, 2, 4),导出会成功,输出形状是 floordiv(b, 2)。但这个 reshape 只有在 b 是偶数时才合法:传入 b = 3,元素数 12 和 1×2×4 = 8 对不上。导出阶段没法发现这一点,要等到调用、做形状细化时才报一个不太好懂的 Error refining shapes。
正确做法是把假设写进形状规格:用 "2*k, 4" 声明第 0 维一定是偶数。这样 x.shape[0] // 2 化简成 k,调用时传入奇数会得到清晰的报错 Division had remainder 1 when computing the value of 'k'。
# ❌ 错误写法:用 floordiv(b, 2) 隐含"b 是偶数"的假设——能导出,奇数 b 调用时在形状细化阶段失败
half_reshape = lambda x: x.reshape(x.shape[0] // 2, 2, 4)
exp_floor = export.export(jax.jit(half_reshape))(x_poly)
print("导出成功,输出形状:", exp_floor.out_avals[0].shape)
print("b = 4 调用:", exp_floor.call(jnp.ones((4, 4))).shape)
try:
exp_floor.call(jnp.ones((3, 4)))
print("b = 3:可以运行")
except ValueError as e:
err_refine = str(e)
print("b = 3 调用时报错:ValueError:", err_refine)
assert str(exp_floor.out_avals[0].shape[0]) == "floordiv(b, 2)"
assert "Error refining shapes" in err_refine
# ✅ 正确写法:把"偶数"写进形状规格 "2*k, 4";b // 2 化简为 k,奇数输入在调用时被清楚地拒绝
x_even = jax.ShapeDtypeStruct(export.symbolic_shape("2*k, 4"), jnp.float32)
exp_even = export.export(jax.jit(half_reshape))(x_even)
print("输入规格:", exp_even.in_avals[0].shape, "→ 输出形状:", exp_even.out_avals[0].shape)
assert exp_even.call(jnp.ones((6, 4))).shape == (3, 2, 4)
try:
exp_even.call(jnp.ones((5, 4)))
except ValueError as e:
err_even = str(e)
print("b = 5 调用时报错:", err_even[:150], "...")
assert str(exp_even.out_avals[0].shape[0]) == "k"
assert "Division had remainder 1 when computing the value of 'k'" in err_even
多个参数共享符号维度:作用域¶
几个参数的符号维度需要"是同一个 b"时(比如 x: (b, 4) 和 mask: (b,)),这些符号必须来自同一个作用域(SymbolicScope,它还保存着约束)。分别调用两次 symbolic_shape 会创建两个作用域,导出时报错。
有两种写法:一次 symbolic_shape 把形状都拆出来,或者用 export.symbolic_args_specs(args, specs) 按"示例参数 + 规格字符串"批量生成。规格里 _ 表示"沿用示例参数的这一维"。
mask_fn = lambda x, m: x * m[:, None]
# ❌ 错误写法:两次独立调用 symbolic_shape,两个 "b" 属于不同的作用域
try:
spec_x = jax.ShapeDtypeStruct(export.symbolic_shape("b, 4"), jnp.float32)
spec_m = jax.ShapeDtypeStruct(export.symbolic_shape("b"), jnp.float32)
export.export(jax.jit(mask_fn))(spec_x, spec_m)
print("实测:可以导出")
except ValueError as e:
err_scope = str(e)
print("实测报错:ValueError:", err_scope.split("\n")[0])
assert "Invalid mixing of symbolic scopes" in err_scope
# ✅ 正确写法:symbolic_args_specs 用同一个作用域生成所有参数的规格("_" 表示沿用示例参数的维度)
specs = export.symbolic_args_specs((jnp.ones((2, 4)), jnp.ones((2,))), ("b, _", "b"))
print("生成的规格:", specs)
exp_mask = export.export(jax.jit(mask_fn))(*specs)
xm, mm = jnp.arange(12.).reshape(3, 4), jnp.array([1., 0., 2.])
assert max_diff(exp_mask.call(xm, mm), mask_fn(xm, mm)) == 0.0
try:
exp_mask.call(jnp.ones((3, 4)), jnp.ones(5)) # 两个参数的 b 不一致
except ValueError as e:
print("b 不一致时报错:", str(e)[:120], "...")
assert "Found inconsistency between dimension size args[1].shape[0] (= 5)" in str(e)
🔍 补充:符号维度的几条规则¶
⚪ 选读:多态导出遇到「解不出维度变量」、相等比较意外为 False 时
- 只能从输入形状"解"出维度变量:JAX 只解一元线性方程。规格
"a + b, 4"无法从一个数解出a和b,报Cannot solve for values of dimension variables;"2*k"、"k + 1"都可以。 - 相等比较是"所有取值下都相等":
b == 1判为False(因为不总成立),文档里明确说这一点"不完全可靠"。依赖x.shape[0] == 1做广播特判的代码,在多态导出下会走False分支,需要自己检查。 - 调试:形状细化失败时,设置环境变量
JAX_DUMP_IR_TO=<目录>可以把细化前后的模块写出来;报错里的shape_assertion信息会指明是哪个参数的哪一维。 - 数据相关的形状(比如
jnp.nonzero(x)不带size=)在jit里本来就不允许,导出更不行。符号维度只解决"由输入形状决定的形状"。
# 实测上面的第一、二条
try:
a_, b_ = export.symbolic_shape("a, b")
export.export(jax.jit(lambda x: x))(jax.ShapeDtypeStruct((a_ + b_, 4), jnp.float32))
except ValueError as e:
print("规格 a+b:", str(e)[:105], "...")
assert "Cannot solve for values of dimension variables" in str(e)
b_sym, = export.symbolic_shape("b")
print("b == 1 →", b_sym == 1, "| b >= 1 →", b_sym >= 1)
assert (b_sym == 1) is False and (b_sym >= 1) is True
§2.4 多平台导出¶
⚪ 选读:同一份产物要在多种硬件上运行时
export.export(jax.jit(f), platforms=("cpu", "cuda", "tpu")) 为每个平台分别做 lowering,再把它们合并进同一个 StableHLO 模块:@main 多出一个整数参数 platform_index,模块内部按它分派到对应平台的实现。调用时 JAX 根据当前设备自动填入这个参数,用户感觉不到。
⚠️ 本节只做 lowering,没有在 GPU/TPU 上执行。 本机只有 CPU;lowering 不需要对应的硬件,所以
cuda、tpu版本的程序可以生成、序列化,但只有cpu那一份在本机真正运行过。
为什么要多平台导出:训练在 TPU 上、推理在 GPU 上,或者同一个产物要同时发给几种硬件的服务。大多数算子的 lowering 在各平台上相同,所以多平台模块只比单平台大一点。差异主要来自少数平台相关的 lowering,比如某些随机数、线性代数或排序的实现。
# 只做 lowering,未在 GPU/TPU 执行
exp_cpu = export.export(jax.jit(mlp_apply))(sds(params), x_poly)
exp_mp = export.export(jax.jit(mlp_apply), platforms=("cpu", "cuda", "tpu"))(sds(params), x_poly)
print("platforms:", exp_mp.platforms)
print("多平台 @main 签名开头:", main_signature(exp_mp)[:110], "...")
size_cpu, size_mp = len(exp_cpu.serialize()), len(exp_mp.serialize())
print(f"序列化大小:仅 cpu {size_cpu:,} 字节;cpu+cuda+tpu {size_mp:,} 字节(多 {size_mp / size_cpu - 1:.0%})")
assert exp_mp.platforms == ("cpu", "cuda", "tpu")
assert "platform_index" in main_signature(exp_mp) and "platform_index" not in main_signature(exp_cpu)
assert size_mp < 2 * size_cpu # 共享的部分只存一份
# 在本机(CPU)调用:自动选中 cpu 分支,结果与原函数一致
xb = jax.random.normal(jax.random.key(7), (6, 4))
assert max_diff(export.deserialize(exp_mp.serialize()).call(params, xb), mlp_apply(params, xb)) < 1e-5
print("多平台产物在 CPU 上调用(自动选择 cpu 分支)结果正确 ✓")
# ❌ 错误写法:只为 cuda 导出,却拿到 CPU 上调用
exp_cuda = export.export(jax.jit(mlp_apply), platforms=("cuda",))(sds(params), x_poly) # 只做 lowering
try:
exp_cuda.call(params, xb)
print("实测:可以运行")
except ValueError as e:
err_plat = str(e)
print("实测报错:ValueError:", err_plat)
assert "was exported for platforms '('cuda',)' but it is used on '('cpu',)'" in err_plat
# ✅ 正确写法:platforms 里包含所有会运行它的平台(包括本地开发 / 测试用的 cpu)
exp_cuda_cpu = export.export(jax.jit(mlp_apply), platforms=("cuda", "cpu"))(sds(params), x_poly)
assert max_diff(exp_cuda_cpu.call(params, xb), mlp_apply(params, xb)) < 1e-5
print("platforms=('cuda', 'cpu'):CPU 上可直接调用;cuda 分支只做了 lowering ✓")
§2.5 导出可求导的函数:vjp_order¶
⚪ 选读:加载端需要对导出函数求导、或想知道
Exported.call能配合哪些变换时
在同一个进程里,Exported 记得怎么生成自己的反向函数(has_vjp() 为 True),所以 jax.grad(exp.call) 直接可用:JAX 会在需要时导出 VJP 函数。
但序列化时默认不保存反向函数:serialize(vjp_order=0)。读回来的对象只能做前向,对它求导会报 ValueError: No VJP is available。需要在加载端求导(比如拿导出的模型做微调、做对抗样本、算输入梯度做可解释性分析),就要在导出端声明 serialize(vjp_order=k):它会把 1 到 k 阶的 VJP 函数一起导出并序列化,k 阶以内的反向模式导数都能用。
0.12 中 Exported.serialize 的签名是 serialize(self, vjp_order: int = 0) -> bytearray(已用 inspect.signature 确认)。
import inspect
print("Exported.serialize 签名:", inspect.signature(export.Exported.serialize))
assert "vjp_order" in str(inspect.signature(export.Exported.serialize))
def mse_loss(params, x, y):
return jnp.mean((mlp_apply(params, x) - y) ** 2)
xg = jax.random.normal(jax.random.key(10), (16, 4))
yg = jax.random.normal(jax.random.key(11), (16, 3))
exp_loss = export.export(jax.jit(mse_loss))(sds(params), sds(xg), sds(yg))
print("同一进程内 has_vjp():", exp_loss.has_vjp())
g_ref = jax.grad(mse_loss)(params, xg, yg)
g_exp = jax.grad(exp_loss.call)(params, xg, yg) # 对导出函数求导,梯度仍是字典
print("梯度结构:", jax.tree.map(jnp.shape, g_exp))
assert exp_loss.has_vjp() and max_diff(g_exp, g_ref) < 1e-6
vjp_exp = exp_loss.vjp() # 反向函数本身也是一个 Exported
print("vjp() 的输入:", len(vjp_exp.in_avals), "个(原输入 6 个叶子 + 输出余切 1 个);输出:", len(vjp_exp.out_avals), "个")
assert len(vjp_exp.in_avals) == 7 and len(vjp_exp.out_avals) == 6
# ❌ 错误写法:用默认的 serialize()(vjp_order=0)保存,读回后再求导
loaded0 = export.deserialize(exp_loss.serialize())
try:
jax.grad(loaded0.call)(params, xg, yg)
print("实测:可以求导")
except ValueError as e:
err_vjp0 = str(e)
print("实测报错:ValueError:", err_vjp0, "| has_vjp() =", loaded0.has_vjp())
assert err_vjp0 == "No VJP is available" and not loaded0.has_vjp()
# ✅ 正确写法:导出端声明需要的阶数 vjp_order=k,k 阶以内的反向导数都可用
sizes = {k: len(exp_loss.serialize(vjp_order=k)) for k in (0, 1, 2)}
print("序列化大小(字节):", sizes)
assert sizes[0] < sizes[1] < sizes[2]
loaded1 = export.deserialize(exp_loss.serialize(vjp_order=1))
assert max_diff(jax.grad(loaded1.call)(params, xg, yg), g_ref) < 1e-6 # 一阶 OK
def grad_norm2(load): # 二阶:对"梯度范数平方"再求导
return lambda p: sum(jnp.sum(g ** 2) for g in jax.tree.leaves(jax.grad(load)(p, xg, yg)))
try:
jax.grad(grad_norm2(loaded1.call))(params)
except ValueError as e:
print("vjp_order=1 时求二阶导:ValueError:", e)
assert str(e) == "No VJP is available"
loaded2 = export.deserialize(exp_loss.serialize(vjp_order=2))
gg_ref = jax.grad(grad_norm2(mse_loss))(params)
gg_exp = jax.grad(grad_norm2(loaded2.call))(params)
assert max_diff(gg_exp, gg_ref) < 1e-5
print("vjp_order=1:一阶导正确、二阶导报错;vjp_order=2:二阶导与原函数一致 ✓")
Exported.call 支持哪些变换¶
Exported.call 在 jaxpr 里是一个 call_exported 原语,外面包着一层 custom_vjp。由此能推出它和各种变换怎么配合(下面逐项验证):
| 变换 | 结果 | 原因 |
|---|---|---|
jax.jit | ✅ 可以,而且可以嵌进更大的 jit 函数 | 原语有 lowering 规则,内联进外层模块 |
jax.grad / jax.vjp | ✅(同进程,或 vjp_order ≥ 1) | 走 custom_vjp 的反向函数 |
jax.jvp / jax.jacfwd | ❌ TypeError | custom_vjp 函数不支持前向模式 |
jax.vmap | ❌ NotImplementedError | call_exported 没有 batching 规则 |
需要批处理时,在导出前就把批维度做进程序:要么用符号 batch 维度(§2.3),要么导出 jax.vmap(f) 本身。
# ❌ 错误写法:对 Exported.call 做 vmap / jvp
x_single = jax.random.normal(jax.random.key(12), (2, 4))
x_stack = jax.random.normal(jax.random.key(13), (5, 2, 4)) # 5 组,每组 (2, 4)
errs_tf = {}
for name, fn in [("vmap", lambda: jax.vmap(exp.call, in_axes=(None, 0))(params, x_stack)),
("jvp", lambda: jax.jvp(lambda x: exp.call(params, x), (x_single,), (x_single,)))]:
try:
fn()
errs_tf[name] = "可以运行"
except Exception as e:
errs_tf[name] = f"{type(e).__name__}: {e}"
print(f"{name}: {errs_tf[name]}")
assert errs_tf["vmap"].startswith("NotImplementedError: Batching rule for 'call_exported' not implemented")
assert errs_tf["jvp"].startswith("TypeError") and "custom_vjp" in errs_tf["jvp"]
# ✅ 正确写法:在导出前把批维度做进程序——导出 vmap 之后的函数(这里再加一个符号维度 n)
batched = jax.vmap(mlp_apply, in_axes=(None, 0))
x_stack_spec = jax.ShapeDtypeStruct(export.symbolic_shape("n, 2, 4"), jnp.float32)
exp_batched = export.export(jax.jit(batched))(sds(params), x_stack_spec)
assert max_diff(exp_batched.call(params, x_stack), batched(params, x_stack)) < 1e-5
# jit 可以嵌套:导出的函数可以作为更大 jit 函数的一部分(前后处理用 jnp 写)
@jax.jit
def predict_labels(params, x):
return jnp.argmax(exp_poly.call(params, x), axis=-1)
assert bool(jnp.all(predict_labels(params, xb) == jnp.argmax(mlp_apply(params, xb), -1)))
print("导出 vmap(f) 代替 vmap(exp.call) ✓;Exported.call 可以嵌进外层 jit ✓")
jaxpr_txt = str(jax.make_jaxpr(predict_labels)(params, xb))
print("外层 jaxpr 里含 call_exported 原语:", "call_exported" in jaxpr_txt)
assert "call_exported" in jaxpr_txt
§2.6 版本兼容性:三层版本号¶
🟢 必读
导出产物要在"导出它的 JAX"之外的环境里运行,所以兼容性是核心问题。涉及三层版本:
| 层 | 是什么 | 本机实测值 |
|---|---|---|
| 序列化格式版本 | FlatBuffer 外壳(签名、PyTree、分片等元数据)的格式版本,JAX 内部常量 _SERIALIZATION_VERSION | 12(见下) |
| 调用约定版本 | @main 的参数约定:维度变量、platform_index、token、效果、分片属性等怎么传;Exported.calling_convention_version | 默认 10,支持 9–10 |
| StableHLO 版本 | 程序本体(可移植字节码)的 opset 版本;JAX 序列化时以"至少 4 周前的版本"为目标 | 见下 |
官方承诺(依据 JAX 文档 Compatibility guarantees 与 StableHLO compatibility.md):
- 向后兼容 6 个月:用旧 JAX 导出、序列化的产物,可以被之后 6 个月内发布的 jaxlib 加载并运行,数值与旧版本一致(以 jaxlib 发布时间计)。超出窗口的产物默认拒绝加载,可以临时用
--jax_export_deserialize_expired_versions=1放行。 - 向前兼容 3 周:新 JAX 导出的产物,可以被3 周前的 jaxlib 运行,前提是没用到更新的功能。
- StableHLO 本身承诺可移植产物 5 年向后、2 年向前兼容;JAX 在此基础上收紧成上面的 6 个月 / 3 周,因为它还依赖调用约定和 custom call。
- custom call 不在保证范围内:模块里出现的
stablehlo.custom_call目标,只有 JAX 明确列为"稳定"的那一批(如 LAPACK、shape_assertion,以及 Pallas TPU 的tpu_custom_call、Mosaic GPU 的mosaic_gpu_v2)才允许序列化;其他目标在导出时报Cannot serialize code with custom calls whose targets have no compatibility guarantees,确认风险后可用disabled_checks=[export.DisabledSafetyCheck.custom_call("目标名")]放行。 - 只对序列化产物承诺:
mlir_module()打印的文本、jit(f).lower().as_text()都没有兼容性保证,不能当交付格式。 - 反序列化的输入必须可信:官方文档明确写着 "The serialized bytearray must be trusted input"。
import jax._src.export.serialization as _ser # 私有模块,这里只读取一个常量来查看,不要在代码里依赖它
from jax.extend.mlir.dialects import stablehlo as shlo
print("序列化格式版本 _SERIALIZATION_VERSION =", _ser._SERIALIZATION_VERSION)
print("调用约定:默认", jax.config.jax_export_calling_convention_version,
"| 支持范围", export.minimum_supported_calling_convention_version, "–", export.maximum_supported_calling_convention_version)
target = shlo.get_version_from_compatibility_requirement(shlo.StablehloCompatibilityRequirement.WEEK_4)
print("StableHLO:当前版本", shlo.get_current_version(), "| 最低可读版本", shlo.get_minimum_version(), "| 序列化目标版本(≥4 周前)", target)
bc = exp.mlir_module_serialized
print("可移植字节码文件头:", bc[:4], "(MLIR bytecode 魔数 'ML\\xefR')")
assert isinstance(_ser._SERIALIZATION_VERSION, int) and _ser._SERIALIZATION_VERSION >= 12
assert export.minimum_supported_calling_convention_version <= exp.calling_convention_version <= export.maximum_supported_calling_convention_version
assert exp.calling_convention_version == jax.config.jax_export_calling_convention_version == 10
ver = lambda s: tuple(int(t) for t in s.split("."))
assert ver(shlo.get_minimum_version()) <= ver(target) <= ver(shlo.get_current_version())
assert bc[:4] == b"ML\xefR"
用旧的调用约定导出:需要交给稍旧的消费端时,可以通过配置 jax_export_calling_convention_version(环境变量 JAX_EXPORT_CALLING_CONVENTION_VERSION)选择较低的调用约定。下面在子进程里用版本 9 导出、序列化,再在当前进程(默认 10)里加载运行,验证"新 JAX 能读旧约定的产物"。
cc9_path = SCRATCH / "mlp_cc9.jaxexport"
out = run_py("""
import sys, jax, jax.numpy as jnp
from jax import export
w = jax.ShapeDtypeStruct((4, 3), jnp.float32)
x = jax.ShapeDtypeStruct(export.symbolic_shape("b, 4"), jnp.float32)
e = export.export(jax.jit(lambda w, x: jnp.tanh(x @ w)))(w, x)
open(sys.argv[1], "wb").write(e.serialize())
print("CC", e.calling_convention_version)
""", cc9_path, env_extra={"JAX_EXPORT_CALLING_CONVENTION_VERSION": "9"})
print("子进程输出:", out.strip())
e9 = export.deserialize(cc9_path.read_bytes())
w9 = jax.random.normal(jax.random.key(3), (4, 3))
x9 = jax.random.normal(jax.random.key(4), (5, 4))
print("当前进程读回:calling_convention_version =", e9.calling_convention_version)
assert out.strip() == "CC 9" and e9.calling_convention_version == 9
assert max_diff(e9.call(w9, x9), jnp.tanh(x9 @ w9)) < 1e-6
print("版本 9 的产物在默认版本 10 的环境里正常运行 ✓")
🔍 补充:把兼容性做成工程流程¶
⚪ 选读:要把导出产物纳入 CI、长期存档或带自定义 kernel 导出时
- 把产物和导出它的版本记在一起:在文件名或旁边的 JSON 里写上
jax.__version__、导出日期、calling_convention_version、platforms。超过 6 个月的产物,用新版本重新导出,不要指望deserialize_expired_versions。 - 回归测试:把"黄金输入 → 黄金输出"和导出产物一起存档。每次升级 JAX,加载旧产物跑一遍,同时用新 JAX 重新导出跑一遍,两者都要和黄金输出对拍。JAX 自己的
back_compat测试就是这样做的。 - custom call 是最大的风险点:自己注册的 FFI 目标(进阶/05)和 Pallas 的 Triton 后端(
__gpu$xla.gpu.triton)都不在稳定名单里,导出时要显式DisabledSafetyCheck.custom_call(...),消费端还必须注册同名目标;pure_callback根本无法序列化(§4.4)。Pallas 的 TPU kernel(tpu_custom_call)和 Mosaic GPU kernel(mosaic_gpu_v2)在 0.12 中已列入稳定名单,可以直接序列化(下面验证,只做 lowering)。
# 实测:Pallas TPU kernel 不需要 DisabledSafetyCheck,自定义 FFI 目标需要(只做 lowering,未在 TPU 上执行)
from jax.experimental import pallas as pl
from jax._src.export._export import _CUSTOM_CALL_TARGETS_GUARANTEED_STABLE as _STABLE # 私有常量,这里只读取查看
def _add_kernel(x_ref, y_ref, o_ref):
o_ref[...] = x_ref[...] + y_ref[...]
s8 = jax.ShapeDtypeStruct((8, 128), jnp.float32)
exp_pl = export.export(jax.jit(pl.pallas_call(_add_kernel, out_shape=s8)), platforms=("tpu",))(s8, s8)
blob_pl = exp_pl.serialize() # 没有传 disabled_checks
assert "tpu_custom_call" in exp_pl.mlir_module() and len(blob_pl) > 0
print("Pallas TPU kernel:导出 + 序列化成功(只做 lowering)")
ffi_fn = lambda x: jax.ffi.ffi_call("my_custom_target", jax.ShapeDtypeStruct(x.shape, x.dtype))(x)
try:
export.export(jax.jit(ffi_fn))(jnp.ones(3))
err_ffi = None
except ValueError as e:
err_ffi = str(e)
print("自定义 FFI 目标:ValueError:", err_ffi.splitlines()[0][:95], "...")
assert err_ffi is not None and "no compatibility guarantees" in err_ffi
exp_ffi = export.export(jax.jit(ffi_fn),
disabled_checks=[export.DisabledSafetyCheck.custom_call("my_custom_target")])(jnp.ones(3))
assert len(exp_ffi.serialize()) > 0
print("显式 DisabledSafetyCheck.custom_call 后可以导出(消费端必须注册同名目标才能运行)")
print("稳定名单:tpu_custom_call", "tpu_custom_call" in _STABLE, "| mosaic_gpu_v2", "mosaic_gpu_v2" in _STABLE,
"| __gpu$xla.gpu.triton", "__gpu$xla.gpu.triton" in _STABLE)
assert {"tpu_custom_call", "mosaic_gpu_v2"} <= _STABLE and "__gpu$xla.gpu.triton" not in _STABLE
§2.7 含分片、shard_map、pmap 的函数怎么导出¶
⚪ 选读:导出含分片、
shard_map的多设备函数时
导出带分片的函数时,Exported 会记录 nr_devices,以及输入输出在抽象 mesh(只有轴名和大小,没有具体设备)上的分片。规则如下(出自官方文档 Device-polymorphic export 一节;下面在子进程里用 --xla_force_host_platform_device_count=4 模拟 4 个 CPU 设备,逐条验证):
- 导出时
nr_devices = N的产物,只能在恰好 N 个设备的环境里调用,并且调用时要处在一个大小匹配的 mesh 里(jax.set_mesh)。具体用哪 N 个设备不重要,因为保存的是抽象 mesh。 - 单设备导出(
nr_devices = 1、没有分片注解)的产物是设备多态的:可以直接拿分片后的输入调用,JAX 会按输入分片编译。 shard_map可以导出,里面的集合通信(psum→all_reduce)原样保留在 StableHLO 中。pmap不是jit的结果,不能直接传给export.export;包一层jax.jit(jax.pmap(...))虽然能导出,但pmap已经是旧接口,新代码应该改用jit+ 分片或shard_map(迁移方法见 进阶/06 §6.4 补充)。
shard_src = """
import os, sys
import numpy as np, jax, jax.numpy as jnp
from jax import export, lax
from jax.sharding import PartitionSpec as P
assert jax.device_count() == 4
def check(name, ok, info=""):
print(("PASS " if ok else "FAIL ") + name, info)
f = lambda w, x: jnp.tanh(x @ w)
w = jnp.full((4, 3), 0.1); x = jnp.arange(32.).reshape(8, 4)
mesh = jax.make_mesh((4,), ("data",))
with jax.set_mesh(mesh):
xs, ws = jax.device_put(x, P("data")), jax.device_put(w, P())
e = export.export(jax.jit(f))(ws, xs) # 输入按 batch 维分到 4 个设备
check("1 nr_devices==4", e.nr_devices == 4, e.nr_devices)
check("1 记录输入分片", str(e.in_shardings_jax(mesh)[1].spec) == str(P("data")), e.in_shardings_jax(mesh)[1].spec)
y = e.call(ws, xs)
check("1 同 mesh 调用正确", np.allclose(y, f(w, x), atol=1e-6), y.sharding.spec)
blob = e.serialize()
e2 = export.deserialize(blob)
try: # 不在 mesh 里调用
e2.call(w, x); check("1 无 mesh 调用应失败", False)
except ValueError as err:
check("1 无 mesh 调用 → ValueError", "is not equal to the size of the mesh 4" in str(err), str(err)[:90])
mesh2 = jax.make_mesh((2,), ("data",), devices=jax.devices()[:2])
try: # 只用 2 个设备的 mesh
with jax.set_mesh(mesh2):
e2.call(jax.device_put(w, P()), jax.device_put(x, P("data")))
check("1 2 设备调用应失败", False)
except ValueError as err:
check("1 2 设备 mesh 调用 → ValueError", "Length of device assignment 2" in str(err), str(err)[:90])
mesh_rev = jax.make_mesh((4,), ("data",), devices=jax.devices()[::-1])
with jax.set_mesh(mesh_rev): # 同样 4 个设备、顺序不同
y = e2.call(jax.device_put(w, P()), jax.device_put(x, P("data")))
check("1 换一组 4 设备 mesh 调用正确", np.allclose(y, f(w, x), atol=1e-6))
e1 = export.export(jax.jit(f))(w, x) # 规则 2:单设备导出
with jax.set_mesh(mesh):
y = e1.call(ws, xs)
check("2 单设备产物接受分片输入", e1.nr_devices == 1 and np.allclose(y, f(w, x), atol=1e-6), y.sharding.spec)
g = lambda v: lax.psum(jnp.sum(v), "data") # 规则 3:shard_map
with jax.set_mesh(mesh):
sm = jax.jit(jax.shard_map(g, in_specs=P("data"), out_specs=P()))
e3 = export.export(sm)(xs)
txt = e3.mlir_module()
check("3 shard_map 可导出且含 all_reduce", e3.nr_devices == 4 and "all_reduce" in txt)
check("3 shard_map 结果正确", float(e3.call(xs)) == float(x.sum()), float(e3.call(xs)))
try: # 规则 4:pmap
export.export(jax.pmap(lambda v: v * 2))(jnp.ones((4, 3))); check("4 直接导出 pmap 应失败", False)
except ValueError as err:
check("4 直接导出 pmap → ValueError", "must be the result of `jit`" in str(err))
ep = export.export(jax.jit(jax.pmap(lambda v: v * 2)))(jnp.ones((4, 3)))
check("4 jit(pmap) 可导出", ep.nr_devices == 4, ep.nr_devices)
"""
out = run_py(shard_src, env_extra={"XLA_FLAGS": "--xla_force_host_platform_device_count=4"})
print(out)
lines = [l for l in out.splitlines() if l.startswith(("PASS", "FAIL"))]
assert len(lines) == 11 and all(l.startswith("PASS") for l in lines), lines
要点:
- 想让一个产物在不同规模的集群上都能用,最简单的办法是导出单设备版本(规则 2),让调用方决定怎么分片;代价是导出端无法固定通信模式。
- 需要固定分片方案(比如张量并行的大模型推理)时,导出时用
jax.make_mesh+jax.set_mesh确定 mesh 形状;调用方必须提供同样大小的 mesh。 - 多主机(多进程)场景下,导出产物本身不关心"进程",只关心全局设备数;加载端每个进程各自
deserialize,在同一个全局 mesh 里调用。
§3 Orbax 进阶:管理训练过程中的检查点¶
§3.1 基础回顾(详见进阶/06 §6.3)¶
⚪ 选读:已学过进阶/06 §6.3 的话,只需看「目标目录已存在」这个坑
进阶/06 §6.3 已经讲过:要保存的训练状态包括参数、优化器状态、步数、RNG key 和 BN 统计量;用 ocp.StandardCheckpointer().save(path, pytree) 保存,保存是异步的,读回前要 wait_until_finished();恢复时用 nnx.eval_shape 构造抽象模板,再 nnx.update 写回模型。这里只补一个那一章没提到的坑:目标目录已存在时,再次保存会报错。
ckpt_path = SCRATCH / "single_ckpt"
state0 = {"w": jnp.arange(6.).reshape(2, 3), "step": jnp.array(0)}
ckptr.save(ckpt_path, state0)
ckptr.wait_until_finished()
# ❌ 错误写法:对同一个目录再次 save(比如每个 epoch 覆盖同一个"最新检查点"目录)
try:
ckptr.save(ckpt_path, {"w": state0["w"] + 1, "step": jnp.array(1)})
ckptr.wait_until_finished()
print("实测:可以覆盖")
except ValueError as e:
err_exists = str(e)
print("实测报错:ValueError:", err_exists.replace(str(SCRATCH), "<SCRATCH>"))
assert "already exists" in err_exists
# ✅ 正确写法:确实要覆盖时显式传 force=True;需要多步保存时用 CheckpointManager(§3.2),每步一个子目录
state1 = {"w": state0["w"] + 1, "step": jnp.array(1)}
ckptr.save(ckpt_path, state1, force=True)
ckptr.wait_until_finished()
restored1 = ckptr.restore(ckpt_path, sds(state1))
assert int(restored1["step"]) == 1 and max_diff(restored1, state1) == 0.0
print("force=True 覆盖成功,读回的是新内容 ✓")
§3.2 CheckpointManager:多步保存与保留策略¶
🟢 必读
训练中通常每隔几步存一次,只保留最近几个,外加验证指标最好的几个。ocp.CheckpointManager(目录, options=ocp.CheckpointManagerOptions(...)) 负责这些事情:每个 step 一个子目录,自动清理旧的。常用选项:
| 选项 | 作用 |
|---|---|
max_to_keep=n | 最多保留 n 个检查点 |
save_interval_steps=k | 只在 step 是 k 的倍数时真正保存(save() 返回 False 表示这一步跳过了) |
best_fn=lambda metrics: ..., best_mode="min"/"max" | 按 save(..., metrics=...) 传入的指标挑"最好"的检查点 |
preservation_policy=... | 更灵活的保留策略组合(LatestN、BestN、EveryNSteps……) |
保存和恢复的写法是 mngr.save(step, args=ocp.args.StandardSave(pytree), metrics=...) 和 mngr.restore(step, args=ocp.args.StandardRestore(模板))。
mgr_dir = SCRATCH / "mgr_interval"
mngr = ocp.CheckpointManager(mgr_dir, options=ocp.CheckpointManagerOptions(max_to_keep=2, save_interval_steps=2))
saved_flags = [mngr.save(step, args=ocp.args.StandardSave({"w": jnp.ones(3) * step})) for step in range(7)]
mngr.wait_until_finished()
print("每步 save() 的返回值:", saved_flags)
print("磁盘上保留的步:", mngr.all_steps(), "| 子目录:", sorted(p.name for p in mgr_dir.iterdir()))
print("latest_step:", mngr.latest_step())
assert saved_flags == [True, False, True, False, True, False, True]
assert mngr.all_steps() == [4, 6] and mngr.latest_step() == 6
restored = mngr.restore(6, args=ocp.args.StandardRestore(jax.eval_shape(lambda: {"w": jnp.ones(3)})))
assert max_diff(restored, {"w": jnp.ones(3) * 6}) == 0.0
mngr.close()
按指标保留最佳:一个容易踩的坑¶
best_fn 配合 max_to_keep 时,max_to_keep 的含义变成"保留指标最好的 n 个"。最新的那一步如果指标不好,会被直接删掉,连 latest_step() 返回的都不再是真正最后保存的那一步,断点续训就没有"最新状态"可用了。
losses = [5.0, 1.0, 4.0, 2.0, 9.0] # 第 4 步(最新)的验证损失最差
# ❌ 错误写法:只靠 best_fn + max_to_keep,以为会"保留最好的 2 个 + 最新的"
try:
mngr = ocp.CheckpointManager(SCRATCH / "mgr_best", options=ocp.CheckpointManagerOptions(
max_to_keep=2, best_fn=lambda m: m["loss"], best_mode="min"))
for step, l in enumerate(losses):
mngr.save(step, args=ocp.args.StandardSave({"w": jnp.ones(3) * step}), metrics={"loss": l})
mngr.wait_until_finished()
print("实测:保留的步", mngr.all_steps(), "| best_step", mngr.best_step(), "| latest_step", mngr.latest_step())
kept_bad, latest_bad = mngr.all_steps(), mngr.latest_step()
mngr.close()
except Exception as e:
print("实测报错:", type(e).__name__, e)
assert kept_bad == [1, 3] and 4 not in kept_bad # 最新的第 4 步被删掉了
assert latest_bad == 3 # latest_step() 也"倒退"到了第 3 步
# ✅ 正确写法:用 preservation_policy 组合"最新 1 个"和"最好 2 个"
cm = ocp.checkpoint_managers
policy = cm.AnyPreservationPolicy([
cm.LatestN(n=1), # 断点续训用
cm.BestN(get_metric_fn=lambda m: m["loss"], reverse=True, n=2), # reverse=True:指标越小越好(实测)
])
mngr = ocp.CheckpointManager(SCRATCH / "mgr_policy", options=ocp.CheckpointManagerOptions(
preservation_policy=policy, best_fn=lambda m: m["loss"], best_mode="min"))
for step, l in enumerate(losses):
mngr.save(step, args=ocp.args.StandardSave({"w": jnp.ones(3) * step}), metrics={"loss": l})
mngr.wait_until_finished()
print("保留的步:", mngr.all_steps(), "| best_step:", mngr.best_step(), "| latest_step:", mngr.latest_step())
assert mngr.all_steps() == [1, 3, 4] and mngr.best_step() == 1 and mngr.latest_step() == 4
best = mngr.restore(mngr.best_step(), args=ocp.args.StandardRestore(jax.eval_shape(lambda: {"w": jnp.ones(3)})))
assert max_diff(best, {"w": jnp.ones(3) * 1}) == 0.0
mngr.close()
print("最新 1 个 + 最好 2 个都在;按 best_step 恢复得到第 1 步的权重 ✓")
BestN(reverse=...) 的方向容易记反。结论是:reverse=False 保留指标最大的 n 个,reverse=True 保留最小的 n 个。下面用同一组损失验证:
kept = {}
for rev in (False, True):
m_ = ocp.CheckpointManager(SCRATCH / f"mgr_rev_{rev}", options=ocp.CheckpointManagerOptions(
preservation_policy=cm.BestN(get_metric_fn=lambda m: m["loss"], reverse=rev, n=2)))
for step, l in enumerate(losses):
m_.save(step, args=ocp.args.StandardSave({"w": jnp.ones(3)}), metrics={"loss": l})
m_.wait_until_finished()
kept[rev] = m_.all_steps()
m_.close()
print("losses =", losses, "→ reverse=False 保留", kept[False], ";reverse=True 保留", kept[True])
assert kept[False] == [0, 4] and kept[True] == [1, 3]
§3.3 用 jax.eval_shape 得到抽象模板¶
⚪ 选读:恢复时要转换 dtype、或检查点与模型结构对不上时
恢复时的模板决定了恢复成什么样:PyTree 结构、每个叶子的形状、dtype、分片。模板只需要 jax.ShapeDtypeStruct,用 jax.eval_shape(init_fn, ...) 得到就行,它只追踪形状,不分配内存(NNX 模型用 nnx.eval_shape,见进阶/06)。
模板有三个实用的作用:
- dtype 转换:模板写
bfloat16,Orbax 恢复时直接转换(比如 fp32 训练、bf16 推理); - 分片:模板里带
sharding=NamedSharding(...),每个设备只读自己那一块(进阶/06 §6.3 补充); - 检查:形状或结构对不上时直接报错,而不是悄悄截断。
init_fn = lambda key: init_mlp(key)
abstract = jax.eval_shape(init_fn, jax.random.key(0)) # 不分配内存
print("抽象模板:", jax.tree.map(lambda s: f"{s.dtype}{list(s.shape)}", abstract))
ckptr.save(SCRATCH / "mlp_params", params)
ckptr.wait_until_finished()
restored_f32 = ckptr.restore(SCRATCH / "mlp_params", abstract)
assert max_diff(restored_f32, params) == 0.0
abstract_bf16 = jax.tree.map(lambda s: jax.ShapeDtypeStruct(s.shape, jnp.bfloat16), abstract)
restored_bf16 = ckptr.restore(SCRATCH / "mlp_params", abstract_bf16)
print("按 bf16 模板恢复:", {k: str(v.dtype) for k, v in restored_bf16.items()})
assert all(v.dtype == jnp.bfloat16 for v in restored_bf16.values())
assert max_diff(restored_bf16, params) < 0.02
print("用 eval_shape 模板恢复 fp32 逐位一致;按 bf16 模板恢复时自动转换 ✓")
# ❌ 错误写法:模板的形状与磁盘上的不一致(比如改了隐藏层宽度却加载旧检查点)
wrong = dict(abstract, w1=jax.ShapeDtypeStruct((4, 16), jnp.float32))
try:
ckptr.restore(SCRATCH / "mlp_params", wrong)
print("实测:可以恢复")
except ValueError as e:
err_shape = str(e)
print("实测报错:ValueError:", err_shape[:150], "...")
assert "is not compatible with the stored shape: (4, 8)" in err_shape
# ✅ 正确写法:先用 metadata() 查看磁盘上的结构和形状,再决定模板(或者确认要加载的是哪个版本的模型)
meta_tree = ckptr.metadata(SCRATCH / "mlp_params").item_metadata.tree
print("磁盘上的形状:", {k: tuple(v.shape) for k, v in meta_tree.items()})
assert {k: tuple(v.shape) for k, v in meta_tree.items()} == {k: v.shape for k, v in params.items()}
§3.4 与 Flax NNX 配合:保存、恢复、继续训练¶
🟢 必读
NNX 模块是可变对象,但 nnx.state(model) / nnx.split(model) 随时可以拿到纯 PyTree 的 State,Orbax 可以直接保存。恢复的流程:
nnx.eval_shape(lambda: 构造模型和优化器)得到抽象的模型和优化器;- 用它们的
nnx.state(...)作为 Orbax 的恢复模板; nnx.split(抽象模型)取出GraphDef(模型结构),再nnx.merge(graphdef, 恢复的状态)得到真正的模型;优化器用nnx.update写回状态。
要让续训和"一口气训完"逐位一致,所有影响训练轨迹的东西都必须进 checkpoint:参数、优化器状态(Adam 的 $m$、$v$ 和步数),还有数据的位置。下面的数据批次由 jax.random.fold_in(data_key, step) 生成,step 取自优化器状态,所以恢复了优化器就恢复了数据位置。
权重和数据都作为函数参数传入(nnx.jit 的参数),没有闭包常量,避开本机的 CPU bug。
class MLP(nnx.Module):
def __init__(self, din, dh, dout, *, rngs: nnx.Rngs):
self.fc1 = nnx.Linear(din, dh, rngs=rngs)
self.fc2 = nnx.Linear(dh, dh, rngs=rngs)
self.fc3 = nnx.Linear(dh, dout, rngs=rngs)
def __call__(self, x):
x = nnx.relu(self.fc1(x))
x = nnx.relu(self.fc2(x))
return self.fc3(x)
TEACHER = jax.random.normal(jax.random.key(100), (4, 3)) # 生成标签用的"教师"矩阵
def make_batch(data_key, step, teacher, n=128):
"""第 step 步的数据批次:由 (data_key, step) 决定,恢复了 step 就恢复了数据位置。"""
x = jax.random.normal(jax.random.fold_in(data_key, step), (n, 4))
y = jnp.argmax(jnp.sin(2.0 * x) @ teacher, axis=-1)
return x, y
def loss_fn(model, x, y):
return optax.softmax_cross_entropy_with_integer_labels(model(x), y).mean()
@nnx.jit
def train_step(model, optimizer, data_key, teacher):
x, y = make_batch(data_key, optimizer.step[...], teacher) # 数据位置取自优化器步数
loss, grads = nnx.value_and_grad(loss_fn)(model, x, y)
optimizer.update(model, grads)
return loss
TX = optax.adam(1e-2)
def make_model_and_opt():
model = MLP(4, 32, 3, rngs=nnx.Rngs(0))
return model, nnx.Optimizer(model, TX, wrt=nnx.Param)
DATA_KEY = jax.random.key(42)
N1, N2 = 30, 30
# (a) 一口气训练 N1 + N2 步,作为参照
model_ref, opt_ref = make_model_and_opt()
curve_ref = [float(train_step(model_ref, opt_ref, DATA_KEY, TEACHER)) for _ in range(N1 + N2)]
# (b) 训练 N1 步后保存
model_a, opt_a = make_model_and_opt()
curve_a = [float(train_step(model_a, opt_a, DATA_KEY, TEACHER)) for _ in range(N1)]
nnx_dir = SCRATCH / "nnx_ckpt"
mngr = ocp.CheckpointManager(nnx_dir, options=ocp.CheckpointManagerOptions(max_to_keep=3))
mngr.save(int(opt_a.step[...]), args=ocp.args.StandardSave({"model": nnx.state(model_a), "opt": nnx.state(opt_a)}),
metrics={"loss": curve_a[-1]})
mngr.wait_until_finished()
print("已保存的步:", mngr.all_steps(), "| State 类型:", type(nnx.state(model_a)).__name__)
assert mngr.all_steps() == [N1]
del model_a, opt_a # 模拟进程退出:内存里的模型没了
# (c) "新进程":从抽象模板恢复,merge 出模型,继续训练 N2 步
abs_model, abs_opt = nnx.eval_shape(make_model_and_opt) # 只有形状,不分配内存
template = {"model": nnx.state(abs_model), "opt": nnx.state(abs_opt)}
restored = mngr.restore(mngr.latest_step(), args=ocp.args.StandardRestore(template))
mngr.close()
graphdef, _ = nnx.split(abs_model) # 模型结构
model_b = nnx.merge(graphdef, restored["model"]) # 结构 + 恢复的参数
opt_b = nnx.Optimizer(model_b, TX, wrt=nnx.Param)
nnx.update(opt_b, restored["opt"]) # Adam 的 m、v、step
print("恢复后 optimizer.step =", int(opt_b.step[...]))
assert int(opt_b.step[...]) == N1
curve_b = [float(train_step(model_b, opt_b, DATA_KEY, TEACHER)) for _ in range(N2)]
curve_resumed = curve_a + curve_b
gap = float(np.max(np.abs(np.array(curve_resumed) - np.array(curve_ref))))
print(f"续训曲线与一口气训练的曲线最大差: {gap:.1e}")
print(" 第", N1 - 1, "步(保存前最后一步):", round(curve_ref[N1 - 1], 6), "| 续训第一步:", round(curve_b[0], 6),
"| 参照的第", N1, "步:", round(curve_ref[N1], 6))
assert gap == 0.0 # 逐位一致:曲线无缝衔接
assert max_diff(nnx.state(model_b, nnx.Param), nnx.state(model_ref, nnx.Param)) == 0.0
assert curve_ref[-1] < 0.7 * curve_ref[0] # 训练确实在进行
print("保存 → 恢复 → 续训:loss 曲线与不中断训练逐位一致,最终参数也逐位一致 ✓")
# ❌ 错误写法:只保存参数、不保存优化器状态——恢复后重新建一个 Optimizer(Adam 的 m、v、step 全部归零)
try:
model_c = nnx.merge(graphdef, restored["model"])
opt_c = nnx.Optimizer(model_c, TX, wrt=nnx.Param) # 没有 nnx.update(opt_c, ...)
curve_c = curve_a + [float(train_step(model_c, opt_c, DATA_KEY, TEACHER)) for _ in range(N2)]
gap_c = float(np.max(np.abs(np.array(curve_c) - np.array(curve_ref))))
print(f"实测:可以运行,但曲线与参照的最大差 = {gap_c:.3f};续训第一步 step = 0,数据也回到了第 0 批")
except Exception as e:
print("实测报错:", type(e).__name__, e)
assert gap_c > 1e-3
# ✅ 正确写法:参数、优化器状态(含步数)一起保存和恢复——即上面 (b)(c) 的做法
assert int(opt_b.step[...]) == N1 + N2 and gap == 0.0
print("优化器状态随 checkpoint 一起恢复,step、Adam 矩和数据位置都接得上 ✓")
🔍 补充:检查点里还应该有什么¶
⚪ 选读:设计生产级训练检查点(RNG、数据管线、异步保存)时
- RNG:模型里如果有 dropout,
nnx.Rngs的计数器也是nnx.Variable(nnx.RngState),nnx.state(model)会把它一起带上,恢复后随机数序列也能接上。用nnx.state(model, nnx.Param)只存参数时会丢掉它。 - 数据管线位置:真实训练里数据来自文件,这里的
fold_in(step)技巧不适用;Grain 等数据加载器提供可以存档的迭代器状态,应作为另一个 item 一起存(ocp.args.Composite(state=..., data=...))。 - 异步保存与计算重叠:
mngr.save立刻返回,后台线程写盘,训练继续。程序退出前一定要mngr.wait_until_finished()或mngr.close(),否则最后一个检查点可能只写了一半(Orbax 用临时目录加原子重命名,保证不会读到半成品,但这一步就丢了)。 - 推理只需要参数:发布模型时另存一份只含
nnx.Param的"推理检查点"(可以转 bf16),比完整训练状态小得多:Adam 状态是参数的 2 倍。§5 就是这样做的。
§4 部署出口概览¶
jax.export 的产物是 StableHLO。它可以走三条路:
┌─► jax.export.deserialize(...).call() Python + jaxlib(本章主线,§5)
Exported ──序列化─┤
(StableHLO) ├─► mlir_module_serialized ─► C++ 运行时:PJRT 插件 / IREE / 自研引擎
└─► jax2tf ─► TF SavedModel ─► TF Serving / TFLite(需要 TensorFlow)
§4.1 StableHLO 文本 / 字节码 → C++ 运行时(IREE、PJRT)¶
⚪ 选读:要把模型交给 C++ 运行时(PJRT、IREE)时
- 两种形态:
exp.mlir_module()是给人看的文本(.mlir);exp.mlir_module_serialized是 StableHLO 可移植产物(portable artifact,MLIR bytecode,文件头ML\xefR),只有它享有 StableHLO 的兼容性承诺。 - PJRT:OpenXLA 定义的统一设备 C API(
pjrt_c_api.h)。每种硬件以 PJRT 插件的形式实现它,框架通过它编译、执行程序。JAX 自己就是一个 PJRT 客户端:本机的 CPU 后端、CUDA 插件、TPU 的libtpu都是 PJRT 实现。C++ 程序可以直接加载 PJRT 插件,把 StableHLO 交给它编译执行,完全不需要 Python。 - IREE:OpenXLA 旗下的 MLIR 编译器与运行时,可以把 StableHLO 编译成独立的
.vmfb模块,在服务器、移动端、嵌入式(CPU/Vulkan/Metal/CUDA 等后端)上运行,典型命令是iree-compile --iree-input-type=stablehlo ...。
⚠️ 未在本机执行:本机没有安装 IREE 或独立的 PJRT C++ 工程,下面只生成并检查交给它们的文件。
交给 C++ 运行时的注意事项:
- 最好导出固定形状。多态模块里有
?维度和@shape_assertioncustom call,形状细化是 JAX 在调用时做的;C++ 消费端要么自己实现细化,要么接收已固定形状的版本。下面对比一下两种模块的区别。 - 权重作为参数:C++ 端从自己的权重文件(或 Orbax 存的 TensorStore/Zarr)加载权重,按
in_avals的展平顺序传入。所以要把in_tree/in_avals的顺序和参数名一起写进元数据(§5 的meta.json)。 - 多平台模块的第一个参数是
platform_index,C++ 端要自己传。
exp_static = export.export(jax.jit(mlp_apply))(sds(params), jax.ShapeDtypeStruct((8, 4), jnp.float32))
mlir_txt_path = SCRATCH / "mlp_static_b8.mlir"
mlir_bc_path = SCRATCH / "mlp_static_b8.mlirbc"
mlir_txt_path.write_text(exp_static.mlir_module())
mlir_bc_path.write_bytes(exp_static.mlir_module_serialized)
print(f"文本 {mlir_txt_path.name}: {mlir_txt_path.stat().st_size:,} 字节;字节码 {mlir_bc_path.name}: {mlir_bc_path.stat().st_size:,} 字节")
print("固定形状 @main:", main_signature(exp_static)[:160])
def has_dynamic(m):
return bool(re.search(r"tensor<\?", m)) or "shape_assertion" in m
print("固定形状模块含动态维度 / shape_assertion:", has_dynamic(exp_static.mlir_module()))
print("多态模块含动态维度 / shape_assertion: ", has_dynamic(exp_poly.mlir_module()))
assert not has_dynamic(exp_static.mlir_module()) and has_dynamic(exp_poly.mlir_module())
assert mlir_bc_path.read_bytes()[:4] == b"ML\xefR"
# 文本由字节码反序列化得到:读回的 Exported 里只有字节码,mlir_module() 会把它解析回文本
# (C++ 侧对应 stablehlo-translate --deserialize)
text_from_bc = export.deserialize(exp_static.serialize()).mlir_module()
assert text_from_bc == mlir_txt_path.read_text()
print("从字节码恢复的 StableHLO 文本与直接打印的完全相同 ✓")
§4.2 jax2tf 的现状¶
⚪ 选读:必须产出 TF SavedModel 时
先看一下:jax.experimental.jax2tf 在 0.12 里仍然存在(模块文件还在),但 import 时就需要 TensorFlow。本环境没有安装 TF,也不打算装,所以这里只说明现状:
spec_j2t = importlib.util.find_spec("jax.experimental.jax2tf")
print("jax.experimental.jax2tf 模块存在:", spec_j2t is not None, "→", Path(spec_j2t.origin).relative_to(Path(jax.__file__).parent.parent))
print("本环境装有 tensorflow:", importlib.util.find_spec("tensorflow") is not None)
try:
from jax.experimental import jax2tf
print("实测:import 成功")
except ModuleNotFoundError as e:
err_j2t = str(e)
print("实测:import jax2tf → ModuleNotFoundError:", err_j2t)
assert spec_j2t is not None and err_j2t == "No module named 'tensorflow'"
现状(依据 jax2tf README 与 JAX 导出文档):
- 从 JAX 0.4.14(2023 年 7 月)起,
jax2tf.convert的默认模式是 native serialization:它内部就是用jax.export生成 StableHLO,再包进一个 TF 的XlaCallModule算子。0.12 源码里native_serialization、enable_xla两个参数都已标记弃用(传入会触发DeprecationWarning),只剩这一种模式。也就是说,jax2tf现在只是"jax.export+ 一层 TF 外壳"。 - 用途:需要 TF SavedModel 的场景,比如 TF Serving、已有的 TF 推理基础设施、TF.js。权重要用
tf.Variable作为参数传入(README 的建议,理由和 §2.2 一样:闭包会把权重变成图常量)。 - TFLite 等需要把
XlaCallModule再转换成 TF 原生算子,路径长、覆盖不全。 - 不需要 TF 生态时,直接用
jax.export:依赖更少(只要jax+flatbuffers),兼容性承诺清楚。
§4.3 ONNX:不在官方路径上¶
⚪ 选读:目标运行时只接受 ONNX 时
JAX 和 OpenXLA 没有官方的 ONNX 导出器。社区常见的做法是 jax2tf → SavedModel → tf2onnx,或者第三方的 JAX→ONNX 转换项目。这些都需要额外依赖,算子覆盖和数值一致性要自己验证。如果目标运行时只吃 ONNX(如某些边缘推理芯片),可以考虑这条路;否则优先走 StableHLO(IREE 同样面向服务器、移动端和嵌入式部署,见 §4.1)。本环境没有试这条路。
§4.4 用 pure_callback 把模型包成服务函数:取舍¶
⚪ 选读:想在导出的函数里调用外部库或服务时
有时会想在 JAX 程序内部调用外部推理服务、C++ 库或者另一个模型,pure_callback 是最直接的桥(语义见 进阶/05 §5.4)。但它与导出是冲突的:回调函数是当前进程里的 Python 对象,不可能被序列化。先看实际效果:固定形状时在导出阶段报 NotImplementedError;符号形状时报的是更难懂的 RuntimeError: std::bad_cast。
def host_postprocess(logits):
"""宿主端 NumPy 后处理(假装是一段外部库代码)。"""
return np.asarray(np.exp(logits) / np.exp(logits).sum(-1, keepdims=True), np.float32)
def model_with_callback(params, x):
logits = mlp_apply(params, x)
return jax.pure_callback(host_postprocess, jax.ShapeDtypeStruct(logits.shape, logits.dtype), logits,
vmap_method="sequential")
# ❌ 错误写法:把 pure_callback 放进要导出的函数里(固定形状、符号形状各试一次)
errs_cb = {}
for name, x_spec in [("固定形状 (8, 4)", jax.ShapeDtypeStruct((8, 4), jnp.float32)), ("符号形状 (b, 4)", x_poly)]:
try:
export.export(jax.jit(model_with_callback))(sds(params), x_spec)
errs_cb[name] = "可以导出"
except Exception as e:
errs_cb[name] = f"{type(e).__name__}: {str(e).splitlines()[0]}"
print(f"{name}: {errs_cb[name]}")
assert errs_cb["固定形状 (8, 4)"] == "NotImplementedError: serialization of host_callbacks is not yet implemented"
assert errs_cb["符号形状 (b, 4)"].startswith("RuntimeError") # 实测为不友好的 std::bad_cast
# ✅ 正确写法:导出边界只包含纯 JAX 计算;回调、I/O、预处理放在导出程序的外面(服务层)
exp_core = export.export(jax.jit(lambda p, x: jax.nn.softmax(mlp_apply(p, x), axis=-1)))(sds(params), x_poly)
def serve(request_rows, loaded_exp, loaded_params):
"""服务函数:Python 端解析请求 → 调用导出程序 → Python 端组织响应。"""
x = np.asarray(request_rows, np.float32) # 预处理(任意 Python/NumPy 代码)
probs = np.asarray(loaded_exp.call(loaded_params, x)) # 纯 JAX 计算:已导出、可序列化
return [{"label": int(i), "prob": round(float(p[i]), 4)} for p, i in zip(probs, probs.argmax(-1))]
served = export.deserialize(exp_core.serialize())
rows = np.asarray(xb[:3]).tolist()
resp = serve(rows, served, params)
print("服务响应:", resp)
ref_probs = host_postprocess(np.asarray(mlp_apply(params, xb[:3])))
assert [r["label"] for r in resp] == ref_probs.argmax(-1).tolist()
# 计时(只打印):纯 JAX 后处理 vs pure_callback 后处理,都在同一个 jit 里
xb_big = jax.random.normal(jax.random.key(20), (4096, 4))
f_jnp = jax.jit(lambda p, x: jax.nn.softmax(mlp_apply(p, x), axis=-1))
f_cb = jax.jit(model_with_callback)
for name, f in [("纯 JAX", f_jnp), ("pure_callback", f_cb)]:
jax.block_until_ready(f(params, xb_big))
t0 = time.perf_counter()
for _ in range(50):
jax.block_until_ready(f(params, xb_big))
print(f"{name:14s}: {(time.perf_counter() - t0) / 50 * 1e3:.3f} ms/次")
assert max_diff(f_jnp(params, xb_big), f_cb(params, xb_big)) < 1e-5
取舍总结:
回调在 JAX 程序内部(pure_callback) | 回调在导出程序外部(服务层) | |
|---|---|---|
能否 jax.export / 序列化 | ❌ NotImplementedError | ✅ |
| 设备 | 只在宿主(CPU)上执行;GPU/TPU 上每次都要在设备和宿主之间搬数据 | 计算全部留在设备上 |
| 性能 | 每次回到 Python(受 GIL 限制),打断 XLA 融合 | 只在请求边界上进出 Python |
| 可微 / vmap | 需要 custom_jvp 和 vmap_method | 导出时就决定好(vjp_order、符号 batch) |
| 适合 | 研究原型、调用暂时无法用 JAX 重写的库、调试 | 生产推理服务 |
经验:导出边界之内只放纯 JAX 计算;分词、解码、图片解码、数据库查询这类 I/O 和外部库调用放在服务层。真要在设备上调用 C++ 代码,用 FFI(jax.ffi.ffi_call,见进阶/05),并在两端注册同名的 custom call 目标。
§5 综合实践:训练 → checkpoint → 导出 → 全新子进程独立推理¶
完整流程:
- 用 Flax NNX + Optax 训练一个小 MLP(3 分类),训练中用
CheckpointManager保存检查点; - 从检查点恢复,验证与内存中的模型一致;
- 只取
nnx.Param转成普通字典,用 Orbax 另存一份推理参数;用jax.export导出带符号 batch 维度的推理函数,序列化成文件,并写一份元数据meta.json; - 在全新子进程(
python -I,工作目录设在产物目录)里只加载导出文件和参数,不 importflax、optax或任何模型定义代码,完成推理,与原模型对拍; - 列出生成的文件和大小。
产物写在与本 notebook 同级的 export_artifacts/ 目录下。
# 1. 数据与训练(权重、数据都作为函数参数传入)
def make_dataset(key, n):
x = jax.random.normal(key, (n, 4))
y = jnp.argmax(jnp.sin(2.0 * x) @ TEACHER, axis=-1)
return x, y
X_train, Y_train = make_dataset(jax.random.key(1), 4096)
X_test, Y_test = make_dataset(jax.random.key(2), 1024)
@nnx.jit
def train_step_xy(model, optimizer, x, y):
loss, grads = nnx.value_and_grad(loss_fn)(model, x, y)
optimizer.update(model, grads)
return loss
@nnx.jit
def evaluate(model, x, y):
logits = model(x)
return loss_fn(model, x, y), jnp.mean(jnp.argmax(logits, -1) == y)
model = MLP(4, 64, 3, rngs=nnx.Rngs(0))
optimizer = nnx.Optimizer(model, optax.adam(1e-2), wrt=nnx.Param)
train_ckpt_dir = ART / "train_ckpt"
mngr = ocp.CheckpointManager(train_ckpt_dir, options=ocp.CheckpointManagerOptions(
save_interval_steps=100, best_fn=lambda m: m["test_loss"], best_mode="min",
preservation_policy=cm.AnyPreservationPolicy([ # 最新 1 个(续训)+ 测试损失最小的 1 个
cm.LatestN(n=1), cm.BestN(get_metric_fn=lambda m: m["test_loss"], reverse=True, n=1)])))
BATCH, STEPS = 256, 600
loss_init, acc_init = map(float, evaluate(model, X_test, Y_test))
print(f"训练前:test loss {loss_init:.4f} | test acc {acc_init:.3f}")
history = []
for step in range(1, STEPS + 1):
idx = jax.random.randint(jax.random.fold_in(jax.random.key(3), step), (BATCH,), 0, X_train.shape[0])
loss = train_step_xy(model, optimizer, X_train[idx], Y_train[idx])
if step % 100 == 0:
test_loss, test_acc = map(float, evaluate(model, X_test, Y_test))
history.append((step, float(loss), test_loss, test_acc))
mngr.save(step, args=ocp.args.StandardSave({"model": nnx.state(model), "opt": nnx.state(optimizer)}),
metrics={"test_loss": test_loss})
mngr.wait_until_finished()
for row in history:
print("step %4d | train loss %.4f | test loss %.4f | test acc %.3f" % row)
print("保留的检查点:", mngr.all_steps(), "| latest:", mngr.latest_step())
assert history[-1][2] < 0.5 * loss_init # 测试损失明显下降
assert history[-1][3] > 0.9 # 测试准确率 > 90%
best_hist = min(history, key=lambda r: r[2])[0]
print("测试损失最小的步:", best_hist, "| best_step():", mngr.best_step())
assert mngr.latest_step() == STEPS and mngr.best_step() == best_hist
assert mngr.all_steps() == sorted({STEPS, best_hist})
# 2. 从最新的检查点恢复(抽象模板),验证与内存中的模型逐位一致
abs_m = nnx.eval_shape(lambda: MLP(4, 64, 3, rngs=nnx.Rngs(0)))
abs_o = nnx.eval_shape(lambda: nnx.Optimizer(abs_m, optax.adam(1e-2), wrt=nnx.Param))
latest = mngr.latest_step()
restored_train = mngr.restore(latest, args=ocp.args.StandardRestore({"model": nnx.state(abs_m), "opt": nnx.state(abs_o)}))
mngr.close()
graphdef_m, _ = nnx.split(abs_m)
model_restored = nnx.merge(graphdef_m, restored_train["model"])
print("恢复的步:", latest, "| optimizer step:", int(restored_train["opt"]["step"][...]))
assert int(restored_train["opt"]["step"][...]) == latest
assert latest == STEPS and int(restored_train["opt"]["step"][...]) == STEPS
assert max_diff(nnx.state(model_restored), nnx.state(model)) == 0.0 # 参数逐位一致
assert max_diff(restored_train["opt"], nnx.state(optimizer)) == 0.0 # Adam 状态逐位一致
assert max_diff(model_restored(X_test[:5]), model(X_test[:5])) == 0.0
print("训练检查点恢复成功 ✓")
3. 导出推理函数:为什么接口要用普通字典¶
导出函数的输入 PyTree 结构(in_tree)会被序列化。nnx.State 里的叶子是 nnx.Param 这类 Flax 自定义类型,序列化时 JAX 不认识它们:
graphdef_p, param_state = nnx.split(model, nnx.Param) # 结构 + 参数(nnx.State)
x_batch_spec = jax.ShapeDtypeStruct(export.symbolic_shape("b, 4"), jnp.float32)
# ❌ 错误写法:直接把 nnx.State 作为导出函数的输入
try:
exp_state = export.export(jax.jit(lambda st, x: nnx.merge(graphdef_p, st)(x)))(param_state, x_batch_spec)
exp_state.serialize()
print("实测:可以序列化")
except ValueError as e:
err_state = str(e)
print("实测报错:ValueError:", err_state)
assert "Cannot serialize PyTreeDef containing an unregistered type" in err_state and "Param" in err_state
# ✅ 正确写法:导出接口用普通的嵌套字典;函数内部再用"抽象模板 + replace_by_pure_dict + merge"装回 NNX 模型
infer_params = nnx.to_pure_dict(param_state) # {'fc1': {'kernel':..., 'bias':...}, ...}
abs_param_state = sds(param_state) # 模板只含形状,不含任何权重数值
def infer(pure_params, x):
state = jax.tree.map(lambda s: s, abs_param_state) # 复制一份模板
nnx.replace_by_pure_dict(state, pure_params) # 填入(追踪中的)参数
return nnx.merge(graphdef_p, state)(x)
exp_infer = export.export(jax.jit(infer))(sds(infer_params), x_batch_spec)
print("导出接口 in_tree:", exp_infer.in_tree)
print("in_avals:", exp_infer.in_avals)
print("out_avals:", exp_infer.out_avals)
assert str(exp_infer.out_avals[0].shape) == "(b, 3)"
assert max_diff(exp_infer.call(infer_params, X_test[:7]), model(X_test[:7])) < 1e-5
# 权重没有被烘焙进程序:模块里没有任何二维浮点常量
assert not re.search(r"constant dense<[^>]*> : tensor<\d+x\d+xf32>", exp_infer.mlir_module())
# 写文件:导出程序、可读的 StableHLO 文本、元数据;推理参数单独用 Orbax 保存
(ART / "mlp_infer.jaxexport").write_bytes(exp_infer.serialize())
(ART / "mlp_infer.stablehlo.mlir").write_text(exp_infer.mlir_module())
flat_names = [jax.tree_util.keystr(p) for p, _ in jax.tree_util.tree_flatten_with_path(infer_params)[0]]
meta = {"jax_version": jax.__version__, "exported_at": time.strftime("%Y-%m-%d"),
"platforms": list(exp_infer.platforms), "calling_convention_version": exp_infer.calling_convention_version,
"params_flat_order": flat_names, "input": "x: float32[b, 4]", "output": "logits: float32[b, 3]"}
(ART / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2))
ckptr.save(ART / "infer_params", infer_params)
ckptr.wait_until_finished()
print(json.dumps(meta, ensure_ascii=False, indent=1))
4. 全新子进程:只加载导出文件和参数¶
子进程脚本只 import jax、numpy、orbax.checkpoint,用 python -I 启动,工作目录设在 export_artifacts/。它不知道模型有几层、用什么激活函数,只知道"有一个导出文件和一份参数"。参数用 Orbax 在没有模板的情况下恢复(得到普通字典),结构正好与导出接口一致。
脚本最后检查 flax、optax 确实没有被加载。测试输入取 batch = 1、7、64、1024 四种,验证符号 batch 维度。
test_inputs = {f"b{n}": np.asarray(X_test[:n]) for n in (1, 7, 64, 1024)}
np.savez(SCRATCH / "pipeline_inputs.npz", **test_inputs)
standalone_src = """
import sys, json
from pathlib import Path
import numpy as np
import jax
from jax import export
import orbax.checkpoint as ocp
exp = export.deserialize(open("mlp_infer.jaxexport", "rb").read()) # 程序
params = ocp.StandardCheckpointer().restore(Path("infer_params").absolute()) # 数据(无模板 → 普通字典)
meta = json.load(open("meta.json"))
inputs = np.load(sys.argv[1])
outs = {k: np.asarray(exp.call(params, inputs[k])) for k in inputs.files}
np.savez(sys.argv[2], **outs)
print("子进程:导出时 JAX", meta["jax_version"], "| 当前 JAX", jax.__version__, "| in_avals", exp.in_avals)
print("子进程:输出形状", {k: v.shape for k, v in outs.items()})
print("MODULES flax=%s optax=%s" % ("flax" in sys.modules, "optax" in sys.modules))
"""
out = run_py(standalone_src, SCRATCH / "pipeline_inputs.npz", SCRATCH / "pipeline_outputs.npz", cwd=ART)
print(out)
assert "MODULES flax=False optax=False" in out
for token in ("MLP", "nnx", "relu", "Linear", "def "): # 子进程脚本里没有模型定义
assert token not in standalone_src, token
sub_outs = np.load(SCRATCH / "pipeline_outputs.npz")
for k in test_inputs:
ref_logits = np.asarray(model(jnp.asarray(test_inputs[k])))
d = max_diff(sub_outs[k], ref_logits)
same_label = bool(np.all(sub_outs[k].argmax(-1) == ref_logits.argmax(-1)))
print(f"{k:>6s}: 输出 {sub_outs[k].shape},与原 NNX 模型的最大差 {d:.2e},预测类别完全一致 {same_label}")
assert sub_outs[k].shape == (test_inputs[k].shape[0], 3) and d < 1e-5 and same_label
acc_sub = float(np.mean(sub_outs["b1024"].argmax(-1) == np.asarray(Y_test)))
print(f"子进程推理的测试准确率: {acc_sub:.3f}(与训练结束时的 {history[-1][3]:.3f} 一致)")
assert abs(acc_sub - history[-1][3]) < 1e-6
print("全新子进程只凭导出文件 + Orbax 参数完成推理,与原模型对拍通过 ✓")
# 5. 列出生成的文件和大小
print(f"产物目录: {ART.name}/\n")
for p in sorted(ART.rglob("*")):
if p.is_file():
print(f" {str(p.relative_to(ART)):70s} {p.stat().st_size:>9,} B")
summary = {name: dir_bytes(ART / name) for name in sorted(os.listdir(ART))}
print("\n按顶层条目汇总:")
for name, b in summary.items():
print(f" {name:28s} {b / 1024:9.1f} KiB")
p_bytes = tree_bytes(infer_params)
print(f"\n推理参数原始字节数 {p_bytes:,} B;导出程序 {summary['mlp_infer.jaxexport']:,} B(不含权重)")
assert set(summary) == {"train_ckpt", "infer_params", "mlp_infer.jaxexport", "mlp_infer.stablehlo.mlir", "meta.json"}
assert summary["mlp_infer.jaxexport"] < p_bytes # 程序比权重还小:权重没有被烘焙进去
assert summary["infer_params"] >= p_bytes # 参数检查点至少包含全部权重
assert summary["train_ckpt"] > 2 * summary["infer_params"] # 训练检查点:每个保留的步都含参数 + Adam 的 m、v(约 3 倍参数)
§6 实战规则清单¶
保存什么、用什么
- 数据(参数、优化器状态、步数、RNG、数据位置)用 Orbax;程序用
jax.export;持久化编译缓存只用来加速重启,不是交付格式。 - 断点续训要存完整训练状态,至少包括参数、优化器状态(含步数)和数据位置;只存参数,Adam 的矩和学习率调度会从头开始。
- 发布推理模型时,另存一份只含
nnx.Param的推理检查点,可以转 bf16。
导出
- 权重一律作为参数传入导出函数,绝不闭包捕获。否则程序和数据绑死,文件按参数量膨胀,在本机还会触发 CPU 后端 bug。
- 导出接口的 PyTree 只用
dict/list/tuple,不要用nnx.State或自定义类;自定义类型要先export.register_pytree_node_serialization。 - batch 维度用
symbolic_shape("b, ...");有"偶数""≥ 3"等假设时,写成"2*k"或constraints=(...),不要靠//隐含。 - 多个参数共享的维度要来自同一个作用域:用一次
symbolic_shape或symbolic_args_specs。 - 加载端要求导的,导出时写
serialize(vjp_order=k);加载端要批处理的,在导出前把批维度做进程序(Exported.call不能vmap)。 platforms=里列出所有会运行它的平台,包括开发用的cpu;GPU/TPU 版本在 CPU 上只能 lowering,正式发布前要在目标硬件上验证。
兼容性与运维
- 只交付
serialize()的产物或mlir_module_serialized字节码,不要交付mlir_module()文本。 - 产物旁边写元数据:JAX 版本、导出日期、调用约定版本、平台、参数展平顺序、输入输出签名。
- 6 个月向后兼容窗口:定期用新 JAX 重新导出,并与存档的黄金输出对拍。
- 导出边界内不要有
pure_callback/io_callback(无法序列化);自定义 FFI 目标、Pallas 的 Triton kernel 不在稳定名单里,需要显式DisabledSafetyCheck,消费端也要注册同名实现(Pallas TPU / Mosaic GPU kernel 已在名单内,§2.6)。 deserialize的输入必须可信,不要加载来源不明的产物。- 环境里要单独安装
flatbuffers,否则serialize/deserialize报ImportError。
Orbax
- 保存是异步的:读回前、进程退出前都要
wait_until_finished()/close()。 - 同一目录不能重复
save,确实要覆盖时传force=True;多步保存用CheckpointManager。 best_fn+max_to_keep只保留"最好的 n 个",最新的可能被删;要用AnyPreservationPolicy([LatestN, BestN])组合。BestN(reverse=True)保留指标最小的。- 恢复一律带抽象模板(
jax.eval_shape/nnx.eval_shape),用它控制 dtype 和分片;形状不对时会报错,而不是截断。
✍️ 动手练习¶
每道题先自己写,再看参考答案。参考答案在本章末尾的「附录:练习参考答案」里,对应代码单元的第一行是 # 参考答案:练习 N。
练习 1:两个符号维度。 序列模型的输入是 x: float32[b, t, 8],batch b 和序列长度 t 都可变。函数 pool_linear(params, x) 先沿 t 求平均,再乘 params["w"](形状 (8, 2))、加 params["b"](形状 (2,))。用一次导出得到适配任意 (b, t) 的产物,序列化再读回,验证 (b, t) ∈ {(1, 1), (3, 5), (8, 17)} 都与原函数一致;再验证特征维不是 8 时调用报 ValueError。
自检接口:pool_linear(params, x);exp1:导出、序列化再读回得到的 Exported 对象,调用方式为 exp1.call(params, x)。
练习 2:用导出的损失函数做微调。 把 §2.5 的 mse_loss 导出并以 vjp_order=1 序列化,读回后只用读回的对象对 params 做 50 步梯度下降(学习率 0.1)。同时用原始 mse_loss 做同样的 50 步,验证两条参数轨迹最终一致(最大差 < 1e-5),并且损失下降了。
自检接口:loaded_loss:mse_loss 导出、序列化再读回的 Exported 对象;p_exp:只用 loaded_loss 做完 50 步梯度下降后的参数。
练习 3:组合保留策略。 用 CheckpointManager 和 AnyPreservationPolicy 实现"保留最新 2 个 + 验证损失最小的 1 个"。依次保存 6 步,损失为 [3, 1, 2, 5, 4, 6]。先在纸上推出会保留哪几步,再用 assert 验证;最后恢复 best_step() 的内容并检查。
自检接口:make_manager3(directory):返回按本题策略配置好的 CheckpointManager。自检会在临时目录里换一组损失序列重新保存,检查保留的步数和 best_step()。
练习 4:batch 必须是 4 的倍数。 函数 group_mean(x) 把 x: float32[b, d] 每 4 行分一组求平均,得到 [b/4, d]。导出时让 d 也保持符号。验证 b = 8, 12 时结果正确,b = 10 时调用报错,且错误信息指出余数是 2。
自检接口:group_mean(x);exp4:导出得到的 Exported 对象,调用方式为 exp4.call(x)。
# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
pool_linear, exp1
except NameError as e:
print(f"⏳ 练习 1 尚未作答:{e}")
else:
_rng = np.random.default_rng(2024)
_w = _rng.normal(size=(8, 2)).astype(np.float32)
_b = _rng.normal(size=(2,)).astype(np.float32)
_p = {"w": jnp.asarray(_w), "b": jnp.asarray(_b)}
assert isinstance(exp1, export.Exported), f"exp1 应当是 export.Exported 对象,实际是 {type(exp1).__name__}"
_xs = exp1.in_avals[-1].shape
assert len(_xs) == 3 and _xs[2] == 8, f"x 的导出形状应为 (b, t, 8),实际 {_xs}"
assert not isinstance(_xs[0], int) and not isinstance(_xs[1], int), f"b 和 t 都应是符号维度,实际 {_xs}"
for _bt in [(2, 9), (5, 3), (1, 30)]:
_x = _rng.normal(size=(*_bt, 8)).astype(np.float32)
_ref = _x.mean(axis=1) @ _w + _b # NumPy 独立计算
assert np.allclose(pool_linear(_p, jnp.asarray(_x)), _ref, atol=1e-5), f"pool_linear 在 (b, t) = {_bt} 时结果不对"
assert np.allclose(exp1.call(_p, jnp.asarray(_x)), _ref, atol=1e-5), f"exp1.call 在 (b, t) = {_bt} 时结果不对"
try:
exp1.call(_p, jnp.ones((2, 3, 5)))
except ValueError:
pass
else:
raise AssertionError("特征维不是 8 时,exp1.call 应当报 ValueError")
print("✅ 练习 1 自检通过")
# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
loaded_loss, p_exp
except NameError as e:
print(f"⏳ 练习 2 尚未作答:{e}")
else:
assert isinstance(loaded_loss, export.Exported), f"loaded_loss 应当是 export.Exported 对象,实际是 {type(loaded_loss).__name__}"
_p0 = jax.tree.map(lambda a: 0.5 * a + 0.1, params) # 换一个新的参数点
try:
_g = jax.grad(loaded_loss.call)(_p0, xg, yg)
except Exception as _e:
raise AssertionError(f"对 loaded_loss.call 求梯度失败({type(_e).__name__}),检查序列化时的参数") from None
assert max_diff(_g, jax.grad(mse_loss)(_p0, xg, yg)) < 1e-5, "loaded_loss 的梯度与原函数不一致"
_q = params # 用原函数独立重算 50 步作对照
for _ in range(50):
_q = jax.tree.map(lambda w, g: w - 0.1 * g, _q, jax.grad(mse_loss)(_q, xg, yg))
assert max_diff(p_exp, _q) < 1e-5, "p_exp 与「50 步、学习率 0.1」的梯度下降结果不一致"
assert float(mse_loss(p_exp, xg, yg)) < float(mse_loss(params, xg, yg)), "训练后损失应当下降"
print("✅ 练习 2 自检通过")
# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
make_manager3
except NameError as e:
print(f"⏳ 练习 3 尚未作答:{e}")
else:
_d = Path(tempfile.mkdtemp(prefix="selfcheck3_"))
_m = make_manager3(_d / "run")
_losses = [2.0, 6.0, 1.0, 5.0, 3.0, 4.0, 7.0] # 和题目不同的一组损失
for _s, _l in enumerate(_losses):
_m.save(_s, args=ocp.args.StandardSave({"w": jnp.full(2, float(_s))}), metrics={"loss": _l})
_m.wait_until_finished()
_best = int(np.argmin(_losses))
_expect = sorted({len(_losses) - 2, len(_losses) - 1, _best})
assert _m.all_steps() == _expect, f"应当保留 {_expect}(最新 2 个 + 损失最小的 1 个),实际保留 {_m.all_steps()}"
assert _m.best_step() == _best, f"best_step() 应为 {_best},实际 {_m.best_step()}"
_r = _m.restore(_best, args=ocp.args.StandardRestore(jax.eval_shape(lambda: {"w": jnp.zeros(2)})))
assert max_diff(_r, {"w": jnp.full(2, float(_best))}) == 0.0, "恢复出的内容不对"
_m.close()
shutil.rmtree(_d, ignore_errors=True)
print("✅ 练习 3 自检通过")
# ✍️ 作答区:练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 4
try:
group_mean, exp4
except NameError as e:
print(f"⏳ 练习 4 尚未作答:{e}")
else:
assert isinstance(exp4, export.Exported), f"exp4 应当是 export.Exported 对象,实际是 {type(exp4).__name__}"
_rng = np.random.default_rng(7)
for _bb, _dd in [(16, 7), (4, 1), (20, 2)]: # d 取不同值:d 必须是符号维度
_x = _rng.normal(size=(_bb, _dd)).astype(np.float32)
_ref = np.stack([_x[4 * i:4 * i + 4].mean(axis=0) for i in range(_bb // 4)])
assert np.allclose(group_mean(jnp.asarray(_x)), _ref, atol=1e-6), f"group_mean 在 x.shape = {(_bb, _dd)} 时结果不对"
assert np.allclose(exp4.call(jnp.asarray(_x)), _ref, atol=1e-6), f"exp4.call 在 x.shape = {(_bb, _dd)} 时结果不对"
for _bb, _rem in [(6, 2), (9, 1)]:
try:
exp4.call(jnp.ones((_bb, 3), jnp.float32))
except ValueError as _e:
assert f"remainder {_rem}" in str(_e), f"b = {_bb} 时错误信息应指出余数是 {_rem}:{str(_e)[:120]}"
else:
raise AssertionError(f"b = {_bb} 不是 4 的倍数,exp4.call 应当报 ValueError")
print("✅ 练习 4 自检通过")
📝 测验¶
1. 下列哪一项可以在一个没有模型 Python 源码的新进程里直接运行推理?
- A. Orbax 保存的参数检查点
- B. 持久化编译缓存目录
- C.
Exported.serialize()的产物 + 一份参数 - D.
jit(f).lower(x).as_text()打印出的文本
2. blob = exp.serialize() 写入文件,另一个进程 deserialize 后执行 jax.grad(loaded.call)(...),结果是?
- A. 正常求导 B.
ValueError: No VJP is availableC. 返回全零梯度 D. 自动用前向模式求导
3. 用 symbolic_shape("b, 4") 导出含 if x.shape[0] > 2: 的函数会报 InconclusiveDimensionOperation。最直接的修法是?
- A. 把
if改成jnp.where - B.
symbolic_shape("b, 4", constraints=("b >= 3",)) - C. 改用
jax.vmap - D. 设置
jax_export_calling_convention_version=9
4. 关于"导出时权重作为参数而不是闭包常量",下列说法错误的是?
- A. 闭包常量会被烘焙进 StableHLO,导出文件随参数量变大
- B. 作为参数时,同一个导出程序可以配不同的权重
- C. 作为参数会让导出程序在调用时变慢一倍
- D. 在 jaxlib 0.12.0.1 的 CPU 后端上,闭包常量 + matmul + sum 会悄悄算错,导出产物同样受影响
5. JAX 对 jax.export 序列化产物的兼容性承诺是?
- A. 向后 5 年、向前 2 年 B. 向后 6 个月、向前 3 周 C. 只保证同一 jaxlib 版本 D. 没有任何承诺
6. CheckpointManagerOptions(max_to_keep=2, best_fn=lambda m: m["loss"], best_mode="min"),依次保存 5 步,损失为 [5, 1, 4, 2, 9]。最后磁盘上保留哪几步?
- A.
[3, 4]B.[1, 3]C.[1, 3, 4]D.[0, 1]
点击展开:答案与解析
- C。导出产物包含完整的计算图(StableHLO),只需要
jax和flatbuffers就能运行(§2.2、§5)。A 只有数据,要靠模型代码装回去;B 是本机的机器码缓存,换环境就失效,而且仍需 Python 端追踪出同一个程序才能命中;D 的文本没有兼容性保证,也没有输入输出的 PyTree 信息。 - B。
serialize()默认vjp_order=0,不保存反向函数,读回后has_vjp()为False(§2.5 验证过)。要在加载端求导,导出端必须写serialize(vjp_order=1)。D 也不对:Exported.call外面包的是custom_vjp,jvp会报TypeError。 - B。用约束告诉 JAX
b >= 3,b > 2就有了确定结论;调用时shape_assertion会检查约束(§2.3)。A 改变的是"按数据选择",而这里的条件取决于形状,二者语义不同;而且如果两个分支的输出形状不同,jnp.where根本写不出来。 - C。作为参数只是改变了权重从哪里来,计算图相同,没有"慢一倍"的道理;反而避免了巨大常量拖慢编译。A、B、D 都在 §2.2 验证过:128×128 的例子里,闭包版文件是传参版的二十多倍(多出来的正是 64 KiB 的权重),结果误差约 961。
- B。以 jaxlib 发布时间计,向后兼容 6 个月、向前兼容 3 周(§2.6)。A 是 StableHLO 自身对可移植产物的承诺,JAX 因为还依赖调用约定和 custom call,收紧到了 6 个月 / 3 周。
- B。有
best_fn时max_to_keep表示"保留指标最好的 n 个":损失最小的是第 1 步(1)和第 3 步(2)。最新的第 4 步(9)被删除(§3.2 验证过)。要同时保留最新的,用AnyPreservationPolicy([LatestN(1), BestN(...)]),结果才是 C。
# 验证测验第 2、5、6 题的关键事实(正文都验证过,这里汇总再查一遍)
assert err_vjp0 == "No VJP is available" # 第 2 题
assert export.maximum_supported_calling_convention_version == 10 # 第 5 题相关:本机调用约定范围
assert kept_bad == [1, 3] # 第 6 题
print("测验第 2、6 题的结论与正文实测一致 ✓")
📌 本章小结¶
- 两类东西分开存:数据用 Orbax,程序用
jax.export。两者可以独立更新:同一个程序可以配不同权重,同一份权重也可以配不同的程序。持久化编译缓存只是本机加速手段,不能当交付格式。 jax.export流程:export.export(jax.jit(f))(*specs)→Exported(in_avals、out_avals、platforms、mlir_module()),然后serialize()→ 文件 →deserialize()→.call()。在python -I子进程里只读字节就能运行,已证明不依赖原始源码。序列化需要单独安装flatbuffers。- 权重作为参数:闭包常量会烘焙进程序(128×128 的例子里文件大了二十多倍),并把本机 CPU bug 一起带进产物。
- 形状多态:
symbolic_shape("b, 4")一次导出适配任意 batch。不能整除、对符号维度做条件判断都会报InconclusiveDimensionOperation,可用constraints或"2*k"这类规格表达假设;floordiv会把错误推迟到形状细化阶段。共享的维度要来自同一作用域。 - 多平台:一个模块包含多个平台的实现,靠
platform_index分派;GPU/TPU 在本机只做了 lowering。 - 求导:同进程可以直接
grad(exp.call);序列化后要vjp_order=k。Exported.call不支持vmap和jvp,需要的话在导出前把批维度做进程序。 - 兼容性:序列化格式版本 12、调用约定版本 10(支持 9–10)、StableHLO 可移植字节码;JAX 承诺向后 6 个月、向前 3 周。只交付序列化产物,custom call 不在保证范围内。
- 分片:N 设备产物只能在 N 设备的 mesh 里调用;单设备产物是设备多态的;
shard_map可以导出,pmap要包一层jit,但应该迁移掉。 - Orbax 进阶:
CheckpointManager负责多步保存、save_interval_steps、max_to_keep;best_fn会删掉最新的检查点,要用AnyPreservationPolicy组合策略;恢复时带抽象模板,可以顺便转换 dtype、指定分片;NNX 用nnx.state/nnx.split/nnx.merge保存恢复,续训曲线逐位接上。 - 部署出口:StableHLO 字节码交给 PJRT 或 IREE(建议固定形状);
jax2tf仍然存在但依赖 TF,内部就是jax.export;ONNX 不在官方路径上;pure_callback不能导出,应放在导出边界外的服务层。
🗂️ 速查表¶
| API | 作用 |
|---|---|
pip install flatbuffers | 序列化的必需依赖(不是 jax 的依赖,要单独装) |
jax.export.export(jax.jit(f), platforms=..., disabled_checks=...)(*args) | 导出,得到 Exported(参数可以是 ShapeDtypeStruct) |
exp.in_avals / out_avals / in_tree / platforms / nr_devices | 导出签名与元信息 |
exp.mlir_module() / exp.mlir_module_serialized | StableHLO 文本(无兼容保证)/ 可移植字节码 |
exp.call(*args) | 调用导出函数(可以嵌进 jit,可以 grad,不能 vmap/jvp) |
exp.serialize(vjp_order=k) / jax.export.deserialize(blob) | 序列化(带 k 阶 VJP)/ 反序列化 |
exp.has_vjp() / exp.vjp() | 能否求导 / 反向函数的 Exported |
export.symbolic_shape("b, 4", constraints=("b >= 3",)) | 符号维度与约束("2*k" 表示偶数) |
export.symbolic_args_specs(args, ("b, _", "b")) | 为多个参数生成同一作用域的符号规格 |
export.is_symbolic_dim(d) | 判断是否为符号维度 |
export.DisabledSafetyCheck.custom_call(name) / .platform() | 放行不受保证的 custom call / 平台检查 |
export.minimum/maximum_supported_calling_convention_version | 调用约定版本支持范围 |
JAX_EXPORT_CALLING_CONVENTION_VERSION / jax_export_calling_convention_version | 选择导出用的调用约定版本 |
export.register_pytree_node_serialization(...) | 让自定义 PyTree 类型可以序列化 |
exp.in_shardings_jax(mesh) / out_shardings_jax(mesh) | 把导出时的分片映射到具体 mesh |
ocp.StandardCheckpointer().save(path, tree, force=True) / .restore(path, 模板) | 单个检查点保存(覆盖)/ 恢复 |
ocp.StandardCheckpointer().metadata(path).item_metadata.tree | 查看磁盘上的结构、形状、dtype |
ocp.CheckpointManager(dir, options=ocp.CheckpointManagerOptions(...)) | 多步检查点管理 |
max_to_keep / save_interval_steps / best_fn + best_mode | 保留数量 / 保存间隔 / 按指标选最佳 |
ocp.checkpoint_managers.AnyPreservationPolicy([LatestN(n), BestN(get_metric_fn, reverse, n)]) | 组合保留策略(reverse=True:越小越好) |
mngr.save(step, args=ocp.args.StandardSave(tree), metrics=...) | 保存一步 |
mngr.restore(step, args=ocp.args.StandardRestore(模板)) | 恢复一步 |
mngr.all_steps() / latest_step() / best_step() / wait_until_finished() / close() | 查询与收尾 |
jax.eval_shape(init_fn, ...) / nnx.eval_shape(lambda: Model(...)) | 抽象模板(不分配内存) |
nnx.state(m) / nnx.split(m, nnx.Param) / nnx.merge(gdef, state) / nnx.update(obj, state) | NNX 状态的取出、拆分、合并、写回 |
nnx.to_pure_dict(state) / nnx.replace_by_pure_dict(state, d) | NNX State ↔ 普通字典(导出接口用普通字典) |
jax.extend.mlir.dialects.stablehlo.get_current_version() 等 | StableHLO 版本查询 |
jax.experimental.jax2tf.convert | JAX → TF(需要 TensorFlow;内部使用 jax.export) |
练习 1¶
题目见正文「✍️ 动手练习」。
# 参考答案:练习 1
def pool_linear(p, x):
return jnp.mean(x, axis=1) @ p["w"] + p["b"] # 沿 t 求平均,再做线性层
p1 = {"w": jax.random.normal(jax.random.key(30), (8, 2)), "b": jnp.ones(2)}
x1_spec = jax.ShapeDtypeStruct(export.symbolic_shape("b, t, 8"), jnp.float32) # 一次调用 → 同一作用域
exp1 = export.deserialize(export.export(jax.jit(pool_linear))(sds(p1), x1_spec).serialize())
print("in_avals:", exp1.in_avals, "→ out_avals:", exp1.out_avals)
assert str(exp1.out_avals[0].shape) == "(b, 2)"
for bb, tt in [(1, 1), (3, 5), (8, 17)]:
x1 = jax.random.normal(jax.random.key(bb * 100 + tt), (bb, tt, 8))
assert max_diff(exp1.call(p1, x1), pool_linear(p1, x1)) < 1e-5
try:
exp1.call(p1, jnp.ones((2, 3, 7)))
raise AssertionError("应当报错")
except ValueError as e:
print("特征维 7:", str(e)[:80])
assert "Shape mismatch" in str(e)
print("练习 1 ✓")
练习 2¶
题目见正文「✍️ 动手练习」。
# 参考答案:练习 2
loaded_loss = export.deserialize(exp_loss.serialize(vjp_order=1)) # 只保存 1 阶 VJP 就够了
lr = 0.1
p_exp, p_ref = params, params
loss_start = float(loaded_loss.call(p_exp, xg, yg))
for _ in range(50):
g_e = jax.grad(loaded_loss.call)(p_exp, xg, yg) # 读回的对象求导
g_r = jax.grad(mse_loss)(p_ref, xg, yg) # 原函数求导
p_exp = jax.tree.map(lambda w, g: w - lr * g, p_exp, g_e)
p_ref = jax.tree.map(lambda w, g: w - lr * g, p_ref, g_r)
loss_end = float(loaded_loss.call(p_exp, xg, yg))
print(f"损失 {loss_start:.4f} → {loss_end:.4f};两条参数轨迹最大差 {max_diff(p_exp, p_ref):.1e}")
assert loss_end < loss_start and max_diff(p_exp, p_ref) < 1e-5
print("练习 2 ✓")
练习 3¶
题目见正文「✍️ 动手练习」。
# 参考答案:练习 3
# 推导:最新 2 个是第 4、5 步;损失最小的是第 1 步(损失 1)→ 保留 [1, 4, 5]
losses3 = [3.0, 1.0, 2.0, 5.0, 4.0, 6.0]
def make_manager3(directory):
pol = cm.AnyPreservationPolicy([cm.LatestN(n=2),
cm.BestN(get_metric_fn=lambda m: m["loss"], reverse=True, n=1)])
return ocp.CheckpointManager(directory, options=ocp.CheckpointManagerOptions(
preservation_policy=pol, best_fn=lambda m: m["loss"], best_mode="min"))
m3 = make_manager3(SCRATCH / "ex3")
for step, l in enumerate(losses3):
m3.save(step, args=ocp.args.StandardSave({"w": jnp.full(2, float(step))}), metrics={"loss": l})
m3.wait_until_finished()
print("保留:", m3.all_steps(), "| best_step:", m3.best_step())
assert m3.all_steps() == [1, 4, 5] and m3.best_step() == 1
r3 = m3.restore(m3.best_step(), args=ocp.args.StandardRestore(jax.eval_shape(lambda: {"w": jnp.zeros(2)})))
assert max_diff(r3, {"w": jnp.full(2, 1.0)}) == 0.0
m3.close()
print("练习 3 ✓")
练习 4¶
题目见正文「✍️ 动手练习」。
# 参考答案:练习 4
def group_mean(x):
return x.reshape(x.shape[0] // 4, 4, x.shape[1]).mean(axis=1)
x4_spec = jax.ShapeDtypeStruct(export.symbolic_shape("4*k, d"), jnp.float32) # 把"4 的倍数"写进规格
exp4 = export.export(jax.jit(group_mean))(x4_spec)
print("in:", exp4.in_avals[0].shape, "→ out:", exp4.out_avals[0].shape)
assert str(exp4.out_avals[0].shape) == "(k, d)"
for bb, dd in [(8, 3), (12, 5)]:
x4 = jax.random.normal(jax.random.key(bb + dd), (bb, dd))
assert max_diff(exp4.call(x4), np.asarray(x4).reshape(bb // 4, 4, dd).mean(1)) < 1e-6
try:
exp4.call(jnp.ones((10, 3)))
raise AssertionError("应当报错")
except ValueError as e:
print("b = 10:", str(e)[:110], "...")
assert "Division had remainder 2 when computing the value of 'k'" in str(e)
print("练习 4 ✓")