[CI][Bugfix] Fix FlashAttention reported MLA dimension support (#48631)

Signed-off-by: Matthew Bonanni <mbonanni@redhat.com>

Matthew Bonanni committed Jul 14, 2026 at 17:33 UTC 313d01f507fc86306960aadf7939d2b7376b3a90
5 files changed +213 -36
docs/design/attention_backends.md
+1 -1
@@ -205,7 +205,7 @@ hardware and configuration.
205
206 | Backend | Description | Dtypes | Compute Cap. | Notes |
207 | ------- | ----------- | ------ | ------------ | ----- |
208 -| `FLASH_ATTN`‡ | FlashAttention varlen (FA2/FA3/FA4) | fp16, bf16 | Any | (qk_nope_head_dim=128, qk_rope_head_dim=64, v_head_dim=128) only |
208 +| `FLASH_ATTN`‡ | FlashAttention varlen (FA2/FA3/FA4) | fp16, bf16 | Any | (qk_nope_head_dim=128, qk_rope_head_dim=64, v_head_dim=128) (FA2/FA3/FA4) or (qk_nope_head_dim=192, qk_rope_head_dim=64, v_head_dim=256) (FA2/FA3 only) |
209 | `TRTLLM_RAGGED` | TensorRT-LLM ragged attention | fp16, bf16 | 10.x | (qk_nope_head_dim=128, qk_rope_head_dim=64, v_head_dim=128) or (qk_nope_head_dim=192, qk_rope_head_dim=64, v_head_dim=256) only |
210 | `FLASHINFER` | FlashInfer CUTLASS backend | fp16, bf16 | 10.x | (qk_nope_head_dim=128, qk_rope_head_dim=64, v_head_dim=128) only |
211 | `TOKENSPEED_MLA` | | fp16, bf16 | 10.x | (qk_nope_head_dim=128, qk_rope_head_dim=64, v_head_dim=128) only |
tests/v1/attention/test_mla_prefill_selector.py
+34 -18
@@ -123,55 +123,71 @@ class TestGetMLAPrefillBackend:
123 ):
124 get_mla_prefill_backend(vllm_config)
125
126 - def test_auto_selection_on_hopper(self):
126 + @pytest.mark.parametrize(
127 + ("qk_nope_head_dim", "v_head_dim"),
128 + [(128, 128), (192, 256)],
129 + ids=["deepseek", "glm"],
130 + )
131 + def test_auto_selection_on_hopper(self, qk_nope_head_dim: int, v_head_dim: int):
132 try:
133 flash_attn_cls = MLAPrefillBackendEnum.FLASH_ATTN.get_class()
134 except ImportError:
135 pytest.skip("FLASH_ATTN backend not available")
136 return
137
133 - vllm_config = _make_vllm_config()
138 + vllm_config = _make_vllm_config(
139 + _make_mock_model_config(
140 + qk_nope_head_dim=qk_nope_head_dim,
141 + v_head_dim=v_head_dim,
142 + )
143 + )
144
135 - with patch("vllm.platforms.current_platform") as mock_platform:
145 + with (
146 + patch("vllm.platforms.current_platform") as mock_platform,
147 + patch.object(flash_attn_cls, "is_available", return_value=True),
148 + patch(
149 + "vllm.v1.attention.backends.mla.prefill.flash_attn."
150 + "get_flash_attn_version",
151 + return_value=3,
152 + ),
153 + ):
154 mock_platform.get_device_capability.return_value = DeviceCapability(
155 major=9, minor=0
156 )
157 + mock_platform.is_rocm.return_value = False
158
140 - with patch.object(
141 - flash_attn_cls,
142 - "validate_configuration",
143 - return_value=[],
144 - ):
145 - backend = get_mla_prefill_backend(vllm_config)
146 - assert backend.get_name() == "FLASH_ATTN"
159 + backend = get_mla_prefill_backend(vllm_config)
160 + assert backend.get_name() == "FLASH_ATTN"
161
162
163 class TestAutoSelectMLAPrefillBackend:
164 """Tests for fallback and error paths in auto-selection."""
165
152 - def test_blackwell_falls_back_to_trtllm(self):
166 + def test_blackwell_glm_dimensions_fall_back_to_trtllm(self):
167 capability = DeviceCapability(major=10, minor=0)
168 selector_config = MLAPrefillSelectorConfig(
169 dtype=torch.bfloat16,
170 mla_dimensions=MLADimensions(
157 - qk_nope_head_dim=128,
171 + qk_nope_head_dim=192,
172 qk_rope_head_dim=64,
159 - v_head_dim=128,
173 + v_head_dim=256,
174 ),
175 )
176
177 try:
178 + flash_attn_cls = MLAPrefillBackendEnum.FLASH_ATTN.get_class()
179 trtllm_cls = MLAPrefillBackendEnum.TRTLLM_RAGGED.get_class()
180 except ImportError:
166 - pytest.skip("TRTLLM_RAGGED backend not available")
181 + pytest.skip("MLA prefill backend not available")
182 return
183
184 with (
185 patch("vllm.platforms.current_platform") as mock_platform,
171 - patch.object(
172 - MLAPrefillBackendEnum.FLASH_ATTN,
173 - "get_class",
174 - side_effect=ImportError("FLASH_ATTN not available"),
186 + patch.object(flash_attn_cls, "is_available", return_value=True),
187 + patch(
188 + "vllm.v1.attention.backends.mla.prefill.flash_attn."
189 + "get_flash_attn_version",
190 + return_value=4,
191 ),
192 patch.object(trtllm_cls, "validate_configuration", return_value=[]),
193 ):
tools/pre_commit/generate_attention_backend_docs.py
+139
@@ -446,6 +446,119 @@ def parse_supported_mla_dimensions(node: ast.AST | None) -> list[str]:
446 return supported_dimensions
447
448
449 +def _parse_mla_dimension_reference(
450 + node: ast.AST,
451 + named_dimensions: dict[str, str],
452 +) -> str | None:
453 + if isinstance(node, ast.Name):
454 + return named_dimensions.get(node.id)
455 + return parse_mla_dimensions_call(node)
456 +
457 +
458 +def _parse_mla_dimensions_return(
459 + statements: list[ast.stmt],
460 + named_dimensions: dict[str, str],
461 +) -> list[str]:
462 + for statement in statements:
463 + if not isinstance(statement, ast.Return):
464 + continue
465 + value = statement.value
466 + if not (
467 + isinstance(value, ast.Compare)
468 + and isinstance(value.left, ast.Name)
469 + and value.left.id == "mla_dimensions"
470 + and len(value.ops) == 1
471 + and len(value.comparators) == 1
472 + ):
473 + continue
474 +
475 + comparator = value.comparators[0]
476 + if isinstance(value.ops[0], ast.Eq):
477 + dimension = _parse_mla_dimension_reference(
478 + comparator,
479 + named_dimensions,
480 + )
481 + return [dimension] if dimension is not None else []
482 + if isinstance(value.ops[0], ast.In) and isinstance(
483 + comparator, ast.List | ast.Tuple | ast.Set
484 + ):
485 + dimensions = [
486 + _parse_mla_dimension_reference(element, named_dimensions)
487 + for element in comparator.elts
488 + ]
489 + return [dimension for dimension in dimensions if dimension is not None]
490 + return []
491 +
492 +
493 +def _parse_fa_version_condition(node: ast.AST) -> int | None:
494 + if not (
495 + isinstance(node, ast.Compare)
496 + and len(node.ops) == 1
497 + and isinstance(node.ops[0], ast.Eq)
498 + and len(node.comparators) == 1
499 + and isinstance(node.comparators[0], ast.Constant)
500 + and isinstance(node.comparators[0].value, int)
501 + ):
502 + return None
503 +
504 + left = node.left
505 + is_fa_version = isinstance(left, ast.Name) and left.id == "fa_version"
506 + is_get_fa_version = (
507 + isinstance(left, ast.Call)
508 + and isinstance(left.func, ast.Name)
509 + and left.func.id == "get_flash_attn_version"
510 + )
511 + if is_fa_version or is_get_fa_version:
512 + return node.comparators[0].value
513 + return None
514 +
515 +
516 +def parse_supports_mla_dimensions(
517 + method: ast.FunctionDef | None,
518 +) -> tuple[list[str], dict[int | None, list[str]]]:
519 + """Parse dimensions and FA-version branches from a support method."""
520 + if method is None:
521 + return [], {}
522 +
523 + named_dimensions: dict[str, str] = {}
524 + for statement in method.body:
525 + if not (
526 + isinstance(statement, ast.Assign)
527 + and len(statement.targets) == 1
528 + and isinstance(statement.targets[0], ast.Name)
529 + ):
530 + continue
531 + dimension = parse_mla_dimensions_call(statement.value)
532 + if dimension is not None:
533 + named_dimensions[statement.targets[0].id] = dimension
534 +
535 + support_by_version: dict[int | None, list[str]] = {}
536 + for statement in method.body:
537 + if isinstance(statement, ast.Return):
538 + dimensions = _parse_mla_dimensions_return([statement], named_dimensions)
539 + if dimensions:
540 + support_by_version[None] = dimensions
541 + elif isinstance(statement, ast.If):
542 + fa_version = _parse_fa_version_condition(statement.test)
543 + if fa_version is None:
544 + continue
545 + dimensions = _parse_mla_dimensions_return(statement.body, named_dimensions)
546 + if dimensions:
547 + support_by_version[fa_version] = dimensions
548 + default_dimensions = _parse_mla_dimensions_return(
549 + statement.orelse, named_dimensions
550 + )
551 + if default_dimensions:
552 + support_by_version[None] = default_dimensions
553 +
554 + supported_dimensions: list[str] = []
555 + for dimensions in support_by_version.values():
556 + for dimension in dimensions:
557 + if dimension not in supported_dimensions:
558 + supported_dimensions.append(dimension)
559 + return supported_dimensions, support_by_version
560 +
561 +
562 def parse_mla_prefill_backend_file(class_path: str) -> dict[str, Any] | None:
563 """Parse a single MLA prefill backend file to extract its properties.
564
@@ -472,6 +585,7 @@ def parse_mla_prefill_backend_file(class_path: str) -> dict[str, Any] | None:
585 info: dict[str, Any] = {
586 "compute_capability": "Any",
587 "supported_mla_dimensions": [],
588 + "mla_dimension_support_by_fa_version": {},
589 "dtypes": "fp16, bf16", # Default from base class
590 }
591
@@ -501,6 +615,13 @@ def parse_mla_prefill_backend_file(class_path: str) -> dict[str, Any] | None:
615 if dtypes:
616 info["dtypes"] = ", ".join(dtypes)
617
618 + dimensions, support_by_version = parse_supports_mla_dimensions(
619 + find_method(class_node, "supports_mla_dimensions")
620 + )
621 + if dimensions:
622 + info["supported_mla_dimensions"] = dimensions
623 + info["mla_dimension_support_by_fa_version"] = support_by_version
624 +
625 # Parse get_name static method
626 get_name_method = find_method(class_node, "get_name")
627 if get_name_method:
@@ -580,6 +701,24 @@ def parse_mla_prefill_backends() -> list[dict[str, Any]]:
701 supported_mla_dimensions = backend_info.get("supported_mla_dimensions", [])
702 if supported_mla_dimensions:
703 notes = " or ".join(supported_mla_dimensions) + " only"
704 + support_by_version = backend_info.get(
705 + "mla_dimension_support_by_fa_version", {}
706 + )
707 + if any(version is not None for version in support_by_version):
708 + default_dimensions = support_by_version.get(None, [])
709 + dimension_notes = []
710 + for dimension in supported_mla_dimensions:
711 + versions = [
712 + f"FA{version}"
713 + for version in (2, 3, 4)
714 + if dimension
715 + in support_by_version.get(version, default_dimensions)
716 + ]
717 + version_limit = "" if len(versions) == 3 else " only"
718 + dimension_notes.append(
719 + f"{dimension} ({'/'.join(versions)}{version_limit})"
720 + )
721 + notes = " or ".join(dimension_notes)
722 elif backend_name == "FLASH_ATTN":
723 notes = "FA4 on SM100+, FA3 on SM90, FA2 otherwise"
724
vllm/v1/attention/backends/mla/prefill/base.py
+20 -8
@@ -56,6 +56,13 @@ class MLAPrefillBackend(ABC):
56 def supports_dtype(cls, dtype: torch.dtype) -> bool:
57 return dtype in cls.supported_dtypes
58
59 + @classmethod
60 + def supports_mla_dimensions(cls, mla_dimensions: MLADimensions) -> bool:
61 + return (
62 + not cls.supported_mla_dimensions
63 + or mla_dimensions in cls.supported_mla_dimensions
64 + )
65 +
66 @classmethod
67 def is_available(cls) -> bool:
68 return True
@@ -86,15 +93,20 @@ class MLAPrefillBackend(ABC):
93 if not cls.is_available():
94 invalid_reasons.append("required dependencies not available")
95
89 - if (
90 - cls.supported_mla_dimensions
91 - and selector_config.mla_dimensions not in cls.supported_mla_dimensions
92 - ):
93 - supported = ", ".join(str(dims) for dims in cls.supported_mla_dimensions)
94 - invalid_reasons.append(
95 - "Model does not have supported MLA dimensions "
96 - f"(got {selector_config.mla_dimensions}; supported: {supported})"
96 + mla_dimensions = selector_config.mla_dimensions
97 + if not cls.supports_mla_dimensions(mla_dimensions):
98 + reason = (
99 + f"Model does not have supported MLA dimensions (got {mla_dimensions}"
100 )
101 + if (
102 + cls.supported_mla_dimensions
103 + and mla_dimensions not in cls.supported_mla_dimensions
104 + ):
105 + supported = ", ".join(
106 + str(dims) for dims in cls.supported_mla_dimensions
107 + )
108 + reason += f"; supported: {supported}"
109 + invalid_reasons.append(reason + ")")
110
111 return invalid_reasons
112
vllm/v1/attention/backends/mla/prefill/flash_attn.py
+19 -9
@@ -3,7 +3,7 @@
3 """FlashAttention backend for MLA prefill."""
4
5 import functools
6 -from typing import TYPE_CHECKING, ClassVar
6 +from typing import TYPE_CHECKING
7
8 import torch
9
@@ -43,14 +43,6 @@ else:
43 class FlashAttnPrefillBackend(MLAPrefillBackend):
44 """FlashAttention backend for MLA prefill."""
45
46 - supported_mla_dimensions: ClassVar[list[MLADimensions]] = [
47 - MLADimensions(
48 - qk_nope_head_dim=128,
49 - qk_rope_head_dim=64,
50 - v_head_dim=128,
51 - ),
52 - ]
53 -
46 @staticmethod
47 def get_name() -> str:
48 return "FLASH_ATTN"
@@ -59,6 +51,24 @@ class FlashAttnPrefillBackend(MLAPrefillBackend):
51 def is_available(cls) -> bool:
52 return is_flash_attn_varlen_func_available()
53
54 + @classmethod
55 + def supports_mla_dimensions(cls, mla_dimensions: MLADimensions) -> bool:
56 + dims_deepseek = MLADimensions(
57 + qk_nope_head_dim=128,
58 + qk_rope_head_dim=64,
59 + v_head_dim=128,
60 + )
61 + dims_glm = MLADimensions(
62 + qk_nope_head_dim=192,
63 + qk_rope_head_dim=64,
64 + v_head_dim=256,
65 + )
66 + fa_version = get_flash_attn_version()
67 + if fa_version == 4:
68 + return mla_dimensions == dims_deepseek
69 + else:
70 + return mla_dimensions in [dims_deepseek, dims_glm]
71 +
72 def __init__(
73 self,
74 num_heads: int,