[ROCm] Give turboquant boundary layers a layout-compatible backend (#54988)

Signed-off-by: Stefan Koncarevic <stefan.koncarevic@amd.com> Signed-off-by: Andreas Karatzas <akaratza@amd.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com> Co-authored-by: Codex <noreply@openai.com>

stefankoncarevic committed Sep 24, 2026 at 05:00 UTC e78e367c6e22740fac4b027fc6431756bb014ec6
3 files changed +232 -16
tests/v1/attention/test_rocm_attention_backends_selection.py
+143
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for attention backend selectors."""
4
5 +from types import SimpleNamespace
6 from unittest.mock import MagicMock, patch
7
8 import pytest
@@ -34,6 +35,15 @@ def mock_get_cdna_version():
35 yield
36
37
38 +@pytest.fixture
39 +def cleared_attention_selector_cache():
40 + from vllm.v1.attention.selector import _cached_get_attn_backend
41 +
42 + _cached_get_attn_backend.cache_clear()
43 + yield
44 + _cached_get_attn_backend.cache_clear()
45 +
46 +
47 def test_aiter_unified_attention_uses_dedicated_metadata_builder():
48 from vllm.v1.attention.backends.rocm_aiter_unified_attn import (
49 RocmAiterUnifiedAttentionBackend,
@@ -434,6 +444,139 @@ def test_aiter_fa_requires_mi3xx(mock_vllm_config):
444 )
445
446
447 +@pytest.fixture
448 +def turboquant_run_config():
449 + """Current config of a run whose KV cache dtype is a turboquant_* preset."""
450 + config = SimpleNamespace(
451 + cache_config=SimpleNamespace(cache_dtype="turboquant_k8v4")
452 + )
453 + with patch(
454 + "vllm.config.get_current_vllm_config_or_none",
455 + return_value=config,
456 + ):
457 + yield config
458 +
459 +
460 +def test_turboquant_boundary_selection_is_not_cached_from_ordinary_run(
461 + cleared_attention_selector_cache,
462 +):
463 + from vllm.config import CacheConfig, VllmConfig, set_current_vllm_config
464 + from vllm.v1.attention.backends.utils import get_supported_kv_cache_layouts
465 + from vllm.v1.attention.selector import get_attn_backend
466 + from vllm.v1.kv_cache_layout import KVCacheLayout
467 +
468 + ordinary_config = VllmConfig(cache_config=CacheConfig(cache_dtype="auto"))
469 + turboquant_config = VllmConfig(
470 + cache_config=CacheConfig(cache_dtype="turboquant_k8v4")
471 + )
472 +
473 + backend_priorities = [
474 + AttentionBackendEnum.ROCM_ATTN,
475 + AttentionBackendEnum.TRITON_ATTN,
476 + AttentionBackendEnum.TURBOQUANT,
477 + ]
478 + with patch(
479 + "vllm.platforms.rocm._get_backend_priorities",
480 + return_value=backend_priorities,
481 + ):
482 + with set_current_vllm_config(ordinary_config):
483 + ordinary_backend = get_attn_backend(128, torch.float16, "auto")
484 +
485 + with set_current_vllm_config(turboquant_config):
486 + boundary_backend = get_attn_backend(128, torch.float16, "auto")
487 + quantized_backend = get_attn_backend(128, torch.float16, "turboquant_k8v4")
488 +
489 + assert ordinary_backend is AttentionBackendEnum.ROCM_ATTN.get_class()
490 + assert quantized_backend is AttentionBackendEnum.TURBOQUANT.get_class()
491 + assert get_supported_kv_cache_layouts([boundary_backend, quantized_backend]) == [
492 + KVCacheLayout.LBNHC
493 + ]
494 +
495 +
496 +def test_turboquant_run_does_not_mask_unsupported_selected_backend(
497 + turboquant_run_config,
498 +):
499 + from vllm.platforms.rocm import RocmPlatform
500 +
501 + attn_selector_config = AttentionSelectorConfig(
502 + head_size=128,
503 + dtype=torch.float16,
504 + kv_cache_dtype="auto",
505 + block_size=16,
506 + )
507 +
508 + with (
509 + patch("vllm.platforms.rocm.get_cdna_version", return_value=1),
510 + pytest.raises(ValueError, match="compute capability not supported"),
511 + ):
512 + RocmPlatform.get_attn_backend_cls(
513 + selected_backend=AttentionBackendEnum.ROCM_AITER_FA,
514 + attn_selector_config=attn_selector_config,
515 + )
516 +
517 +
518 +def test_turboquant_layout_check_respects_backend_override():
519 + from vllm.platforms.rocm import _shares_layout_with_turboquant
520 + from vllm.v1.kv_cache_layout import KVCacheLayout
521 +
522 + boundary_backend = MagicMock()
523 + boundary_backend.supported_kv_cache_layouts.return_value = (KVCacheLayout.LBHNC,)
524 + turboquant_override = MagicMock()
525 + turboquant_override.supported_kv_cache_layouts.return_value = (KVCacheLayout.LBHNC,)
526 +
527 + with patch.object(
528 + AttentionBackendEnum.TURBOQUANT,
529 + "get_class",
530 + return_value=turboquant_override,
531 + ) as get_turboquant_class:
532 + assert _shares_layout_with_turboquant(boundary_backend)
533 + get_turboquant_class.assert_called_once_with()
534 +
535 +
536 +@pytest.mark.parametrize(
537 + "selected_backend",
538 + [None, "ROCM_ATTN", "ROCM_AITER_FA", "TURBOQUANT"],
539 +)
540 +def test_turboquant_boundary_layers_share_a_layout(
541 + selected_backend,
542 + turboquant_run_config,
543 + mock_get_cdna_version,
544 +):
545 + """A turboquant_* run must resolve to backends with a common KV layout.
546 +
547 + The boundary layers keep the native dtype and pick their own backend while
548 + every other layer picks TURBOQUANT. Engine startup hard-errors when the two
549 + share no layout, so the boundary layers must not land on a native ROCm or
550 + AITER backend, whichever backend the run asked for.
551 + """
552 + from vllm.platforms.rocm import RocmPlatform
553 + from vllm.utils.import_utils import resolve_obj_by_qualname
554 + from vllm.v1.attention.backends.utils import get_supported_kv_cache_layouts
555 +
556 + backend_enum = (
557 + getattr(AttentionBackendEnum, selected_backend) if selected_backend else None
558 + )
559 +
560 + def resolve(kv_cache_dtype):
561 + path = RocmPlatform.get_attn_backend_cls(
562 + selected_backend=backend_enum,
563 + attn_selector_config=AttentionSelectorConfig(
564 + head_size=128,
565 + dtype=torch.float16,
566 + kv_cache_dtype=kv_cache_dtype,
567 + block_size=16,
568 + ),
569 + )
570 + return resolve_obj_by_qualname(path)
571 +
572 + boundary_backend = resolve("auto")
573 + quantized_backend = resolve("turboquant_k8v4")
574 +
575 + assert quantized_backend is AttentionBackendEnum.TURBOQUANT.get_class()
576 + # Raises when the intersection is empty, which is the startup failure.
577 + assert get_supported_kv_cache_layouts([boundary_backend, quantized_backend])
578 +
579 +
580 def test_sparse_not_supported(mock_vllm_config):
581 """Test that sparse MLA without use_mla flag raises an error."""
582 from vllm.platforms.rocm import RocmPlatform
vllm/platforms/rocm.py
+82 -16
@@ -23,10 +23,14 @@ if TYPE_CHECKING:
23 from vllm.config import VllmConfig
24 from vllm.config.kernel import IrOpPriorityConfig
25 from vllm.utils.argparse_utils import FlexibleArgumentParser
26 + from vllm.v1.attention.backend import AttentionBackend
27 from vllm.v1.attention.selector import AttentionSelectorConfig
28
29 logger = init_logger(__name__)
30
31 +_KV_CACHE_DTYPE_REASON = "kv_cache_dtype not supported"
32 +_TURBOQUANT_LAYOUT_REASON = "no KV cache layout in common with TURBOQUANT"
33 +
34 try:
35 from amdsmi import (
36 AmdSmiException,
@@ -505,6 +509,53 @@ def _get_backend_priorities(
509 return backends
510
511
512 +def _uses_turboquant(vllm_config: "VllmConfig | None") -> bool:
513 + """Whether the run's KV cache dtype is one of the turboquant_* presets."""
514 + cache_config = getattr(vllm_config, "cache_config", None)
515 + return cache_config is not None and str(cache_config.cache_dtype).startswith(
516 + "turboquant_"
517 + )
518 +
519 +
520 +def _shares_layout_with_turboquant(backend_class: type["AttentionBackend"]) -> bool:
521 + """Whether this backend reads a KV cache layout TURBOQUANT reads too.
522 +
523 + A turboquant_* run keeps its boundary layers at the native dtype (see
524 + TurboQuantConfig.get_boundary_skip_layers), so those layers pick a backend of
525 + their own while every other layer picks TURBOQUANT. One layout has to serve
526 + the whole worker, and get_supported_kv_cache_layouts() turns an empty
527 + intersection into a hard error at engine startup.
528 + """
529 + layouts = backend_class.supported_kv_cache_layouts()
530 + turboquant_layouts = (
531 + AttentionBackendEnum.TURBOQUANT.get_class().supported_kv_cache_layouts()
532 + )
533 + if layouts is None or turboquant_layouts is None:
534 + return True
535 + return not set(layouts).isdisjoint(turboquant_layouts)
536 +
537 +
538 +def _get_invalid_reasons(
539 + backend_class: type["AttentionBackend"],
540 + device_capability: DeviceCapability,
541 + attn_selector_config: "AttentionSelectorConfig",
542 + *,
543 + is_turboquant_run: bool,
544 +) -> list[str]:
545 + """Why this backend cannot serve the layer, empty when it can."""
546 + invalid_reasons = backend_class.validate_configuration(
547 + device_capability=device_capability,
548 + **attn_selector_config._asdict(),
549 + )
550 + if (
551 + not invalid_reasons
552 + and is_turboquant_run
553 + and not _shares_layout_with_turboquant(backend_class)
554 + ):
555 + invalid_reasons = [_TURBOQUANT_LAYOUT_REASON]
556 + return invalid_reasons
557 +
558 +
559 class RocmPlatform(Platform):
560 _enum = PlatformEnum.ROCM
561 device_name: str = "rocm"
@@ -611,12 +662,14 @@ class RocmPlatform(Platform):
662 # TODO: Make this explicit in the selector in a future PR.
663 if is_encoder_decoder and AttentionBackendEnum.ROCM_ATTN in backend_priorities:
664 backend_priorities.remove(AttentionBackendEnum.ROCM_ATTN)
665 + is_turboquant_run = _uses_turboquant(vllm_config)
666 for priority, backend in enumerate(backend_priorities):
667 try:
616 - backend_class = backend.get_class()
617 - invalid_reasons_i = backend_class.validate_configuration(
618 - device_capability=device_capability,
619 - **attn_selector_config._asdict(),
668 + invalid_reasons_i = _get_invalid_reasons(
669 + backend.get_class(),
670 + device_capability,
671 + attn_selector_config,
672 + is_turboquant_run=is_turboquant_run,
673 )
674 except ImportError:
675 invalid_reasons_i = ["ImportError"]
@@ -639,11 +692,16 @@ class RocmPlatform(Platform):
692
693 # First try checking just the selected backend, if there is one.
694 if selected_backend is not None:
695 + # Keep lazy: vllm.config imports current_platform during initialization.
696 + from vllm.config import get_current_vllm_config_or_none
697 +
698 + is_turboquant_run = _uses_turboquant(get_current_vllm_config_or_none())
699 try:
643 - backend_class = selected_backend.get_class()
644 - sel_invalid_reasons = backend_class.validate_configuration(
645 - device_capability=device_capability,
646 - **attn_selector_config._asdict(),
700 + sel_invalid_reasons = _get_invalid_reasons(
701 + selected_backend.get_class(),
702 + device_capability,
703 + attn_selector_config,
704 + is_turboquant_run=is_turboquant_run,
705 )
706 except ImportError:
707 sel_invalid_reasons = ["ImportError"]
@@ -653,21 +711,29 @@ class RocmPlatform(Platform):
711 selected_backend.name,
712 )
713 return selected_backend.get_path()
656 - # Only tolerate the mismatch for turboquant_* KV-cache layers:
657 - # boundary layers keep the native dtype (served by the selected
658 - # backend) while turboquant_* layers need TURBOQUANT, so no single
659 - # --attention-backend can serve every layer. For any other dtype
660 - # the explicit selection is genuinely invalid -> fail loud.
714 + # Only tolerate the mismatch when turboquant is in play: boundary
715 + # layers keep the native dtype while every other layer needs
716 + # TURBOQUANT, so no single --attention-backend can serve every layer.
717 + # For any other dtype the selection is genuinely invalid -> fail loud.
718 kv_dtype = attn_selector_config.kv_cache_dtype
662 - if not (kv_dtype is not None and str(kv_dtype).startswith("turboquant")):
719 + layer_is_turboquant = kv_dtype is not None and str(kv_dtype).startswith(
720 + "turboquant"
721 + )
722 + is_turboquant_fallback = (
723 + is_turboquant_run or layer_is_turboquant
724 + ) and sel_invalid_reasons in (
725 + [_KV_CACHE_DTYPE_REASON],
726 + [_TURBOQUANT_LAYOUT_REASON],
727 + )
728 + if not is_turboquant_fallback:
729 raise ValueError(
730 f"Selected backend {selected_backend} is not valid for "
731 f"this configuration. Reason: {sel_invalid_reasons}"
732 )
733 # NOTE: pass a str (not the list) -- info_once hashes its args.
734 logger.info_once(
669 - "Selected backend %s is incompatible with this turboquant "
670 - "layer (%s); using the auto-selected per-layer backend. "
735 + "Selected backend %s is incompatible with this layer (%s) of "
736 + "the turboquant run; using the auto-selected per-layer backend. "
737 "Reason: %s",
738 selected_backend.name,
739 attn_selector_config.attn_type,
vllm/v1/attention/selector.py
+7
@@ -194,6 +194,9 @@ def get_attn_backend(
194 backend=backend,
195 attn_selector_config=attn_selector_config,
196 num_heads=num_heads,
197 + _run_kv_cache_dtype=(
198 + cache_config.cache_dtype if cache_config is not None else None
199 + ),
200 )
201
202
@@ -202,7 +205,11 @@ def _cached_get_attn_backend(
205 backend,
206 attn_selector_config: AttentionSelectorConfig,
207 num_heads: int | None = None,
208 + *,
209 + _run_kv_cache_dtype: CacheDType | None = None,
210 ) -> type[AttentionBackend]:
211 + # Some platform selectors inspect the run-wide cache dtype. Keep it in the
212 + # cache key even though they read the value from the current config.
213 from vllm.platforms import current_platform
214
215 attention_cls = current_platform.get_attn_backend_cls(