@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 54254f7a61
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 7, 22:45 UTC

[ROCm][CI] Fix spec decode logprobs flakiness and parametrize tree attention backends (#34599)

Signed-off-by: Andreas Karatzas <akaratza@amd.com>

Andreas Karatzas committed Feb 20, 2026 at 22:25 UTC 54254f7a6155002b1a493c3cefda9752bc9ce92f
2 files changed +356 -151
tests/v1/sample/test_logprobs.py
+158 -134
@@ -52,7 +52,7 @@ def vllm_model(vllm_runner, request) -> Generator[VllmRunner, None, None]:
52 # TODO: enable this once we support it for
53 # prompt logprobs.
54 enable_prefix_caching=request.param,
55 - gpu_memory_utilization=0.4, # up to 2 alive concurrently
55 + gpu_memory_utilization=0.4,
56 ) as vllm_model:
57 yield vllm_model
58
@@ -366,21 +366,20 @@ def test_max_logprobs():
366 Should also fail for `prompt_logprobs > max_logprobs`
367 APC should not matter as this test checks basic request validation.
368 """
369 - runner = VllmRunner(
369 + with VllmRunner(
370 "facebook/opt-125m",
371 max_logprobs=1,
372 enable_prefix_caching=False,
373 - # 2 other llms alive during whole session
373 gpu_memory_utilization=0.15,
374 max_model_len=256,
376 - )
377 - vllm_sampling_params = SamplingParams(logprobs=1)
378 - # should pass
379 - runner.generate(["Hello world"], sampling_params=vllm_sampling_params)
375 + ) as runner:
376 + vllm_sampling_params = SamplingParams(logprobs=1)
377 + # should pass
378 + runner.generate(["Hello world"], sampling_params=vllm_sampling_params)
379
381 - bad_sampling_params = SamplingParams(logprobs=2)
382 - with pytest.raises(ValueError):
383 - runner.generate(["Hello world"], sampling_params=bad_sampling_params)
380 + bad_sampling_params = SamplingParams(logprobs=2)
381 + with pytest.raises(ValueError):
382 + runner.generate(["Hello world"], sampling_params=bad_sampling_params)
383
384
385 def test_none_logprobs(vllm_model, example_prompts):
@@ -449,33 +448,31 @@ def test_all_logprobs(example_prompts):
448 Args:
449 example_prompts: list of example prompts (test fixture)
450 """
452 - runner = VllmRunner(
451 + with VllmRunner(
452 "facebook/opt-125m",
453 max_logprobs=-1,
454 enable_prefix_caching=False,
456 - # 2 other llms alive during whole session
455 gpu_memory_utilization=0.15,
456 max_model_len=256,
459 - )
460 -
461 - sampling_params_logprobs_all = SamplingParams(
462 - max_tokens=5, logprobs=-1, prompt_logprobs=-1
463 - )
464 - results_logprobs_all = runner.llm.generate(
465 - example_prompts, sampling_params=sampling_params_logprobs_all
466 - )
467 - vocab_size = runner.llm.llm_engine.model_config.get_vocab_size()
457 + ) as runner:
458 + sampling_params_logprobs_all = SamplingParams(
459 + max_tokens=5, logprobs=-1, prompt_logprobs=-1
460 + )
461 + results_logprobs_all = runner.llm.generate(
462 + example_prompts, sampling_params=sampling_params_logprobs_all
463 + )
464 + vocab_size = runner.llm.llm_engine.model_config.get_vocab_size()
465
469 - for i in range(len(results_logprobs_all)):
470 - logprobs = results_logprobs_all[i].outputs[0].logprobs
471 - prompt_logprobs = results_logprobs_all[i].prompt_logprobs
472 - assert logprobs is not None
473 - for logprob in logprobs:
474 - assert len(logprob) == vocab_size
475 - assert prompt_logprobs is not None
476 - assert prompt_logprobs[0] is None
477 - for prompt_logprob in prompt_logprobs[1:]:
478 - assert len(prompt_logprob) == vocab_size
466 + for i in range(len(results_logprobs_all)):
467 + logprobs = results_logprobs_all[i].outputs[0].logprobs
468 + prompt_logprobs = results_logprobs_all[i].prompt_logprobs
469 + assert logprobs is not None
470 + for logprob in logprobs:
471 + assert len(logprob) == vocab_size
472 + assert prompt_logprobs is not None
473 + assert prompt_logprobs[0] is None
474 + for prompt_logprob in prompt_logprobs[1:]:
475 + assert len(prompt_logprob) == vocab_size
476
477
478 @pytest.mark.parametrize("logprobs_mode", get_args(LogprobsMode))
@@ -495,24 +492,28 @@ def test_logprobs_mode(logprobs_mode: LogprobsMode):
492 max_model_len=16,
493 logprobs_mode=logprobs_mode,
494 )
498 - vllm_sampling_params = SamplingParams(logprobs=1)
499 - results = llm.generate(["Hello world"], sampling_params=vllm_sampling_params)
500 -
501 - total_token_with_logprobs = 0
502 - positive_values = 0
503 - for output in results[0].outputs:
504 - for logprobs in output.logprobs:
505 - for token_id in logprobs:
506 - logprob = logprobs[token_id]
507 - if logprobs_mode in ("raw_logprobs", "processed_logprobs"):
508 - assert logprob.logprob <= 0
509 - if logprob.logprob > 0:
510 - positive_values = positive_values + 1
511 - total_token_with_logprobs = total_token_with_logprobs + 1
512 - assert total_token_with_logprobs >= len(results[0].outputs)
513 - if logprobs_mode in ("raw_logits", "processed_logits"):
514 - assert positive_values > 0
515 - del llm
495 + try:
496 + vllm_sampling_params = SamplingParams(logprobs=1)
497 + results = llm.generate(["Hello world"], sampling_params=vllm_sampling_params)
498 +
499 + total_token_with_logprobs = 0
500 + positive_values = 0
501 + for output in results[0].outputs:
502 + for logprobs in output.logprobs:
503 + for token_id in logprobs:
504 + logprob = logprobs[token_id]
505 + if logprobs_mode in ("raw_logprobs", "processed_logprobs"):
506 + assert logprob.logprob <= 0
507 + if logprob.logprob > 0:
508 + positive_values = positive_values + 1
509 + total_token_with_logprobs = total_token_with_logprobs + 1
510 + assert total_token_with_logprobs >= len(results[0].outputs)
511 + if logprobs_mode in ("raw_logits", "processed_logits"):
512 + assert positive_values > 0
513 + finally:
514 + del llm
515 + torch.cuda.empty_cache()
516 + cleanup_dist_env_and_memory()
517
518
519 class TestCorrectDecodedToken:
@@ -767,7 +768,7 @@ class TestCorrectDecodedToken:
768 # Simulate cases where individual tokens decode to "�"
769 # but combinations decode correctly
770 if len(ids) == 1:
770 - if ids[0] == 3 or ids[0] == 4 or ids[0] == 8 or ids[0] == 9:
771 + if ids[0] in (3, 4, 8, 9):
772 return "�"
773 elif len(ids) == 2:
774 if ids == [2, 3]:
@@ -809,42 +810,41 @@ def test_verify_tokens_integration():
810 corrects tokens ending with the replacement character "�".
811 Uses facebook/opt-125m which is known to produce these issues.
812 """
812 - runner = VllmRunner(
813 + with VllmRunner(
814 "facebook/opt-125m",
815 max_logprobs=0,
816 enable_prefix_caching=False,
817 gpu_memory_utilization=0.15,
818 max_model_len=256,
818 - )
819 -
820 - # Use a prompt that triggers multi-byte UTF-8 issues
821 - # Based on user's example: "In this example,"
822 - test_prompts = ["In this example,"]
823 -
824 - sampling_params = SamplingParams(
825 - max_tokens=16,
826 - temperature=0,
827 - logprobs=0,
828 - )
819 + ) as runner:
820 + # Use a prompt that triggers multi-byte UTF-8 issues
821 + # Based on user's example: "In this example,"
822 + test_prompts = ["In this example,"]
823 +
824 + sampling_params = SamplingParams(
825 + max_tokens=16,
826 + temperature=0,
827 + logprobs=0,
828 + )
829
830 - results = runner.llm.generate(test_prompts, sampling_params=sampling_params)
831 -
832 - # Verify that decoded tokens don't contain replacement characters
833 - for result in results:
834 - assert result.outputs[0].logprobs is not None
835 - for logprob_dict in result.outputs[0].logprobs:
836 - for token_id, logprob_info in logprob_dict.items():
837 - decoded_token = logprob_info.decoded_token
838 - # Decoded tokens should not end with replacement character
839 - # They should either be corrected or empty string
840 - assert not decoded_token.endswith("�"), (
841 - f"Token {token_id} decoded to '{decoded_token}' which "
842 - f"ends with replacement character"
843 - )
844 - # Decoded tokens should not contain lone replacement characters
845 - assert decoded_token != "�", (
846 - f"Token {token_id} is a lone replacement character"
847 - )
830 + results = runner.llm.generate(test_prompts, sampling_params=sampling_params)
831 +
832 + # Verify that decoded tokens don't contain replacement characters
833 + for result in results:
834 + assert result.outputs[0].logprobs is not None
835 + for logprob_dict in result.outputs[0].logprobs:
836 + for token_id, logprob_info in logprob_dict.items():
837 + decoded_token = logprob_info.decoded_token
838 + # Decoded tokens should not end with replacement character
839 + # They should either be corrected or empty string
840 + assert not decoded_token.endswith("�"), (
841 + f"Token {token_id} decoded to '{decoded_token}' which "
842 + f"ends with replacement character"
843 + )
844 + # Decoded tokens should not contain lone replacement characters
845 + assert decoded_token != "�", (
846 + f"Token {token_id} is a lone replacement character"
847 + )
848
849
850 def test_utf8_edge_cases_with_real_model():
@@ -853,45 +853,44 @@ def test_utf8_edge_cases_with_real_model():
853 Tests prompts that are likely to trigger byte-fallback tokenization
854 and multi-byte UTF-8 splitting.
855 """
856 - runner = VllmRunner(
856 + with VllmRunner(
857 "facebook/opt-125m",
858 max_logprobs=1,
859 enable_prefix_caching=False,
860 gpu_memory_utilization=0.15,
861 max_model_len=256,
862 - )
863 -
864 - # Prompts with various multi-byte UTF-8 characters
865 - test_prompts = [
866 - 'Smart quotes: "Hello"', # Curly quotes
867 - "Em dash — test", # Em dash
868 - "Ellipsis… continues", # Ellipsis
869 - "Chinese: 你好", # Chinese characters
870 - "Emoji: 😀 🎉", # Emojis
871 - 'Mixed: "quoted" — with symbols', # Mixed
872 - ]
873 -
874 - sampling_params = SamplingParams(
875 - max_tokens=10,
876 - temperature=0,
877 - logprobs=1,
878 - )
862 + ) as runner:
863 + # Prompts with various multi-byte UTF-8 characters
864 + test_prompts = [
865 + 'Smart quotes: "Hello"', # Curly quotes
866 + "Em dash — test", # Em dash
867 + "Ellipsis… continues", # Ellipsis
868 + "Chinese: 你好", # Chinese characters
869 + "Emoji: 😀 🎉", # Emojis
870 + 'Mixed: "quoted" — with symbols', # Mixed
871 + ]
872 +
873 + sampling_params = SamplingParams(
874 + max_tokens=10,
875 + temperature=0,
876 + logprobs=1,
877 + )
878
880 - results = runner.llm.generate(test_prompts, sampling_params=sampling_params)
879 + results = runner.llm.generate(test_prompts, sampling_params=sampling_params)
880
882 - for i, result in enumerate(results):
883 - prompt = test_prompts[i]
884 - assert result.outputs[0].logprobs is not None
881 + for i, result in enumerate(results):
882 + prompt = test_prompts[i]
883 + assert result.outputs[0].logprobs is not None
884
886 - # Check that no decoded tokens end with replacement character
887 - for logprob_dict in result.outputs[0].logprobs:
888 - for token_id, logprob_info in logprob_dict.items():
889 - decoded_token = logprob_info.decoded_token
890 - assert not decoded_token.endswith("�"), (
891 - f"Prompt: '{prompt}'\n"
892 - f"Token {token_id} decoded to '{decoded_token}' which "
893 - f"ends with replacement character"
894 - )
885 + # Check that no decoded tokens end with replacement character
886 + for logprob_dict in result.outputs[0].logprobs:
887 + for token_id, logprob_info in logprob_dict.items():
888 + decoded_token = logprob_info.decoded_token
889 + assert not decoded_token.endswith("�"), (
890 + f"Prompt: '{prompt}'\n"
891 + f"Token {token_id} decoded to '{decoded_token}' which "
892 + f"ends with replacement character"
893 + )
894
895
896 def test_correct_decoded_token_preserves_valid_tokens():
@@ -901,36 +900,35 @@ def test_correct_decoded_token_preserves_valid_tokens():
900 ending with "�", but this test verifies the broader _verify_tokens
901 logic doesn't affect valid tokens.
902 """
904 - runner = VllmRunner(
903 + with VllmRunner(
904 "facebook/opt-125m",
905 max_logprobs=2,
906 enable_prefix_caching=False,
907 gpu_memory_utilization=0.15,
908 max_model_len=256,
910 - )
911 -
912 - # Simple prompt with standard ASCII characters
913 - test_prompts = ["Hello world, this is a test."]
914 -
915 - sampling_params = SamplingParams(
916 - max_tokens=10,
917 - temperature=0,
918 - logprobs=2,
919 - )
909 + ) as runner:
910 + # Simple prompt with standard ASCII characters
911 + test_prompts = ["Hello world, this is a test."]
912 +
913 + sampling_params = SamplingParams(
914 + max_tokens=10,
915 + temperature=0,
916 + logprobs=2,
917 + )
918
921 - results = runner.llm.generate(test_prompts, sampling_params=sampling_params)
919 + results = runner.llm.generate(test_prompts, sampling_params=sampling_params)
920
923 - for result in results:
924 - assert result.outputs[0].logprobs is not None
921 + for result in results:
922 + assert result.outputs[0].logprobs is not None
923
926 - # All decoded tokens should be valid strings
927 - for logprob_dict in result.outputs[0].logprobs:
928 - for token_id, logprob_info in logprob_dict.items():
929 - decoded_token = logprob_info.decoded_token
930 - # Valid tokens should be non-empty strings (or empty if corrected)
931 - assert isinstance(decoded_token, str)
932 - # Should not contain replacement character
933 - assert "�" not in decoded_token
924 + # All decoded tokens should be valid strings
925 + for logprob_dict in result.outputs[0].logprobs:
926 + for token_id, logprob_info in logprob_dict.items():
927 + decoded_token = logprob_info.decoded_token
928 + # Valid tokens should be non-empty strings (or empty if corrected)
929 + assert isinstance(decoded_token, str)
930 + # Should not contain replacement character
931 + assert "�" not in decoded_token
932
933
934 @pytest.mark.parametrize("logprobs_mode", get_args(LogprobsMode))
@@ -985,16 +983,33 @@ def test_correct_decoded_token_preserves_valid_tokens():
983 def test_spec_decode_logprobs(
984 logprobs_mode: LogprobsMode,
985 model_setup: tuple[str, str, dict, int],
986 + monkeypatch,
987 ):
988 """Spec decode logprobs should match those of the base model.
989
990 + Runs the base model and spec decode model sequentially, ensuring
991 + only one LLM instance is alive at a time to avoid GPU memory
992 + contention. Both use identical chunked prefill settings and eager
993 + mode to control for infrastructure differences.
994 +
995 Args:
996 logprobs_mode: logprobs mode.
997 model_setup: Tuple of (method, base model name,
998 speculative_config dict, top_logprobs).
999 + monkeypatch: pytest fixture for setting env vars.
1000 """
1001 from vllm import LLM
1002
1003 + # The ROCm skinny GEMM kernels (gemm_kernels.cu) are
1004 + # non-deterministic across LLM instantiations due to persistent
1005 + # workgroup scheduling and wave-level shuffle reductions, which
1006 + # causes logprob differences that get misattributed to spec decode.
1007 + # Disable them so this test isolates spec decode correctness only.
1008 + # TODO(akaratza): Remove this workaround once the follow-up to
1009 + # https://github.com/vllm-project/vllm/pull/33493#issuecomment-3906083975
1010 + # lands with a determinism fix for wvSplitK kernels.
1011 + monkeypatch.setenv("VLLM_ROCM_USE_SKINNY_GEMM", "0")
1012 +
1013 method, model_name, spec_config, top_logprobs = model_setup
1014
1015 prompt = "Hello world " * 50
@@ -1068,8 +1083,17 @@ def test_spec_decode_logprobs(
1083 for ref_logprob, spec_logprob in zip(ref_logprobs, spec_logprobs):
1084 assert math.isclose(
1085 ref_logprob.logprob, spec_logprob.logprob, rel_tol=5e-2, abs_tol=1e-1
1086 + ), (
1087 + f"Logprob mismatch: ref={ref_logprob.logprob} "
1088 + f"spec={spec_logprob.logprob} "
1089 + f"diff={abs(ref_logprob.logprob - spec_logprob.logprob)} "
1090 + f"(token={ref_logprob.decoded_token!r})"
1091 + )
1092 + assert ref_logprob.rank == spec_logprob.rank, (
1093 + f"Rank mismatch: ref={ref_logprob.rank} "
1094 + f"spec={spec_logprob.rank} "
1095 + f"(token={ref_logprob.decoded_token!r})"
1096 )
1072 - assert ref_logprob.rank == spec_logprob.rank
1097 assert ref_logprob.decoded_token == spec_logprob.decoded_token
1098
1099
tests/v1/spec_decode/test_tree_attention.py
+198 -17
@@ -13,6 +13,7 @@ from tests.v1.attention.utils import (
13 try_get_attention_backend,
14 )
15 from vllm.config import ParallelConfig, SpeculativeConfig
16 +from vllm.platforms import current_platform
17 from vllm.v1.attention.backend import CommonAttentionMetadata
18 from vllm.v1.attention.backends.fa_utils import is_flash_attn_varlen_func_available
19 from vllm.v1.attention.backends.registry import AttentionBackendEnum
@@ -23,11 +24,156 @@ if not is_flash_attn_varlen_func_available():
24 allow_module_level=True,
25 )
26
27 +# --------------------------------------------------------------------------- #
28 +# KV cache layout adaptation
29 +# --------------------------------------------------------------------------- #
30 +# Two KV cache layouts exist across backends:
31 +#
32 +# Flash layout: (2, num_blocks, block_size, num_kv_heads, head_size)
33 +# - dim 0 separates key (index 0) and value (index 1)
34 +# - Used by: FLASH_ATTN, TREE_ATTN, ROCM_AITER_FA, ROCM_ATTN
35 +#
36 +# Block layout: (num_blocks, 2, block_size, num_kv_heads, head_size)
37 +# - dim 1 separates key (index 0) and value (index 1)
38 +# - Used by: TRITON_ATTN
39 +#
40 +# The test creates KV caches in flash layout (the canonical format used by
41 +# tree attention). When a reference backend needs block layout we transpose
42 +# dims 0 and 1.
43 +#
44 +# Note: ROCM_ATTN uses flash layout for storage but its forward path calls
45 +# PagedAttention.split_kv_cache which reinterprets the raw memory as paged
46 +# layout (num_blocks, num_kv_heads, head_size//x, block_size, x). This is
47 +# a view-level incompatibility, not a transpose - see the TODO in
48 +# _get_available_reference_backends for details.
49 +#
50 +# TODO: Replace this mapping with a `KV_CACHE_LAYOUT` class attribute on each
51 +# AttentionImpl so the layout is self-documented by the backend itself, e.g.:
52 +# class TritonAttentionImpl(AttentionImpl):
53 +# KV_CACHE_LAYOUT = "block"
54 +# --------------------------------------------------------------------------- #
55 +
56 +_BLOCK_KV_LAYOUT_BACKENDS = frozenset(
57 + {
58 + AttentionBackendEnum.TRITON_ATTN,
59 + }
60 +)
61 +
62 +# Backends whose do_kv_cache_update requires engine-level state (e.g.
63 +# ForwardContext) that is not available in this test harness, but whose
64 +# KV cache is flash layout and can be written with reshape_and_cache_flash.
65 +# When a backend is listed here, forward_attention() bypasses
66 +# do_kv_cache_update and writes directly to the cache.
67 +_NEEDS_DIRECT_CACHE_UPDATE = frozenset(
68 + {
69 + AttentionBackendEnum.ROCM_AITER_FA,
70 + }
71 +)
72 +
73 +# Backends with known test-harness incompatibilities - see the TODOs
74 +# inside _get_available_reference_backends for details.
75 +_INCOMPATIBLE_REFERENCE_BACKENDS = frozenset(
76 + {
77 + AttentionBackendEnum.ROCM_AITER_FA,
78 + AttentionBackendEnum.ROCM_ATTN,
79 + }
80 +)
81 +
82 +
83 +def _adapt_kv_cache_for_backend(
84 + kv_cache: torch.Tensor,
85 + backend: AttentionBackendEnum,
86 +) -> torch.Tensor:
87 + """Convert kv_cache from flash layout ``(2, num_blocks, ...)`` to block
88 + layout ``(num_blocks, 2, ...)`` if the backend requires it. Returns the
89 + original tensor unchanged when no conversion is needed."""
90 + if backend in _BLOCK_KV_LAYOUT_BACKENDS:
91 + return kv_cache.transpose(0, 1).contiguous()
92 + return kv_cache
93 +
94 +
95 +def _get_platform_default_backend() -> AttentionBackendEnum:
96 + """Ask the platform what backend it would auto-select at runtime."""
97 + from vllm.v1.attention.selector import AttentionSelectorConfig
98 +
99 + config = AttentionSelectorConfig(
100 + block_size=32,
101 + kv_cache_dtype="auto",
102 + use_mla=False,
103 + use_sparse=False,
104 + head_size=128,
105 + dtype=torch.bfloat16,
106 + )
107 + backend_path = current_platform.get_attn_backend_cls(
108 + selected_backend=None,
109 + attn_selector_config=config,
110 + )
111 + for backend in AttentionBackendEnum:
112 + try:
113 + if backend.get_path() == backend_path:
114 + return backend
115 + except ValueError:
116 + continue
117 + raise RuntimeError(
118 + f"Platform returned backend path '{backend_path}' "
119 + f"that doesn't match any AttentionBackendEnum member."
120 + )
121 +
122 +
123 +def _get_available_reference_backends() -> list[AttentionBackendEnum]:
124 + """Collect all reference backends the current platform can run.
125 +
126 + On CUDA this is just FLASH_ATTN. On ROCm this includes the platform
127 + default plus every backend the hardware supports, so the test validates
128 + tree attention against all of them.
129 + """
130 + if current_platform.is_rocm():
131 + backends: list[AttentionBackendEnum] = []
132 +
133 + # 1. Whatever the platform would auto-select at runtime.
134 + default_backend = _get_platform_default_backend()
135 + if default_backend not in _INCOMPATIBLE_REFERENCE_BACKENDS:
136 + backends.append(default_backend)
137 +
138 + # 2. TRITON_ATTN - always available on ROCm.
139 + if AttentionBackendEnum.TRITON_ATTN not in backends:
140 + backends.append(AttentionBackendEnum.TRITON_ATTN)
141 +
142 + # TODO: Enable ROCM_ATTN. Its forward path uses
143 + # PagedAttention.split_kv_cache which reinterprets the raw
144 + # cache memory as paged layout:
145 + # key: (num_blocks, num_kv_heads, head_size//x, block_size, x)
146 + # value: (num_blocks, num_kv_heads, head_size, block_size)
147 + # Tree attention writes prefix data in NHD flash layout, so the
148 + # same bytes produce completely different values when read in
149 + # paged format. Supporting ROCM_ATTN would require writing
150 + # prefix data via PagedAttention.write_to_paged_cache into a
151 + # separate paged-format KV cache.
152 +
153 + # TODO: Enable ROCM_AITER_FA. Its metadata builder reads head
154 + # counts from the model config at construction time and
155 + # allocates extend_workspace with those dimensions. The test
156 + # uses independent head count parameters (num_heads=2/4,
157 + # num_kv_heads=2) that don't match the model config
158 + # (Llama-3-8B: 32 q heads, 8 kv heads), causing a head count
159 + # mismatch in flash_attn_varlen_func during extend_forward.
160 + # Fixing this requires either matching test head counts to the
161 + # model config or decoupling the builder from model config
162 + # head geometry. The direct cache update path
163 + # (_NEEDS_DIRECT_CACHE_UPDATE) is already in place for when
164 + # this is resolved.
165 +
166 + return backends
167 +
168 + # CUDA: flash attention.
169 + return [AttentionBackendEnum.FLASH_ATTN]
170 +
171
172 class MockAttentionLayer(torch.nn.Module):
173 _q_scale = torch.tensor(1.0, dtype=torch.float32, device="cuda")
174 _k_scale = torch.tensor(1.0, dtype=torch.float32, device="cuda")
175 _v_scale = torch.tensor(1.0, dtype=torch.float32, device="cuda")
176 + layer_name = "mock_layer"
177
178 def __init__(self):
179 super().__init__()
@@ -48,6 +194,13 @@ def forward_attention(
194 spec_token_tree: str | None = None,
195 num_spec_tokens: int = 0,
196 ) -> torch.Tensor:
197 + """Run a single attention forward pass through the given backend.
198 +
199 + ``kv_cache`` is expected in **flash layout**
200 + ``(2, num_blocks, block_size, num_kv_heads, head_size)``.
201 + It is automatically converted when the target backend needs a
202 + different layout.
203 + """
204 batch_size, q_len, num_heads, dim_per_head = q.shape
205 num_kv_heads = k.shape[-2]
206 # Initialize the query and KV sequence lengths.
@@ -116,31 +269,58 @@ def forward_attention(
269 kv_cache_dtype="auto",
270 )
271
272 + # Adapt KV cache layout for this backend.
273 + adapted_kv_cache = _adapt_kv_cache_for_backend(kv_cache, backend)
274 +
275 # Run forward pass and return output.
276 query = q.view(-1, num_heads, dim_per_head)
277 key = k.view(-1, num_kv_heads, dim_per_head)
278 value = v.view(-1, num_kv_heads, dim_per_head)
279 output = torch.empty_like(query)
280 if not try_backend_includes_kv_cache_update(backend):
125 - instance.do_kv_cache_update(
126 - layer=layer,
127 - key=key,
128 - value=value,
129 - kv_cache=kv_cache,
130 - slot_mapping=attn_metadata.slot_mapping,
131 - )
281 + if backend in _NEEDS_DIRECT_CACHE_UPDATE:
282 + # This backend's do_kv_cache_update requires engine-level
283 + # ForwardContext that isn't available in this test harness.
284 + # Write directly using reshape_and_cache_flash since the
285 + # KV cache layout is identical (flash layout, unbind on dim 0).
286 + key_cache, value_cache = adapted_kv_cache.unbind(0)
287 + torch.ops._C_cache_ops.reshape_and_cache_flash(
288 + key,
289 + value,
290 + key_cache,
291 + value_cache,
292 + attn_metadata.slot_mapping,
293 + "auto",
294 + layer._k_scale,
295 + layer._v_scale,
296 + )
297 + else:
298 + instance.do_kv_cache_update(
299 + layer=layer,
300 + key=key,
301 + value=value,
302 + kv_cache=adapted_kv_cache,
303 + slot_mapping=attn_metadata.slot_mapping,
304 + )
305 return instance.forward(
306 layer=layer,
307 query=query,
308 key=key,
309 value=value,
137 - kv_cache=kv_cache.clone(),
310 + kv_cache=adapted_kv_cache.clone(),
311 attn_metadata=attn_metadata,
312 output=output,
313 )
314
315
143 -def test_tree_attn_correctness() -> None:
316 +@pytest.mark.parametrize(
317 + "reference_backend",
318 + _get_available_reference_backends(),
319 + ids=lambda b: b.name,
320 +)
321 +def test_tree_attn_correctness(
322 + reference_backend: AttentionBackendEnum,
323 +) -> None:
324 torch.manual_seed(42)
325 torch.cuda.manual_seed_all(42)
326
@@ -205,7 +385,9 @@ def test_tree_attn_correctness() -> None:
385 dtype=torch.bfloat16,
386 )
387
208 - # Set up the block table and KV cache for paged KV.
388 + # KV cache in flash layout - the canonical format for
389 + # tree attention. forward_attention() handles conversion
390 + # when needed.
391 assert max_sequence_length % block_size == 0
392 max_blocks_per_batch = max_sequence_length // block_size
393 kv_cache = torch.randn(
@@ -263,9 +445,7 @@ def test_tree_attn_correctness() -> None:
445 num_spec_tokens=tree_size_q - 1,
446 ).view(batch_size, -1, num_heads, dim_per_head)
447
266 - # Verify that the chain attention output for each
267 - # branch of the tree (computed using FA3) matches
268 - # the tree attention output.
448 + # Verify each branch against the reference backend.
449 for q_index in range(tree_size_q):
450 # Get the q, k, and v for the branch.
451 branch_mask = tree_attn_mask[q_index, :]
@@ -286,8 +466,8 @@ def test_tree_attn_correctness() -> None:
466 branch_positions, block_table, block_size
467 )
468
289 - # Compute flash attention for the branch.
290 - flash_attn_output = forward_attention(
469 + # Reference attention for this branch.
470 + ref_output = forward_attention(
471 q=q_branch,
472 k=k_branch,
473 v=v_branch,
@@ -295,16 +475,17 @@ def test_tree_attn_correctness() -> None:
475 block_table=block_table,
476 slot_mapping=branch_slot_mapping,
477 seqlen_k=sequence_position + q_len,
298 - backend=AttentionBackendEnum.FLASH_ATTN,
478 + backend=reference_backend,
479 ).view(batch_size, -1, num_heads, dim_per_head)
480
481 # Compare the outputs.
482 assert torch.allclose(
483 tree_attn_output[:, branch_indices],
304 - flash_attn_output,
484 + ref_output,
485 atol=7.81e-3,
486 ), (
487 f"outputs are not close for "
488 + f"reference_backend: {reference_backend.name}, "
489 f"batch_size: {batch_size}, "
490 f"num_heads: {num_heads}, "
491 f"sequence_position: {sequence_position}, "