export(model: Module, example: object, path: str, precision: Precision = Precision.FLOAT32, weights: WeightPrecision | Palettize | Sparsify = WeightPrecision.FLOAT, shapes: list[tuple[int, ...]] | None = None, shape_range: dict[int, tuple[int, int]] | None = None, state: list[State] | None = None, image_input: ImageInput | None = None, classifier: Classifier | None = None, metadata: Metadata | None = None, compute_units: ComputeUnits = ComputeUnits.ALL, output_field: str | None = None, minimum_deployment_target: DeploymentTarget | None = None, draws: Draws = Draws.REFUSED, activations: Activations = Activations.SIMULATED)Trace model, write a .mlpackage at path, and load it.
Parameters
modelnn.ModuleMust be in
eval() mode: an exported graph is an inference
graph, and a training-mode dropout is refused rather than
silently turned into an identity.Supplies each input's shape and dtype; the values are irrelevant.
A tuple is passed to the model positionally, a mapping by
keyword. The exported model's input shapes are fixed to these.
pathstrDestination
.mlpackage. Replaced if it exists.Precision of the program body.
FLOAT32 keeps the export
faithful to the model it came from; FLOAT16 is what the
Neural Engine runs. Inputs and outputs stay float32 either way.How weights are stored.
INT8 keeps eight bits per weight plus
one scale per output channel and lets Core ML dequantize on the
way in — the package halves against float16 and the accelerator
moves half as much memory, at a real cost in agreement that
CoreMLModel.verify will quantify.shapeslist of tuple of int or None, optional, keyword-only= NoneEvery input shape the package should accept — a range of batch
sizes, or of resolutions — with the example's own among them.
Found by tracing at each shape and comparing, so an operation
whose configuration came from the input size (an adaptive pool
bakes its kernel that way) is refused by name rather than fixed
to one shape and wrong at the others.
shape_rangedict of int to tuple of int or None, optional, keyword-only= NoneAxis to
(lowest, highest), when the sizes are a range rather
than a short list — a variable sequence length, a camera whose
resolution changes. Axes left out keep the example's size.
Mutually exclusive with shapes.Input/output pairs Core ML should carry between predictions
rather than exchange with the caller — a decoder's key-value
cache. Needs iOS 18 / macOS 15, which a package asks for only
when it uses one. The state begins at zero.
Present the sole input as an image, with the normalisation it
expects, so an app can hand over a pixel buffer instead of
converting pixels itself.
Declare the model a classifier over these labels, so Core ML —
and Vision through it — returns the winning label and a
label-to-probability map instead of a score array. Read with
CoreMLModel.classify.Description, author, licence and version to record in the package.
Which processors Core ML may schedule on.
What to do with a quantization-aware model's activation
fake-quantization. The default carries it as arithmetic, so the
package reproduces the model exactly;
DROPPED removes it,
leaving quantized weights and float activations — which is what
the Neural Engine computes anyway. Inert for a model that carries
none.What to do about a model that draws random numbers in
forward. Core ML folds a draw at build time, so the default
refuses rather than writing a package that returns one fixed
sample forever. AS_INPUT declares each draw as an input the
caller fills; the handle draws for a caller who passes nothing,
so the package still samples the way the model does.Oldest system the package must run on. State, palettization and
several entry points each raise that floor to
IOS18; naming a
lower one refuses the export rather than producing a package that
loads nowhere the caller intended. None accepts whatever the
features require, and the result is reported on the model.output_field(str or None, optional, keyword - only)= NoneSingle attribute to export when the model returns an output
dataclass.
None exports every tensor field it declares —
a detector's boxes and objectness as well as its class scores.Returns
CoreMLModelLoaded and ready to CoreMLModel.predict.
Raises
ValueErrorThe model is in training mode.
UnsupportedOpThe trace contains an operation with no MIL translation.
Examples
The whole of it, for a classifier that should run on the accelerator:
>>> import shutil, tempfile
>>> import lucid, lucid.nn as nn, lucid.models as M, lucid.coreml as cml
>>> model = M.create_model(
... "resnet_18_cls", num_classes=10,
... stem_channels=8, hidden_sizes=(8, 16, 32, 64), # narrowed, to be quick
... ).eval()
>>> x, room = lucid.randn(1, 3, 32, 32), tempfile.mkdtemp()
>>> package = cml.export(
... model, x, f"{room}/resnet18.mlpackage",
... precision=cml.Precision.FLOAT16,
... compute_units=cml.ComputeUnits.CPU_AND_NE,
... )
>>> package.verify(model, x, relative=True) < 1e-2
True
>>> package.benchmark(x).median_ms > 0.0 # once it has settled
True
A model of several inputs is given them the way its forward
takes them — a tuple positionally, a mapping by name:
>>> class Pair(nn.Module):
... def __init__(self):
... super().__init__()
... self.image = nn.Linear(16, 4)
... self.text = nn.Linear(8, 4)
... def forward(self, image, text):
... return self.image(image) + self.text(text)
>>> pair, image, text = Pair().eval(), lucid.randn(1, 16), lucid.randn(1, 8)
>>> with cml.export(pair, (image, text), f"{room}/pair.mlpackage") as two:
... print(two.input_names, two.predict((image, text)).shape)
['input_0', 'input_1'] (1, 4)
>>> feed = {"image": image, "text": text}
>>> with cml.export(pair, feed, f"{room}/named.mlpackage") as two:
... print(two.input_names, two.predict(feed).shape)
['image', 'text'] (1, 4)
Smaller on disk, at a cost worth measuring before shipping it:
>>> with cml.export(model, x, f"{room}/int8.mlpackage",
... weights=cml.WeightPrecision.INT8) as small:
... print(small.verify(model, x, relative=True) < 1e-2)
True
Pixels in and labels out:
>>> pixels = (lucid.rand(1, 3, 32, 32) * 255).round()
>>> names = tuple(f"class_{i}" for i in range(10))
>>> with cml.export(model, pixels, f"{room}/cls.mlpackage",
... image_input=cml.ImageInput(scale=1 / 255.0),
... classifier=cml.Classifier(labels=names)) as labelled:
... label, scores = labelled.classify(pixels)
>>> label in names, len(scores)
(True, 10)
A batch axis the caller may vary, and a model that samples:
>>> with cml.export(model, x, f"{room}/flex.mlpackage",
... shape_range={0: (1, 16)}) as flexible:
... print(flexible.predict(lucid.randn(4, 3, 32, 32)).shape)
(4, 10)
>>> class Encoder(nn.Module):
... def __init__(self):
... super().__init__()
... self.mu = nn.Linear(8, 4)
... self.logvar = nn.Linear(8, 4)
... def forward(self, h):
... mu, logvar = self.mu(h), self.logvar(h)
... return mu + (logvar * 0.5).exp() * lucid.randn(1, 4)
>>> with cml.export(Encoder().eval(), lucid.randn(1, 8), f"{room}/vae.mlpackage",
... draws=cml.Draws.AS_INPUT) as vae:
... print(vae.noise_inputs)
[('noise_0', (1, 4))]
A model that came out of lucid.quantization needs nothing said
about it — the export recognises what it is carrying:
>>> import lucid.quantization as q
>>> def small_net():
... return nn.Sequential(
... nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(),
... nn.Conv2d(32, 32, 3, padding=1), nn.ReLU(),
... nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(32, 10),
... )
>>> aware = q.prepare_qat(small_net(), q.get_default_qat_qconfig_mapping())
>>> _ = aware(x) # fine-tune, then
>>> with cml.export(aware.eval(), x, f"{room}/qat.mlpackage",
... weights=cml.WeightPrecision.INT8,
... activations=cml.Activations.DROPPED) as qat:
... print(qat.predict(x).shape)
(1, 10)
Leave a converted model's weights alone, though. They already sit on
MLX's grid, and asking for another stacks two of them — 3.8e-06
against the quantized model becomes 3.4e-02:
>>> dynamic = q.quantize_dynamic(small_net().eval())
>>> with cml.export(dynamic, x, f"{room}/dynamic.mlpackage") as converted:
... print(converted.verify(dynamic, x, relative=True) < 1e-4)
True
>>> package.close()
>>> shutil.rmtree(room)