main
py 1,769 lines 63.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Generates documentation table for attention backends showing feature support.
4
5 This script parses all registered attention backends using AST (no imports needed)
6 and generates a markdown table showing what features each backend supports,
7 based on the checks in AttentionBackend.validate_configuration().
8
9 This approach avoids requiring CUDA/ROCm/GPU libraries to be installed.
10
11 It runs as an mkdocs-gen-files script, so the page is generated at docs build
12 time rather than being committed to the repository.
13 """
14
15 import ast
16 import logging
17 import sys
18 from collections.abc import Callable
19 from pathlib import Path
20 from typing import Any
21
22 sys.path.insert(0, str(Path(__file__).parent))
23
24 from generated_content import fill_markers # noqa: E402
25
26 logger = logging.getLogger("mkdocs")
27
28 # ---------------------------------------------------------------------------
29 # Constants and file paths
30 # ---------------------------------------------------------------------------
31
32 REPO_ROOT = Path(__file__).parent.parent.parent.parent
33
34 BACKENDS_DIR = REPO_ROOT / "vllm" / "v1" / "attention" / "backends"
35 REGISTRY_FILE = BACKENDS_DIR / "registry.py"
36 CUDA_PLATFORM_FILE = REPO_ROOT / "vllm" / "platforms" / "cuda.py"
37 FA_UTILS_FILE = BACKENDS_DIR / "fa_utils.py"
38 FLASHINFER_UTILS_FILE = REPO_ROOT / "vllm" / "utils" / "flashinfer.py"
39 MLA_ATTENTION_FILE = (
40 REPO_ROOT / "vllm" / "model_executor" / "layers" / "attention" / "mla_attention.py"
41 )
42
43 # Backends to skip during doc generation
44 SKIP_BACKENDS = {"CUSTOM", "TORCH_SDPA"}
45
46 BACKEND_KV_DTYPE_EXCLUDES: dict[str, set[str]] = {
47 # fp8 is an alias for fp8_ds_mla for FlashMLA Sparse
48 "FLASHMLA_SPARSE": {"fp8"},
49 }
50
51
52 MLA_PREFILL_DIR = BACKENDS_DIR / "mla" / "prefill"
53 MLA_PREFILL_REGISTRY_FILE = MLA_PREFILL_DIR / "registry.py"
54 MLA_PREFILL_SELECTOR_FILE = MLA_PREFILL_DIR / "selector.py"
55
56
57 # ---------------------------------------------------------------------------
58 # AST utility helpers
59 # ---------------------------------------------------------------------------
60
61
62 def find_class_in_ast(tree: ast.AST, class_name: str) -> ast.ClassDef | None:
63 """Find a class definition in an AST."""
64 for node in ast.walk(tree):
65 if isinstance(node, ast.ClassDef) and node.name == class_name:
66 return node
67 return None
68
69
70 def find_method(node: ast.ClassDef, method_name: str) -> ast.FunctionDef | None:
71 """Find a method in a class definition."""
72 for item in node.body:
73 if isinstance(item, ast.FunctionDef) and item.name == method_name:
74 return item
75 return None
76
77
78 def method_returns_true(method: ast.FunctionDef | None) -> bool:
79 """Check if a method simply returns True."""
80 if method is None:
81 return False
82 for node in ast.walk(method):
83 if (
84 isinstance(node, ast.Return)
85 and isinstance(node.value, ast.Constant)
86 and node.value.value is True
87 ):
88 return True
89 return False
90
91
92 def check_method_overrides(node: ast.ClassDef, method_name: str) -> bool:
93 """Check if a method is overridden and returns True."""
94 return method_returns_true(find_method(node, method_name))
95
96
97 def _find_bool_class_var(class_node: ast.ClassDef, var_name: str) -> bool | None:
98 """Find a bool class variable in a class definition. Returns None if not found."""
99 for item in class_node.body:
100 # Check for annotated assignment: attr: bool = True/False
101 if (
102 isinstance(item, ast.AnnAssign)
103 and isinstance(item.target, ast.Name)
104 and item.target.id == var_name
105 and isinstance(item.value, ast.Constant)
106 and isinstance(item.value.value, bool)
107 ):
108 return item.value.value
109 # Check for plain assignment: attr = True/False
110 if isinstance(item, ast.Assign):
111 for target in item.targets:
112 if (
113 isinstance(target, ast.Name)
114 and target.id == var_name
115 and isinstance(item.value, ast.Constant)
116 and isinstance(item.value.value, bool)
117 ):
118 return item.value.value
119 return None
120
121
122 def _parse_list_class_var(node: ast.ClassDef, var_name: str) -> list[str] | None:
123 """Parse a list-type class variable, returning None if not found."""
124 for item in node.body:
125 if not isinstance(item, ast.AnnAssign):
126 continue
127 if not isinstance(item.target, ast.Name):
128 continue
129 if item.target.id != var_name:
130 continue
131 if not (item.value and isinstance(item.value, ast.List)):
132 continue
133 result = []
134 for elt in item.value.elts:
135 if isinstance(elt, ast.Attribute):
136 result.append(elt.attr)
137 elif isinstance(elt, ast.Constant):
138 result.append(str(elt.value))
139 return result
140 return None
141
142
143 def _parse_return_list(
144 method: ast.FunctionDef | None, handle_multiple_of: bool = False
145 ) -> list[str]:
146 """Extract list items from a method's return statement."""
147 if method is None:
148 return []
149 for stmt in ast.walk(method):
150 if not isinstance(stmt, ast.Return):
151 continue
152 if not isinstance(stmt.value, ast.List):
153 continue
154 sizes = []
155 for elt in stmt.value.elts:
156 if isinstance(elt, ast.Constant):
157 sizes.append(str(elt.value))
158 elif (
159 handle_multiple_of
160 and isinstance(elt, ast.Call)
161 and isinstance(elt.func, ast.Name)
162 and elt.func.id == "MultipleOf"
163 and elt.args
164 and isinstance(elt.args[0], ast.Constant)
165 ):
166 sizes.append(f"%{elt.args[0].value}")
167 if sizes:
168 return sizes
169 return []
170
171
172 def _get_parent_class_name(class_node: ast.ClassDef) -> str | None:
173 """Get the first parent class name (simple name only).
174
175 Handles both simple inheritance (class Foo(Bar)) and generic
176 inheritance (class Foo(Bar[T])).
177 """
178 if not class_node.bases:
179 return None
180 base = class_node.bases[0]
181 if isinstance(base, ast.Name):
182 return base.id
183 if isinstance(base, ast.Subscript) and isinstance(base.value, ast.Name):
184 return base.value.id
185 return None
186
187
188 def _resolve_import_to_file(
189 tree: ast.AST, class_name: str, source_file: Path | None = None
190 ) -> Path | None:
191 """Try to resolve a class name to its source file via imports in the AST.
192
193 Handles both absolute imports (from vllm.foo import Bar) and relative
194 imports (from .foo import Bar) when source_file is provided.
195 """
196 for node in ast.walk(tree):
197 if not isinstance(node, ast.ImportFrom):
198 continue
199 for alias in node.names:
200 actual_name = alias.asname or alias.name
201 if actual_name != class_name:
202 continue
203 if not node.module:
204 continue
205
206 if node.level and node.level > 0 and source_file:
207 # Relative import: resolve from the source file's directory
208 base_dir = source_file.parent
209 for _ in range(node.level - 1):
210 base_dir = base_dir.parent
211 module_path = node.module.replace(".", "/")
212 py_file = base_dir / f"{module_path}.py"
213 else:
214 # Absolute import
215 module_path = node.module.replace(".", "/")
216 py_file = REPO_ROOT / f"{module_path}.py"
217
218 if py_file.exists():
219 return py_file
220 return None
221
222
223 def _find_cc_in_function(tree: ast.AST, func_name: str) -> str | None:
224 """Find a compute capability from is_device_capability_family() calls in a function.
225
226 Looks for the pattern: current_platform.is_device_capability_family(N)
227 and converts N (e.g. 100) to a CC string (e.g. "10.x").
228 """
229 for node in ast.walk(tree):
230 if not isinstance(node, ast.FunctionDef) or node.name != func_name:
231 continue
232 for n in ast.walk(node):
233 if (
234 isinstance(n, ast.Call)
235 and isinstance(n.func, ast.Attribute)
236 and n.func.attr == "is_device_capability_family"
237 and n.args
238 and isinstance(n.args[0], ast.Constant)
239 and isinstance(n.args[0].value, int)
240 ):
241 return f"{n.args[0].value // 10}.x"
242 return None
243
244
245 def _find_exact_cc_in_function(tree: ast.AST, func_name: str) -> str | None:
246 """Find a compute capability from is_device_capability() calls in a function."""
247 for node in ast.walk(tree):
248 if not isinstance(node, ast.FunctionDef) or node.name != func_name:
249 continue
250 for n in ast.walk(node):
251 if (
252 isinstance(n, ast.Call)
253 and isinstance(n.func, ast.Attribute)
254 and n.func.attr == "is_device_capability"
255 and n.args
256 and isinstance(n.args[0], ast.Constant)
257 and isinstance(n.args[0].value, int)
258 ):
259 capability = n.args[0].value
260 return f"{capability // 10}.{capability % 10}"
261 return None
262
263
264 # ---------------------------------------------------------------------------
265 # Registry and file resolution
266 # ---------------------------------------------------------------------------
267
268
269 def parse_registry() -> dict[str, str]:
270 """Parse the registry.py file to get backend names and their class paths."""
271 tree = ast.parse(REGISTRY_FILE.read_text())
272 for node in ast.walk(tree):
273 if isinstance(node, ast.ClassDef) and node.name == "AttentionBackendEnum":
274 return _extract_enum_values(node)
275 return {}
276
277
278 def _extract_enum_values(node: ast.ClassDef) -> dict[str, str]:
279 """Extract enum name -> value mapping from a class definition."""
280 result: dict[str, str] = {}
281 for item in node.body:
282 if not isinstance(item, ast.Assign):
283 continue
284 for target in item.targets:
285 if not isinstance(target, ast.Name):
286 continue
287 if isinstance(item.value, ast.Constant) and item.value.value:
288 result[target.id] = item.value.value
289 return result
290
291
292 def get_file_from_class_path(class_path: str) -> Path | None:
293 """Convert a class path to a file path."""
294 if not class_path:
295 return None
296 module_path = class_path.rsplit(".", 1)[0].replace(".", "/")
297 py_file = REPO_ROOT / f"{module_path}.py"
298 return py_file if py_file.exists() else None
299
300
301 def parse_mla_prefill_registry() -> dict[str, str]:
302 """Parse MLAPrefillBackendEnum from the prefill registry.
303
304 Returns:
305 A dict mapping backend names to their class paths.
306
307 """
308 if not MLA_PREFILL_REGISTRY_FILE.exists():
309 return {}
310
311 try:
312 tree = ast.parse(MLA_PREFILL_REGISTRY_FILE.read_text())
313 except Exception:
314 return {}
315
316 for node in ast.walk(tree):
317 if isinstance(node, ast.ClassDef) and node.name == "MLAPrefillBackendEnum":
318 return _extract_enum_values(node)
319 return {}
320
321
322 def parse_mla_prefill_priorities() -> dict[str, list[str]]:
323 """Parse MLA prefill backend priorities from selector.py.
324
325 Returns:
326 A dict with keys like 'blackwell' and 'default' containing
327 lists of backend enum names in priority order.
328
329 """
330 if not MLA_PREFILL_SELECTOR_FILE.exists():
331 return {}
332
333 try:
334 tree = ast.parse(MLA_PREFILL_SELECTOR_FILE.read_text())
335 except Exception:
336 return {}
337
338 priorities: dict[str, list[str]] = {}
339
340 for node in ast.walk(tree):
341 if not isinstance(node, ast.FunctionDef):
342 continue
343 if node.name != "_get_mla_prefill_backend_priorities":
344 continue
345
346 # Look for if statements checking device_capability.major
347 for stmt in ast.walk(node):
348 if not isinstance(stmt, ast.If):
349 continue
350
351 # Check if it's a capability.major == 10 check (Blackwell)
352 is_blackwell = (
353 isinstance(stmt.test, ast.Compare)
354 and isinstance(stmt.test.left, ast.Attribute)
355 and stmt.test.left.attr == "major"
356 and stmt.test.comparators
357 and isinstance(stmt.test.comparators[0], ast.Constant)
358 and stmt.test.comparators[0].value == 10
359 )
360
361 # Extract backends from return statements
362 for body_stmt in stmt.body:
363 if isinstance(body_stmt, ast.Return) and isinstance(
364 body_stmt.value, ast.List
365 ):
366 backends = []
367 for elt in body_stmt.value.elts:
368 if isinstance(elt, ast.Attribute):
369 backends.append(elt.attr)
370 if is_blackwell:
371 priorities["blackwell"] = backends
372 else:
373 priorities["default"] = backends
374
375 # Extract from else branch
376 for else_stmt in stmt.orelse:
377 if isinstance(else_stmt, ast.Return) and isinstance(
378 else_stmt.value, ast.List
379 ):
380 backends = []
381 for elt in else_stmt.value.elts:
382 if isinstance(elt, ast.Attribute):
383 backends.append(elt.attr)
384 priorities["default"] = backends
385
386 return priorities
387
388
389 def parse_mla_dimensions_call(node: ast.AST) -> str | None:
390 """Parse an MLADimensions(...) call into a compact display string."""
391 if not isinstance(node, ast.Call):
392 return None
393
394 func = node.func
395 if not isinstance(func, ast.Name) or func.id != "MLADimensions":
396 return None
397
398 dimensions: dict[str, int] = {}
399 for keyword in node.keywords:
400 if (
401 keyword.arg is not None
402 and isinstance(keyword.value, ast.Constant)
403 and isinstance(keyword.value.value, int)
404 ):
405 dimensions[keyword.arg] = keyword.value.value
406
407 qk_nope_head_dim = dimensions.get("qk_nope_head_dim")
408 qk_rope_head_dim = dimensions.get("qk_rope_head_dim")
409 v_head_dim = dimensions.get("v_head_dim")
410 if qk_nope_head_dim is None or qk_rope_head_dim is None or v_head_dim is None:
411 return None
412
413 return (
414 f"(qk_nope_head_dim={qk_nope_head_dim}, "
415 f"qk_rope_head_dim={qk_rope_head_dim}, v_head_dim={v_head_dim})"
416 )
417
418
419 def parse_supported_mla_dimensions(node: ast.AST | None) -> list[str]:
420 """Parse a supported_mla_dimensions class variable."""
421 if not isinstance(node, ast.List):
422 return []
423
424 supported_dimensions = []
425 for element in node.elts:
426 dimensions = parse_mla_dimensions_call(element)
427 if dimensions is not None:
428 supported_dimensions.append(dimensions)
429 return supported_dimensions
430
431
432 def _parse_mla_dimension_reference(
433 node: ast.AST,
434 named_dimensions: dict[str, str],
435 ) -> str | None:
436 if isinstance(node, ast.Name):
437 return named_dimensions.get(node.id)
438 return parse_mla_dimensions_call(node)
439
440
441 def _parse_mla_dimensions_return(
442 statements: list[ast.stmt],
443 named_dimensions: dict[str, str],
444 ) -> list[str]:
445 for statement in statements:
446 if not isinstance(statement, ast.Return):
447 continue
448 value = statement.value
449 if not (
450 isinstance(value, ast.Compare)
451 and isinstance(value.left, ast.Name)
452 and value.left.id == "mla_dimensions"
453 and len(value.ops) == 1
454 and len(value.comparators) == 1
455 ):
456 continue
457
458 comparator = value.comparators[0]
459 if isinstance(value.ops[0], ast.Eq):
460 dimension = _parse_mla_dimension_reference(
461 comparator,
462 named_dimensions,
463 )
464 return [dimension] if dimension is not None else []
465 if isinstance(value.ops[0], ast.In) and isinstance(
466 comparator, ast.List | ast.Tuple | ast.Set
467 ):
468 dimensions = [
469 _parse_mla_dimension_reference(element, named_dimensions)
470 for element in comparator.elts
471 ]
472 return [dimension for dimension in dimensions if dimension is not None]
473 return []
474
475
476 def _parse_fa_version_condition(node: ast.AST) -> int | None:
477 if not (
478 isinstance(node, ast.Compare)
479 and len(node.ops) == 1
480 and isinstance(node.ops[0], ast.Eq)
481 and len(node.comparators) == 1
482 and isinstance(node.comparators[0], ast.Constant)
483 and isinstance(node.comparators[0].value, int)
484 ):
485 return None
486
487 left = node.left
488 is_fa_version = isinstance(left, ast.Name) and left.id == "fa_version"
489 is_get_fa_version = (
490 isinstance(left, ast.Call)
491 and isinstance(left.func, ast.Name)
492 and left.func.id == "get_flash_attn_version"
493 )
494 if is_fa_version or is_get_fa_version:
495 return node.comparators[0].value
496 return None
497
498
499 def parse_supports_mla_dimensions(
500 method: ast.FunctionDef | None,
501 ) -> tuple[list[str], dict[int | None, list[str]]]:
502 """Parse dimensions and FA-version branches from a support method."""
503 if method is None:
504 return [], {}
505
506 named_dimensions: dict[str, str] = {}
507 for statement in method.body:
508 if not (
509 isinstance(statement, ast.Assign)
510 and len(statement.targets) == 1
511 and isinstance(statement.targets[0], ast.Name)
512 ):
513 continue
514 dimension = parse_mla_dimensions_call(statement.value)
515 if dimension is not None:
516 named_dimensions[statement.targets[0].id] = dimension
517
518 support_by_version: dict[int | None, list[str]] = {}
519 for statement in method.body:
520 if isinstance(statement, ast.Return):
521 dimensions = _parse_mla_dimensions_return([statement], named_dimensions)
522 if dimensions:
523 support_by_version[None] = dimensions
524 elif isinstance(statement, ast.If):
525 fa_version = _parse_fa_version_condition(statement.test)
526 if fa_version is None:
527 continue
528 dimensions = _parse_mla_dimensions_return(statement.body, named_dimensions)
529 if dimensions:
530 support_by_version[fa_version] = dimensions
531 default_dimensions = _parse_mla_dimensions_return(
532 statement.orelse, named_dimensions
533 )
534 if default_dimensions:
535 support_by_version[None] = default_dimensions
536
537 supported_dimensions: list[str] = []
538 for dimensions in support_by_version.values():
539 for dimension in dimensions:
540 if dimension not in supported_dimensions:
541 supported_dimensions.append(dimension)
542 return supported_dimensions, support_by_version
543
544
545 def parse_mla_prefill_backend_file(class_path: str) -> dict[str, Any] | None:
546 """Parse a single MLA prefill backend file to extract its properties.
547
548 Args:
549 class_path: The fully qualified class path.
550
551 Returns:
552 A dict with backend properties, or None if parsing fails.
553
554 """
555 file_path = get_file_from_class_path(class_path)
556 if file_path is None:
557 return None
558
559 try:
560 tree = ast.parse(file_path.read_text())
561 except Exception:
562 return None
563
564 class_name = class_path.rsplit(".", 1)[1]
565 class_node = find_class_in_ast(tree, class_name)
566 if class_node is None:
567 return None
568
569 info: dict[str, Any] = {
570 "compute_capability": "Any",
571 "supported_mla_dimensions": [],
572 "mla_dimension_support_by_fa_version": {},
573 "dtypes": "fp16, bf16", # Default from base class
574 }
575
576 # Parse class variables
577 for item in class_node.body:
578 if (
579 isinstance(item, ast.AnnAssign)
580 and isinstance(item.target, ast.Name)
581 and item.target.id == "supported_mla_dimensions"
582 ):
583 info["supported_mla_dimensions"] = parse_supported_mla_dimensions(
584 item.value
585 )
586
587 # Parse supported_dtypes class variable
588 if (
589 isinstance(item, ast.AnnAssign)
590 and isinstance(item.target, ast.Name)
591 and item.target.id == "supported_dtypes"
592 and isinstance(item.value, ast.List)
593 ):
594 dtype_map = {"float16": "fp16", "bfloat16": "bf16", "float32": "fp32"}
595 dtypes = []
596 for elt in item.value.elts:
597 if isinstance(elt, ast.Attribute):
598 dtypes.append(dtype_map.get(elt.attr, elt.attr))
599 if dtypes:
600 info["dtypes"] = ", ".join(dtypes)
601
602 dimensions, support_by_version = parse_supports_mla_dimensions(
603 find_method(class_node, "supports_mla_dimensions")
604 )
605 if dimensions:
606 info["supported_mla_dimensions"] = dimensions
607 info["mla_dimension_support_by_fa_version"] = support_by_version
608
609 # Parse get_name static method
610 get_name_method = find_method(class_node, "get_name")
611 if get_name_method:
612 for n in ast.walk(get_name_method):
613 if isinstance(n, ast.Return) and isinstance(n.value, ast.Constant):
614 info["name"] = n.value.value
615
616 # Parse supports_compute_capability classmethod
617 cc_method = find_method(class_node, "supports_compute_capability")
618 if cc_method:
619 for n in ast.walk(cc_method):
620 # Look for capability.major == 10 style checks
621 if (
622 isinstance(n, ast.Compare)
623 and isinstance(n.left, ast.Attribute)
624 and n.left.attr == "major"
625 and n.comparators
626 and isinstance(n.comparators[0], ast.Constant)
627 ):
628 major = n.comparators[0].value
629 info["compute_capability"] = f"{major}.x"
630
631 return info
632
633
634 def parse_mla_prefill_backends() -> list[dict[str, Any]]:
635 """Parse MLA prefill backend options from the prefill registry.
636
637 MLA uses different backends for prefill vs decode. The decode backends are
638 registered in the main registry, but prefill backends have their own
639 registry at vllm/v1/attention/backends/mla/prefill/registry.py.
640
641 Returns a list of prefill backend info dicts with their requirements.
642 """
643 registry = parse_mla_prefill_registry()
644 priorities = parse_mla_prefill_priorities()
645
646 if not registry:
647 return []
648
649 # Get the priority order (Blackwell order shows all backends)
650 priority_order = priorities.get("blackwell", list(registry.keys()))
651
652 prefill_backends: list[dict[str, Any]] = []
653
654 # Backend-specific metadata that can't be easily parsed from code
655 backend_metadata = {
656 "TRTLLM_RAGGED": {
657 "description": "TensorRT-LLM ragged attention",
658 },
659 "FLASHINFER": {
660 "description": "FlashInfer CUTLASS backend",
661 },
662 "FLASH_ATTN": {
663 "description": "FlashAttention varlen (FA2/FA3/FA4)",
664 },
665 }
666
667 for backend_name in priority_order:
668 if backend_name not in registry:
669 continue
670
671 class_path = registry[backend_name]
672 backend_info = parse_mla_prefill_backend_file(class_path)
673 if backend_info is None:
674 continue
675
676 metadata = backend_metadata.get(backend_name, {})
677 display_name = backend_info.get("name", backend_name)
678
679 # Add marker for the highest-priority automatic backend.
680 marker = ""
681 if backend_name == priority_order[0] and priorities.get("blackwell"):
682 marker = "‡"
683
684 notes = ""
685 supported_mla_dimensions = backend_info.get("supported_mla_dimensions", [])
686 if supported_mla_dimensions:
687 notes = " or ".join(supported_mla_dimensions) + " only"
688 support_by_version = backend_info.get(
689 "mla_dimension_support_by_fa_version", {}
690 )
691 if any(version is not None for version in support_by_version):
692 default_dimensions = support_by_version.get(None, [])
693 dimension_notes = []
694 for dimension in supported_mla_dimensions:
695 versions = [
696 f"FA{version}"
697 for version in (2, 3, 4)
698 if dimension
699 in support_by_version.get(version, default_dimensions)
700 ]
701 version_limit = "" if len(versions) == 3 else " only"
702 dimension_notes.append(
703 f"{dimension} ({'/'.join(versions)}{version_limit})"
704 )
705 notes = " or ".join(dimension_notes)
706 elif backend_name == "FLASH_ATTN":
707 notes = "FA4 on SM100+, FA3 on SM90, FA2 otherwise"
708
709 prefill_backends.append(
710 {
711 "name": display_name,
712 "marker": marker,
713 "description": metadata.get("description", ""),
714 "dtypes": backend_info.get("dtypes", "fp16, bf16"),
715 "compute_capability": backend_info.get("compute_capability", "Any"),
716 "notes": notes,
717 }
718 )
719
720 return prefill_backends
721
722
723 # ---------------------------------------------------------------------------
724 # Backend feature extraction from AST
725 # ---------------------------------------------------------------------------
726
727
728 def parse_supported_dtypes(node: ast.ClassDef) -> str:
729 """Parse supported_dtypes class variable."""
730 dtype_map = {"float16": "fp16", "bfloat16": "bf16", "float32": "fp32"}
731 dtypes = _parse_list_class_var(node, "supported_dtypes")
732 if dtypes is None:
733 return "fp16, bf16"
734 return ", ".join(dtype_map.get(d, d) for d in dtypes)
735
736
737 def parse_kv_cache_dtypes(node: ast.ClassDef) -> str:
738 """Parse supported_kv_cache_dtypes class var or supports_kv_cache_dtype method."""
739 # First try the class variable
740 dtypes = _parse_list_class_var(node, "supported_kv_cache_dtypes")
741 if dtypes:
742 return ", ".join(dtypes)
743
744 # Fall back to parsing the supports_kv_cache_dtype method
745 # Look for `kv_cache_dtype in ["auto", "bfloat16"]` pattern
746 method = find_method(node, "supports_kv_cache_dtype")
747 if method:
748 for n in ast.walk(method):
749 if (
750 isinstance(n, ast.Compare)
751 and len(n.ops) == 1
752 and isinstance(n.ops[0], ast.In)
753 and len(n.comparators) == 1
754 and isinstance(n.comparators[0], ast.List)
755 ):
756 dtypes = [
757 e.value
758 for e in n.comparators[0].elts
759 if isinstance(e, ast.Constant) and isinstance(e.value, str)
760 ]
761 if dtypes:
762 return ", ".join(dtypes)
763
764 return "auto"
765
766
767 def parse_block_sizes(node: ast.ClassDef) -> str:
768 """Parse get_supported_kernel_block_sizes method."""
769 method = find_method(node, "get_supported_kernel_block_sizes")
770 sizes = _parse_return_list(method, handle_multiple_of=True)
771 return ", ".join(sizes) if sizes else "Any"
772
773
774 def parse_head_sizes(node: ast.ClassDef) -> str:
775 """Parse get_supported_head_sizes method."""
776 method = find_method(node, "get_supported_head_sizes")
777 sizes = _parse_return_list(method)
778 return ", ".join(sizes) if sizes else "Any"
779
780
781 def parse_compute_capability(node: ast.ClassDef) -> str:
782 """Parse supports_compute_capability method."""
783 method = find_method(node, "supports_compute_capability")
784 if method is None:
785 return "Any"
786
787 min_cap: tuple[int, int] | None = None
788 max_cap: tuple[int, int] | None = None
789 major_list: list[int] = []
790
791 for n in ast.walk(method):
792 if not isinstance(n, ast.Compare):
793 continue
794
795 # Handle `capability >= DeviceCapability(...)` or `capability <= ...`
796 for op, comp in zip(n.ops, n.comparators):
797 if not (
798 isinstance(comp, ast.Call)
799 and isinstance(comp.func, ast.Name)
800 and comp.func.id == "DeviceCapability"
801 and comp.args
802 and isinstance(comp.args[0], ast.Constant)
803 ):
804 continue
805 major = comp.args[0].value
806 minor = 0
807 if len(comp.args) > 1 and isinstance(comp.args[1], ast.Constant):
808 minor = comp.args[1].value
809 if isinstance(op, ast.GtE):
810 min_cap = (major, minor)
811 elif isinstance(op, ast.LtE):
812 max_cap = (major, minor)
813
814 # Handle `capability.major == N` or `capability.major in [N, M]`
815 if (
816 isinstance(n.left, ast.Attribute)
817 and n.left.attr == "major"
818 and len(n.ops) == 1
819 and len(n.comparators) == 1
820 ):
821 comp = n.comparators[0]
822 if isinstance(n.ops[0], ast.Eq) and isinstance(comp, ast.Constant):
823 major_list.append(comp.value)
824 elif isinstance(n.ops[0], ast.In) and isinstance(comp, ast.List):
825 major_list.extend(
826 e.value
827 for e in comp.elts
828 if isinstance(e, ast.Constant) and isinstance(e.value, int)
829 )
830
831 if major_list:
832 major_list.sort()
833 if len(major_list) == 1:
834 return f"{major_list[0]}.x"
835 if major_list == list(range(major_list[0], major_list[-1] + 1)):
836 return f"{major_list[0]}.x-{major_list[-1]}.x"
837 return ", ".join(f"{major}.x" for major in major_list)
838
839 if min_cap:
840 if max_cap:
841 return f"{min_cap[0]}.x-{max_cap[0]}.x"
842 return f"≥{min_cap[0]}.{min_cap[1]}"
843
844 return "Any"
845
846
847 def parse_attention_types(node: ast.ClassDef) -> str:
848 """Parse supports_attn_type method."""
849 method = find_method(node, "supports_attn_type")
850 if method is None:
851 return "Decoder"
852
853 type_map = {
854 "DECODER": "Decoder",
855 "ENCODER": "Encoder",
856 "ENCODER_ONLY": "Encoder Only",
857 "ENCODER_DECODER": "Enc-Dec",
858 }
859 types: set[str] = set()
860
861 for n in ast.walk(method):
862 # Handle `attn_type in (AttentionType.DECODER, ...)`
863 if not (
864 isinstance(n, ast.Compare)
865 and len(n.ops) == 1
866 and isinstance(n.ops[0], ast.In)
867 and len(n.comparators) == 1
868 and isinstance(n.comparators[0], ast.Tuple | ast.Set)
869 ):
870 continue
871
872 for elt in n.comparators[0].elts:
873 if isinstance(elt, ast.Attribute) and elt.attr in type_map:
874 types.add(type_map[elt.attr])
875
876 if not types:
877 return "Decoder"
878 return "All" if types >= set(type_map.values()) else ", ".join(sorted(types))
879
880
881 def parse_impl_bool_attr(
882 tree: ast.AST,
883 class_name: str,
884 attr_name: str,
885 default: bool = False,
886 source_file: Path | None = None,
887 _visited: set[str] | None = None,
888 ) -> bool:
889 """Parse a boolean class attribute from an impl class, following inheritance.
890
891 Walks up the inheritance chain within the same file and across files
892 (by resolving imports) to find the attribute value.
893 """
894 if _visited is None:
895 _visited = set()
896 if class_name in _visited:
897 return default
898 _visited.add(class_name)
899
900 class_node = find_class_in_ast(tree, class_name)
901 if class_node is None:
902 return default
903
904 # Check directly on this class
905 value = _find_bool_class_var(class_node, attr_name)
906 if value is not None:
907 return value
908
909 # Check parent class
910 parent_name = _get_parent_class_name(class_node)
911 if parent_name:
912 # Try parent in same file first
913 parent_node = find_class_in_ast(tree, parent_name)
914 if parent_node is not None:
915 return parse_impl_bool_attr(
916 tree, parent_name, attr_name, default, source_file, _visited
917 )
918
919 # Try resolving cross-file import
920 parent_file = _resolve_import_to_file(tree, parent_name, source_file)
921 if parent_file:
922 try:
923 parent_tree = ast.parse(parent_file.read_text())
924 return parse_impl_bool_attr(
925 parent_tree,
926 parent_name,
927 attr_name,
928 default,
929 parent_file,
930 _visited,
931 )
932 except Exception:
933 pass
934
935 return default
936
937
938 def analyze_backend(backend_name: str, class_path: str) -> dict[str, Any] | None:
939 """Analyze a backend class and extract feature information."""
940 file_path = get_file_from_class_path(class_path)
941 if file_path is None:
942 return None
943
944 try:
945 tree = ast.parse(file_path.read_text())
946 except Exception as e:
947 logger.warning("Could not parse %s: %s", file_path, e)
948 return None
949
950 class_name = class_path.rsplit(".", 1)[1]
951 class_node = find_class_in_ast(tree, class_name)
952 if class_node is None:
953 return None
954
955 # Check if this is an MLA backend by parent class or naming
956 parent = _get_parent_class_name(class_node)
957 mla_parents = {"MLACommonBackend", "FlashMLABackend", "FlashMLASparseBackend"}
958 is_mla_backend = (
959 parent in mla_parents
960 or ".mla." in class_path.lower()
961 or "_mla" in backend_name.lower()
962 )
963
964 # Determine compute capability - use N/A for non-CUDA backends
965 is_non_cuda = backend_name.startswith(("CPU_", "ROCM_"))
966 compute_cap = "N/A" if is_non_cuda else parse_compute_capability(class_node)
967
968 # Parse impl class features (DCP support)
969 impl_method = find_method(class_node, "get_impl_cls")
970 impl_class_name = None
971 if impl_method:
972 for stmt in ast.walk(impl_method):
973 if isinstance(stmt, ast.Return) and isinstance(stmt.value, ast.Name):
974 impl_class_name = stmt.value.id
975 break
976
977 supports_dcp = False
978 if impl_class_name:
979 supports_dcp = parse_impl_bool_attr(
980 tree, impl_class_name, "can_return_lse_for_decode", False, file_path
981 )
982
983 kv_cache_dtypes = parse_kv_cache_dtypes(class_node)
984 if backend_name in BACKEND_KV_DTYPE_EXCLUDES:
985 excluded = BACKEND_KV_DTYPE_EXCLUDES[backend_name]
986 kv_cache_dtypes = ", ".join(
987 d
988 for d in (d.strip() for d in kv_cache_dtypes.split(","))
989 if d not in excluded
990 )
991
992 return {
993 "name": backend_name,
994 "dtypes": parse_supported_dtypes(class_node),
995 "kv_cache_dtypes": kv_cache_dtypes,
996 "block_sizes": parse_block_sizes(class_node),
997 "head_sizes": parse_head_sizes(class_node),
998 "attn_types": parse_attention_types(class_node),
999 "compute_capability": compute_cap,
1000 "is_mla": is_mla_backend or check_method_overrides(class_node, "is_mla"),
1001 "supports_sink": check_method_overrides(class_node, "supports_sink"),
1002 "supports_non_causal": check_method_overrides(
1003 class_node, "supports_non_causal"
1004 ),
1005 "is_sparse": check_method_overrides(class_node, "is_sparse"),
1006 "supports_mm_prefix": check_method_overrides(class_node, "supports_mm_prefix"),
1007 "supports_dcp": supports_dcp,
1008 }
1009
1010
1011 # ---------------------------------------------------------------------------
1012 # Special backend variant parsers (FA2/FA3/FA4, FlashInfer TRTLLM, MLA prefill)
1013 # ---------------------------------------------------------------------------
1014
1015
1016 def _parse_fa4_supported_caps() -> str | None:
1017 """Parse flash_attn_interface.py for FA4 supported compute capabilities.
1018
1019 Looks for `cc not in [9, 10, 11]` pattern in _is_fa4_supported().
1020 """
1021 fa_interface_file = (
1022 REPO_ROOT / "vllm" / "vllm_flash_attn" / "flash_attn_interface.py"
1023 )
1024 if not fa_interface_file.exists():
1025 return None
1026
1027 try:
1028 tree = ast.parse(fa_interface_file.read_text())
1029 except Exception:
1030 return None
1031
1032 for node in ast.walk(tree):
1033 if not isinstance(node, ast.FunctionDef) or node.name != "_is_fa4_supported":
1034 continue
1035 for n in ast.walk(node):
1036 if not (
1037 isinstance(n, ast.Compare)
1038 and len(n.ops) == 1
1039 and isinstance(n.ops[0], ast.NotIn)
1040 and isinstance(n.comparators[0], ast.List)
1041 ):
1042 continue
1043 caps: list[int] = [
1044 e.value
1045 for e in n.comparators[0].elts
1046 if isinstance(e, ast.Constant) and isinstance(e.value, int)
1047 ]
1048 if caps:
1049 caps.sort()
1050 return f"{caps[0]}.x-{caps[-1]}.x"
1051
1052 return None
1053
1054
1055 def parse_flash_attn_features() -> dict[str, dict[str, Any]]:
1056 """Parse fa_utils.py to detect FA2 vs FA3 vs FA4 feature differences.
1057
1058 Returns a dict with 'fa2', 'fa3', and 'fa4' keys containing their respective
1059 feature overrides for compute capability, KV cache dtypes, and sink support.
1060 """
1061 if not FA_UTILS_FILE.exists():
1062 return {}
1063
1064 try:
1065 tree = ast.parse(FA_UTILS_FILE.read_text())
1066 except Exception:
1067 return {}
1068
1069 # Analyze the functions to determine FA3/FA4-specific features
1070 fa3_supports_fp8 = False
1071 fa4_supports_fp8 = False
1072 fa3_supports_sinks = False
1073 fa4_supports_sinks = False
1074 fa3_compute_cap: str | None = None
1075 fa4_compute_cap: str | None = None
1076
1077 for node in ast.walk(tree):
1078 if not isinstance(node, ast.FunctionDef):
1079 continue
1080
1081 # Check flash_attn_supports_kv_cache_dtype for fp8 support per FA version.
1082 # Accept both equality checks and membership checks such as `in (3, 4)`.
1083 if node.name == "flash_attn_supports_kv_cache_dtype":
1084 for n in ast.walk(node):
1085 if not (
1086 isinstance(n, ast.Compare)
1087 and len(n.ops) == 1
1088 and len(n.comparators) == 1
1089 ):
1090 continue
1091 is_version_compare = (
1092 isinstance(n.left, ast.Name) and n.left.id == "fa_version"
1093 ) or (
1094 isinstance(n.left, ast.Call)
1095 and isinstance(n.left.func, ast.Name)
1096 and n.left.func.id == "get_flash_attn_version"
1097 )
1098 if not is_version_compare:
1099 continue
1100
1101 versions: list[Any] = []
1102 comparator = n.comparators[0]
1103 if isinstance(n.ops[0], ast.Eq) and isinstance(
1104 comparator, ast.Constant
1105 ):
1106 versions = [comparator.value]
1107 elif isinstance(n.ops[0], ast.In) and isinstance(
1108 comparator, (ast.Tuple, ast.List, ast.Set)
1109 ):
1110 versions = [
1111 elt.value
1112 for elt in comparator.elts
1113 if isinstance(elt, ast.Constant)
1114 ]
1115
1116 fa3_supports_fp8 |= 3 in versions
1117 fa4_supports_fp8 |= 4 in versions
1118
1119 # Check flash_attn_supports_sinks - looks for `fa_version == 3/4`
1120 # or `get_flash_attn_version() == 3/4` (also accepts `in (3, 4)`)
1121 if node.name == "flash_attn_supports_sinks":
1122 for n in ast.walk(node):
1123 if (
1124 isinstance(n, ast.Compare)
1125 and len(n.ops) == 1
1126 and isinstance(n.ops[0], ast.Eq)
1127 and isinstance(n.comparators[0], ast.Constant)
1128 ):
1129 is_version_compare = (
1130 isinstance(n.left, ast.Name) and n.left.id == "fa_version"
1131 ) or (
1132 isinstance(n.left, ast.Call)
1133 and isinstance(n.left.func, ast.Name)
1134 and n.left.func.id == "get_flash_attn_version"
1135 )
1136 if is_version_compare:
1137 val = n.comparators[0].value
1138 if val == 3:
1139 fa3_supports_sinks = True
1140 elif val == 4:
1141 fa4_supports_sinks = True
1142 elif (
1143 isinstance(n, ast.Compare)
1144 and len(n.ops) == 1
1145 and isinstance(n.ops[0], ast.In)
1146 and isinstance(n.comparators[0], (ast.Tuple, ast.List, ast.Set))
1147 ):
1148 is_version_compare = (
1149 isinstance(n.left, ast.Name) and n.left.id == "fa_version"
1150 ) or (
1151 isinstance(n.left, ast.Call)
1152 and isinstance(n.left.func, ast.Name)
1153 and n.left.func.id == "get_flash_attn_version"
1154 )
1155 if is_version_compare:
1156 for elt in n.comparators[0].elts:
1157 if isinstance(elt, ast.Constant):
1158 if elt.value == 3:
1159 fa3_supports_sinks = True
1160 elif elt.value == 4:
1161 fa4_supports_sinks = True
1162
1163 # Check get_flash_attn_version for FA3/FA4 compute capability
1164 if node.name == "get_flash_attn_version":
1165 for n in ast.walk(node):
1166 # Handle IfExp (ternary) with `device_capability.major == 9`
1167 if isinstance(n, ast.IfExp):
1168 test = n.test
1169 if isinstance(test, ast.BoolOp):
1170 for val in test.values:
1171 if (
1172 isinstance(val, ast.Compare)
1173 and isinstance(val.left, ast.Attribute)
1174 and val.left.attr == "major"
1175 and val.comparators
1176 and isinstance(val.comparators[0], ast.Constant)
1177 ):
1178 fa3_compute_cap = f"{val.comparators[0].value}.x"
1179 break
1180
1181 # Handle If statements for FA3/FA4 detection
1182 # e.g. `if device_capability.major == 9` -> FA3
1183 # `elif device_capability.major >= 10` -> FA4
1184 if isinstance(n, ast.If):
1185 test = n.test
1186 comparisons = (
1187 [v for v in test.values if isinstance(v, ast.Compare)]
1188 if isinstance(test, ast.BoolOp)
1189 else [test]
1190 if isinstance(test, ast.Compare)
1191 else []
1192 )
1193 for comp in comparisons:
1194 if not (
1195 isinstance(comp.left, ast.Attribute)
1196 and comp.left.attr == "major"
1197 and comp.comparators
1198 and isinstance(comp.comparators[0], ast.Constant)
1199 and isinstance(comp.comparators[0].value, int)
1200 ):
1201 continue
1202 op = comp.ops[0]
1203 val = comp.comparators[0].value
1204 if isinstance(op, ast.Eq) and fa3_compute_cap is None:
1205 fa3_compute_cap = f"{val}.x"
1206 elif isinstance(op, ast.GtE) and fa4_compute_cap is None:
1207 fa4_compute_cap = f"≥{val}.0"
1208
1209 # Fallback: try to parse FA4 compute caps from flash_attn_interface.py
1210 if fa4_compute_cap is None:
1211 fa4_compute_cap = _parse_fa4_supported_caps()
1212
1213 return {
1214 "fa2": {
1215 "supports_fp8": False,
1216 "supports_sink": False,
1217 },
1218 "fa3": {
1219 "compute_capability": fa3_compute_cap,
1220 "supports_fp8": fa3_supports_fp8,
1221 "supports_sink": fa3_supports_sinks,
1222 },
1223 "fa4": {
1224 "compute_capability": fa4_compute_cap,
1225 "supports_fp8": fa4_supports_fp8,
1226 "supports_sink": fa4_supports_sinks,
1227 },
1228 }
1229
1230
1231 def parse_flashinfer_trtllm_features() -> dict[str, dict[str, Any]]:
1232 """Parse flashinfer.py to detect FlashInfer TRTLLM API variants.
1233
1234 FLASHINFER uses XQA on SM90 and trtllm-gen on SM100 through FlashInfer's
1235 TRTLLM decode API. These variants have different capabilities than native
1236 FlashInfer.
1237 """
1238 if not FLASHINFER_UTILS_FILE.exists():
1239 return {}
1240
1241 try:
1242 tree = ast.parse(FLASHINFER_UTILS_FILE.read_text())
1243 except Exception:
1244 return {}
1245
1246 xqa_compute_cap = _find_exact_cc_in_function(tree, "supports_trtllm_attention")
1247 trtllm_gen_compute_cap = _find_cc_in_function(tree, "supports_trtllm_attention")
1248
1249 if not xqa_compute_cap and not trtllm_gen_compute_cap:
1250 return {}
1251
1252 # KV cache dtypes that only work with a dedicated kernel (e.g. nvfp4
1253 # requires the SM100 NVFP4 MHA kernel) and should not appear in the
1254 # generic attention-backend feature matrix.
1255 kernel_only_kv_dtypes = ["nvfp4"]
1256
1257 return {
1258 "native": {
1259 # Native FlashInfer path.
1260 "supports_sink": False,
1261 },
1262 "xqa": {
1263 # XQA decode path on Hopper.
1264 "compute_capability": xqa_compute_cap,
1265 "supports_sink": False,
1266 },
1267 "trtllm_gen": {
1268 # trtllm-gen pathway on Blackwell.
1269 "compute_capability": trtllm_gen_compute_cap,
1270 "supports_sink": True,
1271 },
1272 "exclude_kv_dtypes": kernel_only_kv_dtypes,
1273 }
1274
1275
1276 # ---------------------------------------------------------------------------
1277 # Backend variant expansion (FA2/FA3/FA4, FlashInfer native/XQA/trtllm-gen)
1278 # ---------------------------------------------------------------------------
1279
1280
1281 def _apply_fp8_support(kv_cache_dtypes: str, supports_fp8: bool) -> str:
1282 """Add or remove fp8 dtypes from a comma-separated kv_cache_dtypes string.
1283
1284 The base FLASH_ATTN backend lists fp8 dtypes in ``supported_kv_cache_dtypes``,
1285 but actual support varies by FA version (e.g. FA2 has no fp8 support), so each
1286 variant's row is normalized to match its own capability.
1287 """
1288 fp8_dtypes = {"fp8", "fp8_e4m3", "fp8_e5m2"}
1289 dtypes = kv_cache_dtypes.split(", ")
1290 base_dtypes = [dtype for dtype in dtypes if dtype not in fp8_dtypes]
1291 if supports_fp8:
1292 advertised_fp8_dtypes = [dtype for dtype in dtypes if dtype in fp8_dtypes]
1293 return ", ".join(base_dtypes + advertised_fp8_dtypes)
1294 return ", ".join(base_dtypes)
1295
1296
1297 def _expand_flash_attn_variants(
1298 all_backends: list[dict[str, Any]],
1299 fa_features: dict[str, dict[str, Any]],
1300 ) -> list[dict[str, Any]]:
1301 """Expand FLASH_ATTN into FA2, FA3, and FA4 variants."""
1302 expanded = []
1303 for backend in all_backends:
1304 if backend["name"] != "FLASH_ATTN":
1305 backend.setdefault("_sort_key", backend["name"])
1306 backend.setdefault("_sort_order", 0)
1307 backend.setdefault("version", "")
1308 expanded.append(backend)
1309 continue
1310
1311 # Create FA2 entry (keeps base backend's compute_capability)
1312 fa2 = backend.copy()
1313 fa2["version"] = "FA2*"
1314 fa2["_sort_key"] = "FLASH_ATTN"
1315 fa2["_sort_order"] = 0
1316 fa2["supports_sink"] = fa_features["fa2"]["supports_sink"]
1317 fa2["kv_cache_dtypes"] = _apply_fp8_support(
1318 backend["kv_cache_dtypes"], fa_features["fa2"]["supports_fp8"]
1319 )
1320
1321 # Create FA3 entry (uses parsed compute_capability from fa_utils)
1322 fa3 = backend.copy()
1323 fa3["version"] = "FA3*"
1324 fa3["_sort_key"] = "FLASH_ATTN"
1325 fa3["_sort_order"] = 1
1326 if fa_features["fa3"]["compute_capability"]:
1327 fa3["compute_capability"] = fa_features["fa3"]["compute_capability"]
1328 fa3["supports_sink"] = fa_features["fa3"]["supports_sink"]
1329 fa3["kv_cache_dtypes"] = _apply_fp8_support(
1330 backend["kv_cache_dtypes"], fa_features["fa3"]["supports_fp8"]
1331 )
1332
1333 expanded.append(fa2)
1334 expanded.append(fa3)
1335
1336 # Create FA4 entry if FA4 features are available
1337 if "fa4" in fa_features:
1338 fa4 = backend.copy()
1339 fa4["version"] = "FA4*"
1340 fa4["_sort_key"] = "FLASH_ATTN"
1341 fa4["_sort_order"] = 2
1342 if fa_features["fa4"].get("compute_capability"):
1343 fa4["compute_capability"] = fa_features["fa4"]["compute_capability"]
1344 fa4["supports_sink"] = fa_features["fa4"]["supports_sink"]
1345 fa4["kv_cache_dtypes"] = _apply_fp8_support(
1346 backend["kv_cache_dtypes"], fa_features["fa4"]["supports_fp8"]
1347 )
1348 expanded.append(fa4)
1349
1350 return expanded
1351
1352
1353 def _expand_flashinfer_variants(
1354 all_backends: list[dict[str, Any]],
1355 fi_features: dict[str, dict[str, Any]],
1356 ) -> list[dict[str, Any]]:
1357 """Expand FLASHINFER into native, XQA, and trtllm-gen variants."""
1358 expanded = []
1359 for backend in all_backends:
1360 if backend["name"] != "FLASHINFER":
1361 expanded.append(backend)
1362 continue
1363
1364 # Parse original compute capability to get min CC
1365 orig_cap = backend["compute_capability"]
1366 parts = orig_cap.replace(".x", "").split("-")
1367 min_cc = parts[0] if parts else "7"
1368
1369 # Create native entry.
1370 native = backend.copy()
1371 native["version"] = "Native†"
1372 native["_sort_key"] = "FLASHINFER"
1373 native["_sort_order"] = 0
1374 native["supports_sink"] = fi_features["native"]["supports_sink"]
1375 native["compute_capability"] = f"{min_cc}.x-9.x"
1376
1377 # Remove KV dtypes only supported by SM100 kernels (e.g. nvfp4)
1378 exclude = fi_features.get("exclude_kv_dtypes", [])
1379 if exclude:
1380 native["kv_cache_dtypes"] = ", ".join(
1381 d
1382 for d in (d.strip() for d in native["kv_cache_dtypes"].split(","))
1383 if d not in exclude
1384 )
1385
1386 # Create XQA entry.
1387 xqa = backend.copy()
1388 xqa["version"] = "XQA†"
1389 xqa["_sort_key"] = "FLASHINFER"
1390 xqa["_sort_order"] = 1
1391 xqa["compute_capability"] = fi_features["xqa"]["compute_capability"]
1392 xqa["supports_sink"] = fi_features["xqa"]["supports_sink"]
1393 xqa["supports_non_causal"] = False
1394 if exclude:
1395 xqa["kv_cache_dtypes"] = ", ".join(
1396 d
1397 for d in (d.strip() for d in xqa["kv_cache_dtypes"].split(","))
1398 if d not in exclude
1399 )
1400
1401 # Create trtllm-gen entry.
1402 trtllm_gen = backend.copy()
1403 trtllm_gen["version"] = "trtllm-gen†"
1404 trtllm_gen["_sort_key"] = "FLASHINFER"
1405 trtllm_gen["_sort_order"] = 2
1406 trtllm_gen["compute_capability"] = fi_features["trtllm_gen"][
1407 "compute_capability"
1408 ]
1409 trtllm_gen["supports_sink"] = fi_features["trtllm_gen"]["supports_sink"]
1410
1411 expanded.append(native)
1412 if fi_features["xqa"]["compute_capability"]:
1413 expanded.append(xqa)
1414 if fi_features["trtllm_gen"]["compute_capability"]:
1415 expanded.append(trtllm_gen)
1416 return expanded
1417
1418
1419 # ---------------------------------------------------------------------------
1420 # CUDA priority list parsing
1421 # ---------------------------------------------------------------------------
1422
1423
1424 def parse_cuda_priority_lists() -> dict[str, list[str]]:
1425 """Parse priority lists from cuda.py using AST.
1426
1427 The structure of _get_backend_priorities is:
1428 if use_mla:
1429 if device_capability.major == 10:
1430 return [MLA list for SM100]
1431 else:
1432 return [MLA list for default]
1433 else:
1434 if device_capability.major == 10:
1435 return [Standard list for SM100]
1436 else:
1437 return [Standard list for default]
1438 """
1439 if not CUDA_PLATFORM_FILE.exists():
1440 return {}
1441
1442 try:
1443 source = CUDA_PLATFORM_FILE.read_text()
1444 tree = ast.parse(source)
1445 except Exception:
1446 return {}
1447
1448 priorities: dict[str, list[str]] = {}
1449
1450 # Find the _get_backend_priorities function
1451 for node in ast.walk(tree):
1452 if not isinstance(node, ast.FunctionDef):
1453 continue
1454 if node.name != "_get_backend_priorities":
1455 continue
1456
1457 # Process the function body directly
1458 for stmt in node.body:
1459 if not isinstance(stmt, ast.If):
1460 continue
1461
1462 # Check if this is the "if use_mla:" branch
1463 is_mla_branch = (
1464 isinstance(stmt.test, ast.Name) and stmt.test.id == "use_mla"
1465 )
1466
1467 if is_mla_branch:
1468 _extract_priorities(stmt.body, priorities, "mla")
1469 if stmt.orelse:
1470 _extract_priorities(stmt.orelse, priorities, "standard")
1471 else:
1472 _extract_priorities([stmt], priorities, "standard")
1473
1474 return priorities
1475
1476
1477 def _get_backends_from_return(stmts: list) -> list[str]:
1478 """Extract backend names from return statements in a list of statements.
1479
1480 Handles starred unpacking (e.g. ``*sparse_backends``) by resolving the
1481 variable from assignments found in the same statement list. When the
1482 variable is conditionally assigned (inside an ``if/else``), the ``else``
1483 branch value is used as the representative default.
1484 """
1485 # Collect variable assignments so we can resolve starred expressions.
1486 # For conditional assignments, last-written (else branch) wins.
1487 var_assigns: dict[str, list[str]] = {}
1488 for stmt in stmts:
1489 if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.List):
1490 for target in stmt.targets:
1491 if isinstance(target, ast.Name):
1492 var_assigns[target.id] = [
1493 e.attr for e in stmt.value.elts if isinstance(e, ast.Attribute)
1494 ]
1495 elif isinstance(stmt, ast.If):
1496 for branch in (stmt.body, stmt.orelse):
1497 for branch_stmt in branch:
1498 if isinstance(branch_stmt, ast.Assign) and isinstance(
1499 branch_stmt.value, ast.List
1500 ):
1501 for target in branch_stmt.targets:
1502 if isinstance(target, ast.Name):
1503 var_assigns[target.id] = [
1504 e.attr
1505 for e in branch_stmt.value.elts
1506 if isinstance(e, ast.Attribute)
1507 ]
1508
1509 for stmt in stmts:
1510 if isinstance(stmt, ast.Return) and isinstance(stmt.value, ast.List):
1511 backends: list[str] = []
1512 for e in stmt.value.elts:
1513 if isinstance(e, ast.Attribute):
1514 backends.append(e.attr)
1515 elif (
1516 isinstance(e, ast.Starred)
1517 and isinstance(e.value, ast.Name)
1518 and e.value.id in var_assigns
1519 ):
1520 backends.extend(var_assigns[e.value.id])
1521 return backends
1522 return []
1523
1524
1525 def _is_sm100_check(test: ast.expr) -> bool:
1526 """Check if test is `something.major == 10`, possibly inside an `and`."""
1527 if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.And):
1528 return any(_is_sm100_check(value) for value in test.values)
1529 return (
1530 isinstance(test, ast.Compare)
1531 and isinstance(test.left, ast.Attribute)
1532 and test.left.attr == "major"
1533 and len(test.ops) == 1
1534 and isinstance(test.ops[0], ast.Eq)
1535 and len(test.comparators) == 1
1536 and isinstance(test.comparators[0], ast.Constant)
1537 and test.comparators[0].value == 10
1538 )
1539
1540
1541 def _extract_priorities(body: list, priorities: dict[str, list[str]], prefix: str):
1542 """Extract priority lists from if/else statement body."""
1543 for stmt in body:
1544 if isinstance(stmt, ast.If):
1545 is_sm100 = _is_sm100_check(stmt.test)
1546 if_key = f"{prefix}_sm100" if is_sm100 else f"{prefix}_default"
1547 else_key = f"{prefix}_default" if is_sm100 else f"{prefix}_sm100"
1548
1549 if backends := _get_backends_from_return(stmt.body):
1550 priorities[if_key] = backends
1551 if backends := _get_backends_from_return(stmt.orelse):
1552 priorities[else_key] = backends
1553
1554 elif isinstance(stmt, ast.Return) and isinstance(stmt.value, ast.List):
1555 backends = [e.attr for e in stmt.value.elts if isinstance(e, ast.Attribute)]
1556 priorities[f"{prefix}_default"] = backends
1557
1558
1559 # ---------------------------------------------------------------------------
1560 # Data-driven table rendering
1561 #
1562 # Each column is a (header, formatter) pair. The formatter takes a backend
1563 # info dict and returns the cell string. Tables are assembled by selecting
1564 # which columns to include, then calling _render_table().
1565 # ---------------------------------------------------------------------------
1566
1567 # Column type alias for readability
1568 TableColumn = tuple[str, Callable[[dict[str, Any]], str]]
1569
1570 # Shared column definitions -- order here matches the output table order
1571 _COL_BACKEND: TableColumn = ("Backend", lambda b: f"`{b['name']}`")
1572 _COL_VERSION: TableColumn = ("Version", lambda b: b.get("version", ""))
1573 _COL_DTYPES: TableColumn = ("Dtypes", lambda b: b["dtypes"])
1574 _COL_KV_DTYPES: TableColumn = (
1575 "KV Dtypes",
1576 lambda b: add_literal_quotes(b["kv_cache_dtypes"]),
1577 )
1578 _COL_BLOCK_SIZES: TableColumn = ("Block Sizes", lambda b: b["block_sizes"])
1579 _COL_HEAD_SIZES: TableColumn = ("Head Sizes", lambda b: b["head_sizes"])
1580 _COL_SINK: TableColumn = ("Sink", lambda b: bool_to_emoji(b["supports_sink"]))
1581 _COL_NON_CAUSAL: TableColumn = (
1582 "Non-Causal",
1583 lambda b: bool_to_emoji(b["supports_non_causal"]),
1584 )
1585 _COL_SPARSE: TableColumn = ("Sparse", lambda b: bool_to_emoji(b["is_sparse"]))
1586 _COL_MM_PREFIX: TableColumn = (
1587 "MM Prefix",
1588 lambda b: bool_to_emoji(b["supports_mm_prefix"]),
1589 )
1590 _COL_DCP: TableColumn = ("DCP", lambda b: bool_to_emoji(b["supports_dcp"]))
1591 _COL_ATTN_TYPES: TableColumn = ("Attention Types", lambda b: b["attn_types"])
1592 _COL_COMPUTE_CAP: TableColumn = ("Compute Cap.", lambda b: b["compute_capability"])
1593
1594
1595 def add_literal_quotes(value: str) -> str:
1596 """Add literal backticks around all comma-separated items in a string."""
1597 items = [item.strip() for item in value.split(",")]
1598 return ", ".join(f"`{item}`" for item in items)
1599
1600
1601 def bool_to_emoji(value: bool) -> str:
1602 """Convert a boolean to a checkmark or X emoji."""
1603 return "✅" if value else "❌"
1604
1605
1606 def _build_columns(is_mla: bool, has_versions: bool) -> list[TableColumn]:
1607 """Build the column list for a backend feature table.
1608
1609 The column selection depends on whether it's an MLA table (includes
1610 Sparse column) and whether any backend has version variants (includes
1611 Version column).
1612 """
1613 cols: list[TableColumn] = [_COL_BACKEND]
1614 if has_versions:
1615 cols.append(_COL_VERSION)
1616 cols.extend([_COL_DTYPES, _COL_KV_DTYPES, _COL_BLOCK_SIZES, _COL_HEAD_SIZES])
1617 cols.append(_COL_SINK)
1618 cols.append(_COL_NON_CAUSAL)
1619 if is_mla:
1620 cols.append(_COL_SPARSE)
1621 cols.extend([_COL_MM_PREFIX, _COL_DCP, _COL_ATTN_TYPES, _COL_COMPUTE_CAP])
1622 return cols
1623
1624
1625 def _sort_key(x: dict[str, Any]) -> tuple[str, int]:
1626 """Sort key that keeps parent/child rows together in order."""
1627 return (x.get("_sort_key", x["name"]), x.get("_sort_order", 0))
1628
1629
1630 def _render_table(
1631 columns: list[TableColumn],
1632 backends: list[dict[str, Any]],
1633 ) -> list[str]:
1634 """Render a markdown table from column specs and backend data."""
1635 header = "| " + " | ".join(name for name, _ in columns) + " |"
1636 sep = "| " + " | ".join("-" * len(name) for name, _ in columns) + " |"
1637 lines = [header, sep]
1638 for info in sorted(backends, key=_sort_key):
1639 row = "| " + " | ".join(fmt(info) for _, fmt in columns) + " |"
1640 lines.append(row.replace(" ", " "))
1641 return lines
1642
1643
1644 def _priority_table(
1645 title: str,
1646 backends: list[str],
1647 annotations: dict[str, str] | None = None,
1648 ) -> list[str]:
1649 """Render a priority table for a list of backends."""
1650
1651 def _fmt(b: str) -> str:
1652 suffix = annotations.get(b, "") if annotations else ""
1653 return f"`{b}`{suffix}"
1654
1655 return [
1656 f"**{title}:**",
1657 "",
1658 "| Priority | Backend |",
1659 "| -------- | ------- |",
1660 *[f"| {i} | {_fmt(b)} |" for i, b in enumerate(backends, 1)],
1661 "",
1662 ]
1663
1664
1665 _SM100 = "Blackwell (SM 10.x)"
1666 _AMPERE = "Ampere/Hopper (SM 8.x-9.x)"
1667
1668
1669 def _priority_block(
1670 priorities: dict[str, list[str]],
1671 sm100_key: str,
1672 default_key: str,
1673 sm100_annotations: dict[str, str] | None = None,
1674 ) -> str:
1675 """Render whichever priority tables exist for one attention category."""
1676 lines: list[str] = []
1677 if sm100_key in priorities:
1678 lines += _priority_table(_SM100, priorities[sm100_key], sm100_annotations)
1679 if default_key in priorities:
1680 lines += _priority_table(_AMPERE, priorities[default_key])
1681 return "\n".join(lines).strip()
1682
1683
1684 def _feature_table(backends: list[dict[str, Any]], is_mla: bool) -> str:
1685 """Render a backend feature table (header, separator, one row per backend)."""
1686 has_versions = any(b.get("version") for b in backends)
1687 columns = _build_columns(is_mla, has_versions)
1688 return "\n".join(_render_table(columns, backends))
1689
1690
1691 def _mla_prefill_table(prefill_backends: list[dict[str, Any]]) -> str:
1692 """Render the MLA prefill backend table."""
1693 lines = [
1694 "| Backend | Description | Dtypes | Compute Cap. | Notes |",
1695 "| ------- | ----------- | ------ | ------------ | ----- |",
1696 ]
1697 for backend in prefill_backends:
1698 row = "| `{}`{} | {} | {} | {} | {} |".format(
1699 backend["name"],
1700 backend.get("marker", ""),
1701 backend["description"],
1702 backend.get("dtypes", "fp16, bf16"),
1703 backend["compute_capability"],
1704 backend.get("notes", ""),
1705 )
1706 lines.append(row.replace(" ", " "))
1707 return "\n".join(lines)
1708
1709
1710 def build_blocks() -> dict[str, str]:
1711 """Build the generated table blocks keyed by their `gen:` marker name.
1712
1713 Only the tables are generated here; the surrounding prose lives in the
1714 handwritten ``docs/design/attention_backends.md`` page.
1715 """
1716 attention_backends_map = parse_registry()
1717 priorities = parse_cuda_priority_lists()
1718 fa_features = parse_flash_attn_features()
1719 fi_features = parse_flashinfer_trtllm_features()
1720 mla_prefill_backends = parse_mla_prefill_backends()
1721
1722 all_backends = []
1723 for backend_name, class_path in attention_backends_map.items():
1724 if backend_name in SKIP_BACKENDS:
1725 continue
1726 info = analyze_backend(backend_name, class_path)
1727 if info:
1728 all_backends.append(info)
1729 if fa_features:
1730 all_backends = _expand_flash_attn_variants(all_backends, fa_features)
1731 if fi_features:
1732 all_backends = _expand_flashinfer_variants(all_backends, fi_features)
1733
1734 # DeepSeek V4 (*_DSV4) and MiniMax M3 sparse backends get their own tables
1735 # rather than mixing into the main MLA / standard tables (the ROCm V4 backend
1736 # isn't flagged is_mla by the AST heuristic, so filter purely on the name).
1737 def _is_v4(b: dict[str, Any]) -> bool:
1738 return b["name"].endswith("_DSV4")
1739
1740 def _is_minimax(b: dict[str, Any]) -> bool:
1741 return not b["is_mla"] and not _is_v4(b) and b["name"].startswith("MINIMAX")
1742
1743 v4_decode_backends = [b for b in all_backends if _is_v4(b)]
1744 minimax_backends = [b for b in all_backends if _is_minimax(b)]
1745 mla_backends = [b for b in all_backends if b["is_mla"] and not _is_v4(b)]
1746 non_mla_backends = [
1747 b
1748 for b in all_backends
1749 if not b["is_mla"] and not _is_v4(b) and not _is_minimax(b)
1750 ]
1751
1752 mla_sm100_annotations = {"FLASHINFER_MLA_SPARSE": "**\\***"}
1753 return {
1754 "priority-standard": _priority_block(
1755 priorities, "standard_sm100", "standard_default"
1756 ),
1757 "priority-mla": _priority_block(
1758 priorities, "mla_sm100", "mla_default", mla_sm100_annotations
1759 ),
1760 "table-standard": _feature_table(non_mla_backends, is_mla=False),
1761 "table-minimax": _feature_table(minimax_backends, is_mla=False),
1762 "table-mla-prefill": _mla_prefill_table(mla_prefill_backends),
1763 "table-mla-decode": _feature_table(mla_backends, is_mla=True),
1764 "table-mla-v4-decode": _feature_table(v4_decode_backends, is_mla=True),
1765 }
1766
1767
1768 logger.info("Generating attention backend documentation")
1769 fill_markers("design/attention_backends.md", build_blocks())