main
py 563 lines 21 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for the FlashInfer SM90 sparse MLA backend wiring and index packing.
4
5 The FlashInfer wrapper and top-k conversion are replaced by CPU recorders;
6 the tests pin the contract between the impl and the kernel API: page_size=1
7 varlen rows, reserved-buffer refresh, plan parameters (dims, NoPE/rope scale,
8 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
16
17 # isort: off
18 import vllm.v1.attention.backends.mla.flashinfer_mla_sparse_sm90 as sm90_mod
19 from vllm.v1.attention.backends.mla.flashinfer_mla_sparse_sm90 import (
20 FlashInferMLASparseSM90Backend,
21 FlashInferMLASparseSM90Builder,
22 FlashInferMLASparseSM90Impl,
23 )
24 from vllm.v1.kv_cache_interface import MLAAttentionSpec
25 # isort: on
26
27 BLOCK_SIZE = 64
28 HEAD = 512
29 TOPK = 128 # triton convert requires width % 128 == 0
30
31
32 def ref_convert(req_id, block_table, token_indices, BLOCK_SIZE=64, **_):
33 out = torch.full_like(token_indices, -1)
34 counts = torch.zeros(token_indices.shape[0], dtype=torch.int32)
35 for t in range(token_indices.shape[0]):
36 vals = []
37 for j in range(token_indices.shape[1]):
38 pos = int(token_indices[t, j])
39 if pos == -1:
40 continue
41 blk = int(block_table[int(req_id[t]), pos // BLOCK_SIZE])
42 if blk < 0:
43 continue
44 vals.append(blk * BLOCK_SIZE + pos % BLOCK_SIZE)
45 out[t, : len(vals)] = torch.tensor(vals, dtype=out.dtype)
46 counts[t] = len(vals)
47 return out, counts
48
49
50 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)
59
60 def run(self, *args, **kwargs):
61 q_nope, q_pe, ckv, kpe = args
62 self.run_args = (q_nope, q_pe, ckv, kpe, kwargs)
63 return torch.zeros(
64 q_nope.shape[0], q_nope.shape[1], ckv.shape[-1], dtype=torch.bfloat16
65 )
66
67
68 class FakeState:
69 def __init__(self, width, max_tokens=64):
70 self.kv_indices = torch.zeros(max_tokens * width, dtype=torch.int32)
71 self.kv_len_arr = torch.zeros(max_tokens, dtype=torch.int32)
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
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):
83 for row in range(slots.shape[0]):
84 start, end = self.kv_indptr[row : row + 2].tolist()
85 self.kv_indices[start:end] = slots[row, : end - start].clamp(min=0)
86
87
88 def make_impl(qk_rope, kv_dtype="fp8_e4m3", num_heads=2, topk_width=TOPK):
89 impl = object.__new__(FlashInferMLASparseSM90Impl)
90 impl.num_heads = num_heads
91 impl.head_size = HEAD + qk_rope
92 impl.scale = (HEAD + qk_rope) ** -0.5
93 impl.kv_lora_rank = HEAD
94 impl.qk_rope_head_dim = qk_rope
95 impl.kv_cache_dtype = kv_dtype
96 impl.use_fp8_kv_cache = kv_dtype in ("fp8", "fp8_e4m3")
97 rows = 4
98 impl.topk_indices_buffer = torch.full((rows, topk_width), -1, dtype=torch.int32)
99 return impl, rows
100
101
102 def make_batch(rows, topk_rows, own_blocks):
103 req_id = torch.tensor([0] * rows, dtype=torch.int32)
104 block_table = torch.zeros(1, 16, dtype=torch.int32)
105 block_table[:, 0] = own_blocks[0]
106 topk = torch.full((rows, TOPK), -1, dtype=torch.int32)
107 for t, row in enumerate(topk_rows):
108 topk[t, : len(row)] = torch.tensor(row, dtype=torch.int32)
109 return SimpleNamespace(
110 req_id_per_token=req_id, block_table=block_table, block_size=BLOCK_SIZE
111 )
112
113
114 @pytest.mark.parametrize("qk_rope,kv_dtype", [(0, "fp8_e4m3"), (64, "auto")])
115 def test_forward_wiring(monkeypatch, qk_rope, kv_dtype):
116 impl, rows = make_impl(qk_rope, kv_dtype)
117 state = FakeState(TOPK)
118 monkeypatch.setattr(
119 sm90_mod, "triton_convert_req_index_to_global_index", ref_convert
120 )
121
122 # req with context 10 < topk: 8 valid + -1 padding.
123 topk_rows = [
124 [7, 3, 1, 9, 0, 2, 5, 8] + [-1] * (TOPK - 8),
125 [4, 0, 2, 3, 1] + [-1] * (TOPK - 5),
126 [6, 5, 4] + [-1] * (TOPK - 3),
127 [2] + [-1] * (TOPK - 1),
128 ]
129 impl.topk_indices_buffer.copy_(torch.tensor(topk_rows, dtype=torch.int32))
130 state.kv_indptr[1:5] = torch.tensor([8, 13, 16, 17], dtype=torch.int32)
131 state.kv_indptr[5:] = 17
132 meta = make_batch(rows, topk_rows, [3])
133 meta.state = state
134 q_nope = torch.randn(rows, impl.num_heads, HEAD)
135 q_rope = torch.randn(rows, impl.num_heads, qk_rope)
136 cache = torch.zeros(
137 8 * BLOCK_SIZE,
138 impl.head_size,
139 dtype=torch.uint8 if impl.use_fp8_kv_cache else torch.bfloat16,
140 )
141
142 out, lse = impl.forward_mqa(
143 (q_nope, q_rope), cache, meta, SimpleNamespace(_k_scale_float=0.5)
144 )
145 assert lse is None and out.shape == (rows, impl.num_heads, HEAD)
146
147 # Reserved buffers carry this step's slots; lengths are NOT refreshed
148 # here (the builder plans them host-side before capture/replay).
149 ref_slots, ref_counts = ref_convert(
150 meta.req_id_per_token, meta.block_table, impl.topk_indices_buffer
151 )
152 offset = 0
153 for t in range(rows):
154 k = int(ref_counts[t])
155 assert (
156 state.kv_indices[offset : offset + k].tolist() == ref_slots[t, :k].tolist()
157 )
158 offset += k
159 assert state.plan_calls == []
160
161 assert state.wrapper.run_args is not None
162 q_pe, ckv, kpe, kwargs = state.wrapper.run_args[1:]
163 assert q_pe.shape == (rows, impl.num_heads, qk_rope)
164 assert ckv.shape == (8 * BLOCK_SIZE, 1, HEAD)
165 assert kpe.shape[-1] == qk_rope
166 if impl.use_fp8_kv_cache:
167 assert kwargs["ckv_scale"] == 0.5 and kwargs["kpe_scale"] == 1.0
168 else:
169 assert kwargs == {}
170
171
172 @pytest.mark.parametrize("use_mha", [False, True])
173 @pytest.mark.parametrize("num_decodes", [0, 1])
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)
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(
186 sm90_mod.FlashInferMLASparseMetadataBuilder,
187 "build",
188 lambda *_args, **_kwargs: metadata,
189 )
190 cam = SimpleNamespace(
191 num_reqs=num_decodes + 1,
192 query_start_loc_cpu=torch.tensor(
193 [0, 1, 6] if num_decodes else [0, 5], dtype=torch.int32
194 ),
195 seq_lens_cpu_upper_bound=torch.tensor(
196 [1402, 5] if num_decodes else [5], dtype=torch.int32
197 ),
198 positions=None,
199 )
200
201 result = builder.build(0, cam)
202
203 assert result.state is builder.state
204 expected_lens = [1402] if num_decodes else []
205 if not use_mha:
206 expected_lens += [1, 2, 3, 4, 5]
207 assert builder.state.plan_calls[0][0] == len(expected_lens)
208 assert builder.state.plan_calls[0][1].tolist() == expected_lens
209
210
211 @pytest.mark.parametrize("kv_dtype", [torch.bfloat16, torch.float8_e4m3fn])
212 def test_plan_uses_state_params(monkeypatch, kv_dtype):
213 """The NoPE/rope dims and scale live on the builder state, not the layer.
214
215 Without a device batch layout plan() takes exact per-row KV lengths and
216 rebuilds the schedule on every call; the indptrs are always full-size
217 with zero-query padding rows past num_tokens. An fp8 cache plans as
218 float8_e4m3fn.
219 """
220 wrapper = FakeWrapper()
221 plan_info = [0] * sm90_mod._PLAN_INFO_LEN
222 plan_info[sm90_mod._PI_KV_INDPTR] = 64 * 4
223 wrapper._plan_info = torch.tensor(plan_info)
224 wrapper._planned_backend = SimpleNamespace(
225 _int_workspace_buffer=torch.zeros(64, dtype=torch.int32)
226 )
227 state = sm90_mod._SM90State.__new__(sm90_mod._SM90State)
228 state.device = torch.device("cpu")
229 state.wrapper = wrapper
230 state.num_heads = 4
231 state.kv_dtype = kv_dtype
232 state.kv_lora_rank = HEAD
233 state.qk_rope_head_dim = 64
234 state.sm_scale = 576**-0.5
235 state.max_tokens = 4
236 state.topk_width = TOPK
237 state.kv_indices = torch.zeros(4 * TOPK)
238 state._arange_cpu = torch.arange(5, dtype=torch.int32)
239 state._qo_cpu = torch.empty(5, dtype=torch.int32)
240 state._kv_cpu = torch.empty(5, dtype=torch.int32)
241 state._lens_cpu = torch.full((4,), TOPK, dtype=torch.int32)
242 state._saved_kv_end = torch.empty(0, dtype=torch.int32)
243
244 state.plan(3, torch.tensor([2, 5, 7], dtype=torch.int32), None, None)
245 assert wrapper.plan_args is not None
246 args, kwargs = wrapper.plan_args
247 (qo, kv, indices, kv_len, heads, ckv, kpe, page, causal, scale) = args
248 assert qo.tolist() == [0, 1, 2, 3, 3] # clamp: rows past 3 have no queries
249 assert kv.tolist() == [0, 2, 7, 14, 14]
250 assert kv_len.tolist() == [2, 5, 7, 0]
251 assert (heads, ckv, kpe, page, causal) == (4, HEAD, 64, 1, False)
252 assert scale == 576**-0.5
253 assert kwargs["q_data_type"] == torch.bfloat16
254 assert kwargs["kv_data_type"] == kv_dtype
255
256 # Replanning a smaller batch must clear the previous rows' lengths.
257 state.plan(1, torch.tensor([TOPK], dtype=torch.int32), None, None)
258 assert state._kv_cpu.tolist() == [0, TOPK, TOPK, TOPK, TOPK]
259 assert state._lens_cpu.tolist() == [TOPK, 0, 0, 0]
260 state.plan(0, torch.empty(0, dtype=torch.int32), None, None)
261 assert state._kv_cpu.tolist() == [0, 0, 0, 0, 0]
262 assert state._lens_cpu.tolist() == [0, 0, 0, 0]
263
264
265 @pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA")
266 @pytest.mark.parametrize("width", [128, 2048, 2176])
267 def test_pack_indices_replays_with_updated_offsets(width):
268 """Graph replay packs exact prefixes after row lengths and slots change."""
269 state = sm90_mod._SM90State.__new__(sm90_mod._SM90State)
270 state.kv_indptr = torch.zeros(5, dtype=torch.int32, device="cuda")
271 state.kv_indices = torch.full((4 * width,), -99, dtype=torch.int32, device="cuda")
272 # Exercise a contiguous view with an unaligned starting address.
273 slots = torch.arange(4 * width + 1, dtype=torch.int32, device="cuda")[1:]
274 slots = slots.view(4, width)
275 state.kv_indptr.copy_(torch.tensor([0, 1, 4, 4, 4]))
276 state.pack_indices(slots)
277 torch.accelerator.synchronize()
278 graph = torch.cuda.CUDAGraph()
279 with torch.cuda.graph(graph):
280 state.pack_indices(slots)
281
282 for lengths in ([1, 3, 0, 0], [width, 0, 17, width - 1], [0, 0, 0, 0]):
283 offsets = torch.tensor([0, *torch.tensor(lengths).cumsum(0).tolist()])
284 state.kv_indptr.copy_(offsets)
285 state.kv_indices.fill_(-99)
286 slots.add_(1)
287 graph.replay()
288 expected = torch.cat(
289 [slots[row, :length] for row, length in enumerate(lengths)]
290 )
291 torch.testing.assert_close(state.kv_indices[: expected.numel()], expected)
292 assert (state.kv_indices[expected.numel() :] == -99).all()
293
294
295 @pytest.mark.parametrize("adaptive", [False, True])
296 def test_kv_lens_host_formula(adaptive):
297 """Per-row host lengths: context == position + 1; capped at
298 index_topk + trailing-pool remainder past the sparse threshold.
299
300 Exact (synced) without a host upper bound; with one, a sync-free bound
301 capped at the largest valid count, which the plan clamps on device.
302 """
303 builder = object.__new__(FlashInferMLASparseSM90Builder)
304 builder.state = FakeState(TOPK)
305 builder._adaptive_verification = adaptive
306 seq_lens = torch.tensor([100, 9, 3000], dtype=torch.int32)
307 cam = SimpleNamespace(
308 num_reqs=3,
309 query_start_loc_cpu=torch.tensor([0, 5, 7, 10], dtype=torch.int32),
310 seq_lens=seq_lens,
311 seq_lens_cpu_upper_bound=None,
312 )
313 num_rows, lens, exact = builder._kv_lens_host(cam)
314 assert num_rows == 10 and exact
315 # req0: positions 95..99 -> ctx 96..100 (all <= 2048: full context)
316 # req1: positions 7,8 -> ctx 8,9
317 # req2: positions 2997..2999 -> ctx 2998..3000 (> 2048: topk + ctx%4)
318 assert lens.tolist() == [96, 97, 98, 99, 100, 8, 9, 2050, 2051, 2048]
319
320 cam.seq_lens_cpu_upper_bound = seq_lens
321 num_rows, lens, exact = builder._kv_lens_host(cam)
322 assert num_rows == 10 and not exact
323 if adaptive:
324 # Host query split may differ from the device one: batch-wide bound.
325 assert lens.tolist() == [2051] * 10
326 else:
327 assert lens.tolist() == [96, 97, 98, 99, 100, 8, 9, 2051, 2051, 2051]
328
329
330 def test_kv_lens_host_empty():
331 builder = object.__new__(FlashInferMLASparseSM90Builder)
332 cam = SimpleNamespace(
333 num_reqs=0,
334 query_start_loc_cpu=torch.tensor([0], dtype=torch.int32),
335 seq_lens=torch.zeros(0, dtype=torch.int32),
336 )
337 num_rows, lens, _ = builder._kv_lens_host(cam)
338 assert num_rows == 0 and lens.numel() == 0
339
340
341 _NO_KPOOL = object()
342
343
344 @pytest.mark.parametrize(
345 "spec_dtype,expected_kv_dtype",
346 [
347 (torch.bfloat16, torch.bfloat16),
348 (torch.uint8, torch.float8_e4m3fn),
349 ],
350 ids=["bf16", "fp8"],
351 )
352 @pytest.mark.parametrize(
353 "index_kpool,prefill_lens",
354 [
355 (4, [2048, 2049, 2050, 2051]),
356 (None, [2048] * 4),
357 (_NO_KPOOL, [2048] * 4),
358 ],
359 ids=["kpool4", "kpool_none", "no_kpool_attr"],
360 )
361 def test_builder_kpool_from_model_config(
362 monkeypatch, index_kpool, prefill_lens, spec_dtype, expected_kv_dtype
363 ):
364 """The builder took kpool from the KV cache spec, whose tokens_per_state is
365 1, so the tail pool was never read. The state plans the cache's logical
366 dtype: uint8 fp8 storage becomes float8_e4m3fn."""
367 monkeypatch.setattr(
368 sm90_mod.FlashInferMLASparseMetadataBuilder,
369 "__init__",
370 lambda *_args, **_kwargs: None,
371 )
372
373 class _RecordingState:
374 def __init__(self, *args, **kwargs):
375 self.kv_dtype = args[2]
376 self.index_topk = kwargs["index_topk"]
377 self.index_kpool = kwargs["index_kpool"]
378
379 monkeypatch.setattr(sm90_mod, "_SM90State", _RecordingState)
380 impl, _ = make_impl(64)
381 spec = MLAAttentionSpec(
382 block_size=BLOCK_SIZE, num_kv_heads=1, head_size=576, dtype=spec_dtype
383 )
384 assert spec.tokens_per_state == 1
385 hf_config = SimpleNamespace(index_topk=2048)
386 if index_kpool is not _NO_KPOOL:
387 hf_config.index_kpool = index_kpool
388 vllm_config = SimpleNamespace(
389 compilation_config=SimpleNamespace(
390 static_forward_context={"attn": SimpleNamespace(impl=impl)}
391 ),
392 scheduler_config=SimpleNamespace(
393 max_num_batched_tokens=64, async_scheduling=False
394 ),
395 model_config=SimpleNamespace(hf_text_config=hf_config),
396 speculative_config=None,
397 )
398 builder = FlashInferMLASparseSM90Builder(
399 spec, ["attn"], vllm_config, torch.device("cpu")
400 )
401 assert builder.state.kv_dtype == expected_kv_dtype
402 # req0: prefill chunk ending at context 42295; req1: context <= topk.
403 cam = SimpleNamespace(
404 num_reqs=2,
405 query_start_loc_cpu=torch.tensor([0, 4, 6], dtype=torch.int32),
406 seq_lens=torch.tensor([42295, 100], dtype=torch.int32),
407 seq_lens_cpu_upper_bound=None,
408 )
409 num_rows, lens, exact = builder._kv_lens_host(cam)
410 assert num_rows == 6 and exact
411 assert lens.tolist() == prefill_lens + [99, 100]
412
413
414 def _sm90_flashinfer_available():
415 return (
416 torch.cuda.is_available()
417 and torch.cuda.get_device_capability()[0] == 9
418 and sm90_mod.has_flashinfer_sm90_nope_mla()
419 )
420
421
422 SM90_HEADS, SM90_TOPK, SM90_KPOOL, SM90_WIDTH = 16, 2048, 4, 2176
423
424
425 def _real_state(rows):
426 return sm90_mod._SM90State(
427 torch.device("cuda"),
428 SM90_HEADS,
429 torch.bfloat16,
430 rows,
431 SM90_WIDTH,
432 HEAD,
433 0,
434 HEAD**-0.5,
435 SM90_TOPK,
436 SM90_KPOOL,
437 )
438
439
440 def _clamp_batch(num_reqs, q_per_req, lo, hi, topk, kpool, width):
441 """Random batch: per-row contexts, device layout, and top-k slots."""
442 last = torch.randint(lo + q_per_req, hi, (num_reqs,))
443 ctx = (last[:, None] + torch.arange(1 - q_per_req, 1)).reshape(-1)
444 valid = torch.where(ctx <= topk, ctx, topk + ctx % kpool)
445 cam = SimpleNamespace(
446 seq_lens=last.to(torch.int32).cuda(),
447 query_start_loc=(torch.arange(num_reqs + 1) * q_per_req).to(torch.int32).cuda(),
448 )
449 req_id = torch.arange(num_reqs, dtype=torch.int32).repeat_interleave(q_per_req)
450 slots = torch.randint(0, 1 << 16, (ctx.numel(), width), dtype=torch.int32)
451 slots[torch.arange(width) >= valid[:, None]] = -1
452 return ctx, valid.to(torch.int32), cam, req_id.cuda(), slots.cuda()
453
454
455 @pytest.mark.skipif(
456 not _sm90_flashinfer_available(), reason="Requires SM90 FlashInfer MLA"
457 )
458 @pytest.mark.parametrize(
459 "num_reqs,q_per_req,lo,hi",
460 [
461 (64, 1, 1, 3000),
462 (16, 6, 2030, 2070), # MTP-style rows straddling index_topk
463 (4096, 1, 1, 3000), # clamp spans several program blocks
464 ],
465 )
466 def test_upper_bound_plan_matches_exact(num_reqs, q_per_req, lo, hi):
467 """Sync-free planning from optimistic host bounds (async spec decode)
468 must match exact planning: the device clamp trims every work item back
469 to its row's valid count, so no -1 slot is read."""
470 torch.manual_seed(0)
471 heads, topk, kpool, width = SM90_HEADS, SM90_TOPK, SM90_KPOOL, SM90_WIDTH
472 ctx, valid, cam, req_id, slots = _clamp_batch(
473 num_reqs, q_per_req, lo, hi, topk, kpool, width
474 )
475 rows = ctx.numel()
476
477 kv = torch.randn(1 << 16, 1, HEAD, device="cuda", dtype=torch.bfloat16)
478 q = torch.randn(rows, heads, HEAD, device="cuda", dtype=torch.bfloat16)
479 q_pe = q.new_empty(rows, heads, 0)
480
481 def run(state):
482 state.pack_indices(slots)
483 return state.wrapper.run(q, q_pe, kv, kv[..., :0])
484
485 exact = _real_state(rows)
486 exact.plan(rows, valid, None, None)
487 ub = _real_state(rows)
488 host_bound = torch.clamp(ctx + torch.randint(0, 7, (rows,)), max=ub.max_valid)
489 ub.plan(rows, host_bound, cam, req_id)
490 out = run(ub)
491 assert not out.isnan().any()
492 torch.testing.assert_close(out, run(exact), atol=1e-2, rtol=1e-2)
493
494
495 @pytest.mark.skipif(
496 not _sm90_flashinfer_available(), reason="Requires SM90 FlashInfer MLA"
497 )
498 def test_upper_bound_plan_reused_across_steps(monkeypatch):
499 """Growing contexts (draft/decode steps) reuse the padded plan and only
500 re-clamp on device, matching an exact replan every step."""
501 torch.manual_seed(0)
502 heads, topk, kpool, width = SM90_HEADS, SM90_TOPK, SM90_KPOOL, SM90_WIDTH
503 rows = 48
504 ctx, _, _, req_id, _ = _clamp_batch(rows, 1, 1, 3000, topk, kpool, width)
505
506 exact, ub = _real_state(rows), _real_state(rows)
507 replans: list[int] = []
508 plan_fn = ub._plan
509
510 def counting_plan(num_tokens, kv_lens):
511 replans.append(num_tokens)
512 plan_fn(num_tokens, kv_lens)
513
514 monkeypatch.setattr(ub, "_plan", counting_plan)
515 kv = torch.randn(1 << 16, 1, HEAD, device="cuda", dtype=torch.bfloat16)
516 for step in range(5):
517 step_ctx = ctx + step
518 valid = torch.where(step_ctx <= topk, step_ctx, topk + step_ctx % kpool)
519 slots = torch.randint(0, 1 << 16, (rows, width), dtype=torch.int32)
520 slots[torch.arange(width) >= valid[:, None]] = -1
521 slots = slots.cuda()
522 cam = SimpleNamespace(
523 seq_lens=step_ctx.to(torch.int32).cuda(),
524 query_start_loc=torch.arange(rows + 1, dtype=torch.int32).cuda(),
525 )
526 num_plans = len(replans)
527 ub.plan(rows, torch.clamp(step_ctx + 5, max=ub.max_valid), cam, req_id)
528 assert len(replans) - num_plans == (1 if step == 0 else 0)
529 exact.plan(rows, valid.to(torch.int32), None, None)
530 q = torch.randn(rows, heads, HEAD, device="cuda", dtype=torch.bfloat16)
531 outs = []
532 for state in (ub, exact):
533 state.pack_indices(slots)
534 outs.append(state.wrapper.run(q, q[..., :0], kv, kv[..., :0]))
535 torch.testing.assert_close(outs[0], outs[1], atol=1e-2, rtol=1e-2)
536
537
538 def test_supports_combination_gates(monkeypatch, default_vllm_config):
539 monkeypatch.setattr(sm90_mod, "has_flashinfer_sm90_nope_mla", lambda: True)
540 call = lambda **kw: FlashInferMLASparseSM90Backend.supports_combination(
541 head_size=576,
542 dtype=torch.bfloat16,
543 kv_cache_dtype="fp8_e4m3",
544 block_size=64,
545 use_mla=True,
546 has_sink=False,
547 use_sparse=True,
548 use_mm_prefix=False,
549 device_capability=SimpleNamespace(major=9),
550 **kw,
551 )
552 assert call() is None # no model config: only the feature gate applies
553
554 import vllm.config as cfg
555
556 monkeypatch.setattr(
557 cfg,
558 "get_current_vllm_config",
559 lambda: SimpleNamespace(model_config=None),
560 )
561 assert call() is None
562 monkeypatch.setattr(sm90_mod, "has_flashinfer_sm90_nope_mla", lambda: False)
563 assert "requires FlashInfer" in (call() or "")