[ROCm][Perf] Replace torch.topk in DSA candidate block selection (#58208)

Signed-off-by: fai <fangzhouai@gmail.com> Co-authored-by: TJian <tunjian.tan@embeddedllm.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>

Fangzhou Ai committed Sep 28, 2026 at 10:42 UTC 6b24bd8e75a3e465083e8a131968915bc00efaf4
1 file changed +256
vllm/model_executor/kernels/attention/dsa/candidate_blocks.py
+256
@@ -3,8 +3,13 @@
3
4 import torch
5
6 +from vllm.platforms import current_platform
7 from vllm.triton_utils import tl, triton
8
9 +_IDX_BITS = tl.constexpr(20)
10 +_IDX_MASK = tl.constexpr((1 << 20) - 1)
11 +_NEG_INF = tl.constexpr(-float("inf"))
12 +
13
14 @triton.jit
15 def _max_with_nan(a, b):
@@ -138,6 +143,252 @@ def _mask_candidates_kernel(
143 )
144
145
146 +@triton.jit
147 +def _pack_key(score, index, valid):
148 + """Order-preserving int64 key: float order << _IDX_BITS | ~index.
149 +
150 + Flipping the sign bit (all bits when negative) maps IEEE order onto
151 + unsigned order. The index rides inverted in the low bits so ties resolve to
152 + the smaller index, matching torch.topk. NaN is forced above +inf because
153 + torch.topk ranks it highest, and _block_scores_kernel can produce it.
154 + Invalid lanes pack to 0, below every valid key.
155 + """
156 + bits = score.to(tl.uint32, bitcast=True)
157 + ordered = bits ^ tl.where(bits >> 31 != 0, 0xFFFFFFFF, 0x80000000)
158 + ordered = tl.where(score != score, 0xFFFFFFFF, ordered)
159 + tie = (_IDX_MASK - index).to(tl.int64)
160 + return tl.where(valid, (ordered.to(tl.int64) << _IDX_BITS) | tie, 0)
161 +
162 +
163 +@triton.jit
164 +def _scan_range_topk(scores, row, lo, hi, nblocks, K_PAD: tl.constexpr, TILE):
165 + """Running top-K of packed keys over blocks [lo, hi) of one row."""
166 + best = tl.zeros((K_PAD,), dtype=tl.int64)
167 + for start in range(lo, hi, TILE):
168 + cols = start + tl.arange(0, TILE)
169 + valid = cols < hi
170 + score = tl.load(scores + row * nblocks + cols, valid, other=_NEG_INF)
171 + # -inf marks padding; it must never take a slot.
172 + key = _pack_key(score, cols, valid & (score != _NEG_INF))
173 + best = tl.topk(tl.join(best, tl.topk(key, K_PAD)).reshape(2 * K_PAD), K_PAD)
174 + return best
175 +
176 +
177 +@triton.jit
178 +def _live_blocks(starts, ends, row, nblocks, BLOCK_SIZE, HAS_STARTS, ROW_REPEAT):
179 + """Blocks this row can populate, matching _block_scores_kernel's extent.
180 +
181 + The logits workspace is padded to the full width, so past a row's end every
182 + block scores -inf and can never win a slot. Bounding the scan here is what
183 + torch.topk cannot do: it has no per-row extent.
184 + """
185 + bound = row // ROW_REPEAT
186 + start = tl.load(starts + bound) if HAS_STARTS else 0
187 + end = tl.load(ends + bound)
188 + live = (end - start + BLOCK_SIZE - 1) // BLOCK_SIZE
189 + return tl.minimum(tl.maximum(live, 1), nblocks).to(tl.int32)
190 +
191 +
192 +@triton.jit
193 +def _active_chunks(live, NUM_CHUNKS: tl.constexpr, TILE: tl.constexpr):
194 + """Chunks worth running: each should own a full tile, else short contexts
195 + pay NUM_CHUNKS tile-wide top-ks to look at almost nothing."""
196 + return tl.minimum(tl.maximum((live + TILE - 1) // TILE, 1), NUM_CHUNKS)
197 +
198 +
199 +@triton.jit
200 +def _write_candidates(
201 + best,
202 + out,
203 + row,
204 + stride_row,
205 + stride_col,
206 + OUT_K: tl.constexpr,
207 + K_PAD: tl.constexpr,
208 + TAIL: tl.constexpr,
209 +):
210 + slots = tl.arange(0, K_PAD)
211 + index = (_IDX_MASK - (best & _IDX_MASK)).to(tl.int32)
212 + tl.store(
213 + out + row * stride_row + slots * stride_col,
214 + tl.where(best != 0, index, -1),
215 + slots < OUT_K,
216 + )
217 + if TAIL > 0:
218 + # Fewer blocks than requested candidates: pad the unused slots.
219 + rest = K_PAD + tl.arange(0, TAIL)
220 + tl.store(out + row * stride_row + rest * stride_col, -1, rest < OUT_K)
221 +
222 +
223 +@triton.jit(do_not_specialize=["nblocks"])
224 +def _row_topk_kernel(
225 + scores,
226 + starts,
227 + ends,
228 + out,
229 + nblocks,
230 + stride_row,
231 + stride_col,
232 + BLOCK_SIZE: tl.constexpr,
233 + HAS_STARTS: tl.constexpr,
234 + ROW_REPEAT: tl.constexpr,
235 + OUT_K: tl.constexpr,
236 + K_PAD: tl.constexpr,
237 + TAIL: tl.constexpr,
238 + TILE: tl.constexpr,
239 +):
240 + row = tl.program_id(0).to(tl.int64)
241 + hi = _live_blocks(starts, ends, row, nblocks, BLOCK_SIZE, HAS_STARTS, ROW_REPEAT)
242 + best = _scan_range_topk(scores, row, 0, hi, nblocks, K_PAD, TILE)
243 + _write_candidates(best, out, row, stride_row, stride_col, OUT_K, K_PAD, TAIL)
244 +
245 +
246 +@triton.jit(do_not_specialize=["nblocks"])
247 +def _row_topk_partial_kernel(
248 + scores,
249 + starts,
250 + ends,
251 + partial,
252 + nblocks,
253 + BLOCK_SIZE: tl.constexpr,
254 + HAS_STARTS: tl.constexpr,
255 + ROW_REPEAT: tl.constexpr,
256 + NUM_CHUNKS: tl.constexpr,
257 + K_PAD: tl.constexpr,
258 + TILE: tl.constexpr,
259 +):
260 + row = tl.program_id(0).to(tl.int64)
261 + chunk = tl.program_id(1)
262 + live = _live_blocks(starts, ends, row, nblocks, BLOCK_SIZE, HAS_STARTS, ROW_REPEAT)
263 + # Split on device from this row's live extent: a host-side bound would cost
264 + # a sync, and the padded width says nothing about the context.
265 + active = _active_chunks(live, NUM_CHUNKS, TILE)
266 + if chunk < active:
267 + split = (live + active - 1) // active
268 + lo = chunk * split
269 + hi = tl.minimum(lo + split, live)
270 + best = _scan_range_topk(scores, row, lo, hi, nblocks, K_PAD, TILE)
271 + slots = tl.arange(0, K_PAD)
272 + tl.store(partial + row * (NUM_CHUNKS * K_PAD) + chunk * K_PAD + slots, best)
273 +
274 +
275 +@triton.jit(do_not_specialize=["nblocks"])
276 +def _row_topk_merge_kernel(
277 + partial,
278 + starts,
279 + ends,
280 + out,
281 + nblocks,
282 + stride_row,
283 + stride_col,
284 + BLOCK_SIZE: tl.constexpr,
285 + HAS_STARTS: tl.constexpr,
286 + ROW_REPEAT: tl.constexpr,
287 + NUM_CHUNKS: tl.constexpr,
288 + OUT_K: tl.constexpr,
289 + K_PAD: tl.constexpr,
290 + TAIL: tl.constexpr,
291 + TILE: tl.constexpr,
292 +):
293 + row = tl.program_id(0).to(tl.int64)
294 + live = _live_blocks(starts, ends, row, nblocks, BLOCK_SIZE, HAS_STARTS, ROW_REPEAT)
295 + active = _active_chunks(live, NUM_CHUNKS, TILE)
296 + lanes = tl.arange(0, NUM_CHUNKS * K_PAD)
297 + # Chunks the partial pass skipped hold stale keys; mask them out.
298 + keys = tl.load(
299 + partial + row * (NUM_CHUNKS * K_PAD) + lanes,
300 + (lanes // K_PAD) < active,
301 + other=0,
302 + )
303 + _write_candidates(
304 + tl.topk(keys, K_PAD), out, row, stride_row, stride_col, OUT_K, K_PAD, TAIL
305 + )
306 +
307 +
308 +# One wave of workgroups on MI355X; below this a program-per-row leaves most
309 +# CUs idle and splitting the row pays for the extra merge.
310 +_TARGET_PROGRAMS = 256
311 +# The merge top-k spans NUM_CHUNKS*K_PAD lanes whether or not those chunks ran,
312 +# so more chunks buy parallelism at a cost the low-row cases cannot absorb.
313 +_MAX_CHUNKS = 8
314 +_TILE = 512
315 +
316 +
317 +def _rocm_select_topk(
318 + scores: torch.Tensor,
319 + row_ks: torch.Tensor | None,
320 + row_ke: torch.Tensor,
321 + nblocks: int,
322 + topk_blocks: int,
323 + out: torch.Tensor,
324 + row_repeat: int,
325 + block_size: int,
326 +) -> None:
327 + """Fused block top-k for ROCm, replacing topk + _store_candidates_kernel.
328 +
329 + torch.topk runs a multi-pass radix select that sorts values it does not
330 + need -- the caller only consumes the *set* of block ids -- and scans the
331 + padding because it cannot see per-row extents.
332 + """
333 + rows = scores.shape[0]
334 + k_pad = triton.next_power_of_2(min(topk_blocks, nblocks))
335 + # _scan_range_topk does a running top-K_PAD merge one TILE-wide slice at a
336 + # time; tl.topk silently corrupts its output if asked for more elements
337 + # than the slice holds, so the tile must never be smaller than K_PAD.
338 + tile = max(_TILE, k_pad)
339 +
340 + num_chunks = 1
341 + if rows < _TARGET_PROGRAMS:
342 + # Sized only to fill the machine; how many actually run is decided per
343 + # row on device from its live extent.
344 + want = min(
345 + triton.cdiv(_TARGET_PROGRAMS, max(rows, 1)),
346 + _MAX_CHUNKS,
347 + max(1, nblocks // tile),
348 + )
349 + num_chunks = max(1, triton.next_power_of_2(want + 1) // 2)
350 +
351 + common = dict(
352 + BLOCK_SIZE=block_size,
353 + HAS_STARTS=row_ks is not None,
354 + ROW_REPEAT=row_repeat,
355 + OUT_K=topk_blocks,
356 + K_PAD=k_pad,
357 + TAIL=triton.next_power_of_2(topk_blocks - k_pad) if topk_blocks > k_pad else 0,
358 + TILE=tile,
359 + )
360 + if num_chunks == 1:
361 + _row_topk_kernel[(rows,)](
362 + scores, row_ks, row_ke, out, nblocks, *out.stride(), **common
363 + )
364 + return
365 +
366 + partial = scores.new_empty((rows, num_chunks * k_pad), dtype=torch.int64)
367 + _row_topk_partial_kernel[(rows, num_chunks)](
368 + scores,
369 + row_ks,
370 + row_ke,
371 + partial,
372 + nblocks,
373 + BLOCK_SIZE=block_size,
374 + HAS_STARTS=row_ks is not None,
375 + ROW_REPEAT=row_repeat,
376 + NUM_CHUNKS=num_chunks,
377 + K_PAD=k_pad,
378 + TILE=tile,
379 + )
380 + _row_topk_merge_kernel[(rows,)](
381 + partial,
382 + row_ks,
383 + row_ke,
384 + out,
385 + nblocks,
386 + *out.stride(),
387 + NUM_CHUNKS=num_chunks,
388 + **common,
389 + )
390 +
391 +
392 def select_candidate_blocks(
393 logits: torch.Tensor,
394 row_ks: torch.Tensor | None,
@@ -176,6 +427,11 @@ def select_candidate_blocks(
427 row_repeat,
428 128,
429 )
430 + if current_platform.is_rocm():
431 + _rocm_select_topk(
432 + scores, row_ks, row_ke, nblocks, topk_blocks, out, row_repeat, block_size
433 + )
434 + return
435 # Keep the existing top-k tie behavior.
436 top = scores.topk(min(topk_blocks, nblocks), dim=-1)
437 _store_candidates_kernel[(rows, triton.cdiv(topk_blocks, 256))](