[AMD] Enable GLM DSA prefill top-k to the v2 kernel - #37889
Open
EricKing626 wants to merge 16 commits into
Open
EricKing626 wants to merge 16 commits into
EricKing626 wants to merge 16 commits into
Conversation
The v2 fused top-k + page-table transform assumes the decode layout: row i selects over scores[i, :seq_lens[i]] and maps through page_tables[i]. DSA extend cannot describe itself that way -- every request's scores are packed into one row-major buffer, so a row's window starts at a batch-global column offset, and a request contributes many rows that all share its single page-table row. Add two optional per-row indirections, row_starts (score column offset) and row_to_batch (page-table row), so that layout needs neither a row-local copy of the score buffer nor a per-row expansion of the page table -- at 327K context the latter would be hundreds of MB per forward. Offsetting the score pointer also keeps the selected index row-local, which is what the transform already expects, so the emit path is untouched. Both default to null and reproduce the previous addressing exactly, so this commit is a capability addition with no behavior change. The page-table row count is only required to match the score row count when the mapping is absent; with it the caller owns that bound, which is the one invariant the kernel cannot check.
GLM-5.2 runs prefill with a dsa_prefill_backend outside the flashmla_sparse family (tilelang), so get_topk_transform_method already returns PAGED for EXTEND rather than RAGGED. Despite that, prefill never reached the v2 fused transform: the dispatch also required row_starts to be absent and the score row count to equal the page-table row count, and extend violates both -- it passes ks and has many rows per request. It therefore fell to the legacy transform, which gathers the wide page_size=1 table. Now that the kernel can address packed rows, dispatch extend to it as well, feeding ks as row_starts and the metadata's token_to_batch_idx as row_to_batch. The plan needs no new work: it is already built per forward over dsa_seqlens_expanded, whose row count is exactly what v2 sees. Two shapes deliberately stay on the legacy path. Chunked extend, because its plan spans the whole forward while each call sees one chunk, and any extend whose row stride is not a multiple of 4, because the kernel's vectorized load requires that and an extend row stride is the batch's total KV length -- only aligned by luck. Both are checked in the dispatch rather than left to the helper's assertions, so they fall back instead of raising. The decode condition is left byte-identical: dsa_drop_wide_page_table drops the page_size=1 table for exactly that condition, and the two must stay in sync or the legacy transform would read a table that no longer exists.
EricKing626
force-pushed
the
amd/topk-v2-prefill
branch
from
September 4, 2026 02:42
2d3b18f to
459ac47
Compare
EricKing626
marked this pull request as ready for review
September 4, 2026 08:55
EricKing626
requested review from
1am9trash,
BBuf,
DarkSharpness,
Fridge003,
HaiShaw,
HydraQYH,
Qiaolin-Yu,
YAMY1234,
celve,
hebiao064,
hubertlu-tw,
ispobock,
kkHuang-amd,
merrymercy,
rainj-me and
yuan-luo
as code owners
September 4, 2026 08:55
A window start is a running KV length, so it lands on a 16-byte boundary only by luck, and the previous commit offset the score pointer by it directly. The vectorized load then faults: CI aborted on the ragged case [1, 13, 2], whose starts are 0 / 1537 / 3086. Do what topk_ragged_kernel already does for the same reason. Round the read base down to the vector boundary, mask the <= 3 columns that pulls in ahead of the window (they belong to the preceding request, so they are finite scores that would otherwise win the selection), widen seq_len by the residue, and subtract it back at the page lookup. The mask lands after the PDL wait, or the indexer overwrites it. The residue is a read-window artifact, so every decision stays on the row's real length: the trivial path takes the un-rounded problem and reads no scores at all, and the cluster routing threshold is compared against the same length the plan used. The widened seq_len cannot leave the dispatch level's bound because the window end is unchanged and the level comes from the score column count. The cluster path is excluded on the host whenever row_starts is set: there one row is split across the blocks of a cluster, so the head mask would need a cluster-wide barrier to be visible, whereas every other path has one block per row and publishes it with the __syncthreads() that already opens forward(). Zero residue reproduces the previous addressing exactly, so nothing changes for callers that do not pass row_starts.
…sglang into amd/topk-v2-prefill
The packed-row extension of the paged top-k v2 transform (`row_starts` / `row_to_batch`, plus the 16-byte read-base alignment it needs) only exists because ROCm has no other route: on CUDA, DSA extend reaches the fused transform through `topk_transform_ragged`, while on ROCm `get_topk_transform_ method` returns PAGED for EXTEND and the ragged kernel is unreachable. Compile the extension out on non-ROCm builds so the CUDA paths are unchanged: - `TopKPagedParams`: `scores` goes back to `const float*`, and `row_starts` / `row_to_batch` (with `head_residue` / `mask_head`) exist only under `USE_ROCM`. `problem()` builds the original problem and re-points it only on ROCm when packed rows are supplied. - `TopKProblem::index_shift` and its use in `transform_output` are `USE_ROCM` only; `transform_output` keeps its original form otherwise. - `topk_main_kernel`: the residue / head-mask block is `USE_ROCM` only; the CUDA build keeps the plain `seq_len <= topk` trivial check and the cluster dispatch loses the `row_starts == nullptr` exclusion, which is now dead there. - Host `transform_paged` rejects `row_starts` / `row_to_batch` with a `RuntimeCheck` on non-ROCm instead of wiring them up. Python side, the `is_xpu()` branch of `topk_transform_paged_v2` is restored to its original body; the packed-row precondition moves out into its own check that asserts `is_hip()` only when packed rows are actually passed. The PAGED extend dispatch in `dsa_topk_backend` is gated on `is_hip()`, and `test_topk_v2_packed_rows` is skipped off ROCm. No functional change on ROCm.
EricKing626
force-pushed
the
amd/topk-v2-prefill
branch
from
September 8, 2026 07:22
28c4585 to
960ae11
Compare
row_starts shifts the read window and records the correction in index_shift, but index_shift is only applied by transform_output. In INDICES mode (page_table absent) emit() writes the raw index, so the selected positions come back off by up to kVecSize-1 with no diagnostic. No caller needs that combination, so reject it on the host instead of leaving it unguarded.
mask_head writes the pulled-in residue columns back into scores. If the caller passes a view whose row stride is smaller than its row width, one row's head mask lands in the previous row's tail and corrupts scores the kernel has not read yet. Assert stride >= width on the packed path.
topk_transform_paged_v2 masks the head-alignment residue columns to -inf inside the score tensor, but the docstring described the call as read-only. Say so explicitly, along with the two constraints it implies: scores cannot be reused afterwards, and its rows must not overlap.
EricKing626
force-pushed
the
amd/topk-v2-prefill
branch
from
September 9, 2026 08:18
e416f58 to
b0ccf96
Compare
5 tasks
Contributor
|
/rerun-failed-ci |
1 similar comment
Contributor
Author
|
/rerun-failed-ci |
The packed-row path rounds each row's read base down to a 16-byte boundary, so a selected position is up to kVecSize-1 too large. That correction lived in its own TopKProblem field applied inside transform_output, which forced a USE_ROCM branch into a code path CUDA also compiles. bias already exists for the same shape of correction: ragged mode adds the row's offset into the flattened output there. The two never coexist - paged callers have no output offset, and ragged never rounds down - so bias = -residue carries the round-down instead. The correction now happens once in emit(), which both output modes share, and transform_output goes back to its single unbranched form.
Conflicts came from upstream's new DUAL_OUTPUT mode (raw_indices), which lands on the same lines as the packed-row (row_starts) support. - params: keep both head_residue/mask_head and get_raw_output_ptr - topk_main_kernel: keep the ROCm residue prologue, and pass get_raw_output_ptr(blockIdx.x) to trivial_transform on both branches - transform_paged: signature takes raw_indices, then row_starts and row_to_batch, so existing positional callers are unaffected - reject row_starts + raw_indices (host-side, C++ and Python): the raw output is written straight from the index register and never goes through emit, so it would miss the residue correction that bias carries
DarkSharpness
requested changes
Sep 14, 2026
DarkSharpness
left a comment
Collaborator
There was a problem hiding this comment.
Hi. Could you please temporarily hold on until #38798 ? We have some critical update on v2 algorithm, which introduce many new mechanisms for that (like padding).
Also, some comments on this PR: please try to avoid adding too many comments in the code (especially for parts that remain unchanged). Please keep the comments brief within 1 ~ 2 lines
pre-commit reflows this call onto one line (114 cols, under the limit).
Per review: keep added comments brief and drop the ones attached to code this PR does not change (the cluster-path note under #ifndef USE_ROCM, and a stale duplicate above the page_table RuntimeCheck).
Restore the original bias comment; the field's behaviour is unchanged.
Contributor
Author
|
/rerun-failed-ci |
1 similar comment
Contributor
Author
|
/rerun-failed-ci |
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.
Motivation
Extend the DSA top-k v2 kernel to packed score rows and route GLM-5.x prefill through it. The prefill top-k kernel drops ~73%; at ISL 70000 / OSL 300 that is +4.9% token throughput per GPU, −3.5% median TPOT and −4.8% median TTFT (geomean over concurrency 4-64); GSM8k 0.927.
PR #36684 and PR #36851 turned the v2 fused top-k on for GLM-5.x on ROCm, but only decode ever reached it. Prefill still ran the legacy paged transform, which gathers the wide
page_size=1table.The blocker was addressing, not math. The v2 kernel assumed each row starts at column 0 of its own score row and owns one page-table row. DSA extend breaks both: all requests share one packed score buffer, so a row starts at some column in the middle, and every row of a request reads the same page-table row.
Two small lookup tables fix that -- one for where a row starts, one for which page-table row it uses. No copy of the score buffer, and no per-row page table, which at 327K context would be hundreds of MB per forward.
Modifications
topk_v2.cuh,dsv4/topk.py-- add two optional(rows,)int32 indirections totopk_transform_512_v2:iscores[i, :seq_lens[i]]scores[i, row_starts[i] : row_starts[i] + seq_lens[i]]page_tables[i]page_tables[row_to_batch[i]]Both default to null and give exactly the old addressing, so nothing changes for existing callers. Shifting the score pointer keeps the selected index row-local, which is what the transform already expects, so the output path is untouched. The page-table row count has to match the score row count only when
row_to_batchis absent; with it, the caller owns that bound.dsa/dsa_topk_backend.py-- route packed PAGED extend to_topk_transform_v2_paged, passingksasrow_startsandtoken_to_batch_idxasrow_to_batch. GLM-5.x already qualifies as PAGED forEXTEND, and the plan needs no new work: it is already built once per forward overdsa_seqlens_expanded, whose row count is what v2 sees.Two shapes still take the legacy path. The dispatch checks for them itself, so they fall back instead of tripping the helper's asserts:
--chunked-prefill-sizeeach chunk is its own forward with its own plan, and does take the v2 path.The decode condition is left byte-identical, because
dsa_drop_wide_page_tabledrops thepage_size=1table on exactly that condition and the two must stay in sync.dsa_backend.py-- comment only, recording that packed PAGED extend is now a plan consumer.Accuracy Tests
GSM8k, 8x MI355X (gfx950), GLM-5.2-MXFP4, TP=4, non-MTP, with this PR:
test/registered/kernels/ops/attention/test_topk_v2.pyon gfx950:250 passed, 3 warnings in 19.99s, including the newtest_topk_v2_packed_rowscases that check therow_starts/row_to_batchlayout against a row-local reference.Speed Tests and Profiling
Baseline is #29xxx as merged (top-k v2 on, prefill still legacy); this PR is the prefill routing on top of it. Same build, same flags, non-MTP, 8x MI355X, TP4.
Kernel-level --
topk_main_kernelnow replacestopk_transform_prefill_kernelon every prefill launch; the old kernel is gone from the traces entirely. ISL 70000 / OSL 300, per TP rank, matching launch grids:Per rank at conc 4: 882.9 ms → 234.9 ms of top-k over the profiled window.
End-to-end -- ISL 70000 / OSL 300,
--max-running-requests 8. Geomeans are over the per-config ratios.Interactivity is
1000 / median TPOT; Token TPUT per GPU is(input + output) tok/s / 4for one TP4 server. Concurrency above 8 exceeds the admission cap, so TTFT there is mostly queueing time.Reproduce:
Checklist
Review and Merge Process
/tag-and-rerun-ci,/tag-run-ci-label,/rerun-failed-ciCI States
Latest PR Test (Base): 🚫 Run #34846434360
Latest PR Test (Extra): ❌ Run #34846433791
Latest PR Test (AMD ROCm 10): ❌ Run #34846434075