Chapter 9: Export and Deployment
JAX study notes · an extra chapter in the Advanced part. Prerequisites: Basics 00–07, Advanced 00–06. Environment: Python 3.14 · JAX / jaxlib 0.12.0.1 · Flax 0.12.12 · Optax 0.2.9 · Orbax 0.12.10 · CPU only.
The earlier chapters stop at "the model is trained". This chapter picks up from there: how to save it, and how to hand it to other people or other programs to run. Concretely, that means saving the parameters as a checkpoint, exporting the computation graph as a StableHLO program that doesn't depend on the Python source, and then running inference in a brand-new process using only those two things.
Every conclusion in this chapter was run on JAX 0.12.0.1 and verified with assert; timings are only printed, not asserted. This machine only has a CPU, so anything that can't run here (GPU/TPU execution, C++ runtimes, TensorFlow) is marked "lowering only" or "not run on this machine".
How this relates to earlier chapters (not repeated, just a quick recap):
- Basic Orbax
StandardCheckpointersave and restore,nnx.eval_shapeabstract templates, and writing back withnnx.update: see Advanced/06 §6.3 "Serialization and checkpoints". This chapter starts fromCheckpointManager, retention policies and resuming training. - The persistent compilation cache (
jax_compilation_cache_dir): see the Extra in Advanced/02 §2.5. It caches machine code compiled for this machine, which is a different thing from this chapter'sjax.export(a portable StableHLO program); §1 compares the two. - The jaxlib 0.12.0.1 CPU backend bug where "a closed-over constant matrix + matmul + sum silently returns wrong results": for the symptoms and workaround see "Known issues in this environment" in 00 Overview, and for the root cause see Advanced/02 §2.3. In §2.2 you'll see that this bug travels with the exported artifact unchanged.
- The semantics of
pure_callback(it can be removed by DCE, needsvmap_method, isn't differentiable): see Advanced/05 §5.4. §4.4 only discusses its trade-offs in deployment. - Looking at StableHLO with
jit(f).lower(...).as_text(): see Basics 02 JIT compilation.
Learning goals¶
- Tell apart the two kinds of things you need to save: data (parameters, optimizer state, saved with Orbax) and programs (the computation graph, saved with
jax.export), and know when each applies. - Master the full flow
jax.export.export→Exported→serialize()/deserialize()→.call(), and be able to prove that the exported artifact runs without the original Python source. - Use
symbolic_shapefor shape-polymorphic export, and understand the rules for constraints, divisibility, conditionals and scopes on symbolic dimensions, as well as what the various errors mean. - Do multi-platform export (
platforms=), export differentiable functions (vjp_order), and know which transformationsExported.callsupports and which it doesn't. - Understand the three layers of version compatibility: serialization format version, calling convention version, and the compatibility promise for StableHLO portable artifacts; know the rules for exporting functions with sharding.
- Manage multi-step checkpoints with Orbax
CheckpointManager(max_to_keep, keeping the best by metric, combined retention policies), restore from abstract templates, and resume training seamlessly with Flax NNX. - Know the current state of deployment targets: StableHLO for C++ runtimes (IREE, PJRT),
jax2tf, ONNX, and wrapping a service withpure_callback. - Do one end-to-end exercise: train → checkpoint → export → load only the artifacts in a fresh subprocess and run inference → cross-check.
Contents¶
| Section | Content | Runs on this machine |
|---|---|---|
| §0 | Environment notes: the flatbuffers dependency; shared helpers | ✅ |
| §1 | Save the two kinds of things separately: data (Orbax) and programs (jax.export) | ✅ |
| §2.1 | jax.export basics: the fields of Exported | ✅ |
| §2.2 | Serialize to a file and run in a new subprocess; why weights must be arguments | ✅ |
| §2.3 | Shape polymorphism: symbolic_shape, constraints, divisibility, scopes | ✅ |
| §2.4 | Multi-platform export platforms=("cpu","cuda","tpu") | ⚠️ lowering only, not run on GPU/TPU |
| §2.5 | Exporting differentiable functions: vjp_order; Exported.call with vmap/jvp | ✅ |
| §2.6 | Version compatibility: serialization version, calling convention version, the StableHLO promise | ✅ |
| §2.7 | Exporting functions with sharding / shard_map / pmap (4 devices simulated in a subprocess) | ✅ |
| §3 | Orbax in depth: CheckpointManager, retention policies, abstract templates, resuming NNX training | ✅ |
| §4 | Overview of deployment targets: StableHLO → IREE/PJRT, jax2tf, ONNX, pure_callback | ⚠️ C++ runtimes and TF not run on this machine |
| §5 | Putting it together: train → checkpoint → export → standalone inference in a subprocess → cross-check | ✅ |
| §6 | Practical rules checklist | — |
After that come: hands-on exercises (4), a quiz (6 questions), the chapter summary, and a cheat sheet.
Official docs: Exporting and serialization (including the two pages Exporting and serializing staged-out computations and Shape polymorphism). The compatibility promises and calling convention versions in this chapter all follow those docs.
§0 Environment notes and shared helpers¶
⚠️ Serialization needs flatbuffers: Exported.serialize() and jax.export.deserialize() read and write exported artifacts in the FlatBuffers format. But flatbuffers isn't a dependency of jax / jaxlib (neither package's metadata lists it, not even in an extra), so pip install jax won't pull it in. You have to install it separately:
pip install flatbuffers # pure Python package; 25.12.19 in this environment, and already in requirements.txt
Without it, jax.export.export(...) itself still works (exporting only uses MLIR); you get an ImportError only when you call serialize() / deserialize(). Below we first define the helpers used throughout the chapter, then verify this with a ❌/✅ pair of cells.
Shared helpers:
run_py(src, *args): run a script in a brand-new subprocess. It usespython -I(isolated mode: the current directory and the script directory aren't added tosys.path, andPYTHONPATHis ignored), so the subprocess can't possibly import anything defined in this notebook. We rely on it to verify that "the exported artifact runs without the source code" and to simulate multiple devices (XLA_FLAGSis read only at process start).sds(tree): turn a PyTree of arrays into ajax.ShapeDtypeStructPyTree with the same structure; exporting only needs shapes and dtypes.max_diff,tree_bytes,dir_bytes: error and size statistics.ART: the export artifact directoryexport_artifacts/(next to this notebook, excluded by.gitignore). It's cleared and recreated at the start of every run.
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) # keep only Orbax error logs so INFO/WARNING don't flood the output
print("Python", sys.version.split()[0], "| JAX", jax.__version__, "| jaxlib", jaxlib.__version__,
"| Flax", flax.__version__, "| Optax", optax.__version__, "| Orbax", version("orbax-checkpoint"))
print("Devices:", jax.devices())
# Export artifact directory: next to this notebook (export_artifacts/ in the notebook's folder), cleared on every run
ART = Path("export_artifacts").resolve()
if ART.exists():
shutil.rmtree(ART)
ART.mkdir(parents=True)
SCRATCH = Path(tempfile.mkdtemp(prefix="ext03_")) # intermediate files go in a temp dir, not the repo
print("Artifact directory:", ART.name + "/")
def run_py(src, *args, env_extra=None, cwd=None):
"""Run script src in a brand-new subprocess (python -I isolated mode) and return stdout; on failure raise an exception that includes 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"Subprocess failed (return code {r.returncode}):\n{r.stderr[-3000:]}")
return r.stdout
def sds(tree):
"""PyTree of arrays → ShapeDtypeStruct PyTree with the same structure (shape and dtype only)."""
return jax.tree.map(lambda a: jax.ShapeDtypeStruct(np.shape(a), a.dtype), tree)
def max_diff(a, b):
"""Largest elementwise absolute difference between two PyTrees of the same structure (compared in 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):
"""Pull out the signature line of the exported module's @main (to see argument and result types)."""
return next(l.strip() for l in exp.mlir_module().splitlines() if "func.func public @main" in l)
# ❌ Wrong: calling serialize() without flatbuffers installed; jax won't install it for you
# We don't actually uninstall the package: in the subprocess we set sys.modules["flatbuffers"] = None, so any later import flatbuffers raises ImportError, which simulates "not installed"
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("Export itself succeeded, platforms =", exp.platforms)
try:
exp.serialize()
print("RESULT serialization succeeded")
except ImportError as e:
print("RESULT ImportError:", e)
""")
print("Measured:\n" + out)
except Exception as e:
print("Measured error:", type(e).__name__, e)
assert "Export itself succeeded" in out
assert "RESULT ImportError: Please install 'flatbuffers' in order to use Exported serialization" in out
# ✅ Correct: install flatbuffers separately (pip install flatbuffers) and add it to requirements.txt
import flatbuffers
deps = (requires("jax") or []) + (requires("jaxlib") or [])
print("flatbuffers version:", flatbuffers.__version__)
print("Do jax/jaxlib declare flatbuffers as a dependency?", any(d.lower().startswith("flatbuffers") for d in deps))
assert not any(d.lower().startswith("flatbuffers") for d in deps) # it really isn't a jax dependency
blob = export.export(jax.jit(jnp.sin))(jnp.ones(3)).serialize()
print("serialize() works:", type(blob).__name__, len(blob), "bytes")
assert isinstance(blob, bytearray) and len(blob) > 0
§1 Save the two kinds of things separately: data and programs¶
A trained model = a program (how the forward pass is computed) + data (the actual values of the weights). The JAX ecosystem uses different tools for the two:
| Data: parameters, optimizer state, step, RNG key | Program: the computation graph | |
|---|---|---|
| Tool | Orbax (orbax.checkpoint) | jax.export |
| What gets saved | arrays (TensorStore/Zarr format, shardable, asynchronous writes) | StableHLO portable bytecode + metadata such as the input/output signature |
| What you need to read it back | the model definition code (to put the arrays back into the model); a restore template | only jax (or any runtime that can run StableHLO), no model source code |
| Typical use | resuming training, fine-tuning, reloading after changing the parallel strategy, releasing a model (the weights) | inference services, handing off to C++/mobile runtimes, delivering a "black box" function across teams, freezing a version for regression tests |
| Version stability | the array format is stable, but the PyTree structure depends on your code | JAX promises 6 months of backward compatibility (§2.6) |
There's a third thing that's easy to mix up: the persistent compilation cache (Advanced/02 §2.5). It stores the machine code XLA compiled for this machine, this jaxlib version and this set of compiler options, and it's invalidated as soon as the cache key changes. It's only for faster restarts and is not a delivery format.
| Persistent compilation cache | jax.export | |
|---|---|---|
| Contents | compiled executables | StableHLO from before compilation |
| Portability | none: changing jaxlib, flags or hardware invalidates it | across jaxlib versions (within the compatibility window), multi-platform |
| Needs compiling after loading? | no | yes (compiled by the current XLA after loading) |
The biggest benefit of saving the two separately is that you can update them independently: the same exported program can be paired with different weights (no need to re-export after retraining or fine-tuning), and the same weights can be paired with different programs (a different inference batch size, a different platform). Below we first show "one program + two sets of data" with a minimal example; §5 does the full workflow.
def linear_apply(params, x):
"""The smallest "model": the program only describes the computation; weights come in as arguments."""
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)} # first set of weights
params_b = {"w": jax.random.normal(k2, (4, 2)), "b": jnp.ones(2)} # second set of weights (say, after retraining)
x_demo = jax.random.normal(k3, (3, 4))
# Program: export once (uses only shapes and dtypes)
prog = export.export(jax.jit(linear_apply))(sds(params_a), sds(x_demo))
# Data: save the two sets of weights with 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)) # restore from an abstract template
y = prog.call(restored, x_demo)
print(f"Weights {name}: output of the exported program = {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("One exported program + two sets of Orbax weights → two different models, both matching the original function ✓")
§2 jax.export: exporting the computation graph as a portable program¶
§2.1 Basic usage: what's inside an Exported object¶
🟢 Must-read
The flow is a single line: jax.export.export(jax.jit(f))(*args). The arguments can be real arrays or just jax.ShapeDtypeStructs (exporting only needs shapes and dtypes, not real data). The resulting Exported object contains:
| Field / method | Meaning |
|---|---|
fun_name | the original function's name (just a name, no source code) |
in_tree / in_avals | the PyTree structure of the inputs, and the abstract value (shape + dtype) of each leaf after flattening |
out_tree / out_avals | the structure and abstract values of the outputs |
platforms | which platforms it was lowered for; defaults to the current backend, ("cpu",) |
nr_devices | the number of devices at export time (> 1 for sharded functions, see §2.7) |
calling_convention_version | the calling convention version (§2.6) |
mlir_module() | StableHLO text (for humans to read) |
mlir_module_serialized | StableHLO portable bytecode (the program that actually gets saved) |
call(*args) | call the exported function (it gets compiled in the current process) |
serialize(vjp_order=0) / jax.export.deserialize(blob) | save to bytes / restore from bytes |
has_vjp() / vjp() | whether it can be differentiated / get the Exported of the backward function (§2.5) |
Note that in_avals is flattened: dict leaves are sorted by key name, so {"w1","b1","w2","b2"} flattens in the order b1, b2, w1, w2.
def mlp_apply(params, x):
"""Pure-function version of a two-layer MLP. The weights params are an argument, not closed-over constants."""
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 signature:", 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
# Exporting with only ShapeDtypeStructs works too and produces exactly the same program
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("Exporting with only ShapeDtypeStructs: the bytecode is identical to exporting with real arrays ✓")
# ❌ Wrong: exporting a plain Python function directly (not wrapped in jax.jit)
try:
export.export(mlp_apply)(params, x2)
print("Measured: it runs")
except ValueError as e:
print("Measured error: ValueError:", e)
err_nojit = str(e)
assert "must be the result of `jit`" in err_nojit
# ✅ Correct: export.export only accepts the result of jax.jit; jit options such as static_argnums and donate take effect too
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 Serialize to a file and run it in a brand-new subprocess¶
🟢 Must-read
exp.serialize() returns a bytearray that you can write straight to a file; jax.export.deserialize(blob) accepts bytes or bytearray.
Below we write the exported artifact and the weights (as .npz for now; §5 switches to Orbax) to a temp directory, then load and call them in a python -I subprocess. To prove that the subprocess doesn't use the original Python source, we add two more checks:
- The text of the subprocess script contains nothing computation-related such as
mlp_apply,tanhor@(matmul); all it does is "load → call"; - In isolated mode the subprocess's
sys.pathdoesn't include this notebook's directory, and it can't import any function defined in the 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"Export file {exp_path.name}: {len(blob)} bytes; header {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()) # only reads bytes; doesn't import any model code
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("subprocess fun_name =", exp.fun_name, "| in_avals =", exp.in_avals)
print("subprocess 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 difference between subprocess output and the original function:", 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 "): # the subprocess script contains no computation logic
assert token not in sub_src, token
# In the same process, the deserialized object has the same signature as the original
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("serialize → file → deserialize in a new subprocess → call: the result matches the original function ✓")
Why weights must be arguments, not closed-over constants¶
If you write jax.jit(lambda x: model_with_baked_weights(x)), the weights get baked into the program as stablehlo.constant. That causes three problems:
- Program and data are welded together: changing the weights means re-exporting; the export file grows linearly with the parameter count, and a model with billions of parameters simply won't fit in one StableHLO module (protobuf/FlatBuffer both have limits around 2GB).
- You can't manage the data independently: the weights lose what Orbax gives you, such as sharding, asynchronous writes and on-demand loading.
- It carries this machine's CPU bug into the artifact: for the bug itself see "Known issues in this environment" in 00 Overview. Once the weights are baked into the exported artifact as constants, calling it on CPU silently returns wrong results just the same.
Below we verify this with the same data as Advanced/02:
# ❌ Wrong: baking the weights into the exported program as closed-over constants (program and data welded together, a bloated file, and it triggers the CPU backend 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 reference value
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"Closure version: export file {size_closure:,} bytes; result {r_closure:.4f}, reference {ref:.4f}, error {abs(r_closure - ref):.2e}")
print("Did the closure version trigger the CPU bug:", abs(r_closure - ref) > 1.0)
except Exception as e:
print("Measured error:", type(e).__name__, e)
# The weights really are embedded in the StableHLO as a 128×128 constant
assert re.search(r"stablehlo\.constant dense<.*> : tensor<128x128xf32>", exp_closure.mlir_module())
# ✅ Correct: pass the weights in as arguments; the exported program contains no weights at all
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"Argument version: export file {size_param:,} bytes; result {r_param:.4f}, error {abs(r_param - ref):.2e}")
print(f"The closure version's file is {size_closure / size_param:.0f}× the argument version's (the {W_np.nbytes:,} bytes of weights were baked in)")
assert abs(r_param - ref) < 1e-2 # the argument version is correct
assert size_closure > W_np.nbytes > 10 * size_param # the closure version's size is mostly that copy of the weights
assert not re.search(r"tensor<128x128xf32> = stablehlo\.constant|constant dense<[^>]{100,}", exp_param.mlir_module())
§2.3 Shape polymorphism: export once, handle any batch size¶
🟢 Must-read
The artifact above only accepts x.shape == (2, 4). An inference service sees varying batch sizes, and exporting one artifact per size obviously isn't practical. Shape polymorphism keeps some dimensions symbolic:
x_spec = jax.ShapeDtypeStruct(export.symbolic_shape("b, 4"), jnp.float32) # dim 0 is the symbol b
At export time, JAX traces the function with the symbol b, and the corresponding dimension shows up as ? (a dynamic dimension) in the StableHLO. At call time, JAX solves for the value of b from the actual argument's shape, then does shape refinement, replacing ? with the concrete value, and then compiles. The module also gets shape_assertions inserted, which check at call time that the arguments satisfy the assumptions made at export time.
x_poly = jax.ShapeDtypeStruct(export.symbolic_shape("b, 4"), jnp.float32)
print("Symbolic shape:", x_poly.shape, "| type:", 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 signature:", 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()) # symbolic shapes can be serialized too
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("One exported artifact; batch = 1 / 5 / 17 / 256 all match the original function ✓")
Checks at call time: only symbolic dimensions can vary¶
Dimensions fixed at export time (the 4 here) and dtypes are checked at call time; a mismatch raises ValueError right away, with no silent broadcasting or type conversion.
# ❌ Wrong: calling with a shape / dtype that doesn't match the export signature
errs_call = {}
for name, bad_x in [("feature dim 5 ≠ 4", jnp.ones((3, 5))), ("dtype int32", jnp.ones((3, 4), jnp.int32))]:
try:
exp_poly.call(params, bad_x)
errs_call[name] = "it runs"
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["feature dim 5 ≠ 4"]
assert "Dtype mismatch for args[1]: expected float32 and called with int32" in errs_call["dtype int32"]
# ✅ Correct: outside the export boundary, convert the input to the shape and dtype the export signature requires
raw = np.arange(12, dtype=np.int64).reshape(3, 4) # data from outside may well be int64
y_ok = exp_poly.call(params, jnp.asarray(raw, jnp.float32))
assert y_ok.shape == (3, 3)
print("Convert the input to float32, (b, 4) first, then call ✓")
Arithmetic on symbolic dimensions: divisibility and conditionals¶
Symbolic dimensions can take part in addition, subtraction, multiplication, floor division, max/min and so on, but JAX has to be able to reach a definite conclusion for every possible value. Two common errors:
- Not evenly divisible: reshaping
(b, 4)to(-1, 3)requires4bto be divisible by 3, which isn't true for everyb, so export fails withInconclusiveDimensionOperation. - Inconclusive conditionals: Python's
if x.shape[0] > 2:needs a definite boolean, butb > 2holds for someband not for others, which also raisesInconclusiveDimensionOperation.
By default JAX assumes every dimension variable is $\ge 1$, so b >= 1 is decided as true, while b >= 2 is inconclusive. When you need stronger assumptions, declare them explicitly with constraints=; these constraints are checked at call time.
# ❌ Wrong: reshape to (-1, 3); 4b isn't necessarily divisible by 3
try:
export.export(jax.jit(lambda x: x.reshape(-1, 3)))(x_poly)
print("Measured: it exports")
except Exception as e:
err_div = f"{type(e).__name__}: {e}"
print("Measured error:", err_div[:220])
assert err_div.startswith("InconclusiveDimensionOperation") and "Cannot divide evenly" in err_div
# ✅ Correct: only do reshapes that work for every b; the output shape contains the symbolic expression 2*b
exp_r2 = export.export(jax.jit(lambda x: x.reshape(-1, 2)))(x_poly)
print("Output shape of 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)
# ❌ Wrong: a Python conditional on a symbolic dimension (b > 2 holds for some b and not for others)
def scale_if_large(x):
if x.shape[0] > 2: # tracing needs a definite Python bool here
return x * 2.0
return x
try:
export.export(jax.jit(scale_if_large))(x_poly)
print("Measured: it exports")
except Exception as e:
err_cmp = f"{type(e).__name__}: {e}"
print("Measured error:", err_cmp.split("\n")[0])
assert err_cmp.startswith("InconclusiveDimensionOperation") and "'b' > '2' is inconclusive" in err_cmp
# ✅ Correct: declare b >= 3 with constraints, and the comparison has a definite answer; the constraint is checked at call time
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 violates the constraint
except ValueError as e:
err_ge3 = str(e)
print("Error when calling with b = 2:", err_ge3[:110], "...")
assert "do not match the symbolic shape constraint b >= 3" in err_ge3
print("With the constraint declared it exports; calls that violate it are stopped by shape_assertion ✓")
Trap: floordiv exports fine but fails at call time during "shape refinement"¶
If the function does x.reshape(x.shape[0] // 2, 2, 4), export succeeds and the output shape is floordiv(b, 2). But this reshape is only valid when b is even: pass b = 3, and 12 elements don't match 1×2×4 = 8. The export stage can't catch this; you only find out at call time, during shape refinement, with a not-very-clear Error refining shapes.
The right approach is to put the assumption into the shape spec: use "2*k, 4" to declare that dim 0 is always even. Then x.shape[0] // 2 simplifies to k, and passing an odd size at call time gives a clear error: Division had remainder 1 when computing the value of 'k'.
# ❌ Wrong: floordiv(b, 2) silently assumes "b is even"; it exports, but calling with an odd b fails during shape refinement
half_reshape = lambda x: x.reshape(x.shape[0] // 2, 2, 4)
exp_floor = export.export(jax.jit(half_reshape))(x_poly)
print("Export succeeded, output shape:", exp_floor.out_avals[0].shape)
print("Call with b = 4:", exp_floor.call(jnp.ones((4, 4))).shape)
try:
exp_floor.call(jnp.ones((3, 4)))
print("b = 3: it runs")
except ValueError as e:
err_refine = str(e)
print("Error when calling with b = 3: ValueError:", err_refine)
assert str(exp_floor.out_avals[0].shape[0]) == "floordiv(b, 2)"
assert "Error refining shapes" in err_refine
# ✅ Correct: put "even" into the shape spec "2*k, 4"; b // 2 simplifies to k, and odd inputs are clearly rejected at call time
x_even = jax.ShapeDtypeStruct(export.symbolic_shape("2*k, 4"), jnp.float32)
exp_even = export.export(jax.jit(half_reshape))(x_even)
print("Input spec:", exp_even.in_avals[0].shape, "→ output 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("Error when calling with 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
Several arguments sharing a symbolic dimension: scopes¶
When the symbolic dimensions of several arguments need to be "the same b" (say x: (b, 4) and mask: (b,)), those symbols must come from the same scope (a SymbolicScope, which also holds the constraints). Calling symbolic_shape twice creates two scopes, and export fails.
There are two ways to do it: split out all the shapes with a single symbolic_shape call, or use export.symbolic_args_specs(args, specs) to generate them in bulk from "example arguments + spec strings". In a spec, _ means "keep this dimension from the example argument".
mask_fn = lambda x, m: x * m[:, None]
# ❌ Wrong: two independent symbolic_shape calls, so the two "b"s belong to different scopes
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("Measured: it exports")
except ValueError as e:
err_scope = str(e)
print("Measured error: ValueError:", err_scope.split("\n")[0])
assert "Invalid mixing of symbolic scopes" in err_scope
# ✅ Correct: symbolic_args_specs generates the specs for all arguments in one scope ("_" keeps the example argument's dimension)
specs = export.symbolic_args_specs((jnp.ones((2, 4)), jnp.ones((2,))), ("b, _", "b"))
print("Generated specs:", 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)) # the two arguments disagree on b
except ValueError as e:
print("Error when b is inconsistent:", str(e)[:120], "...")
assert "Found inconsistency between dimension size args[1].shape[0] (= 5)" in str(e)
🔍 Extra: a few rules for symbolic dimensions¶
⚪ Optional: when polymorphic export can't "solve for a dimension variable", or an equality comparison is unexpectedly False
- Dimension variables can only be "solved" from input shapes: JAX only solves linear equations in one unknown. The spec
"a + b, 4"can't solve for bothaandbfrom one number and raisesCannot solve for values of dimension variables;"2*k"and"k + 1"both work. - Equality means "equal for every value":
b == 1is decided asFalse(because it doesn't always hold), and the docs explicitly say this is "not completely reliable". Code that relies onx.shape[0] == 1as a special case for broadcasting takes theFalsebranch under polymorphic export, so check it yourself. - Debugging: when shape refinement fails, set the environment variable
JAX_DUMP_IR_TO=<dir>to write out the module before and after refinement; theshape_assertioninformation in the error points to the argument and dimension involved. - Data-dependent shapes (such as
jnp.nonzero(x)withoutsize=) aren't allowed injitto begin with, let alone in export. Symbolic dimensions only cover "shapes determined by input shapes".
# Check the first two rules above
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("Spec 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 Multi-platform export¶
⚪ Optional: when the same artifact has to run on several kinds of hardware
export.export(jax.jit(f), platforms=("cpu", "cuda", "tpu")) lowers separately for each platform and then merges them into one StableHLO module: @main gets an extra integer argument platform_index, and inside the module it dispatches to the implementation for that platform. At call time JAX fills in this argument automatically based on the current device, so you never notice it.
⚠️ This section only does lowering; nothing was run on GPU/TPU. This machine has only a CPU. Lowering doesn't need the corresponding hardware, so the
cudaandtpuversions of the program can be generated and serialized, but only thecpuone has actually been run here.
Why export for multiple platforms: training happens on TPU and inference on GPU, or one artifact has to ship to services running on several kinds of hardware. Most ops lower the same way on every platform, so a multi-platform module is only a little bigger than a single-platform one. The differences come mainly from a few platform-specific lowerings, such as some implementations of random numbers, linear algebra or sorting.
# Lowering only; not run on 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("Start of the multi-platform @main signature:", main_signature(exp_mp)[:110], "...")
size_cpu, size_mp = len(exp_cpu.serialize()), len(exp_mp.serialize())
print(f"Serialized size: cpu only {size_cpu:,} bytes; cpu+cuda+tpu {size_mp:,} bytes ({size_mp / size_cpu - 1:.0%} more)")
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 # shared parts are stored only once
# Calling on this machine (CPU): the cpu branch is selected automatically and the result matches the original function
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("The multi-platform artifact called on CPU (cpu branch selected automatically) gives the right result ✓")
# ❌ Wrong: exporting only for cuda, then calling it on CPU
exp_cuda = export.export(jax.jit(mlp_apply), platforms=("cuda",))(sds(params), x_poly) # lowering only
try:
exp_cuda.call(params, xb)
print("Measured: it runs")
except ValueError as e:
err_plat = str(e)
print("Measured error: ValueError:", err_plat)
assert "was exported for platforms '('cuda',)' but it is used on '('cpu',)'" in err_plat
# ✅ Correct: include every platform it will run on in platforms (including cpu for local development / testing)
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'): callable directly on CPU; the cuda branch was only lowered ✓")
§2.5 Exporting differentiable functions: vjp_order¶
⚪ Optional: when the loading side needs to differentiate the exported function, or you want to know which transformations
Exported.callworks with
Within the same process, an Exported remembers how to produce its own backward function (has_vjp() is True), so jax.grad(exp.call) just works: JAX exports the VJP function when it's needed.
But serialization doesn't save the backward function by default: serialize(vjp_order=0). The object you read back can only run forward, and differentiating it raises ValueError: No VJP is available. If the loading side needs derivatives (say, fine-tuning the exported model, generating adversarial examples, or computing input gradients for interpretability), declare serialize(vjp_order=k) on the exporting side: it exports and serializes the VJP functions of orders 1 through k along with it, so reverse-mode derivatives up to order k are available.
In 0.12 the signature of Exported.serialize is serialize(self, vjp_order: int = 0) -> bytearray (confirmed with inspect.signature).
import inspect
print("Exported.serialize signature:", 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() in the same process:", exp_loss.has_vjp())
g_ref = jax.grad(mse_loss)(params, xg, yg)
g_exp = jax.grad(exp_loss.call)(params, xg, yg) # differentiate the exported function; the gradient is still a dict
print("Gradient structure:", 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() # the backward function is itself an Exported
print("vjp() takes", len(vjp_exp.in_avals), "inputs (6 leaves of the original inputs + 1 output cotangent) and returns", len(vjp_exp.out_avals), "outputs")
assert len(vjp_exp.in_avals) == 7 and len(vjp_exp.out_avals) == 6
# ❌ Wrong: save with the default serialize() (vjp_order=0), then differentiate after reading it back
loaded0 = export.deserialize(exp_loss.serialize())
try:
jax.grad(loaded0.call)(params, xg, yg)
print("Measured: it can be differentiated")
except ValueError as e:
err_vjp0 = str(e)
print("Measured error: ValueError:", err_vjp0, "| has_vjp() =", loaded0.has_vjp())
assert err_vjp0 == "No VJP is available" and not loaded0.has_vjp()
# ✅ Correct: declare the order you need, vjp_order=k, on the exporting side; reverse-mode derivatives up to order k are available
sizes = {k: len(exp_loss.serialize(vjp_order=k)) for k in (0, 1, 2)}
print("Serialized size (bytes):", 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 # first order OK
def grad_norm2(load): # second order: differentiate the "squared gradient norm" again
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("Second derivative with 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: first derivative correct, second derivative fails; vjp_order=2: second derivative matches the original function ✓")
Which transformations Exported.call supports¶
In a jaxpr, Exported.call is a call_exported primitive wrapped in a custom_vjp. That tells you how it interacts with the various transformations (each item is verified below):
| Transformation | Result | Why |
|---|---|---|
jax.jit | ✅ works, and can be nested inside a larger jit function | the primitive has a lowering rule and is inlined into the outer module |
jax.grad / jax.vjp | ✅ (same process, or vjp_order ≥ 1) | goes through the custom_vjp backward function |
jax.jvp / jax.jacfwd | ❌ TypeError | custom_vjp functions don't support forward mode |
jax.vmap | ❌ NotImplementedError | call_exported has no batching rule |
When you need batching, build the batch dimension into the program before exporting: either use a symbolic batch dimension (§2.3) or export jax.vmap(f) itself.
# ❌ Wrong: vmap / jvp over Exported.call
x_single = jax.random.normal(jax.random.key(12), (2, 4))
x_stack = jax.random.normal(jax.random.key(13), (5, 2, 4)) # 5 groups, each (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] = "it runs"
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"]
# ✅ Correct: build the batch dimension into the program before exporting, by exporting the vmapped function (plus a symbolic dimension n here)
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 can nest: the exported function can be part of a larger jit function (pre/post-processing written in 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("Export vmap(f) instead of vmap(exp.call) ✓; Exported.call can be nested inside an outer jit ✓")
jaxpr_txt = str(jax.make_jaxpr(predict_labels)(params, xb))
print("The outer jaxpr contains the call_exported primitive:", "call_exported" in jaxpr_txt)
assert "call_exported" in jaxpr_txt
§2.6 Version compatibility: three layers of version numbers¶
🟢 Must-read
Exported artifacts are meant to run outside "the JAX that exported them", so compatibility is the central issue. Three layers of versions are involved:
| Layer | What it is | Measured on this machine |
|---|---|---|
| Serialization format version | the format version of the FlatBuffer wrapper (metadata such as the signature, PyTree and sharding); the JAX internal constant _SERIALIZATION_VERSION | 12 (see below) |
| Calling convention version | the argument convention of @main: how dimension variables, platform_index, tokens, effects, sharding attributes and so on are passed; Exported.calling_convention_version | default 10, supports 9–10 |
| StableHLO version | the opset version of the program itself (the portable bytecode); when serializing, JAX targets "a version at least 4 weeks old" | see below |
Official promises (based on the JAX docs Compatibility guarantees and StableHLO compatibility.md):
- 6 months of backward compatibility: an artifact exported and serialized with an older JAX can be loaded and run by any jaxlib released within the following 6 months, with the same numerics as the old version (measured by jaxlib release date). Artifacts outside the window are refused by default; you can let them through temporarily with
--jax_export_deserialize_expired_versions=1. - 3 weeks of forward compatibility: an artifact exported by a newer JAX can be run by a jaxlib from 3 weeks earlier, as long as it doesn't use newer features.
- StableHLO itself promises 5 years of backward and 2 years of forward compatibility for portable artifacts; JAX tightens that to the 6 months / 3 weeks above, because it also depends on the calling convention and custom calls.
- Custom calls aren't covered: of the
stablehlo.custom_calltargets that can appear in a module, only the batch JAX explicitly lists as "stable" (such as LAPACK,shape_assertion, Pallas TPU'stpu_custom_calland Mosaic GPU'smosaic_gpu_v2) can be serialized. Other targets fail at export withCannot serialize code with custom calls whose targets have no compatibility guarantees; once you've accepted the risk, you can let them through withdisabled_checks=[export.DisabledSafetyCheck.custom_call("target_name")]. - The promise only covers serialized artifacts: the text printed by
mlir_module()andjit(f).lower().as_text()come with no compatibility guarantee and can't be used as a delivery format. - The input to deserialization must be trusted: the official docs say explicitly "The serialized bytearray must be trusted input".
import jax._src.export.serialization as _ser # private module; we only read one constant here to look at it, don't depend on it in your code
from jax.extend.mlir.dialects import stablehlo as shlo
print("Serialization format version _SERIALIZATION_VERSION =", _ser._SERIALIZATION_VERSION)
print("Calling convention: default", jax.config.jax_export_calling_convention_version,
"| supported range", 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: current version", shlo.get_current_version(), "| minimum readable version", shlo.get_minimum_version(), "| serialization target version (≥4 weeks old)", target)
bc = exp.mlir_module_serialized
print("Portable bytecode header:", bc[:4], "(MLIR bytecode magic number '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"
Exporting with an older calling convention: when you hand off to a slightly older consumer, you can pick a lower calling convention through the jax_export_calling_convention_version config option (environment variable JAX_EXPORT_CALLING_CONVENTION_VERSION). Below we export and serialize with version 9 in a subprocess, then load and run it in the current process (default 10), verifying that "a newer JAX can read artifacts with an older convention".
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("Subprocess output:", 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("Read back in the current process: 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("A version-9 artifact runs fine in an environment that defaults to version 10 ✓")
🔍 Extra: turning compatibility into an engineering process¶
⚪ Optional: when you put exported artifacts into CI, archive them long term, or export with custom kernels
- Record the artifact together with the version that exported it: put
jax.__version__, the export date,calling_convention_versionandplatformsin the file name or in a JSON file next to it. For artifacts older than 6 months, re-export with a new version instead of relying ondeserialize_expired_versions. - Regression tests: archive "golden inputs → golden outputs" together with the exported artifact. Every time you upgrade JAX, load the old artifact and run it, and also re-export with the new JAX and run that; both must cross-check against the golden outputs. JAX's own
back_compattests work this way. - Custom calls are the biggest risk: FFI targets you register yourself (Advanced/05) and Pallas's Triton backend (
__gpu$xla.gpu.triton) aren't on the stable list, so export needs an explicitDisabledSafetyCheck.custom_call(...), and the consumer must register a target with the same name;pure_callbackcan't be serialized at all (§4.4). Pallas TPU kernels (tpu_custom_call) and Mosaic GPU kernels (mosaic_gpu_v2) are on the stable list in 0.12 and can be serialized directly (verified below, lowering only).
# Measured: a Pallas TPU kernel needs no DisabledSafetyCheck, a custom FFI target does (lowering only, not run on TPU)
from jax.experimental import pallas as pl
from jax._src.export._export import _CUSTOM_CALL_TARGETS_GUARANTEED_STABLE as _STABLE # private constant; we only read it to look at it
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() # no disabled_checks passed
assert "tpu_custom_call" in exp_pl.mlir_module() and len(blob_pl) > 0
print("Pallas TPU kernel: export + serialization succeeded (lowering only)")
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("Custom FFI target: 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("With an explicit DisabledSafetyCheck.custom_call it exports (the consumer must register a target with the same name to run it)")
print("Stable list: 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 Exporting functions with sharding, shard_map or pmap¶
⚪ Optional: when exporting multi-device functions with sharding or
shard_map
When you export a sharded function, Exported records nr_devices and the shardings of the inputs and outputs on an abstract mesh (only axis names and sizes, no concrete devices). The rules are below (from the Device-polymorphic export section of the official docs; we verify each one in a subprocess that simulates 4 CPU devices with --xla_force_host_platform_device_count=4):
- An artifact exported with
nr_devices = Ncan only be called in an environment with exactly N devices, and the call must happen inside a mesh of matching size (jax.set_mesh). Which N devices doesn't matter, because what's saved is an abstract mesh. - An artifact exported on a single device (
nr_devices = 1, no sharding annotations) is device-polymorphic: you can call it directly with sharded inputs, and JAX compiles it according to the input shardings. shard_mapcan be exported, and the collectives inside it (psum→all_reduce) are kept as they are in the StableHLO.pmapisn't the result ofjit, so it can't be passed directly toexport.export; wrapping it asjax.jit(jax.pmap(...))does export, butpmapis a legacy interface, and new code should usejit+ sharding orshard_mapinstead (for how to migrate, see the Extra in Advanced/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) # input split along the batch dim across 4 devices
check("1 nr_devices==4", e.nr_devices == 4, e.nr_devices)
check("1 input sharding recorded", 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 call in the same mesh is correct", np.allclose(y, f(w, x), atol=1e-6), y.sharding.spec)
blob = e.serialize()
e2 = export.deserialize(blob)
try: # call outside any mesh
e2.call(w, x); check("1 call without a mesh should fail", False)
except ValueError as err:
check("1 call without a 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: # a mesh with only 2 devices
with jax.set_mesh(mesh2):
e2.call(jax.device_put(w, P()), jax.device_put(x, P("data")))
check("1 call on 2 devices should fail", False)
except ValueError as err:
check("1 call on a 2-device 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): # the same 4 devices in a different order
y = e2.call(jax.device_put(w, P()), jax.device_put(x, P("data")))
check("1 call on a different 4-device mesh is correct", np.allclose(y, f(w, x), atol=1e-6))
e1 = export.export(jax.jit(f))(w, x) # rule 2: single-device export
with jax.set_mesh(mesh):
y = e1.call(ws, xs)
check("2 single-device artifact accepts sharded inputs", 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") # rule 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 exports and contains all_reduce", e3.nr_devices == 4 and "all_reduce" in txt)
check("3 shard_map result is correct", float(e3.call(xs)) == float(x.sum()), float(e3.call(xs)))
try: # rule 4: pmap
export.export(jax.pmap(lambda v: v * 2))(jnp.ones((4, 3))); check("4 exporting pmap directly should fail", False)
except ValueError as err:
check("4 exporting pmap directly → 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) exports", 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
Key points:
- If you want one artifact to work on clusters of different sizes, the simplest way is to export the single-device version (rule 2) and let the caller decide how to shard; the cost is that the exporting side can't pin down the communication pattern.
- When you need a fixed sharding scheme (say, tensor-parallel inference for a large model), fix the mesh shape at export time with
jax.make_mesh+jax.set_mesh; the caller must provide a mesh of the same size. - In multi-host (multi-process) setups, the exported artifact doesn't care about "processes", only the global device count; on the loading side each process does its own
deserializeand calls it inside the same global mesh.
§3 Orbax in depth: managing checkpoints during training¶
§3.1 A quick recap (details in Advanced/06 §6.3)¶
⚪ Optional: if you've already done Advanced/06 §6.3, you only need the "target directory already exists" trap
Advanced/06 §6.3 already covered this: the training state to save includes parameters, optimizer state, the step, the RNG key and BN statistics; you save with ocp.StandardCheckpointer().save(path, pytree), saving is asynchronous, so call wait_until_finished() before reading back; and on restore you build an abstract template with nnx.eval_shape and write it back into the model with nnx.update. Here we add just one trap that chapter didn't mention: saving again when the target directory already exists raises an error.
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()
# ❌ Wrong: saving to the same directory again (for example, overwriting one "latest checkpoint" directory every epoch)
try:
ckptr.save(ckpt_path, {"w": state0["w"] + 1, "step": jnp.array(1)})
ckptr.wait_until_finished()
print("Measured: it overwrites")
except ValueError as e:
err_exists = str(e)
print("Measured error: ValueError:", err_exists.replace(str(SCRATCH), "<SCRATCH>"))
assert "already exists" in err_exists
# ✅ Correct: when you really mean to overwrite, pass force=True explicitly; for multi-step saving use CheckpointManager (§3.2), one subdirectory per step
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 overwrote it, and what we read back is the new content ✓")
§3.2 CheckpointManager: multi-step saving and retention policies¶
🟢 Must-read
During training you usually save every few steps, keep only the last few, plus the few with the best validation metric. ocp.CheckpointManager(directory, options=ocp.CheckpointManagerOptions(...)) takes care of this: one subdirectory per step, with old ones cleaned up automatically. Common options:
| Option | What it does |
|---|---|
max_to_keep=n | keep at most n checkpoints |
save_interval_steps=k | only actually save when the step is a multiple of k (save() returning False means this step was skipped) |
best_fn=lambda metrics: ..., best_mode="min"/"max" | pick the "best" checkpoints by the metrics passed to save(..., metrics=...) |
preservation_policy=... | more flexible combinations of retention policies (LatestN, BestN, EveryNSteps, …) |
Saving and restoring look like mngr.save(step, args=ocp.args.StandardSave(pytree), metrics=...) and mngr.restore(step, args=ocp.args.StandardRestore(template)).
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("Return value of save() at each step:", saved_flags)
print("Steps kept on disk:", mngr.all_steps(), "| subdirectories:", 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()
Keeping the best by metric: an easy trap¶
When best_fn is combined with max_to_keep, max_to_keep comes to mean "keep the n with the best metric". If the latest step has a bad metric, it gets deleted outright. Even latest_step() no longer returns the step you really saved last, so resuming training has no "latest state" to start from.
losses = [5.0, 1.0, 4.0, 2.0, 9.0] # step 4 (the latest) has the worst validation loss
# ❌ Wrong: relying only on best_fn + max_to_keep and expecting it to "keep the best 2 + the latest"
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("Measured: steps kept", 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("Measured error:", type(e).__name__, e)
assert kept_bad == [1, 3] and 4 not in kept_bad # the latest step, 4, was deleted
assert latest_bad == 3 # latest_step() also "went back" to step 3
# ✅ Correct: use preservation_policy to combine "the latest 1" and "the best 2"
cm = ocp.checkpoint_managers
policy = cm.AnyPreservationPolicy([
cm.LatestN(n=1), # for resuming training
cm.BestN(get_metric_fn=lambda m: m["loss"], reverse=True, n=2), # reverse=True: smaller metric is better (measured)
])
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("Steps kept:", 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("The latest 1 + the best 2 are all there; restoring best_step gives the weights from step 1 ✓")
It's easy to get the direction of BestN(reverse=...) backwards. The answer: reverse=False keeps the n with the largest metric, reverse=True keeps the n with the smallest. Verified below with the same losses:
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 keeps", kept[False], "; reverse=True keeps", kept[True])
assert kept[False] == [0, 4] and kept[True] == [1, 3]
§3.3 Getting an abstract template with jax.eval_shape¶
⚪ Optional: when you need to convert dtypes on restore, or the checkpoint doesn't match the model structure
The template you restore with decides what you get back: the PyTree structure and each leaf's shape, dtype and sharding. A template only needs jax.ShapeDtypeStructs, which you get from jax.eval_shape(init_fn, ...); it only traces shapes and allocates no memory (for NNX models use nnx.eval_shape, see Advanced/06).
Templates have three practical uses:
- dtype conversion: write
bfloat16in the template and Orbax converts on restore (for example, fp32 training and bf16 inference); - Sharding: put
sharding=NamedSharding(...)in the template, and each device reads only its own piece (the Extra in Advanced/06 §6.3); - Checking: a shape or structure mismatch raises an error right away instead of silently truncating.
init_fn = lambda key: init_mlp(key)
abstract = jax.eval_shape(init_fn, jax.random.key(0)) # allocates no memory
print("Abstract template:", 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("Restored with a bf16 template:", {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("Restoring with the eval_shape template is bit-identical in fp32; restoring with a bf16 template converts automatically ✓")
# ❌ Wrong: the template's shapes don't match what's on disk (say, you changed the hidden width but load an old checkpoint)
wrong = dict(abstract, w1=jax.ShapeDtypeStruct((4, 16), jnp.float32))
try:
ckptr.restore(SCRATCH / "mlp_params", wrong)
print("Measured: it restores")
except ValueError as e:
err_shape = str(e)
print("Measured error: ValueError:", err_shape[:150], "...")
assert "is not compatible with the stored shape: (4, 8)" in err_shape
# ✅ Correct: look at the structure and shapes on disk with metadata() first, then decide on the template (or confirm which model version you're loading)
meta_tree = ckptr.metadata(SCRATCH / "mlp_params").item_metadata.tree
print("Shapes on disk:", {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 Working with Flax NNX: save, restore, keep training¶
🟢 Must-read
NNX modules are mutable objects, but nnx.state(model) / nnx.split(model) give you a pure-PyTree State at any time, which Orbax can save directly. The restore flow:
nnx.eval_shape(lambda: build the model and optimizer)gives you an abstract model and optimizer;- use their
nnx.state(...)as the Orbax restore template; nnx.split(abstract_model)gives you theGraphDef(the model structure), thennnx.merge(graphdef, restored_state)gives you the real model; for the optimizer, write the state back withnnx.update.
For resumed training to be bit-identical to "training straight through", everything that affects the training trajectory has to go into the checkpoint: parameters, optimizer state (Adam's $m$, $v$ and the step), and the data position. The data batches below are generated by jax.random.fold_in(data_key, step), with step taken from the optimizer state, so restoring the optimizer restores the data position too.
Weights and data are both passed in as function arguments (arguments to nnx.jit), with no closed-over constants, which avoids this machine's 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)) # "teacher" matrix used to generate labels
def make_batch(data_key, step, teacher, n=128):
"""The data batch for a given step: determined by (data_key, step), so restoring step restores the data position."""
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) # data position comes from the optimizer step
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) Train N1 + N2 steps straight through, as the reference
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) Train N1 steps, then save
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("Saved steps:", mngr.all_steps(), "| State type:", type(nnx.state(model_a)).__name__)
assert mngr.all_steps() == [N1]
del model_a, opt_a # simulate the process exiting: the in-memory model is gone
# (c) A "new process": restore from the abstract template, merge the model, and keep training for N2 steps
abs_model, abs_opt = nnx.eval_shape(make_model_and_opt) # shapes only, no memory allocated
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 structure
model_b = nnx.merge(graphdef, restored["model"]) # structure + restored parameters
opt_b = nnx.Optimizer(model_b, TX, wrt=nnx.Param)
nnx.update(opt_b, restored["opt"]) # Adam's m, v and step
print("optimizer.step after restore =", 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"Max difference between the resumed curve and the straight-through curve: {gap:.1e}")
print(" step", N1 - 1, "(last step before saving):", round(curve_ref[N1 - 1], 6), "| first resumed step:", round(curve_b[0], 6),
"| reference step", N1, ":", round(curve_ref[N1], 6))
assert gap == 0.0 # bit-identical: the curves join seamlessly
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] # training really is making progress
print("save → restore → resume: the loss curve matches uninterrupted training bit for bit, and so do the final parameters ✓")
# ❌ Wrong: save only the parameters, not the optimizer state, and create a new Optimizer after restoring (Adam's m, v and step all reset to zero)
try:
model_c = nnx.merge(graphdef, restored["model"])
opt_c = nnx.Optimizer(model_c, TX, wrt=nnx.Param) # no 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"Measured: it runs, but the max difference from the reference curve = {gap_c:.3f}; the first resumed step has step = 0, and the data goes back to batch 0")
except Exception as e:
print("Measured error:", type(e).__name__, e)
assert gap_c > 1e-3
# ✅ Correct: save and restore the parameters and the optimizer state (including the step) together, as in (b)(c) above
assert int(opt_b.step[...]) == N1 + N2 and gap == 0.0
print("The optimizer state is restored along with the checkpoint, so the step, the Adam moments and the data position all line up ✓")
🔍 Extra: what else belongs in a checkpoint¶
⚪ Optional: when designing production training checkpoints (RNG, data pipelines, asynchronous saving)
- RNG: if the model has dropout, the counters in
nnx.Rngsare alsonnx.Variables (nnx.RngState), andnnx.state(model)brings them along, so the random number sequence picks up where it left off after restoring. If you save only the parameters withnnx.state(model, nnx.Param), you lose it. - Data pipeline position: in real training the data comes from files, so the
fold_in(step)trick here doesn't apply; data loaders such as Grain provide checkpointable iterator state, which should be saved alongside as another item (ocp.args.Composite(state=..., data=...)). - Overlapping asynchronous saving with compute:
mngr.savereturns immediately, a background thread writes to disk, and training continues. Before the program exits you must callmngr.wait_until_finished()ormngr.close(), or the last checkpoint may be only half written (Orbax uses a temp directory plus an atomic rename, so you'll never read a half-finished one, but that step is lost). - Inference only needs the parameters: when releasing a model, save a separate "inference checkpoint" with only
nnx.Param(optionally converted to bf16). It's much smaller than the full training state, since Adam state is 2× the parameters. §5 does exactly this.
§4 Overview of deployment targets¶
The artifact jax.export produces is StableHLO. It can go three ways:
┌─► jax.export.deserialize(...).call() Python + jaxlib (the main thread of this chapter, §5)
Exported ─serialize─┤
(StableHLO) ├─► mlir_module_serialized ─► C++ runtimes: PJRT plugins / IREE / in-house engines
└─► jax2tf ─► TF SavedModel ─► TF Serving / TFLite (needs TensorFlow)
§4.1 StableHLO text / bytecode → C++ runtimes (IREE, PJRT)¶
⚪ Optional: when handing the model to a C++ runtime (PJRT, IREE)
- Two forms:
exp.mlir_module()is text for humans (.mlir);exp.mlir_module_serializedis the StableHLO portable artifact (MLIR bytecode, headerML\xefR), and only it is covered by StableHLO's compatibility promise. - PJRT: the unified device C API defined by OpenXLA (
pjrt_c_api.h). Each kind of hardware implements it as a PJRT plugin, and frameworks use it to compile and run programs. JAX itself is a PJRT client: this machine's CPU backend, the CUDA plugin and TPU'slibtpuare all PJRT implementations. A C++ program can load a PJRT plugin directly and hand it StableHLO to compile and run, with no Python at all. - IREE: the MLIR compiler and runtime under OpenXLA. It can compile StableHLO into a standalone
.vmfbmodule that runs on servers, mobile and embedded devices (CPU/Vulkan/Metal/CUDA and other backends); a typical command isiree-compile --iree-input-type=stablehlo ....
⚠️ Not run on this machine: IREE and a standalone PJRT C++ project aren't installed here, so below we only generate and inspect the files you'd hand to them.
Things to watch when handing off to a C++ runtime:
- Prefer exporting fixed shapes. A polymorphic module has
?dimensions and@shape_assertioncustom calls, and shape refinement is something JAX does at call time; a C++ consumer either implements refinement itself or receives a version with fixed shapes. Below we compare the two kinds of module. - Weights as arguments: the C++ side loads weights from its own weight files (or the TensorStore/Zarr that Orbax saves) and passes them in the flattened order of
in_avals. So write thein_tree/in_avalsorder into the metadata together with the parameter names (themeta.jsonin §5). - In a multi-platform module the first argument is
platform_index, which the C++ side has to pass itself.
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"Text {mlir_txt_path.name}: {mlir_txt_path.stat().st_size:,} bytes; bytecode {mlir_bc_path.name}: {mlir_bc_path.stat().st_size:,} bytes")
print("Fixed-shape @main:", main_signature(exp_static)[:160])
def has_dynamic(m):
return bool(re.search(r"tensor<\?", m)) or "shape_assertion" in m
print("Fixed-shape module has dynamic dims / shape_assertion:", has_dynamic(exp_static.mlir_module()))
print("Polymorphic module has dynamic dims / 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"
# The text comes from deserializing the bytecode: an Exported read back holds only the bytecode, and mlir_module() parses it back into text
# (the C++ equivalent is stablehlo-translate --deserialize)
text_from_bc = export.deserialize(exp_static.serialize()).mlir_module()
assert text_from_bc == mlir_txt_path.read_text()
print("The StableHLO text recovered from bytecode is identical to the text printed directly ✓")
§4.2 The state of jax2tf¶
⚪ Optional: when you must produce a TF SavedModel
First, a look: jax.experimental.jax2tf still exists in 0.12 (the module file is still there), but importing it requires TensorFlow. TF isn't installed in this environment and we don't plan to install it, so here we only describe the current state:
spec_j2t = importlib.util.find_spec("jax.experimental.jax2tf")
print("jax.experimental.jax2tf module exists:", spec_j2t is not None, "→", Path(spec_j2t.origin).relative_to(Path(jax.__file__).parent.parent))
print("tensorflow installed in this environment:", importlib.util.find_spec("tensorflow") is not None)
try:
from jax.experimental import jax2tf
print("Measured: import succeeded")
except ModuleNotFoundError as e:
err_j2t = str(e)
print("Measured: import jax2tf → ModuleNotFoundError:", err_j2t)
assert spec_j2t is not None and err_j2t == "No module named 'tensorflow'"
Current state (based on the jax2tf README and the JAX export docs):
- Since JAX 0.4.14 (July 2023), the default mode of
jax2tf.convertis native serialization: internally it usesjax.exportto produce StableHLO and wraps it in a TFXlaCallModuleop. In the 0.12 source, thenative_serializationandenable_xlaarguments are both marked deprecated (passing them triggers aDeprecationWarning), so this is the only mode left. In other words,jax2tfis now just "jax.export+ a TF wrapper". - Use it when you need a TF SavedModel, for example TF Serving, existing TF inference infrastructure, or TF.js. Weights should be passed in as
tf.Variablearguments (the README's advice, for the same reason as §2.2: a closure turns the weights into graph constants). - TFLite and similar targets need the
XlaCallModuleconverted again into native TF ops; that path is long and coverage is incomplete. - When you don't need the TF ecosystem, use
jax.exportdirectly: fewer dependencies (justjax+flatbuffers) and a clear compatibility promise.
§4.3 ONNX: not on the official path¶
⚪ Optional: when the target runtime only accepts ONNX
JAX and OpenXLA have no official ONNX exporter. Common community approaches are jax2tf → SavedModel → tf2onnx, or third-party JAX→ONNX converters. All of them need extra dependencies, and you have to verify op coverage and numerical agreement yourself. If the target runtime only takes ONNX (some edge inference chips, for example), this route is worth considering; otherwise prefer StableHLO (IREE also targets server, mobile and embedded deployment, see §4.1). This route wasn't tried in this environment.
§4.4 Wrapping a model as a service function with pure_callback: the trade-offs¶
⚪ Optional: when you want to call an external library or service from inside an exported function
Sometimes you'll want to call an external inference service, a C++ library or another model from inside a JAX program, and pure_callback is the most direct bridge (for its semantics see Advanced/05 §5.4). But it conflicts with export: the callback is a Python object in the current process and can't possibly be serialized. Let's see what actually happens: with a fixed shape, export fails with NotImplementedError; with a symbolic shape you get the more cryptic RuntimeError: std::bad_cast.
def host_postprocess(logits):
"""Host-side NumPy post-processing (pretend it's some external library code)."""
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")
# ❌ Wrong: putting pure_callback inside the function you want to export (tried once with a fixed shape and once with a symbolic shape)
errs_cb = {}
for name, x_spec in [("fixed shape (8, 4)", jax.ShapeDtypeStruct((8, 4), jnp.float32)), ("symbolic shape (b, 4)", x_poly)]:
try:
export.export(jax.jit(model_with_callback))(sds(params), x_spec)
errs_cb[name] = "it exports"
except Exception as e:
errs_cb[name] = f"{type(e).__name__}: {str(e).splitlines()[0]}"
print(f"{name}: {errs_cb[name]}")
assert errs_cb["fixed shape (8, 4)"] == "NotImplementedError: serialization of host_callbacks is not yet implemented"
assert errs_cb["symbolic shape (b, 4)"].startswith("RuntimeError") # measured: an unfriendly std::bad_cast
# ✅ Correct: the export boundary contains only pure JAX computation; callbacks, I/O and preprocessing live outside the exported program (in the service layer)
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):
"""Service function: parse the request in Python → call the exported program → build the response in Python."""
x = np.asarray(request_rows, np.float32) # preprocessing (any Python/NumPy code)
probs = np.asarray(loaded_exp.call(loaded_params, x)) # pure JAX computation: exported and serializable
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("Service response:", resp)
ref_probs = host_postprocess(np.asarray(mlp_apply(params, xb[:3])))
assert [r["label"] for r in resp] == ref_probs.argmax(-1).tolist()
# Timing (printed only): pure JAX post-processing vs pure_callback post-processing, both inside the same 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 [("pure 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/call")
assert max_diff(f_jnp(params, xb_big), f_cb(params, xb_big)) < 1e-5
The trade-offs in summary:
Callback inside the JAX program (pure_callback) | Callback outside the exported program (service layer) | |
|---|---|---|
Can you jax.export / serialize it | ❌ NotImplementedError | ✅ |
| Device | runs only on the host (CPU); on GPU/TPU every call moves data between device and host | all computation stays on the device |
| Performance | every call goes back into Python (limited by the GIL) and breaks XLA fusion | enters and leaves Python only at the request boundary |
| Differentiable / vmap | needs custom_jvp and vmap_method | decided at export time (vjp_order, symbolic batch) |
| Good for | research prototypes, calling libraries you can't rewrite in JAX yet, debugging | production inference services |
Rule of thumb: put only pure JAX computation inside the export boundary; I/O and external library calls such as tokenization, decoding, image decoding and database queries belong in the service layer. If you really need to call C++ code on the device, use FFI (jax.ffi.ffi_call, see Advanced/05) and register a custom call target with the same name on both sides.
§5 Putting it together: train → checkpoint → export → standalone inference in a brand-new subprocess¶
The full workflow:
- Train a small MLP (3 classes) with Flax NNX + Optax, saving checkpoints with
CheckpointManagerduring training; - Restore from the checkpoint and verify it matches the in-memory model;
- Take only the
nnx.Params, convert them to a plain dict, and save a separate copy of the inference parameters with Orbax; export the inference function with a symbolic batch dimension withjax.export, serialize it to a file, and write a metadata filemeta.json; - In a brand-new subprocess (
python -I, working directory set to the artifact directory), load only the export file and the parameters, without importingflax,optaxor any model definition code, run inference, and cross-check against the original model; - List the generated files and their sizes.
The artifacts are written to the export_artifacts/ directory next to this notebook.
# 1. Data and training (weights and data are both passed in as function arguments)
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([ # the latest 1 (for resuming) + the 1 with the lowest test loss
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"Before training: 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("Checkpoints kept:", mngr.all_steps(), "| latest:", mngr.latest_step())
assert history[-1][2] < 0.5 * loss_init # test loss dropped clearly
assert history[-1][3] > 0.9 # test accuracy > 90%
best_hist = min(history, key=lambda r: r[2])[0]
print("Step with the lowest test loss:", 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. Restore from the latest checkpoint (with an abstract template) and verify it's bit-identical to the in-memory model
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("Restored step:", 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 # parameters are bit-identical
assert max_diff(restored_train["opt"], nnx.state(optimizer)) == 0.0 # Adam state is bit-identical
assert max_diff(model_restored(X_test[:5]), model(X_test[:5])) == 0.0
print("Training checkpoint restored successfully ✓")
3. Exporting the inference function: why the interface should use plain dicts¶
The input PyTree structure of an exported function (in_tree) gets serialized. The leaves of an nnx.State are Flax custom types such as nnx.Param, which JAX doesn't recognize when serializing:
graphdef_p, param_state = nnx.split(model, nnx.Param) # structure + parameters (nnx.State)
x_batch_spec = jax.ShapeDtypeStruct(export.symbolic_shape("b, 4"), jnp.float32)
# ❌ Wrong: using an nnx.State directly as the input of the exported function
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("Measured: it serializes")
except ValueError as e:
err_state = str(e)
print("Measured error: ValueError:", err_state)
assert "Cannot serialize PyTreeDef containing an unregistered type" in err_state and "Param" in err_state
# ✅ Correct: use plain nested dicts for the export interface; inside the function, rebuild the NNX model with "abstract template + replace_by_pure_dict + merge"
infer_params = nnx.to_pure_dict(param_state) # {'fc1': {'kernel':..., 'bias':...}, ...}
abs_param_state = sds(param_state) # the template holds only shapes, no weight values at all
def infer(pure_params, x):
state = jax.tree.map(lambda s: s, abs_param_state) # make a copy of the template
nnx.replace_by_pure_dict(state, pure_params) # fill in the (traced) parameters
return nnx.merge(graphdef_p, state)(x)
exp_infer = export.export(jax.jit(infer))(sds(infer_params), x_batch_spec)
print("Export interface 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
# The weights aren't baked into the program: the module has no 2D float constants at all
assert not re.search(r"constant dense<[^>]*> : tensor<\d+x\d+xf32>", exp_infer.mlir_module())
# Write files: the exported program, readable StableHLO text, metadata; the inference parameters are saved separately with 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. A brand-new subprocess: load only the export file and the parameters¶
The subprocess script imports only jax, numpy and orbax.checkpoint, is launched with python -I, and has its working directory set to export_artifacts/. It doesn't know how many layers the model has or which activation it uses; all it knows is "there's an export file and a set of parameters". The parameters are restored with Orbax without a template (giving a plain dict), and that structure matches the export interface exactly.
At the end the script checks that flax and optax really weren't loaded. The test inputs use four batch sizes, 1, 7, 64 and 1024, to verify the symbolic batch dimension.
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()) # the program
params = ocp.StandardCheckpointer().restore(Path("infer_params").absolute()) # the data (no template → plain dict)
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("subprocess: exported with JAX", meta["jax_version"], "| current JAX", jax.__version__, "| in_avals", exp.in_avals)
print("subprocess: output shapes", {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 "): # no model definition in the subprocess script
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}: output {sub_outs[k].shape}, max difference from the original NNX model {d:.2e}, predicted classes identical {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"Test accuracy of subprocess inference: {acc_sub:.3f} (matches the {history[-1][3]:.3f} at the end of training)")
assert abs(acc_sub - history[-1][3]) < 1e-6
print("A brand-new subprocess ran inference using only the export file + Orbax parameters, and the cross-check against the original model passed ✓")
# 5. List the generated files and their sizes
print(f"Artifact directory: {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("\nTotals by top-level entry:")
for name, b in summary.items():
print(f" {name:28s} {b / 1024:9.1f} KiB")
p_bytes = tree_bytes(infer_params)
print(f"\nRaw bytes of the inference parameters {p_bytes:,} B; exported program {summary['mlp_infer.jaxexport']:,} B (no weights)")
assert set(summary) == {"train_ckpt", "infer_params", "mlp_infer.jaxexport", "mlp_infer.stablehlo.mlir", "meta.json"}
assert summary["mlp_infer.jaxexport"] < p_bytes # the program is smaller than the weights: the weights weren't baked in
assert summary["infer_params"] >= p_bytes # the parameter checkpoint contains at least all the weights
assert summary["train_ckpt"] > 2 * summary["infer_params"] # training checkpoints: each kept step holds params + Adam's m and v (about 3× the params)
§6 Practical rules checklist¶
What to save, and with what
- Data (parameters, optimizer state, step, RNG, data position) goes in Orbax; programs go in
jax.export; the persistent compilation cache is only for faster restarts and isn't a delivery format. - To resume training you need the full training state: at least the parameters, the optimizer state (including the step) and the data position. If you save only the parameters, Adam's moments and the learning-rate schedule start over from scratch.
- When releasing an inference model, save a separate inference checkpoint with only
nnx.Param, optionally converted to bf16.
Exporting
- Weights are always passed as arguments to the exported function, never captured in a closure. Otherwise program and data are welded together, the file grows with the parameter count, and on this machine it also triggers the CPU backend bug.
- The export interface's PyTree should use only
dict/list/tuple, notnnx.Stateor custom classes; custom types first needexport.register_pytree_node_serialization. - Use
symbolic_shape("b, ...")for the batch dimension; when there are assumptions like "even" or "≥ 3", write them as"2*k"orconstraints=(...)rather than relying on//to imply them. - Dimensions shared by several arguments must come from the same scope: use a single
symbolic_shapecall orsymbolic_args_specs. - If the loading side needs derivatives, export with
serialize(vjp_order=k); if it needs batching, build the batch dimension into the program before exporting (Exported.callcan't bevmapped). - List every platform it will run on in
platforms=, includingcpufor development; GPU/TPU versions can only be lowered on a CPU, so verify on the target hardware before a real release.
Compatibility and operations
- Deliver only
serialize()artifacts ormlir_module_serializedbytecode, nevermlir_module()text. - Write metadata next to the artifact: JAX version, export date, calling convention version, platforms, the flattened parameter order, and the input/output signature.
- Mind the 6-month backward-compatibility window: re-export with a new JAX regularly and cross-check against archived golden outputs.
- Keep
pure_callback/io_callbackout of the export boundary (they can't be serialized); custom FFI targets and Pallas Triton kernels aren't on the stable list, need an explicitDisabledSafetyCheck, and the consumer must register an implementation with the same name (Pallas TPU / Mosaic GPU kernels are on the list, §2.6). - The input to
deserializemust be trusted; don't load artifacts of unknown origin. - Install
flatbuffersseparately in your environment, orserialize/deserializeraiseImportError.
Orbax
- Saving is asynchronous: call
wait_until_finished()/close()before reading back and before the process exits. - You can't
saveto the same directory twice; passforce=Truewhen you really mean to overwrite, and useCheckpointManagerfor multi-step saving. best_fn+max_to_keepkeeps only "the best n", and the latest may be deleted; combine them withAnyPreservationPolicy([LatestN, BestN]).BestN(reverse=True)keeps the smallest metric.- Always restore with an abstract template (
jax.eval_shape/nnx.eval_shape) and use it to control dtypes and sharding; a shape mismatch raises an error instead of truncating.
✍️ Hands-on exercises¶
Write each one yourself first, then look at the reference solution. The reference solutions are in the "Appendix: reference solutions" at the end of this chapter; the first line of the corresponding code cell is # Reference solution: Exercise N.
Exercise 1: two symbolic dimensions. A sequence model's input is x: float32[b, t, 8], where both the batch b and the sequence length t vary. The function pool_linear(params, x) first averages over t, then multiplies by params["w"] (shape (8, 2)) and adds params["b"] (shape (2,)). Get an artifact that handles any (b, t) from a single export, serialize it and read it back, and verify that (b, t) ∈ {(1, 1), (3, 5), (8, 17)} all match the original function; then verify that calling with a feature dimension other than 8 raises ValueError.
Self-check interface: pool_linear(params, x); exp1: the Exported object you get after exporting, serializing and reading back, called as exp1.call(params, x).
Exercise 2: fine-tuning with an exported loss function. Export the mse_loss from §2.5 and serialize it with vjp_order=1; after reading it back, do 50 steps of gradient descent on params (learning rate 0.1) using only the object you read back. Do the same 50 steps with the original mse_loss, and verify that the two parameter trajectories end up the same (max difference < 1e-5) and that the loss went down.
Self-check interface: loaded_loss: the Exported object for mse_loss after exporting, serializing and reading back; p_exp: the parameters after 50 steps of gradient descent using only loaded_loss.
Exercise 3: a combined retention policy. Use CheckpointManager and AnyPreservationPolicy to implement "keep the latest 2 + the 1 with the lowest validation loss". Save 6 steps in order, with losses [3, 1, 2, 5, 4, 6]. Work out on paper which steps will be kept, then verify with assert; finally restore the contents of best_step() and check them.
Self-check interface: make_manager3(directory): returns a CheckpointManager configured with this exercise's policy. The self-check saves again in a temp directory with a different loss sequence and checks the kept steps and best_step().
Exercise 4: the batch must be a multiple of 4. The function group_mean(x) splits x: float32[b, d] into groups of 4 rows and averages each group, giving [b/4, d]. Keep d symbolic too when exporting. Verify that the result is correct for b = 8, 12, that calling with b = 10 raises an error, and that the error message says the remainder is 2.
Self-check interface: group_mean(x); exp4: the exported Exported object, called as exp4.call(x).
# ✍️ Your answer: Exercise 1
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 1
try:
pool_linear, exp1
except NameError as e:
print(f"⏳ Exercise 1: not answered yet: {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 should be an export.Exported object, got {type(exp1).__name__}"
_xs = exp1.in_avals[-1].shape
assert len(_xs) == 3 and _xs[2] == 8, f"The exported shape of x should be (b, t, 8), got {_xs}"
assert not isinstance(_xs[0], int) and not isinstance(_xs[1], int), f"Both b and t should be symbolic dimensions, got {_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 # computed independently with NumPy
assert np.allclose(pool_linear(_p, jnp.asarray(_x)), _ref, atol=1e-5), f"For (b, t) = {_bt}, pool_linear gives the wrong result"
assert np.allclose(exp1.call(_p, jnp.asarray(_x)), _ref, atol=1e-5), f"For (b, t) = {_bt}, exp1.call gives the wrong result"
try:
exp1.call(_p, jnp.ones((2, 3, 5)))
except ValueError:
pass
else:
raise AssertionError("When the feature dimension isn't 8, exp1.call should raise ValueError")
print("✅ Exercise 1: self-check passed")
# ✍️ Your answer: Exercise 2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 2
try:
loaded_loss, p_exp
except NameError as e:
print(f"⏳ Exercise 2: not answered yet: {e}")
else:
assert isinstance(loaded_loss, export.Exported), f"loaded_loss should be an export.Exported object, got {type(loaded_loss).__name__}"
_p0 = jax.tree.map(lambda a: 0.5 * a + 0.1, params) # a new parameter point
try:
_g = jax.grad(loaded_loss.call)(_p0, xg, yg)
except Exception as _e:
raise AssertionError(f"Taking the gradient of loaded_loss.call failed ({type(_e).__name__}); check the arguments you serialized with") from None
assert max_diff(_g, jax.grad(mse_loss)(_p0, xg, yg)) < 1e-5, "The gradient of loaded_loss doesn't match the original function"
_q = params # recompute 50 steps independently with the original function for comparison
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 doesn't match the result of gradient descent with 50 steps and learning rate 0.1"
assert float(mse_loss(p_exp, xg, yg)) < float(mse_loss(params, xg, yg)), "The loss should go down after training"
print("✅ Exercise 2: self-check passed")
# ✍️ Your answer: Exercise 3
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 3
try:
make_manager3
except NameError as e:
print(f"⏳ Exercise 3: not answered yet: {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] # a different set of losses from the exercise
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"Should keep {_expect} (the latest 2 + the 1 with the lowest loss), but kept {_m.all_steps()}"
assert _m.best_step() == _best, f"best_step() should be {_best}, got {_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, "The restored contents are wrong"
_m.close()
shutil.rmtree(_d, ignore_errors=True)
print("✅ Exercise 3: self-check passed")
# ✍️ Your answer: Exercise 4
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 4
try:
group_mean, exp4
except NameError as e:
print(f"⏳ Exercise 4: not answered yet: {e}")
else:
assert isinstance(exp4, export.Exported), f"exp4 should be an export.Exported object, got {type(exp4).__name__}"
_rng = np.random.default_rng(7)
for _bb, _dd in [(16, 7), (4, 1), (20, 2)]: # several values of d: d must be a symbolic dimension
_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"For x.shape = {(_bb, _dd)}, group_mean gives the wrong result"
assert np.allclose(exp4.call(jnp.asarray(_x)), _ref, atol=1e-6), f"For x.shape = {(_bb, _dd)}, exp4.call gives the wrong result"
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"For b = {_bb} the error message should say the remainder is {_rem}: {str(_e)[:120]}"
else:
raise AssertionError(f"b = {_bb} isn't a multiple of 4, so exp4.call should raise ValueError")
print("✅ Exercise 4: self-check passed")
📝 Quiz¶
1. Which of these can run inference directly in a new process without the model's Python source code?
- A. A parameter checkpoint saved by Orbax
- B. A persistent compilation cache directory
- C. The output of
Exported.serialize()+ a set of parameters - D. The text printed by
jit(f).lower(x).as_text()
2. blob = exp.serialize() is written to a file; another process calls deserialize and then runs jax.grad(loaded.call)(...). What happens?
- A. It differentiates normally · B.
ValueError: No VJP is available· C. It returns all-zero gradients · D. It automatically differentiates in forward mode
3. Exporting a function that contains if x.shape[0] > 2: with symbolic_shape("b, 4") raises InconclusiveDimensionOperation. What's the most direct fix?
- A. Replace the
ifwithjnp.where - B.
symbolic_shape("b, 4", constraints=("b >= 3",)) - C. Switch to
jax.vmap - D. Set
jax_export_calling_convention_version=9
4. About "passing weights as arguments instead of closed-over constants when exporting", which statement is wrong?
- A. Closed-over constants get baked into the StableHLO, so the export file grows with the parameter count
- B. With weights as arguments, the same exported program can be paired with different weights
- C. Passing them as arguments makes the exported program twice as slow to call
- D. On the jaxlib 0.12.0.1 CPU backend, closed-over constant + matmul + sum silently returns wrong results, and exported artifacts are affected too
5. What compatibility promise does JAX make for serialized jax.export artifacts?
- A. 5 years backward, 2 years forward · B. 6 months backward, 3 weeks forward · C. only the same jaxlib version · D. no promise at all
6. With CheckpointManagerOptions(max_to_keep=2, best_fn=lambda m: m["loss"], best_mode="min"), you save 5 steps in order with losses [5, 1, 4, 2, 9]. Which steps are left on disk at the end?
- A.
[3, 4]· B.[1, 3]· C.[1, 3, 4]· D.[0, 1]
Click to expand: answers and explanations
- C. The exported artifact contains the complete computation graph (StableHLO) and needs only
jaxandflatbuffersto run (§2.2, §5). A is only data and needs the model code to put it back together; B is this machine's machine-code cache, which is invalidated in a different environment and still needs the Python side to trace the same program to get a hit; D's text has no compatibility guarantee and no PyTree information for inputs and outputs. - B.
serialize()defaults tovjp_order=0and doesn't save the backward function, so after reading it backhas_vjp()isFalse(verified in §2.5). To differentiate on the loading side, the exporting side must writeserialize(vjp_order=1). D is wrong too:Exported.callis wrapped in acustom_vjp, andjvpraisesTypeError. - B. A constraint tells JAX that
b >= 3, sob > 2has a definite answer, and at call timeshape_assertionchecks the constraint (§2.3). A changes things into "select by data", but this condition depends on the shape, so the semantics differ; and if the two branches have output shapes that differ, you can't even write it withjnp.where. - C. Passing weights as arguments only changes where the weights come from; the computation graph is the same, so there's no reason for it to be "twice as slow". If anything it avoids huge constants slowing down compilation. A, B and D were all verified in §2.2: in the 128×128 example, the closure version's file is more than twenty times the argument version's (the extra is exactly the 64 KiB of weights), and the result is off by about 961.
- B. Measured by jaxlib release date: 6 months of backward compatibility and 3 weeks of forward compatibility (§2.6). A is StableHLO's own promise for portable artifacts; because JAX also depends on the calling convention and custom calls, it tightens that to 6 months / 3 weeks.
- B. With
best_fn,max_to_keepmeans "keep the n with the best metric": the lowest losses are step 1 (1) and step 3 (2). The latest step, 4 (9), is deleted (verified in §3.2). To keep the latest as well, useAnyPreservationPolicy([LatestN(1), BestN(...)]), and then the answer would be C.
# Check the key facts behind quiz questions 2, 5 and 6 (all verified in the main text; collected here and checked again)
assert err_vjp0 == "No VJP is available" # question 2
assert export.maximum_supported_calling_convention_version == 10 # related to question 5: this machine's calling convention range
assert kept_bad == [1, 3] # question 6
print("The answers to quiz questions 2 and 6 match the measurements in the main text ✓")
📌 Chapter summary¶
- Save the two kinds of things separately: data with Orbax, programs with
jax.export. The two can be updated independently: the same program can be paired with different weights, and the same weights with different programs. The persistent compilation cache is just a local speedup and can't serve as a delivery format. - The
jax.exportflow:export.export(jax.jit(f))(*specs)→Exported(in_avals,out_avals,platforms,mlir_module()), thenserialize()→ file →deserialize()→.call(). It runs in apython -Isubprocess from the bytes alone, which proves it doesn't depend on the original source. Serialization needsflatbuffersinstalled separately. - Weights as arguments: closed-over constants get baked into the program (in the 128×128 example the file was more than twenty times bigger) and carry this machine's CPU bug into the artifact.
- Shape polymorphism:
symbolic_shape("b, 4")handles any batch with one export. Uneven division and conditionals on symbolic dimensions raiseInconclusiveDimensionOperation; express assumptions withconstraintsor specs like"2*k";floordivpushes the error back to the shape refinement stage. Shared dimensions must come from the same scope. - Multi-platform: one module contains implementations for several platforms and dispatches on
platform_index; GPU/TPU were only lowered on this machine. - Differentiation: within the same process you can
grad(exp.call)directly; after serialization you needvjp_order=k.Exported.calldoesn't supportvmaporjvp; if you need them, build the batch dimension into the program before exporting. - Compatibility: serialization format version 12, calling convention version 10 (9–10 supported), StableHLO portable bytecode; JAX promises 6 months backward and 3 weeks forward. Deliver only serialized artifacts; custom calls aren't covered.
- Sharding: an N-device artifact can only be called inside an N-device mesh; a single-device artifact is device-polymorphic;
shard_mapcan be exported, andpmapneeds ajitwrapper but should be migrated away from. - Orbax in depth:
CheckpointManagerhandles multi-step saving,save_interval_stepsandmax_to_keep;best_fndeletes the latest checkpoint, so combine policies withAnyPreservationPolicy; restore with an abstract template, which also lets you convert dtypes and specify sharding; NNX saves and restores withnnx.state/nnx.split/nnx.merge, and the resumed training curve joins up bit for bit. - Deployment targets: hand StableHLO bytecode to PJRT or IREE (fixed shapes recommended);
jax2tfstill exists but depends on TF and usesjax.exportinternally; ONNX isn't on the official path;pure_callbackcan't be exported and belongs in the service layer outside the export boundary.
🗂️ Cheat sheet¶
| API | What it does |
|---|---|
pip install flatbuffers | required for serialization (not a jax dependency; install it separately) |
jax.export.export(jax.jit(f), platforms=..., disabled_checks=...)(*args) | export, giving an Exported (arguments can be ShapeDtypeStructs) |
exp.in_avals / out_avals / in_tree / platforms / nr_devices | export signature and metadata |
exp.mlir_module() / exp.mlir_module_serialized | StableHLO text (no compatibility guarantee) / portable bytecode |
exp.call(*args) | call the exported function (can be nested in jit, can be grad-ed, can't be vmap/jvp-ed) |
exp.serialize(vjp_order=k) / jax.export.deserialize(blob) | serialize (with VJPs up to order k) / deserialize |
exp.has_vjp() / exp.vjp() | whether it can be differentiated / the Exported of the backward function |
export.symbolic_shape("b, 4", constraints=("b >= 3",)) | symbolic dimensions and constraints ("2*k" means even) |
export.symbolic_args_specs(args, ("b, _", "b")) | generate symbolic specs in one scope for several arguments |
export.is_symbolic_dim(d) | check whether a dimension is symbolic |
export.DisabledSafetyCheck.custom_call(name) / .platform() | let through a custom call without guarantees / skip the platform check |
export.minimum/maximum_supported_calling_convention_version | supported range of calling convention versions |
JAX_EXPORT_CALLING_CONVENTION_VERSION / jax_export_calling_convention_version | choose the calling convention version used for export |
export.register_pytree_node_serialization(...) | make a custom PyTree type serializable |
exp.in_shardings_jax(mesh) / out_shardings_jax(mesh) | map the export-time shardings onto a concrete mesh |
ocp.StandardCheckpointer().save(path, tree, force=True) / .restore(path, template) | save (overwrite) / restore a single checkpoint |
ocp.StandardCheckpointer().metadata(path).item_metadata.tree | inspect the structure, shapes and dtypes on disk |
ocp.CheckpointManager(dir, options=ocp.CheckpointManagerOptions(...)) | multi-step checkpoint management |
max_to_keep / save_interval_steps / best_fn + best_mode | how many to keep / save interval / pick the best by metric |
ocp.checkpoint_managers.AnyPreservationPolicy([LatestN(n), BestN(get_metric_fn, reverse, n)]) | combined retention policy (reverse=True: smaller is better) |
mngr.save(step, args=ocp.args.StandardSave(tree), metrics=...) | save one step |
mngr.restore(step, args=ocp.args.StandardRestore(template)) | restore one step |
mngr.all_steps() / latest_step() / best_step() / wait_until_finished() / close() | queries and cleanup |
jax.eval_shape(init_fn, ...) / nnx.eval_shape(lambda: Model(...)) | abstract template (allocates no memory) |
nnx.state(m) / nnx.split(m, nnx.Param) / nnx.merge(gdef, state) / nnx.update(obj, state) | get, split, merge and write back NNX state |
nnx.to_pure_dict(state) / nnx.replace_by_pure_dict(state, d) | NNX State ↔ plain dict (use plain dicts for the export interface) |
jax.extend.mlir.dialects.stablehlo.get_current_version(), etc. | query StableHLO versions |
jax.experimental.jax2tf.convert | JAX → TF (needs TensorFlow; uses jax.export internally) |
Appendix: reference solutions¶
Write your own answer in the "✍️ Your answer" cells first and check it with the "🧪 Self-check"; come here to compare once the self-check passes or if you're really stuck. Running the solutions below in order overwrites any functions with the same names you defined in the answer cells.
Exercise 1¶
See "✍️ Hands-on exercises" in the main text for the problem.
# Reference solution: Exercise 1
def pool_linear(p, x):
return jnp.mean(x, axis=1) @ p["w"] + p["b"] # average over t, then apply the linear layer
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) # one call → one scope
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("should have raised an error")
except ValueError as e:
print("Feature dim 7:", str(e)[:80])
assert "Shape mismatch" in str(e)
print("Exercise 1 ✓")
Exercise 2¶
See "✍️ Hands-on exercises" in the main text for the problem.
# Reference solution: Exercise 2
loaded_loss = export.deserialize(exp_loss.serialize(vjp_order=1)) # saving the first-order VJP is enough
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) # differentiate the object read back
g_r = jax.grad(mse_loss)(p_ref, xg, yg) # differentiate the original function
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 {loss_start:.4f} → {loss_end:.4f}; max difference between the two parameter trajectories {max_diff(p_exp, p_ref):.1e}")
assert loss_end < loss_start and max_diff(p_exp, p_ref) < 1e-5
print("Exercise 2 ✓")
Exercise 3¶
See "✍️ Hands-on exercises" in the main text for the problem.
# Reference solution: Exercise 3
# Reasoning: the latest 2 are steps 4 and 5; the lowest loss is step 1 (loss 1) → keep [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("Kept:", 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("Exercise 3 ✓")
Exercise 4¶
See "✍️ Hands-on exercises" in the main text for the problem.
# Reference solution: Exercise 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) # put "multiple of 4" into the spec
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("should have raised an error")
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("Exercise 4 ✓")