[triton][mqa] fix silent tail-row drop in deepgemm_fp8_paged_mqa_logits at large output stride - #4244
Merged
Merged
Conversation
Contributor
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
|
zejunchen-zejun
force-pushed
the
zejun/fix_paged_mqa_logits_i32_offset_overflow
branch
from
July 15, 2026 02:25
ed5e117 to
fc35def
Compare
zejunchen-zejun
marked this pull request as ready for review
July 15, 2026 07:08
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a correctness bug in the paged-MQA FP8 logits Triton path where AMD buffer_store silently drops stores once the per-row output byte offset crosses the 2**31 boundary, leaving tail rows unwritten for very wide dense logits tensors.
Changes:
- Update Gluon
buffer_storeaddressing to advance the base pointer in 64-bit (ptr += row*stride) while keepingvoffsetsmall (column-only), preventing 32-bit byte-offset overflow drops. - Widen
stride_out_batchto 64-bit in the wrapper signature and ensure non-Gluon kernels treatstride_out_batchastl.int64. - Add a regression test that crosses the 2**31 byte-offset boundary and asserts both “all rows touched” and bit-identical results vs a compact reference.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| op_tests/test_pa_mqa_logits_offset.py | Adds a regression test that reproduces the tail-row drop at wide output stride and validates row coverage + correctness. |
| aiter/ops/triton/gluon/pa_mqa_logits.py | Fixes Gluon buffer_store writes by using 64-bit base-pointer advancement per output row. |
| aiter/ops/triton/attention/pa_mqa_logits.py | Updates kernel signature to use i64 for stride_out_batch and removes tt.pointer_range constraint for the output pointer. |
| aiter/ops/triton/_triton_kernels/attention/pa_mqa_logits.py | Types stride_out_batch as tl.int64 so row-stride address arithmetic promotes to 64-bit. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return out | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires a ROCm GPU") |
Comment on lines
+101
to
+103
| def test_paged_mqa_logits_wide_output_no_tail_drop(batch_size): | ||
| rows = batch_size * NEXT_N | ||
| assert rows > 512, "shape must cross the 2**31 boundary (needs batch*next_n > 512)" |
…mqa_logits Root cause: the Gluon kernel writes logits with gl.amd.cdna3.buffer_store, whose AMD hardware voffset is a 32-bit byte offset. The store address for output row r is `r * stride_out_batch * elem_size`. Once that reaches 2**31 the offset overflows and the store is silently dropped, leaving the tail rows unwritten. This bites callers that allocate a wide dense logits tensor. GLM-5.2 sparse-MLA (DSA) MTP decode allocates logits as [batch*next_n, max_model_len] with max_model_len = 1<<20; at con>=256 with next_n=4 that is [1024, 1<<20], so stride_out_batch = 1<<20 and row 512 hits exactly 512*(1<<20)*4 = 2**31. Rows 512..1023 are never written -> top-k reads all-zero rows -> wrong sparse-KV indices -> MTP acceptance collapses (~50% -> ~25%). Fix: - Gluon path (gluon/pa_mqa_logits.py): advance the OutLogits base pointer in 64 bit (`OutLogits_buffer + row.to(int64) * stride_out_batch`) and keep the buffer_store `offsets` as the int32 column index only. buffer_store requires int32 offsets, so the large row offset must live in the (64-bit) base pointer, not the voffset. Applied to all 19 stores across the 3 gluon kernels. - Wrapper (attention/pa_mqa_logits.py): declare stride_out_batch as i64 and drop the tt.pointer_range 32 hint on OutLogits_buffer so the base can be addressed beyond 2 GB. - Non-gluon path (_triton_kernels/attention/pa_mqa_logits.py): type all stride_out_batch params as tl.int64 so `row * stride_out_batch` promotes to int64 before tl.store (tl.store already does 64-bit addressing). Matches the one kernel there that was already i64. Verified on gfx942 (MI308X) with a standalone reproducer (no ATOM/top-k): - before: one-shot [256,4] x physical_cols=1<<20 writes 512/1024 rows (first_untouched_row=512); at physical_cols=600000 it is 895 (= 2**31 boundary) - after: writes 1024/1024 rows at both widths, and single-shot output is bit-identical to the chunked (<=512-row) reference for all 1024 rows; small width (4096) unchanged (no regression). Note: the gluon path is the default (enable_gluon_pa_mqa_logits=True) and is what GLM exercises; it is GPU-verified. The non-gluon typing fix is by inspection (same root cause, tl.store handles i64, consistent with the existing i64 kernel). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds op_tests/test_pa_mqa_logits_offset.py, which exercises the real failing
layout of deepgemm_fp8_paged_mqa_logits: max_model_len=1<<20 and
batch*next_n in {516, 1024} (crossing the row-512 / 2**31 byte-offset boundary).
It asserts (1) every output row is written (no sentinel left) and (2) the wide
dense output is bit-identical to a compact-width reference (whose own row offsets
never cross 2**31), with an explicit check on rows >= 512.
This guards a *silent* bug (no crash, just dropped tail rows) that the existing
pa-mqa tests could not catch because they use a small max_model_len. Verified on
gfx942 (MI308X): FAILS on the pre-fix kernel ("512/1024 rows left unwritten,
first_untouched_row=512") and PASSES with the base-pointer 64-bit fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zejunchen-zejun
force-pushed
the
zejun/fix_paged_mqa_logits_i32_offset_overflow
branch
from
July 16, 2026 02:54
9a3af8b to
ae4740d
Compare
1 task
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
At large MTP batches the sparse-indexer decode path issues one big deepgemm_fp8_paged_mqa_logits + top_k_per_row_decode launch whose later rows can retain stale sparse-KV indices in the persistent buffer (rows beyond the CU wave capacity), so requests past the cliff attend to wrong KV and their drafts get rejected -> acceptance halves (~50% -> ~25% at con256, cliff at seq 128), so when specify max num seqs to the value which is smaller than 128, the accept ratio restores to the normal value, while for default 512, most of requests has low draft token accept ratio. Here is the histogram illustration.

Root cause: the Gluon kernel stores logits with gl.amd.cdna3.buffer_store, whose AMD hardware voffset is a 32-bit byte offset. The output address for row r is
r * out_logits.stride(0) * out_logits.element_size(). Once that reaches 2**31 the offset overflows and the store is silently dropped, leaving the tail rows unwritten (they keep their prior/zero contents).This bites callers that allocate a wide dense logits tensor. Concretely, GLM-5.2 sparse-MLA (DSA) MTP decode allocates logits as [batchnext_n, max_model_len] with max_model_len = 1<<20; at con>=256 with next_n=4 that is [1024, 1<<20], so stride(0)=1<<20 and row 512 hits exactly 512 * (1<<20) * 4 = 2**31 bytes. Rows 512..1023 are never written -> top-k reads all-zero rows -> wrong sparse-KV indices -> MTP acceptance collapses (~50% -> ~25%). Verified with a standalone reproducer: with physical_cols=1<<20 the first untouched row is 512; with physical_cols=600000 it moves to 895 (= where rstride*4 first crosses 2**31), proving it is a byte-offset boundary, not a fixed row count.
Fix
Address the output in 64-bit by advancing the base pointer, keeping the buffer_store voffset a small int32 column index:
This is a single-launch fix — no chunking, no extra kernel launches, no change to the non-overflowing path.
Here is the validation result by model side:
atom
atom-vllm
small reproducer: