[Bugfix] Revert "[ROCm][Perf] Replace torch.topk in DSA candidate block selection" (#59125)

Signed-off-by: Fangzhou Ai <31551580+Fangzhou-Ai@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>

Fangzhou Ai committed Sep 28, 2026 at 22:11 UTC cf971b419a534472a99cc951f8f3e32a109e9fcc
1 file changed -256
vllm/model_executor/kernels/attention/dsa/candidate_blocks.py
-256
@@ -3,13 +3,8 @@
3
4 import torch
5
6 -from vllm.platforms import current_platform
6 from vllm.triton_utils import tl, triton
7
9 -_IDX_BITS = tl.constexpr(20)
10 -_IDX_MASK = tl.constexpr((1 << 20) - 1)
11 -_NEG_INF = tl.constexpr(-float("inf"))
12 -
8
9 @triton.jit
10 def _max_with_nan(a, b):
@@ -143,252 +138,6 @@ def _mask_candidates_kernel(
138 )
139
140
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 -
141 def select_candidate_blocks(
142 logits: torch.Tensor,
143 row_ks: torch.Tensor | None,
@@ -427,11 +176,6 @@ def select_candidate_blocks(
176 row_repeat,
177 128,
178 )
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
179 # Keep the existing top-k tie behavior.
180 top = scores.topk(min(topk_blocks, nblocks), dim=-1)
181 _store_candidates_kernel[(rows, triton.cdiv(topk_blocks, 256))](