Model and Samples
The ONNX and .npz sample-set rules for the Physical AI lane, and how to validate your set before uploading.
A Physical AI upload is two things: one ONNX model, and a set of real input samples. This page is the contract both have to satisfy. Melange checks it at ingest and rejects a bad set with a specific message, because the alternative is a cryptic failure hours later inside the engine build.
The model
- One
.onnxfile per upload. If your model is a pipeline of several graphs, upload each stage as its own model — see the SAM 2 walkthrough, which ships an encoder and a decoder as two models. - External data is supported. If your exporter wrote weights to
.dataor.onnx_datasidecars, attach them in the External Data Files field. The dashboard reads the graph and tells you which files it expects. - Dynamic axes are fine. They get frozen from your first sample — see below.
Export with the dynamo exporter
Melange requires graphs produced by torch.onnx.export(..., dynamo=True). This is not a stylistic preference: the dynamo exporter specializes control flow and stamps concrete output shapes at trace time, and a fixed-shape device engine cannot be built without both.
Uploads are checked at ingest and rejected — with the re-export recipe — when the graph carries a legacy TorchScript signature:
| Signature | Why it cannot be served |
|---|---|
If nodes whose condition does not depend on any graph input | The TorchScript padding-branch pattern. A dynamo export resolves these at trace time; quantization tooling cannot see through them. |
| Symbolic or unannotated output dimensions on a graph whose inputs are all static | With static inputs there is no symbol the outputs could legitimately inherit, so the engine's output shapes are unknowable. |
| Weights redeclared as graph inputs | Left behind by keep_initializers_as_inputs-style exporters; a dynamo export keeps weights as initializers. |
If you hit one of these, re-export with dynamo=True and declare any dynamic axes through dynamic_shapes= with torch.export.Dim — a single-element sample works with Dim(name, min=1). The dynamo exporter runs on onnxscript, which PyTorch does not install for you; without it the export fails on ModuleNotFoundError: No module named 'onnxscript' before it emits anything.
The sample set
One sample is one .npz archive. The rules:
Keys are exactly the model's input names
Not a subset, not a superset. If the graph declares pixel_values, the archive has a pixel_values array and nothing else.
Dtypes match what the graph declares
An int64 array for an input the model declares as int32 is rejected. NumPy's defaults are a common source of this — np.array([[1]]) is int64 on Linux.
Static dimensions match, dynamic ones get frozen
Every axis the model pins to a number must match exactly. Axes the model leaves dynamic are frozen to whatever the first sample uses, so an engine is always a fixed-shape engine.
All samples agree on shape and dtype
The module is frozen to a single static shape, so sample 5 cannot have a different sequence length than sample 1.
At least one sample, at most 50
Melange never falls back to random or synthetic calibration data for your model. If you give it nothing, it stops.
Files are read in sorted filename order, so the alphabetically first file is the one whose shapes are frozen into the engine. Name them sample_00.npz, sample_01.npz, … and the ordering stays obvious.
Why the samples matter this much
The same set is read three times during a run:
| Stage | Which samples | What happens |
|---|---|---|
| Shape freeze | The first one | Dynamic axes become static; the engine is built for these shapes. |
| Quantization calibration | All of them | FP8 and INT8 activation ranges are measured on this data. |
| Scoring | All of them | Every engine's outputs are compared against the original FP32 ONNX on these inputs. |
So use real inputs, drawn from the distribution the model will actually see. Zeros and random noise pass validation and then quietly ruin calibration, and they make the accuracy column meaningless — it would be measuring agreement on data neither model was built for. Eight to sixteen representative samples is a good starting point.
Writing an archive
np.savez takes the input names as keyword arguments, which is exactly the shape of the contract:
import numpy as np
np.savez_compressed(
"samples/sample_00.npz",
pixel_values=pixel_values, # float32 [1, 3, 1024, 1024]
point_coords=point_coords, # float32 [1, 1, 1, 2]
point_labels=point_labels, # int32 [1, 1, 1]
)savez_compressed is worth it: sample sets are mostly floating-point tensors, and the upload is smaller for free.
Validating before you upload
Save this as validate_samples.py. It applies the same checks the server does; run it against a model and its sample directory and fix what it reports — every failure here is an upload that would have been rejected.
python validate_samples.py my_model.onnx samples/import sys
from pathlib import Path
import numpy as np
import onnx
from onnx.helper import tensor_dtype_to_np_dtype
MODEL, SAMPLES = sys.argv[1], Path(sys.argv[2])
model = onnx.load(MODEL, load_external_data=False)
initializers = {i.name for i in model.graph.initializer}
specs = {}
for value_info in model.graph.input:
if value_info.name in initializers:
continue
tensor_type = value_info.type.tensor_type
specs[value_info.name] = (
tensor_dtype_to_np_dtype(tensor_type.elem_type),
[d.dim_value if d.dim_value > 0 else None for d in tensor_type.shape.dim],
)
print("model inputs:", {k: (str(d), s) for k, (d, s) in specs.items()})
paths = sorted(SAMPLES.glob("*.npz"))
assert paths, f"no .npz files in {SAMPLES}"
first = None
for path in paths:
with np.load(path) as archive:
sample = {key: archive[key] for key in archive.files}
assert set(sample) == set(specs), (
f"{path.name}: keys {sorted(sample)} != model inputs {sorted(specs)}"
)
for name, (dtype, dims) in specs.items():
array = sample[name]
assert array.dtype == dtype, f"{path.name}/{name}: dtype {array.dtype}, model declares {dtype}"
assert len(array.shape) == len(dims), f"{path.name}/{name}: rank {len(array.shape)}, model declares {len(dims)}"
for axis, declared in enumerate(dims):
assert declared is None or declared == array.shape[axis], (
f"{path.name}/{name}: axis {axis} is {array.shape[axis]}, model pins {declared}"
)
shapes = {k: (v.dtype, v.shape) for k, v in sample.items()}
if first is None:
first = shapes
else:
assert shapes == first, f"{path.name} does not match {paths[0].name}"
print(f"{path.name}: ok")
print(f"{len(paths)} samples valid; frozen shapes = "
f"{ {k: list(v[1]) for k, v in first.items()} }")Common rejections
| Message | Cause | Fix |
|---|---|---|
keys do not match the model inputs | The archive was saved with your own names, or an input was forgotten | Save with np.savez(path, **{input_name: array}) using the names the script above prints |
has dtype int64, but the model declares int32 | NumPy's default integer width | array.astype(np.int32) before saving |
the model pins axis N to M | The sample was built at a different resolution or batch size | Regenerate the sample at the exported shape |
differs from '<first file>' | Samples were captured at mixed shapes | Pick one shape and rebuild the set |
no '*.npz' sample files | .npy files were attached instead | This lane takes .npz archives; one archive holds all inputs of one sample |