Skip to content

perf(gqa): autotune the decomposed prefill GEMMs instead of trusting one heuristic pick - #718

Draft
BoarQing wants to merge 2 commits into
feat/rgp-capture-fencefrom
perf/gqa-gemm-autotune
Draft

perf(gqa): autotune the decomposed prefill GEMMs instead of trusting one heuristic pick#718
BoarQing wants to merge 2 commits into
feat/rgp-capture-fencefrom
perf/gqa-gemm-autotune

Conversation

@BoarQing

Copy link
Copy Markdown
Contributor

What

GQA's decomposed prefill asked hipBLASLt for exactly one heuristic candidate and never benchmarked it, while lib/Runtime/real/matmul.cpp next door has had a real autotuner on the same API for a while. This ports that loop into queryOrCreateGemmState: request up to 60 candidates, benchmark them on the first real call with the actual operands, cache the winner per shape in the existing GqaGemmCache.

The heuristic's first pick was not close. On the score GEMM it ran 30-32 ms against the tuned winner's 8.5-9.9 ms -- a 3.2-4.5x gap on the same 137.5 GFLOP the value GEMM was already doing in 9.76 ms. The score and value GEMMs having identical FLOPs but a 2.7x latency difference was the clue that pointed here.

Tuning happens on real operands rather than scratch buffers, and is cost-aware: a shape whose first candidate exceeds 20 ms gets an abbreviated schedule (2 rounds, 1 iteration, no warmup), because the full 4x30 schedule was calibrated for microsecond launches and cost 13 minutes at a 16K prompt. Workspace sizing reserves the largest candidate's requirement while a shape is still untuned.

Second commit is an honest negative result: asking the score GEMM for fp16 output instead of fp32, so the whole score/bias/softmax chain runs over one fp16 S x S buffer. It halves that buffer and doubles the head-grouping budget, but buys no TTFT (26,003 ms off against 26,178 ms on). Left in, default off, behind HIPDNN_EP_GQA_SCORE_FP16, for memory-constrained deployments -- fp32 stays the default because it is what keeps softmax clear of inf - inf = NaN.

Measurement

Gemma-4 26B-A4B, 16K VLM prefill, gfx1151, same binary both arms, default score budget, 1 warmup + 3 reps:

arm TTFT
HIPDNN_EP_GQA_AUTOTUNE=0 38,012 ms
autotune on (default) 26,003 ms

-12.01 s (31.6%). Plan estimated 8.7 s.

Verification

  • HIPDNN_EP_DEBUG confirms the mechanism, not just the clock: [GQA-AUTOTUNE] score: tested 7/7 algos, best=#5 0.164 ms vs heuristic #0 0.466 ms (2.84x), and 4.41x on another shape. The winner is never candidate #0, which is what the old code took.
  • Real-model output diverges in wording from token 26 at a low-confidence position, then stays coherent. This is the expected class of change: a different algorithm sums in a different order. Same config run twice is deterministic. matmul.cpp already ships this same property.

Notes for review

  • HIPDNN_EP_GQA_AUTOTUNE=0 restores the single-heuristic behaviour in the same binary, which is how the number above was taken.
  • First-call tuning cost is paid once per shape per process and is excluded from warm TTFT by the harness's warmup rep.

Made with Cursor

a1_iputest and others added 2 commits August 14, 2026 04:49
…ristic pick

The decomposed GQA pipeline asked hipBLASLt for exactly one heuristic
candidate and used it unconditionally. Nothing ever measured it, so a
misprediction was invisible -- and it was mispredicting badly: at a 16K prompt
the score GEMM's first-ranked algorithm runs 30-32 ms against 8.5-9.9 ms for
the fastest candidate of the same shape (3.2-4.5x), which is what made the
score GEMM cost 26.25 ms/head against the value GEMM's 9.76 ms for an
identical 137.5 GFLOP. The value GEMM was mispredicted too, by 1.4-1.8x.

Ports matmul.cpp's approach (MAX_ALGO_CANDIDATES=60, AUTOTUNE_TIMING_ITERS=3)
into the GQA GEMM cache. Split in two, because the shape is known where the
descriptors are built but the device pointers are not: queryOrCreateGemmState
now keeps the whole candidate list, and the first real GEMM of that shape times
them with its own operands and caches the winner. Candidate 0 stays active
until tuning replaces it, so any failure degrades to the old behaviour.
Warm-up doubles as the validity check -- a candidate that cannot run these
operands is skipped rather than timed.

Two details the port could not inherit:

- Workspace. Sizing to the incumbent's requirement would skip the hungrier
  candidates and narrow the sweep to small-workspace algorithms, so an untuned
  shape reserves the largest workspace any candidate asks for.
- Sweep cost. matmul.cpp's fixed 3 timed iterations assume microsecond
  launches; these are tens of ms, where 60 candidates x 3 is seconds per
  shape. Probing the incumbent first and dropping to one timed iteration above
  1 ms keeps the sweep affordable without measuring noise.

HIPDNN_EP_GQA_AUTOTUNE overrides the runtime-wide HIPDNN_EP_AUTOTUNE for GQA
alone. Flipping the shared flag would retune every other GEMM in the model, so
a TTFT delta could not be attributed here; the override gives both A/B legs
from one binary.

Measured, same binary, Gemma-4 26B-A4B 16K VLM prefill, 3 reps + 1 warmup:
37,091 ms -> 25,917 ms warm TTFT, an 11.17 s (30.1%) gain. Run-to-run stddev
also drops from 371 ms to 23 ms. Tuning cost is paid once per shape, on the
warmup iteration.

Numerics: greedy output tracks the untuned run for 25 of 32 tokens and then
takes a different, coherent continuation, consistent with fp accumulation
order differing between algorithms. This is the same class of change
matmul.cpp already makes for every other GEMM in this model. Not verified
against a reference at this shape: the decomposed path has no numeric test at
head_dim 256 / seqlen 16384 (test/numeric covers attention only to head_dim
128, seqlen 256).

Co-authored-by: Cursor <cursoragent@cursor.com>
…e memory

Tries the score GEMM with outputFp32=false, which the plan flagged as worth
measuring: the fp32 S x S score buffer plus its fp16 probability copy is the
decomposed prefill's dominant memory traffic at a long prompt, and a single
fp16 buffer should remove roughly half of it. Under HIPDNN_EP_GQA_SCORE_FP16
the GEMM writes fp16 scores and bias, mask and softmax all run in place there,
so the fp32 buffer is not allocated at all.

The result is negative on time and positive on memory. Measured at the default
16 GiB score budget, same binary, 3 reps + 1 warmup: 26,003 ms without it
against 26,178 ms with it -- 175 ms slower, against per-leg stddevs of 29 and
7 ms. The chain is evidently not bandwidth-limited at this shape once the
GEMMs are tuned, and the fp32<->fp16 conversion in the bias pass is not free.

It is kept, default off, for the memory: halving the per-head score footprint
doubles how many heads fit a given score budget, and that budget is the only
reason head grouping exists. It is also what a constrained budget wants for
speed -- at HIPDNN_EP_GQA_SCORE_BUDGET_MB=1700, where fp32 fits one head per
group and fp16 fits two, the same A/B is 25,592 ms against 21,615 ms, a 3.98 s
win. So the flag's value is entirely in relieving the budget, not in the
narrower dtype, and it should stay off wherever the budget is not binding.

Numerics are clean: greedy decode over 32 tokens is token-for-token identical
to the fp32-score run at the same 16K prompt. The risk the plan named is real
but was not hit -- fp32 scores exist so that softmax and the causal mask
cannot produce inf - inf = NaN, and fp16 only bounds that instead of
excluding it. The bias add is still evaluated in fp32 before the narrowing
store, so a masked entry lands at score + (-65504) as a finite fp16 rather
than saturating to -inf; the fp16 causal mask writes -65504 where the fp32 one
writes -INFINITY; and all softmax reductions stay fp32 regardless of the
buffer dtype, so only the stored logits lose precision.

Kernel changes are type parameters on the existing kernels rather than new
ones, so every fp32 instantiation is unchanged: softmax_f32_to_out_kernel
gains an input type (fp16 in place is safe -- one block owns one column, so a
pass-3 store is never read by another block), and add_attention_bias_kernel
gains a score type.

Correcting the record on the previous commit: its 37,091 -> 25,917 ms was
taken with HIPDNN_EP_GQA_SCORE_BUDGET_MB=1700 leaking in from the shell, not
at the default budget. Both legs had it, so the A/B stands, but the
default-budget figure for that change is 38,012 -> 26,003 ms, a 12.01 s
(31.6%) gain. The 38,012 ms untuned baseline also independently reproduces the
plan's 38,384 ms starting point.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

Copy link
Copy Markdown

Thanks for opening a PR!

This project follows LLVM's incremental-development and AI-tool-use
guidance. See CONTRIBUTING.md
for the project workflow.

Before requesting review, please check that:

  1. The change is focused. Substantial work links the relevant issue
    or design discussion.
  2. The PR documents relevant test results and updates affected
    documentation.
  3. If AI tools provided substantial assistance, the description
    explains what was assisted and how it was validated, and commit
    trailers identify the tool. The contributor has reviewed and
    understands the result.

Reviewers are assigned through
CODEOWNERS where ownership
is configured.

@github-actions

Copy link
Copy Markdown

L2 Accuracy Results (EP vs CPU)

Model Combined L2 Total Elems Skipped NaN/Inf
conv_test_hybrid 4.8668E-07 64 0
GroupQueryAttention_seq256 25.2366 2621440 0
MatMulNBits_o_seq128 259.906 368640 0
QMoE_seq128 34.9552 368640 0

Threshold: 0.01 | Run: 4424 - Commit: 9bd0b72

@github-actions

Copy link
Copy Markdown

MorphiZen EP Performance Results

Model QPS Session (s) 1st Infer (ms) CPU% Mem (MB)
full_model_seq128 7.50 6.14 370 3 1243
GroupQueryAttention_seq128 4396.35 1.82897 11 6 314
matmul_down_seq128 526.22 2.44 73 3 352

EPContext Export Performance

Model QPS Session (s) 1st Infer (ms) CPU% Mem (MB)
full_model_seq128 7.56 44.27 359 3 15589

EPContext Import Performance

Model QPS Session (s) 1st Infer (ms) CPU% Mem (MB)
full_model_seq128 7.55 9.54 361 3 15760

OGA Benchmark Results

Model Warmup Reps Prompt Len Gen Tokens TTFT (ms) TPS Peak Mem (GB) GPU Mem (GB)
gpt-oss-20b-webgpu-int4-rtn-block-32 1 5 128 128 170.1 80.5 1.33 13.53
Llama-3.1-8B-awq-g128-int4-asym-fp16-onnx-dml 1 5 128 128 300.3 44.4 1.22 6.43

OGA Wheel Smoke (Python benchmark_e2e.py)

Model TTFT (ms) TPS
Llama-3.1-8B-awq-g128-int4-asym-fp16-onnx-dml 193 43.4

Run: 4424 - Commit: 9bd0b72

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant