Autotuning¶
Tile configurations perform differently as problem sizes change.
metile.autotune compiles and times a candidate list, then caches its
selection. The cache includes the device, toolchain, kernel and compiler
sources, candidate list, launch grid, operand type, dtype, shape, strides and
relevant compile-policy overrides. Matching the explicit tuning key alone
isn’t enough to reuse a cached result.
The general autotuner only measures latency; it doesn’t check outputs against a reference. Validate every configuration before tuning, especially if precision or reduction order changes. The optional MLX backends have their own numerical checks.
Basic Usage¶
import metile
from metile_kernels.gemm import matmul
autotuned_matmul = metile.autotune(
configs=[
metile.Config(BLOCK_M=64, BLOCK_N=64, BLOCK_K=32, WM=2, WN=2),
metile.Config(BLOCK_M=128, BLOCK_N=128, BLOCK_K=64, WM=4, WN=4),
metile.Config(BLOCK_M=128, BLOCK_N=128, BLOCK_K=128, WM=4, WN=4),
],
key=["M", "N", "K"],
)(matmul)
configsA list of
metile.Configobjects. Each config is a set of constexpr values to try.keyArgument names that identify the workload. A new key value triggers tuning unless a matching cached selection exists.
Launching¶
When tile sizes vary, provide a callable to compute the grid for each configuration.
Here M, N, and K are the matrix dimensions, and A, B, and
C are their input and output buffers:
grid = lambda cfg, M=M, N=N: (
metile.cdiv(M, cfg["BLOCK_M"]),
metile.cdiv(N, cfg["BLOCK_N"]),
)
autotuned_matmul[grid](A, B, C, M, N, K)
On the first call with new key values, the autotuner:
Attempts to compile every configuration, recording failures
Benchmarks candidates in rotated, alternating round-robin order, recording both GPU timestamps and synchronized launch-to-completion latency
Re-benchmarks up to eight candidates within 8% of the provisional winner in a 30-round rotating finalist tournament
Selects the lowest measured latency, allowing compressed generated-code size to break a tie within 0.25%
Caches the result and measured latency with the device and toolchain identity
Dispatches with the winning config
Later calls with the same key values and compilation contract use the cached winner without re-tuning.
For kernels measured at one millisecond or less, selection uses synchronized end-to-end latency, including command encoding and completion handling. These take a significant share of that time. Longer throughput kernels use GPU timestamps to avoid host-side scheduling noise. The winner’s raw GPU latency is saved separately and determines its prepared-dispatch completion policy.
The cache defaults to ~/Library/Caches/metile on macOS. Set
METILE_CACHE_DIR to relocate it, or METILE_DISABLE_DISK_CACHE=1 to disable
persistent autotune choices while debugging. The following timings illustrate
cache behavior; they are not benchmark results:
First call (M=1024, N=1024, K=1024):
+--------------------------------------------------+
| Config(BM=64, BN=64, BK=32): 1.26ms |
| Config(BM=128, BN=128, BK=64): 0.62ms <-- | winner cached
| Config(BM=128, BN=128, BK=128): 0.91ms |
+--------------------------------------------------+
Subsequent calls (same M, N, K):
+--------------------------------------------------+
| cached -> Config(BM=128, BN=128, BK=64) | no re-tuning
+--------------------------------------------------+
Config Object¶
cfg = metile.Config(
BLOCK_M=128,
BLOCK_N=128,
BLOCK_K=64,
WM=4,
WN=4,
K_UNROLL=1,
SWIZZLE="hilbert",
)
Keyword arguments become kernel constexprs. Parameters outside the kernel’s
signature are stored in func.constexprs for the compiler; for example,
WM and WN control the tensor_ops SIMDgroup layout.
The reserved num_simdgroups constructor argument defaults to None
(automatic). Setting it adds a checked NUM_SG requirement. Caller launch
overrides take precedence over candidate values for both compilation and grid
evaluation. Configurations can include SCHEDULE=metile.Schedule(...);
see Execution Schedules and Fusion.
Schedules can be searched alongside tile shapes with SWIZZLE="linear",
"grouped2", "grouped4", "grouped8", "diagonal",
"morton", "hilbert", or "auto".
SCHEDULE_ENCODING="arithmetic" or "bitwise" exposes equivalent decoder
representations as ordinary autotune candidates; the default "auto" uses the
compiler’s target-cost and MDL extractor.
On the aligned M5 NAX path, NAX_OUTER_K controls the reduction epoch and
NAX_K_UNROLL=2 preloads two 16-wide K fragments before issuing their native MMAs.
Tune these parameters for the target shape: increasing the amount of live data
can outweigh the benefit of fewer loads or fences.
NAX_SKIP_FIRST_EPOCH_BARRIER retains every inter-epoch scheduling fence but skips
the redundant fence before the first epoch. It is searched as a separate representation
because the uniform predicate helps medium reductions but the unconditional form can
remain faster for long reductions.
NAX_TRAILING_EPOCH_BARRIER moves the same inter-epoch fence to the end of each
non-final epoch. The tuner measures this placement as another candidate.
The block-scaled runtime also measures a paired reduction representation that reuses
one E8M0 scale load across the two 16-wide steps in each 32-value quantization group.
It executes the decoded weight fragments sequentially, avoiding the register-pressure
cost of dense-style fragment preloading. Small aligned shapes additionally search a
32x64 two-SIMDgroup tile, which provides finer occupancy than the conventional
four-SIMDgroup 64x64 tile on the base M5.
Schedule Algebra and MDL¶
Schedule selection runs as a Metal IR pass rather than choosing whole-kernel
templates. It represents each traversal as a finite permutation of the launch
grid. Starting from a small set of reflections and axis exchanges, it derives
the exact shape-preserving action: D4 for interchangeable square grid and
tile axes, D2 for ordinary rectangles or anisotropic square tiles, C2
for degenerate one-axis grids, and the trivial group for a single tile.
It verifies that action by constructing orbits and stabilizers, canonicalizes
the traversals and searches one representative per orbit.
Each static traversal lowers to a scalar schedule-expression program. Exact rewrites can replace power-of-two multiplication, division and remainder with shifts and masks. The extractor first minimizes a Metal operation-cost model, then breaks ties deterministically using the DEFLATE-compressed canonical expression encoding as a minimum-description-length metric. Code generation uses the selected expression tree directly; adding a decoder representation doesn’t require another whole-kernel template.
For cross-kernel autotuning, meTile uses DEFLATE-compressed generated MSL length as a reproducible description-length metric. It is an approximation, not an evaluation of Kolmogorov complexity. Measured latency is primary: MDL can only select a smaller representation within 0.25% of the fastest result.
The same approach extends beyond GEMM traversal. The FFT candidate family uses
one kernel written with ordinary eDSL operations. It searches threadgroup
width, the number of register-local radix-2 stages, bit-reversed gather versus
shared scatter, and global versus threadgroup twiddle placement. Native
reverse_bits lowering makes the permutation decoder branch-free without
a host-generated index table.
Decode attention applies the same policy across algorithms. Short and highly parallel shapes search one online-softmax threadgroup per head. Long contexts additionally search a multi-threadgroup partial pass followed by an online merge pass. Both kernels remain ordinary eDSL programs, and the persisted winner is keyed by head grid, context length, head dimension, device, toolchain, source, and candidate family.
The optional MLX backend also includes native MLX as a candidate. Attention and RMSNorm require 5% headroom before selecting generated Metal; other families have their own margins. A custom primitive can change graph scheduling even when isolated timings are close. See MLX-LM Backend for those policies.
Verbose Output¶
With verbose=True (the default), the autotuner prints results in this form.
The values here are illustrative:
autotune matmul [M=1024, N=1024, K=1024]: Config(BLOCK_M=128, BLOCK_N=128, BLOCK_K=64, ...)
Config(BLOCK_M=64, BLOCK_N=64, BLOCK_K=32, ...): 1.26ms
Config(BLOCK_M=128, BLOCK_N=128, BLOCK_K=64, ...): 0.62ms <--
Config(BLOCK_M=128, BLOCK_N=128, BLOCK_K=128, ...): 0.91ms
The <-- indicates which configuration was selected.
If a config fails (e.g., exceeds threadgroup memory limits), its failure reason is printed:
Config(...): FAILED (LoweringError: GEMM requires 49152 bytes threadgroup memory ...)
Tuning Parameters¶
metile.autotune(
configs=[...],
key=["M", "N", "K"],
warmup=5,
rep=20,
verbose=True,
)
These are the defaults: five warmup rounds and twenty timed rounds per configuration, followed by finalist remeasurement when needed.
Prepared Dispatch¶
For repeated inference, use .prepare() to autotune once and bind a
dispatcher. Later calls skip tracing, lowering, compilation and argument
conversion:
from metile.runtime.metal_device import MetalDevice
dispatch = autotuned_matmul[grid].prepare(A, B, C, M, N, K)
dispatch.repeat(1000)
MetalDevice.get().sync()
repeat encodes repeated work under one runtime lock. Compatible calls batch
until sync(), numpy(), or an ordinary launch flushes them. Each repetition
uses the same bound buffers; account for any in-place updates in the kernel.
Prepared GEMMs use an ordered encoder. Independent element-wise kernels can
use a concurrent encoder; the runtime tracks input/output buffer hazards and
inserts Metal buffer barriers between dependent dispatches. Multi-buffer
kernels use one cached setBuffers:offsets:withRange: call instead of
repeated Objective-C bindings.
Compatible dispatches reuse unchanged pipeline and buffer state within the
shared encoder. repeat(count) also avoids repeated Python lock transitions
when encoding the same operation many times. The runtime checks support for
optional selectors and keeps bound buffers alive until completion.
The autotuner stores the selected kernel’s measured GPU latency with its device-
and toolchain-specific configuration. Prepared kernels measured at one millisecond or
less receive a completion-poll budget derived from that latency: three times the GPU
time plus 300 microseconds, bounded between 900 and 1500 microseconds. Longer kernels
keep the blocking waitUntilCompleted path. A command buffer containing an
unclassified dispatch also blocks, and batches beyond eight dispatches never spin.
This measured policy covers short reductions and rectangular GEMMs without relying on
a square-shape heuristic. Directly configured small static GEMMs retain a conservative
900-microsecond fallback. Set METILE_LOW_LATENCY_SPIN_US=0 to disable active
waiting, or provide a non-negative microsecond cap for application-specific
latency/power tradeoffs; the default cap is 1500 microseconds.