Melange

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 .onnx file 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 .data or .onnx_data sidecars, 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:

SignatureWhy it cannot be served
If nodes whose condition does not depend on any graph inputThe 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 staticWith static inputs there is no symbol the outputs could legitimately inherit, so the engine's output shapes are unknowable.
Weights redeclared as graph inputsLeft 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:

StageWhich samplesWhat happens
Shape freezeThe first oneDynamic axes become static; the engine is built for these shapes.
Quantization calibrationAll of themFP8 and INT8 activation ranges are measured on this data.
ScoringAll of themEvery 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

MessageCauseFix
keys do not match the model inputsThe archive was saved with your own names, or an input was forgottenSave with np.savez(path, **{input_name: array}) using the names the script above prints
has dtype int64, but the model declares int32NumPy's default integer widtharray.astype(np.int32) before saving
the model pins axis N to MThe sample was built at a different resolution or batch sizeRegenerate the sample at the exported shape
differs from '<first file>'Samples were captured at mixed shapesPick one shape and rebuild the set
no '*.npz' sample files.npy files were attached insteadThis lane takes .npz archives; one archive holds all inputs of one sample

Next steps

On this page