| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Engine-level parity: ReplaySSM standard decode vs the baseline SSM kernel.""" |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | import vllm.envs as envs |
| 8 | from vllm.v1.metrics.reader import Counter |
| 9 | |
| 10 | from ...models.utils import check_logprobs_close |
| 11 | from ...utils import large_gpu_mark, multi_gpu_test |
| 12 | |
| 13 | # Mamba2 (Nemotron-3) hybrids. |
| 14 | MAMBA2_MODEL = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16" |
| 15 | # FlashInfer 0.7.0's standard SSU cannot compile Nano's DIM=80 configuration. |
| 16 | MAMBA2_FLASHINFER_MODEL = "nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4" |
| 17 | MODELS = [ |
| 18 | pytest.param(MAMBA2_MODEL, marks=large_gpu_mark(min_gb=40)), |
| 19 | ] |
| 20 | FLASHINFER_MODELS = [ |
| 21 | pytest.param(MAMBA2_FLASHINFER_MODEL, marks=large_gpu_mark(min_gb=40)), |
| 22 | ] |
| 23 | |
| 24 | PROMPTS = [ |
| 25 | "The capital of France is", |
| 26 | "Once upon a time, in a small village,", |
| 27 | ] |
| 28 | |
| 29 | |
| 30 | @pytest.fixture(autouse=True) |
| 31 | def _use_v1_model_runner_by_default(monkeypatch): |
| 32 | # Triton ReplaySSM is V1-only. FlashInfer V2 tests override this locally. |
| 33 | with monkeypatch.context() as patch: |
| 34 | patch.setenv("VLLM_USE_V2_MODEL_RUNNER", "0") |
| 35 | envs.disable_envs_cache() |
| 36 | yield |
| 37 | envs.disable_envs_cache() |
| 38 | |
| 39 | |
| 40 | def _check_replayssm_parity( |
| 41 | vllm_runner, |
| 42 | model_name, |
| 43 | *, |
| 44 | tensor_parallel_size=1, |
| 45 | mamba_backend: str = "triton", |
| 46 | name_1: str = "replayssm", |
| 47 | expected_v2: bool | None = None, |
| 48 | ): |
| 49 | # Compare logprobs, not greedy ids: ReplaySSM's fp arithmetic can flip a |
| 50 | # near-tie. Baseline and ReplaySSM use the same backend so only ReplaySSM |
| 51 | # behavior varies. |
| 52 | common = dict( |
| 53 | max_model_len=1024, |
| 54 | trust_remote_code=True, |
| 55 | enable_prefix_caching=False, |
| 56 | mamba_cache_mode="none", |
| 57 | tensor_parallel_size=tensor_parallel_size, |
| 58 | mamba_backend=mamba_backend, |
| 59 | ) |
| 60 | with vllm_runner(model_name, **common) as llm: |
| 61 | if expected_v2 is not None: |
| 62 | assert llm.llm.llm_engine.vllm_config.use_v2_model_runner is expected_v2 |
| 63 | baseline = llm.generate_greedy_logprobs(PROMPTS, max_tokens=32, num_logprobs=5) |
| 64 | with vllm_runner( |
| 65 | model_name, |
| 66 | use_replayssm=True, |
| 67 | replayssm_buffer_len=16, |
| 68 | **common, |
| 69 | ) as llm: |
| 70 | if expected_v2 is not None: |
| 71 | assert llm.llm.llm_engine.vllm_config.use_v2_model_runner is expected_v2 |
| 72 | replay = llm.generate_greedy_logprobs(PROMPTS, max_tokens=32, num_logprobs=5) |
| 73 | |
| 74 | check_logprobs_close( |
| 75 | outputs_0_lst=baseline, |
| 76 | outputs_1_lst=replay, |
| 77 | name_0="baseline", |
| 78 | name_1=name_1, |
| 79 | ) |
| 80 | |
| 81 | |
| 82 | @pytest.mark.parametrize("model_name", MODELS) |
| 83 | def test_replayssm_decode_matches_baseline(vllm_runner, model_name): |
| 84 | _check_replayssm_parity(vllm_runner, model_name) |
| 85 | |
| 86 | |
| 87 | @multi_gpu_test(num_gpus=2) |
| 88 | @pytest.mark.parametrize("model_name", [MAMBA2_MODEL]) |
| 89 | def test_replayssm_decode_matches_baseline_tp2(vllm_runner, model_name): |
| 90 | # Tensor-parallel correctness: ReplaySSM's caches and checkpoint state are |
| 91 | # sharded per rank, so TP2 decode must still match the baseline at TP2. |
| 92 | _check_replayssm_parity(vllm_runner, model_name, tensor_parallel_size=2) |
| 93 | |
| 94 | |
| 95 | @pytest.mark.parametrize("model_name", FLASHINFER_MODELS) |
| 96 | @pytest.mark.parametrize("use_v2_model_runner", [False, True], ids=["v1", "v2"]) |
| 97 | def test_replayssm_flashinfer_decode_matches_baseline( |
| 98 | vllm_runner, model_name, monkeypatch, use_v2_model_runner |
| 99 | ): |
| 100 | try: |
| 101 | with monkeypatch.context() as patch: |
| 102 | patch.setenv("VLLM_USE_V2_MODEL_RUNNER", str(int(use_v2_model_runner))) |
| 103 | envs.disable_envs_cache() |
| 104 | _check_replayssm_parity( |
| 105 | vllm_runner, |
| 106 | model_name, |
| 107 | mamba_backend="flashinfer", |
| 108 | name_1="replayssm_flashinfer", |
| 109 | expected_v2=use_v2_model_runner, |
| 110 | ) |
| 111 | finally: |
| 112 | # The context restores the environment before the final cache reset. |
| 113 | envs.disable_envs_cache() |
| 114 | |
| 115 | |
| 116 | @pytest.mark.parametrize("model_name", FLASHINFER_MODELS) |
| 117 | def test_replayssm_flashinfer_spec_decode_matches_baseline(vllm_runner, model_name): |
| 118 | common = dict( |
| 119 | max_model_len=1024, |
| 120 | trust_remote_code=True, |
| 121 | enable_prefix_caching=False, |
| 122 | mamba_cache_mode="none", |
| 123 | speculative_config={ |
| 124 | "method": "ngram", |
| 125 | "num_speculative_tokens": 3, |
| 126 | "prompt_lookup_max": 3, |
| 127 | }, |
| 128 | ) |
| 129 | with vllm_runner(model_name, mamba_backend="flashinfer", **common) as llm: |
| 130 | baseline = llm.generate_greedy_logprobs(PROMPTS, max_tokens=32, num_logprobs=5) |
| 131 | with vllm_runner( |
| 132 | model_name, |
| 133 | use_replayssm=True, |
| 134 | replayssm_buffer_len=16, |
| 135 | mamba_backend="flashinfer", |
| 136 | **common, |
| 137 | ) as llm: |
| 138 | replay = llm.generate_greedy_logprobs(PROMPTS, max_tokens=32, num_logprobs=5) |
| 139 | |
| 140 | check_logprobs_close( |
| 141 | outputs_0_lst=baseline, |
| 142 | outputs_1_lst=replay, |
| 143 | name_0="baseline_spec", |
| 144 | name_1="replayssm_flashinfer_spec", |
| 145 | ) |
| 146 | |
| 147 | |
| 148 | @multi_gpu_test(num_gpus=2) |
| 149 | @large_gpu_mark(min_gb=40) |
| 150 | @pytest.mark.parametrize("use_v2_model_runner", [False, True], ids=["v1", "v2"]) |
| 151 | def test_replayssm_flashinfer_mtp(vllm_runner, monkeypatch, use_v2_model_runner): |
| 152 | common = dict( |
| 153 | max_model_len=1024, |
| 154 | trust_remote_code=True, |
| 155 | enable_prefix_caching=False, |
| 156 | mamba_cache_mode="none", |
| 157 | mamba_backend="flashinfer", |
| 158 | tensor_parallel_size=2, |
| 159 | disable_log_stats=False, |
| 160 | speculative_config={"method": "mtp", "num_speculative_tokens": 3}, |
| 161 | ) |
| 162 | try: |
| 163 | with monkeypatch.context() as patch: |
| 164 | patch.setenv("VLLM_USE_V2_MODEL_RUNNER", str(int(use_v2_model_runner))) |
| 165 | envs.disable_envs_cache() |
| 166 | with vllm_runner( |
| 167 | MAMBA2_FLASHINFER_MODEL, |
| 168 | use_replayssm=True, |
| 169 | replayssm_buffer_len=16, |
| 170 | **common, |
| 171 | ) as llm: |
| 172 | assert ( |
| 173 | llm.llm.llm_engine.vllm_config.use_v2_model_runner |
| 174 | is use_v2_model_runner |
| 175 | ) |
| 176 | outputs = llm.generate_greedy(PROMPTS, max_tokens=32) |
| 177 | draft_count = sum( |
| 178 | metric.value |
| 179 | for metric in llm.llm.get_metrics() |
| 180 | if isinstance(metric, Counter) |
| 181 | and metric.name == "vllm:spec_decode_num_drafts" |
| 182 | ) |
| 183 | finally: |
| 184 | envs.disable_envs_cache() |
| 185 | |
| 186 | # At least one request must run past the 16-token replay window; another |
| 187 | # may legitimately stop early on EOS. |
| 188 | assert any(len(token_ids) > 16 for token_ids, _ in outputs) |
| 189 | assert draft_count > 0 |
| 190 | |
| 191 | |
| 192 | # Prefix spans several mamba blocks; prefix caching only reuses full blocks. |
| 193 | _PC_SENTENCE = ( |
| 194 | "In a detailed survey of state space models, the authors compared many " |
| 195 | "architectures across a wide range of long-context language tasks and " |
| 196 | "measured their throughput, memory use, and accuracy in careful detail. " |
| 197 | ) |
| 198 | _PC_PREFIX = _PC_SENTENCE * 120 |
| 199 | PREFIX_CACHING_PROMPTS = [ |
| 200 | _PC_PREFIX + "The most important conclusion was that", |
| 201 | _PC_PREFIX + "Surprisingly, the experiments showed that", |
| 202 | _PC_PREFIX + "The most important conclusion was that", |
| 203 | ] |
| 204 | |
| 205 | |
| 206 | def _prefix_cache_hits(llm) -> int: |
| 207 | return sum( |
| 208 | m.value |
| 209 | for m in llm.llm.get_metrics() |
| 210 | if isinstance(m, Counter) and m.name == "vllm:prefix_cache_hits" |
| 211 | ) |
| 212 | |
| 213 | |
| 214 | def _check_replayssm_prefix_caching_parity( |
| 215 | vllm_runner, model_name, *, tensor_parallel_size=1 |
| 216 | ): |
| 217 | # align mode materializes the exact SSM state at each block boundary, so |
| 218 | # ReplaySSM's cached prefixes must match the always-materialized baseline. |
| 219 | common = dict( |
| 220 | max_model_len=8192, |
| 221 | trust_remote_code=True, |
| 222 | enable_prefix_caching=True, |
| 223 | enable_chunked_prefill=True, |
| 224 | mamba_cache_mode="align", |
| 225 | disable_log_stats=False, # required for llm.get_metrics() |
| 226 | tensor_parallel_size=tensor_parallel_size, |
| 227 | ) |
| 228 | with vllm_runner(model_name, **common) as llm: |
| 229 | baseline = llm.generate_greedy_logprobs( |
| 230 | PREFIX_CACHING_PROMPTS, max_tokens=32, num_logprobs=5 |
| 231 | ) |
| 232 | with vllm_runner( |
| 233 | model_name, use_replayssm=True, replayssm_buffer_len=16, **common |
| 234 | ) as llm: |
| 235 | # Prime the cache, then measure, so cache hits are deterministic. |
| 236 | llm.generate_greedy_logprobs( |
| 237 | PREFIX_CACHING_PROMPTS, max_tokens=32, num_logprobs=5 |
| 238 | ) |
| 239 | replay = llm.generate_greedy_logprobs( |
| 240 | PREFIX_CACHING_PROMPTS, max_tokens=32, num_logprobs=5 |
| 241 | ) |
| 242 | replay_hits = _prefix_cache_hits(llm) |
| 243 | |
| 244 | # Without real cache hits the cached path is never exercised. |
| 245 | assert replay_hits > 0, ( |
| 246 | "ReplaySSM align-mode run produced no prefix-cache hits; the shared " |
| 247 | "prefix may be shorter than one mamba block, so prefix caching is inert" |
| 248 | ) |
| 249 | check_logprobs_close( |
| 250 | outputs_0_lst=baseline, |
| 251 | outputs_1_lst=replay, |
| 252 | name_0="baseline_align_pc", |
| 253 | name_1="replayssm_align_pc", |
| 254 | ) |
| 255 | |
| 256 | |
| 257 | @pytest.mark.parametrize("model_name", MODELS) |
| 258 | def test_replayssm_prefix_caching_matches_baseline(vllm_runner, model_name): |
| 259 | _check_replayssm_prefix_caching_parity(vllm_runner, model_name) |
| 260 | |
| 261 | |
| 262 | @multi_gpu_test(num_gpus=2) |
| 263 | @pytest.mark.parametrize("model_name", [MAMBA2_MODEL]) |
| 264 | def test_replayssm_prefix_caching_matches_baseline_tp2(vllm_runner, model_name): |
| 265 | _check_replayssm_prefix_caching_parity( |
| 266 | vllm_runner, model_name, tensor_parallel_size=2 |
| 267 | ) |