Sparse model IR¶
The sparse model exchange contract is MLIR-first. Graph semantics live in the
lattice MLIR dialect; weights live in weights.safetensors. The Python
contract package provides an annotation-backed dialect schema and textual MLIR
builder, but it does not define a second graph IR.
Artifact layout¶
The current exchange bundle is:
model.lattice/
graph.mlir
weights.safetensors
graph.mlir owns sparse value construction, weight binding, convolution,
submanifold convolution, target convolution, dense feature projection,
dense feature activation, and sparse algebra semantics.
weights.safetensors stores dense or packed quantized payloads referenced
by lattice.weight operations.
Package metadata can be added later as shallow manifest data, but graph semantics belong in MLIR.
Project goals¶
The MLIR integration has a narrow deployment goal:
Torch/CUDA-side exporter or other producer
-> graph.mlir + weights.safetensors
-> native MLIR parser and lattice verifier
-> typed RuntimePlan
-> MLX/Metal runtime lowering through mlx_lattice.ops
The project should optimize for these properties:
graph.mliris the portable semantic graph contract.weights.safetensorsstores tensor payloads referenced by symboliclattice.weightoperations.native MLIR tooling parses and verifies graph structure before Python lowering.
module-level artifact metadata records the dialect version, schema digest, weight file, and stable input/output ABI.
Python receives a typed
RuntimePlanand rejects malformed importer payloads at the boundary.every accepted v0 dialect operation either has a complete MLX lowering or is rejected by verification/import with a clear diagnostic.
backend-specific route selection, coordinate managers, relation caches, TensorOps choices, and Metal/CUDA scheduling remain runtime implementation details.
Non-goals¶
The MLIR artifact is not intended to become:
a Python MLIR parser implemented inside
mlx-lattice;a JSON graph contract embedded inside MLIR attributes;
a serialized PyTorch module, MLX module, or sparse Python object;
a custom MLIR-to-Metal compiler pipeline;
an ahead-of-time device-specific executable artifact;
a persistence format for backend handles, rulebook buffers, TensorOps tile masks, or path-selection thresholds.
Those may be useful implementation details or future optimization layers, but they are not part of the stable portable model contract.
Executable entry¶
An executable artifact contains one graph entry:
func.func @forward(...) -> (...) {
...
return ... : ...
}
The verifier rejects modules with no function, more than one function, an
entry not named forward, or a forward function that returns no values.
The entry body is a single block containing only lattice dialect operations
and func.return. SSA argument names in textual MLIR are not semantic API
names; stable named inputs should be added through explicit ABI metadata rather
than inferred from parser-preserved text.
The module must also carry artifact metadata:
module attributes {
lattice.ir_version = 2,
lattice.schema_digest = "...",
lattice.input_names = ["coords", "features", "active"],
lattice.input_roles = ["sparse_coords",
"sparse_features",
"sparse_active"],
lattice.output_names = ["output"],
lattice.output_roles = ["sparse_tensor"],
lattice.weight_file = "weights.safetensors"
} {
...
}
lattice.schema_digest is a SHA-256 fingerprint of the contract dialect
schema. It closes the gap where two producers both claim
ir_version = 2 but disagree on the operation, attribute, or type surface.
The native verifier and Python RuntimePlan both reject mismatched digests.
IR v2 makes the sparse ABI’s coordinate scale explicit. The public coordinate
tensor always contains physical (batch, x, y, z) positions, while the
stride attribute records the lattice scale. Importers convert to their
internal coordinate representation only after checking that every spatial
coordinate is exactly divisible by that stride. Convolution artifact ops also
carry accumulation = \"canonical_f32\": portable artifacts accumulate in
FP32 even when their input and weight storage is FP16.
lattice.input_names and lattice.output_names are the public call ABI.
They are independent from SSA value labels such as %arg0 or importer
runtime labels such as arg0/v3. lattice.input_roles and
lattice.output_roles describe how each public item should be bound. The
currently accepted input roles are tensor, sparse_coords,
sparse_features, and sparse_active. The accepted output roles are
tensor and sparse_tensor.
The native MLX importer exposes a structured execution plan after verification.
Plan argument and output records include generated runtime value labels,
stable ABI names, canonical MLIR type text, and roles. Sparse tensor shorthand
is accepted only when the entry ABI exposes sparse_coords,
sparse_features, and sparse_active roles. Keyword execution binds by
ABI names, not by generated runtime labels.
Python freezes the native payload into RuntimePlan before execution.
Runtime lowerings receive typed plan operations rather than raw dictionaries;
the dictionary shape is only the native binding transport, not a second runtime
IR. This freeze step resolves each operation through the lattice-contract
schema and validates operation arity plus required attributes before any
framework lowering runs. It also checks runtime use/definition order, rejects
duplicate value labels, and rejects returns that do not refer to defined
values.
Annotation-backed contract¶
lattice-contract declares the dialect surface through annotations in one
schema. The MLIR builder consumes that schema directly:
builder = MLIRModuleBuilder()
x = builder.sparse_make(...)
w = builder.weight(...)
y = builder.conv3d(input=x, weight=w, ...)
This keeps op names, result names, and attribute names centralized. Backend exporters should call the generated builder surface rather than manually formatting dialect strings or maintaining their own operation registries.
Convolution identity¶
The dialect distinguishes support-generating and support-preserving sparse
convolution by operation identity, not by geometry inference. A producer should
lower Conv3d to lattice.conv3d even when stride=(1, 1, 1).
Submanifold convolution must be represented as an explicit
SubmConv3d/lattice.subm_conv3d operation. This keeps Torch/CUDA
training graphs, MLX/Metal deployment graphs, and native relation builders on
the same semantic contract.
Quantization model¶
Quantized graph op variants such as lattice.quantized_conv3d are not part
of the semantic graph surface. Quantization is represented by weight packing:
%w = lattice.weight @stem.qweight
{storage_key = "stem.qweight",
layout = #lattice.weight_layout<conv3d_o_xyz_i>,
packing = #lattice.packing<int4,
group_size = 32,
scale_dtype = f16,
mode = affine>}
: !lattice.weight<conv3d, i4>
The consuming operation remains lattice.conv3d.
In weights.safetensors, dense weights use the declared storage_key
directly. Packed int4/int8 weights use <storage_key>.weight,
<storage_key>.scales, and <storage_key>.biases. The graph still owns
bit width, group size, scale dtype, mode, and logical layout through the
lattice.weight attributes.
Bias is explicit dataflow. A convolution or linear operation may carry an
optional third operand produced by lattice.weight with family bias and
layout bias_c. Omitting that operand means no bias is applied.
Feature-only updates¶
Feature-only MLIR operations are value-aligned, not sparse-object mutations.
lattice.linear and lattice.activation consume rank-2 dense feature
tensors. lattice.batch_norm, lattice.layer_norm, and
lattice.rms_norm follow the same rule. A graph that needs to preserve
sparse support must spell that out:
%coords, %features, %active = lattice.sparse.decompose %input
: !lattice.sparse_tensor<rank = 3,
coord = batch_x_y_z,
feature = row_channel,
dtype = f16>
-> (tensor<?x4xi32>, tensor<?x32xf16>, tensor<1xi32>)
%features2 = lattice.activation %features
{kind = #lattice.activation<relu>,
approximate = #lattice.gelu_approx<none>,
alpha = 0.01 : f32,
beta = 1.0 : f32,
threshold = 20.0 : f32}
: (tensor<?x32xf16>) -> tensor<?x32xf16>
%out = lattice.sparse.with_features %input, %features2
: (!lattice.sparse_tensor<rank = 3,
coord = batch_x_y_z,
feature = row_channel,
dtype = f16>,
tensor<?x32xf16>)
-> !lattice.sparse_tensor<rank = 3,
coord = batch_x_y_z,
feature = row_channel,
dtype = f16>
This keeps coordinate identity visible to the verifier and prevents importers from hiding sparse mutation semantics inside framework-specific helpers.
Normalization parameters are symbolic weights, not attributes. BatchNorm uses
explicit frozen mean and variance operands; training-mode running-stat updates
are outside the deployment artifact ABI. Scale, mean, and variance use the
channel weight family with channel_c layout. Bias uses the bias
family with bias_c layout.
Runtime boundary¶
mlx_lattice.artifact loads and saves the exchange bundle. In MLIR-enabled
builds it can also ask the native parser/verifier for a structured execution
plan and lower that verified plan through mlx_lattice.ops. The Python layer
does not parse MLIR text itself and should not carry a second graph runtime.
Runtime-only structures such as coordinate managers, relation caches, CSR views, implicit-GEMM maps, TensorOps routes, and Metal kernel dispatch choices must stay outside the portable IR.
Sparse value ABI¶
Sparse values should cross framework boundaries through explicit ABI tensors:
coordinates, features, active row count, and sparse stride. The dialect models
this with lattice.sparse.make and lattice.sparse.decompose. It
deliberately excludes runtime identity such as CoordinateManager and
CoordinateMapKey.