Skip to content

feat(core): shape-bucketed kernel autotuner and cold-L2 benchmarks - #965

Merged
inureyes merged 3 commits into
mainfrom
feature/issue-906-shape-bucketed-autotuner
Jul 30, 2026
Merged

inureyes merged 3 commits into
mainfrom
feature/issue-906-shape-bucketed-autotuner

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

Adds mlxcel-core::autotune, a shape-bucketed kernel autotuner whose durable contribution is the cache key and its invalidation rules rather than the profiling loop, plus mlxcel-core::bench_rotation for cold-last-level-cache microbenchmarking. The v1 paged-attention decode NumSplits launch shape becomes the first consumer, validated on Apple Silicon; the two CUDA kernel knobs named in the issue are wired but explicitly unvalidated.

Design

Cache keys are (op, kernel/runner identity, power-of-two shape bucket, dtype), so nearby shapes share one entry and the tuning matrix stays bounded. Every entry carries the mlxcel version and the pinned MLX commit it was measured under, and a mismatch in either discards it instead of applying a stale config. The store deliberately mirrors PolicyStore in src/server/batch/mtp_policy.rs: directory from mlxcel_core::cache_root(), one JSON file per key hash, atomic write-plus-rename, a load path that validates the version field and every key field, a silent no-op when no cache root resolves, and a #[cfg(test)] explicit-dir constructor for injection. A corrupt or unreadable file warns and is ignored, never panics or propagates. An out-of-bucket lookup (saturated bucket, or an op with no candidates at that shape) warns once and returns the op's default.

Selection is min-latency over a median-of-5 timing per candidate, each repetition individually bracketed by an eval and a backend sync. A candidate must beat the op's own default by more than 2% to be selected, so a noisy host converges back to today's behavior rather than to a coin flip. Candidates that fail to launch are dropped from the sweep rather than aborting it, because a launch configuration can legitimately be infeasible on a given device.

Precedence, highest first: an explicitly-set env var for that knob, a valid cached tactic, a tactic profiled now (only under MLXCEL_AUTOTUNE=1), then the op's default, which is exactly the pre-change behavior.

Issue task items

1. Autotuner module. TunableOp (candidates per bucket, default tactic, env override, run, sync), the median-of-N profiling harness with real MLX synchronization, and the persistent JSON cache with metadata-mismatch invalidation and out-of-bucket default fallback. Files: src/lib/mlxcel-core/src/autotune/{mod,tactic,bucket,profile,store}.rs.

2. First consumers. The v1 paged-decode NumSplits op is the Metal-tunable one and is wired end to end (src/lib/mlxcel-core/src/autotune/ops/paged_decode_splits.rs, consumed in PagedBlockPool::paged_decode_fused). paged_attention_decode gains a num_splits_override parameter through the cxx bridge and paged_attention_num_splits_cap exposes the budget ceiling, so the tuner enumerates candidates from the launcher's own arithmetic instead of duplicating the 28672 / (dim * 4) budget. An override of 0 reproduces the pre-change launch exactly and out-of-range values clamp back to the ceiling, so a stale cached tactic can never produce an infeasible launch. The qmm CTA tile_m and multirow-qmv row-window ops are wired in src/lib/mlxcel-core/src/autotune/ops/cuda_kernel_knobs.rs and are UNVALIDATED, see the section below. Issue #898's paged-decode v2 kv_chunk_size is left as a documented seam with a reserved op name (OP_PAGED_DECODE_V2_KV_CHUNK) and a three-step plug-in description; no v2 plan is invented here because the feasible chunk sizes depend on v2 memory accounting that does not exist yet.

3. Tuning entry points. mlxcel tune profiles the matrix offline and writes the winners, always profiling so it works with MLXCEL_AUTOTUNE unset. --model reads only config.json for head geometry (head_dim, num_attention_heads, num_key_value_heads, with a hidden_size / num_attention_heads fallback); no weights are loaded, because the split-count optimum is a launch-shape property. --dry-run, --op, and per-sweep knobs (--batch-sizes, --context-lengths, --reps, --min-improvement) are available. Lazy first-use tuning sits behind MLXCEL_AUTOTUNE=1, default off; MLXCEL_AUTOTUNE=cache consumes an earlier mlxcel tune result without ever profiling at runtime.

4. Cold-L2 benchmark support. src/lib/mlxcel-core/src/bench_rotation.rs computes a rotation count as ceil(2 * last_level_cache / per_iteration_read_bytes) clamped to 64, so the rotation set exceeds the cache and a buffer is evicted before the rotation returns to it. The Apple SLC is estimated by device family from the silicon generation and performance-core count (macOS exposes no SLC size through sysctl), biased high because under-estimating silently reintroduces the bias the mode exists to remove. cuda_l2_bytes() is a clearly-marked stub that returns None and documents the cudaDeviceProp::l2CacheSize FFI helper it needs; MLXCEL_BENCH_LLC_BYTES is the override for both platforms. examples/page_gather_microbench.rs gains --cold-l2 and --rotation, prints the mode and rotation count in its header and per config, and appends mode and rotation as the last two CSV columns so column-indexed readers of the historical schema keep working. Warm stays the default, so existing numbers remain reproducible. Methodology is documented in docs/benchmarks.md, including why large-context configs need no rotation at all and why a kernel whose warm and cold numbers agree is not memory-limited.

Default-off guarantee

With MLXCEL_AUTOTUNE unset and no cache present, resolve_num_splits returns 0 after one OnceLock read and one mode read: no cache read, no memo lock, no filesystem access, and the C++ launcher takes exactly the pre-change path. apply_tuned_cuda_kernel_env returns immediately. Explicit env vars are still honored verbatim.

What is NOT validated

The qmm CTA tile_m and multirow-qmv row-window ops were written on an Apple-Silicon-only host with no CUDA hardware, so their candidate sets, defaults, and workload shapes are derived from the kernel sources and the existing GB10 reports rather than from a run. Nothing in this PR measures them. The module carries a prominent UNVALIDATED banner, docs/environment-variables.md repeats it for MLXCEL_QMV_MULTIROW_MAX_ROWS, and both ops are lazy_tunable() == false so a serving process never profiles them. src/lib/mlx-cpp/patches/mlx/backend/cuda/quantized/qmm/qmv.cu was edited but could not be compiled here, since a Metal-only build never compiles the CUDA patch tree.

Test plan

  • cargo test --release -p mlxcel-core --lib --features metal,accelerate autotune:: (42 passed)
  • cargo test --release -p mlxcel-core --lib --features metal,accelerate bench_rotation:: (15 passed)
  • cargo test --bin mlxcel --features metal,accelerate commands::tune (10 passed)
  • cargo test --example page_gather_microbench --features metal,accelerate (15 passed)
  • cargo test --release --test autotuner_outcome --features metal,accelerate (2 passed, GPU-backed reduced matrix asserting the tuned tactic is never slower than the default)
  • cargo test --release -p mlxcel-core --lib --features metal,accelerate ffi_tests::test_fused_paged_decode (3 passed, unchanged)
  • cargo test --release -p mlxcel-core --lib --features metal,accelerate ffi_tests::test_pooled_paged_decode (3 passed, unchanged)
  • cargo clippy -p mlxcel-core --lib --tests --features metal,accelerate -- -D warnings
  • cargo clippy --lib --bins --test autotuner_outcome --example page_gather_microbench --features metal,accelerate -- -D warnings
  • cargo fmt --check
  • python3 scripts/ci/check_cross_repo_refs.py
  • mlxcel tune --dry-run and a single-cell mlxcel tune run confirming the cache file is written with the expected key, tactic, and metadata fields

Full benchmark measurement is deliberately left to a separate serialized pass so GPU contention does not pollute the numbers.

Closes #906

Kernel configuration in this tree was manual and shape-blind: the Blackwell qmm CTA tile was tuned once by hand and set by env, the multirow-qmv decode window was a hardcoded 8-row ceiling, and the v1 paged-decode kernel picked its `NumSplits` launch shape from a threadgroup-memory budget alone. Those are per-shape decisions, and a per-shape decision made once by hand is wrong for most shapes.

`mlxcel-core::autotune` adds the durable part of a serving-runtime autotuner: the cache key and its invalidation rules. Keys are `(op, kernel/runner identity, power-of-two shape bucket, dtype)` so nearby shapes share one entry and the tuning matrix stays bounded. Entries carry the mlxcel version and pinned MLX commit they were measured under, and a mismatch in either discards the entry instead of applying a stale config. The store mirrors `PolicyStore` in `src/server/batch/mtp_policy.rs`: one JSON file per key hash under the mlxcel cache root, atomic write-plus-rename, and a load path that validates the version and every key field. A corrupt or unreadable file warns and is ignored, never fatal. An out-of-bucket lookup warns once and returns the op's default.

Selection is min-latency over a median-of-5 timing per candidate, with each repetition individually bracketed by an eval and a backend sync. A candidate must beat the op's own default by more than 2% to be selected, so a noisy host converges back to today's behavior rather than to a coin flip; candidates that fail to launch are dropped from the sweep rather than aborting it.

First consumer, validated on Apple Silicon: the v1 paged-attention decode `NumSplits` launch shape. `paged_attention_decode` gains a `num_splits_override` parameter through the cxx bridge, and `paged_attention_num_splits_cap` exposes the budget ceiling so the tuner enumerates candidates from the launcher's own arithmetic instead of duplicating it. An override of `0` reproduces the pre-change launch exactly, and out-of-range values clamp back to the ceiling, so a stale cached tactic can never produce an infeasible launch. `NumSplits` is a template argument, so every candidate is a distinct JIT specialization.

The two CUDA consumers (qmm CTA `tile_m`, multirow-qmv row window) are wired but UNVALIDATED: no CUDA host was available, so their candidate sets and defaults are derived from the kernel sources and the existing GB10 reports rather than from a run. Both are process-wide env knobs read by patched MLX kernels, so they are tuned only by the offline `mlxcel tune` path (`lazy_tunable() == false`) and applied at process start by `apply_tuned_cuda_kernel_env`, following the `apply_cuda_graph_cache_default` precedent. `qmv.cu` gains `MLXCEL_QMV_MULTIROW_MAX_ROWS`, which narrows the dispatch window without touching the compile-time accumulator width. Issue #898's paged-decode v2 `kv_chunk_size` is left as a documented seam with a reserved op name; its feasible chunk sizes depend on v2 plan accounting that does not exist yet.

`mlxcel tune` profiles the matrix offline and writes the winners. It always profiles, so it works with `MLXCEL_AUTOTUNE` unset, and `--model` reads only `config.json` for head geometry (no weights are loaded). `MLXCEL_AUTOTUNE` defaults to off and is then fully inert: no cache reads, no profiling, no filesystem access on the decode path. `cache` consumes tuned tactics without profiling; `1` additionally tunes an unseen bucket on first use. An explicitly-set env var for any tuned knob always wins and suppresses tuning for it.

`mlxcel-core::bench_rotation` adds cold-last-level-cache benchmark support: rotating input buffers sized from the detected cache so each timed iteration reads evicted memory, with the Apple SLC estimated by device family and `MLXCEL_BENCH_LLC_BYTES` as the override (the CUDA L2 path is a clearly-marked stub needing an FFI helper). `examples/page_gather_microbench.rs` gains `--cold-l2`, prints the mode and rotation count, and appends both as CSV columns; warm remains the default so historical numbers stay reproducible.

Validation: 42 autotuner unit tests, 15 rotation tests, 10 `mlxcel tune` tests, 4 microbench tests, and a GPU-backed integration test (`tests/autotuner_outcome.rs`) asserting on a reduced matrix that the selected tactic is never slower than the default. The existing fused and pooled paged-decode parity tests pass unchanged. The CUDA paths are not exercised anywhere here.

Closes #906
@inureyes inureyes added status:review Under review type:enhancement New features, capabilities, or significant additions priority:medium Medium priority area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:benchmark Benchmark harness and performance measurement (bench_*.sh, /update-benchmarks) labels Jul 30, 2026
inureyes added 2 commits July 30, 2026 17:56
Issue #906 carries a mandatory determinism guard: repeated tuning runs on the same machine must converge to tactics whose measured times are within noise of each other, with a flaky-tactic guard on top of median-of-N. The harness satisfied the median-of-N half (warmup=2, reps=5) but not the guard, and the constants were far too weak to make the first half mean anything. Two back-to-back `mlxcel tune --op paged-decode-splits` runs on an idle M1 Ultra disagreed in 3 of 12 cells, and the same default tactic re-measured 554.4us then 417.2us at batch 4 / ctx 1024, a 33% swing, while every ctx 16384 cell re-measured within a few percent. The instability was concentrated where per-launch overhead dominates and 5 repetitions after 2 warmup iterations do not average it out.

Four changes to the measurement methodology, all in the profiling harness:

Warmup is now budgeted in wall-clock time (`warmup_budget_us`, 40ms) with the iteration count as a floor. Two iterations of a sub-millisecond launch is under a millisecond of work, nowhere near enough to bring an Apple GPU to a steady clock, so the old warmup left the ramp inside the measurement.

Timed repetitions scale with measured launch cost. The warmup doubles as a cost probe and each candidate then targets `sample_budget_us` (250ms) of samples, clamped to `[reps, max_reps]`. Cheap cells are both the noisy ones and the cheap ones to sample harder, so a ~500us launch now gets ~500 repetitions instead of 5. `reps` stays the documented floor of 5 and `mlxcel tune` refuses a lower value, so the existing gate in `tests/autotuner_outcome.rs` still holds.

Candidates are sampled round-robin on a stride rather than one at a time to completion. Measuring A fully and then B charges A for whatever thermal and clock state the sweep started in, which is a systematic bias in candidate order rather than noise; interleaving spreads every candidate's repetitions across the same window so drift is common-mode.

The switch threshold is now variance-aware, which is the flaky-tactic guard the issue asked for and the part that was missing. Each measurement carries a scaled median absolute deviation over its repetitions, and switching away from the default requires clearing both `min_improvement` and the default's and candidate's combined relative spread. Candidates that are statistically indistinguishable from the leader collapse to whichever sits nearest the default, so two equally good tactics stop alternating between runs. A tactic that cannot be shown better than the default falls back to the default, which is the safe direction.

The observed spread and the threshold actually applied are recorded in the cache entry (`spread`, `default_spread`, `required_improvement`) and printed as `spread` / `reps` / `guard` columns by `mlxcel tune`, so a human can see which rows were decided by the kernel and which by the noise floor. No cell is silently dropped. `TACTIC_VERSION` goes to 2 because a v1 entry was chosen under a rule that ignored dispersion and is not comparable; the new fields are `serde(default)` so an old file is a clean miss rather than a parse warning.

Behavior gated on `MLXCEL_AUTOTUNE` is untouched: unset stays fully inert, and an explicit env override still wins over any tuned or cached value.

Validated on an idle M1 Ultra: four consecutive `mlxcel tune --op paged-decode-splits` runs now select an identical tactic in all 12 of 12 cells (short context uniformly keeps the default; every ctx 16384 cell selects num_splits=16 at 1.15x to 1.23x). Per-sample spread at short context dropped from 13-17% to 3-5%, and the marginal batch 1 / ctx 16384 cell that flipped now clears its guard by a factor of four. A run takes ~26s.

Refs #906
Adds the performance-validation report issue #906 requires, covering the autotuner outcome gate and the cold last-level-cache methodology on Apple M1 Ultra with the Metal backend.

The headline result is that the shipped static split count is the wrong choice at long context. `num_splits` defaults to the largest value the threadgroup-memory budget allows (32 at head_dim 128), chosen without reference to how many tokens there are to stripe. At context 16384 the tuner selects 16 instead and gains 1.15x to 1.23x, consistently across every batch size measured and across seven independent sweeps. Short-context cells show no measurable win and keep the default.

Records two limits honestly rather than burying them. The host carried a load average of 12.8 to 18.0 from unrelated concurrent work, so short-context cells spread 5 to 19 percent per candidate and cannot resolve differences smaller than that; the long-context cells spread 1 to 5 percent under the same load, which is why their result is the one worth quoting. Separately, no CUDA hardware was available, so the qmm CTA tile and multirow-qmv tuners ship wired but unvalidated and the `qmv.cu` change has not been compiled by any toolchain.

Reconciles the cold-L2 section of `docs/benchmarks.md` with what was measured. The warm-to-cold median delta on this part is only -1.0 to +5.0 percent, not the large correction the section implied; the reproducible benefit is dispersion, which falls from 27 percent to 4.0 percent. Also corrects the stated no-rotation crossover, which lands at batch 4 / context 16384 on a 96 MiB SLC rather than at context 32768.
@inureyes inureyes added status:done Completed and removed status:review Under review labels Jul 30, 2026
@inureyes
inureyes merged commit be7afe2 into main Jul 30, 2026
5 checks passed
@inureyes
inureyes deleted the feature/issue-906-shape-bucketed-autotuner branch July 30, 2026 09:01
@inureyes inureyes self-assigned this Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:benchmark Benchmark harness and performance measurement (bench_*.sh, /update-benchmarks) area:core mlxcel-core: MLX FFI, primitives, KV cache, layers priority:medium Medium priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shape-bucketed kernel autotuner and cold-L2 benchmark methodology

1 participant