Core AI model zoo

Custom Metal kernels in Core AI (the GPU speed lever)

Foundation note for the GPU-now track. Custom Metal kernels are a first-class Core AI authoring feature (WWDC 325) and are GPU-only — the ANE cannot run hand-written MSL. Sources: coreai-torch/docs/guides/custom-metal-kernels.ipynb, coreai-torch/docs/api/TorchMetalKernel.md, coreai-torch/coreai_torch/_torch_metal_kernel.py, coreai/authoring/metal.py, ondevice/_wwdc325_transcript.txt (video MdlyLT_y3i0), skills/.../model-authoring/references/gpu_rules.md.

What it is & when to use

A custom Metal kernel lets you write a raw MSL GPU function, wrap it with a PyTorch reference, and have torch.export + coreai-torch embed it into the .aimodel as a real Core AI op (coreai.metal4_kernel). It is not a raw-Metal bypass — the MSL travels inside the single .aimodel artifact and runs in the OS Core AI runtime (WWDC 325, ~line 476–485).

Use it for op fusion — collapse several graph ops (and their dispatch + intermediate-memory traffic) into one kernel dispatch. WWDC 325 (~line 461): “You can take a group of these ops and fuse them into a single operation. This replaces several steps with a single kernel dispatch within the graph.” Core AI already ships fast prepackaged kernels for heavy ops (e.g. SDPA); write custom ones for what it doesn’t cover or to fuse a hot path.

GPU-only — and that is the structural reason the speed track lives on the GPU. 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. (See compute-units-and-authoring.md.)

The API

from coreai_torch import TorchMetalKernel, TorchConverter, get_decomp_table
from coreai.authoring import MetalParameter   # re-exported as coreai_torch.MetalParameter

TorchMetalKernel(
    name: str,                       # kernel id; becomes part of the generated kernel name
    input_names: list[str],          # names used in the MSL body; count == torch_defn params
    result_names: list[str],         # output names used in the MSL body (>=1)
    src: str,                        # MSL body ONLY (signature/buffer bindings/#includes auto-generated)
    torch_defn: Callable,            # PyTorch reference — what torch.export sees, used for shape inference
    metal_params: list[MetalParameter] | None = None,   # thread attrs, e.g. thread_position_in_grid
    helper_src: str | None = None,   # extra MSL pasted before the kernel (helpers/typedefs/constants)
    template_dtypes: dict[str,str] | None = None,        # input_name -> placeholder string in `src`
)

MetalParameter(name: str, dtype: str, attr: str)   # e.g. ("gid","uint2","thread_position_in_grid")

(_torch_metal_kernel.py:44-93; metal.py:36-52)

Call it inside an nn.Module like a function, passing dispatch + output shapes per call:

out = kernel(*args,
             threads_per_grid=(N,1,1),
             threads_per_thread_group=(T,1,1),
             result_shapes=[list(out_shape)])   # one entry per result; enables dynamic shapes

result_shapes is how Core AI “bakes in” output-shape-from-input-shape so the kernel works under dynamic shapes (WWDC 325, ~line 511–518).

Critical ordering — register kernels BEFORE add_exported_program

converter = TorchConverter()
converter.register_custom_kernels([kernel])        # FIRST
converter.add_exported_program(ep, input_names=[...], output_names=[...])   # THEN
prog = converter.to_coreai(); prog.optimize()

WWDC 325 (~line 519): “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.” This matches register_custom_kernels → builds a register_torch_lowering handler for coreai_metal_kernels::<name>.default (converter.py:1003-1033).

Minimal end-to-end (from the official guide)

def torch_add(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:   # reference for shape inference
    return x + y

vadd = 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 M(nn.Module):
    def forward(self, x, y):
        return vadd(x, y, threads_per_grid=(x.shape[0],1,1),
                    threads_per_thread_group=(1,1,1), result_shapes=[list(x.shape)])

ep = torch.export.export(M().eval(), (x,y)).run_decompositions(get_decomp_table())
conv = TorchConverter(); conv.register_custom_kernels([vadd])
conv.add_exported_program(ep, input_names=["x","y"], output_names=["result"])
conv.to_coreai().optimize()

How it lowers & ships

  1. torch.export traces the torch_defn reference; the op is registered under coreai_metal_kernels:: (_torch_metal_kernel.py:265).
  2. to_coreai() lowers it to coreai.metal4_kernel via CustomMetalKernel._construct_kernel_op (metal.py:423-479): forces Metal-buffer backing on inputs/results, templates the MSL with the actual dtypes, bakes the result-shape computation.
  3. The MSL is embedded in the .aimodel; the runtime dispatches it on the GPU at the right graph point.

Constraints & gotchas

Performance patterns (WWDC 325 + gpu_rules.md + this project’s measured results)