Skip to content

Commit 04418af

Browse files
committed
Harden vLLM attention worker per review: split mixed-batch FA decode/prefill, share sparse layer validation, disable sparse cascade, require vLLM>=0.15, reject sm<8.9
Signed-off-by: Kai Xu <kaix@nvidia.com>
1 parent eb4e32e commit 04418af

4 files changed

Lines changed: 372 additions & 93 deletions

File tree

examples/vllm_serve/sparse_attn_worker.py

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,13 @@ def _load_quant_api(vllm_version: str):
8888
import torch
8989
from packaging import version
9090

91-
if version.parse(vllm_version) < version.parse("0.14.0"):
92-
raise RuntimeError("The compact NVFP4 attention worker requires vLLM >= 0.14.0")
91+
# vLLM >= 0.15 is required: the ModelOpt kernel bypasses the native
92+
# FlashAttentionImpl.forward() and relies on the external do_kv_cache_update
93+
# contract to write the current step's K/V before reading the paged cache.
94+
# That contract only exists from 0.15; on 0.14 reshape_and_cache_flash ran
95+
# inside the native forward we skip, so the cache could be read stale.
96+
if version.parse(vllm_version) < version.parse("0.15.0"):
97+
raise RuntimeError("The compact NVFP4 attention worker requires vLLM >= 0.15.0")
9398

9499
from vllm.config import compilation
95100
from vllm.v1.attention import backend
@@ -146,12 +151,40 @@ def _global_errors(worker, api) -> list[str]:
146151
return errors
147152

148153

149-
def _quant_layer_errors(module, api) -> list[str]:
154+
def _device_capability_error(device) -> str | None:
155+
"""Reject GPUs below sm_89: the NVFP4 P/V helpers cast block scales through
156+
``tl.float8e4nv``, which requires CUDA compute capability >= 8.9 (Ada/Hopper+).
157+
Validate here so unsupported devices fail before any module mutation rather
158+
than late during Triton compilation."""
159+
import torch
160+
161+
if device is None or getattr(device, "type", None) != "cuda":
162+
return None
163+
major, minor = torch.cuda.get_device_capability(device)
164+
if (major, minor) < (8, 9):
165+
return (
166+
f"NVFP4 attention requires CUDA compute capability >= 8.9 (Ada/Hopper+); "
167+
f"got sm_{major}{minor}"
168+
)
169+
return None
170+
171+
172+
def _layer_errors(module) -> list[str]:
173+
"""Per-layer attention semantics the ModelOpt kernel does not implement.
174+
175+
Shared by the quant and sparse plans: the sparse Triton path makes the same
176+
assumptions (regular decoder self-attention, no sliding window / ALiBi /
177+
softcap / sinks / fp8 KV / cross-layer KV sharing / mismatched head dims), so
178+
accepting any of them would silently change model output.
179+
"""
150180
impl = getattr(module, "impl", None)
151181
errors = []
152-
if type(module) is not api.plugin.vllm_attention.Attention:
182+
if type(module) is not VLLMAttention:
153183
errors.append(f"layout {type(module).__name__} is not regular decoder self-attention")
154-
if getattr(module, "attn_type", None) != api.backend.AttentionType.DECODER:
184+
# vLLM's AttentionType.DECODER is the string constant "decoder"; compare to it
185+
# directly so the shared sparse-only path stays independent of quant-only vLLM
186+
# imports (vllm.v1.attention.backend).
187+
if getattr(module, "attn_type", None) != "decoder":
155188
errors.append("attn_type must be DECODER")
156189
head_size = getattr(module, "head_size", None)
157190
if not isinstance(head_size, int) or head_size % 16:
@@ -229,9 +262,15 @@ def _sparse_plans(worker):
229262
sparse_kw = _sparse_kwargs(name, sparse_cfg)
230263
if not sparse_kw:
231264
continue
265+
# Validate the full attention semantics before installing a sparse-only
266+
# adapter: the sparse kernel shares the quant path's assumptions, so an
267+
# unsupported layer must fail here rather than silently change output.
268+
reasons = _layer_errors(module)
232269
new_impl, error = _select_new_impl(module)
233270
if error:
234-
errors.append(f"{name or '<root>'}: {error}")
271+
reasons.append(error)
272+
if reasons:
273+
errors.extend(f"{name or '<root>'}: {reason}" for reason in reasons)
235274
else:
236275
plans.append(_AttentionPlan(module, new_impl, sparse_kw, None, None))
237276
_raise_unsupported(errors, "sparse attention")
@@ -252,7 +291,7 @@ def _quant_plans(worker):
252291
if not isinstance(module, api.plugin._ATTENTION_TYPES):
253292
continue
254293
attention_count += 1
255-
reasons = _quant_layer_errors(module, api)
294+
reasons = _layer_errors(module)
256295
# Prefer the model compute dtype (fp16/bf16); _get_device_dtype's buffer scan
257296
# can otherwise report fp32 from the attention module's scale buffers.
258297
device, dtype = api.plugin._get_device_dtype(module)
@@ -262,6 +301,8 @@ def _quant_plans(worker):
262301
reasons.append("device/dtype could not be resolved")
263302
elif dtype not in (api.torch.float16, api.torch.bfloat16):
264303
reasons.append(f"resolved dtype {dtype} must be fp16 or bf16")
304+
if capability_error := _device_capability_error(device):
305+
reasons.append(capability_error)
265306
sparse_kw = _sparse_kwargs(name, sparse_cfg)
266307
if graph_error := _sparse_graph_error(sparse_kw, mode, api):
267308
reasons.append(graph_error)
@@ -278,10 +319,14 @@ def _quant_plans(worker):
278319
return tuple(plans)
279320

280321

281-
def _install_sparse_plans(plans) -> None:
322+
def _install_sparse_plans(worker, plans) -> None:
282323
for plan in plans:
283324
plan.new_impl.sparse_kw = plan.sparse_kw
284325
plan.module.impl = plan.new_impl
326+
# Disable cascade attention: vLLM may auto-select it on shared-prefix batches,
327+
# but the ModelOpt sparse kernel does not implement the cascade path. Mirror
328+
# the quant install so an installed sparse layer never hits it at runtime.
329+
worker.model_runner.cascade_attn_enabled = False
285330
installed = dict(Counter(type(plan.new_impl).__name__ for plan in plans))
286331
print(
287332
f"[ModelOpt] Sparse attention: replaced impl on {len(plans)} attention layers: {installed}"
@@ -320,7 +365,7 @@ def _install_attention(worker, *, quantize: bool) -> None:
320365
else:
321366
plans = _sparse_plans(worker)
322367
if plans is not None:
323-
_install_sparse_plans(plans)
368+
_install_sparse_plans(worker, plans)
324369

325370

326371
class _ModelOptAttentionWorker(BaseWorker):

modelopt/torch/sparsity/attention_sparsity/plugins/vllm.py

Lines changed: 132 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,12 @@ def _resolve_forward(
200200
transform_active = _should_run_modelopt_kernel(getattr(impl, "sparse_kw", None), quant_active)
201201

202202
if getattr(attn_metadata, "use_cascade", False):
203-
if transform_active:
203+
# Cascade is unimplemented by the ModelOpt kernel. Quantization must not be
204+
# silently dropped (it would change numerics), so reject it; a sparse-only
205+
# transform is numerically safe to delegate to the native dense path.
206+
if transform_active and quant_active:
204207
raise NotImplementedError(
205-
"vLLM cascade attention is incompatible with an active ModelOpt attention transform"
208+
"vLLM cascade attention is incompatible with active ModelOpt attention quantization"
206209
)
207210
return None
208211

@@ -350,6 +353,83 @@ def _forward_modelopt(
350353
return output
351354

352355

356+
def _dispatch_modelopt(
357+
impl,
358+
*,
359+
query: torch.Tensor,
360+
block_table: torch.Tensor,
361+
seq_lens: torch.Tensor,
362+
cu_seqlens_q: torch.Tensor,
363+
num_actual_tokens: int,
364+
max_query_len: int,
365+
output: torch.Tensor,
366+
num_decodes: int,
367+
num_prefills: int,
368+
num_decode_tokens: int,
369+
num_prefill_tokens: int,
370+
**common_kw,
371+
) -> torch.Tensor:
372+
"""Run the ModelOpt path, splitting mixed decode+prefill batches by phase.
373+
374+
NVFP4 P-QDQ is schedule-sensitive by design, so a decode result must not
375+
depend on whether a prefill request is co-scheduled. When a batch mixes
376+
``q_len==1`` decode rows with ``q_len>1`` (chunked-)prefill rows,
377+
``max_query_len > 1`` and the whole batch would otherwise take the prefill
378+
skip-softmax path. Split so each phase runs its own schedule -- decode rows
379+
always take the fixed decode path. Both the FlashAttention and FlashInfer
380+
adapters share this dispatch.
381+
"""
382+
if not (num_decodes and num_prefills):
383+
return _forward_modelopt(
384+
impl,
385+
query=query,
386+
block_table=block_table,
387+
seq_lens=seq_lens,
388+
cu_seqlens_q=cu_seqlens_q,
389+
num_actual_tokens=num_actual_tokens,
390+
max_query_len=max_query_len,
391+
output=output,
392+
**common_kw,
393+
)
394+
395+
if num_decode_tokens % num_decodes:
396+
raise NotImplementedError("Non-uniform mixed decode is unsupported")
397+
if num_decode_tokens + num_prefill_tokens != num_actual_tokens:
398+
raise ValueError("Mixed-batch token counts do not match common metadata")
399+
400+
# Sparse-only launches may have an inactive phase (for example N:M sparsity
401+
# is prefill-only). Compute the native result once, then overwrite each
402+
# active phase with its ModelOpt result.
403+
if not common_kw.get("quant_active", False):
404+
common_kw["dense_fallback"]()
405+
406+
_forward_modelopt(
407+
impl,
408+
query=query[:num_decode_tokens],
409+
block_table=block_table[:num_decodes],
410+
seq_lens=seq_lens[:num_decodes],
411+
cu_seqlens_q=cu_seqlens_q[: num_decodes + 1],
412+
num_actual_tokens=num_decode_tokens,
413+
max_query_len=num_decode_tokens // num_decodes,
414+
output=output[:num_decode_tokens],
415+
**common_kw,
416+
)
417+
prefill_start = num_decode_tokens
418+
prefill_cu_seqlens_q = cu_seqlens_q[num_decodes:] - cu_seqlens_q[num_decodes]
419+
_forward_modelopt(
420+
impl,
421+
query=query[prefill_start : prefill_start + num_prefill_tokens],
422+
block_table=block_table[num_decodes : num_decodes + num_prefills],
423+
seq_lens=seq_lens[num_decodes : num_decodes + num_prefills],
424+
cu_seqlens_q=prefill_cu_seqlens_q,
425+
num_actual_tokens=num_prefill_tokens,
426+
max_query_len=max_query_len,
427+
output=output[prefill_start : prefill_start + num_prefill_tokens],
428+
**common_kw,
429+
)
430+
return output
431+
432+
353433
class ModelOptSparseAttentionImpl(FlashAttentionImpl):
354434
"""FlashAttention adapter for the compact ModelOpt Triton path."""
355435

@@ -396,18 +476,25 @@ def forward(
396476
if attn_metadata is None:
397477
return output.fill_(0)
398478

479+
native_result = None
480+
399481
def native_forward():
400-
return self._forward_vllm_flash_attn(
401-
layer,
402-
query,
403-
key,
404-
value,
405-
kv_cache,
406-
attn_metadata,
407-
output,
408-
output_scale,
409-
output_block_scale,
410-
)
482+
# Memoized: a split mixed batch may request the native dense result
483+
# for an inactive phase after it was already computed for the batch.
484+
nonlocal native_result
485+
if native_result is None:
486+
native_result = self._forward_vllm_flash_attn(
487+
layer,
488+
query,
489+
key,
490+
value,
491+
kv_cache,
492+
attn_metadata,
493+
output,
494+
output_scale,
495+
output_block_scale,
496+
)
497+
return native_result
411498

412499
resolved = _resolve_forward(
413500
self,
@@ -421,26 +508,35 @@ def native_forward():
421508

422509
key_cache, value_cache = kv_cache.unbind(0)
423510
is_decode_only = attn_metadata.max_query_len <= 1
424-
return _forward_modelopt(
511+
common_kw = {
512+
"layer": layer,
513+
"key_cache": key_cache,
514+
"value_cache": value_cache,
515+
"max_seq_len": attn_metadata.max_seq_len,
516+
"is_causal": getattr(attn_metadata, "causal", not is_decode_only),
517+
"p_qdq": resolved.p_qdq,
518+
"p_qdq_amax": resolved.p_qdq_amax,
519+
"v_qdq": resolved.v_qdq,
520+
"v_qdq_amax": resolved.v_qdq_amax,
521+
"quant_active": resolved.quant_active,
522+
"dense_fallback": native_forward,
523+
}
524+
# Split mixed decode+prefill batches so decode rows never fall into the
525+
# schedule-sensitive prefill skip-softmax path (see _dispatch_modelopt).
526+
return _dispatch_modelopt(
425527
self,
426-
layer=layer,
427528
query=query,
428-
key_cache=key_cache,
429-
value_cache=value_cache,
430529
block_table=attn_metadata.block_table,
431530
seq_lens=attn_metadata.seq_lens,
432531
cu_seqlens_q=attn_metadata.query_start_loc,
433532
num_actual_tokens=attn_metadata.num_actual_tokens,
434533
max_query_len=attn_metadata.max_query_len,
435-
max_seq_len=attn_metadata.max_seq_len,
436-
is_causal=getattr(attn_metadata, "causal", not is_decode_only),
437534
output=output,
438-
p_qdq=resolved.p_qdq,
439-
p_qdq_amax=resolved.p_qdq_amax,
440-
v_qdq=resolved.v_qdq,
441-
v_qdq_amax=resolved.v_qdq_amax,
442-
quant_active=resolved.quant_active,
443-
dense_fallback=native_forward,
535+
num_decodes=getattr(attn_metadata, "num_decodes", 0),
536+
num_prefills=getattr(attn_metadata, "num_prefills", 0),
537+
num_decode_tokens=getattr(attn_metadata, "num_decode_tokens", 0),
538+
num_prefill_tokens=getattr(attn_metadata, "num_prefill_tokens", 0),
539+
**common_kw,
444540
)
445541

446542

@@ -616,62 +712,21 @@ def prepare_modelopt():
616712
"dense_fallback": dense_fallback,
617713
"prepare_modelopt": prepare_modelopt,
618714
}
619-
num_decodes = getattr(attn_metadata, "num_decodes", 0)
620-
num_prefills = getattr(attn_metadata, "num_prefills", 0)
621-
if not (num_decodes and num_prefills):
622-
return _forward_modelopt(
623-
impl,
624-
query=query,
625-
block_table=attn_metadata._modelopt_block_table,
626-
seq_lens=attn_metadata._modelopt_seq_lens,
627-
cu_seqlens_q=attn_metadata._modelopt_query_start_loc,
628-
num_actual_tokens=attn_metadata._modelopt_num_actual_tokens,
629-
max_query_len=max_query_len,
630-
output=output,
631-
**common_kw,
632-
)
633-
634-
num_decode_tokens = attn_metadata.num_decode_tokens
635-
num_prefill_tokens = attn_metadata.num_prefill_tokens
636-
if num_decode_tokens % num_decodes:
637-
raise NotImplementedError("Non-uniform mixed FlashInfer decode is unsupported")
638-
if num_decode_tokens + num_prefill_tokens != attn_metadata._modelopt_num_actual_tokens:
639-
raise ValueError("FlashInfer mixed-batch token counts do not match common metadata")
640-
641-
# Sparse-only launches may have an inactive phase (for example N:M
642-
# is prefill-only). Compute the native result once, then overwrite
643-
# each active phase with its ModelOpt result.
644-
if not resolved.quant_active:
645-
dense_fallback()
646-
647-
block_table = attn_metadata._modelopt_block_table
648-
seq_lens = attn_metadata._modelopt_seq_lens
649-
cu_seqlens_q = attn_metadata._modelopt_query_start_loc
650-
_forward_modelopt(
715+
return _dispatch_modelopt(
651716
impl,
652-
query=query[:num_decode_tokens],
653-
block_table=block_table[:num_decodes],
654-
seq_lens=seq_lens[:num_decodes],
655-
cu_seqlens_q=cu_seqlens_q[: num_decodes + 1],
656-
num_actual_tokens=num_decode_tokens,
657-
max_query_len=num_decode_tokens // num_decodes,
658-
output=output[:num_decode_tokens],
659-
**common_kw,
660-
)
661-
prefill_start = num_decode_tokens
662-
prefill_cu_seqlens_q = cu_seqlens_q[num_decodes:] - cu_seqlens_q[num_decodes]
663-
_forward_modelopt(
664-
impl,
665-
query=query[prefill_start : prefill_start + num_prefill_tokens],
666-
block_table=block_table[num_decodes : num_decodes + num_prefills],
667-
seq_lens=seq_lens[num_decodes : num_decodes + num_prefills],
668-
cu_seqlens_q=prefill_cu_seqlens_q,
669-
num_actual_tokens=num_prefill_tokens,
717+
query=query,
718+
block_table=attn_metadata._modelopt_block_table,
719+
seq_lens=attn_metadata._modelopt_seq_lens,
720+
cu_seqlens_q=attn_metadata._modelopt_query_start_loc,
721+
num_actual_tokens=attn_metadata._modelopt_num_actual_tokens,
670722
max_query_len=max_query_len,
671-
output=output[prefill_start : prefill_start + num_prefill_tokens],
723+
output=output,
724+
num_decodes=getattr(attn_metadata, "num_decodes", 0),
725+
num_prefills=getattr(attn_metadata, "num_prefills", 0),
726+
num_decode_tokens=getattr(attn_metadata, "num_decode_tokens", 0),
727+
num_prefill_tokens=getattr(attn_metadata, "num_prefill_tokens", 0),
672728
**common_kw,
673729
)
674-
return output
675730

676731

677732
def get_flashinfer_sparse_impl_cls() -> type:

tests/gpu_vllm/torch/sparsity/attention_sparsity/test_quant_sparse_attn_worker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def test_quant_policy_rejects_old_vllm(monkeypatch):
5656
worker_module._load_quant_api.cache_clear()
5757

5858
try:
59-
with pytest.raises(RuntimeError, match=r"vLLM >= 0\.14\.0"):
59+
with pytest.raises(RuntimeError, match=r"vLLM >= 0\.15\.0"):
6060
worker_module._install_attention(SimpleNamespace(), quantize=True)
6161
finally:
6262
worker_module._load_quant_api.cache_clear()

0 commit comments

Comments
 (0)