The authority on how to write one kernel in wh-iron-std — file shape,
naming, the #[kernel(variants(...))] axis, shared primitives, the CPU oracle,
and the bench. Follow it to produce a kernel that is consistent with the rest of
the library: correct, testable, benchmarkable, and maintainable. This is the
target style the whole library is migrating toward.
Companion:
KERNEL_CONSOLIDATION_PLAN.mdowns where files go (thekernels/<family>/layout) and the family-by-family migration roadmap. This guide owns how a kernel is written. Where they overlap (naming, the per-file shape), this guide is authoritative.
- Where does my kernel live?
- File skeleton
- The kernel function
- Dispatch modes and grid geometry
- Compile-time variants
- Shared primitives (cross-kernel calling)
- Writing the CPU oracle and tests
- Writing the bench
- Registering the kernel
- DSL reference and known limitations
- Worked example: elementwise scale
Kernels are grouped by operation family under kernels/, not by whether an
upstream metal reference exists (that's a property of one optional bench, not of
the kernel):
crates/wh-iron-std/src/kernels/
ops/ core/gather/scatter/reduce/elementwise primitives
gemm/ sdpa/ moe/ norm/ rope/ convolution/ ssm/ quant/ audio/ vision/ sampling/ kv_cache/
primitives.rs cross-family decode/reduce ops inlined at codegen
Folder names spell out abbreviated single words (convolution, not conv) and
keep standard acronyms (gemm, sdpa, moe, rope, ssm). Put the kernel in
the folder for its operation (rope_yarn → kernels/rope/,
gemv → kernels/gemm/). The quantized form of an op is not a separate
family — it folds into the op's file as a format axis (see §5). See
KERNEL_CONSOLIDATION_PLAN.md for the full
family list and the migration state (the crate is mid-migration from the legacy
mlx/ + iron/ split).
A kernel whose purpose is to validate a codegen path or HW intrinsic
end-to-end — not to do production work — is a probe. Probes live in
crates/wh-iron-std/src/probe/ (a crate-root module, like utils, outside
the kernels/<family>/ tree) and are named iron_<thing>_probe:
iron_simdgroup_load_probe (the Op::SimdgroupLoad round-trip),
iron_mpp_matmul_probe, iron_mma_probe_* (MMA layout). Use _probe, not _smoke
— "smoke" is reserved for test code (backend bring-up tests like
tests/cuda_smoke.rs, codegen-sanity #[test] fn …_smoke()), which stays
test-side and is not a kernel.
Every kernel file has the same four-section shape, in this order:
//! Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom)
//! SPDX-License-Identifier: Apache-2.0
//! One-line description — what this kernel computes and which upstream it ports.
//!
//! Longer description of the algorithm, layout, and any non-obvious choices.
//!
//! ## Layout ← required for kernels with multiple buffers
//!
//! - `input [rows, n]` T — description
//! - `output [rows, n]` T
//!
//! ## DISPATCH INVARIANTS ← required for Reduction / threadgroup kernels
//!
//! - **TPG = N / 4.** Each thread owns exactly 4 elements.
//! - **TPG must be a multiple of 32.**
//! - **Grid: 1 threadgroup per row.**
use wh-iron::kernel;
// ── 1. Kernel function(s) ────────────────────────────────────────────────────
#[kernel]
pub fn iron_my_kernel<T>(...) { ... }
// ── 2. Correctness tests ─────────────────────────────────────────────────────
pub mod kernel_tests {
use wh-iron::{test::*, test_kernel};
use super::iron_my_kernel;
use crate::utils::{pack_f32, unpack_f32};
fn setup(...) -> TestSetup { ... }
#[test_kernel(dtypes = [f32, f16, bf16], tol = [1e-4, 1e-2, 5e-2])]
fn test_iron_my_kernel(dt: DType) -> TestSetup { setup(..., dt) }
}
// ── 3. Benchmarks ────────────────────────────────────────────────────────────
pub mod kernel_benches {
use wh-iron::{bench, test::*};
use super::iron_my_kernel;
#[bench(dtypes = [f32, f16, bf16])]
fn bench_iron_my_kernel(dt: DType) -> BenchSetup { ... }
}The //! block at the top is the public documentation for the kernel. It must include:
- What the kernel computes (one line).
- Why key algorithmic choices were made (e.g. f32 accumulation for bf16 inputs).
- Layout — the shape and dtype of every buffer, if non-trivial.
- DISPATCH INVARIANTS — for Reduction and threadgroup kernels, the exact TPG / grid constraints, stated as hard requirements. Violations in Reduction kernels silently miscompute (the GPU won't error); this section is the contract the caller must uphold.
| Pattern | Used for |
|---|---|
iron_<op> |
The operation (iron_softmax, iron_copy, iron_rope_banded) |
iron_<op>_<variant> |
A named variant (iron_rms_norm_small) |
iron_<family>_<variant> |
Variant families from variants(...) (iron_hadamard_n64, iron_int4_conv2d) |
Name the operation, not the model or the source. The iron_ prefix marks
an Iron kernel; after it, describe the operation — not a model or a reference
source (kokoro → adain1d/lstm; no mlx/model coupling in the name). The
old mt_ prefix has been retired in favour of iron_; don't reintroduce it.
Every kernel that operates on floating-point data is generic over T:
#[kernel]
pub fn iron_my_kernel<T>(inp: Tensor<T>, out: Tensor<T>, ...) { ... }The convention for multi-type kernels (e.g. float input, u32 index output) is a single T for the primary floating-point type; integer buffers are typed concretely:
pub fn iron_dequant<T>(packed: Tensor<u32>, scales: Tensor<T>, out: Tensor<T>, ...)A Tensor parameter is treated as an output if:
- It is declared
mut:mut out: Tensor<T>— preferred, explicit. - Its name matches the legacy heuristic set (
out,output,result,y).
Prefer mut for new kernels; it is unambiguous and survives refactors.
Scalar values baked in at dispatch time (shapes, bit-widths, thresholds):
pub fn iron_my_kernel<T>(
inp: Tensor<T>,
out: Tensor<T>,
#[constexpr] n: u32,
#[constexpr] eps: f32,
)#[constexpr] parameters are declared in kernel.constexprs and passed as Metal constant T& buffers. They are available as plain variable names inside the kernel body (n, eps).
Use u32 for counts and indices; f32 for floating-point thresholds; bool (rendered as u32) for flags.
Always accumulate in f32, regardless of the input dtype:
// Good
let v = load(inp[idx]).cast::<f32>();
let acc = acc + v * v;
store(out[idx], acc.cast::<T>());
// Bad — bf16 accumulation loses precision across a long reduction
let v = load(inp[idx]);
let acc = acc + v * v;Load inputs with .cast::<f32>() at the load site; cast outputs back to T at the store site.
The kernel's dispatch mode determines how program_id maps to the grid.
| Mode | KernelMode |
Grid | Use for |
|---|---|---|---|
| Elementwise | Grid3D (default) |
[ceil(N/TPG), 1, 1] × [TPG, 1, 1] |
One thread per output element |
| Reduction | Reduction |
[rows, 1, 1] × [TPG, 1, 1] |
One threadgroup per row; threads reduce |
| Tiled 2D | Grid3D |
[N/BN, M/BM, 1] × [TPG, 1, 1] |
Matmul tile geometry |
Elementwise kernels use program_id::<0>() (or program_id(0)) as the flat element index:
let idx = program_id::<0>();
store(out[idx], load(a[idx]) + load(b[idx]));Reduction kernels use tgid_x / tgid_y for the row, and tid for the lane within the threadgroup:
let row = tgid_x;
let lane = tid;Grid3D kernels use program_id::<0>() through program_id::<2>() for each axis, or the tgid_x / tgid_y / tgid_z aliases.
Every kernel doc comment that uses a non-trivial grid shape must state:
/// Grid: Reduction, `[rows, 1, 1]` × `[N, 1, 1]` (one thread per element).
Tests and benches must wire the same geometry. Mismatched geometry is the most common source of silent GPU miscomputes.
Use #[kernel(variants(...))] when the same algorithm is needed at multiple values of a compile-time integer (bit-widths, transform sizes, tile shapes). This eliminates macro_rules! dispatch and lets the compiler constant-fold the variant-specific values.
/// Walsh–Hadamard transform — produces `iron_hadamard_n64`, `_n128`, … `_n1024`.
///
/// Produces kernels: `iron_hadamard_n64`, `iron_hadamard_n128`, `iron_hadamard_n256`,
/// `iron_hadamard_n512`, `iron_hadamard_n1024`.
#[kernel(variants(N = [64, 128, 256, 512, 1024], LOG_N = [6, 7, 8, 9, 10], suffix = "n{N}"))]
pub fn iron_hadamard<T>(inp: Tensor<T>, out: Tensor<T>, #[constexpr] scale: f32) {
threadgroup_alloc("buf", N, "f32"); // N is a literal at compile time
for s in range(0u32, LOG_N, 1u32) { // LOG_N is a literal at compile time
...
}
}Rules:
- The
suffixtemplate uses{PARAM}interpolation:"int{BITS}"→"int4". - Multiple correlated params (like
NandLOG_N) are listed as parallel arrays of the same length. - Generated module names:
{fn_name}_{suffix}— e.g.iron_hadamard_n64. - Use ALL_CAPS, multi-character names for variant params (
BITS,LOG_N, notb,n) to avoid substring collisions in ident-embedding.
#[test_kernel] and #[bench] accept the same variants(...) syntax. Use ident-embedding (intBITS → int4) to reference the generated module:
// Collapses 5 test functions into 1
#[test_kernel(dtypes = [f32, f16, bf16], tol = [1e-4, 1e-2, 1e-1],
variants(BITS = [2, 4, 8], suffix = "int{BITS}"))]
fn test_dequant_gather(dt: DType) -> TestSetup {
setup(dequant_gather_intBITS::kernel_ir_for(dt), BITS, dt)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ident-embedding: BITS → 2/4/8
}
// Collapses 5 bench functions into 1. The bench name is the (variant-renamed)
// function name — `suffix = "int{BITS}"` makes them bench_dequant_gather_int2,
// _int4, _int8. There is no `name` key.
#[bench(dtypes = [f32, f16, bf16], variants(BITS = [2, 4, 8], suffix = "int{BITS}"))]
fn bench_dequant_gather(dt: DType) -> BenchSetup {
gb(dequant_gather_intBITS::kernel_ir_for(dt), BITS, 4096, 64, dt)
}If different variants need different test parameters (e.g. different hidden sizes for pow-2 vs odd bit-widths), use two separate #[test_kernel] functions with different variant lists rather than trying to unify them.
The single biggest LOC-reduction tool. A sub-expression that recurs across many kernels — a weight decode (E2M1 nibble, E8M0 scale, a straddle-aware N-bit unpack), a simdgroup reduction — is factored into its own #[kernel] and called from every kernel that needs it. KernelInlinePass inlines the call at codegen, so there is zero runtime overhead: it is exactly as fast as pasting the body inline, but written once.
// kernels/primitives.rs (or quant/codec for the decode family)
#[kernel]
pub fn iron_decode_e2m1(nib: u32) -> f32 { /* 4-bit E2M1 codebook */ }
#[kernel]
pub fn iron_decode_e8m0(byte: u32) -> f32 { exp2(byte.cast::<f32>() - 127.0f32) }// In a quantized matmul/conv body — call instead of re-deriving:
let nib = (load(weight[w_pack + col / 8u32]) >> ((col % 8u32) * 4u32)) & 0xFu32;
let scale = iron_decode_e8m0(load(scales[w_blk + col / block_size]).cast::<u32>());
acc = acc + pix * (iron_decode_e2m1(nib) * scale);Extract a primitive once it appears in two or more kernels. Keep primitives small and decode/reduce-focused. The host-side CPU oracle should call the same math (e.g. quant::codec) so the kernel and oracle can't drift apart.
The CPU oracle is a plain Rust function that reimplements the kernel's math in f32. It must:
- Round all inputs through the target dtype (
pack_f32→unpack_f32) before computing, so the oracle sees the same precision losses as the GPU. - Match the kernel's accumulation order where precision matters. For reductions, left-fold in
f32. - Be algorithm-independent — don't mirror the kernel's implementation; express the mathematical definition directly. This catches bugs in both.
// Good oracle: mathematical definition, independent of the kernel's butterfly order
for i in 0..n {
let acc: f32 = (0..n)
.map(|j| {
let sign = if (i & j).count_ones() % 2 == 0 { 1.0 } else { -1.0 };
sign * xd[r * n + j]
})
.sum();
expected[r * n + i] = acc * scale;
}fn setup(n: usize, dt: DType) -> TestSetup {
// 1. Build inputs in f32
let x: Vec<f32> = (0..n).map(|i| ...).collect();
// 2. Round through dtype — oracle sees what GPU loads
let xd = unpack_f32(&pack_f32(&x, dt), dt);
// 3. Compute expected in f32
let expected: Vec<f32> = xd.iter().map(|&v| ...).collect();
// 4. Assemble TestSetup
TestSetup::new(iron_my_kernel::kernel_ir_for(dt))
.mode(KernelMode::Reduction) // omit for elementwise (default)
.input(TestBuffer::from_vec("inp", pack_f32(&x, dt), dt))
.input(TestBuffer::zeros("out", n, dt))
.constexpr("n", n as u32)
.expect(TestBuffer::from_vec("out", pack_f32(&expected, dt), dt))
.grid_3d(rows as u32, 1, 1, [tpg as u32, 1, 1]) // matches kernel dispatch
}Key builder methods:
| Method | Notes |
|---|---|
.mode(KernelMode::Reduction) |
Required for Reduction kernels; omit for elementwise |
.input(TestBuffer::from_vec(...)) |
Inputs in declaration order |
.input(TestBuffer::zeros("out", n, dt)) |
Output buffers initialised to zero |
.constexpr("name", value) |
One call per #[constexpr] param |
.expect(TestBuffer::from_vec(...)) |
Only the output buffers need expected values |
.grid_1d(n, tpg) |
Elementwise shorthand: ceil(n/tpg) threadgroups |
.grid_3d(gx, gy, gz, [tpg, 1, 1]) |
Explicit 3D grid |
#[test_kernel(dtypes = [f32, f16, bf16], tol = [1e-4, 1e-2, 5e-2])]
fn test_iron_my_kernel(dt: DType) -> TestSetup { setup(1024, dt) }dtypes— always test all three float dtypes unless the kernel only supports a subset.tol— per-dtype absolute tolerance. A reasonable starting point:f32:1e-4(rounding + online-algorithm drift)f16:1e-2(shorter mantissa; wide if the op has long reductions)bf16:5e-2(only 7 mantissa bits)
- Test name = function name (the inventory key is
test_iron_my_kernel_f32, etc.).
Write at least two tests per kernel:
- Standard shape — representative production size, exercises the main code path.
- Edge-case shape — exercises a specific branch (e.g. large-magnitude inputs to pin the overflow guard, a shape that hits the spill path in a bit-stream read, single-simdgroup dispatch).
Name edge-case tests descriptively: test_iron_softmax_large_values, not test_iron_softmax_2.
#[bench(dtypes = [f32, f16, bf16])]
fn bench_iron_my_kernel(dt: DType) -> BenchSetup {
let n = 64 * 1024 * 1024usize;
BenchSetup::new(iron_my_kernel::kernel_ir_for(dt))
.mode(KernelMode::Reduction) // omit for elementwise
.buffer(BenchBuffer::random("inp", n, dt))
.buffer(BenchBuffer::zeros("out", n, dt).output()) // .output() marks readback
.constexpr("n", n as u32)
.grid_3d(rows as u32, 1, 1, [tpg as u32, 1, 1])
.bytes_moved((2 * n * dt.size_bytes()) as u64) // reads + writes
}#[bench] has no name key — the registered bench name is always the
function name (for a variants(...) bench, the suffix-renamed name). So make the
function name descriptive: bench_iron_softmax, bench_mxfp4_conv2d. Do not encode
a mlx//iron/ path in the name; the family is the folder, not the bench string.
Report the memory bandwidth that the kernel's working set consumes. For elementwise ops: (reads + writes) * n * sizeof(T). For reductions: include both the input stream and the (typically smaller) output. For matmuls: include packed weights, scales/biases, input, and output.
Do not count the small #[constexpr] constant buffers.
A bench may carry an optional metal reference — a hand-written .metal
comparator for side-by-side throughput + correctness. It is not tied to mlx/:
any kernel can have one, and most have none. Add it with .with_reference(...):
.with_reference(
RefKernel::new(
format!("looped_softmax_{tn}"), // reference kernel function name
include_str!(concat!(env!("OUT_DIR"), "/metal/softmax.metal")),
)
.buffer(BenchBuffer::zeros("inp", n, dt)) // reference buffer order may differ
.buffer(BenchBuffer::zeros("out", n, dt).output())
.buffer(BenchBuffer::from_vec("axis_size", (n as u32).to_le_bytes().to_vec(), DType::U32))
.grid(Grid::new_3d(rows as u32, 1, 1, [1024, 1, 1]))
.tol(dtype_tol(dt).max(1e-4)),
)Buffers shared by name (e.g. "inp") are filled with the same random data as the kernel — the runner overwrites the placeholder. Buffers unique to the reference (like "axis_size") are provided as separate from_vec entries.
After creating the file, declare it in its family mod.rs:
// crates/wh-iron-std/src/kernels/<family>/mod.rs
pub mod my_kernel;The #[kernel] / #[bench] / #[test_kernel] proc-macros automatically submit the kernel and its tests/benches to the global inventory via inventory::submit!. No manual registration is needed beyond the mod declaration.
| Identifier | Type | Description |
|---|---|---|
tid |
u32 |
Thread index within the threadgroup (thread_position_in_threadgroup.x) |
tgid_x / tgid_y / tgid_z |
u32 |
Threadgroup index per axis |
lsize |
u32 |
Threadgroup size (threads per group) |
program_id::<N>() |
u32 |
Global thread index on axis N (elementwise) |
program_id(N) |
u32 |
Same, non-const-generic form |
// Arithmetic
let y = sqrt(x); exp(x); log(x); exp2(x); log2(x);
let y = sin(x); cos(x); atan2(y, x);
let y = abs(x); recip(x); pow(a, b);
// Reductions (simdgroup-wide)
let m = simd_sum(v); simd_max(v); simd_min(v);
// Reductions (threadgroup-wide, Reduction mode only)
let m = reduce_max(local_max); reduce_sum(local_sum);
// Control
let v = select(cond, a, b); // branchless ternary
let v = max(a, b); min(a, b);
// Threadgroup memory
threadgroup_alloc("name", SIZE); // allocate SIZE f32 slots
threadgroup_alloc("name", SIZE, "u32"); // allocate SIZE u32 slots
threadgroup_store("name", idx, val);
let v = threadgroup_load("name", idx);
threadgroup_barrier(); // synchronise all threads
// Atomic (on threadgroup memory)
atomic_or_tg("name", idx, mask);
// Casts
let f = val.cast::<f32>();
let t = acc.cast::<T>();// Counted loop — use range()
for i in range(start, end, step) { ... }
// Conditional
if cond { ... }
if cond { ... } else { ... }These patterns are silently dropped by the body parser — the kernel compiles but produces wrong output:
whileloops — rewrite as aforwithrange, or use explicitif-blocks.returnstatements — useif/elsebranching instead.macro_rules!invocations inside a#[kernel]body — inline all code; the proc-macro runs before declarative macros expand.
Additional constraints:
threadgroup_allocis hoisted to function scope — allocate names once, unconditionally. Never allocate the same name in two branches; the later allocation is a no-op and the first is used for both.- No stack-allocated arrays — use threadgroup memory for per-thread scratch that needs to survive a barrier.
- No
let mutcaptured across loop iterations — declarelet mut acc = 0.0f32before the loop, update inside.
A complete, minimal kernel that multiplies every element of a tensor by a scalar constant. This is the "hello world" for the DSL.
//! Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom)
//! SPDX-License-Identifier: Apache-2.0
//! Elementwise scale — multiplies every input element by a scalar `alpha`.
use wh-iron::kernel;
/// Multiply each element of `inp` by `alpha`, writing to `out`.
///
/// Grid: Elementwise, `[ceil(n/256), 1, 1]` × `[256, 1, 1]`.
#[kernel]
pub fn iron_scale<T>(inp: Tensor<T>, mut out: Tensor<T>, #[constexpr] alpha: f32) {
let idx = program_id::<0>();
store(out[idx], (load(inp[idx]).cast::<f32>() * alpha).cast::<T>());
}
/// Correctness: GPU output must match `alpha * x` within dtype precision.
pub mod kernel_tests {
use wh-iron::{test::*, test_kernel};
use super::iron_scale;
use crate::utils::{pack_f32, unpack_f32};
fn setup(n: usize, alpha: f32, dt: DType) -> TestSetup {
let x: Vec<f32> = (0..n).map(|i| (i % 17) as f32 * 0.1 - 0.8).collect();
let xd = unpack_f32(&pack_f32(&x, dt), dt);
let expected: Vec<f32> = xd.iter().map(|&v| v * alpha).collect();
TestSetup::new(iron_scale::kernel_ir_for(dt))
.input(TestBuffer::from_vec("inp", pack_f32(&x, dt), dt))
.input(TestBuffer::zeros("out", n, dt))
.constexpr("alpha", alpha)
.expect(TestBuffer::from_vec("out", pack_f32(&expected, dt), dt))
.grid_1d(n, 256)
}
#[test_kernel(dtypes = [f32, f16, bf16], tol = [1e-6, 1e-3, 1e-3])]
fn test_iron_scale(dt: DType) -> TestSetup { setup(1024, 2.5, dt) }
// alpha = 0 collapses every output to zero — pins the zero-multiply path.
#[test_kernel(dtypes = [f32], tol = [1e-6])]
fn test_iron_scale_zero_alpha(dt: DType) -> TestSetup { setup(256, 0.0, dt) }
}
/// Benchmark: 64M-element scale, reads + writes one stream each.
pub mod kernel_benches {
use wh-iron::{bench, test::*};
use super::iron_scale;
#[bench(dtypes = [f32, f16, bf16])]
fn bench_iron_scale(dt: DType) -> BenchSetup {
let n = 64 * 1024 * 1024usize;
BenchSetup::new(iron_scale::kernel_ir_for(dt))
.buffer(BenchBuffer::random("inp", n, dt))
.buffer(BenchBuffer::zeros("out", n, dt).output())
.constexpr("alpha", 2.5f32)
.grid_1d(n, 256)
.bytes_moved((2 * n * dt.size_bytes()) as u64)
}
}After creating the file, add one line to its family mod.rs (e.g.
src/kernels/ops/mod.rs):
pub mod scale;Run cargo test -p wh-iron-std to confirm the tests register and pass.