Skip to content

metal : per-device tuned (Q, NE) for flash-attn vec - #25750

Merged
forforever73 merged 12 commits into
ggml-org:dev-metalfrom
forforever73:fa-vec-tuning
Jul 29, 2026
Merged

metal : per-device tuned (Q, NE) for flash-attn vec#25750
forforever73 merged 12 commits into
ggml-org:dev-metalfrom
forforever73:fa-vec-tuning

Conversation

@forforever73

Copy link
Copy Markdown
Contributor

Overview

cont #23566.

This PR adds a compile-time lookup table that picks a better (Q, NE) per (device, dtype, head size, KV depth, batch width), following the generic-tuning / per-device-config direction from the #23114 discussion.

The table covers 6 KV dtypes: f16 plus the five quantized caches q4_0/q4_1/q5_0/q5_1/q8_0. Zero regression when the table doesn't hit: the baseline path stays byte-identical to today's backend in host_name, pipeline cache key, and numerics — only a table hit selects a new _q{Q}_ne{NE} kernel.

How it works

test-backend-ops tune --tune-perf   (offline sweep over the (dtype, Q, NE) grid)
            │  emits pasteable C table rows, one block per dtype
            ▼
ggml-metal-tuning.{h,cpp}            (compile-time table + fa_vec_pick)
   key   = { device_id, dtype, dk, dv, ne11_bucket, ne01_bucket }
   cfg   = { Q, NE }
   pick  = short KV → baseline;
           else exact (ne11_b, ne01_b) bucket → ne01-domain default → baseline,
           tried under the device-exact SKU, then the family's representative SKU
            │  consumed by
            ▼
ggml-metal-ops.cpp                   (fa_vec_pick → nqptg=cfg.Q)
            │  launches
            ▼
kernels/fa.metal                     (Q-generic vec body; one instantiation per (dtype,dk,dv,Q,NE))

test-backend-ops tune --tune-perf sweeps the grid offline and prints a pasteable C table, so any Apple-silicon owner can reproduce it. fa_vec_pick matches the device-exact SKU first, then the family's representative SKU — same-family parts benefit without their own sweep.The table selects only Q and NE. nsg/nwg stay on the existing adaptive heuristic.

The tuning table

The key is 2D per dtype — (ne11_bucket, ne01_bucket):

  • ne11 (KV depth) — buckets {1024, 4096, 16384}; the baseline→Q>1 crossover is head-size dependent. Short KV (ne11 < 1024) is left to baseline: attention is a small share of the step at short context, and short-KV timings are the noisiest.
  • ne01 (batch width) — buckets {2,3,4,5} split decode (ne01==1) from batch (≥2) and point-resolve ne01 = 2, 3, 4. The per-point-optimal Q isn't monotonic in the batch: q5's heavy dequant favors Q4 at ne01=3–4 while other dtypes stay Q1/Q2. Since real draft-n-max is typically ≤3 (ne01 ≤ 4), ne01 ≥ 5 collapses into a single min-max-regret backstop bucket.

The table is stored group-split to stay small: within one (dtype, dk, dv) the winning cfg is usually constant across ne11 buckets, so the generator emits one ne11-collapsed default row per ne01 domain, plus exact-bucket exception rows only where a bucket would lose too much on that default.

Two thresholds gate this. θ = 1.05 (target selection): a bucket is tuned only if its target beats baseline by ≥5% in aggregate over its sampled points, else it keeps baseline. τ = 5% (compression): a bucket folds into the domain default only if its pointwise worst regret vs its own target stays within 5%, else it becomes an exception.

Kernel micro-benchmarks (M4 Max, Apple9)

test-backend-ops tune --tune-perf sweeps the (Q, NE) grid on a GQA spec-decode shape (nh=4, nr23=[8,1], type_K==type_V). Each candidate's time is the per-op median of 7 reps, timed in shuffled order with a periodic baseline anchor to flag thermal throttling. The full per-(dtype, dk, ne11, ne01) matrix is in the attached sweep.

What fa_vec_pick commits at runtime for a dk=128, ne11=32768 head, per dtype (decode ne01=1, batch ne01=2–4). These are the table's actual picks, not the per-point best. Each stays within τ=5% of it (e.g. q5_0 ne01=3/4 commit Q2 where this point's fastest is Q4). ne01=1 stays Q1 and is numerically identical to baseline, so a speedup > 1 there is a pure NE retune on caches whose baseline NE was suboptimal:

dtype ne01=1 (decode) ne01=2 ne01=3 ne01=4
f16 Q1,NE1 (1.00×) Q2,NE2 (1.55×) Q2,NE2 (1.24×) Q2,NE2 (1.62×)
q4_0 Q1,NE4 (1.38×) Q1,NE4 (1.32×) Q1,NE4 (1.32×) Q1,NE4 (1.31×)
q4_1 Q1,NE4 (1.27×) Q2,NE4 (1.33×) Q1,NE4 (1.24×) Q2,NE4 (1.36×)
q5_0 Q1,NE4 (1.10×) Q2,NE4 (1.56×) Q2,NE4 (1.21×) Q2,NE4 (1.58×)
q5_1 Q1,NE4 (1.09×) Q2,NE4 (1.57×) Q2,NE4 (1.22×) Q2,NE4 (1.60×)
q8_0 Q1,NE4 (1.29×) Q2,NE4 (1.47×) Q1,NE4 (1.27×) Q2,NE4 (1.52×)

For the tail bucket (ne01 ≥ 5), one cfg must cover the whole range, and no single point's winner generalizes — a higher-Q cfg wins on Q-aligned ne01 but wastes padded rows on misaligned ne01. So the runtime picks the min-max-regret cfg: the one minimizing the worst per-point regret over the bucket, measured against the per-point oracle.

On a representative bucket (q4_0, dk=dv=192, ne11=2048) the per-point winner flips: Q1,NE4 is fastest at ne01=5–7, Q2,NE2 at ne01=8/16 — no single cfg is optimal across the bucket:

ne01 Q1,NE4 (min-max-regret) Q2,NE2 (ne01=8 winner) Q2,NE4 (flat Q2)
5 157.4 188.0 214.9
6 190.0 193.2 220.8
7 218.2 243.4 262.8
8 248.8 247.7 269.4
16 491.7 476.0 513.8
worst regret (t/t_oracle − 1) 3.3% 19.4% 36.5%

So the runtime commits Q1,NE4 — within 3.3% of the per-point best at every ne01 (mean 0.7%). The two shortcuts blow up at the misaligned small-ne01 points: extrapolating the single ne01=8 winner (Q2,NE2) is 19.4% off at ne01=5, a uniform Q2,NE4 up to 36.5%.

In real spec-decode, --spec-draft-n-max ≤ 3 keeps ne01 ≤ 4 on the point buckets (regret ≈ 0); the tail bucket mainly backstops large draft-n / MTP.

The raw --tune-perf output every table above:
fa_vec_sweep_m4max.txt

End-to-end (speculative decoding)

llama-server -m <target> [-md <draft>] --spec-type draft-simple|draft-mtp --spec-draft-n-max 3 -fa on -np 4, greedy. A fixed-token prompt pins the KV depth (ne11); decode tok/s is the server timings median of 3, baseline build (empty table) vs tuned build.

Across the 48 comparisons (4 targets × {f16, q8_0, q5_0, q4_0} × {4k, 16k, 32k}), speedup grows with KV length, peaking at 1.323× (Qwen3-30B-A3B q5_0 @32k). The only two rows below 1.0× — 27B q4_0 @4k (0.994×) and 35BA3B q8_0 @32k (0.988×) — are draft-mtp cases: the tuned kernel (Q≥2 in verify) slightly perturbs the MTP head's logits and shifts which drafts are accepted (35BA3B q8_0 @32k: acceptance drops from 0.99 baseline to 0.72 tuned). Dividing that out, the kernel step-rate is 1.027× and 1.243×, and grows with depth like the rest.

Full e2e matrix — 4 targets × {f16, q8_0, q5_0, q4_0} × {4k, 16k, 32k} (48 comparisons)
target + draft KV context baseline tok/s tuned tok/s speedup
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 f16 4096 57.34 57.75 1.007×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 f16 16384 47.68 50.77 1.065×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 f16 32768 38.28 43.12 1.126×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 q8_0 4096 53.77 55.78 1.037×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 q8_0 16384 42.54 46.99 1.105×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 q8_0 32768 32.84 38.48 1.172×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 q5_0 4096 50.19 52.93 1.055×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 q5_0 16384 33.50 39.13 1.168×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 q5_0 32768 23.14 29.20 1.262×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 q4_0 4096 54.99 55.81 1.015×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 q4_0 16384 42.63 45.27 1.062×
Llama-3.1-8B-Q4_K_M + Llama-3.2-1B-f16 q4_0 32768 32.64 36.03 1.104×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 f16 4096 86.61 88.04 1.017×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 f16 16384 53.41 59.76 1.119×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 f16 32768 35.27 41.78 1.185×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 q8_0 4096 74.56 80.39 1.078×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 q8_0 16384 44.26 52.05 1.176×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 q8_0 32768 28.74 35.52 1.236×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 q5_0 4096 63.89 70.49 1.103×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 q5_0 16384 31.17 38.94 1.249×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 q5_0 32768 18.54 24.53 1.323×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 q4_0 4096 75.84 78.76 1.038×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 q4_0 16384 44.46 49.15 1.106×
Qwen3-30B-A3B-Q4_K_M + Qwen3-0.6B-f16 q4_0 32768 28.55 32.58 1.141×
Qwen3.6-27B-MTP-Q8_0 (mtp) f16 4096 25.97 26.13 1.006×
Qwen3.6-27B-MTP-Q8_0 (mtp) f16 16384 25.57 26.45 1.034×
Qwen3.6-27B-MTP-Q8_0 (mtp) f16 32768 24.12 25.76 1.068×
Qwen3.6-27B-MTP-Q8_0 (mtp) q8_0 4096 25.01 25.66 1.026×
Qwen3.6-27B-MTP-Q8_0 (mtp) q8_0 16384 21.93 24.08 1.098×
Qwen3.6-27B-MTP-Q8_0 (mtp) q8_0 32768 20.30 23.45 1.155×
Qwen3.6-27B-MTP-Q8_0 (mtp) q5_0 4096 23.54 25.22 1.072×
Qwen3.6-27B-MTP-Q8_0 (mtp) q5_0 16384 21.13 23.07 1.092×
Qwen3.6-27B-MTP-Q8_0 (mtp) q5_0 32768 16.86 19.39 1.150×
Qwen3.6-27B-MTP-Q8_0 (mtp) q4_0 4096 23.64 23.50 0.994×
Qwen3.6-27B-MTP-Q8_0 (mtp) q4_0 16384 23.40 25.88 1.106×
Qwen3.6-27B-MTP-Q8_0 (mtp) q4_0 32768 18.95 20.67 1.091×
Qwen3.6-35BA3B-MTP (mtp) f16 4096 93.76 94.97 1.013×
Qwen3.6-35BA3B-MTP (mtp) f16 16384 88.33 93.26 1.056×
Qwen3.6-35BA3B-MTP (mtp) f16 32768 83.96 92.95 1.107×
Qwen3.6-35BA3B-MTP (mtp) q8_0 4096 103.35 107.62 1.041×
Qwen3.6-35BA3B-MTP (mtp) q8_0 16384 77.37 93.92 1.214×
Qwen3.6-35BA3B-MTP (mtp) q8_0 32768 68.52 67.73 0.988×
Qwen3.6-35BA3B-MTP (mtp) q5_0 4096 84.37 85.62 1.015×
Qwen3.6-35BA3B-MTP (mtp) q5_0 16384 68.64 75.07 1.094×
Qwen3.6-35BA3B-MTP (mtp) q5_0 32768 45.28 54.55 1.205×
Qwen3.6-35BA3B-MTP (mtp) q4_0 4096 93.91 101.06 1.076×
Qwen3.6-35BA3B-MTP (mtp) q4_0 16384 80.97 94.35 1.165×
Qwen3.6-35BA3B-MTP (mtp) q4_0 32768 57.52 72.62 1.262×

Compilation cost

build vec instantiations fa cold-start compile embed binary size
baseline 80 4.59 s 3.83 MB
this PR 398 10.77 s 3.91 MB

Adding a new device

The table is collected per-device with the same offline sweep that produced the M4 Max rows, so any Apple-silicon owner can extend it.

Contributor workflow
  1. Build the test binary with the Metal backend on the target machine:
    cmake --build <build> --target test-backend-ops -j
  2. Sweep the (dtype, Q, NE) grid (6 dtypes × 10 dk/dv × 4 ne11 × 9 ne01, a few hours):
    ./build/bin/test-backend-ops tune --tune-perf -b MTL0 > fa_vec_sweep.txt 2>/dev/null
    The part that gets folded into the table is the fa_vec_tuned_table block at the end — one // ---- <dtype>: N rows ---- section per dtype. It's finished output (min-max-regret target, θ=1.05 gate, short-KV drop, and τ=5% compression already applied); the attached fa_vec_sweep_m4max.txt is exactly this format.
  3. Validate numerics + no regression:
    ./build/bin/test-backend-ops tune -b MTL0 (drift guard + per-(dtype, Q, NE) matrix vs CPU-ref)
    ./build/bin/test-backend-ops test -o FLASH_ATTN_EXT -b MTL0
  4. Post fa_vec_sweep.txt in the discussion thread.

Validation output (this machine, build_embed)

$ ./build_embed/bin/test-backend-ops tune -b MTL0
fa_vec tune-check: 11448 cases run, 0 failed
  Backend MTL0: OK

$ ./build_embed/bin/test-backend-ops test -o FLASH_ATTN_EXT -b MTL0
  4752/4752 tests passed
  Backend MTL0: OK

tune -b MTL0 covers f16 + all 5 quantized caches × every (Q, NE) × padded rows × sinks × nsg(1/2/4); the quantized Q>1 dequant-once / cross-Q reuse is correct at all precisions.

Once the discussion thread has collected sweeps from other devices, I'll fold each one's fa_vec_tuned_table block into the table — registering the SKU enum in ggml-metal-device.{h,m} where a device isn't listed yet (the M1–M5 families already are, so this is rare).

follow-ups

  • Thermal throttling: the sweep only flags it (anchor-drift # WARN); a cooldown mechanism (run a batch, pause until the GPU cools, resume) would keep long sweeps throttle-free instead of needing a re-run.
  • Aggregate target-selection gate: the θ=1.05 gate compares baseline vs target by summed time over the bucket's sampled points (ratio-of-sums), which weights the heaviest points most — a win concentrated there can pass the gate while lighter points barely benefit. Making it per-point / worst-case (as the τ compression pass already is) would be more robust.
  • Separate the tuning tool: extract the ~250-line sweep/emit generator out of tests/test-backend-ops.cpp into a standalone tools/metal-kernel-tuning (FA-vec as the first plugin); design in docs/superpowers/specs/2026-07-13-metal-kernel-tuning-tool.md.

Requirements

  • I have read and agree with the contributing guidelines.
  • AI usage disclosure: Yes, claude was used for the offline sweep generator, numerical validation, and code development. The table design (key, buckets, group-split compression, min-max-regret backstop), and the fallback scheme were designed by a human.

@forforever73
forforever73 requested review from a team and ggerganov as code owners July 16, 2026 02:52
@github-actions github-actions Bot added testing Everything test related ggml changes relating to the ggml tensor library for machine learning Apple Metal https://en.wikipedia.org/wiki/Metal_(API) labels Jul 16, 2026
@ggerganov ggerganov self-assigned this Jul 16, 2026
@ggerganov

Copy link
Copy Markdown
Member

@forforever73 I've updated the dev-metal branch. Let's rebase this on top so I can take alook.

@forforever73

Copy link
Copy Markdown
Contributor Author

Done, ready for review now

@ggerganov ggerganov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes to test-backend-ops.cpp are very heavy. Should be a separate binary?

@forforever73

Copy link
Copy Markdown
Contributor Author

@ggerganov Yes, that's the plan. I'm going to add a separate tool for this.

@ggerganov

Copy link
Copy Markdown
Member

@ggerganov Yes, that's the plan. I'm going to add a separate tool for this.

Ah, ok. I just saw the follow-ups in the description.

Comment thread ggml/src/ggml-metal/ggml-metal.cpp Outdated
Comment on lines +897 to +911
if (strcmp(name, "ggml_backend_metal_set_fa_vec_override") == 0) {
return (void *)ggml_backend_metal_set_fa_vec_override;
}
if (strcmp(name, "ggml_backend_metal_clear_fa_vec_override") == 0) {
return (void *)ggml_backend_metal_clear_fa_vec_override;
}
if (strcmp(name, "ggml_backend_metal_fa_vec_ne11_bucket") == 0) {
return (void *)ggml_backend_metal_fa_vec_ne11_bucket;
}
if (strcmp(name, "ggml_backend_metal_fa_vec_ne01_bucket") == 0) {
return (void *)ggml_backend_metal_fa_vec_ne01_bucket;
}
if (strcmp(name, "ggml_backend_metal_fa_vec_baseline_ne") == 0) {
return (void *)ggml_backend_metal_fa_vec_baseline_ne;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The names of these procs should have the prefix ggml_backend_metal_tuning_.

For example: ggml_backend_metal_tuning_set_fa_vec_override

Comment thread ggml/src/ggml-metal/ggml-metal-tuning.h Outdated
// test/tune-only override; when set, fa_vec_pick returns it directly.
void fa_vec_set_override(fa_vec_cfg_t cfg);
void fa_vec_clear_override();
bool fa_vec_override_active();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fa_vec_override_active seems unused

Comment on lines +33 to +40
struct fa_vec_key_t {
int8_t device_id;
int8_t dtype;
int16_t dk;
int16_t dv;
int8_t ne11_b;
int8_t ne01_b;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some point we will also try to add auto-tuning for other ops, such as MUL_MAT and MUL_MAT_ID. Not sure if it's worth to generalize the tuning key to work with all ops. Alternatively, we can add individual key structs for each op. Just marking this for consideration later on.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inspired by #25377 and based on my current experiments, adding auto-tuning for mul_mm also shows improvements. The tuning parameters differ somewhat from those for fa_vec, though. Once this PR is merged, I'll take a step back and evaluate whether it makes sense to generalize the tuning key or keep separate key structs for different ops.

@ggerganov ggerganov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to merge this, since the base branch is not master.

@forforever73
forforever73 merged commit 96ece71 into ggml-org:dev-metal Jul 29, 2026
22 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Apple Metal https://en.wikipedia.org/wiki/Metal_(API) ggml changes relating to the ggml tensor library for machine learning testing Everything test related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants