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 StandardCheckpointer save and restore, nnx.eval_shape abstract templates, and writing back with nnx.update: see Advanced/06 §6.3 "Serialization and checkpoints". This chapter starts from CheckpointManager, 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's jax.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, needs vmap_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

  1. 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.
  2. 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.
  3. Use symbolic_shape for shape-polymorphic export, and understand the rules for constraints, divisibility, conditionals and scopes on symbolic dimensions, as well as what the various errors mean.
  4. Do multi-platform export (platforms=), export differentiable functions (vjp_order), and know which transformations Exported.call supports and which it doesn't.
  5. 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.
  6. 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.
  7. Know the current state of deployment targets: StableHLO for C++ runtimes (IREE, PJRT), jax2tf, ONNX, and wrapping a service with pure_callback.
  8. Do one end-to-end exercise: train → checkpoint → export → load only the artifacts in a fresh subprocess and run inference → cross-check.

Contents

SectionContentRuns on this machine
§0Environment notes: the flatbuffers dependency; shared helpers✅
§1Save the two kinds of things separately: data (Orbax) and programs (jax.export)✅
§2.1jax.export basics: the fields of Exported✅
§2.2Serialize to a file and run in a new subprocess; why weights must be arguments✅
§2.3Shape polymorphism: symbolic_shape, constraints, divisibility, scopes✅
§2.4Multi-platform export platforms=("cpu","cuda","tpu")⚠️ lowering only, not run on GPU/TPU
§2.5Exporting differentiable functions: vjp_order; Exported.call with vmap/jvp✅
§2.6Version compatibility: serialization version, calling convention version, the StableHLO promise✅
§2.7Exporting functions with sharding / shard_map / pmap (4 devices simulated in a subprocess)✅
§3Orbax in depth: CheckpointManager, retention policies, abstract templates, resuming NNX training✅
§4Overview of deployment targets: StableHLO → IREE/PJRT, jax2tf, ONNX, pure_callback⚠️ C++ runtimes and TF not run on this machine
§5Putting it together: train → checkpoint → export → standalone inference in a subprocess → cross-check✅
§6Practical 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 uses python -I (isolated mode: the current directory and the script directory aren't added to sys.path, and PYTHONPATH is 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_FLAGS is read only at process start).
  • sds(tree): turn a PyTree of arrays into a jax.ShapeDtypeStruct PyTree with the same structure; exporting only needs shapes and dtypes.
  • max_diff, tree_bytes, dir_bytes: error and size statistics.
  • ART: the export artifact directory export_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)
Python 3.14.7 | JAX 0.12.0.1 | jaxlib 0.12.0.1 | Flax 0.12.12 | Optax 0.2.9 | Orbax 0.12.10
Devices: [CpuDevice(id=0)]
Artifact directory: export_artifacts/
# ❌ 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
Measured:
Export itself succeeded, platforms = ('cpu',)
RESULT ImportError: Please install 'flatbuffers' in order to use Exported serialization

# ✅ 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
flatbuffers version: 25.12.19
Do jax/jaxlib declare flatbuffers as a dependency? False
serialize() works: bytearray 2584 bytes

§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 keyProgram: the computation graph
ToolOrbax (orbax.checkpoint)jax.export
What gets savedarrays (TensorStore/Zarr format, shardable, asynchronous writes)StableHLO portable bytecode + metadata such as the input/output signature
What you need to read it backthe model definition code (to put the arrays back into the model); a restore templateonly jax (or any runtime that can run StableHLO), no model source code
Typical useresuming 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 stabilitythe array format is stable, but the PyTree structure depends on your codeJAX 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 cachejax.export
Contentscompiled executablesStableHLO from before compilation
Portabilitynone: changing jaxlib, flags or hardware invalidates itacross jaxlib versions (within the compatibility window), multi-platform
Needs compiling after loading?noyes (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 ✓")
Weights a: output of the exported program = [ 0.0851 -2.211 ]
Weights b: output of the exported program = [-0.6876 -1.8632]
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 / methodMeaning
fun_namethe original function's name (just a name, no source code)
in_tree / in_avalsthe PyTree structure of the inputs, and the abstract value (shape + dtype) of each leaf after flattening
out_tree / out_avalsthe structure and abstract values of the outputs
platformswhich platforms it was lowered for; defaults to the current backend, ("cpu",)
nr_devicesthe number of devices at export time (> 1 for sharded functions, see §2.7)
calling_convention_versionthe calling convention version (§2.6)
mlir_module()StableHLO text (for humans to read)
mlir_module_serializedStableHLO 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 ✓")
fun_name     : mlp_apply
in_tree      : PyTreeDef((({'b1': *, 'b2': *, 'w1': *, 'w2': *}, *), {}))
in_avals     : (ShapedArray(float32[8]), ShapedArray(float32[3]), ShapedArray(float32[4,8]), ShapedArray(float32[8,3]), ShapedArray(float32[2,4]))
out_avals    : (ShapedArray(float32[2,3]),)
platforms    : ('cpu',) | nr_devices: 1 | calling_convention_version: 10
@main signature: func.func public @main(%arg0: tensor<8xf32> loc("params['b1']"), %arg1: tensor<3xf32> loc("params['b2']"), %arg2: tensor<4x8xf32> loc("params['w1']"), %arg3: tensor<8x3xf32> loc("params['w2']"), %arg4 ...
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
Measured error: ValueError: Function to be exported must be the result of `jit` but is: <function mlp_apply at 0x11ac6d900>
# ✅ 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)) ✓")
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:

  1. The text of the subprocess script contains nothing computation-related such as mlp_apply, tanh or @ (matmul); all it does is "load → call";
  2. In isolated mode the subprocess's sys.path doesn'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 ✓")
Export file mlp_static.jaxexport: 3764 bytes; header b'@\x00\x00\x00\x00\x00:\x00'
subprocess fun_name = mlp_apply | in_avals = (ShapedArray(float32[8]), ShapedArray(float32[3]), ShapedArray(float32[4,8]), ShapedArray(float32[8,3]), ShapedArray(float32[2,4]))
subprocess sys.path[0] = /opt/homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python314.zip

Max difference between subprocess output and the original function: 0.0
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:

  1. 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).
  2. You can't manage the data independently: the weights lose what Orbax gives you, such as sharding, asynchronous writes and on-demand loading.
  3. 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())
Closure version: export file 68,496 bytes; result -1942.7557, reference -981.4677, error 9.61e+02
Did the closure version trigger the CPU bug: True
# ✅ 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())
Argument version: export file 3,024 bytes; result -981.4674, error 2.50e-04
The closure version's file is 23× the argument version's (the 65,536 bytes of weights were baked in)

§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 ✓")
Symbolic shape: (b, 4) | type: _DimExpr | is_symbolic_dim: True
in_avals : (ShapedArray(float32[8]), ShapedArray(float32[3]), ShapedArray(float32[4,8]), ShapedArray(float32[8,3]), ShapedArray(float32[b,4]))
out_avals: (ShapedArray(float32[b,3]),)
@main signature: : tensor<8x3xf32> loc(unknown), %arg4: tensor<?x4xf32> loc(unknown)) -> (tensor<?x3xf32> {jax.result_info = "result"}) {
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"]
feature dim 5 ≠ 4: ValueError: Shape mismatch for args[1].shape[1] (expected same constant): expected (b, 4) and called with (3, 5)
dtype int32: ValueError: Dtype mismatch for args[1]: expected float32 and called with 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 ✓")
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:

  1. Not evenly divisible: reshaping (b, 4) to (-1, 3) requires 4b to be divisible by 3, which isn't true for every b, so export fails with InconclusiveDimensionOperation.
  2. Inconclusive conditionals: Python's if x.shape[0] > 2: needs a definite boolean, but b > 2 holds for some b and not for others, which also raises InconclusiveDimensionOperation.

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
Measured error: InconclusiveDimensionOperation: Cannot divide evenly the sizes of shapes (b, 4) and (-1, 3). The remainder mod(4*b, - 3) should be 0.
# ✅ 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)
Output shape of reshape(-1, 2): (2*b, 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
Measured error: InconclusiveDimensionOperation: Symbolic dimension comparison 'b' > '2' is inconclusive.
# ✅ 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 ✓")
Error when calling with b = 2: Input shapes do not match the symbolic shape constraint b >= 3. Expected 'b - 3' to be greater or equal to 0,  ...
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
Export succeeded, output shape: (floordiv(b, 2), 2, 4)
Call with b = 4: (2, 2, 4)
Error when calling with b = 3: ValueError: Error refining shapes. Define JAX_DUMP_IR_TO to dump the module.
# ✅ 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
Input spec: (2*k, 4) → output shape: (k, 2, 4)
Error when calling with b = 5: Input shapes do not match the polymorphic shapes specification. Division had remainder 1 when computing the value of 'k'. Using the following polymorp ...

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
Measured error: ValueError: Invalid mixing of symbolic scopes when exporting <lambda>.
# ✅ 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)
Generated specs: (ShapeDtypeStruct(shape=(b, 4), dtype=float32), ShapeDtypeStruct(shape=(b,), dtype=float32))
Error when b is inconsistent: Input shapes do not match the polymorphic shapes specification. Found inconsistency between dimension size args[1].shape ...

🔍 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 both a and b from one number and raises Cannot solve for values of dimension variables; "2*k" and "k + 1" both work.
  • Equality means "equal for every value": b == 1 is decided as False (because it doesn't always hold), and the docs explicitly say this is "not completely reliable". Code that relies on x.shape[0] == 1 as a special case for broadcasting takes the False branch 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; the shape_assertion information in the error points to the argument and dimension involved.
  • Data-dependent shapes (such as jnp.nonzero(x) without size=) aren't allowed in jit to 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
Spec a+b: Cannot solve for values of dimension variables {'b', 'a'}. We can only solve linear uni-variate constrain ...
b == 1 → False | b >= 1 → 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 cuda and tpu versions of the program can be generated and serialized, but only the cpu one 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 ✓")
platforms: ('cpu', 'cuda', 'tpu')
Start of the multi-platform @main signature: func.func public @main(%arg0: tensor<i32> {jax.global_constant = "_platform_index"} loc(unknown), %arg1: tenso ...
Serialized size: cpu only 4,856 bytes; cpu+cuda+tpu 5,132 bytes (6% more)
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
Measured error: ValueError: Function 'mlp_apply' was exported for platforms '('cuda',)' but it is used on '('cpu',)'.
# ✅ 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 ✓")
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.call works 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
Exported.serialize signature: (self, vjp_order: 'int' = 0) -> 'bytearray'
has_vjp() in the same process: True
Gradient structure: {'b1': (8,), 'b2': (3,), 'w1': (4, 8), 'w2': (8, 3)}
vjp() takes 7 inputs (6 leaves of the original inputs + 1 output cotangent) and returns 6 outputs
# ❌ 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()
Measured error: ValueError: No VJP is available | has_vjp() = False
# ✅ 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 ✓")
Serialized size (bytes): {0: 4344, 1: 10180, 2: 18076}
Second derivative with vjp_order=1: ValueError: No VJP is available
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):

TransformationResultWhy
jax.jit✅ works, and can be nested inside a larger jit functionthe 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❌ TypeErrorcustom_vjp functions don't support forward mode
jax.vmap❌ NotImplementedErrorcall_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"]
vmap: NotImplementedError: Batching rule for 'call_exported' not implemented
jvp: TypeError: can't apply forward-mode autodiff (jvp) to a custom_vjp function.
# ✅ 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
Export vmap(f) instead of vmap(exp.call) ✓; Exported.call can be nested inside an outer jit ✓
The outer jaxpr contains the call_exported primitive: True

§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:

LayerWhat it isMeasured on this machine
Serialization format versionthe format version of the FlatBuffer wrapper (metadata such as the signature, PyTree and sharding); the JAX internal constant _SERIALIZATION_VERSION12 (see below)
Calling convention versionthe argument convention of @main: how dimension variables, platform_index, tokens, effects, sharding attributes and so on are passed; Exported.calling_convention_versiondefault 10, supports 9–10
StableHLO versionthe 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_call targets that can appear in a module, only the batch JAX explicitly lists as "stable" (such as LAPACK, shape_assertion, Pallas TPU's tpu_custom_call and Mosaic GPU's mosaic_gpu_v2) can be serialized. Other targets fail at export with Cannot serialize code with custom calls whose targets have no compatibility guarantees; once you've accepted the risk, you can let them through with disabled_checks=[export.DisabledSafetyCheck.custom_call("target_name")].
  • The promise only covers serialized artifacts: the text printed by mlir_module() and jit(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"
Serialization format version _SERIALIZATION_VERSION = 12
Calling convention: default 10 | supported range 9 – 10
StableHLO: current version 1.22.0 | minimum readable version 0.9.0 | serialization target version (≥4 weeks old) 1.18.0
Portable bytecode header: b'ML\xefR' (MLIR bytecode magic number '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 ✓")
Subprocess output: CC 9
Read back in the current process: calling_convention_version = 9
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_version and platforms in 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 on deserialize_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_compat tests 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 explicit DisabledSafetyCheck.custom_call(...), and the consumer must register a target with the same name; pure_callback can'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
Pallas TPU kernel: export + serialization succeeded (lowering only)
Custom FFI target: ValueError: Cannot serialize code with custom calls whose targets have no compatibility guarantees. See htt ...
With an explicit DisabledSafetyCheck.custom_call it exports (the consumer must register a target with the same name to run it)
Stable list: tpu_custom_call True | mosaic_gpu_v2 True | __gpu$xla.gpu.triton False

§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):

  1. An artifact exported with nr_devices = N can 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.
  2. 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.
  3. shard_map can be exported, and the collectives inside it (psum → all_reduce) are kept as they are in the StableHLO.
  4. pmap isn't the result of jit, so it can't be passed directly to export.export; wrapping it as jax.jit(jax.pmap(...)) does export, but pmap is a legacy interface, and new code should use jit + sharding or shard_map instead (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
PASS 1 nr_devices==4 4
PASS 1 input sharding recorded P('data',)
PASS 1 call in the same mesh is correct P('data', None)
PASS 1 call without a mesh → ValueError Length of device assignment 1 is not equal to the size of the mesh 4 of aval f32[8@data,3]
PASS 1 call on a 2-device mesh → ValueError Length of device assignment 2 is not equal to the size of the mesh 4 of aval f32[8@data,3]
PASS 1 call on a different 4-device mesh is correct 
PASS 2 single-device artifact accepts sharded inputs P('data',)
PASS 3 shard_map exports and contains all_reduce 
PASS 3 shard_map result is correct 496.0
PASS 4 exporting pmap directly → ValueError 
PASS 4 jit(pmap) exports 4

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 deserialize and 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
Measured error: ValueError: Destination <SCRATCH>/single_ckpt already 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 ✓")
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:

OptionWhat it does
max_to_keep=nkeep at most n checkpoints
save_interval_steps=konly 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()
Return value of save() at each step: [True, False, True, False, True, False, True]
Steps kept on disk: [4, 6] | subdirectories: ['4', '6']
latest_step: 6

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
Measured: steps kept [1, 3] | best_step 1 | latest_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 ✓")
Steps kept: [1, 3, 4] | best_step: 1 | latest_step: 4
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]
losses = [5.0, 1.0, 4.0, 2.0, 9.0] → reverse=False keeps [0, 4] ; reverse=True keeps [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:

  1. dtype conversion: write bfloat16 in the template and Orbax converts on restore (for example, fp32 training and bf16 inference);
  2. Sharding: put sharding=NamedSharding(...) in the template, and each device reads only its own piece (the Extra in Advanced/06 §6.3);
  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 ✓")
Abstract template: {'b1': 'float32[8]', 'b2': 'float32[3]', 'w1': 'float32[4, 8]', 'w2': 'float32[8, 3]'}
Restored with a bf16 template: {'b1': 'bfloat16', 'b2': 'bfloat16', 'w1': 'bfloat16', 'w2': 'bfloat16'}
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
Measured error: ValueError: Requested shape: (4, 16) is not compatible with the stored shape: (4, 8). Truncating/padding is disabled. To enable it, set `strict=False` in `ArrayRe ...
# ✅ 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()}
Shapes on disk: {'b1': (8,), 'b2': (3,), 'w1': (4, 8), 'w2': (8, 3)}

§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:

  1. nnx.eval_shape(lambda: build the model and optimizer) gives you an abstract model and optimizer;
  2. use their nnx.state(...) as the Orbax restore template;
  3. nnx.split(abstract_model) gives you the GraphDef (the model structure), then nnx.merge(graphdef, restored_state) gives you the real model; for the optimizer, write the state back with nnx.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
Saved steps: [30] | State type: State
# (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 ✓")
optimizer.step after restore = 30
Max difference between the resumed curve and the straight-through curve: 0.0e+00
  step 29 (last step before saving): 0.8995 | first resumed step: 0.918914 | reference step 30 : 0.918914
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
Measured: it runs, but the max difference from the reference curve = 0.329; the first resumed step has step = 0, and the data goes back to batch 0
# ✅ 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 ✓")
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.Rngs are also nnx.Variables (nnx.RngState), and nnx.state(model) brings them along, so the random number sequence picks up where it left off after restoring. If you save only the parameters with nnx.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.save returns immediately, a background thread writes to disk, and training continues. Before the program exits you must call mngr.wait_until_finished() or mngr.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_serialized is the StableHLO portable artifact (MLIR bytecode, header ML\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's libtpu are 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 .vmfb module that runs on servers, mobile and embedded devices (CPU/Vulkan/Metal/CUDA and other backends); a typical command is iree-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:

  1. Prefer exporting fixed shapes. A polymorphic module has ? dimensions and @shape_assertion custom 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.
  2. 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 the in_tree / in_avals order into the metadata together with the parameter names (the meta.json in §5).
  3. 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 ✓")
Text mlp_static_b8.mlir: 6,504 bytes; bytecode mlp_static_b8.mlirbc: 2,750 bytes
Fixed-shape @main: func.func public @main(%arg0: tensor<8xf32> loc("params['b1']"), %arg1: tensor<3xf32> loc("params['b2']"), %arg2: tensor<4x8xf32> loc("params['w1']"), %arg3: te
Fixed-shape module has dynamic dims / shape_assertion: False
Polymorphic module has dynamic dims / shape_assertion: True
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'"
jax.experimental.jax2tf module exists: True → jax/experimental/jax2tf/__init__.py
tensorflow installed in this environment: False
Measured: import jax2tf → ModuleNotFoundError: 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.convert is native serialization: internally it uses jax.export to produce StableHLO and wraps it in a TF XlaCallModule op. In the 0.12 source, the native_serialization and enable_xla arguments are both marked deprecated (passing them triggers a DeprecationWarning), so this is the only mode left. In other words, jax2tf is 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.Variable arguments (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 XlaCallModule converted again into native TF ops; that path is long and coverage is incomplete.
  • When you don't need the TF ecosystem, use jax.export directly: fewer dependencies (just jax + 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
fixed shape (8, 4): NotImplementedError: serialization of host_callbacks is not yet implemented
symbolic shape (b, 4): RuntimeError: 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
Service response: [{'label': 2, 'prob': 0.6365}, {'label': 2, 'prob': 0.4869}, {'label': 0, 'prob': 0.5081}]
pure JAX      : 0.101 ms/call
pure_callback : 0.161 ms/call

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✅
Deviceruns only on the host (CPU); on GPU/TPU every call moves data between device and hostall computation stays on the device
Performanceevery call goes back into Python (limited by the GIL) and breaks XLA fusionenters and leaves Python only at the request boundary
Differentiable / vmapneeds custom_jvp and vmap_methoddecided at export time (vjp_order, symbolic batch)
Good forresearch prototypes, calling libraries you can't rewrite in JAX yet, debuggingproduction 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:

  1. Train a small MLP (3 classes) with Flax NNX + Optax, saving checkpoints with CheckpointManager during training;
  2. Restore from the checkpoint and verify it matches the in-memory model;
  3. 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 with jax.export, serialize it to a file, and write a metadata file meta.json;
  4. In a brand-new subprocess (python -I, working directory set to the artifact directory), load only the export file and the parameters, without importing flax, optax or any model definition code, run inference, and cross-check against the original model;
  5. 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})
Before training: test loss 1.2326 | test acc 0.253
step  100 | train loss 0.2732 | test loss 0.4375 | test acc 0.837
step  200 | train loss 0.2188 | test loss 0.2694 | test acc 0.902
step  300 | train loss 0.1464 | test loss 0.2559 | test acc 0.906
step  400 | train loss 0.1237 | test loss 0.2548 | test acc 0.905
step  500 | train loss 0.2189 | test loss 0.2924 | test acc 0.916
step  600 | train loss 0.1045 | test loss 0.2189 | test acc 0.931
Checkpoints kept: [600] | latest: 600
Step with the lowest test loss: 600 | best_step(): 600
# 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 ✓")
Restored step: 600 | optimizer step: 600
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
Measured error: ValueError: Cannot serialize PyTreeDef containing an unregistered type `<class 'flax.nnx.variablelib.Param'>`. Use `export.register_pytree_node_serialization` or `export.register_namedtuple_serialization`.
# ✅ 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))
Export interface in_tree: PyTreeDef((({'fc1': {'bias': *, 'kernel': *}, 'fc2': {'bias': *, 'kernel': *}, 'fc3': {'bias': *, 'kernel': *}}, *), {}))
in_avals: (ShapedArray(float32[64]), ShapedArray(float32[4,64]), ShapedArray(float32[64]), ShapedArray(float32[64,64]), ShapedArray(float32[3]), ShapedArray(float32[64,3]), ShapedArray(float32[b,4]))
out_avals: (ShapedArray(float32[b,3]),)
{
 "jax_version": "0.12.0.1",
 "exported_at": "2026-11-14",
 "platforms": [
  "cpu"
 ],
 "calling_convention_version": 10,
 "params_flat_order": [
  "['fc1']['bias']",
  "['fc1']['kernel']",
  "['fc2']['bias']",
  "['fc2']['kernel']",
  "['fc3']['bias']",
  "['fc3']['kernel']"
 ],
 "input": "x: float32[b, 4]",
 "output": "logits: float32[b, 3]"
}

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 ✓")
subprocess: exported with JAX 0.12.0.1 | current JAX 0.12.0.1 | in_avals (ShapedArray(float32[64]), ShapedArray(float32[4,64]), ShapedArray(float32[64]), ShapedArray(float32[64,64]), ShapedArray(float32[3]), ShapedArray(float32[64,3]), ShapedArray(float32[b,4]))
subprocess: output shapes {'b1': (1, 3), 'b7': (7, 3), 'b64': (64, 3), 'b1024': (1024, 3)}
MODULES flax=False optax=False

    b1: output (1, 3), max difference from the original NNX model 0.00e+00, predicted classes identical True
    b7: output (7, 3), max difference from the original NNX model 0.00e+00, predicted classes identical True
   b64: output (64, 3), max difference from the original NNX model 0.00e+00, predicted classes identical True
 b1024: output (1024, 3), max difference from the original NNX model 0.00e+00, predicted classes identical True
Test accuracy of subprocess inference: 0.931 (matches the 0.931 at the end of training)
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)
Artifact directory: export_artifacts/

  infer_params/_CHECKPOINT_METADATA                                            262 B
  infer_params/_METADATA                                                     1,332 B
  infer_params/_sharding                                                       553 B
  infer_params/array_metadatas/process_0                                     1,059 B
  infer_params/commit_success.txt                                              116 B
  infer_params/d/05828dcfdd3e61525e900d844fc081dc                            1,637 B
  infer_params/manifest.ocdbt                                                  114 B
  infer_params/ocdbt.process_0/d/05d2be67532dec0b5c53fc0dbb4d77d2              260 B
  infer_params/ocdbt.process_0/d/1fb3264a461126b42b6e49ada817422c           17,126 B
  infer_params/ocdbt.process_0/d/8f421192112347d8da4f6084d5336161            1,622 B
  infer_params/ocdbt.process_0/d/ce1f0d8770f0676888e47c8883e2938c              179 B
  infer_params/ocdbt.process_0/d/d86a6072ebef4b411b173764c9a5f087              234 B
  infer_params/ocdbt.process_0/manifest.ocdbt                                  284 B
  meta.json                                                                    371 B
  mlp_infer.jaxexport                                                        5,768 B
  mlp_infer.stablehlo.mlir                                                  11,794 B
  train_ckpt/600/_CHECKPOINT_METADATA                                          366 B
  train_ckpt/600/commit_success.txt                                            118 B
  train_ckpt/600/default/_METADATA                                           7,202 B
  train_ckpt/600/default/_sharding                                           2,349 B
  train_ckpt/600/default/array_metadatas/process_0                           3,862 B
  train_ckpt/600/default/commit_success.txt                                    147 B
  train_ckpt/600/default/d/183044db098ce7e12a035bdade3dc93b                  6,485 B
  train_ckpt/600/default/manifest.ocdbt                                        115 B
  train_ckpt/600/default/ocdbt.process_0/d/154e5d145bf4606f2e684008cac57216       187 B
  train_ckpt/600/default/ocdbt.process_0/d/22fff54865ae7666a2a324ee004b07c5    47,910 B
  train_ckpt/600/default/ocdbt.process_0/d/2c1f34adf1df57bb760105265764fbf1       363 B
  train_ckpt/600/default/ocdbt.process_0/d/542c7f1b9899800aa73e102ecd44fa9c     6,469 B
  train_ckpt/600/default/ocdbt.process_0/d/7d5eb9ef1a8582d2734f57a3d489981b       694 B
  train_ckpt/600/default/ocdbt.process_0/manifest.ocdbt                        286 B
  train_ckpt/600/metrics/commit_success.txt                                    147 B
  train_ckpt/600/metrics/metrics                                                34 B

Totals by top-level entry:
  infer_params                      24.2 KiB
  meta.json                          0.4 KiB
  mlp_infer.jaxexport                5.6 KiB
  mlp_infer.stablehlo.mlir          11.5 KiB
  train_ckpt                        74.9 KiB

Raw bytes of the inference parameters 18,700 B; exported program 5,768 B (no weights)

§6 Practical rules checklist

What to save, and with what

  1. 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.
  2. 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.
  3. When releasing an inference model, save a separate inference checkpoint with only nnx.Param, optionally converted to bf16.

Exporting

  1. 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.
  2. The export interface's PyTree should use only dict / list / tuple, not nnx.State or custom classes; custom types first need export.register_pytree_node_serialization.
  3. Use symbolic_shape("b, ...") for the batch dimension; when there are assumptions like "even" or "≥ 3", write them as "2*k" or constraints=(...) rather than relying on // to imply them.
  4. Dimensions shared by several arguments must come from the same scope: use a single symbolic_shape call or symbolic_args_specs.
  5. 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.call can't be vmapped).
  6. List every platform it will run on in platforms=, including cpu for development; GPU/TPU versions can only be lowered on a CPU, so verify on the target hardware before a real release.

Compatibility and operations

  1. Deliver only serialize() artifacts or mlir_module_serialized bytecode, never mlir_module() text.
  2. Write metadata next to the artifact: JAX version, export date, calling convention version, platforms, the flattened parameter order, and the input/output signature.
  3. Mind the 6-month backward-compatibility window: re-export with a new JAX regularly and cross-check against archived golden outputs.
  4. Keep pure_callback / io_callback out of the export boundary (they can't be serialized); custom FFI targets and Pallas Triton kernels aren't on the stable list, need an explicit DisabledSafetyCheck, and the consumer must register an implementation with the same name (Pallas TPU / Mosaic GPU kernels are on the list, §2.6).
  5. The input to deserialize must be trusted; don't load artifacts of unknown origin.
  6. Install flatbuffers separately in your environment, or serialize / deserialize raise ImportError.

Orbax

  1. Saving is asynchronous: call wait_until_finished() / close() before reading back and before the process exits.
  2. You can't save to the same directory twice; pass force=True when you really mean to overwrite, and use CheckpointManager for multi-step saving.
  3. best_fn + max_to_keep keeps only "the best n", and the latest may be deleted; combine them with AnyPreservationPolicy([LatestN, BestN]). BestN(reverse=True) keeps the smallest metric.
  4. 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")
⏳ Exercise 1: not answered yet: name 'pool_linear' is not defined
# ✍️ 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")
⏳ Exercise 2: not answered yet: name 'loaded_loss' is not defined
# ✍️ 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")
⏳ Exercise 3: not answered yet: name 'make_manager3' is not defined
# ✍️ 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")
⏳ Exercise 4: not answered yet: name 'group_mean' is not defined

📝 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 if with jnp.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
  1. C. The exported artifact contains the complete computation graph (StableHLO) and needs only jax and flatbuffers to 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.
  2. B. serialize() defaults to vjp_order=0 and doesn't save the backward function, so after reading it back has_vjp() is False (verified in §2.5). To differentiate on the loading side, the exporting side must write serialize(vjp_order=1). D is wrong too: Exported.call is wrapped in a custom_vjp, and jvp raises TypeError.
  3. B. A constraint tells JAX that b >= 3, so b > 2 has a definite answer, and at call time shape_assertion checks 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 with jnp.where.
  4. 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.
  5. 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.
  6. B. With best_fn, max_to_keep means "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, use AnyPreservationPolicy([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 ✓")
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.export flow: export.export(jax.jit(f))(*specs) → Exported (in_avals, out_avals, platforms, mlir_module()), then serialize() → file → deserialize() → .call(). It runs in a python -I subprocess from the bytes alone, which proves it doesn't depend on the original source. Serialization needs flatbuffers installed 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 raise InconclusiveDimensionOperation; express assumptions with constraints or specs like "2*k"; floordiv pushes 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 need vjp_order=k. Exported.call doesn't support vmap or jvp; 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_map can be exported, and pmap needs a jit wrapper but should be migrated away from.
  • Orbax in depth: CheckpointManager handles multi-step saving, save_interval_steps and max_to_keep; best_fn deletes the latest checkpoint, so combine policies with AnyPreservationPolicy; restore with an abstract template, which also lets you convert dtypes and specify sharding; NNX saves and restores with nnx.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); jax2tf still exists but depends on TF and uses jax.export internally; ONNX isn't on the official path; pure_callback can't be exported and belongs in the service layer outside the export boundary.

🗂️ Cheat sheet

APIWhat it does
pip install flatbuffersrequired 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_devicesexport signature and metadata
exp.mlir_module() / exp.mlir_module_serializedStableHLO 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_versionsupported range of calling convention versions
JAX_EXPORT_CALLING_CONVENTION_VERSION / jax_export_calling_convention_versionchoose 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.treeinspect 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_modehow 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.convertJAX → 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 ✓")
in_avals: (ShapedArray(float32[2]), ShapedArray(float32[8,2]), ShapedArray(float32[b,t,8])) → out_avals: (ShapedArray(float32[b,2]),)
Feature dim 7: Shape mismatch for args[1].shape[2] (expected same constant): expected (b, t, 8)
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 ✓")
Loss 1.2859 → 0.5323; max difference between the two parameter trajectories 6.0e-08
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 ✓")
Kept: [1, 4, 5] | best_step: 1
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 ✓")
in: (4*k, d) → out: (k, d)
b = 10: Input shapes do not match the polymorphic shapes specification. Division had remainder 2 when computing the va ...
Exercise 4 ✓