TorchMetalKernel: writing and embedding a custom Metal kernel¶
Part 8 · Core AI: converting from PyTorch · Reference 03
Requirements: Current conversion uses coreai-torch 0.4.3 with beta coreai-core==1.0.0b3, Python ≥3.11, and torch ≥2.8.0. The TorchMetalKernel source/signature inventory below was inspected in 0.4.1 (pyproject.toml:5-59, coreai_torch/__init__.py:32-39); its 2.13.0 warning boundary is a package compatibility claim, not fresh local testing of that range. Custom kernels run on the GPU, and Apple's Metal tests require a Mac. Core AI assets target OS 27 and Xcode 27.
If your kernel body reaches for TensorOps (mpp::tensor_ops::matmul2d, cooperative tensors),
a second, finer version ladder applies to the device OS, not to coreai-torch:
26.0 TensorOps introduced · 26.1 bfloat tensors · 26.3 cooperative tensors as matmul
inputs · 26.4 int4/int8 tensors. ✅ VERIFIED — Apple Tech Talk 111432, "Accelerate your
machine learning workloads with the M5 and A19 GPUs", quoted in
notes/transcripts/missing-sessions.md §7.5. 26.2 appears nowhere in that ladder, even though
the shipped Xcode 26.6 SDK annotates the relevant TensorOps symbols with a 26.2 deployment macro.
Both statements are true and they are about different things — §11 untangles them.
See the shared evidence conventions. API citations and runtime checks attest their named source revision or fixture.
What this covers¶
Provide a PyTorch reference, a Metal kernel, and a TorchMetalKernel registration. Check reversed tensor extents, result shapes, dispatch, dtype, and parity with the reference before relying on a performance improvement.
What this does not cover¶
Related references: Part 11 — Metal and TensorOps.
What you need¶
- A Mac. Not a preference:
tests/dsl/conftest.pyskips the entire Metal test tree on non-Darwin, and the runtime path requires Metal-backed storage. pip install coreai-torch(installscoreaiandcoreai_torch). The.aimodelyou produce needs macOS 27 / iOS 27 to run.- Working knowledge of Metal Shading Language. This guide does not teach MSL. It teaches the ~15 things about MSL-inside-Core-AI that differ from MSL-inside-a-Metal-app.
- Ideally, a profile showing that the built-in path is your bottleneck. §2 explains how to get one and why the answer is usually "don't".
Contents¶
- The mechanism: three pieces, one artifact
- When to do this at all
- The complete worked example: a fused SiLU
- The constructor, field by field
- What the converter generates — and the axis reversal
result_shapes: why every call site- Thread dispatch: grid, threadgroup, and bounds
- Registering, converting, running
- Scalar inputs: literals in disguise
- Dtype templating, the kernel cache, and multiple outputs
helper_srcand reaching TensorOps- The failure taxonomy
- Does it pay? Community measurements
- Testing a kernel, and the de-risk ladder
- Deployment reality
- Quick reference
- Sources and evidence ledger
1. The mechanism: three pieces, one artifact¶
1.1 The shape of it¶
Everywhere else in coreai-torch, conversion is a one-input affair: an ExportedProgram goes in,
an AIProgram comes out. Custom Metal kernels add a second input channel that runs parallel to
the graph:
┌──────────────────────────────┐
your nn.Module ──►│ torch.export.export │──► ExportedProgram
│ .run_decompositions( │ (custom-kernel ops survive
│ get_decomp_table()) │ decomposition intact)
└──────────────────────────────┘ │
▼
your MSL body ───► TorchMetalKernel(...) ──► converter.register_custom_kernels([k])
▲ │
│ ▼
torch reference converter.add_exported_program(ep, ...)
(shape inference) │
▼
converter.to_coreai()
program.save_asset(path)
│
▼
one .aimodel directory,
MSL text embedded inside
The output is not "a model plus a shader library". It is one .aimodel directory whose
program bytecode carries your Metal source as a string attribute on a coreai.metal4_kernel
operation. The kernel travels with the model, gets compiled by the Core AI runtime on the device
during specialization, and survives ahead-of-time compilation to .aimodelc.
✅ VERIFIED — the IR shape, from
tests/dsl/test_custom_kernels.py:116-128, a live FileCheck assertion:coreai.metal4_kernel kernel_args(%a, %b), threads_per_grid %g, threads_per_thread_group %tg, result_shapes(%rs) {kernel_name = "custom_add_<8 random chars>", kernel_source = "<the whole MSL>"} : (tensor<2x2x3xf16, #coreaix.hw_constraints<MTLBuffer, alignments: [1x1x1x1], interleave: [1x1x1]>>, tensor<2x2x3xf16, …>, tensor<3xui32>, // threads_per_grid tensor<3xui32>, // threads_per_thread_group tensor<3xui32>) // result_shapes, one per result -> tensor<2x2x3xf16, …>Three things worth reading off that: the MSL is a string attribute (
kernel_source), the dispatch tuples and result shapes are real tensor operands (tensor<3xui32>), and every tensor touching the kernel carries an#coreaix.hw_constraints<MTLBuffer, …>encoding. That last one is why runtime inputs must be Metal-backed — §8.4.✅ VERIFIED — the kernel is not a raw-Metal bypass. It is lowered to a first-class Core AI operation and executed by the OS Core AI runtime like any other op. Community summary of the same point, from
notes/repos/john-rocky-models.md§5.6 (custom-metal-kernels.md:11-14): "It is not a raw-Metal bypass — the MSL travels inside the single.aimodelartifact and runs in the OS Core AI runtime."
1.2 The three pieces¶
Piece one — the PyTorch reference. A plain Python function with fully annotated parameters and
return type. This is what torch.export traces. It never runs on device; its entire job is to tell
the exporter the shape and dtype of the kernel's outputs so the surrounding graph type-checks.
✅ VERIFIED —
docs/api/TorchMetalKernel.md,torch_defnrow: "Reference PyTorch implementation used for shape inference duringtorch.export."
Piece two — the Metal kernel body. Note body, not function. You write the statements that go
inside the [[kernel]] function's braces. You do not write the signature, the buffer bindings, the
#include, or the [[kernel]] attribute.
✅ VERIFIED —
docs/api/TorchMetalKernel.md,srcrow: "Body of the Metal[[kernel]]function. The signature, buffer bindings, and#include <metal_stdlib>are generated automatically frominput_names,result_names, andmetal_params."
Piece three — the registration. A TorchMetalKernel(...) object binding the two together, plus
the input and output names. The names are load-bearing in a way that is easy to miss: the
identifiers you declare are the identifiers your MSL body uses. The session's SiLU example
declares "x" and "y", and the on-screen MSL body writes to y and reads from x.
✅ VERIFIED — session 325:186–204, verbatim: "With just these two pieces, I can now register a Core AI
TorchMetalKernel, give it the Metal source, the PyTorch reference and the input and output names. In this case, the input and output names are"x"and"y"respectively, and you can see those names being used in the MSL kernel above." "So you write the Metal. You write the PyTorch reference. And Core AI binds them together."
Then you call it. From session 325:202: "Using it in a model, I will just call it like any other Python function. Pass the input, specify the thread grid and I am done."
Except there is a fourth thing you must pass at every call site, and the presenter flags it explicitly one sentence later. That is §6.
1.3 The one-line mental model¶
A
TorchMetalKernelis atorch.library.custom_opwhose lowering emits your MSL.
That is not an analogy. It is the implementation. TorchMetalKernel.__init__ registers a real
PyTorch custom operator under the coreai_metal_kernels namespace, and
register_custom_kernels registers a coreai-torch lowering for exactly that operator name:
✅ VERIFIED —
coreai_torch/_torch_metal_kernel.py:297-300:torch_custom_op = torch.library.custom_op( f"coreai_metal_kernels::{self.name}", mutates_args=(), )(wrapper)and
coreai_torch/converter.py:1050-1052:
Two consequences follow immediately and both bite people:
- The kernel op survives
run_decompositions(). It is not an ATen op, so the decomposition table has nothing to say about it. ✅ VERIFIED —docs/guides/custom-metal-kernels.ipynb, step 3: "Custom kernel ops are preserved throughrun_decompositions()— they are not decomposed." - The kernel name is a global registration key. Two
TorchMetalKernelobjects with the samenamestring cannot both be registered with one converter, even if their MSL is byte-identical. §12.1 has the error.
2. When to do this at all¶
2.1 Apple's own framing is a gate, not an invitation¶
The session introduces custom kernels only after telling you not to need them:
✅ VERIFIED — session 325:170–177, verbatim and in order: "Core AI already ships with pre-packaged fast kernels and primitives for heavy operations like Scaled Dot Product Attention, commonly found in Transformers. You can find examples of how to leverage these operations in the
coreai-modelsrepository." "But if you live on the cutting edge and want even more customization, we also have support for custom Metal 4 kernels."
"Live on the cutting edge" is doing a lot of work in that sentence. The pre-packaged path is not a consolation prize — it is a set of hand-tuned kernels that already know about the hardware you are targeting, that survive OS updates, and that Apple regression-tests. Your kernel does none of those things.
There is a second, quieter signal in the same repository. The coreai-torch docs guide opens with
its own three-item gate:
✅ VERIFIED —
docs/guides/custom-metal-kernels.ipynb, "When You Need This": - "You need a GPU kernel that is not available as a standard PyTorch or CoreAI op." - "You want to fuse multiple operations into a single Metal dispatch for performance." - "You need fine-grained control over thread dispatch, shared memory, or Metal-specific features."
Note that "my op is slow" is not on that list. "Not available" and "fuse multiple operations" are.
2.2 The escalation ladder¶
In practice there are four rungs, and you should be able to say why each of the ones below you failed before you climb.
| Rung | Mechanism | Cost to you | When it is the answer |
|---|---|---|---|
| 0 | Plain ATen, converted as-is | zero | Almost always. Start here; most models export without any of this. |
| 1 | Composite ops — coreai_torch.composite_ops.SDPA / RoPE / RMSNorm / GatherMM / GatedDeltaUpdate, or ExternalizeSpec(composite_op_name=…) |
a module swap | A well-known building block is being decomposed into primitives and losing its optimized implementation. |
| 2 | Custom lowering — register_torch_lowering("my_lib::op.default") |
Python, no MSL | Your op can be expressed with Core AI operations; you just need the converter to know how. Portable across compute units. |
| 3 | TorchMetalKernel |
MSL + a maintenance burden | Nothing above expresses it, or profiling says the dispatch count is the bottleneck. GPU only. |
Rung 1 exists precisely so you do not need rung 3, and the mechanism is worth restating because it is under-used:
✅ VERIFIED —
docs/guides/conversion-workflows.ipynb, cell 8 rationale: "Externalizing a submodule preserves its operation boundary during conversion… When you mark a well-known building block — such as attention, RoPE, or RMSNorm — as a composite op, the compiler recognizes that operation and can apply an optimized implementation tailored to it, producing a faster model."
2.3 Rung 2 versus rung 3¶
Apple's own docs point you down the ladder rather than up it. The custom-kernel notebook's "Next
Steps" section lists custom lowering first, described as "a simpler alternative for ops that can
be expressed using standard Core AI operations." ✅ VERIFIED —
docs/guides/custom-metal-kernels.ipynb, final markdown cell.
The trade is:
register_torch_lowering |
TorchMetalKernel |
|
|---|---|---|
| What you write | Python calling coreai.* graph builders |
Metal Shading Language |
| Compute units | CPU / GPU / Neural Engine — whatever the compiler picks | GPU only |
| Fusion | Participates in normal graph optimization | Is a fusion barrier (§13.3) |
| Numerics | The compiler's | Yours, including every exp() you forgot to stabilize |
| API stability | Depends on coreai._compiler.dialects — "private upstream API" |
Depends on coreai.authoring — "experimental and subject to change" |
Both carry an explicit instability warning. The TorchMetalKernel one appears twice, at the top of
both the API reference and the tutorial:
✅ VERIFIED —
docs/api/TorchMetalKernel.md, warning block: "Authoring Metal kernels uses APIs fromcoreai-core(such ascoreai.authoring). These APIs are experimental and subject to change in future releases."
2.4 The compute-unit consequence is structural¶
This is the fact most likely to invalidate a plan, and it has nothing to do with betas or bugs:
✅ VERIFIED (structural) — the Neural Engine executes fixed hardware operations (convolution, layer norm, and friends); it cannot execute arbitrary Metal Shading Language. A model containing a
coreai.metal4_kernelop therefore has a GPU-resident region, permanently. Community statement of the same, fromnotes/repos/john-rocky-models.md§5.6 (custom-metal-kernels.md:22-24): "The ANE runs only fixed hardware ops (Conv/LayerNorm/…); it cannot execute arbitrary MSL. So 'write fused-int8 kernels' is, by construction, a GPU strategy — independent of any beta bug."
The same community source tabulates the two first-class authoring modes, sourced to Apple's own
agent-skill reference files (neural_engine_rules.md / gpu_rules.md in apple/coreai-models):
| Neural Engine (iOS-style authoring) | GPU (macOS-style authoring) | |
|---|---|---|
| Shapes | fully static | dynamic OK |
| Layout | BC1S (B, C, 1, S) |
standard (B, S, D) / (B, H, S, D) |
| Projections | 1×1 Conv2d |
nn.Linear, fused QKV |
| Attention | per-head, sequential | fused native SDPA |
| Custom MSL kernels | NO | YES (TorchMetalKernel) |
| Precision | fp16 only | fp16 weights, fp32 intermediates OK |
(Community-compiled table, notes/repos/john-rocky-models.md §5.3, citing Apple's
coreai-skills reference files. Attribute as community-organized from Apple source, not as an Apple
publication.)
There is a corollary for users of the optional coreai-models loader: its recognized
multi-entrypoint structures select its Neural Engine preference.2 A custom
Metal kernel remains GPU-only, which may be exactly right, but do not generalize the helper’s naming
policy into a Core AI framework rule. See guide 05 in this part for the split, and Part 7 for what
the Swift side sees.
2.5 The honest performance picture¶
Two community results from the same author, on the same machine, using the same API, pointing in opposite directions. Both are worth internalizing before you start.
Community-measured —
notes/repos/john-rocky-models.md§5.6, quoting the author'scustom-metal-kernels.md:116-125. Single-author material with self-declared uncontrolled benchmarks; hardware M4 Max, beta macOS 27, dated mid-2026. Not an Apple figure.
- "The win is killing dispatch overhead via fusion, not kernelizing ops. Per-op kernelization of small ops does NOT help — measured here: kernelizing attention q/k/v/o was slower; any single op-class ≤ 1.3 ms. The real lever is collapsing ~28 ops/layer into 1–3 mega-kernels (whole-layer fusion)."
- "Custom int8 wins only on BIG memory-bound matmuls (FFN, the 262 144-vocab head)… Don't kernelize small projections (k/v)."
- "Prefer native SDPA on GPU — already fused; don't hand-roll it."
- "A decode-step SSM scan kernel is only ~3–8 % faster than the plain torch graph (paired A/B) — not worth the barrier + shape constraints. The SSM kernel win is prefill (chunked SSD, 13.7× on Mac), not decode."
Against that, the same author's mixture-of-experts kernel is one of the largest single wins in the whole corpus — 39 → 141 tok/s. §13 gives that result in full, including why it worked when kernelizing attention did not.
The synthesis is uncomfortable but clear: custom kernels pay when they change the asymptotics of memory traffic or collapse a dispatch storm. They lose when they merely re-implement something Apple already fused. SDPA is the canonical example of the latter.
2.6 Get a profile first¶
You do not have to guess which ops dominate. coreai_torch.debugging ships an op-level
benchmarker:
import sys
from coreai_torch.debugging.benchmarker import benchmark_coreai_program
result = await benchmark_coreai_program(
coreai_program=coreai_program,
inputs={"x": torch.randn(2, 4)},
num_runs=50,
)
result.write_summary(sys.stdout)
for name, module in result.get_module_timings().items():
print(f"{name}: {module.aggregated_op_stats.average:.3f}ms avg")
✅ VERIFIED —
docs/api/debugging.md. Noteget_module_timings()keys by PyTorch module, which is what makes the output actionable: you learn thatdetector.decodercosts 40 % of the graph, not thataten.muldoes.⚠️ During the preview, operation-level debug metadata requires two environment variables to be set before conversion, or the module and source attribution is simply missing:
✅ VERIFIED —docs/api/debugging.md:5-13.
And one more measurement rule, which is the most reusable sentence in the community archive:
Community-measured protocol —
notes/repos/john-rocky-models.md§5.6 (:124): "Measurement protocol matters more than the kernel: pair both arms in one process, interleave ≥ 8 reps, report median + spread; unpaired single-shot on a ±15 %-drift machine will confirm anything."
3. The complete worked example: a fused SiLU¶
3.1 Apple's canonical example, verbatim¶
Before the session's example, here is the one that is quoted verbatim from Apple's shipping docs and that you can copy without any reconstruction. It is a two-input element-wise add.
✅ VERIFIED —
docs/guides/custom-metal-kernels.ipynbcells 2, 4, 6, 8 anddocs/api/TorchMetalKernel.md"Example". Reproduced in full; the only edit is merging the notebook's repeated imports.
import torch
import torch.nn as nn
from coreai.authoring import MetalParameter
from coreai_torch import TorchConverter, TorchMetalKernel, get_decomp_table
def torch_add(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
"""Reference implementation for shape inference during export."""
return x + y
custom_add = TorchMetalKernel(
"vector_add",
input_names=["x", "y"],
result_names=["output"],
src="output[id] = x[id] + y[id];",
torch_defn=torch_add,
metal_params=[
MetalParameter("id", "uint", "thread_position_in_grid"),
],
)
class AddModel(nn.Module):
def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
return custom_add(
x,
y,
threads_per_grid=(x.shape[0], 1, 1),
threads_per_thread_group=(1, 1, 1),
result_shapes=[list(x.shape)],
)
model = AddModel().eval()
example_inputs = (torch.randn(16), torch.randn(16))
exported = torch.export.export(model, args=example_inputs)
exported = exported.run_decompositions(get_decomp_table())
converter = TorchConverter()
converter.register_custom_kernels([custom_add])
converter.add_exported_program(
exported,
input_names=["x", "y"],
output_names=["result"],
)
coreai_program = converter.to_coreai()
Nine lines of that are the entire API surface. Everything else in this guide is about the ways those nine lines go wrong.
Two details in the boilerplate that are not about Metal at all but will stop you before you get there:
.eval()before export. ✅ VERIFIED —docs/getting-started/quickstart.ipynbcell 3: "Always call.eval()before exporting. Layers such asBatchNormandDropoutbehave differently in training mode and produce a different graph."run_decompositions(get_decomp_table())is mandatory, not optional, and Core AI's table is not PyTorch's. ✅ VERIFIED —docs/api/TorchConverter.md:53-55: "The caller must callrun_decompositions()on the program before passing it here — useget_decomp_table()to preserve known composite ops in the lowered IR." Usingtorch.export.default_decompositions()instead decomposesinstance_norminto an unsupported op and you get aValueErrorlisting unsupported ATen ops.
3.2 The session's example: a fused SiLU¶
Session 325 uses the Sigmoid Linear Unit — x · σ(x) — as its demonstration.
✅ VERIFIED — session 325:186–194, verbatim: "First, I define a PyTorch reference for our example. A standard Sigmoid Linear Unit, or SiLU. It's a common activation function used in generative transformer models. This is what
torch.exportsees during tracing." "Below that, I implement the actual Metal kernel in MSL. This is a simple element-wise kernel, one thread per element, that computes the fused activation directly on the GPU."
Here is that example built from the verified API. The Python is verified API usage; the MSL body is written for this guide, not quoted from Apple.
🟡 RECONSTRUCTED — the MSL body only. The on-screen source at 325:191 was never read aloud, so the exact expression Apple used is unknown: whether they wrote
x / (1 + exp(-x))orx * (1 / (1 + exp(-x))), and whether they usedexp,metal::precise::exporfast::exp, is not recoverable from the transcript. Every API call below is ✅ verified againstdocs/api/TorchMetalKernel.mdandcoreai_torch/_torch_metal_kernel.py; only the four lines between the triple quotes are inference. Treat the shape as right and the arithmetic as yours to own.
import torch
import torch.nn as nn
from coreai.authoring import MetalParameter
from coreai_torch import TorchConverter, TorchMetalKernel, get_decomp_table
# ── Piece 1: the PyTorch reference. This is what torch.export sees. ────────────
def torch_silu(x: torch.Tensor) -> torch.Tensor:
"""SiLU / swish: x * sigmoid(x). Never runs on device — shape inference only."""
return x * torch.sigmoid(x)
# ── Piece 2 + 3: the MSL body, and the object that binds it to the reference. ──
SILU_SRC = """
// One thread per element. `id` is bound by metal_params below.
// `x` and `y` are the names declared in input_names / result_names.
if (id >= x.get_extent(0)) { return; } // guard: grid may over-dispatch
const float v = float(x[id]);
y[id] = v / (1.0f + metal::precise::exp(-v)); // fused sigmoid-linear unit
"""
silu_kernel = TorchMetalKernel(
"fused_silu",
input_names=["x"],
result_names=["y"],
src=SILU_SRC,
torch_defn=torch_silu,
metal_params=[
MetalParameter("id", "uint", "thread_position_in_grid"),
],
)
# ── Calling it: like any other Python function, plus the dispatch arguments. ───
class SiLUModel(nn.Module):
def forward(self, x: torch.Tensor) -> torch.Tensor:
n = x.shape[0]
return silu_kernel(
x,
threads_per_grid=(n, 1, 1),
threads_per_thread_group=(min(n, 256), 1, 1),
result_shapes=[list(x.shape)], # <- REQUIRED at every call site. §6.
)
# ── Conversion: register the kernels FIRST, then add the program. ─────────────
model = SiLUModel().eval()
example_inputs = (torch.randn(4096, dtype=torch.float16),)
exported = torch.export.export(model, args=example_inputs)
exported = exported.run_decompositions(get_decomp_table())
converter = TorchConverter()
converter.register_custom_kernels([silu_kernel]) # BEFORE add_exported_program
converter.add_exported_program(
exported,
input_names=["x"],
output_names=["y"],
)
coreai_program = converter.to_coreai()
Read the MSL body against the declarations one more time, because this is the coupling that the session calls out and that nothing in Python checks for you:
| MSL identifier | Where it comes from | Declared as |
|---|---|---|
x |
input_names=["x"] |
a device tensor parameter, generated |
y |
result_names=["y"] |
a device tensor parameter, generated |
id |
metal_params=[MetalParameter("id", …)] |
uint id [[thread_position_in_grid]], generated |
If you rename input_names to ["inp"] and leave the body saying x, nothing in TorchMetalKernel
complains — the constructor validates names against each other and against torch_defn's parameter
count, never against the text of src. You find out at load_function() time, from a message that
does not mention x. §12.4.
3.3 What the session says happens next¶
✅ VERIFIED — session 325:203–204: "One thing to note, is that I pass in the
result_shapesto every instantiation of the custom kernel in the PyTorch source. This allows Core AI to bake in the computation of the output shapes of the kernel from the input shapes, if your model has dynamic shaped inputs." "When I convert withTorchConverter, I register my custom kernels with the converter, then add the exported program as before. The Metal source gets embedded directly in the asset — a single artifact. The kernel travels with the model."
Both of those sentences describe hard requirements with non-obvious failure modes, and each gets its
own section: result_shapes is §6, registration order is §8.1.
4. The constructor, field by field¶
4.1 The signature¶
✅ VERIFIED —
coreai_torch/_torch_metal_kernel.py:71-81, read from the shipping source. Identical todocs/api/TorchMetalKernel.md.
class TorchMetalKernel(CustomMetalKernel):
def __init__(
self,
name: str,
input_names: list[str],
result_names: list[str],
src: str,
torch_defn: Callable[..., Any],
metal_params: list[MetalParameter] | None = None,
helper_src: str | None = None,
template_dtypes: dict[str, str] | None = None,
) -> None: ...
and the call:
def __call__(
self,
*args: Any,
threads_per_grid: tuple[int, int, int],
threads_per_thread_group: tuple[int, int, int],
result_shapes: list[list[int]],
) -> Any: ...
✅ VERIFIED — _torch_metal_kernel.py:413-419. All three dispatch arguments are keyword-only
(they follow *args) and none has a default. You cannot forget one; you can only get one wrong.
MetalParameter is re-exported from coreai.authoring for convenience:
✅ VERIFIED —
coreai_torch/__init__.py:186-187, with Apple's own comment:so# Re-export MetalParameter so users don't need a separate coreai import. from coreai.authoring import MetalParameterfrom coreai_torch import TorchMetalKernel, MetalParameterandfrom coreai.authoring import MetalParameterare equally valid. Apple's docs page uses the first form; Apple's notebook uses the second.
4.2 name¶
Becomes part of the emitted kernel's name, and is the registration key.
- Must be a non-empty, non-whitespace string. ✅ VERIFIED —
_torch_metal_kernel.py:145-147,ValueError(f"Kernel name must be a non-empty string, got {name!r}"). Apple's comment explains why: "The Swift runtime (CustomMetalKernel.swift) treats an emptykernelNameas a user error; catch it eagerly with a clear message rather than letting it surface deep in the runtime." - The name in the IR is not the name you gave it. Each
(rank, dtype)combination gets an 8-character random suffix:kernel_name = "custom_add_<8 chars>". ✅ VERIFIED —tests/dsl/test_custom_kernels.py:122andtests/dsl/test_kernel_collisions.py:6-14. Consequence: never pattern-match on emitted kernel names. Apple's own tests diff by thekernel_sourcestring attribute instead, with a helper that walks the escaped string (test_dtype_specialization.py:56-80).
4.3 input_names and result_names¶
The identifiers your MSL body uses, and — for inputs — the positional binding to torch_defn's
parameters.
Validation, all at construction time:
| Rule | Error |
|---|---|
result_names non-empty |
ValueError("result_names must contain at least one entry") |
| no duplicates within either list | ValueError(f"Duplicate {label} names: {duplicates}") |
| no name in both lists | ValueError(f"Names appear in both input_names and result_names: {overlap}") |
len(input_names) == len(torch_defn parameters) |
ValueError("torch function should have same number of parameters as specified by input names, expected N, got M") |
✅ VERIFIED — _torch_metal_kernel.py:149-201; each message is pinned by a test in
tests/dsl/test_metal_kernel_robustness.py:355-411 and tests/dsl/test_custom_kernels.py:254-272.
Apple's rationale for the overlap check is worth quoting because it tells you what the generated
code looks like: "Duplicate or overlapping names produce ill-formed Metal kernel sources (two
parameters with the same identifier) and confusing failures." (_torch_metal_kernel.py:154-157.)
Inputs and results are parameters of the same function — there is no separate output binding
mechanism.
Zero inputs is legal. A kernel with only results and thread parameters constructs and lowers
cleanly (tests/dsl/test_metal_kernel_robustness.py:83-138, "Kernels with zero kernel inputs (only
outputs + thread params)"). Zero results is not — it is rejected at construction.
4.4 src¶
The body. Not the function. The generated wrapper supplies:
✅ VERIFIED —
docs/api/TorchMetalKernel.md: "The signature, buffer bindings, and#include <metal_stdlib>are generated automatically frominput_names,result_names, andmetal_params."
An empty src is accepted — Apple's own test says "An empty body src is acceptable — Python
should not over-validate" and asserts that it "still produces valid Metal source and lowers
cleanly" (tests/dsl/test_metal_kernel_robustness.py:167-198). So does a body of pure garbage;
see §12.4, which is the most important paragraph in this guide.
4.5 torch_defn¶
The reference implementation. Its constraints are strict, checked at construction, and worth learning as a list because every one of them has a test:
✅ VERIFIED —
_torch_metal_kernel.py:174-244,docs/api/TorchMetalKernel.md"Constraints".
- Every parameter must be annotated as
torch.Tensor,int,floatorbool. Anything else — includingstr— raisesTypeError("custom kernels only supporttorch.Tensor,float,boolandintinputs, got …"). - No variadic parameters.
*args/**kwargsraiseTypeError("custom kernels do not support variadic parameters (*args / **kwargs); got parameter '…' with kind …"). - Parameter count must equal
len(input_names). - The return annotation must be
torch.Tensor,list[torch.Tensor], ortuple[torch.Tensor, ...]with a concrete member count.tuple[torch.Tensor, ...](the ellipsis form),Sequence[torch.Tensor], and bareintare all rejected —tests/dsl/test_custom_kernels.py:294-343pins all three. - Arity must match: a single-
Tensorreturn with tworesult_namesraisesValueError("torch_defn returns a single torch.Tensor, but result_names has 2 entries: …"); a concrete tuple whose length differs raises the analogous message.list[torch.Tensor]is variable-length so it is only checked at call time.
✅ VERIFIED, and a real trap if your module uses
from __future__ import annotations.TorchMetalKernelresolves annotations withinspect.signature(torch_defn, eval_str=True)specifically for this. Apple's comment (_torch_metal_kernel.py:104-106): "eval_str=True resolves PEP 563 string annotations introduced byfrom __future__ import annotationsin the caller's module. Without this,param.annotationis the bare string"torch.Tensor"and the identity checks in_validate_torch_inputsfail." This is handled for you — but it means your annotations must be resolvable in the defining module's namespace. Atorch_defnannotated with a type imported underif TYPE_CHECKING:will raise at construction, not at export.
What torch_defn is not. It is not a fallback. It never executes on device, and Core AI does
not compare its output against your kernel's. It is a shape oracle and nothing more — which is
exactly why §5.4's failure mode is so nasty: a reference that is correct and a kernel that is wrong
produce a model that converts, exports, and lies.
4.6 metal_params¶
The thread attributes bound into the generated signature.
🟡 RECONSTRUCTED — the parameter names. The positional form above is ✅ verified from six independent Apple call sites (
docs/api/TorchMetalKernel.md, the notebook, and four test files). The keyword spellingMetalParameter(name=…, dtype=…, attr=…)is reported by community notes citingcoreai/authoring/metal.py:36-52, which is not in this corpus —coreai-coreships as a wheel, not source. Safe default meanwhile: pass the three arguments positionally, exactly as every Apple example does. That form is verified and cannot be wrong.
Attributes seen in Apple's own kernels, all ✅ verified from tests/dsl/:
| Attribute | Type used | Where |
|---|---|---|
thread_position_in_grid |
uint |
element-wise kernels, test_custom_kernels.py:45 |
thread_position_in_grid |
uint2 |
2-D matmul, test_matmul_kernel.py:62 |
thread_position_in_threadgroup |
uint2 |
tiled matmul, test_matmul_kernel.py:158 |
threadgroup_position_in_grid |
uint2 |
tiled matmul, test_matmul_kernel.py:159 |
There is no evidence in the corpus for which other MSL thread attributes are accepted — the mechanism looks like a pass-through of the attribute string, but nothing verifies that.
🔴 GAP — the accepted set of
MetalParameterattribute strings is unenumerated. Apple's examples cover exactly the three above. Whethersimdgroup_index_in_threadgroup,thread_index_in_simdgroup,threads_per_threadgroupand friends are accepted is not shown anywhere in this corpus, and there is no validation list in_torch_metal_kernel.py(the file never inspects the attribute string — it is forwarded toCustomMetalKernelincoreai-core). Resolving it needs thecoreai.authoring.metalsource or a runtime experiment. Safe default meanwhile: stick to the four attributes above, and if you need SIMD-group indices, derive them fromthread_position_in_threadgroupinside the body rather than requesting a new attribute — that is pure MSL and cannot be rejected by the binding layer.
metal_params counts against the 31-parameter budget. See §12.2.
4.7 helper_src¶
Additional Metal source pasted before the kernel definition.
✅ VERIFIED —
docs/api/TorchMetalKernel.md: "Additional Metal source pasted before the kernel definition (helper functions, type aliases, etc.)."
This is where #include <MetalPerformancePrimitives/MetalPerformancePrimitives.h>, using namespace
declarations, structs, type aliases and any function you want to call from the body go. For
anything larger than an element-wise operation this is where most of your code lives. §11.
4.8 template_dtypes¶
One kernel, many dtypes. §10.1.
5. What the converter generates — and the axis reversal¶
5.1 The generated function¶
You write a body; the converter writes the function around it. Assembling what is verifiable, the
generated source for the vector_add example has this shape:
🟡 RECONSTRUCTED — the exact generated text. Individual fragments are ✅ verified from test assertions against the emitted
kernel_sourceattribute:"device half"and"device float"both appear (tests/dsl/test_dtype_specialization.py:251-252), scalar parameters appear asconstant float& c(tests/dsl/test_scalar_inputs.py:119), and the docs state that#include <metal_stdlib>and the buffer bindings are generated. The layout below — parameter order, buffer indices, thetensor<>template arguments — is assembled from those fragments plus the community_tensorops_protoscripts, which describe the auto-generated signature astensor<device T, dextents, tensor_handle>. Do not copy this as source; it is a mental model.
#include <metal_stdlib>
// … plus whatever you put in helper_src, pasted here …
[[kernel]] void vector_add_<8 random chars>(
/* one parameter per input_names entry, in order */ device-address-space tensors,
/* one parameter per result_names entry, in order */ device-address-space tensors,
/* any rank-0 scalar inputs */ constant float& c [[buffer(N)]],
/* one parameter per metal_params entry */ uint id [[thread_position_in_grid]])
{
// ← your `src` goes here, verbatim
}
Three facts you can rely on:
- Tensor inputs and results live in the
deviceaddress space. ✅ VERIFIED —test_dtype_specialization.py:251-252asserts"device half"and"device float"are present in the two emitted sources. - They are Metal tensor objects, not raw pointers. They support
.get_extent(i),.get_rank(), multi-index subscriptingA[k, m], andmetal::array<uint, N>indexing. ✅ VERIFIED — all four appear in Apple's own kernel fixtures (tests/dsl/conftest.py:37-140). - Scalars are
constant T&, not tensors. ✅ VERIFIED —test_scalar_inputs.py:119. §9.
5.2 ⚠️ The axis reversal¶
This is the one to tattoo somewhere.
⚠️ SILENT FAILURE —
MTLTensorextents are the reverse of the torch shape, and your PyTorch reference cannot detect the mismatch.✅ VERIFIED — Apple's own module docstring,
tests/dsl/test_thread_config.py:16-20:"Note:
MTLTensorextents are stored in reverse of the torch shape (seeNDArray+Metal.swift:shapeSpan.reversed()). For a torch tensor of shape(D0, D1, D2)the kernel sees extents(D2, D1, D0);get_extent(0)is the innermost (fastest-varying) torch dim. Multi-dim dispatch tuples must match this convention."Apple's own matmul fixture spells out the consequence in a comment (
tests/dsl/conftest.py:166-170) — read it against a torchxof shape(M, K)andyof shape(K, N):// A shape [K,M]: A[k, m] B shape [N,K]: B[n, k] C shape [N,M]: C[n, m] const uint K = A.get_extent(0); const uint M = A.get_extent(1); const uint N = B.get_extent(0);Everything is transposed.
A.get_extent(0)returnsK— the last torch dimension. The subscript order is reversed too:A[k, gid.y]reads torch elementx[gid.y, k].Why it is silent. Your
torch_defnis written in torch coordinates and is correct. It is traced, it type-checks, the graph converts, the asset saves. Only the Metal body has the wrong convention, and the only thing that would catch it is a numerical comparison against the reference — which Core AI does not perform for you.The community NaN, in full (
notes/repos/john-rocky-models.md§9.2, community-measured, single author, beta OSes): a per-row scale buffer for a ternary-weight kernel had to be torch[N, 1], not[1, N], "because the DSL reverses axes, so the MetalD[0, n]reads torchd[n,0]. The torch reference (d.reshape(-1,1)) is shape-agnostic so CPU passed either way — but the Metal kernel read out-of-bounds and produced NaN logits on the engine."The same author independently rediscovered the rule with a 61-line probe kernel that writes
out[tgid.x, tgid.y] = 100 + 10*x + yinto a known-good[64, 32]output and reads the result back in numpy, recording in a code comment: "Metal tensor coords are TRANSPOSED vs numpy:torch[M,K]→ tensor extents[K,M](dim0 = inner/contiguous). Verified withprobe_dispatch:out[a,b]lands atnumpy[b,a]."Do the probe. Thirty lines, five minutes, and it makes every subsequent kernel in the project cheaper. §14.1 gives it.
5.3 The reversal reaches the dispatch tuple too¶
Apple's docstring says "multi-dim dispatch tuples must match this convention", and the matmul
fixture shows what that means in practice. For torch x: (M, K), y: (K, N), output (M, N):
grid = (N, M, 1) # x dimension of the grid indexes N, y indexes M
threads = (16, 16, 1)
result_shape = [M, N] # result_shapes stays in TORCH order
✅ VERIFIED — tests/dsl/test_matmul_kernel.py:75-87. Note the asymmetry that makes this
genuinely confusing: result_shapes is in torch order ([M, N]), while threads_per_grid is
in tensor/Metal order ((N, M, 1)), and the body indexes C[gid.x, gid.y] = C[n, m]. Three
conventions, one call site.
A working rule that keeps it straight:
- Python-side arguments (
result_shapes,x.shape[…]) — torch order.- Metal-side everything (
get_extent(i), subscripts, and the grid you are indexing with) — reversed order, fastest-varying dimension first.threads_per_gridis Metal-side, becausegid.xis what indexes it.
5.4 The general lesson: a correct reference masks an incorrect kernel¶
The axis reversal is the most common instance of a general property of this API:
⚠️ SILENT FAILURE — the PyTorch reference is not a test.
torch_defnis used for shape inference. It is not executed alongside your kernel, not compared against it, and not shipped in the asset. A kernel that returns zeros, garbage, or NaN produces an.aimodelthat converts without complaint. The only thing standing between you and a wrong model is a numerical test you write yourself. §14.
This is also why the community protocol note is so emphatic that eager-mode PyTorch is not an adequate check for a kernel-bearing module:
Community-measured —
notes/repos/john-rocky-models.md§5.3: "MetalSwitchGLU's eager torch path is unreliable (garbage on MPS) — judge schemes ONLY via a real export + engine run, never eager-MPS."
5.5 Supported element dtypes¶
Kernel inputs and results must have a dtype the converter can map to an MSL type. Unmapped dtypes raise at conversion time, not construction:
TypeError: kernel input at index 0 has unsupported dtype: f8E5M2
TypeError: Result type at index 0 has unsupported dtype: f8E5M2
✅ VERIFIED — tests/dsl/test_custom_kernels.py:382-452, which uses torch.float8_e5m2 to prove
both messages.
The mapping itself is in coreai-core, which is not source-available here. The community record of
it is:
🟡 RECONSTRUCTED — the dtype table. Community-reported mapping (
notes/repos/john-rocky-models.md, citingcoreai/authoring/metal.py):bf16→bfloat,f16→half,f32→float,si8→int8_t,ui8→uint8_t,ui32→uint,si32→int,i1→bool. Independent corroboration for the float half:template_dtypessubstitution is documented by Apple as producing "half,float,bfloat" (docs/api/TorchMetalKernel.md), and Apple's own matmul test parametrizes overtorch.bfloat16, float16, float32, int8, int16, int32and passes for all six (tests/dsl/test_matmul_kernel.py:28-38) — so int16 is supported too, which the community list omits. Safe default: stick to fp16/bf16/fp32 for data and int8/int32 for indices; anything exotic, test the conversion first — theTypeErroris immediate and cheap to trigger.
Sub-byte types are not on that list. If you are working with int4 weights you pass them as
packed uint8 and reinterpret inside the kernel. §11.4.
6. result_shapes: why every call site¶
6.1 What Apple says, and what it means¶
✅ VERIFIED — session 325:203, verbatim: "One thing to note, is that I pass in the
result_shapesto every instantiation of the custom kernel in the PyTorch source. This allows Core AI to bake in the computation of the output shapes of the kernel from the input shapes, if your model has dynamic shaped inputs."
Unpack that. Core AI's compiler needs to know the shape of every tensor in the graph. For a normal op it derives that from the op's semantics. For your kernel it cannot — the compiler has no idea what your MSL does. So you tell it, per call site, by passing an expression that relates the output shape to the input shapes:
result_shapes=[list(x.shape)] # "same shape as x"
result_shapes=[[x.shape[0], y.shape[1]]] # "M from x, N from y"
result_shapes=[list(x.shape), list(x.shape)] # two results, both x-shaped
Because those expressions are evaluated during tracing, they compose with torch.export's
symbolic shapes. When x.shape[0] is a Dim, the expression carries the symbol rather than a
number — which is the "baking in" the presenter describes.
6.2 It is not optional and it is not defaulted¶
Keyword-only, no default. Omitting it is a TypeError from Python itself. Getting the count wrong
is caught explicitly:
ValueError: result_shapes must contain one shape per result name;
expected 2 (for ['out_sin', 'out_cos']), got 1
✅ VERIFIED — _torch_metal_kernel.py:434-440; pinned by
tests/dsl/test_metal_kernel_robustness.py:545-556.
6.3 What it becomes¶
Each shape becomes a uint32 tensor operand on the op:
✅ VERIFIED —
_torch_metal_kernel.py:442-445:grid_tn = torch.tensor(list(threads_per_grid), dtype=torch.uint32) tgroup_tn = torch.tensor(list(threads_per_thread_group), dtype=torch.uint32) shape_tns = [torch.tensor(shape, dtype=torch.uint32) for shape in result_shapes] return self.torch_custom_op(*args, grid_tn, tgroup_tn, *shape_tns)and on the torch-op side the signature is the
torch_defnsignature augmented withthreads_per_grid,threads_per_thread_group, and oneresult_shape_<name>parameter per result (_torch_metal_kernel.py:132-135, 258-281). The wrapper strips those extra arguments before calling your reference, sotorch_defnnever sees them (:285-295).
In the IR they show up as the trailing tensor<3xui32> operands you saw in §1.1.
6.4 ⚠️ The failure mode: a hardcoded shape under a dynamic input¶
⚠️ SILENT FAILURE — a literal
result_shapescompiles into a dynamic-shaped graph without complaint.Apple's own test demonstrates the exact configuration.
tests/dsl/test_custom_kernels.py:58-73defines a model whose call site passesresult_shapes=[result_shape]whereresult_shapeis the Python literal[2, 2, 3].test_import_dynamic_shape(:136-182) then exports that model withdynamic_shapes={"x": {0: dim}, "y": {0: dim}}and asserts the emitted IR:… result_shapes(%rs) … : (tensor<?x2x3xf16, …>, tensor<?x2x3xf16, …>, tensor<3xui32>, tensor<3xui32>, tensor<3xui32>) -> tensor<?x2x3xf16, …>The tensor types are
?x2x3— dimension 0 is dynamic — while theresult_shapesoperand is a constant three-elementui32tensor holding[2, 2, 3]. The conversion succeeds. The test passes; it is checking IR structure, not numerics.What that means for you: if the first dimension arrives as 7 at runtime, the graph's type says
?and your kernel has been told to write a[2, 2, 3]result. Nothing in the Python layer, the converter, or the emitted IR objects.The rule:
result_shapesentries must be expressions over the input tensors'.shape, at every call site, always.list(x.shape)— not[2, 2, 3], not a module attribute captured at__init__, not a constant computed fromexample_inputs. Every Apple example in the tutorial, the API docs and the test suite that is not specifically testing IR structure does exactly this:result_shapes=[list(x.shape)].
6.5 What "every instantiation" actually means¶
The word "instantiation" in the transcript means call site, not kernel object. One
TorchMetalKernel object called from three places in forward() needs three result_shapes
arguments, each computed from the tensors flowing into that call. This is not redundancy; the
converter lowers each FX node independently and each node carries its own shape operands.
The kernel object is reusable and should be reused — a module-level singleton called from many places is the normal pattern, and the kernel cache (§10.2) is built for it.
6.6 The dynamic-shape boundary¶
🔴 GAP — the symbolic path through
result_shapesis not verified end to end. The mechanism is clear at the IR level (aui32shape tensor operand per result) and the transcript states the intent plainly ("bake in the computation of the output shapes… if your model has dynamic shaped inputs"). What this corpus does not contain is a test that exports a kernel-bearing model with a genuinely dynamic dimension, computesresult_shapesfromx.shape, and then runs it at two different sizes. Apple's dynamic-shape test hardcodes the shape and only checks IR (test_custom_kernels.py:136-182); Apple's dynamic matmul test (test_matmul_kernel.py:103-120,dynamic_shapesover theKdimension) does deriveresult_shape = [M, N]fromx.shape/y.shape— butMandNare the static dimensions there; onlyK, which does not appear in the result, is dynamic.Concretely unresolved: what
torch.tensor([SymInt, 2, 3], dtype=torch.uint32)does duringtorch.exporttracing — whether it specializes to the example value, produces a symbolic operand, or raises. Resolving it needs one experiment on a Mac withcoreai-torchinstalled: export a kernel model withDimon the result-bearing axis, run it at two sizes, compare.Safe default meanwhile: (a) always derive
result_shapesfrom input.shape; (b) if a result-bearing dimension is dynamic, verify numerically at two or more sizes before shipping —validate_numerical_output(..., dynamic_shapes=...)from Apple's test harness (§14.3) is set up for exactly this; (c) if you cannot verify, prefer a static shape for the kernel's output and reshape outside the kernel, which costs one op and removes the risk.
7. Thread dispatch: grid, threadgroup, and bounds¶
7.1 The two tuples¶
Both are 3-tuples, both keyword-only, both validated:
ValueError: threads_per_grid must be a 3-tuple, got 2 elements: (16, 1)
ValueError: threads_per_thread_group must be a 3-tuple, got 4 elements: (…)
✅ VERIFIED — _torch_metal_kernel.py:421-433; pinned by
tests/dsl/test_metal_kernel_robustness.py:503-541.
Apple's docstring states the runtime contract precisely:
✅ VERIFIED —
tests/dsl/test_thread_config.py:8-14: "The runtime forwardsthreads_per_gridandthreads_per_threadgroupverbatim to Metal'sdispatchThreads. Threadgroup sizes exceedingmaxTotalThreadsPerThreadgroup(1024 on current Apple Silicon) are rejected by Metal at PSO time; valid sizes near or above the visible grid are simply rounded up — the kernel is responsible for guarding out-of-bounds reads/writes whenthreads_per_gridexceeds the visible tensor extent."
Two things follow. First, this is dispatchThreads, not dispatchThreadgroups — you give a
thread count, not a threadgroup count, so no ceil_div is needed on your side. Second, bounds
guarding is your job, always.
7.2 The converter does not validate your dispatch¶
✅ VERIFIED —
tests/dsl/test_thread_config.py:130-157, name and docstring:test_threadgroup_larger_than_typical_pso_max_lowers— "An over-largethreads_per_thread_groupis accepted at the IR layer. The runtime clamps this topso.maxTotalThreadsPerThreadgroup(1024 on most Apple Silicon GPUs); the converter should not pre-validate that — it's a hardware property only known at PSO compilation time."The test passes
threads_per_thread_group=(2048, 1, 1)and asserts the program lowers.
So a dispatch configuration that no GPU can execute converts cleanly. This is a deliberate design choice, and a defensible one, but it means the first place an impossible dispatch can surface is device load.
Apple's own softmax fixture guards against it in Python rather than trusting the runtime:
MAX_THREADS = 1024
# "We can't exceed 1024 threads per thread group. We should probably have this be
# deferred to the runtime, but we're putting this guard in here so that the tests
# don't choose threadgroups that are too large."
threads_per_thread_group=(min(MAX_THREADS, num_slices), 1, 1)
✅ VERIFIED — tests/dsl/test_softmax.py:32-37, 76-79. Copy the pattern.
7.3 Bounds guards, three canonical forms¶
Apple's own fixtures show three, in increasing order of care.
1-D, guard on the first extent (test_thread_config.py:67):
2-D, guard both (conftest.py:129-141, the naive matmul):
const uint K = A.get_extent(0);
const uint M = A.get_extent(1);
const uint N = B.get_extent(0);
if (gid.x >= N || gid.y >= M) return; // bounds guard
TYPE sum = ZERO;
for (uint k = 0; k < K; ++k) {
sum += A[k, gid.y] * B[gid.x, k];
}
C[gid.x, gid.y] = sum;
Tiled, guard on load and on store (conftest.py:163-200) — this is the important one, because
a tiled kernel has two distinct out-of-range conditions:
const uint TILE = 16;
const uint K = A.get_extent(0);
const uint M = A.get_extent(1);
const uint N = B.get_extent(0);
threadgroup TYPE tileA[TILE][TILE];
threadgroup TYPE tileB[TILE][TILE];
TYPE accum = ZERO;
const uint numTiles = (K + TILE - 1) / TILE;
for (uint t = 0; t < numTiles; ++t) {
const uint a_k = t * TILE + tid.x;
const uint a_m = tgid.y * TILE + tid.y;
tileA[tid.y][tid.x] = (a_k < K && a_m < M) ? A[a_k, a_m] : ZERO; // guard the LOAD
const uint b_n = tgid.x * TILE + tid.x;
const uint b_k = t * TILE + tid.y;
tileB[tid.y][tid.x] = (b_n < N && b_k < K) ? B[b_n, b_k] : ZERO;
threadgroup_barrier(mem_flags::mem_threadgroup);
for (uint kk = 0; kk < TILE; ++kk) {
accum += tileA[tid.y][kk] * tileB[kk][tid.x];
}
threadgroup_barrier(mem_flags::mem_threadgroup);
}
if (gid.x < N && gid.y < M) { // guard the STORE
C[gid.x, gid.y] = accum;
}
Note the zero-padding of out-of-range loads rather than an early return: a thread that returns
early never reaches threadgroup_barrier, which is undefined behaviour in a threadgroup where other
threads do. Every thread must execute every barrier. This is standard MSL discipline, but it is the
most common way a tiled Core AI kernel goes from "wrong" to "hangs".
The matching Python side (test_matmul_kernel.py:175-189) rounds the grid up to whole tiles:
TILE = 16
grid = (((N + TILE - 1) // TILE) * TILE,
((M + TILE - 1) // TILE) * TILE,
1)
threads = (TILE, TILE, 1)
result_shape = [M, N]
7.4 threads_per_grid=(1, 1, 1) is legal and means one thread¶
Apple has a test for it — "threads_per_grid=(1,1,1) writes exactly one element" —
and another confirming that under-dispatch leaves untouched tail elements rather than erroring:
"Under-dispatch leaves untouched tail elements; the kernel must not OOB." ✅ VERIFIED —
tests/dsl/test_thread_config.py:251-256, 299-304.
Which is another way of saying: an under-dispatched kernel returns partially-uninitialized memory and does not fail. If your grid computation has an off-by-one, the tail of your output tensor is whatever was in the buffer. Add the numerical test.
7.5 A dispatch checklist¶
- Grid expressed in Metal/reversed axis order (§5.3).
- Grid derived from
.shapeat the call site, not hardcoded. - Threadgroup product ≤ 1024, clamped in Python.
- Every read and every write bounds-guarded against
get_extent(...). - If you use
threadgroup_barrier, no earlyreturnbefore it — pad instead. - Numerical test at a size that is not a multiple of your tile.
8. Registering, converting, running¶
8.1 Registration order is a hard requirement¶
✅ VERIFIED —
docs/api/TorchMetalKernel.md: "TorchMetalKernelinstances must be registered with the converter viaregister_custom_kernels()beforeadd_exported_program()." Restated indocs/guides/custom-metal-kernels.ipynbstep 4: "This must be called beforeadd_exported_program()." Every one of Apple's fourteentests/dsl/files does it in that order.
converter = TorchConverter()
converter.register_custom_kernels([kernel_a, kernel_b]) # FIRST
converter.add_exported_program(ep, input_names=[...], output_names=[...]) # THEN
program = converter.to_coreai()
The reason is mechanical: add_exported_program walks the FX graph and resolves each node's target
against the converter's lowering registry. register_custom_kernels is what populates that
registry for coreai_metal_kernels::* targets. If the registry is empty when the walk happens, the
node has no lowering rule.
The signature:
✅ VERIFIED — coreai_torch/converter.py:1036-1039. It returns self, so it chains:
TorchConverter().register_custom_kernels([k]).add_exported_program(ep).
A related guarantee worth knowing: TorchConverter.clear() drops staged programs but keeps
lowerings. ✅ VERIFIED — docs/api/TorchConverter.md: "Custom lowerings registered via
register_torch_lowering() are always preserved." Since register_custom_kernels is implemented in
terms of register_torch_lowering, your kernels survive a clear() — which is what you want when
staging several entrypoints into one asset.
8.2 Kernel names are global to a converter¶
✅ VERIFIED —
tests/dsl/test_kernel_collisions.py:76-135. TwoTorchMetalKernelobjects with the samenamecannot both be registered, even with byte-identical MSL, and even across two separateregister_custom_kernelscalls. The error comes from the underlying lowering registry (converter.py:1026-1029):ValueError: 'coreai_metal_kernels::my_name.default' is already registered; set allow_override=True to replace itApple's test docstrings explain the reasoning: "Even with the same MSL source, the second registration fails fast. A coreai-torch
register_custom_kernelscall cannot tell that two kernels are equivalent." and "Distinct MSL bodies under the samenamewould silently shadow. The converter must not allow this."
The practical consequence for library code: do not construct TorchMetalKernel objects inside a
function that may be called twice. Build them at module scope, or memoize. A factory like
def make_kernel(nbits: int) -> TorchMetalKernel:
return TorchMetalKernel(f"gather_qmm_{nbits}", ...) # name varies with config — good
is fine because the name varies; a factory that always returns TorchMetalKernel("my_kernel", ...)
will fail the second time it is used in one conversion.
8.3 Multiple entrypoints, one asset¶
Custom kernels compose with the multi-function split that Part 8's guide 05 covers. Register once, then stage each program:
converter = TorchConverter()
converter.register_custom_kernels([flash_attn_kernel])
converter.add_exported_program(img_ep, entrypoint_name="image_encode",
input_names=["pixel_values"],
output_names=["backbone_features"])
converter.add_exported_program(txt_ep, entrypoint_name="text_encode",
input_names=["input_ids"],
output_names=["text_features"])
converter.add_exported_program(det_ep, entrypoint_name="detect",
input_names=["backbone_features", "text_features"],
output_names=["pred_masks", "pred_boxes",
"pred_logits", "presence_logits"])
program = converter.to_coreai()
program.save_asset(asset_path)
✅ VERIFIED (the multi-entrypoint pattern) —
apple/coreai-models, python/src/coreai_models/segmentation/pipeline.py:265-286, which is the
shipped SAM3 export. entrypoint_name must be unique across staged programs
(docs/api/TorchConverter.md).
⚠️ Remember §2.4: the function containing the kernel is GPU-resident. In a split like the above you
would put a custom attention kernel in image_encode and accept that that function runs on the GPU,
while detect remains free to go elsewhere.
8.4 Running it: Metal-backed storage is required¶
This is the runtime requirement people miss.
✅ VERIFIED —
tests/utils.py:531-533, Apple's own harness docstring: "Passmetal_inputs=Trueto back all runtime inputs withStorageKind.METAL(required for Metal kernels)." The implementation (tests/utils.py:228-231, 485-487) wraps every input asNDArray(data=…, backing=StorageKind.METAL).
from pathlib import Path
import numpy as np
from coreai.runtime import NDArray, StorageKind
asset = coreai_program.save_asset(Path("silu.aimodel"))
async with asset.executable() as ai_model:
function = ai_model.load_function("main")
outputs = await function({
"x": NDArray(data=x.numpy(), backing=StorageKind.METAL),
})
y = outputs["y"].numpy() # materialize INSIDE the block
Four runtime facts, all ✅ VERIFIED from
docs/coreai-core/tutorials/run-an-aimodel.ipynb:
.aimodelis a directory, not a file;save_asset()returns anAIModelAsset.AIModelAsset.load(path)"reads the.aimodeldirectory header from disk so you can inspect it; it does not yet compile the program for inference. That work happens lazily inside theexecutable()async context manager."- Inference is
async.load_functionis synchronous; calling the function is not. - Materialize before leaving the block: "the model's backing buffers are only guaranteed valid
until the context exits." Call
.numpy()inside theasync with.
The StorageKind.METAL requirement is visible in the IR: every kernel-adjacent tensor carries
#coreaix.hw_constraints<MTLBuffer, …> (§1.1). The community note puts the reason plainly:
"Metal-backed buffers forced on all I/O — the GPU can't read host memory mid-kernel"
(notes/repos/john-rocky-models.md, community-reported).
8.5 A footgun on the way out of Python¶
Community-measured, but mechanically obvious once stated —
notes/repos/john-rocky-models.md§5.4: "Keep theAIModelreference alive in a persistent multi-call runner — storing only theload_functionlets the model get GC'd and the function then returns GARBAGE (no crash, just wrong output → looks like a conversion bug). Holdself.models[name] = m."Attribute as community-measured (single author, M4 Max, beta macOS 27, mid-2026). It is not a kernel-specific issue, but it presents as one — a kernel you just wrote returning nonsense is the first thing you will blame.
9. Scalar inputs: literals in disguise¶
9.1 The three allowed scalar types¶
A torch_defn parameter annotated int, float or bool becomes a scalar kernel input.
✅ VERIFIED —
_torch_metal_kernel.py:22-29:
In the generated signature they appear as constant T& parameters rather than tensors:
✅ VERIFIED —
tests/dsl/test_scalar_inputs.py:117-127, which assertsf"constant {metal_dtype}& c "is present in the emittedkernel_sourcefor each of(float, 2.5, "float"),(int, 7, "int"),(bool, True, "bool"). The module docstring (:8-11) states the binding: "A scalar passed to aTorchMetalKernel(int,float,bool) is captured byget_operandas acoreai.constantrank-0 tensor and bound to the kernel as aconstant T& name [[buffer(N)]]parameter — a different runtime path from the regular tensor (MTLTensor) bindings."
Apple's own softmax kernel uses one — the reduction axis is an int scalar:
def torch_softmax(x: torch.Tensor, dim: int) -> torch.Tensor:
return x.softmax(dim=dim)
custom_softmax = TorchMetalKernel(
"custom_softmax",
input_names=["input", "axis"],
result_names=["output"],
src=softmax_src,
torch_defn=torch_softmax,
metal_params=[MetalParameter("gid", "uint", "thread_position_in_grid")],
template_dtypes={"input": "TYPE"},
)
✅ VERIFIED — tests/dsl/test_softmax.py:40-66. And the body uses axis as an ordinary
value: uint normalized_axis = axis >= 0 ? uint(axis) : uint(input.get_rank() + axis);
(tests/dsl/conftest.py:37).
9.2 Scalars are baked as literals, and that has consequences¶
This is the surprising part. The declared constant T& parameter is real, but the value your body
reads is a shadowing local holding a literal, injected in front of your source.
✅ VERIFIED —
_torch_metal_kernel.py:332-407, with Apple's own explanation of why (:337-345):"The runtime binds rank-0 inputs as
MTLTensorresource handles, so aconstant T¶meter declared in the kernel source can't be dereferenced as a value — it would read from the handle, not the scalar's storage. Workaround: keep the parameter declaration intact (the IR contract still surfacesconstant T& <name>) but shadow it inside the body with a local variable initialized to the literal, so the user-written body still resolves the name to the right value."The transform (
:372-407) wraps your body in a nested block so the locals can legally shadow the parameters:
Three things follow, and each will bite somebody:
- The scalar's value is fixed at trace time. A "runtime scalar" is not a thing here — the value
in your
forward()at export time is the value compiled into the kernel. If it varies, it must be a rank-≥1 tensor input, or a separate kernel variant. - Different scalar values produce different kernels. Apple maintains a per-value sub-cache so
that call sites with different scalar values do not collide, while identical ones still share a
pipeline state object (
_scalar_kernel_caches,:63-69and:363-365). Sweeping a scalar over many values therefore multiplies your compiled kernel count. - Your body's variable names must not collide with the injected declarations in a way you did not intend — the shadowing is by design, but a local of the same name inside your own nested scope will shadow the shadow.
9.3 Range and finiteness are enforced¶
ValueError: int scalar 'n'=5000000000 is outside the 32-bit int range that MSL `int` supports
ValueError: float scalar 'eps'=inf is not finite; NaN/Inf scalars are not supported
✅ VERIFIED — _torch_metal_kernel.py:386-400. Both raise at conversion time (when the
lowering runs), not at construction, because the value is only known once the FX node is visited.
bool widens to ui8 at the IR level and is patched back to bool in the MSL signature:
✅ VERIFIED —
_torch_metal_kernel.py:25-29and_utils.py:1070-1082: "Bool widens to ui8 becausei1is rejected by themetal4_kernelverifier; the MSL signature still emitsconstant bool&."Also note
scalar_constant(_utils.py:1070-1086) deliberately bypasses the converter's fp16 promotion: "Bypasses the fp16 promotionget_operandapplies to Python floats so the MSL parameter ends up asconstant float&even when the surrounding tensors are fp16." If you were wondering why your scalar isfloatand nothalfin an fp16 model — that is why, and it is intentional.
9.4 Scalars share the parameter budget¶
29 scalars + 1 result = 30 buffers lowers cleanly; 25 tensors + 6 scalars + 1 result = 32 does not.
✅ VERIFIED — tests/dsl/test_scalar_inputs.py:129-215, both cases, with the second asserting
the limit error. §12.2.
10. Dtype templating, the kernel cache, and multiple outputs¶
10.1 template_dtypes¶
One kernel object, many element types. You put a placeholder token in src and name the input whose
dtype decides the substitution.
✅ VERIFIED —
docs/api/TorchMetalKernel.mdanddocs/guides/custom-metal-kernels.ipynbcell 10, reproduced verbatim:
custom_matmul = TorchMetalKernel(
"matmul",
input_names=["A", "B"],
result_names=["C"],
src="""
const uint K = A.get_extent(0);
const uint M = A.get_extent(1);
const uint N = B.get_extent(0);
if (gid.x >= N || gid.y >= M) return;
TYPE sum = 0.0f;
for (uint k = 0; k < K; ++k) {
sum += A[k, gid.y] * B[gid.x, k];
}
C[gid.x, gid.y] = sum;
""",
torch_defn=torch_matmul,
metal_params=[MetalParameter("gid", "uint2", "thread_position_in_grid")],
# "A" is the input whose dtype determines the substitution;
# every occurrence of "TYPE" in src is replaced with the
# corresponding Metal type (e.g. "half", "float", "bfloat").
template_dtypes={"A": "TYPE"},
)
Validation, both at construction:
ValueError: Inputs {'z'} not specified # template key is not an input name
ValueError: Provided duplicated template strings ['TYPE'] # two inputs, same placeholder
✅ VERIFIED — tests/dsl/test_custom_kernels.py:345-380.
⚠️ The substitution is textual. "Every occurrence of "TYPE" in src is replaced" means
every occurrence — including inside a comment, a string, or a longer identifier such as MY_TYPE.
Choose a placeholder that cannot appear by accident; Apple uses the all-caps TYPE and nothing else
in their bodies contains those four characters.
⚠️ Templating does not solve literals. Apple's own test fixtures template the type but then have to specialize the zero literal separately, per dtype, in Python:
generic_naive_matmul.replace("ZERO", "0") # int
generic_naive_matmul.replace("ZERO", "0.0f") # float / half
generic_naive_matmul.replace("ZERO", "bfloat(0.0)") # bfloat16
✅ VERIFIED — tests/dsl/conftest.py:144-159. TYPE sum = 0.0f; in the docs example works
for half and float but not for the integer instantiations, which is exactly why the test suite has
three fixtures where the docs have one. If you template across integer and float dtypes, template
your literals too.
10.2 The kernel cache, and what "one kernel" means¶
✅ VERIFIED —
tests/dsl/test_kernel_collisions.py:137-227andtest_dtype_specialization.py:6-24. The cache is keyed on(rank, dtype)(plus scalar values, §9.2):
- Same kernel, same
(rank, dtype), two call sites → one emitted source and one randomized name, reused. Apple's robustness test asserts "Twometal4_kernelops emitted, but kernel cache means a single randomized name (the same suffix appears twice)" (test_metal_kernel_robustness.py:233-261).- Same kernel, different
(rank, dtype)→ two distinct sources, two randomized names, two pipeline states.test_dtype_specialization.py:242-254asserts exactly two sources, that they differ, and that one contains"device half"while the other contains"device float".The docstring's framing: "This produces a per-shape kernel variant — the same Python kernel emitted twice with different dtypes generates two distinct PSOs because the templated MSL source differs."
Practical reading: kernel objects are cheap to reuse and expensive to duplicate. One module-level
object called from twenty places costs one compiled kernel per distinct (rank, dtype, scalars)
combination, which is what you want.
10.3 Multiple outputs¶
✅ VERIFIED —
docs/guides/custom-metal-kernels.ipynbcell 11, reproduced verbatim:
def torch_sincos(x: torch.Tensor) -> list[torch.Tensor]:
return [torch.sin(x), torch.cos(x)]
sincos_kernel = TorchMetalKernel(
"sincos",
input_names=["x"],
result_names=["out_sin", "out_cos"],
src="out_sin[id] = sin(x[id]); out_cos[id] = cos(x[id]);",
torch_defn=torch_sincos,
metal_params=[MetalParameter("id", "uint", "thread_position_in_grid")],
)
class SinCosModel(nn.Module):
def forward(self, x: torch.Tensor) -> torch.Tensor:
results = sincos_kernel(
x,
threads_per_grid=(x.shape[0], 1, 1),
threads_per_thread_group=(1, 1, 1),
result_shapes=[list(x.shape), list(x.shape)],
)
return results[0] + results[1] # sin(x) + cos(x)
list[torch.Tensor] is variable-length, so arity is checked at call time (via result_shapes), not
at construction. tuple[torch.Tensor, torch.Tensor] is checked at construction. Prefer the tuple
form when the count is fixed — you get the error earlier and with a better message.
11. helper_src and reaching TensorOps¶
11.1 The handoff¶
Everything so far has been about the seam. The kernels have been deliberately naive: an
element-wise add, a triple-loop matmul. Real performance work means TensorOps —
mpp::tensor_ops::matmul2d, cooperative tensors, reduce_rows, the M5 neural accelerator — and
that is a different discipline with its own guide.
The two WWDC sessions are explicitly wired together in both directions:
✅ VERIFIED — session 325:205: "For more details on how you can write efficient Metal kernels for Core AI, and to see an optimized kernel live in action with the SAM3 model please see the 'Optimize custom machine learning operations with Metal tensors' talk."
✅ VERIFIED — session 330:121: "Check out the 'Deep Dive into Core AI Model authoring and Optimization' session for the details of how to integrate a Metal kernel into a Core AI model."
→ For how to write the kernel: Part 11 — Metal and TensorOps.
That guide covers matmul2d_descriptor's seven positional arguments and its mode::multiply
default, execution scopes, cooperative-tensor construction and the get_*_input /
get_destination template-argument asymmetry, reduce_rows, map_iterator, and the alignment
rules for sub-byte tensors.
→ This section covers only what changes when that kernel lives inside a TorchMetalKernel.
11.2 Where the TensorOps code goes¶
helper_src is pasted before the kernel definition, so it is where includes, using namespace
lines and type aliases belong:
TENSOR_OPS_PREAMBLE = """
#include <metal_tensor>
#include <MetalPerformancePrimitives/MetalPerformancePrimitives.h>
using namespace metal;
using namespace mpp::tensor_ops;
// type aliases, helper functions, structs — anything the body needs
"""
flash_attn = TorchMetalKernel(
"flash_attention",
input_names=["q", "k", "v"],
result_names=["o"],
src=FLASH_ATTENTION_BODY,
helper_src=TENSOR_OPS_PREAMBLE,
torch_defn=torch_sdpa_reference,
metal_params=[
MetalParameter("tgid", "uint2", "threadgroup_position_in_grid"),
MetalParameter("tid", "uint2", "thread_position_in_threadgroup"),
],
)
✅ VERIFIED — that helper_src is "additional Metal source pasted before the kernel
definition (helper functions, type aliases, etc.)" (docs/api/TorchMetalKernel.md), and that the
MPP include line is #include <MetalPerformancePrimitives/MetalPerformancePrimitives.h> with
namespace mpp::tensor_ops (Xcode SDK headers, as used by MLX). 🟡 The specific combination above
— an MPP include inside helper_src — is the obvious composition of two verified facts and is what
the community _tensorops_proto scripts do, but no Apple example in this corpus shows it.
11.3 The SAM3 FlashAttention integration, as narrated¶
Session 330 closes by putting a hand-written FlashAttention kernel into SAM3 through exactly this API. The presenter describes a three-step recipe:
✅ VERIFIED — session 330:122–126, verbatim: "I've followed the steps outlined in that session to integrate our custom FlashAttention kernel into a Sam3 image segmentation model. We define the body of our custom attention kernel as a string in Python and register the
TorchMetalKernelobject, shown here." "Then, we replace the default huggingface attention implementation with one that calls our kernel, shown here." "Finally, we load the model from huggingface and export it from PyTorch as an optimized Core AI asset."And the result, 330:131–132: "Looking at the final result, we can see the model correctly segmented the image. The car is highlighted in blue, so our attention kernel is fully integrated into the model as expected."
So the pattern is: kernel body as a Python string → TorchMetalKernel → monkey-patch the
upstream module's attention implementation to call it → export as usual. That is a genuinely
useful technique, because it lets you keep using an unmodified Hugging Face checkpoint and model
class while swapping one operation.
# Step 2, in shape. The kernel is a plain Python callable, so the patched
# forward is an ordinary function.
def custom_attention_forward(self, q, k, v, **kwargs):
return flash_attn(
q, k, v,
threads_per_grid=(...),
threads_per_thread_group=(...),
result_shapes=[list(q.shape)],
)
SomeAttentionClass.forward = custom_attention_forward
🟡 RECONSTRUCTED — the monkey-patch target. The presenter says "shown here" and the code appears on screen without being read aloud. The technique is verified from the narration; the class you patch, the exact
forwardsignature you must match, and the dispatch tuple used are all unknown. The Hugging Face attention-forward signature has also changed shape acrosstransformersversions. Safe default meanwhile: patch the narrowest thing that works — in moderntransformers, register a named attention implementation and select it via the model config rather than assigning to a class attribute, so a version bump produces an error instead of a silently-unpatched model. And assert the patch took effect (assert cls.forward is custom_attention_forward) before exporting; an unpatched export converts perfectly and is simply the original model.
⚠️ The SAM3 export in apple/coreai-models does not contain this kernel. The shipped recipe
(models/sam3/README.md, segmentation/pipeline.py) uses composite-op SDPA and palettization; the
FlashAttention integration exists only in session 330's demo. There is no code to copy.
11.4 ⚠️ Version reality for quantized kernels¶
If your kernel body dequantizes weights — which is the main reason to write one — the API you can target is narrower than session 330's narration suggests. Three things must be said plainly.
(a) The feature ladder is per-point-release, and 26.2 is not in it.
✅ VERIFIED — Apple Tech Talk 111432, "Accelerate your machine learning workloads with the M5 and A19 GPUs", verbatim: "Since we introduced TensorOps, we've continued expanding the API in iOS and Mac OS 26. In 26.1, we added bfloat tensor support… In 26.3, we added support for cooperative tensors as inputs to matmul. This lets you build custom dequantization routines inside your kernel, essential for running quantized models efficiently. And in 26.4, we added four bit and eight bit integer tensors, so quantized models can fully leverage neural accelerators."
| OS version | TensorOps capability |
|---|---|
| 26.0 | TensorOps introduced (WWDC25 session 262) |
| 26.1 | bfloat tensor element type |
| 26.2 | (nothing in Apple's ladder) |
| 26.3 | cooperative tensors as matmul inputs → in-kernel custom dequantization |
| 26.4 | int4 / int8 tensor element types |
The apparent conflict with the shipped SDK is not a conflict. The Xcode 26.6 headers define
__TENSOR_OPS_SUPPORT_DEPLOYMENT_TARGET_26_2 — a deployment-target macro on specific symbols —
while the ladder above describes features. ✅ Both are verified; they are different views. Do
not print a single blanket "TensorOps requires 26.x". Print the ladder, and gate on the specific
capability you use.
Independent corroboration of the same floor from a shipping build system: MLX compiles its TensorOps
kernels only when MLX_METAL_VERSION >= 400 and SDK ≥ 26.2 and deployment target ≥ 26.2
(mlx/backend/metal/kernels/CMakeLists.txt:158-182, ✅ verified in
notes/repos/mlx-tensorops-kernels.md §11.2).
(b) Xcode 26.6 lacks fp4, fp8 and int2; Xcode 27 adds them.
✅ VERIFIED 26.x baseline. 1. The
MetalPerformancePrimitivesheaders shipped in the Xcode 26.6 SDK:__tensor_ops_datatypeenumerates float32/float16/bfloat16, int4/int8/int16/int32 and the unsigned counterparts. int2, fp4, fp8 and E8M0 are absent from that 26.6 SDK. 2.MTLTensorDataTypeinMTLTensor.hstops atMTLTensorDataTypeInt4 / UInt4 API_AVAILABLE(macos(26.4), ios(26.4)). 3. Tech Talk 111432 announces "four bit and eight bit integer tensors" — four and eight only.One more useful constraint from the header's own supported-combination table (
MPPTensorOpsMatMul2d.h:13-61): 4-bit only ever appears as the right (weight) operand. There is noint4 × int4combination.
For OS 27, MTLTensorDataType adds int2/uint2, FP4 E2M1, FP8 E4M3/E5M2 and unsigned E8M0, and the
MPP type map adds the corresponding metal::*_format shader operand types. Do not carry the 26.6
negative result forward to an Xcode 27 target.1
(c) OS 27 has an E8M0 auxiliary scale-plane mechanism.
✅ VERIFIED (Xcode 27 correction). Session 330’s
dataType,blockFactorsand auxiliary-plane map are present inMTLTensorAuxiliaryPlaneDescriptor,MTLTensorAuxiliaryPlaneDescriptorMap, andMTLTensorDescriptor.auxiliaryPlanes— declared in the macOS 27.0 beta SDK'sMetal.framework/Headers/MTLTensor.h(:164,:191,:288, the last gatedAPI_AVAILABLE(macos(27.0), ios(27.0))). The supported scale plane uses unsigned E8M0 and defaults to block width 32. TensorOps consumes supported data and scale planes together and handles their dequantization.1Fallback. On 26.x, or for a custom format whose scale dtype/block geometry the OS 27 descriptor cannot express, dequantize in-kernel into a cooperative tensor and feed that to
matmul2d. Session 330 explicitly distinguishes this custom-format path from OS 27 automatic dequantization. Cooperative-tensor inputs require 26.3.1
A community de-risk script demonstrates the concrete fallback when you cannot even use cooperative tensors as inputs — staging the dequant through threadgroup memory:
Community-measured / community-authored —
notes/repos/john-rocky-models.md§10.1, describing_tensorops_proto/m2_int4_block32_scaled.py(120 lines, M4 Max, beta macOS 27, 2026-07): block-32 fp16-scaled int4 matmul, which "manually dequants each[k=32, n=32]weight block intothreadgroup half wsh[BLK*TILE_N], applying the per-block fp16 scale, then runsmatmul2dhalf×half withmode::multiplyon the first block andmode::multiply_accumulatethereafter — accumulating into a floatCacrossK/32blocks, withthreadgroup_barrieron both sides." Signed int4 is decoded explicitly (if (code > 7) code -= 16;) with the comment "decode signed int4 manually (unambiguous)". The author's conclusion: you can get block-32 scaling at Metal 4.0 by staging the dequant in threadgroup memory.Note the mode detail —
matmul2d_descriptor's default ismode::multiply, so the first block initializes and subsequent blocks must explicitly requestmultiply_accumulate. Part 11 covers this; it is a documented default that reads backwards.
11.5 Two open questions at this boundary¶
🔴 GAP — the MSL language version
coreai-torchcompiles embedded kernel source at is unpublished. This matters because TensorOps features are gated on it:matmul2dwith uniform int4 is reported to need Metal 4.0, while the multi-plane machinery (metal::tensor_blockwise,__HAVE_TENSOR_MULTIPLANE__) is reported to need 4.1. The question — "cancoreai-torchcompile the embedded MSL at-std=metal4.1?" — is raised verbatim as an open item in community notes (notes/repos/john-rocky-models.md§5.7) and is not answered anywhere in this corpus. There is nomsl_versionorstdparameter onTorchMetalKernel; the compile happens insidecoreai-core. Resolving it needs either thecoreai.authoringsource or an empirical probe — compile a body using a known-4.1-only construct and see whetherload_functionsucceeds.Safe default meanwhile: target Metal 4.0 constructs.
matmul2d, cooperative tensors,reduce_rowsandslice/static_sliceare all reachable there, and the threadgroup-staging dequant above removes the only compelling reason to need 4.1.⚠️ SILENT FAILURE — a missing feature macro makes the whole MPP header expand to nothing. ✅ VERIFIED —
MPPTensorOpsMatMul2d.h:328is guarded by#if defined(__METAL_VERSION__) && defined(__HAVE_TENSOR__). As the header analysis innotes/repos/mlx-tensorops-kernels.md§11.1 puts it: "If__HAVE_TENSOR__is undefined the entire header expands to nothing — no error, just an empty namespace and a confusing 'no member named matmul2d' later." Related macros:__HAVE_BFLOAT__,__HAVE_INT4B_FORMAT_TYPE__,__HAVE_EXECUTION_UNIT__.Inside a
TorchMetalKernelyou do not control the compiler invocation, so ifmatmul2d"does not exist", the cause is a feature macro, not a typo. Add#if !defined(__HAVE_TENSOR__) \n #error "no tensor support" \n #endiftohelper_srcwhile debugging, so the failure names itself.
12. The failure taxonomy¶
Failures land in four buckets, and the further right you go the worse the diagnostics get.
construction → call site → conversion → load / run
(TorchMetalKernel(...)) (kernel(...)) (add_exported_program / to_coreai) (load_function)
rich errors rich errors decent errors one generic message
12.1 Construction-time errors (rich, cheap, all pinned by tests)¶
✅ VERIFIED — every row from coreai_torch/_torch_metal_kernel.py with a matching test in
tests/dsl/.
| Trigger | Exception and message |
|---|---|
empty / whitespace name |
ValueError("Kernel name must be a non-empty string, got …") |
empty result_names |
ValueError("result_names must contain at least one entry") |
| duplicate name in a list | ValueError("Duplicate input names: ['x']") / "Duplicate result names: ['out']" |
| name in both lists | ValueError("Names appear in both input_names and result_names: […]") |
*args / **kwargs in torch_defn |
TypeError("custom kernels do not support variadic parameters (*args / **kwargs); got parameter '…' with kind …") |
| unsupported parameter annotation | TypeError("custom kernels only support \torch.Tensor`, `float`, `bool` and `int` inputs, got |
parameter count ≠ len(input_names) |
ValueError("torch function should have same number of parameters as specified by input names, expected 2, got 1") |
| bad return annotation | TypeError("Metal kernels only support return types of \torch.Tensor`, `list[torch.Tensor]`, or `tuple[torch.Tensor]` (with a concrete number of tuple members). …")` |
| single-Tensor return, >1 result name | ValueError("torch_defn returns a single torch.Tensor, but result_names has 2 entries: …") |
| tuple arity mismatch | ValueError("torch_defn returns tuple of 2 tensors, but result_names has 3 entries: …") |
template_dtypes key not an input |
ValueError("Inputs {'z'} not specified") |
| duplicate template placeholder | ValueError("Provided duplicated template strings ['TYPE']") |
12.2 Call-site and conversion-time errors¶
| Trigger | Exception | When |
|---|---|---|
| grid tuple not length 3 | ValueError("threads_per_grid must be a 3-tuple, got 2 elements: …") |
call |
| threadgroup tuple not length 3 | ValueError("threads_per_thread_group must be a 3-tuple, got …") |
call |
wrong number of result_shapes |
ValueError("result_shapes must contain one shape per result name; expected 2 (for [...]), got 1") |
call |
| parameter budget exceeded | ValueError("metal kernels support 31 inputs, got 32") |
conversion |
| duplicate kernel name | ValueError("'coreai_metal_kernels::N.default' is already registered; set allow_override=True to replace it") |
register_custom_kernels |
| unsupported input dtype | TypeError("kernel input at index 0 has unsupported dtype: f8E5M2") |
conversion |
| unsupported result dtype | TypeError("Result type at index 0 has unsupported dtype: f8E5M2") |
conversion |
| int scalar out of int32 range | ValueError("int scalar 'n'=… is outside the 32-bit int range that MSL \int` supports")` |
conversion |
| non-finite float scalar | ValueError("float scalar 'eps'=inf is not finite; NaN/Inf scalars are not supported") |
conversion |
✅ VERIFIED — all rows from tests/dsl/; the parameter-budget row from
tests/dsl/test_custom_kernels.py:184-252 with Apple's own arithmetic in the comment:
"Metal 4: params = inputs + results + metal_params. Use 21 metal params so 10 inputs + 1 result + 21 metal_params = 32 > PARAMETER_LIMIT (31)."
The budget is len(input_names) + len(result_names) + len(metal_params) ≤ 31, and scalar inputs
count as inputs (tests/dsl/test_scalar_inputs.py:8-17, 176-215). If you are fusing a whole
transformer layer, this is a real ceiling — plan to pack related tensors rather than passing twenty
separate weight buffers.
12.3 The known converter bug: custom kernels inside torch.cond¶
⚠️ VERIFIED BUG, pinned as a strict
xfailin Apple's own suite.tests/dsl/test_metal_kernel_robustness.py:264-290, docstring verbatim:"The converter currently routes branch bodies through
coreai_torch._utils.convert_branch_subgraph, which is wired with_aten_to_core_resolveronly and does not receive the user-defined torch lowerings registered byTorchConverter.register_custom_kernels. Calling a custom kernel inside acondbranch therefore raisesunsupported op in branch. This is a real bug — fixing it requires threading user lowerings throughreplace_cond/replace_while_loop. The test below pins the current behavior so the regression is visible and is upgraded to a passing assertion once the converter is fixed."Marked
@pytest.mark.xfail(..., strict=True), so the day Apple fixes it their CI turns red and the test is promoted.Workaround: hoist the kernel call out of the branch. Compute it unconditionally and select afterwards, or restructure so the conditional selects inputs rather than operations. Independently, higher-order ops (
cond,while_loop) are noted as "not yet supported by the cpu/gpu/neural_engine compute unit runtimes" — they run only on the interpreter compute unit in Apple's tests — so acond-bearing graph has a second problem regardless of kernels.
12.4 ⚠️ The one that gets everybody: a bad kernel body is not a conversion error¶
⚠️ SILENT FAILURE — malformed Metal Shading Language converts cleanly, optimizes cleanly, saves cleanly, loads cleanly, and fails only when you bind a function — with a message that contains no compiler diagnostic.
✅ VERIFIED —
tests/dsl/test_failures.py:22-70, in full. Apple's own test constructs a kernel whose entire body is— an undeclared array, an undeclared index, an undefined identifier, and no semicolon — then:
converter.register_custom_kernels([custom_kernel]) # OK converter.add_exported_program(ep, ...) # OK coreai_program = converter.to_coreai() # OK coreai_program.save_asset(Path(tmp)) # OK model = await AIModel.load(Path(tmp)) # OK with pytest.raises(RuntimeError, match=re.escape( "Kernel coreai.metal4_kernel invoked with invalid parameters")): model.load_function("main") # ← finally fails, hereThe test is named
test_raise_comprehensible_compilation_failure, and the "comprehensible" message isRuntimeError: Kernel coreai.metal4_kernel invoked with invalid parameters. No line number. No column. No Metal compiler output. No mention ofA,sorsdfs.Every category of MSL mistake lands here: syntax errors, undeclared identifiers, a variable name that does not match
input_names, a#includethat is not available, a construct your MSL version does not support, a threadgroup array that exceeds the limit. They all present as the same sentence, atload_function, after conversion has told you everything is fine.Mitigations, in order of value: 1. Compile the MSL yourself first. Paste the body into a standalone
.metalfile with a hand-written signature and runxcrun metal -c. You get real diagnostics. Do this every time you change the body; it costs seconds. 2. Never write more than a few lines betweenload_functioncalls. Bisecting a 200-line kernel against a message with no line number is miserable. 3. Keep a minimal always-works kernel in the same file (the identity kernel from §7.3) so that when everything breaks you can prove the pipeline still works and isolate the change to the body. 4. Watch for name drift.input_names=["x"]with a body that saysinputis not a compile error you will recognize from that message. §3.2's table exists for this.
12.5 ⚠️ Apple's own end-to-end kernel tests are currently disabled¶
This is a fact about the state of the API that you should factor into your risk assessment.
✅ VERIFIED — three files in
tests/dsl/carry a module-level skip:pytestmark = pytest.mark.skip( reason="ExecutableOptions(enable_encoding_functions=...) was removed in the " "AIProgram API; no replacement found in coreai.authoring/runtime/compiler. " "DSL kernel tests need a follow-up once a replacement surfaces." )and, on the individual tests inside them, a second skip:
The three are
test_softmax.py,test_self_attention.pyandtest_multiple_kernels.py— i.e. the numerical, end-to-end, actually-runs-on-the-GPU tests. Additionally, CI deselects the whole marker:.github/workflows/ci.yml:37runspytest tests/ -n auto -m "not slow and not dsl", andtests/dsl/conftest.py:16-23adds thedslmarker to every test in the directory.
What still runs (on a developer's Mac, not in CI): the IR-level tests, the validation tests, the
matmul tests via validate_numerical_output, dtype specialization, thread configuration, scalar
inputs and the collision tests. What does not: Apple's own softmax and self-attention kernels,
end to end, on a device.
Read this as calibration, not alarm. The API is real, it is documented, it lowers correctly, and community work has shipped models built on it. But the "reenable once runtime kernel moved to support Metal 4" note tells you the runtime side is in motion, and §12.4 tells you the diagnostics are thin. Budget for your own numerical harness (§14) rather than assuming the platform will catch you.
13. Does it pay? Community measurements¶
Everything in this section is community-measured: a single author, self-declared uncontrolled
benchmarks, beta operating systems, mid-2026. It is not Apple-published and must not be presented as
such. It is included because it is the only end-to-end evidence in existence for this API, the
numbers are unusually well-documented, and the shape of the result — one large win, several
regressions — is more instructive than any single figure. Source throughout:
notes/repos/john-rocky-models.md, which summarizes the john-rocky/coreai-model-zoo knowledge
files.
13.1 The win: a gather_qmm kernel for mixture-of-experts decode¶
The problem. Core AI's built-in GatherMM composite op gathers expert weights and then runs a
dense matmul — it does not read only the routed experts. So MoE decode is bound by over-reading
weights, not by active parameters.
Community-measured — "
GatherMMgathers then runs a DENSE matmul — it does NOT read only the routed experts, so MoE decode is over-read-bound, not active-param-bound (Qwen3.6-35B-A3B int8 sits at ~25 % of bandwidth)."
The fix. A TorchMetalKernel matvec that takes the routed expert indices as a kernel input
and reads only the top-k experts' weight slabs — "QP[w,n,e], e = IDX[slot] — indexed global
load; the other E−k experts are never fetched." Packaged as a drop-in MetalSwitchGLU replacing
SwitchGLU, applied model-wide by a metalize_moe(model, nbits) helper.
The results. All community-measured on an M4 Max, beta macOS 27, dated 2026-06-13:
| Model | Configuration | Decode | Change |
|---|---|---|---|
| LFM2.5-8B-A1B | int8 MoE, stock GatherMM |
39 tok/s | baseline (8.8 GB, ~345 GB/s effective) |
| LFM2.5-8B-A1B | int8 MoE, custom gather_qmm |
141 tok/s | 3.6× — reads 4 of 32 experts |
| LFM2.5-8B-A1B | int4km, gather_qmm |
162.7 tok/s | 4.7 GB, iPhone-jetsam-safe |
| Qwen3.6-35B-A3B | stock → gather_qmm |
30.9 → 64.9 tok/s | 2.1× — top-8 of 256 experts |
| GLM-4.7-Flash | stock → gather_qmm |
20.3 → 52.4 tok/s | 2.6×; MLA on all 47 layers caps it |
| LFM2.5-8B-A1B int4km | on iPhone 17 Pro (A19 Pro) GPU | ~32 tok/s | "the zoo's first iPhone MoE on hardware" |
Numerics: "kernel == 'select-from-all' bit-for-bit." The kernel changes what is read, not what is computed.
Why it worked when kernelizing attention did not. The kernel changed the memory-traffic
asymptotics: O(E) weight reads became O(k). That is a structural win no amount of fusing or
tuning of the dense path can recover. Compare §2.5's failures, which all re-implemented work that was
already efficient.
The enabler, and its constraint.
Community-measured probe result: "Rank-3 buffer indexing + a DATA-DEPENDENT gather both lower + run on the GPU. So a kernel can take an index tensor as an INPUT and read only the rows it points at —
W[m, n, e]withe = uint(IDX[slot])reads only expert-slabeout of a[E, N, M]tensor."⚠️ The critical caveat, and it is a
torch_defnconstraint: "Thetorch_defnmust stay fake-traceable: express the gather astorch.index_select(W, 0, idx)(shape-static), NEVERint(idx[i])(FakeTensor has no concrete value)."This generalizes. Your reference implementation is traced with
FakeTensors, so any Python-level read of a tensor's value —int(t[0]),t.item(),if t > 0,range(t)— fails at export. The reference may only do shape arithmetic. That constraint applies to everytorch_defn, not just gathers.
And one more correctness trap from the same work, which is a good example of what "the reference cannot catch it" looks like in a real kernel:
Community-measured — "gate/up share the token
xacross routed experts, but the down projection feeds each expert its OWN gated activation — so the kernel'sAmust be[k, K](one row per slot,A[c, slot]), withxreplicated k-wide for gate/up. TreatingAas a single shared[1, K]row silently corrupts down (relative error ~1.3 = garbage)."
13.2 The losses, restated¶
From §2.5, and worth repeating next to the win so the comparison is unavoidable (community-measured, same hardware and period):
- Kernelizing attention q/k/v/o was slower. Any single op-class was ≤ 1.3 ms; there was nothing to win.
- Native SDPA on GPU should not be hand-rolled — "already fused; don't hand-roll it."
- A decode-step SSM scan kernel gained 3–8 %, judged "not worth the barrier + shape constraints." The same technique on prefill (chunked scan) was 13.7× on Mac. Same kernel class, opposite verdict, decided entirely by which phase it ran in.
- An absorbed-MLA staging kernel currently measures 0.78× — i.e. a regression the author records and does not ship, with an explicit stop rule: "If it can't beat naive even @8K → record and stop (don't ship a non-win)."
13.3 ⚠️ The hidden cost: a kernel is a fusion barrier¶
⚠️ SILENT FAILURE — inserting a kernel can make the surrounding graph slower in a way that does not appear in your kernel's own timing.
Community-measured — "A
metal4_kernelop is a FUSION BARRIER, and its edges are materialized in the dtype/layout the kernel asks for. A dtype cast on the boundary (e.g.state.float()in,.half()out) is a realcoreai.castop that blows the tensor up — hand large state/activation tensors across in their native dtype and accumulate fp32 in registers."Two costs, neither visible in a microbenchmark of the kernel: 1. Ops that would have fused across your kernel's position no longer can. This is why replacing a single cheap op with a kernel is usually a regression: you pay a barrier to save nothing. 2. Boundary casts materialize. If your kernel wants fp32 inputs and the graph is fp16, the converter inserts a real cast op writing a full-size fp32 tensor to memory. Take the tensor in its native dtype and widen inside the kernel —
const float v = float(x[id]);— which costs nothing, versus a graph-level cast which costs a full read and write.The §3.2 SiLU body does exactly this:
xis fp16, and the body widens per-element in a register.
13.4 A related structural constraint worth knowing before you commit¶
If your model is a language model behind Foundation Models, note that grammar-constrained decoding
(@Generable) requires access to engine logits, which GPU-pipelined Core AI bundles do not
expose (community-measured; see Part 4). A custom-kernel-bearing model is GPU-resident by
construction (§2.4), so this constraint and the kernel decision travel together.
14. Testing a kernel, and the de-risk ladder¶
Given §5.4 (the reference is not a test), §12.4 (bad MSL is silent until load) and §12.5 (Apple's own end-to-end tests are off), your numerical harness is not optional. Here is the order that costs the least.
14.1 Step 0 — the dispatch probe (30 lines, do this once per project)¶
Before any real kernel, write one that does nothing but reveal the coordinate system. The community
probe_dispatch.py pattern: thread 0 of each threadgroup writes a known encoding of its own
threadgroup id into a known-good output, and you read it back in numpy.
import numpy as np
import torch
import torch.nn as nn
from coreai.authoring import MetalParameter
from coreai.runtime import NDArray, StorageKind
from coreai_torch import TorchConverter, TorchMetalKernel, get_decomp_table
PROBE_SRC = """
// Thread 0 of each threadgroup stamps its own tgid into the output.
if (tid.x != 0 || tid.y != 0) { return; }
if (tgid.x >= out.get_extent(0) || tgid.y >= out.get_extent(1)) { return; }
out[tgid.x, tgid.y] = float(100 + 10 * tgid.x + tgid.y);
"""
def torch_probe(z: torch.Tensor) -> torch.Tensor:
return torch.zeros_like(z)
probe = TorchMetalKernel(
"probe_dispatch",
input_names=["z"],
result_names=["out"],
src=PROBE_SRC,
torch_defn=torch_probe,
metal_params=[
MetalParameter("tid", "uint2", "thread_position_in_threadgroup"),
MetalParameter("tgid", "uint2", "threadgroup_position_in_grid"),
],
)
class ProbeModel(nn.Module):
def forward(self, z: torch.Tensor) -> torch.Tensor:
return probe(
z,
threads_per_grid=(8, 4, 1), # 8 groups in x, 4 in y (1 thread each)
threads_per_thread_group=(1, 1, 1),
result_shapes=[list(z.shape)],
)
Run it, print the numpy array, and write down which torch index moved when tgid.x moved. That
single fact — "out[a, b] in the kernel lands at numpy[b, a]" — is the thing that makes every
subsequent kernel in the project debuggable. §5.2.
14.2 Step 1 — compile the MSL standalone¶
Extract the body, wrap it in a hand-written signature, and run xcrun metal -c probe.metal -o /dev/null.
This is the only way to see a real Metal compiler diagnostic (§12.4). Keep the wrapper file around
and regenerate it whenever you edit the body; the five-line script that writes it is worth more than
any amount of careful reading.
14.3 Step 2 — Apple's numerical harness¶
coreai-torch's own test suite ships a reusable end-to-end validator, and it has first-class
support for custom kernels:
from tests.utils import validate_numerical_output
await validate_numerical_output(
model=MatmulModel().eval(),
custom_kernels=[custom_matmul], # registered before conversion, for you
metal_inputs=True, # StorageKind.METAL on every input — required
input_names=["x", "y"],
output_names=["result"],
atol=0.1, rtol=0.1,
dynamic_shapes=dynamic_shapes, # optional
x=fuzzed_x, y=fuzzed_y, # named tensors become forward() kwargs
)
✅ VERIFIED — tests/utils.py:523-620 and its use in tests/dsl/test_matmul_kernel.py:109-120.
Its docstring documents two modes (model= end-to-end, or a pre-converted coreai_program= plus
torch_out=), and notes the default atol=1e-2 exists "because FP16 accuracy is flaky".
This lives in the repo's tests/ package rather than the installed wheel, so you either vendor it or
adapt its historical 0.4.1 workflow for 0.4.3: export → decompose → register → convert
(successful exit from with module: runs the rewrite before AIProgram(module) is returned) →
save_asset → executable() → load_function → await function(...) → compare against the torch
reference.
Fuzz the shapes. Apple's matmul test picks M, K, N randomly from range(2, 20) on every run
(test_matmul_kernel.py:90-91) — which is how tile-boundary bugs get found. Sizes that are exact
multiples of your tile will pass a broken kernel.
14.4 Step 3 — the isolation script¶
Community practice worth adopting wholesale: for each kernel idea, write a standalone 60–120-line script that does nothing but that kernel, and reports cosine similarity plus relative L2 against a torch reference — before it goes anywhere near your model.
Historical community-authored 0.4.1 example —
notes/repos/john-rocky-models.md§10.1 describes seven such scripts, all built on the same skeleton: "TorchMetalKernel(src=<MSL body>)→torch.export→TorchConverter.register_custom_kernels→add_exported_program→to_coreai().optimize()→save_asset→asset.executable()→load_function("main")→await fn({...}), then cosine + relative-L2 vs a torch reference."Two refinements from that set that are worth stealing: - The base matmul script reports a per-tile correctness map, not one aggregate number — "so a partially-wrong tiling shows up as a spatial pattern rather than one bad number." - One script is a pure A/B gate that answers the go/no-go question before the expensive work: "does the matrix-unit path beat Core AI's default matmul at compute-bound shapes? If
matmul2dcan't beat MPSGraph matmul here, a custom FlashAttention won't beat MPSGraph SDPA either (same matrix ceiling). If it can, FlashAttention is worth building."That ladder produced a negative result on A19 (the default path was already near peak) and an open question on M4 Max. Which is exactly what a de-risk ladder is for, and a good reason to build one before you build a kernel.
14.5 Step 4 — bisect divergence with the debugging tools¶
If a kernel-bearing model produces subtly wrong output, coreai_torch.debugging gives you two
instruments that beat print statements:
# NaN / Inf isolation on the converted program
from coreai_torch.debugging.validator import create_validator_for_coreai_program
validator = await create_validator_for_coreai_program(coreai_program, "main")
result = await validator.check_for_nans(inputs={"x": torch.randn(2, 4)})
print(result.failed_nodes[0]) # first op that produced a NaN
# Cross-framework comparison, PyTorch vs Core AI, op by op
from coreai_torch.debugging.comparator import create_comparator_for_programs
comparator = await create_comparator_for_programs(
source_program=exported_program,
target_program=coreai_program,
target_entry_point="main",
)
result = await comparator.compare_with_tolerance(
inputs={"x": example_input}, rtol=1e-5, atol=1e-8)
for source_op, target_op in result.failed_nodes:
print(f"Mismatch: {source_op} vs {target_op}")
✅ VERIFIED — docs/api/debugging.md. Nearly everything in that module is async. Note the
comparator compares the converted program against the exported one, so for a custom kernel it is
comparing your MSL against your torch_defn — which is precisely the check that nothing else
performs. Remember the preview environment variables from §2.6.
The Core AI Debugger app (https://developer.apple.com/core-ai-debugger/) adds a visual version
of the same comparison with automatically identified "sync points" and a PSNR metric; Part 10 covers
it. For kernel work its value is that it will show you where in the graph divergence starts, which
for a fusion-barrier op is usually the boundary rather than the body.
14.6 One numerics rule that is not about Core AI at all¶
⚠️ Naked
exp()in a hand-written kernel. Any softmax-shaped computation must subtract the running maximum before exponentiating. The community archive records this as an explicit standing rule — "three separate sessions lost to this; subtract the max first" — and Apple's own softmax fixture does it in a labelled pass: "Pass 1 — find maximum value along the axis (numerical stability)" (tests/dsl/conftest.py:92-101), as does their self-attention fixture, which computes the max only over unmasked positions so thatexp(-inf - max) == 0handles the causal mask for free (tests/dsl/test_self_attention.py:89-99).In fp16 this is not a refinement;
exp(12)already overflows half precision. A kernel that works on your test tensor and producesinfon real activations is the classic presentation.
15. Deployment reality¶
15.1 The kernel travels with the model¶
That is the design claim, and it holds through ahead-of-time compilation:
✅ VERIFIED (transcript) — session 325:184: "The Metal source gets embedded directly in the asset — a single artifact. The kernel travels with the model."
Community-measured corroboration through AOT —
notes/repos/john-rocky-models.md§5.2: aTorchMetalKernelmodel survives ahead-of-time compilation — "the.aimodelc'sspecialized_model_*.mpsgraphcontains the full[[kernel]]MSL signature + compiled MTLB inresources.bin, and the compiled asset's outputs are bit-identical to the source.aimodel." Measured cold-load benefit for a GPU monolith:.aimodelc4.9 s vs.aimodel19.2 s true-cold specialize (~4×); warm 0.0 s for both. (Single author, M4 Max / iPhone 17 Pro, beta OSes, 2026-06.)
So the artifact story is clean: one .aimodel directory, containing your MSL as text, which the
runtime compiles during specialization and which coreai-build can pre-compile per architecture.
15.2 What that means for the compute-unit decision¶
Restating §2.4 because it is the deployment consequence people discover late: the function containing the kernel runs on the GPU. Not "prefers"; cannot do otherwise. If your product plan depends on Neural Engine residency for battery life on iPhone, a custom kernel in that function forecloses it.
The corollary that is easy to miss: this is a per-entrypoint decision, and Core AI's multi-function
assets let you make it per-entrypoint rather than per-model. Splitting a model into
image_encode / text_encode / detect is presented in session 325 as a latency technique
("the second inference is 76% faster, even after warmup", 325:261 — Apple-published, hardware and
protocol unstated), but it is also the mechanism by which parts of a model reach the Neural Engine.
Put the kernel in the function that wants the GPU and leave the others free. Guide 05 in this part
covers the split; Part 7 covers what the Swift side sees.
15.3 On-device compile cost and cache behaviour¶
Your MSL is compiled on the device the first time the model specializes. Practical notes, all community-measured (single author, iPhone 17 Pro / M4 Max, beta OSes, 2026-06):
- "The AOT load stages the precompiled MPSGraph package into
Library/Caches/coreai-cache— needs ~3 GB free; a near-full device fails ENOSPC, and the partial stage pollutes the content-keyed cache → next launch failsCode=2(No such file)." The recorded recovery is uninstall → reinstall. - A kernel-bearing GPU model measured decode 17 tok/s, prefill 13 tok/s, resident ~2.1 GB, cold load 9 s on iPhone 17 Pro.
coreai-build compileexits 0 for any requested architecture — "a successful compile does NOT validate the arch choice; only a device load does."
None of that is kernel-specific, but a kernel raises the compile cost and therefore the exposure.
15.4 The maintenance question¶
Two warnings in the shipping docs, and one in the test suite, should shape how you plan:
docs/api/TorchMetalKernel.md: "These APIs are experimental and subject to change in future releases."pyproject.toml:coreai-core==1.0.0b2— an exact pin on a beta.tests/dsl/: three end-to-end suites skipped pending "once runtime kernel moved to support Metal 4".
A custom kernel is code you own forever, on a surface Apple has labelled experimental, on a hardware target that gains capabilities per point release (§11.4). If the ladder in §2.2 offers you a rung below 3, take it.
16. Quick reference¶
16.1 The whole API¶
from coreai.authoring import MetalParameter # or: from coreai_torch import MetalParameter
from coreai_torch import TorchConverter, TorchMetalKernel, get_decomp_table
TorchMetalKernel(
name, # str, non-empty; becomes part of the emitted kernel name
input_names, # list[str]; must match torch_defn's parameter count
result_names, # list[str]; at least one; disjoint from input_names
src, # str, the BODY of the [[kernel]] function
torch_defn, # Callable; annotated Tensor|int|float|bool params,
# returns Tensor | list[Tensor] | tuple[Tensor, …]
metal_params=None, # list[MetalParameter(name, msl_type, attribute)]
helper_src=None, # str, pasted BEFORE the kernel definition
template_dtypes=None, # {input_name: "PLACEHOLDER"} → replaced with the Metal dtype
)
kernel(*args,
threads_per_grid=(x, y, z), # keyword-only, 3-tuple, Metal axis order
threads_per_thread_group=(x, y, z), # keyword-only, 3-tuple, product ≤ 1024
result_shapes=[[...], ...]) # keyword-only, one per result, TORCH order,
# derived from input .shape — never literals
converter = TorchConverter()
converter.register_custom_kernels([kernel]) # BEFORE add_exported_program; returns self
converter.add_exported_program(ep, input_names=[...], output_names=[...])
program = converter.to_coreai()
asset = program.save_asset(Path("m.aimodel")) # a DIRECTORY
async with asset.executable() as ai_model:
fn = ai_model.load_function("main")
out = await fn({"x": NDArray(data=arr, backing=StorageKind.METAL)}) # METAL required
y = out["y"].numpy() # materialize INSIDE the block
16.2 The rules, in one place¶
| # | Rule | Section |
|---|---|---|
| 1 | Exhaust composite ops and custom lowerings before writing MSL | §2.2 |
| 2 | A kernel makes its function GPU-only, permanently | §2.4 |
| 3 | .eval() then torch.export then run_decompositions(get_decomp_table()) |
§3.1 |
| 4 | src is the body; the signature, bindings and #include are generated |
§4.4 |
| 5 | MSL identifiers must match input_names / result_names / metal_params exactly — unchecked |
§3.2, §12.4 |
| 6 | Metal tensor extents are the reverse of the torch shape | §5.2 |
| 7 | result_shapes derived from input .shape, at every call site |
§6.4 |
| 8 | Bounds-guard every read and write; no early return before a barrier |
§7.3 |
| 9 | register_custom_kernels before add_exported_program |
§8.1 |
| 10 | Kernel name is a global key — build kernel objects at module scope |
§8.2 |
| 11 | Runtime inputs need StorageKind.METAL |
§8.4 |
| 12 | Scalars are baked as literals at trace time — they cannot vary at runtime | §9.2 |
| 13 | inputs + results + metal_params ≤ 31 |
§12.2 |
| 14 | No custom kernel inside a torch.cond branch (known converter bug) |
§12.3 |
| 15 | Bad MSL fails only at load_function, with no diagnostic — compile it standalone first |
§12.4 |
| 16 | OS 27 supports E8M0 auxiliary scale planes; dequantize in-kernel on 26.x or for custom formats1 | §11.4 |
| 17 | 26.x supports int4/int8 here; OS 27 adds int2, FP4, FP8 and E8M0 tensor types1 | §11.4 |
| 18 | A kernel is a fusion barrier; take tensors in their native dtype | §13.3 |
| 19 | The torch_defn must stay fake-traceable — no .item(), no int(t[0]) |
§13.1 |
| 20 | Subtract the max before exp() |
§14.6 |
16.3 Error-message → cause lookup¶
| Message | Cause |
|---|---|
Kernel coreai.metal4_kernel invoked with invalid parameters |
Any MSL compile failure — syntax, undeclared identifier, name mismatch. §12.4 |
metal kernels support 31 inputs, got 32 |
inputs + results + metal_params over budget. §12.2 |
… is already registered; set allow_override=True |
Two kernels share a name. §8.2 |
kernel input at index N has unsupported dtype: … |
Dtype has no MSL mapping. §5.5 |
result_shapes must contain one shape per result name |
Count mismatch at the call site. §6.2 |
torch function should have same number of parameters … |
input_names vs torch_defn arity. §4.5 |
custom kernels only support torch.Tensor, float, bool and int inputs |
An annotation the API cannot bind. §4.5 |
unsupported op in branch |
Custom kernel inside torch.cond. §12.3 |
no member named 'matmul2d' |
__HAVE_TENSOR__ not defined for this compile. §11.5 |
| NaN logits, CPU reference fine | Axis reversal. §5.2 |
17. Sources and evidence ledger¶
17.1 Primary — read this session, on disk¶
apple/coreai-torch, local clone at repos/apple__coreai-torch, git main,
HEAD 4529671, package version 0.4.1, coreai-core==1.0.0b2:
coreai_torch/_torch_metal_kernel.py— read in full (445 lines). Every constructor field, every validation message, the scalar-literal injection, the augmented torch-op signature, and the__call__implementation come from here with line citations.coreai_torch/converter.py:1016-1080—register_custom_kernelsand the duplicate-registration error.coreai_torch/_utils.py:1070-1086—scalar_constantand the fp16-promotion bypass.coreai_torch/__init__.py— the public surface and theMetalParameterre-export.docs/api/TorchMetalKernel.md— read in full (178 lines); the constructor table, the call table, the constraints, the templating and multiple-output examples.docs/guides/custom-metal-kernels.ipynb— all 14 cells; the "When You Need This" gate, the four-step walkthrough, and the two extra examples.docs/api/TorchConverter.md,docs/api/debugging.md,docs/getting-started/quickstart.ipynb,docs/coreai-core/tutorials/run-an-aimodel.ipynb,docs/guides/conversion-workflows.ipynb.tests/dsl/— all 14 files. This is where most of the non-obvious material in this guide comes from:conftest.py— the collection hook, and Apple's four MSL fixtures (softmax, naive matmul, tiled matmul, with per-dtype literal specialization).test_custom_kernels.py— the emitted IR, the 31-parameter limit, dtype rejection, all the construction-time validation.test_thread_config.py— the axis-reversal docstring, dispatch lowering, and the boundary-behaviour tests.test_failures.py— the malformed-MSL silent failure, in full.test_matmul_kernel.py— the working naive and tiled matmul call sites, dtype parametrization, shape fuzzing,validate_numerical_outputusage.test_metal_kernel_robustness.py— zero-input kernels, empty bodies, thetorch.condxfail, name and arity validation.test_scalar_inputs.py—constant T&bindings, the 31-buffer accounting.test_dtype_specialization.py,test_kernel_collisions.py— the kernel cache semantics.test_softmax.py,test_self_attention.py,test_multiple_kernels.py— currently skipped; read for their MSL and their skip reasons.tests/utils.py:200-620—validate_numerical_output,metal_inputs,custom_kernels..github/workflows/ci.yml:37—-m "not slow and not dsl".pyproject.toml— versions, pins, markers.
apple/coreai-models: python/src/coreai_models/segmentation/pipeline.py:265-286 (the shipped
three-entrypoint SAM3 conversion); models/sam3/README.md.
17.2 Primary — transcripts¶
- WWDC26 session 325, "Dive into Core AI model authoring and optimization" (Sachin, Core AI
team; Nicole, Core AI Debugger). Lines 166–205 are the custom-kernel segment; 178–184 and 186–204
are quoted verbatim here. Via
notes/transcripts/coreai-python-metal.md. - WWDC26 session 330, "Optimize custom machine learning operations with Metal tensors" (Shiyao, GPU Software Engineer). Lines 118–137 are the Core AI hand-off and the SAM3 FlashAttention integration. Via the same note.
- Apple Tech Talk 111432, "Accelerate your machine learning workloads with the M5 and A19
GPUs" — the TensorOps version ladder. Via
notes/transcripts/missing-sessions.md§7.5.
17.3 Primary — SDK headers¶
MetalPerformancePrimitives.framework and Metal.framework headers from two captures. The Xcode
26.6 baseline is read via notes/repos/mlx-tensorops-kernels.md and
notes/transcripts/coreai-python-metal.md §2; the OS 27 half — the low-bit types and auxiliary
planes — is read directly from the macOS 27.0 beta SDK's shipped headers
(MPPTensorOpsMatMul2d.h, __impl/MPPTensorOpsTypes.h, __impl/MPPTensorOpsAvailability.h,
MTLTensor.h). Used for the dtype set, the supported matmul combinations, the feature macros and
the 26.x deployment baseline, plus the OS 27 low-bit types and auxiliary
planes.1
17.4 Community — attributed, never presented as Apple¶
notes/repos/john-rocky-models.md, summarizing the john-rocky/coreai-model-zoo knowledge files.
⚠️ Single-author material with self-declared uncontrolled benchmarks, on beta operating systems,
M4 Max and iPhone 17 Pro, dated April–July 2026. Everything drawn from it in this guide is labelled
community-measured at the point of use. It is also the only source in existence for several facts —
the gather_qmm result, the axis-reversal NaN, the fusion-barrier cost, the AOT survival of a
kernel-bearing model — which is why it is here at all.
notes/repos/mlx-tensorops-kernels.md — MLX's Metal kernels and the MPP header analysis. MLX is
Apple's, but its kernels are engineering choices, not API documentation; used here for corroborating
what the headers do and do not contain.
17.5 Where sources disagreed, and how this guide ruled¶
| Conflict | Ruling |
|---|---|
Session 330 narrates MTLTensor scale planes with blockFactors and an auxiliary plane map |
Corroborated by Xcode 27. The older negative result came from Xcode 26.x headers and the pinned MLX implementation; in-kernel cooperative-tensor dequantization remains the fallback for 26.x and custom formats. §11.41 |
| Session 330 says int2, FP4, FP8 and E8M0 tensor types are new in iOS/macOS 27 | Corroborated by Xcode 27. The 26.0/26.1/26.3/26.4 ladder still describes earlier TensorOps capabilities; the new low-bit formats form a distinct OS 27 tier. §11.41 |
| Earlier TensorOps research said TensorOps availability is a blanket 26.2 | Superseded by Tech Talk 111432's ladder (26.0/26.1/26.3/26.4). Both the ladder and the 26.2 symbol macro are printed, as separate facts. §11.4 |
MetalParameter's keyword names |
Community-cited only; guide uses the verified positional form. §4.6 |
Community dtype map omits int16; Apple's test parametrizes over it and passes |
Apple's test wins; both stated. §5.5 |
Docs example writes TYPE sum = 0.0f; under templating; Apple's own tests specialize the literal per dtype |
Tests win — the docs example is float-only. §10.1 |
| Session 325 presents the multi-function split purely as a latency technique | The optional coreai-models loader additionally maps recognized structures to its Neural Engine preference; this is package policy, not a framework contract. |
17.6 Declared gaps — nothing is guessed inside these¶
- The SiLU MSL body from session 325:191. Never read aloud; the exact expression and whether it
used
exp,precise::exporfast::expis unrecoverable. §3.2 reconstructs the shape and says so. Resolution: a frame capture of the session slide. - The FlashAttention kernel and monkey-patch from session 330. Shown on screen, not narrated;
tile sizes, threadgroup configuration, the patched class and the
TorchMetalKernelarguments are all unknown, and Apple's shipped SAM3 recipe does not contain the kernel. Resolution: the "TensorOps sample code" download referenced at 330:136, which is not in this corpus. §11.3. MetalParameter's keyword parameter names, and the accepted set of attribute strings. The file (coreai/authoring/metal.py) ships in a wheel, not as source. Resolution: an installedcoreai-core. §4.6.- The symbolic path through
result_shapes— no test in this corpus exports a kernel model with a dynamic result-bearing dimension and runs it at two sizes. Resolution: one experiment on a Mac withcoreai-torchinstalled. §6.6. - The MSL language version the embedded source is compiled at. Determines whether Metal 4.1
constructs are reachable. No parameter exposes it. Resolution: an empirical probe, or the
coreai.authoringsource. §11.5. - The full dtype mapping table. Reconstructed from a community note plus Apple's test
parametrization; the authoritative table is inside
coreai-core. §5.5. - Whether the three skipped
tests/dsl/suites reflect a runtime regression or an API migration in progress. The skip reason names a removedExecutableOptions(enable_encoding_functions=…)parameter and "once runtime kernel moved to support Metal 4"; whether both are the same issue is not stated. Resolution: a latercoreai-torchrelease, or thecoreai-corechangelog — which does not exist yet (docs/release-notes.mdis a "Coming soon" placeholder). §12.5.
17.7 Not used as evidence¶
- Any claim that Core AI ships an Apple sample-code project for custom kernels. It does not —
verified 0
sampleCodeentries across 312 indexed Core AI symbols;/documentation/updates/coreai404s. - The invented spellings in circulation:
.coreaimodel,.aiasset, acoreai-torch convertCLI, "iOS 20 / macOS 17", an on-device LoRA training API. None appear in any Apple artifact in this corpus. The extension is.aimodel(a directory), the entry point is theTorchConverterclass, and the OS line is 26 / 27. - OS 27 scale-plane APIs without their documented E8M0/block-factor/usage constraints. §11.4.
Next in this part: guide 04 covers register_torch_lowering — the rung-2 alternative that
expresses a new op with Core AI primitives instead of MSL, and keeps every compute unit available.
Next for kernel authors: Part 11 — Metal and TensorOps,
which is where the kernel body itself gets written.
-
Apple’s OS 27 API reference documents the scale-plane descriptor, the tensor descriptor’s auxiliary-plane map, and the new tensor datatypes:
MTLTensorAuxiliaryPlaneDescriptor,MTLTensorDescriptor.auxiliaryPlanes, andMTLTensorDataType. The automatic-dequantization path and custom-format fallback are both stated in the authoritative WWDC26 session 330 transcript. ↩↩↩↩↩↩↩↩ -
The classifier and preferences are implemented in the optional
apple/coreai-modelspackage’s pinnedModelStructure.swift. Core AI’s.defaultbehavior is documented separately in Managing model specialization and caching. ↩