[Perf][Attention] Remove D2H sync from FlashInfer SM90 sparse MLA plan under async scheduling (#58684)
Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai>
Nicolò Lucchesi committed
Sep 27, 2026 at 13:06 UTC
c8d7a7dd13e2b40c013fb6d46be800937e4335fb
2 files changed
+381
-106
tests/v1/attention/test_flashinfer_mla_sparse_sm90.py
+181
-28
@@ -9,6 +9,7 @@ causality), ckv/kpe cache splitting, and the backend's model-shape gates.
9
"""
10
11
from types import SimpleNamespace
12
+from typing import Any
13
14
import pytest
15
import torch
@@ -50,6 +51,8 @@ class FakeWrapper:
51
def __init__(self):
52
self.plan_args = None
53
self.run_args = None
54
+ self._plan_info: Any = None
55
+ self._planned_backend: Any = None
56
57
def plan(self, *args, **kwargs):
58
self.plan_args = (args, kwargs)
@@ -69,8 +72,11 @@ class FakeState:
72
self.kv_indptr = torch.zeros(max_tokens + 1, dtype=torch.int32)
73
self.wrapper = FakeWrapper()
74
self.plan_calls = []
75
+ self.index_topk = 2048
76
+ self.index_kpool = 4
77
+ self.max_valid = 2051
78
73
- def plan(self, num_tokens, kv_lens):
79
+ def plan(self, num_tokens, kv_lens, cam, req_id_per_token):
80
self.plan_calls.append((num_tokens, kv_lens))
81
82
def pack_indices(self, slots):
@@ -168,13 +174,12 @@ def test_forward_wiring(monkeypatch, qk_rope, kv_dtype):
174
def test_builder_plans_only_rows_dispatched_to_mqa(monkeypatch, use_mha, num_decodes):
175
"""MHA prefill rows must not make the MQA kernel read beyond its query."""
176
builder = object.__new__(FlashInferMLASparseSM90Builder)
171
- builder._index_topk = 2048
172
- builder._index_kpool = 4
173
- builder._async_scheduling = False
177
+ builder._adaptive_verification = False
178
builder.state = FakeState(TOPK)
179
builder._attention_layer = SimpleNamespace(_use_sparse_mha=lambda _: use_mha)
180
metadata = object.__new__(sm90_mod.FlashInferMLASparseSM90Metadata)
181
metadata.state = None
182
+ metadata.req_id_per_token = None
183
metadata.num_prefills = 1
184
metadata.num_decode_tokens = num_decodes
185
monkeypatch.setattr(
@@ -206,12 +211,17 @@ def test_builder_plans_only_rows_dispatched_to_mqa(monkeypatch, use_mha, num_dec
211
def test_plan_uses_state_params(monkeypatch):
212
"""The NoPE/rope dims and scale live on the builder state, not the layer.
213
209
- plan() takes exact per-row KV lengths; the schedule is rebuilt on every
210
- call (contexts grow between steps) and the indptrs are always full-size
214
+ Without a device batch layout plan() takes exact per-row KV lengths and
215
+ rebuilds the schedule on every call; the indptrs are always full-size
216
with zero-query padding rows past num_tokens.
217
"""
213
- impl, rows = make_impl(64, "auto")
218
wrapper = FakeWrapper()
219
+ plan_info = [0] * sm90_mod._PLAN_INFO_LEN
220
+ plan_info[sm90_mod._PI_KV_INDPTR] = 64 * 4
221
+ wrapper._plan_info = torch.tensor(plan_info)
222
+ wrapper._planned_backend = SimpleNamespace(
223
+ _int_workspace_buffer=torch.zeros(64, dtype=torch.int32)
224
+ )
225
state = sm90_mod._SM90State.__new__(sm90_mod._SM90State)
226
state.device = torch.device("cpu")
227
state.wrapper = wrapper
@@ -227,8 +237,9 @@ def test_plan_uses_state_params(monkeypatch):
237
state._qo_cpu = torch.empty(5, dtype=torch.int32)
238
state._kv_cpu = torch.empty(5, dtype=torch.int32)
239
state._lens_cpu = torch.full((4,), TOPK, dtype=torch.int32)
240
+ state._saved_kv_end = torch.empty(0, dtype=torch.int32)
241
231
- state.plan(3, torch.tensor([2, 5, 7], dtype=torch.int32))
242
+ state.plan(3, torch.tensor([2, 5, 7], dtype=torch.int32), None, None)
243
assert wrapper.plan_args is not None
244
args, kwargs = wrapper.plan_args
245
(qo, kv, indices, kv_len, heads, ckv, kpe, page, causal, scale) = args
@@ -241,10 +252,10 @@ def test_plan_uses_state_params(monkeypatch):
252
assert kwargs["kv_data_type"] == torch.bfloat16
253
254
# Replanning a smaller batch must clear the previous rows' lengths.
244
- state.plan(1, torch.tensor([TOPK], dtype=torch.int32))
255
+ state.plan(1, torch.tensor([TOPK], dtype=torch.int32), None, None)
256
assert state._kv_cpu.tolist() == [0, TOPK, TOPK, TOPK, TOPK]
257
assert state._lens_cpu.tolist() == [TOPK, 0, 0, 0]
247
- state.plan(0, torch.empty(0, dtype=torch.int32))
258
+ state.plan(0, torch.empty(0, dtype=torch.int32), None, None)
259
assert state._kv_cpu.tolist() == [0, 0, 0, 0, 0]
260
assert state._lens_cpu.tolist() == [0, 0, 0, 0]
261
@@ -279,38 +290,49 @@ def test_pack_indices_replays_with_updated_offsets(width):
290
assert (state.kv_indices[expected.numel() :] == -99).all()
291
292
282
-def test_kv_lens_host_formula():
293
+@pytest.mark.parametrize("adaptive", [False, True])
294
+def test_kv_lens_host_formula(adaptive):
295
"""Per-row host lengths: context == position + 1; capped at
284
- index_topk + trailing-pool remainder past the sparse threshold."""
296
+ index_topk + trailing-pool remainder past the sparse threshold.
297
+
298
+ Exact (synced) without a host upper bound; with one, a sync-free bound
299
+ capped at the largest valid count, which the plan clamps on device.
300
+ """
301
builder = object.__new__(FlashInferMLASparseSM90Builder)
286
- builder._index_topk = 2048
287
- builder._index_kpool = 4
288
- builder._async_scheduling = False
302
+ builder.state = FakeState(TOPK)
303
+ builder._adaptive_verification = adaptive
304
+ seq_lens = torch.tensor([100, 9, 3000], dtype=torch.int32)
305
cam = SimpleNamespace(
306
num_reqs=3,
307
query_start_loc_cpu=torch.tensor([0, 5, 7, 10], dtype=torch.int32),
292
- seq_lens=torch.tensor([100, 9, 3000], dtype=torch.int32),
293
- seq_lens_cpu_upper_bound=torch.tensor([100, 9, 3000], dtype=torch.int32),
294
- positions=None,
308
+ seq_lens=seq_lens,
309
+ seq_lens_cpu_upper_bound=None,
310
)
296
- num_rows, lens = builder._kv_lens_host(cam)
297
- assert num_rows == 10
311
+ num_rows, lens, exact = builder._kv_lens_host(cam)
312
+ assert num_rows == 10 and exact
313
# req0: positions 95..99 -> ctx 96..100 (all <= 2048: full context)
314
# req1: positions 7,8 -> ctx 8,9
315
# req2: positions 2997..2999 -> ctx 2998..3000 (> 2048: topk + ctx%4)
316
assert lens.tolist() == [96, 97, 98, 99, 100, 8, 9, 2050, 2051, 2048]
317
318
+ cam.seq_lens_cpu_upper_bound = seq_lens
319
+ num_rows, lens, exact = builder._kv_lens_host(cam)
320
+ assert num_rows == 10 and not exact
321
+ if adaptive:
322
+ # Host query split may differ from the device one: batch-wide bound.
323
+ assert lens.tolist() == [2051] * 10
324
+ else:
325
+ assert lens.tolist() == [96, 97, 98, 99, 100, 8, 9, 2051, 2051, 2051]
326
+
327
328
def test_kv_lens_host_empty():
329
builder = object.__new__(FlashInferMLASparseSM90Builder)
306
- builder._index_topk = 2048
307
- builder._index_kpool = 4
330
cam = SimpleNamespace(
331
num_reqs=0,
332
query_start_loc_cpu=torch.tensor([0], dtype=torch.int32),
333
seq_lens=torch.zeros(0, dtype=torch.int32),
334
)
313
- num_rows, lens = builder._kv_lens_host(cam)
335
+ num_rows, lens, _ = builder._kv_lens_host(cam)
336
assert num_rows == 0 and lens.numel() == 0
337
338
@@ -334,7 +356,13 @@ def test_builder_kpool_from_model_config(monkeypatch, index_kpool, prefill_lens)
356
"__init__",
357
lambda *_args, **_kwargs: None,
358
)
337
- monkeypatch.setattr(sm90_mod, "_SM90State", lambda *_args, **_kwargs: None)
359
+
360
+ class _RecordingState:
361
+ def __init__(self, *_args, **kwargs):
362
+ self.index_topk = kwargs["index_topk"]
363
+ self.index_kpool = kwargs["index_kpool"]
364
+
365
+ monkeypatch.setattr(sm90_mod, "_SM90State", _RecordingState)
366
impl, _ = make_impl(64)
367
spec = MLAAttentionSpec(
368
block_size=BLOCK_SIZE, num_kv_heads=1, head_size=576, dtype=torch.bfloat16
@@ -351,6 +379,7 @@ def test_builder_kpool_from_model_config(monkeypatch, index_kpool, prefill_lens)
379
max_num_batched_tokens=64, async_scheduling=False
380
),
381
model_config=SimpleNamespace(hf_text_config=hf_config),
382
+ speculative_config=None,
383
)
384
builder = FlashInferMLASparseSM90Builder(
385
spec, ["attn"], vllm_config, torch.device("cpu")
@@ -359,14 +388,138 @@ def test_builder_kpool_from_model_config(monkeypatch, index_kpool, prefill_lens)
388
cam = SimpleNamespace(
389
num_reqs=2,
390
query_start_loc_cpu=torch.tensor([0, 4, 6], dtype=torch.int32),
362
- seq_lens_cpu_upper_bound=torch.tensor([42295, 100], dtype=torch.int32),
363
- positions=None,
391
+ seq_lens=torch.tensor([42295, 100], dtype=torch.int32),
392
+ seq_lens_cpu_upper_bound=None,
393
)
365
- num_rows, lens = builder._kv_lens_host(cam)
366
- assert num_rows == 6
394
+ num_rows, lens, exact = builder._kv_lens_host(cam)
395
+ assert num_rows == 6 and exact
396
assert lens.tolist() == prefill_lens + [99, 100]
397
398
399
+def _sm90_flashinfer_available():
400
+ return (
401
+ torch.cuda.is_available()
402
+ and torch.cuda.get_device_capability()[0] == 9
403
+ and sm90_mod.has_flashinfer_sm90_nope_mla()
404
+ )
405
+
406
+
407
+SM90_HEADS, SM90_TOPK, SM90_KPOOL, SM90_WIDTH = 16, 2048, 4, 2176
408
+
409
+
410
+def _real_state(rows):
411
+ return sm90_mod._SM90State(
412
+ torch.device("cuda"),
413
+ SM90_HEADS,
414
+ torch.bfloat16,
415
+ rows,
416
+ SM90_WIDTH,
417
+ HEAD,
418
+ 0,
419
+ HEAD**-0.5,
420
+ SM90_TOPK,
421
+ SM90_KPOOL,
422
+ )
423
+
424
+
425
+def _clamp_batch(num_reqs, q_per_req, lo, hi, topk, kpool, width):
426
+ """Random batch: per-row contexts, device layout, and top-k slots."""
427
+ last = torch.randint(lo + q_per_req, hi, (num_reqs,))
428
+ ctx = (last[:, None] + torch.arange(1 - q_per_req, 1)).reshape(-1)
429
+ valid = torch.where(ctx <= topk, ctx, topk + ctx % kpool)
430
+ cam = SimpleNamespace(
431
+ seq_lens=last.to(torch.int32).cuda(),
432
+ query_start_loc=(torch.arange(num_reqs + 1) * q_per_req).to(torch.int32).cuda(),
433
+ )
434
+ req_id = torch.arange(num_reqs, dtype=torch.int32).repeat_interleave(q_per_req)
435
+ slots = torch.randint(0, 1 << 16, (ctx.numel(), width), dtype=torch.int32)
436
+ slots[torch.arange(width) >= valid[:, None]] = -1
437
+ return ctx, valid.to(torch.int32), cam, req_id.cuda(), slots.cuda()
438
+
439
+
440
+@pytest.mark.skipif(
441
+ not _sm90_flashinfer_available(), reason="Requires SM90 FlashInfer MLA"
442
+)
443
+@pytest.mark.parametrize(
444
+ "num_reqs,q_per_req,lo,hi",
445
+ [
446
+ (64, 1, 1, 3000),
447
+ (16, 6, 2030, 2070), # MTP-style rows straddling index_topk
448
+ (4096, 1, 1, 3000), # clamp spans several program blocks
449
+ ],
450
+)
451
+def test_upper_bound_plan_matches_exact(num_reqs, q_per_req, lo, hi):
452
+ """Sync-free planning from optimistic host bounds (async spec decode)
453
+ must match exact planning: the device clamp trims every work item back
454
+ to its row's valid count, so no -1 slot is read."""
455
+ torch.manual_seed(0)
456
+ heads, topk, kpool, width = SM90_HEADS, SM90_TOPK, SM90_KPOOL, SM90_WIDTH
457
+ ctx, valid, cam, req_id, slots = _clamp_batch(
458
+ num_reqs, q_per_req, lo, hi, topk, kpool, width
459
+ )
460
+ rows = ctx.numel()
461
+
462
+ kv = torch.randn(1 << 16, 1, HEAD, device="cuda", dtype=torch.bfloat16)
463
+ q = torch.randn(rows, heads, HEAD, device="cuda", dtype=torch.bfloat16)
464
+ q_pe = q.new_empty(rows, heads, 0)
465
+
466
+ def run(state):
467
+ state.pack_indices(slots)
468
+ return state.wrapper.run(q, q_pe, kv, kv[..., :0])
469
+
470
+ exact = _real_state(rows)
471
+ exact.plan(rows, valid, None, None)
472
+ ub = _real_state(rows)
473
+ host_bound = torch.clamp(ctx + torch.randint(0, 7, (rows,)), max=ub.max_valid)
474
+ ub.plan(rows, host_bound, cam, req_id)
475
+ out = run(ub)
476
+ assert not out.isnan().any()
477
+ torch.testing.assert_close(out, run(exact), atol=1e-2, rtol=1e-2)
478
+
479
+
480
+@pytest.mark.skipif(
481
+ not _sm90_flashinfer_available(), reason="Requires SM90 FlashInfer MLA"
482
+)
483
+def test_upper_bound_plan_reused_across_steps(monkeypatch):
484
+ """Growing contexts (draft/decode steps) reuse the padded plan and only
485
+ re-clamp on device, matching an exact replan every step."""
486
+ torch.manual_seed(0)
487
+ heads, topk, kpool, width = SM90_HEADS, SM90_TOPK, SM90_KPOOL, SM90_WIDTH
488
+ rows = 48
489
+ ctx, _, _, req_id, _ = _clamp_batch(rows, 1, 1, 3000, topk, kpool, width)
490
+
491
+ exact, ub = _real_state(rows), _real_state(rows)
492
+ replans: list[int] = []
493
+ plan_fn = ub._plan
494
+
495
+ def counting_plan(num_tokens, kv_lens):
496
+ replans.append(num_tokens)
497
+ plan_fn(num_tokens, kv_lens)
498
+
499
+ monkeypatch.setattr(ub, "_plan", counting_plan)
500
+ kv = torch.randn(1 << 16, 1, HEAD, device="cuda", dtype=torch.bfloat16)
501
+ for step in range(5):
502
+ step_ctx = ctx + step
503
+ valid = torch.where(step_ctx <= topk, step_ctx, topk + step_ctx % kpool)
504
+ slots = torch.randint(0, 1 << 16, (rows, width), dtype=torch.int32)
505
+ slots[torch.arange(width) >= valid[:, None]] = -1
506
+ slots = slots.cuda()
507
+ cam = SimpleNamespace(
508
+ seq_lens=step_ctx.to(torch.int32).cuda(),
509
+ query_start_loc=torch.arange(rows + 1, dtype=torch.int32).cuda(),
510
+ )
511
+ num_plans = len(replans)
512
+ ub.plan(rows, torch.clamp(step_ctx + 5, max=ub.max_valid), cam, req_id)
513
+ assert len(replans) - num_plans == (1 if step == 0 else 0)
514
+ exact.plan(rows, valid.to(torch.int32), None, None)
515
+ q = torch.randn(rows, heads, HEAD, device="cuda", dtype=torch.bfloat16)
516
+ outs = []
517
+ for state in (ub, exact):
518
+ state.pack_indices(slots)
519
+ outs.append(state.wrapper.run(q, q[..., :0], kv, kv[..., :0]))
520
+ torch.testing.assert_close(outs[0], outs[1], atol=1e-2, rtol=1e-2)
521
+
522
+
523
def test_supports_combination_gates(monkeypatch, default_vllm_config):
524
monkeypatch.setattr(sm90_mod, "has_flashinfer_sm90_nope_mla", lambda: True)
525
call = lambda **kw: FlashInferMLASparseSM90Backend.supports_combination(
vllm/v1/attention/backends/mla/flashinfer_mla_sparse_sm90.py
+200
-78
@@ -17,14 +17,14 @@ encoded by the indexer's selection, so ``causal=False``.
17
CUDA-graph handling: ``plan()`` copies its inputs to host unconditionally,
18
so it must stay outside graph capture. Each metadata builder owns a wrapper,
19
reserved capture-stable device buffers, and the plan parameters. The wrapper
20
-bakes the per-row ``kv_len`` into its int schedule
21
-at plan() time — ``run()`` never reads the device-side buffer — so the
22
-metadata builder replans every step (outside capture) with exact host-side
23
-lengths derived from the batch's sequence lengths; a full-width schedule
24
-would send the kernel past each row's valid count into the -1 tail of the
25
-converted index buffer (illegal address). Per-step content (top-k slots)
26
-is written into the reserved buffers by kernels inside the captured
27
-forward, and captured runs read the refreshed plan buffers on replay.
20
+bakes the per-row ``kv_len`` into its int schedule at plan() time — ``run()``
21
+never reads the device-side buffer. The builder plans (outside capture) from
22
+sync-free host upper bounds and clamps each scheduled work item's ``kv_end``
23
+on device to the exact valid count, so the kernel never reads past a row's
24
+valid prefix (the -1 tail of the converted index buffer) and async scheduling
25
+needs no D2H sync. Per-step content (top-k slots) is written into the
26
+reserved buffers by kernels inside the captured forward, and captured runs
27
+read the refreshed plan buffers on replay.
28
29
KV cache format: plain contiguous E4M3 ``[num_blocks, block_size, 512]``
30
(uint8 storage) with a per-tensor ``k_scale``; BF16 caches also work. The
@@ -63,6 +63,26 @@ from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheLayout
63
64
_FP8_KV_DTYPES = ("fp8", "fp8_e4m3")
65
_WORKSPACE_BYTES = 128 * 1024 * 1024
66
+# TODO: FlashInfer bakes per-row kv_len into the plan on the host, which
67
+# forces a D2H sync under async scheduling. The plan-info layout constants
68
+# below and the int-workspace clamp in _SM90State work around this by
69
+# planning from sync-free upper bounds; delete them once FlashInfer accepts
70
+# an upper-bound plan() that clamps kv_end internally, or exposes a
71
+# GPU-side plan API (upstream ask:
72
+# https://github.com/flashinfer-ai/flashinfer/issues/5559, see
73
+# vllm-project/vllm#58684).
74
+# FlashInfer MLAPlanInfo layout (scheduler.cuh) field indices.
75
+_PLAN_INFO_LEN = 18
76
+_PI_NUM_BLKS_Y = 1
77
+_PI_Q_INDPTR = 2
78
+_PI_KV_INDPTR = 3
79
+_PI_KV_START = 13
80
+_PI_KV_END = 14
81
+_PI_WORK_INDPTR = 15
82
+# Planned lengths exceed the host upper bound by up to this many tokens so a
83
+# plan stays reusable across draft steps and consecutive decode steps; the
84
+# device-side clamp restores the exact lengths.
85
+_PLAN_SLACK = 32
86
87
88
@triton.jit
@@ -82,6 +102,40 @@ def _pack_topk_indices(
102
tl.store(indices + start + cols, tl.maximum(values, 0), mask=mask)
103
104
105
+@triton.jit
106
+def _clamp_work_kv_end(
107
+ int_ws,
108
+ saved_kv_end,
109
+ seq_lens,
110
+ query_start_loc,
111
+ req_id_per_token,
112
+ num_works_idx,
113
+ q_indptr_off,
114
+ kv_start_off,
115
+ kv_end_off,
116
+ TOPK: tl.constexpr,
117
+ KPOOL: tl.constexpr,
118
+ BLOCK_SIZE: tl.constexpr,
119
+):
120
+ # Clamp each planned work item to its row's exact valid count, derived
121
+ # from the device batch layout exactly as the convert kernel counts it.
122
+ works = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
123
+ mask = works < tl.load(int_ws + num_works_idx)
124
+ row = tl.load(int_ws + q_indptr_off + works, mask=mask, other=0)
125
+ req = tl.load(req_id_per_token + row, mask=mask, other=0)
126
+ seq_len = tl.load(seq_lens + req, mask=mask, other=0)
127
+ query_end = tl.load(query_start_loc + req + 1, mask=mask, other=0)
128
+ ctx = seq_len - query_end + row + 1
129
+ valid = tl.where(ctx <= TOPK, ctx, TOPK + ctx % KPOOL)
130
+ start = tl.load(int_ws + kv_start_off + works, mask=mask, other=0)
131
+ end = tl.load(saved_kv_end + works, mask=mask, other=0)
132
+ tl.store(
133
+ int_ws + kv_end_off + works,
134
+ tl.maximum(tl.minimum(end, valid), start),
135
+ mask=mask,
136
+ )
137
+
138
+
139
class FlashInferMLASparseSM90Backend(AttentionBackend):
140
supported_dtypes: ClassVar[list[torch.dtype]] = [torch.bfloat16]
141
supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [
@@ -190,6 +244,8 @@ class _SM90State:
244
kv_lora_rank: int,
245
qk_rope_head_dim: int,
246
sm_scale: float,
247
+ index_topk: int,
248
+ index_kpool: int,
249
) -> None:
250
from flashinfer.mla import BatchMLAPagedAttentionWrapper
251
@@ -202,6 +258,10 @@ class _SM90State:
258
self.kv_lora_rank = kv_lora_rank
259
self.qk_rope_head_dim = qk_rope_head_dim
260
self.sm_scale = sm_scale
261
+ self.index_topk = index_topk
262
+ self.index_kpool = max(index_kpool, 1)
263
+ # Largest valid count the convert kernel can produce for any context.
264
+ self.max_valid = min(self.index_topk + self.index_kpool - 1, topk_width)
265
# User-reserved buffers: with use_cuda_graph=True plan() refreshes
266
# these in place, so run()'s captured kernels always read them.
267
self.kv_indices = torch.zeros(
@@ -226,24 +286,54 @@ class _SM90State:
286
self._lens_cpu = torch.full(
287
(self.max_tokens,), self.topk_width, dtype=torch.int32
288
)
229
-
230
- def plan(self, num_tokens: int, kv_lens: torch.Tensor) -> None:
231
- """Replan with exact per-row KV lengths (CPU int32, ``[num_tokens]``).
232
-
233
- The wrapper bakes kv_len into its int schedule from host values;
234
- run() never reads the device kv_len_arr buffer. Scheduling with the
235
- full buffer width while kv_indices rows carry a -1 tail past each
236
- row's valid count makes the kernel compute ``-1 * ckv_stride_page``
237
- (illegal address), so the lengths must be exact at plan time and
238
- replanned every step as contexts grow. Must run outside CUDA graph
239
- capture: the in-place refreshed plan_info/indptr buffers are what
240
- captured runs read.
289
+ # Planned per-work kv_end of the current plan, restored by each clamp;
290
+ # sized on first plan to the planner's work capacity.
291
+ self._saved_kv_end = torch.empty(0, dtype=torch.int32, device=device)
292
+ self._planned_num_tokens = -1
293
+ self._int_ws: torch.Tensor | None = None
294
+ self._plan_offsets: tuple[int, int, int, int] | None = None
295
+
296
+ def plan(
297
+ self,
298
+ num_tokens: int,
299
+ kv_lens: torch.Tensor,
300
+ cam: CommonAttentionMetadata | None,
301
+ req_id_per_token: torch.Tensor | None,
302
+ ) -> None:
303
+ """Plan per-row KV lengths (CPU int32, ``[num_tokens]``).
304
+
305
+ The wrapper bakes kv_len into its int schedule from host values, so
306
+ rows past their valid count would send the kernel into the -1 tail of
307
+ the converted index buffer. With ``cam`` and ``req_id_per_token``
308
+ (device batch layout), ``kv_lens`` only needs to upper-bound the
309
+ valid counts: the plan is padded by ``_PLAN_SLACK`` and reused while
310
+ the bound still fits, and every call clamps each work item's kv_end
311
+ on device to the exact count, so no D2H sync is needed. Without them
312
+ the lengths must be exact. Must run outside CUDA graph capture: the
313
+ in-place refreshed plan_info/indptr buffers are what captured runs
314
+ read.
315
"""
316
if torch.cuda.is_current_stream_capturing():
317
raise RuntimeError(
318
"FlashInferMLASparseSM90 plan() called inside CUDA graph "
319
"capture; lengths must be planned host-side before capture."
320
)
321
+ kv_lens = kv_lens.to(torch.int32)
322
+ if cam is None or req_id_per_token is None:
323
+ self._plan(num_tokens, kv_lens)
324
+ self._planned_num_tokens = -1
325
+ return
326
+ planned = self._lens_cpu[:num_tokens]
327
+ if num_tokens != self._planned_num_tokens or not bool(
328
+ ((planned >= kv_lens) & (planned <= kv_lens + _PLAN_SLACK)).all()
329
+ ):
330
+ self._plan(
331
+ num_tokens, torch.clamp(kv_lens + _PLAN_SLACK, max=self.max_valid)
332
+ )
333
+ self._planned_num_tokens = num_tokens
334
+ self._clamp_kv_end(cam, req_id_per_token)
335
+
336
+ def _plan(self, num_tokens: int, kv_lens: torch.Tensor) -> None:
337
# CPU staging buffers are filled in place: plan() runs per step
338
# (once per draft/verify metadata build), so per-call allocations and
339
# device round trips are on the hot path. Passing CPU tensors lets
@@ -257,13 +347,16 @@ class _SM90State:
347
# matching the exact lengths, including zero-length padded rows.
348
torch.clamp(self._arange_cpu, max=num_tokens, out=self._qo_cpu)
349
self._lens_cpu.zero_()
260
- self._lens_cpu[:num_tokens] = kv_lens.to(torch.int32)
350
+ self._lens_cpu[:num_tokens] = kv_lens
351
self._kv_cpu[0] = 0
352
torch.cumsum(self._lens_cpu, dim=0, out=self._kv_cpu[1:])
353
+ # Only the live prefix of kv_indices: the wrapper snapshots and
354
+ # copies whatever is passed.
355
+ num_indices = max(int(self._kv_cpu[-1]), 1)
356
self.wrapper.plan(
357
self._qo_cpu,
358
self._kv_cpu,
266
- self.kv_indices,
359
+ self.kv_indices[:num_indices],
360
self._lens_cpu,
361
self.num_heads,
362
self.kv_lora_rank, # head_dim_ckv
@@ -274,6 +367,42 @@ class _SM90State:
367
q_data_type=torch.bfloat16,
368
kv_data_type=self.kv_dtype,
369
)
370
+ plan_info = [int(x) for x in self.wrapper._plan_info]
371
+ assert len(plan_info) == _PLAN_INFO_LEN, plan_info
372
+ # Per-work arrays are laid out back to back at the planner's capacity.
373
+ max_works = (plan_info[_PI_KV_INDPTR] - plan_info[_PI_Q_INDPTR]) // 4
374
+ if self._saved_kv_end.numel() != max_works:
375
+ self._saved_kv_end = torch.empty(
376
+ max_works, dtype=torch.int32, device=self.device
377
+ )
378
+ self._int_ws = self.wrapper._planned_backend._int_workspace_buffer.view(
379
+ torch.int32
380
+ )
381
+ self._plan_offsets = (
382
+ plan_info[_PI_WORK_INDPTR] // 4 + plan_info[_PI_NUM_BLKS_Y],
383
+ plan_info[_PI_Q_INDPTR] // 4,
384
+ plan_info[_PI_KV_START] // 4,
385
+ plan_info[_PI_KV_END] // 4,
386
+ )
387
+ kv_end_off = self._plan_offsets[3]
388
+ self._saved_kv_end.copy_(self._int_ws[kv_end_off : kv_end_off + max_works])
389
+
390
+ def _clamp_kv_end(
391
+ self, cam: CommonAttentionMetadata, req_id_per_token: torch.Tensor
392
+ ) -> None:
393
+ assert self._int_ws is not None and self._plan_offsets is not None
394
+ block = 1024
395
+ _clamp_work_kv_end[(triton.cdiv(self._saved_kv_end.numel(), block),)](
396
+ self._int_ws,
397
+ self._saved_kv_end,
398
+ cam.seq_lens,
399
+ cam.query_start_loc,
400
+ req_id_per_token,
401
+ *self._plan_offsets,
402
+ TOPK=self.index_topk,
403
+ KPOOL=self.index_kpool,
404
+ BLOCK_SIZE=block,
405
+ )
406
407
def pack_indices(self, topk_slots: torch.Tensor) -> None:
408
num_tokens, width = topk_slots.shape
@@ -312,6 +441,8 @@ class FlashInferMLASparseSM90Builder(FlashInferMLASparseMetadataBuilder):
441
)
442
topk_indices_buffer = impl.topk_indices_buffer
443
assert topk_indices_buffer is not None
444
+ hf_config = vllm_config.model_config.hf_text_config
445
+ assert hf_config.index_topk is not None
446
self.state = _SM90State(
447
device,
448
impl.num_heads,
@@ -321,20 +452,18 @@ class FlashInferMLASparseSM90Builder(FlashInferMLASparseMetadataBuilder):
452
kv_lora_rank=impl.kv_lora_rank,
453
qk_rope_head_dim=impl.qk_rope_head_dim,
454
sm_scale=impl.scale,
455
+ index_topk=int(hf_config.index_topk),
456
+ index_kpool=int(getattr(hf_config, "index_kpool", 1) or 1),
457
+ )
458
+ spec_config = vllm_config.speculative_config
459
+ self._adaptive_verification = bool(
460
+ spec_config is not None and spec_config.enable_adaptive_verification
461
)
325
- # seq_lens_cpu_upper_bound is optimistic on decode rows under async
326
- # spec decode, so the fast sync-free path is only safe without it;
327
- # under async scheduling the exact (device) positions are used at
328
- # the cost of one D2H sync per metadata build.
329
- self._async_scheduling = bool(vllm_config.scheduler_config.async_scheduling)
330
- hf_config = vllm_config.model_config.hf_text_config
331
- assert hf_config.index_topk is not None
332
- self._index_topk = int(hf_config.index_topk)
333
- self._index_kpool = int(getattr(hf_config, "index_kpool", 1) or 1)
462
335
- def _kv_lens_host(self, cam: CommonAttentionMetadata) -> tuple[int, torch.Tensor]:
336
- """Exact per-row KV lengths, host-side (the flashinfer wrapper bakes
337
- them into its schedule at plan time; there is no device-side path).
463
+ def _kv_lens_host(
464
+ self, cam: CommonAttentionMetadata
465
+ ) -> tuple[int, torch.Tensor, bool]:
466
+ """Host per-row KV lengths for the plan and whether they are exact.
467
468
A row for the j-th query token of request i attends
469
``seq_lens[i] - q_len[i] + j + 1`` tokens. The indexer's selection
@@ -343,53 +472,41 @@ class FlashInferMLASparseSM90Builder(FlashInferMLASparseMetadataBuilder):
472
``index_topk`` pool-expanded tokens plus the trailing incomplete
473
pool (valid == ``index_topk + context % index_kpool``). Both match
474
the count of non -1 entries the convert kernel produces.
475
+
476
+ With ``seq_lens_cpu_upper_bound`` (optimistic under async spec
477
+ decode) returns sync-free upper bounds, which the plan clamps on
478
+ device. Otherwise returns exact lengths via a D2H copy.
479
"""
480
num_reqs = cam.num_reqs
481
qsl = cam.query_start_loc_cpu[: num_reqs + 1]
482
num_rows = int(qsl[-1])
483
if num_rows == 0:
351
- return 0, torch.zeros(0, dtype=torch.int32)
352
- # Row context == position + 1. Without async scheduling the
353
- # maintained host upper bound is exact, giving a sync-free path;
354
- # under async scheduling it is optimistic on decode rows, so fall
355
- # back to the exact device positions (one D2H sync per build). The
356
- # seq_lens derivation equals position + 1 because positions are
357
- # contiguous per request.
484
+ return 0, torch.zeros(0, dtype=torch.int32), True
485
+ state = self.state
486
sl_host = cam.seq_lens_cpu_upper_bound
359
- positions = cam.positions
360
- if not self._async_scheduling and sl_host is not None:
361
- seq_lens = sl_host[:num_reqs].to(torch.int32)
362
- q_lens = qsl[1:] - qsl[:-1]
363
- first_pos = seq_lens - q_lens
364
- req_of_row = torch.repeat_interleave(
365
- torch.arange(num_reqs, dtype=torch.int64), q_lens.to(torch.int64)
366
- )
367
- rows = torch.arange(num_rows, dtype=torch.int32)
368
- ctx = (
369
- first_pos.to(torch.int64)[req_of_row]
370
- + rows.to(torch.int64)
371
- - qsl.to(torch.int64)[req_of_row]
372
- + 1
373
- )
374
- elif positions is not None and num_rows <= positions.shape[0]:
375
- ctx = positions[:num_rows].cpu().to(torch.int64) + 1
487
+ if sl_host is None:
488
+ ctx = self._row_contexts(qsl, cam.seq_lens[:num_reqs].cpu())
489
+ topk, kpool = state.index_topk, state.index_kpool
490
+ lens = torch.where(ctx <= topk, ctx, topk + ctx % kpool)
491
+ return num_rows, lens.to(torch.int32), True
492
+ if self._adaptive_verification:
493
+ # The host query split may not match the device one, so bound
494
+ # every row by the longest request.
495
+ ctx = torch.full((num_rows,), int(sl_host[:num_reqs].max()))
496
else:
377
- seq_lens = cam.seq_lens[:num_reqs].cpu().to(torch.int32)
378
- q_lens = qsl[1:] - qsl[:-1]
379
- first_pos = seq_lens - q_lens
380
- req_of_row = torch.repeat_interleave(
381
- torch.arange(num_reqs, dtype=torch.int64), q_lens.to(torch.int64)
382
- )
383
- rows = torch.arange(num_rows, dtype=torch.int32)
384
- ctx = (
385
- first_pos.to(torch.int64)[req_of_row]
386
- + rows.to(torch.int64)
387
- - qsl.to(torch.int64)[req_of_row]
388
- + 1
389
- )
390
- topk = self._index_topk
391
- lens = torch.where(ctx <= topk, ctx, topk + ctx % self._index_kpool)
392
- return num_rows, lens.to(torch.int32)
497
+ ctx = self._row_contexts(qsl, sl_host[:num_reqs])
498
+ return num_rows, torch.clamp(ctx, max=state.max_valid), False
499
+
500
+ @staticmethod
501
+ def _row_contexts(qsl: torch.Tensor, seq_lens: torch.Tensor) -> torch.Tensor:
502
+ num_reqs = qsl.shape[0] - 1
503
+ q_lens = (qsl[1:] - qsl[:-1]).to(torch.int64)
504
+ first_ctx = seq_lens.to(torch.int64) - q_lens + 1
505
+ req_of_row = torch.repeat_interleave(
506
+ torch.arange(num_reqs, dtype=torch.int64), q_lens
507
+ )
508
+ rows = torch.arange(int(qsl[-1]), dtype=torch.int64)
509
+ return first_ctx[req_of_row] + rows - qsl.to(torch.int64)[req_of_row]
510
511
def build(
512
self,
@@ -399,9 +516,9 @@ class FlashInferMLASparseSM90Builder(FlashInferMLASparseMetadataBuilder):
516
) -> FlashInferMLASparseSM90Metadata:
517
metadata = super().build(common_prefix_len, common_attn_metadata, fast_build)
518
assert isinstance(metadata, FlashInferMLASparseSM90Metadata)
402
- # Replan every step outside any CUDA graph capture with this step's
403
- # exact per-row lengths; captured runs read the refreshed buffers.
404
- num_rows, kv_lens = self._kv_lens_host(common_attn_metadata)
519
+ # Plan outside any CUDA graph capture; captured runs read the
520
+ # refreshed buffers.
521
+ num_rows, kv_lens, exact = self._kv_lens_host(common_attn_metadata)
522
# MHA prefills are removed from q before forward_mqa. Plan only the
523
# decode prefix in that case, using the same routing decision as MLA.
524
if metadata.num_prefills > 0 and self._attention_layer._use_sparse_mha(
@@ -409,7 +526,12 @@ class FlashInferMLASparseSM90Builder(FlashInferMLASparseMetadataBuilder):
526
):
527
num_rows = metadata.num_decode_tokens
528
kv_lens = kv_lens[:num_rows]
412
- self.state.plan(num_rows, kv_lens)
529
+ if exact:
530
+ self.state.plan(num_rows, kv_lens, None, None)
531
+ else:
532
+ self.state.plan(
533
+ num_rows, kv_lens, common_attn_metadata, metadata.req_id_per_token
534
+ )
535
metadata.state = self.state
536
return metadata
537