Phase 15 / Deployment, interoperability & edge AI
Export a computation and verify its serving contract
Revision v0.7.0 · Updated since your last visit. Review the changes; your progress is saved.
Lesson · about 110 minutes · CPU practice
Round-trip an exported inference function and reject incompatible requests.
Phase study guide: preparation, milestones and project evidence
Before you start
Before this: Convert PyTorch weights to Flax and locate numerical errors
Hardware: CPU; optional framework and edge-device labs have separate requirements
Setup help: Python, notebook, or Cloud TPU VM
Use the tested CPU packages (the recorded environment uses Python 3.14.3). Other Python versions and accelerator environments require separate checks. The optional Cloud TPU specialization explains the target-specific execution boundaries. Logical CPU devices practice partitioning; they do not simulate TPU performance.
Download the repository ZIP or use your existing checkout. Extract it and open a terminal in the folder containing requirements-cpu.txt. The beginner ZIP is separate; these lessons use the full repository folder.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-cpu.txtpy -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements-cpu.txtAfter activation, run the complete worked example from that same folder:
python phases/15-deployment/03-export-and-serve-a-trained-computation/code/main.py.\.venv\Scripts\python.exe phases/15-deployment/03-export-and-serve-a-trained-computation/code/main.pyFor an incremental build, create main.py in your own practice folder, append each build block in order, and run python main.py with this environment active. Keep that file separate from the downloaded reference solution.
Download this lesson’s notebook. Open it with a Python kernel using the course environment. Run cells from the top after restarting the kernel; experiments depend on earlier definitions.
For local Jupyter, first install the course requirements using the Local Python steps, then use your environment’s Python to start Jupyter:
python -m pip install jupyterlab
python -m jupyter lab.\.venv\Scripts\python.exe -m pip install jupyterlab
.\.venv\Scripts\python.exe -m jupyter labSelect the downloaded deployment-03.ipynb file in JupyterLab. The notebook contains reference solutions; make your prediction and attempt each practice task before opening them. Restart the kernel before changing logical device setup.
The worked reference for this lesson is CPU-validated. Some references explicitly configure logical CPU devices; setting JAX_PLATFORMS does not adapt them to a TPU. Follow the execution matrix and reviewed multi-host example before attempting a target run.
For accelerator preparation, study TPU setup, observed runtime receipts, checkpointed jobs and precision and retained traces. These target paths remain unqualified on physical TPU hardware until an actual receipt is recorded. Cloud provisioning is optional for CPU learning.
Check: The serialized artifact reloads and returns . File size depends on the JAX version.
Revisit the guided CPU setup · Open 19: TPU Setup. Package instructions are CPU instructions; Windows and accelerator environments need their own validation.
What you will be able to do
- Serialize and reload a fixed-shape JAX inference computation.
- Test known values and changed requests independently.
- Distinguish export compatibility from service and edge-device readiness.
The problem
Your function works in the training process. Let’s make sure it also works after saving and reloading its inference computation. We’ll define one request shape, check a known prediction, and deliberately send an incompatible request. These checks give us a clear contract before adding a service around the model.
The idea
Export packages a computation for a supported runtime. The consumer still needs input validation, preprocessing, output meaning and compatibility information. Separate the artifact boundary from the surrounding service.
A serialized computation still needs an interface
The lesson exports a fixed float32 input of shape to an output of shape . A Python function may accept a larger batch while that particular exported signature does not. The consumer must follow the artifact's declared contract.
Serialize, load the file again and call the restored computation on new inputs. Comparing only the original in-memory function does not test the exported bytes. Keep artifact hash, environment and tolerance with the result.
If preprocessing lives outside the export, version it alongside the model. A correct graph can still return wrong application results when the caller normalizes inputs differently. A local round trip does not automatically qualify another runtime, hardware target or version.
Four different deployment boundaries
Predict: Which row is tested by the exported object alone?
Read top to bottom from the raw request to the response. Each row has a distinct contract and a distinct failure. The exported computation handles the numeric call; request fields, artifact identity and response meaning require surrounding checks. The fresh-process experiment exercises loading and calling under the installed CPU runtime.
Freeze the inference boundary
The example closes over trained weights and bias, so those values become part of the exported computation. The single input is float32 ; the output is float32 . A new weight version requires a new artifact here. For systems that pass weights as inputs, define and version those parameter signatures instead. Remove optimizer state and training-only behavior from this inference boundary.
Fixed shapes are a deliberate constraint
A fixed batch-one signature simplifies the runtime contract. A batch of two is not accepted merely because the original Python expression supports it. JAX shape polymorphism is a separate export feature with its own constraints. Record permitted dimensions explicitly; an HTTP wrapper should reject malformed requests before invoking the computation.
Verify the file, not the original function
Serialize, read back and deserialize before checking predictions. Then compare to both a manually known row and NumPy on new inputs. Record the JAX/jaxlib versions, platform, artifact hash, preprocessing and tolerance with the release. Exported calling conventions and supported platforms have compatibility rules; retain the environment instead of assuming a byte file runs everywhere.
Choose a runtime after choosing an artifact
The local exported call still needs a compatible JAX runtime. For TensorFlow serving, investigate a supported jax2tf or Keras SavedModel route and retest outputs after conversion. For LiteRT, use its supported converter and runtime with explicit operator and dtype checks. ONNX Runtime needs a supported ONNX graph and execution provider. A format name alone says nothing about whether a target NPU can execute every operator.
From local call to service
Place decoding, normalization, shape checks, inference and response encoding inside your request boundary. Set input size limits and concurrency limits. Measure cold initialization and warmed request latency separately; queueing and network time change the service result. The edge lesson extends this boundary to a single-device request. Autoscaling and a production server are outside this CPU artifact exercise.
Separate serialized computation from the serving system
Export answers a specific question: can a staged computation be represented, loaded and called with the declared signature? It does not automatically include the request parser, feature names, normalization policy, output labels or service configuration. A fixed shape of says that the call expects one observation with three features; it says nothing about which physical measurements those features represent.
This example captures a particular set of weights and bias in the exported computation. The fresh-process experiment loads only the bytes and a request. It does not import the original inference function or retrain a model. Passing that test is stronger than calling the object that just produced the export, but it still checks the installed CPU runtime, not a different accelerator or another runtime's operator coverage.
Serialization preserves the declared computation
Predict before looking: Do the restored scores match the direct function?
Read the figure
Each category is one output score. The paired bars compare the direct computation with the restored serialized artifact on the same input. Both give approximately for score and for score .
The matching heights show numerical agreement. The bars are adjacent, rather than one line hiding another. A negative second score is valid because these are raw outputs, not probabilities constrained to lie between zero and one.
Connect it to the computation
This is the first behavior to check after serialization: does the restored computation preserve the result within the declared tolerance? The figure makes a coordinate-by-coordinate mismatch visible instead of reducing all outputs to one aggregate number.
The example exports a specific float32 input signature with shape . Agreement for this case is separate from acceptance of arbitrary shapes or support in every serving runtime. Keep the signature and input preprocessing with the artifact, then expand parity checks to the cases your application actually needs.
Open full-size figureDownload data and provenance
The notebook contains the data experiment, plotting code, and recorded figure output. Rerun it to test your changes.
Where to put & run this code: Work in phases/15-deployment/03-export-and-serve-a-trained-computation/code/main.py (or downloaded exercises/deployment-03.py / exercises/deployment-03.ipynb) and execute from the repository root with python3 phases/15-deployment/03-export-and-serve-a-trained-computation/code/main.py.
1 Define an inference-only computation
Create main.py in your lesson workspace and run it with the active course Python environment. Keep training out of the function and compute the known probe before exporting.
# Step 1 — Define an inference-only computation: The probe returns [3.1,-0.45].
import tempfile
from pathlib import Path
import numpy as np
import jax
import jax.numpy as jnp
from jax import export
weights = jnp.array([[1., -2.], [.5, 1.], [-1., .25]], dtype=jnp.float32)
bias = jnp.array([.1, -.2], dtype=jnp.float32)
# Define and JIT-compile `inference(x)` so XLA traces and fuses the operations:
@jax.jit
def inference(x):
return x @ weights + bias
# Create device-backed JAX array ``.
np.testing.assert_allclose(inference(jnp.array([[1.,2.,-1.]], jnp.float32)), [[3.1,-.45]], atol=1e-6)Code building blocks in this step
jnp.array(values, dtype=...)— Constructs an immutable device-backed JAX array from Python/NumPy values.jnp.allclose(actual, expected, rtol=..., atol=...)— Checks that two arrays match elementwise within floating-point tolerance.jax.jit(fn) / @jax.jit— Tracesfnwith abstract shapes and compiles a fused XLA executable cached by input shape and dtype.
The probe returns . Preserve that independent expected value as you cross the serialization boundary.
2 Declare the fixed input signature
Append this block to the same main.py and rerun the whole file. Stage the computation for a batch of one with three float32 features.
# Step 2 — Declare the fixed input signature: The export has one input of shape (1,3).
signature = jax.ShapeDtypeStruct((1, 3), jnp.float32)
artifact = export.export(inference)(signature)
# Check tensor shape invariant: `artifact.in_avals[0].shape == (1,3)`
assert artifact.in_avals[0].shape == (1,3)Code building blocks in this step
shape(...)— Callshapewith your updated parameters or inputs from this lesson's workspace.jax.ShapeDtypeStruct(...)— Calljax.ShapeDtypeStructwith your updated parameters or inputs from this lesson's workspace.assert condition— Verify that the observed output shape, status, or numerical value satisfies the contract.
The export has one input of shape . A batch of two is a different contract; the failure experiment tests that rejection.
3 Write bytes, reload and compare
Append this block to the same main.py and rerun the whole file. Serialize to disk and call the restored artifact on the same probe.
# Step 3 — Write bytes, reload and compare: The output agrees with hand arithmetic.
# Create an isolated temporary directory to run and inspect artifacts safely:
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "dense.jaxexport"
path.write_bytes(artifact.serialize())
restored = export.deserialize(path.read_bytes())
# Convert `sample` to a host NumPy array for inspection or verification.
sample = np.array([[1., 2., -1.]], np.float32)
# Convert `actual` to a host NumPy array for inspection or verification.
actual = np.asarray(restored.call(sample))
np.testing.assert_allclose(actual, [[3.1, -.45]], atol=1e-6)
print("Verified serialized bytes:", path.stat().st_size)Code building blocks in this step
jnp.allclose(actual, expected, rtol=..., atol=...)— Checks that two arrays match elementwise within floating-point tolerance.
The output agrees with hand arithmetic. File size is recorded rather than hard-coded because the representation can change with the JAX version. Next, run the fresh-interpreter experiment.
View the cumulative build in main.py
These blocks append in order to the same file.
# Step 1 — Define an inference-only computation: The probe returns [3.1,-0.45].
import tempfile
from pathlib import Path
import numpy as np
import jax
import jax.numpy as jnp
from jax import export
weights = jnp.array([[1., -2.], [.5, 1.], [-1., .25]], dtype=jnp.float32)
bias = jnp.array([.1, -.2], dtype=jnp.float32)
# Define and JIT-compile `inference(x)` so XLA traces and fuses the operations:
@jax.jit
def inference(x):
return x @ weights + bias
# Create device-backed JAX array ``.
np.testing.assert_allclose(inference(jnp.array([[1.,2.,-1.]], jnp.float32)), [[3.1,-.45]], atol=1e-6)
# Step 2 — Declare the fixed input signature: The export has one input of shape (1,3).
signature = jax.ShapeDtypeStruct((1, 3), jnp.float32)
artifact = export.export(inference)(signature)
# Check tensor shape invariant: `artifact.in_avals[0].shape == (1,3)`
assert artifact.in_avals[0].shape == (1,3)
# Step 3 — Write bytes, reload and compare: The output agrees with hand arithmetic.
# Create an isolated temporary directory to run and inspect artifacts safely:
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "dense.jaxexport"
path.write_bytes(artifact.serialize())
restored = export.deserialize(path.read_bytes())
# Convert `sample` to a host NumPy array for inspection or verification.
sample = np.array([[1., 2., -1.]], np.float32)
# Convert `actual` to a host NumPy array for inspection or verification.
actual = np.asarray(restored.call(sample))
np.testing.assert_allclose(actual, [[3.1, -.45]], atol=1e-6)
print("Verified serialized bytes:", path.stat().st_size)Run the example
python3 phases/15-deployment/03-export-and-serve-a-trained-computation/code/main.pyAbout this command
This shortcut uses an activated macOS/Linux course environment in the repository folder. For Windows, use the explicit interpreter command in “Setup help” above. The script includes worked examples and reference solutions.
Inspect the complete assembled example
# Export a computation and verify its serving contract: Export packages a computation for a supported runtime.
import tempfile
from pathlib import Path
import numpy as np
import jax
import jax.numpy as jnp
from jax import export
weights = jnp.array([[1., -2.], [.5, 1.], [-1., .25]], dtype=jnp.float32)
bias = jnp.array([.1, -.2], dtype=jnp.float32)
# Define and JIT-compile `inference(x)` so XLA traces and fuses the operations:
@jax.jit
def inference(x):
return x @ weights + bias
signature = jax.ShapeDtypeStruct((1, 3), jnp.float32)
artifact = export.export(inference)(signature)
# Create an isolated temporary directory to run and inspect artifacts safely:
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "dense.jaxexport"
path.write_bytes(artifact.serialize())
restored = export.deserialize(path.read_bytes())
# Convert `sample` to a host NumPy array for inspection or verification.
sample = np.array([[1., 2., -1.]], np.float32)
# Convert `actual` to a host NumPy array for inspection or verification.
actual = np.asarray(restored.call(sample))
np.testing.assert_allclose(actual, [[3.1, -.45]], atol=1e-6)
print("Verified serialized bytes:", path.stat().st_size)Expected result: The serialized artifact reloads and returns . File size depends on the JAX version.
Recorded reference execution
Reference output captured from executing the complete lesson script (code/main.py) and its assertions in the course CPU environment.
Verified serialized bytes: 1172
Verified serialized bytes: 1172
Rejected incompatible shape: ValueError
Fresh interpreter loaded bytes and matched the known scores.
Changed exported request verified
Invalid requests rejected before the one valid inference call.
PASS: deployment-03
Download execution record · The notebook includes executed cells and figure outputs. Compare your run with these values; timings depend on the machine.
Reject the wrong batch
# Experiment — Reject the wrong batch: A Python function being shape-generic does not make its fixed...
# Run the boundary check and catch the expected exception:
try:
restored.call(np.ones((2, 3), np.float32))
except (ValueError, TypeError) as error:
print("Rejected incompatible shape:", type(error).__name__)
else:
raise AssertionError("Fixed batch-one contract was not enforced")Expected: The incompatible shape is rejected.
A Python function being shape-generic does not make its fixed exported interface polymorphic.
Load only the exported bytes in a fresh interpreter
# Experiment — Load only the exported bytes in a fresh interpreter: The checked boundary is artifact bytes → installed JAX CPU...
import json
import os
import subprocess
import sys
# Convert `worker` to a host NumPy array for inspection or verification.
worker = """import json,sys
from pathlib import Path
import numpy as np
from jax import export
loaded = export.deserialize(Path(sys.argv[1]).read_bytes())
request = np.asarray(json.load(sys.stdin)['features'], dtype=np.float32)
print(json.dumps({'scores': np.asarray(loaded.call(request)).tolist()}, allow_nan=False))
"""
# Create an isolated temporary directory to run and inspect artifacts safely:
with tempfile.TemporaryDirectory() as directory:
exported_path = Path(directory) / 'dense.jaxexport'
exported_path.write_bytes(artifact.serialize())
# Configure environment variable before initializing the runtime.
completed = subprocess.run([sys.executable, '-c', worker, str(exported_path)], input=json.dumps({'features': [[1.,2.,-1.]]}), text=True, capture_output=True, check=True, env=dict(os.environ, JAX_PLATFORMS='cpu'), timeout=60)
worker_scores = json.loads(completed.stdout)['scores']
np.testing.assert_allclose(worker_scores, [[3.1, -.45]], atol=1e-6)
print('Fresh interpreter loaded bytes and matched the known scores.')Expected: The child process returns using only the exported artifact and request.
The checked boundary is artifact bytes → installed JAX CPU runtime → scores. The worker has a JSON transport for the test, not a production web server. A separate deployment runtime requires its own receipt.
Make it yours
Where to put your code: Append your solution at the bottom of phases/15-deployment/03-export-and-serve-a-trained-computation/code/main.py (or inside the Make it yours cell of exercises/deployment-03.ipynb) and run python3 phases/15-deployment/03-export-and-serve-a-trained-computation/code/main.py to verify your prediction before opening the reference solution.
Verify a new input against an independent NumPy expression.
Show a writing guide and starter scaffold
How to write this exercise code: step-by-step recipe & starter scaffold
1. Key functions & syntax to use:
jnp.allclose(actual, expected, rtol=..., atol=...)— Checks that two arrays match elementwise within floating-point tolerance.
2. Step-by-step implementation plan:
- Convert
expectedto a host NumPy array for inspection or verification.
# Exercise solution: Verify a new input [-2, 0, 3] against an independent NumPy expression.
changed = np.array(...) # TODO: compute changed
# Convert `expected` to a host NumPy array for inspection or verification.
expected = ... # TODO: compute expected
np.testing.assert_allclose(restored.call(changed), expected, atol = ... # TODO: compute np.testing.assert_allclose(restored.call(changed), expected, atol
print("Changed exported request verified")
Reveal the reference solution
# Exercise solution: Verify a new input [-2, 0, 3] against an independent NumPy expression.
changed = np.array([[-2., 0., 3.]], np.float32)
# Convert `expected` to a host NumPy array for inspection or verification.
expected = changed @ np.asarray(weights) + np.asarray(bias)
np.testing.assert_allclose(restored.call(changed), expected, atol=1e-6)
print("Changed exported request verified")Validate a request before inference
Transfer
Write a host-side request checker that rejects nonfinite values and the wrong shape. Test a NaN input and a wrong feature count.
Show a writing guide and starter scaffold
How to write this practice solution: step-by-step recipe & starter scaffold
1. Key functions & syntax to use:
jnp.allclose(actual, expected, rtol=..., atol=...)— Checks that two arrays match elementwise within floating-point tolerance.jnp.isfinite(x)— Returns a boolean mask verifying that no element isNaNorInf.
2. Step-by-step implementation plan:
- Convert
arrayto a host NumPy array for inspection or verification. - Guard input contract (
array.shape != (1, 3) or not np.isfinite(array).all()) and fail fast if violated.
# Validate a request before inference (Transfer): Validation belongs before the runtime call.
def validate_request(values):
# Convert `array` to a host NumPy array for inspection or verification.
array = np.asarray(...) # TODO: compute array
# Guard input contract (`array.shape != (1, 3) or not np.isfinite(array).all()`) and fail fast if violated.
if array.shape != (1, 3) or not np.isfinite(array).all():
raise ValueError("expected finite float32 [1, 3]")
return ... # TODO: return computed result
for invalid in ([[1., 2.]], [[1., float("nan"), 3.]]):
try:
validate_request(invalid)
except ValueError:
pass
else:
raise AssertionError("invalid input accepted")
np.testing.assert_allclose(restored.call(validate_request([[1.,2.,-1.]])), [[3.1,-.45]], atol=1e-6)
Show a hint
Validate shape and finiteness on the host before calling inference. Include a singleton batch, the wrong feature count and a NaN; specify which cases your serving signature permits.
Reveal reference solution and reasoning
# Validate a request before inference (Transfer): Validation belongs before the runtime call.
def validate_request(values):
# Convert `array` to a host NumPy array for inspection or verification.
array = np.asarray(values, dtype=np.float32)
# Guard input contract (`array.shape != (1, 3) or not np.isfinite(array).all()`) and fail fast if violated.
if array.shape != (1, 3) or not np.isfinite(array).all():
raise ValueError("expected finite float32 [1, 3]")
return array
for invalid in ([[1., 2.]], [[1., float("nan"), 3.]]):
try:
validate_request(invalid)
except ValueError:
pass
else:
raise AssertionError("invalid input accepted")
np.testing.assert_allclose(restored.call(validate_request([[1.,2.,-1.]])), [[3.1,-.45]], atol=1e-6)Validation belongs before the runtime call. A dtype cast alone neither verifies input shape nor rejects NaNs.
Prove that request validation precedes exported inference
Transfer / diagnosis
Build a request wrapper that records how often inference runs. Reject both a malformed feature row and a nonfinite row before calling the exported computation. Then verify one valid request.
Show a writing guide and starter scaffold
How to write this practice solution: step-by-step recipe & starter scaffold
1. Key functions & syntax to use:
jnp.allclose(actual, expected, rtol=..., atol=...)— Checks that two arrays match elementwise within floating-point tolerance.
2. Step-by-step implementation plan:
- Accumulate the next contribution into
inference_calls[0].
# Prove that request validation precedes exported inference (Transfer / diagnosis): The wrapper tests ordering as well as acceptance.
inference_calls = ... # TODO: compute inference_calls
def checked_prediction(values):
array = validate_request(...) # TODO: compute array
# Accumulate the next contribution into `inference_calls[0]`.
inference_calls[0] += 1
return ... # TODO: return computed result
for invalid in ([[1., 2.]], [[1., float('inf'), 3.]]):
try:
checked_prediction(invalid)
except ValueError:
pass
else:
raise AssertionError('invalid request reached inference')
assert inference_calls[0] # TODO: complete assertion check
np.testing.assert_allclose(checked_prediction([[1.,2.,-1.]]), [[3.1,-.45]], atol=1e-6)
assert inference_calls[0] # TODO: complete assertion check
print('Invalid requests rejected before the one valid inference call.')
Show a hint
Increment the counter only after validation succeeds. An error message alone does not prove which boundary rejected the input.
Reveal reference solution and reasoning
# Prove that request validation precedes exported inference (Transfer / diagnosis): The wrapper tests ordering as well as acceptance.
inference_calls = [0]
def checked_prediction(values):
array = validate_request(values)
# Accumulate the next contribution into `inference_calls[0]`.
inference_calls[0] += 1
return np.asarray(restored.call(array))
for invalid in ([[1., 2.]], [[1., float('inf'), 3.]]):
try:
checked_prediction(invalid)
except ValueError:
pass
else:
raise AssertionError('invalid request reached inference')
assert inference_calls[0] == 0
np.testing.assert_allclose(checked_prediction([[1.,2.,-1.]]), [[3.1,-.45]], atol=1e-6)
assert inference_calls[0] == 1
print('Invalid requests rejected before the one valid inference call.')The wrapper tests ordering as well as acceptance. A service can satisfy an output check on valid inputs and still waste runtime work or fail unclearly on malformed requests.
Diagnose the result
A missing flatbuffers dependency prevents serialization: reinstall requirements-cpu.txt. A rejected input may be correct enforcement of your shape/dtype signature. Conversion success with output drift calls for intermediate-tensor comparison, preprocessing checks and evaluation on held-out examples.
Keep your evidence
Keep the export/reload code, version/platform manifest, known-output and changed-input checks, rejected-shape result, and the planned serving request boundary.
Save your modified code, its output, and an explanation of your prediction. Checkpoint answers and exercise evidence are tracked separately.
Saved in this browser. Export or back up your portfolio anytime in My learning.
Verified on local CPU. Stage to a Cloud TPU VM via Cloud TPU setup to record accelerator execution receipts (TODO: add automated Cloud TPU CI execution receipts).