Skip to content

fix(gqa): run a short first prompt on the hipBLASLt path as well - #1327

Draft
ximzhang-amd wants to merge 6 commits into
mainfrom
fix/gqa-short-first-prompt-hipblaslt
Draft

ximzhang-amd wants to merge 6 commits into
mainfrom
fix/gqa-short-first-prompt-hipblaslt

Conversation

@ximzhang-amd

@ximzhang-amd ximzhang-amd commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #1301 — merge after it. This branch is stacked on #1301's branch, so it also carries #1301's three
commits (d978107d, 8c318500, fa7d4ec5). Only the last three commits are this PR: 9fad0b1f (fix),
60c90d3f (test) and 56831174 (the first-prompt rule on this path). It cannot go in first: the hipBLASLt path
appends the KV cache with the same kernel, whose device-side past length #1301 clamps, and it uses #1301's
gqa_padded_window_is_first_prompt. Once #1301 merges I will rebase onto main so that only this PR's commits
remain.

Summary

The decomposed hipBLASLt GQA pipeline now runs a padded first prompt under the same rule as the fused path after
#1301: tokens first, padding after, no past, accepted only when total_sequence_length equals the window. Any
other padded window is rejected.

Related issue or design

Depends on #1301 (the same fix on the fused path). Backport of the hip-ir-runtime downstream patch 0008 (link under Notes for reviewers).

Why

gqa_forward_hipblaslt derived the past the same way the fused prefill did and rejected a prompt shorter than the
window. The original motivation was head size 96 (phi-silica), which the fused kernels did not template at the
time. On current main head_dim 96 runs fused (head_dim_ok accepts 64, 96, 128, 256), but the decomposed
path is still taken whenever the fused path is not supported: float32 activations with an unquantized cache,
wave64 devices (CDNA), an attention bias, non-causal attention, reused past KV, or an unsupported window/sink.

What

  • gqa_forward_hipblaslt: a negative past is clamped to 0 only when gqa_padded_window_is_first_prompt() (from
    fix(gqa): run a first prompt shorter than its prefill window #1301) accepts the window, i.e. total_sequence_length == sq. Otherwise the call is rejected before RoPE and the
    KV-cache write, with the same diagnostic as the fused path. The scalar is passed through from
    wrap_group_query_attention. The causal mask takes past_len explicitly and the scores span total_seq keys,
    so padding rows attend to the prompt alone.

  • Tests: every padded-window test from fix(gqa): run a first prompt shorter than its prefill window #1301 is parametrized over float16 (fp16_fused) and float32
    (fp32_hipblaslt), so both paths are covered the same way:

    • test_gqa_padded_chunk_prefill_llama_shape;
    • test_gqa_padded_first_prompt_rotary (RoPE inside GQA, nonzero padding, compared with unpadded ORT CPU);
    • test_gqa_padded_prefill_then_decode_in_place;
    • test_gqa_padded_window_is_rejected, which expects each path's own diagnostic.

    _make_gqa_fixed_cache_model gains an element-type argument.

Test plan

Validation by the original author (gfx1151, from the commit message): phi-silica (head_dim 96, 64-row window),
5-token prompt bit-identical to the full window (at that time head_dim 96 used this path).

  • Builds (gfx1151 EP package from this branch); clang-format, ruff and git diff --check clean.

  • Routing checked in wrap_group_query_attention: float32 + unquantized cache → !fused_supported →
    gqa_forward_hipblaslt. HIPDNN_EP_DEBUG=1 on an fp32 case shows routing to legacy decomposed pipeline (elem=4 … d=128 sq=128 …).

  • Ordering: the only RoPE / KV work in gqa_forward_hipblaslt before the check is the sq == 1 decode
    fallback, which cannot be padded.

  • GPU (gfx1151, Strix Halo, TheRock 7.13, onnxruntime 1.31.0, HIPDNN_EP_STRICT=1), padded-window tests
    (24 = 12 per path):

    EP Result What fails
    this branch 24/24 pass —
    this PR's previous head (unconditional clamp) 18/24 the 6 rejection cases, fp16 and fp32
    fix(gqa): run a first prompt shorter than its prefill window #1301 alone (fused path only) 17/24 the fp32 accepted cases: padded ×4, RoPE ×2, prefill→decode
  • All of test_gqa.py with this branch's EP: 32 pass / 10 fail. The 10 are main's pre-existing
    growing-KV-cache failures, where the EP returns present sized like past; no new failure.

Notes for reviewers

  • Origin: hip-ir-runtime downstream patch
    0008-fix-gqa-run-a-short-first-prompt-on-the-hipBLASLt-pa.patch
  • The fix commit's message still cites head_dim 96 as the trigger; this description gives the current routing.
  • AI assistance: the fix was developed with AI assistance (Co-Authored-By: Claude); the test commit and this
    description were prepared with Claude Code and reviewed by the submitter.

Checklist

  • The change is focused, or links a design/series explaining its scope.
  • Relevant tests were added or updated and the results are documented.
  • User-facing or design documentation was updated when needed (none needed).
  • Substantial AI assistance is disclosed, and I reviewed and understand the result.

🤖 Generated with Claude Code

@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

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

L2 Accuracy Results (EP vs CPU)

Model Combined L2 Total Elems Skipped NaN/Inf
conv_test_hybrid 1.12256E-07 64 0
GroupQueryAttention_seq256 59.2561 2621440 0
MatMulNBits_o_seq128 259.905 368640 0
QMoE_seq128 24.2583 368640 0

Threshold: 0.01 | Run: 7995 - Commit: 186551b

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Binary sizes

Baseline: run 40617608668 - cf8ff09 (dll-sizes)

File This (MB) Main (MB) Delta (MB)
hipgpu.dll 59.34 59.34 0.00
custom_kernels_gfx1170.dll 5.55 5.55 0.00
custom_kernels_gfx11-generic.dll 5.79 5.79 0.00

MorphiZen EP Performance Results

Model QPS Session (s) 1st Infer (ms) CPU% Mem (MB)
full_model_seq128 8.78 7.42 251 3 1159
GroupQueryAttention_seq128 5138.69 2.54977 15 6 231
matmul_down_seq128 506.08 3.07 114 3 274

EPContext Export Performance

Model QPS Session (s) 1st Infer (ms) CPU% Mem (MB)
full_model_seq128 8.82 48.93 251 3 15504

EPContext Import Performance

Model QPS Session (s) 1st Infer (ms) CPU% Mem (MB)
full_model_seq128 8.81 9.84 366 3 15639

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 92.4 75.1 1.25 12.24
Llama-3.1-8B-awq-g128-int4-asym-fp16-onnx-dml 1 5 128 128 118.7 47.6 1.15 5.23

OGA Wheel Smoke (Python benchmark_e2e.py)

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

VLM Benchmark Results

Model TTFT (ms) TPS Output
gemma-4-E4B-it-fp16-int4-kquant-prune-logits 4069.7 7.9 This is a high-quality, outdoor photograph featuring a beautiful, fluffy white dog, likely a West Highland White Terrier or a si
Qwen3.8-27B-fp16-ve-fp16-int4-k_quant-gs128-text-dml-onnx 4853.5 14.0 The user wants a detailed description of the image. Let me carefully observe all elements before writing. Subject: A white do

TTFT / TPS vs main

Baseline: run 40617608668 - cf8ff09 | Gate: TTFT +20% / TPS -20%

Suite Model TTFT (ms) Main Delta TPS Main Delta
oga gpt-oss-20b-webgpu-int4-rtn-block-32 92.4 92.4 0.0% 75.1 75.0 +0.2%
oga Llama-3.1-8B-awq-g128-int4-asym-fp16-onnx-dml 118.7 119.4 -0.6% 47.6 47.7 -0.3%
vlm gemma-4-E4B-it-fp16-int4-kquant-prune-logits 4069.7 4096.2 -0.6% 7.9 7.3 +8.2%
vlm Qwen3.8-27B-fp16-ve-fp16-int4-k_quant-gs128-text-dml-onnx 4853.5 4928.4 -1.5% 14.0 14.0 -0.1%

Run: 7995 - Commit: 186551b

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Kernel Unit Test Results

Op Leaf Arch Cases Failed Suite (ms) Verdict
matmul_nbits Xfp16_Wu2_Yfp16 gfx1151 12 0 6511.8 PASS
matmul_nbits Xfp16_Wu3_Yfp16 gfx1151 12 0 6675.8 PASS
matmul_nbits Xfp16_Wu4_Yfp16 gfx1151 12 0 4259 PASS
matmul_nbits Xfp16_Wi8_Yfp16 gfx1151 12 0 5742.5 PASS
matmul_nbits Xfp32_Wu2_Yfp32 gfx1151 12 0 6413.1 PASS
matmul_nbits Xfp32_Wu3_Yfp32 gfx1151 12 0 6851 PASS
matmul_nbits Xfp32_Wu4_Yfp32 gfx1151 12 0 4267.7 PASS
matmul_nbits Xfp32_Wi8_Yfp32 gfx1151 12 0 5845.6 PASS
gqa/decode Xfp16_Wfp16_Yfp16 gfx1151 17 0 3175.7 PASS
gqa/decode Xfp16_Wi8_Yfp16 gfx1151 13 0 2360.9 PASS
gqa/prefill Xfp16_Wfp16_Yfp16 gfx1151 26 0 5129.4 PASS
gqa/prefill Xfp16_Wi8_Yfp16 gfx1151 9 0 3747.7 PASS
qmoe_ragged_mm Xfp16_Wu4_Yfp16 gfx1151 40 0 2507.5 PASS
gather_block_quantized Xi64_Wu4_Yfp16 gfx1151 32 0 405.7 PASS
gather_block_quantized Xi64_Wu8_Yfp16 gfx1151 32 0 367.8 PASS
gather_block_quantized Xi64_Wfp8_Yfp16 gfx1151 24 0 386.8 PASS

Leaves: 16 (0 failed) | Cases: 289 (0 failed)

Mode: lookup | Coverage: 1 | Run: 7995 - Commit: 186551b

@ximzhang-amd
ximzhang-amd force-pushed the fix/gqa-short-first-prompt-hipblaslt branch from 70e9cf1 to 186551b Compare October 28, 2026 01:59
@ximzhang-amd

Copy link
Copy Markdown
Contributor Author

/run-test models=all node=xsjnucstrhalo09

@amd-yanjunz

Copy link
Copy Markdown
Contributor

ROCm GenAI CI has been triggered.

xingy16-amd and others added 6 commits October 27, 2026 20:51
seqlens_k is the total length less one. The fused prefill derived the
past as seqlens_k + 1 - sq and rejected a negative one, so a prompt
shorter than the window -- tokens in rows 0..seqlens_k, padding after,
no past -- failed in every layer. The wrapper's return code is dropped
by the generated code, so the model went on to return garbage.

That is the padded first prompt ORT reads with a zero past. The host now
clamps the past at 0, and attends over seqlens_k + 1 keys; the prefill
kernels already bound keys by that count, so padding rows attend to the
prompt and nothing past it. The rope and KV-append kernels derive the
past on the device the same way through gqa_past_len_from_seqlens: the
append used to skip a negative past, leaving the cache unwritten, and
rope would have indexed the cos/sin cache below 0. Full windows, later
chunks and decode compute the same past as before.

Checked on gfx1151 against the full window on the same input: a 5-token
prompt gives bit-identical hidden rows 0..4 and KV positions 0..4 on
phi-muffin gemm and conv. Replacing the padding rows' input changes no
output bit. On orca 3.4.0 (int8 KV cache) rows 0..4 match to 2.5e-3
relative L2, with the first int8 flips of one unit nine or more layers
in. Both lengths are deterministic run to run. Decode latency is
unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Made-with: Claude Code
(cherry picked from commit 9caa18d)
test_gqa_padded_chunk_prefill_llama_shape compared only the attention
output of a prompt shorter than its prefill window. The KV-cache append
and RoPE kernels derive the past from seqlens_k on the device too, and an
unclamped negative past made the append kernels skip the batch, leaving
the cache unwritten while the output could still look right.

Also compare present_key / present_value slots [0, real_count) with the
CPU reference of the unpadded prompt, and require them to be non-zero.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Made-with: Claude Code
seqlens_k cannot say where a window's padding is or whether a past
precedes it: 10 tokens after 40 cached ones, padded to a 128-row window,
arrive exactly as a 50-token first prompt. Reading every padded window as
a first prompt therefore computed such a step over the cache, writing its
rows from slot 0 over the cached tokens, where the call used to be
refused.

A padded window is now read as a first prompt (tokens first, padding
after, no past) only when total_sequence_length equals the window, which
is how ORT marks a first prompt and what ORT CPU accepts. Any other
padded window is refused again, before RoPE and the KV-cache write. The
runtime used to ignore the scalar; it is read only for a padded prefill
window. Layouts that carry the same scalars - a first prompt padded
before its tokens, or a padded continuation reporting the window as its
total - are not told apart and are not supported. Fixed-cache exports
whose total is the cache size are refused too; their short prompts need
the caller to pass the window.

Tests:
- the padded fixed-cache test passes total = the window, and gains a
  full-window case;
- a padded first prompt with RoPE inside GQA (1, 22 and 128 tokens) and
  nonzero padding rows, compared with the same tokens unpadded on ORT CPU
  and with ORT CPU on the padded graph;
- a padded prefill followed by a decode step on caches bound in place
  (past and present the same GPU buffers), compared with ORT CPU;
- three padded windows that must be refused (a step after a past, total
  = the token count, total = the cache size), checked by the refusal
  diagnostic of that run and by in-place caches that come back byte for
  byte unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Made-with: Claude Code
The decomposed hipBLASLt pipeline derived the past the same way the fused one
did and rejected a prompt shorter than the window. It is the path for head
sizes the fused kernels do not template -- 96 on phi-silica -- so such a
model still failed every layer on a short prompt.

It now clamps the past at 0 like gqa_forward_fused. The causal mask takes
past_len explicitly and the scores span total_seq keys, so padding rows
attend to the prompt alone; the per-chunk key narrowing, which assumes
total_seq == past_len + sq, already switches itself off here.

On gfx1151, phi-silica (head_dim 96, 64-row window): a 5-token prompt gives
bit-identical hidden rows and KV positions 0..4 against the full window.

Co-Authored-By: Claude <noreply@anthropic.com>
Made-with: Claude Code
The decomposed hipBLASLt pipeline had the same short-first-prompt bug as
the fused prefill. Head size 96 no longer reaches it (the fused kernels
take 64, 96, 128 and 256 now); float32 activations with an unquantized
cache still do, because they are not fused_supported.

Parametrize test_gqa_padded_chunk_prefill_llama_shape over float16
(fused) and float32 (decomposed hipBLASLt), adding an element-type
argument to _make_gqa_fixed_cache_model. The CPU reference
(onnxruntime 1.31.0) accepts both types for the unpadded model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Made-with: Claude Code
… on hipBLASLt too

The decomposed hipBLASLt pipeline clamped every negative past to 0, so a
padded step after a past was computed from cache slot 0 over the cached
tokens, as the fused path did before. It now takes the fused path's rule
(gqa_padded_window_is_first_prompt): a padded window is a first prompt
only when total_sequence_length equals the window, and is refused
otherwise, before RoPE and the KV-cache write.

The padded-window tests run on both paths: fp16 takes the fused kernels,
fp32 the hipBLASLt pipeline.
- the RoPE case against the unpadded ORT CPU result;
- the in-place prefill-then-decode case;
- the three refused windows, each expecting its path's diagnostic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Made-with: Claude Code
@ximzhang-amd
ximzhang-amd force-pushed the fix/gqa-short-first-prompt-hipblaslt branch from 186551b to 5683117 Compare October 28, 2026 02:51

This branch has not been deployed

No deployments
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.

3 participants