all_lessons/ai_compilers / lessons/10 · autotuninglesson 11 / 20

Part IV — Back end

Autotuning & cost models

Lesson 09 handed you a code generator that will faithfully emit any schedule you point it at — pick a tile shape, a num_warps, a num_stages, and out comes a working Triton or PTX kernel. But it answered how to emit, not what to emit. The schedule space it can target is combinatorial, and the fastest point in it is different for every shape and every chip. Hand-picking does not scale past a handful of kernels. This lesson is the missing decision procedure: define a search space, then search it — either by benchmarking real candidates on the device, or by predicting their latency with a learned cost model so you can prune the space before you ever launch a kernel.

The previous step left this broken
Codegen (lesson 09) can lower one schedule to one kernel, but it has no opinion about which schedule. The knobs are real and they interact: a matmul kernel alone has tile sizes (BLOCK_M, BLOCK_N, BLOCK_K), num_warps (1, 2, 4, 8), num_stages (the software-pipelining depth, 1–5), loop unroll factors, and the layout choices from lesson 07. Even a modest grid — say 6 × 6 × 4 tile choices × 4 warp counts × 4 stage counts ≈ 2,300 configurations — is far too many to compile and time by hand, and the winner for a (4096, 4096, 4096) GEMM on an H100 is a different point than the winner for a (8, 4096, 64) decode GEMM on an A10. The scheduling lesson told you the schedule sets speed by 10–100×; codegen can hit any of them; nobody has picked the right one.
Linear position
Forced by: a huge, combinatorial, shape- and hardware-dependent schedule space that no human picks per kernel.
New idea: autotuning — define a search space of legal schedules, then either benchmark candidates on the device (Triton @triton.autotune over a config list; AutoTVM templates) or predict their latency with a learned cost model to prune (Ansor / auto-scheduler, the XLA latency model), keeping the best. The core trade is compile-time search cost vs run-time speedup — and it pays because the same kernel runs millions of times, so you cache the winner under a key.
Forces next: every pass so far — fusion, planning, layout, and now tuning — quietly assumed static shapes to search against. The autotune cache is keyed on shapes; production inference has dynamic shapes, which shatters the key and forces lesson 11.
The plan
Five moves. (1) Size the space and see why enumeration is hopeless. (2) Templates + tuning — AutoTVM: a human writes the template, the machine fills the knobs. (3) Auto-schedulers (Ansor) that generate the space too, plus learned cost models that predict latency from features so you don't have to measure every candidate. (4) Triton's pragmatic autotune over a config list, cached by key. (5) The economics — pay search once, amortize over a million runs; AOT vs JIT; and the cache-key crack that becomes lesson 11. Then drive the widget until a cost model finds near-optimum with a tenth of the measurements.

1 · The space is combinatorial — you cannot enumerate it

A search space is the set of legal schedules the back end could emit for one operator on one target. Start with a single tiled matmul. The schedule knobs from lesson 08, made concrete for a GPU kernel:

tile shape
(BLOCK_M, BLOCK_N, BLOCK_K) — how much of the output one thread block computes and how deep the K-loop reduction tile is. Sets register/SRAM pressure and reuse.
num_warps
Threads per block (warps × 32). Trades occupancy against per-thread registers — typically 1, 2, 4, or 8.
num_stages
Software-pipelining depth: how many K-tiles are prefetched into SRAM ahead of compute to hide HBM latency. Usually 1–5.
unroll / layout
Loop unroll factor (ILP vs code size) and the operand layouts from lesson 07 (tensor-core fragment eligibility).

Multiply it out. Six choices of BLOCK_M × six of BLOCK_N × four of BLOCK_K × four num_warps × four num_stages is 6 · 6 · 4 · 4 · 4 = 2,304 configurations for one operator on one shape. Add unroll and layout and it is tens of thousands. A real model has dozens of distinct GEMMs, convolutions, and fused reductions, each with several shapes seen in practice. Benchmarking one config means: generate the code, compile it (often hundreds of milliseconds to seconds), launch it many times to get a stable median, and clean up. Call that ≈0.5 s amortized per candidate. Brute-forcing 2,304 candidates is ≈19 minutes for a single kernel/shape pair. For a whole model that is days. And the space is not smooth: latency as a function of tile size has cliffs (a tile one element too big spills registers to local memory and collapses; one too small underutilizes the tensor cores), so you cannot just hill-climb naively from a guess. This is the wall: codegen can emit any point, but the right point is buried in a jagged, high-dimensional space.

2 · Templates + tuning — AutoTVM, the human writes the shape of the search

AutoTVM is the first disciplined answer: split the work between human and machine. A human writes a schedule template for an operator class — the loop structure, where the tiling happens, which axes get split — but leaves the actual values as tunable knobs (TVM calls them cfg.define_split, cfg.define_knob). The template fixes the structure of the search space; the autotuner fills the values.

human template (TVM):              machine fills the knobs:
  schedule conv2d:                   tile_f  ∈ {1,2,4,8,16,32}
    split output by tile_f           tile_y  ∈ {1,2,4,7}
    split spatial by tile_y, tile_x  tile_x  ∈ {1,2,4,7}
    reorder, vectorize, unroll       num_threads, unroll ∈ {...}
    cache_read to shared             →  search picks one assignment

The tuner then explores the value space guided by a cost model trained on the fly: it measures a batch of configs on the device, fits a model (XGBoost over hand-engineered schedule features in classic AutoTVM) to predict latency, uses the model to propose promising next configs (simulated annealing over the predicted surface), measures those, and repeats. The win is that the human supplies the inductive bias — the template rules out illegal or absurd schedules up front — so the machine searches a far smaller, all-legal space. The cost: someone has to write and maintain a template per operator class, and a template that is too rigid hides the global optimum. That maintenance burden is exactly what the next idea removes.

3 · Auto-schedulers + learned cost models — generate the space, predict the latency

Ansor (TVM's auto-scheduler, also called Auto-scheduler) drops the human template entirely. From the operator's mathematical definition it generates the search space itself — it derives a hierarchy of possible loop structures (sketches) and then samples concrete schedules (annotations) within them. This widens coverage dramatically: Ansor routinely finds schedules a human template author never wrote, often beating hand-tuned libraries.

But generating a bigger space makes the measurement problem worse, not better — now there are even more candidates. The lever that makes it tractable is the cost model: a learned function that predicts a schedule's latency from cheap-to-extract features — tile sizes, estimated bytes moved, arithmetic intensity, vectorization width, occupancy proxies — without launching the kernel. A prediction costs microseconds; a real benchmark costs ~0.5 s. So the loop becomes: generate thousands of candidates, score all of them with the cost model in milliseconds, then measure only the top few dozen on the device to correct the model's errors and feed those true latencies back as training data. You measure tens of kernels instead of thousands and still land near the optimum.

generateAnsor derives sketches from the op definition and samples concrete schedules — thousands of candidates, no human template.
predictThe learned cost model scores every candidate's latency from features in microseconds each — no device launch. Prune to the promising top-k.
measureBenchmark only the top-k on the real device. These few true latencies are the ground truth.
retrainFeed the measured latencies back; the cost model gets sharper each round, so later predictions need fewer corrective measurements.

The same idea lives inside production compilers under different names. XLA uses an analytical/learned latency-estimating cost model to drive fusion and layout decisions and to rank autotuned GEMM/convolution algorithms, so most of its choices are made by prediction with a thin layer of real autotuning on top for the hot kernels. The frontier — "ML for ML compilers" — is making these cost models good enough that on-device measurement becomes the exception, because measurement is the only slow part of the loop.

strategywho builds the spacehow candidates are rankeddevice measurementscost / risk
brute forcefixed gridmeasure allthousandsfinds the true best; absurdly slow
AutoTVM (template + tune)human templateon-the-fly model + anneal, measuredhundredsgood; template authoring & maintenance
Ansor (auto-scheduler)generated from op deflearned cost model prunes, then measure top-ktenswide coverage; long offline search
XLA latency modelcompiler-internalpredicted, sparse autotuning on hot kernelsfewfast compile; model error can mispick
Triton @autotunehuman config listmeasure each listed config, cached= list lengthpragmatic; only as good as the list

4 · Triton's pragmatic autotune — a config list, measured once, cached by key

The most common autotuner an ML engineer actually touches is the simplest one on the table. Triton's @triton.autotune takes you at your word: you hand it a short list of autotune configs (each a tuple of meta-parameters — block sizes, num_warps, num_stages), and on the first call for a given input it benchmarks every config in the list and remembers the winner. There is no generated space and no learned model — just a curated handful of points you believe span the good region, measured for real.

@triton.autotune(
    configs=[
        triton.Config({'BLOCK_M': 128, 'BLOCK_N': 256, 'BLOCK_K': 64}, num_warps=8, num_stages=3),
        triton.Config({'BLOCK_M': 64,  'BLOCK_N': 256, 'BLOCK_K': 32}, num_warps=4, num_stages=4),
        triton.Config({'BLOCK_M': 128, 'BLOCK_N': 64,  'BLOCK_K': 32}, num_warps=4, num_stages=5),
        triton.Config({'BLOCK_M': 64,  'BLOCK_N': 64,  'BLOCK_K': 32}, num_warps=2, num_stages=2),
    ],
    key=['M', 'N', 'K'],          # re-tune only when these change; otherwise reuse the cached winner
)
@triton.jit
def matmul_kernel(A, B, C, M, N, K, BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr):
    ...

The decisive line is key=['M', 'N', 'K']. The cache key is the set of properties that, if unchanged, mean the previously-found winner is still valid: here the problem dimensions (and implicitly the dtypes and device). On the first call for a new (M, N, K) Triton pays the full benchmark sweep — the compile-time cost — and stores the best config; every subsequent call with the same key skips straight to the cached kernel for free. Pick the key too narrow (omit dtype) and you serve a kernel tuned for the wrong precision; pick it too wide (include the exact pointer values) and you re-tune on every call and never amortize. The key is the amortization contract — and, as section 5 shows, it is also the exact place dynamic shapes break.

For the full mechanics of building a config list, sizing the sweep, and wiring autotune into a real kernel pipeline, see triton_kernels · 13 the autotune pipeline; this lesson is the why, that one is the how.

5 · The economics — pay once, amortize a million times

Autotuning only makes sense because of a property unique to ML workloads: the same kernel on the same shapes runs astronomically often. A served model executes its core GEMMs on every token of every request, billions of times over the deployment's life; a training run does millions of identical steps. So the trade is starkly asymmetric. Let search cost Tsearch seconds once, and let the tuned kernel save Δt per invocation over an untuned default across N runs. Amortization says you win whenever:

N · Δt > Tsearch

Plug in numbers. Suppose tuning a kernel costs Tsearch ≈ 60 s and the winner is Δt ≈ 50 µs faster per call. Break-even is N = 60 / 50×10⁻⁶ = 1.2 million calls — which a served model crosses in minutes. After that, every call is pure profit. This is why heavy compile-time search is rational here and would be insane for, say, a script you run once.

The break-even math also dictates when you tune:

AOT / offline tuning
Search ahead of deployment, ship a database of winning configs (TVM's tuning log, XLA's autotune cache). Compile time is free at serve time; needs the shapes known in advance.
JIT / online tuning
Tune on first encounter at run time (Triton @autotune, torch.compile). Zero offline step, but the first call for each new key pays a latency spike — the cold-start.

Either way the winner is written to a cache keyed by shape, dtype, and device, so the cost is paid once per distinct key. And there is the crack. The entire economic argument rests on the same key recurring millions of times. That holds for training (fixed batch, fixed sequence length, fixed shapes every step) and for static-shape inference. It does not hold when the batch size, the sequence length, or a ragged input varies request to request: every new shape is a fresh key, a cache miss, and a cold-start re-tune. A serving system that sees a hundred distinct sequence lengths gets a hundred compile storms and amortizes nothing. The cache key that made autotuning pay is exactly what dynamic shapes shatter — which is the subject of the next lesson.

6 · Drive it: search strategies on a hidden latency surface

The widget below is a 2-D slice of the schedule space: tile_m × tile_n, each a grid of configs, with a hidden "true" latency surface (a smooth basin plus a jagged cliff where tiles spill registers, marked in red). The ★ is the true optimum. Pick a strategy and a measurement budget. Exhaustive measures every cell — it always finds the ★ but spends the whole budget. Random-k samples cells blindly. Cost-model-guided scores every cell with a cheap (noisy) predictor first, then spends its budget measuring only the predicted-best cells — watch it land near the ★ with a fraction of the measurements. The KPIs make the trade explicit: how many real measurements you spent, the best latency you found, the search time, and what percent of the true optimum that is.

Autotune search: measure vs predict
Same hidden latency surface, three strategies. The knob that proves it: drop the budget low and compare strategies — exhaustive can't finish, random-k gambles, but cost-model-guided still finds ≥95% of optimum because it spends its few measurements where the predictor says to look. Search time ∝ measurements (each ≈0.5 s); predictions are nearly free.
candidates measured
best latency found
search time
% of optimum
fastslow ★ true optimum □ measured ▢ best found

The shape of the result is the lesson. Exhaustive is a flat line at 100% of optimum and maximum search time — correct and unaffordable. Random-k's quality rises with budget but jumps around. Cost-model-guided beats random at every budget and nearly matches exhaustive's quality at a tenth of its measurements: search buys speed; the cost model buys cheaper search.

Failure modes & checklist

Failure modes

  • Re-tuning on every call. A cache key that includes something that changes constantly (a pointer, a batch dim that varies) so the cache never hits. Signal: steady-state latency dominated by compile time; profiler shows the autotuner running mid-serving, not once.
  • Cache key too narrow. Keying on shape but not dtype/device, so an fp16 call reuses a bf16-tuned kernel — wrong or slow. Signal: a config that benchmarked great on one path is mysteriously bad on another that "looks the same."
  • Trusting the cost model blindly. Shipping the model's top pick without measuring it; the predictor mis-ranks the jagged cliff region. Signal: the "tuned" kernel is slower than a default — the model never saw a real measurement near that point.
  • Tuning the wrong shapes. Offline-tuning shapes that don't match production traffic. Signal: great benchmark numbers, no real-world speedup; the served shapes all miss the cache.
  • A config list that misses the good region. Triton autotune is only as good as the list; four mediocre configs find a mediocre winner. Signal: the "best" config is far from a reference library's throughput.

Checklist

  • Define the space, then the strategy. Know your knobs (tiles, warps, stages, layout) before choosing measure-all vs predict-and-prune.
  • Key the cache correctly. Include shape, dtype, and device — nothing that varies per call. The key is the amortization contract.
  • Always measure the cost model's picks. Predict to prune, measure to decide; never ship an unmeasured winner.
  • Do the amortization math. Tune only when N · Δt > Tsearch — heavy search for hot kernels, defaults for cold ones.
  • Prefer AOT for known shapes. Build a tuning log offline so serve-time compile is free; reserve JIT for genuinely unpredictable shapes.
  • Watch the cold-start. Warm the cache before traffic, or the first request of each new key eats a tuning spike.

Checkpoint

Try it
Open the widget, set the budget to 8, and run each strategy. Note the "% of optimum" and "search time" for random-k vs cost-model-guided at the same budget — the cost model should be markedly closer to 100% for the same eight measurements. Now compute the amortization break-even by hand for a kernel where exhaustive took the full 64 measurements at ≈0.5 s each (Tsearch ≈ 32 s) and the tuned kernel saves Δt ≈ 40 µs per call: break-even is N = 32 / 40×10⁻⁶ = 800,000 calls. Then ask the bridge question: if production sends 50 distinct sequence lengths and each is a separate cache key, how many of those 32-second searches do you pay, and how many of the per-key call counts are large enough to clear 800,000? (That mismatch is lesson 11.)

Where this points next

You now have the full back end: lesson 08 chose a schedule, lesson 09 emitted it as code, and lesson 10 searched the schedule space — cheaply, via cost models — and cached the winner so the search amortizes over millions of identical runs. The whole pipeline is finally complete for a fixed problem. But notice the load-bearing word in that sentence: fixed. Fusion grouped a graph of known buffer sizes; memory planning packed lifetimes of known extents; layout assignment optimized concrete tensors; and autotuning just keyed its entire cache on the shape. Every middle-end and back-end pass quietly assumed static shapes. Production inference does not oblige: batch size varies request to request, sequence length grows every decode step as the KV cache extends, MoE routing produces ragged tensors, and data-dependent control flow changes the graph itself. Each new shape is a fresh cache key, a cache miss, and a fresh compile — a compile storm that amortizes nothing. The assumption that made the whole stack tractable is the one reality breaks first. That is lesson 11, Dynamic shapes & control flow.

Takeaway
Codegen can emit any schedule, but the fastest one lives in a combinatorial, shape- and hardware-dependent space — a single matmul has thousands of tile×warp×stage×layout configs, with latency cliffs that defeat hand-tuning. Autotuning defines a search space and finds the best point two ways: measure real candidates on device (AutoTVM fills a human template's knobs; Triton @autotune benchmarks a config list) or predict with a learned cost model that scores candidates from features in microseconds so you measure only the top-k (Ansor generates the space too; the XLA latency model drives fusion/layout). The core trade is compile-time search vs run-time speedup, and it pays because of amortization — the same kernel runs millions of times, so you win whenever N · Δt > Tsearch (break-even in minutes for a served model), and you tune AOT offline or JIT on first use, writing the winner to a cache keyed by shape, dtype, and device. That cache key is the amortization contract — and the exact thing dynamic shapes will shatter, because every new shape is a fresh key, a miss, and a cold-start re-tune.

Interview prompts