| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | import multiprocessing as mp |
| 4 | import os |
| 5 | import traceback |
| 6 | from collections.abc import Callable |
| 7 | from dataclasses import dataclass |
| 8 | from typing import Any |
| 9 | |
| 10 | import datasets |
| 11 | import pytest |
| 12 | import torch |
| 13 | |
| 14 | import vllm.envs as envs |
| 15 | from tests.utils import create_new_process_for_each_test |
| 16 | from vllm import LLM, SamplingParams, TokensPrompt |
| 17 | from vllm.config import CacheConfig |
| 18 | from vllm.distributed import cleanup_dist_env_and_memory |
| 19 | from vllm.model_executor.layers.mamba.mamba_utils import MambaStateCopyFunc |
| 20 | from vllm.platforms import current_platform |
| 21 | from vllm.sequence import IntermediateTensors |
| 22 | from vllm.utils.gpu_sync_debug import gpu_sync_allowed |
| 23 | from vllm.utils.torch_utils import async_tensor_h2d |
| 24 | from vllm.v1.attention.backends.utils import CommonAttentionMetadata |
| 25 | from vllm.v1.core.kv_cache_manager import KVCacheBlocks, KVCacheManager |
| 26 | from vllm.v1.core.sched.output import SchedulerOutput |
| 27 | from vllm.v1.engine.core_client import InprocClient |
| 28 | from vllm.v1.kv_cache_interface import KVCacheConfig |
| 29 | from vllm.v1.outputs import SamplerOutput |
| 30 | from vllm.v1.request import Request |
| 31 | from vllm.v1.sample.metadata import SamplingMetadata |
| 32 | from vllm.v1.spec_decode.metadata import SpecDecodeMetadata |
| 33 | from vllm.v1.worker import mamba_utils |
| 34 | from vllm.v1.worker.gpu_input_batch import CachedRequestState, InputBatch |
| 35 | from vllm.v1.worker.gpu_model_runner import GPUModelRunner |
| 36 | from vllm.v1.worker.mamba_utils import get_mamba_groups |
| 37 | |
| 38 | |
| 39 | @dataclass |
| 40 | class StepAction: |
| 41 | num_computed_tokens_start: int |
| 42 | num_scheduled_tokens: int |
| 43 | kv_cache_block_ids: list[int] # per-block mask: 1=held, 0=freed/nulled |
| 44 | preprocess_copy_idx: tuple[int, int] # -1, -1 for no copy |
| 45 | postprocess_copy_idx: tuple[int, int] # -1, -1 for no copy |
| 46 | |
| 47 | |
| 48 | num_speculative_tokens = 3 |
| 49 | |
| 50 | # Whether the run under test uses async scheduling. Set by each test entrypoint |
| 51 | # before generation; consulted where the scheduler's optimistic token count must |
| 52 | # be corrected for in-flight (possibly-rejected) speculative tokens. |
| 53 | async_scheduling_mode = False |
| 54 | |
| 55 | num_accepted_tokens = 1 |
| 56 | prompt_token_ids: list[int] = [] |
| 57 | MODEL = "Qwen/Qwen3-Next-80B-A3B-Instruct-FP8" |
| 58 | # Must be a value no platform rewrites, since the step actions below encode |
| 59 | # where block boundaries fall. XPU rounds GDN block sizes up to a multiple of 64 |
| 60 | # (XPUPlatform.update_block_size_for_backend), so use one. |
| 61 | BLOCK_SIZE = 576 |
| 62 | DEVICE_TYPE = current_platform.device_type |
| 63 | NUM_HIDDEN_LAYERS = 1 |
| 64 | cur_step_action_idx = 0 |
| 65 | cur_step_action: StepAction | None = None |
| 66 | step_actions: list[StepAction] = [] |
| 67 | |
| 68 | |
| 69 | def get_fake_sample_fn() -> SamplerOutput: |
| 70 | def fake_sample_fn( |
| 71 | self: GPUModelRunner, |
| 72 | logits: torch.Tensor | None, |
| 73 | spec_decode_metadata: SpecDecodeMetadata | None, |
| 74 | ) -> SamplerOutput: |
| 75 | assert logits is not None |
| 76 | num_computed_tokens_cpu_tensor = self.input_batch.num_computed_tokens_cpu_tensor |
| 77 | num_computed_tokens = num_computed_tokens_cpu_tensor[0].item() |
| 78 | if num_computed_tokens < self.input_batch.num_prompt_tokens[0].item(): |
| 79 | first_token_id_index = self.input_batch.num_prompt_tokens[0].item() |
| 80 | else: |
| 81 | first_token_id_index = num_computed_tokens + 1 |
| 82 | if spec_decode_metadata is None: |
| 83 | return SamplerOutput( |
| 84 | sampled_token_ids=async_tensor_h2d( |
| 85 | [[prompt_token_ids[first_token_id_index]]], |
| 86 | device=DEVICE_TYPE, |
| 87 | dtype=torch.int32, |
| 88 | ), |
| 89 | logprobs_tensors=None, |
| 90 | ) |
| 91 | accepted_tokens = prompt_token_ids[ |
| 92 | first_token_id_index : first_token_id_index |
| 93 | + min(num_accepted_tokens, logits.shape[0]) |
| 94 | ] |
| 95 | sampled_token_ids = accepted_tokens |
| 96 | return SamplerOutput( |
| 97 | sampled_token_ids=async_tensor_h2d( |
| 98 | [sampled_token_ids], |
| 99 | device=DEVICE_TYPE, |
| 100 | dtype=torch.int32, |
| 101 | ), |
| 102 | logprobs_tensors=None, |
| 103 | ) |
| 104 | |
| 105 | return fake_sample_fn |
| 106 | |
| 107 | |
| 108 | def get_fake_propose_draft_token_ids_fn(): |
| 109 | def fake_propose_draft_token_ids_fn( |
| 110 | self: GPUModelRunner, |
| 111 | scheduler_output: SchedulerOutput, |
| 112 | sampled_token_ids: torch.Tensor | list[list[int]], |
| 113 | sampling_metadata: SamplingMetadata, |
| 114 | hidden_states: torch.Tensor, |
| 115 | sample_hidden_states: torch.Tensor, |
| 116 | aux_hidden_states: list[torch.Tensor] | None, |
| 117 | spec_decode_metadata: SpecDecodeMetadata | None, |
| 118 | common_attn_metadata: CommonAttentionMetadata, |
| 119 | slot_mappings: dict[str, torch.Tensor] | list[dict[str, torch.Tensor]] | None, |
| 120 | ) -> list[list[int]]: |
| 121 | num_computed_tokens_cpu_tensor = self.input_batch.num_computed_tokens_cpu_tensor |
| 122 | num_computed_tokens = num_computed_tokens_cpu_tensor[0].item() |
| 123 | if ( |
| 124 | self.input_batch.num_tokens_no_spec[0].item() |
| 125 | <= self.input_batch.num_prompt_tokens[0].item() |
| 126 | ): |
| 127 | first_token_id_index = self.input_batch.num_prompt_tokens[0].item() |
| 128 | else: |
| 129 | first_token_id_index = ( |
| 130 | num_computed_tokens + 1 |
| 131 | ) # bonus token isn't considered as computed |
| 132 | first_token_id_index += self.input_batch.num_accepted_tokens_cpu[0].item() |
| 133 | proposed_draft_token_ids = [ |
| 134 | prompt_token_ids[ |
| 135 | first_token_id_index : first_token_id_index + num_speculative_tokens |
| 136 | ] |
| 137 | ] |
| 138 | |
| 139 | next_token_ids = async_tensor_h2d( |
| 140 | prompt_token_ids[ |
| 141 | first_token_id_index - 1 : first_token_id_index |
| 142 | - 1 |
| 143 | + num_accepted_tokens |
| 144 | ], |
| 145 | device=DEVICE_TYPE, |
| 146 | dtype=torch.int32, |
| 147 | ) |
| 148 | |
| 149 | valid_sampled_tokens_count = async_tensor_h2d( |
| 150 | [num_accepted_tokens], |
| 151 | device=DEVICE_TYPE, |
| 152 | dtype=torch.int32, |
| 153 | ) |
| 154 | |
| 155 | self._copy_valid_sampled_token_count(next_token_ids, valid_sampled_tokens_count) |
| 156 | |
| 157 | return async_tensor_h2d( |
| 158 | proposed_draft_token_ids, |
| 159 | device=DEVICE_TYPE, |
| 160 | dtype=torch.int32, |
| 161 | ) |
| 162 | |
| 163 | return fake_propose_draft_token_ids_fn |
| 164 | |
| 165 | |
| 166 | def get_fake_step_action_fn(original_step_action_fn: Callable): |
| 167 | def fake_get_output(self: InprocClient): |
| 168 | global cur_step_action_idx |
| 169 | global cur_step_action |
| 170 | if cur_step_action_idx < len(step_actions): |
| 171 | cur_step_action = step_actions[cur_step_action_idx] |
| 172 | cur_step_action_idx += 1 |
| 173 | else: |
| 174 | cur_step_action = None |
| 175 | print(f"cur_step_action: {cur_step_action_idx=} {cur_step_action=}") |
| 176 | return original_step_action_fn(self) |
| 177 | |
| 178 | return fake_get_output |
| 179 | |
| 180 | |
| 181 | def get_fake_allocate_slots_fn(original_allocate_slots_fn: Callable): |
| 182 | def fake_allocate_slots_fn( |
| 183 | self: KVCacheManager, |
| 184 | request: Request, |
| 185 | num_new_tokens: int, |
| 186 | num_new_computed_tokens: int = 0, |
| 187 | new_computed_blocks: KVCacheBlocks | None = None, |
| 188 | num_lookahead_tokens: int = 0, |
| 189 | num_external_computed_tokens: int = 0, |
| 190 | delay_cache_blocks: bool = False, |
| 191 | num_encoder_tokens: int = 0, |
| 192 | full_sequence_must_fit: bool = False, |
| 193 | reserved_blocks: int = 0, |
| 194 | has_scheduled_reqs: bool = True, |
| 195 | ): |
| 196 | ret = original_allocate_slots_fn( |
| 197 | self, |
| 198 | request, |
| 199 | num_new_tokens, |
| 200 | num_new_computed_tokens, |
| 201 | new_computed_blocks, |
| 202 | num_lookahead_tokens, |
| 203 | num_external_computed_tokens, |
| 204 | delay_cache_blocks, |
| 205 | num_encoder_tokens, |
| 206 | full_sequence_must_fit, |
| 207 | reserved_blocks, |
| 208 | has_scheduled_reqs, |
| 209 | ) |
| 210 | if cur_step_action is not None: |
| 211 | cur_block_ids = self.coordinator.single_type_managers[0].req_to_blocks[ |
| 212 | request.request_id |
| 213 | ] |
| 214 | not_null_block_flags = [not block.is_null for block in cur_block_ids] |
| 215 | block_ids = [1 if block else 0 for block in not_null_block_flags] |
| 216 | assert block_ids == cur_step_action.kv_cache_block_ids |
| 217 | return ret |
| 218 | |
| 219 | return fake_allocate_slots_fn |
| 220 | |
| 221 | |
| 222 | mamba_kv_cache_dict = {} |
| 223 | |
| 224 | |
| 225 | def get_fake_execute_model_fn(original_execute_model_fn: Callable): |
| 226 | last_num_computed_tokens = 0 |
| 227 | num_prompt_tokens = None |
| 228 | |
| 229 | def fake_execute_model_fn( |
| 230 | self: GPUModelRunner, |
| 231 | scheduler_output: SchedulerOutput, |
| 232 | intermediate_tensors: IntermediateTensors | None = None, |
| 233 | ): |
| 234 | if cur_step_action is not None: |
| 235 | num_scheduled_tokens = next( |
| 236 | iter(scheduler_output.num_scheduled_tokens.values()) |
| 237 | ) |
| 238 | assert num_scheduled_tokens == cur_step_action.num_scheduled_tokens |
| 239 | mamba_groups = get_mamba_groups(self.kv_cache_config) |
| 240 | mamba_spec, mamba_group_ids = next(iter(mamba_groups.items())) |
| 241 | mamba_group_id = mamba_group_ids[0] |
| 242 | mamba_layer_name = self.kv_cache_config.kv_cache_groups[ |
| 243 | mamba_group_id |
| 244 | ].layer_names[0] |
| 245 | nonlocal last_num_computed_tokens |
| 246 | nonlocal num_prompt_tokens |
| 247 | |
| 248 | if ( |
| 249 | len(scheduler_output.scheduled_new_reqs) > 0 |
| 250 | and scheduler_output.scheduled_new_reqs[0].prompt_token_ids is not None |
| 251 | ): |
| 252 | # record number of prompt tokens |
| 253 | num_prompt_tokens = len( |
| 254 | scheduler_output.scheduled_new_reqs[0].prompt_token_ids |
| 255 | ) |
| 256 | |
| 257 | if len(scheduler_output.scheduled_cached_reqs.req_ids) > 0: |
| 258 | num_computed_tokens = ( |
| 259 | scheduler_output.scheduled_cached_reqs.num_computed_tokens[0] |
| 260 | ) |
| 261 | if ( |
| 262 | async_scheduling_mode |
| 263 | and self.num_spec_tokens |
| 264 | and num_prompt_tokens is not None |
| 265 | and num_computed_tokens > num_prompt_tokens |
| 266 | ): |
| 267 | # NOTE (tdoublep) with async scheduling, the scheduler does not have an |
| 268 | # accurate measure of the number of computed tokens; we need to subtract |
| 269 | # the number of reject tokens from the previous timestep. |
| 270 | num_computed_tokens -= num_speculative_tokens + 1 - num_accepted_tokens |
| 271 | if ( |
| 272 | num_computed_tokens // BLOCK_SIZE |
| 273 | > last_num_computed_tokens // BLOCK_SIZE |
| 274 | ): |
| 275 | # generated a new aligned block in this step |
| 276 | block_idx = num_computed_tokens // mamba_spec.block_size - 1 |
| 277 | block_id = ( |
| 278 | self.input_batch.block_table.block_tables[mamba_group_id] |
| 279 | .block_table.cpu[0, block_idx] |
| 280 | .item() |
| 281 | ) |
| 282 | if block_id != 0: |
| 283 | kv_cache = self.compilation_config.static_forward_context[ |
| 284 | mamba_layer_name |
| 285 | ].kv_cache |
| 286 | mamba_kv_cache_dict[ |
| 287 | num_computed_tokens - num_computed_tokens % BLOCK_SIZE |
| 288 | ] = ( |
| 289 | kv_cache[0][block_id].clone(), |
| 290 | kv_cache[1][block_id].clone(), |
| 291 | ) |
| 292 | |
| 293 | last_num_computed_tokens = num_computed_tokens |
| 294 | else: |
| 295 | last_num_computed_tokens = 0 |
| 296 | |
| 297 | ret = original_execute_model_fn(self, scheduler_output, intermediate_tensors) |
| 298 | |
| 299 | if cur_step_action is not None: |
| 300 | assert ( |
| 301 | cur_step_action.num_computed_tokens_start |
| 302 | == self.input_batch.num_computed_tokens_cpu[0].item() |
| 303 | ) |
| 304 | |
| 305 | return ret |
| 306 | |
| 307 | return fake_execute_model_fn |
| 308 | |
| 309 | |
| 310 | def get_fake_process_mamba_fn( |
| 311 | original_preprocess_mamba_fn: Callable, |
| 312 | original_copy_fn: Callable, |
| 313 | ): |
| 314 | copy_info: tuple[list[int], list[int], list[int]] | None = None |
| 315 | |
| 316 | def check_copy_info( |
| 317 | action: tuple[int, int], |
| 318 | kv_cache_config: KVCacheConfig, |
| 319 | forward_context: dict[str, Any], |
| 320 | input_batch: InputBatch, |
| 321 | ): |
| 322 | assert copy_info is not None |
| 323 | if action == (-1, -1): |
| 324 | assert len(copy_info[0]) == len(copy_info[1]) == len(copy_info[2]) == 0 |
| 325 | else: |
| 326 | assert len(copy_info[0]) == len(copy_info[1]) == len(copy_info[2]) == 2 |
| 327 | mamba_groups = get_mamba_groups(kv_cache_config) |
| 328 | mamba_spec, mamba_group_ids = next(iter(mamba_groups.items())) |
| 329 | mamba_group_id = mamba_group_ids[0] |
| 330 | mamba_layer_name = kv_cache_config.kv_cache_groups[ |
| 331 | mamba_group_id |
| 332 | ].layer_names[0] |
| 333 | mamba_kv_cache = forward_context[mamba_layer_name].kv_cache[-1] |
| 334 | mamba_block_table = input_batch.block_table.block_tables[ |
| 335 | mamba_group_id |
| 336 | ].block_table.cpu[0] |
| 337 | expected_temporal_src = mamba_kv_cache[ |
| 338 | mamba_block_table[action[0]] |
| 339 | ].data_ptr() |
| 340 | expected_temporal_dest = mamba_kv_cache[ |
| 341 | mamba_block_table[action[1]] |
| 342 | ].data_ptr() |
| 343 | # -1 is qwen3-next's temporal. We skip checking conv as it is more complex. |
| 344 | assert copy_info[0][-1] == expected_temporal_src |
| 345 | assert copy_info[1][-1] == expected_temporal_dest |
| 346 | |
| 347 | def check_fused_copy_info( |
| 348 | action: tuple[int, int], |
| 349 | align_ctx: mamba_utils.MambaSpecDecodeGPUContext, |
| 350 | ): |
| 351 | # Align + spec-decode on a hybrid model routes the pre-copy through the |
| 352 | # fused kernel (preprocess_mamba -> run_fused_precopy) instead of |
| 353 | # do_mamba_copy_block, so copy_info is never populated. Verify from the |
| 354 | # fused buffers (req-0 scope, mirroring check_copy_info): |
| 355 | # - the copy DECISION: src_col is -1 iff no pre-copy is scheduled; |
| 356 | # - the DESTINATION column: state_idx == action[1] (curr_state_idx; |
| 357 | # maps directly through block_ids, exactly as check_copy_info's dst). |
| 358 | # The source column is NOT asserted here: on the scalar path the source |
| 359 | # address is produced by the per-state copy func with an accept-token |
| 360 | # bias offset (collect_mamba_copy_meta), so prev_state_idx does not map |
| 361 | # to action[0] by plain equality. Source block-level exactness (incl. |
| 362 | # the accept-bias) is covered by test_precopy_mamba_align.py. |
| 363 | src_col = int(align_ctx.precopy_src_col_buf.np[0]) |
| 364 | state_idx = int(align_ctx.mamba_state_idx_buf.np[0]) |
| 365 | if action == (-1, -1): |
| 366 | assert src_col == -1 |
| 367 | else: |
| 368 | assert src_col != -1 |
| 369 | assert state_idx == action[1] |
| 370 | |
| 371 | def fake_preprocess_mamba_fn( |
| 372 | scheduler_output: SchedulerOutput, |
| 373 | kv_cache_config: KVCacheConfig, |
| 374 | cache_config: CacheConfig, |
| 375 | mamba_state_idx: dict[str, int], |
| 376 | input_batch: InputBatch, |
| 377 | requests: dict[str, CachedRequestState], |
| 378 | forward_context: dict[str, Any], |
| 379 | mamba_state_copy_funcs: tuple[MambaStateCopyFunc, ...], |
| 380 | copy_bufs: mamba_utils.MambaCopyBuffers, |
| 381 | align_ctx: mamba_utils.MambaSpecDecodeGPUContext | None = None, |
| 382 | ): |
| 383 | nonlocal copy_info |
| 384 | copy_info = None |
| 385 | ret = original_preprocess_mamba_fn( |
| 386 | scheduler_output, |
| 387 | kv_cache_config, |
| 388 | cache_config, |
| 389 | mamba_state_idx, |
| 390 | input_batch, |
| 391 | requests, |
| 392 | forward_context, |
| 393 | mamba_state_copy_funcs, |
| 394 | copy_bufs, |
| 395 | align_ctx, |
| 396 | ) |
| 397 | if cur_step_action is not None: |
| 398 | if align_ctx is not None: |
| 399 | check_fused_copy_info( |
| 400 | cur_step_action.preprocess_copy_idx, |
| 401 | align_ctx, |
| 402 | ) |
| 403 | else: |
| 404 | check_copy_info( |
| 405 | cur_step_action.preprocess_copy_idx, |
| 406 | kv_cache_config, |
| 407 | forward_context, |
| 408 | input_batch, |
| 409 | ) |
| 410 | return ret |
| 411 | |
| 412 | def fake_copy_fn(copy_bufs: mamba_utils.MambaCopyBuffers): |
| 413 | nonlocal copy_info |
| 414 | assert copy_info is None |
| 415 | n = copy_bufs.offset |
| 416 | src_state_list = copy_bufs.src_ptrs.cpu[:n].tolist() |
| 417 | dest_state_list = copy_bufs.dst_ptrs.cpu[:n].tolist() |
| 418 | num_elements_list = copy_bufs.sizes.cpu[:n].tolist() |
| 419 | copy_info = (src_state_list, dest_state_list, num_elements_list) |
| 420 | return original_copy_fn(copy_bufs) |
| 421 | |
| 422 | return fake_preprocess_mamba_fn, fake_copy_fn |
| 423 | |
| 424 | |
| 425 | def run_ref_mamba_state_in_subprocess() -> None: |
| 426 | ctx = mp.get_context("spawn") |
| 427 | proc = ctx.Process(target=_run_ref_mamba_state_worker) |
| 428 | proc.start() |
| 429 | proc.join(timeout=600) |
| 430 | if proc.exitcode != 0: |
| 431 | raise RuntimeError(f"Ref mamba state process exited with code {proc.exitcode}.") |
| 432 | |
| 433 | |
| 434 | def _run_ref_mamba_state_worker(): |
| 435 | try: |
| 436 | os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = "0" |
| 437 | num_generated_tokens = 8000 |
| 438 | num_prompt_tokens = 500 |
| 439 | sampling_params = SamplingParams( |
| 440 | temperature=0.0, max_tokens=num_generated_tokens |
| 441 | ) |
| 442 | prompt_dataset = datasets.load_dataset("heheda/a_long_article") |
| 443 | full_prompt = prompt_dataset["train"][0]["text"] |
| 444 | fake_execute_model_fn = get_fake_execute_model_fn(GPUModelRunner.execute_model) |
| 445 | GPUModelRunner.execute_model = fake_execute_model_fn |
| 446 | fake_sample_fn = get_fake_sample_fn() |
| 447 | GPUModelRunner._sample = fake_sample_fn |
| 448 | engine = LLM( |
| 449 | model=MODEL, |
| 450 | load_format="dummy", |
| 451 | block_size=BLOCK_SIZE, |
| 452 | hf_overrides={"num_hidden_layers": NUM_HIDDEN_LAYERS}, |
| 453 | seed=42, |
| 454 | ) |
| 455 | global prompt_token_ids |
| 456 | prompt_token_ids = engine.get_tokenizer().encode(full_prompt) |
| 457 | print(f"Token IDs length: {len(prompt_token_ids)}") |
| 458 | |
| 459 | _outputs = engine.generate( |
| 460 | [TokensPrompt(prompt_token_ids=prompt_token_ids[:num_prompt_tokens])], |
| 461 | sampling_params, |
| 462 | ) |
| 463 | # ref_mamba_kv_cache_dict = torch.load("mamba_kv_cache_dict.pth") |
| 464 | # check_mamba_state_equal(ref_mamba_kv_cache_dict, mamba_kv_cache_dict) |
| 465 | # torch.save(mamba_kv_cache_dict, "mamba_kv_cache_dict.pth") |
| 466 | cpu_state_ref = { |
| 467 | key: tuple(tensor.detach().cpu() for tensor in tensors) |
| 468 | for key, tensors in mamba_kv_cache_dict.items() |
| 469 | } |
| 470 | torch.save(cpu_state_ref, "mamba_kv_cache_dict_ref.pth") |
| 471 | mamba_kv_cache_dict.clear() |
| 472 | del engine |
| 473 | torch.accelerator.empty_cache() |
| 474 | cleanup_dist_env_and_memory() |
| 475 | except Exception: |
| 476 | traceback.print_exc() |
| 477 | raise |
| 478 | |
| 479 | |
| 480 | def check_mamba_state_equal( |
| 481 | mamba_state_ref: dict, mamba_state_new: dict, keys_to_check: list[int] |
| 482 | ): |
| 483 | atol = 1e-2 |
| 484 | rtol = 1e-2 |
| 485 | for key in keys_to_check: |
| 486 | assert key in mamba_state_new |
| 487 | assert key in mamba_state_ref |
| 488 | # mamba state new is a subset of mamba state ref |
| 489 | for i, (ref, new) in enumerate(zip(mamba_state_ref[key], mamba_state_new[key])): |
| 490 | if ref.device != new.device: |
| 491 | new = new.to(ref.device) |
| 492 | new = new[: ref.shape[0]] |
| 493 | if not torch.allclose(ref, new, atol=atol, rtol=rtol): |
| 494 | diff_mask = ~torch.isclose(ref, new, atol=atol, rtol=rtol) |
| 495 | diff_idx = torch.nonzero(diff_mask) |
| 496 | if diff_idx.shape[0] * 100 < ref.numel(): |
| 497 | print( |
| 498 | f"[WARNING] found {diff_idx.shape[0] * 100 / ref.numel()}% of the elements are different" # noqa: E501 |
| 499 | ) |
| 500 | continue |
| 501 | raise ValueError( |
| 502 | f"Mamba state is not equal for key: {key} at index {i}" |
| 503 | ) |
| 504 | return True |
| 505 | |
| 506 | |
| 507 | @dataclass |
| 508 | class TestConfig: |
| 509 | num_prompt_tokens: int |
| 510 | num_generated_tokens: int |
| 511 | num_accepted_tokens: int |
| 512 | step_actions: list[StepAction] |
| 513 | |
| 514 | |
| 515 | def apply_patch(monkeypatch: pytest.MonkeyPatch): |
| 516 | monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0") |
| 517 | |
| 518 | fake_sample_fn = get_fake_sample_fn() |
| 519 | monkeypatch.setattr(GPUModelRunner, "_sample", fake_sample_fn) |
| 520 | |
| 521 | fake_propose_draft_token_ids_fn = get_fake_propose_draft_token_ids_fn() |
| 522 | monkeypatch.setattr( |
| 523 | GPUModelRunner, "propose_draft_token_ids", fake_propose_draft_token_ids_fn |
| 524 | ) |
| 525 | |
| 526 | fake_execute_model_fn = get_fake_execute_model_fn(GPUModelRunner.execute_model) |
| 527 | monkeypatch.setattr(GPUModelRunner, "execute_model", fake_execute_model_fn) |
| 528 | |
| 529 | fake_step_action_fn = get_fake_step_action_fn(InprocClient.get_output) |
| 530 | monkeypatch.setattr(InprocClient, "get_output", fake_step_action_fn) |
| 531 | |
| 532 | fake_allocate_slots_fn = get_fake_allocate_slots_fn(KVCacheManager.allocate_slots) |
| 533 | monkeypatch.setattr(KVCacheManager, "allocate_slots", fake_allocate_slots_fn) |
| 534 | |
| 535 | fake_preprocess_mamba_fn, fake_copy_fn = get_fake_process_mamba_fn( |
| 536 | mamba_utils.preprocess_mamba, |
| 537 | mamba_utils.do_mamba_copy_block, |
| 538 | ) |
| 539 | monkeypatch.setattr(mamba_utils, "preprocess_mamba", fake_preprocess_mamba_fn) |
| 540 | monkeypatch.setattr(mamba_utils, "do_mamba_copy_block", fake_copy_fn) |
| 541 | |
| 542 | |
| 543 | def get_mamba_prefix_cache_step_configs( |
| 544 | async_scheduling: bool = False, |
| 545 | ) -> dict[str, TestConfig]: |
| 546 | a = async_scheduling |
| 547 | B = BLOCK_SIZE |
| 548 | tests = { |
| 549 | "accept_1": TestConfig( |
| 550 | num_prompt_tokens=B - 6, |
| 551 | num_generated_tokens=20, |
| 552 | num_accepted_tokens=1, |
| 553 | step_actions=[ |
| 554 | StepAction(0, B - 6, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 555 | StepAction(B - 6, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 556 | StepAction( |
| 557 | B - 5, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1) |
| 558 | ), |
| 559 | StepAction( |
| 560 | B - 4, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1) |
| 561 | ), |
| 562 | StepAction(B - 3, 4, [1, 1, 1, 1, 1], (0, 1), (-1, -1)), |
| 563 | StepAction(B - 2, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 564 | StepAction(B - 1, 4, [1, 1, 1, 1, 1], (-1, -1), (1, 0)), |
| 565 | StepAction(B, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 566 | StepAction( |
| 567 | B + 1, |
| 568 | 4, |
| 569 | [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], |
| 570 | (-1, -1), |
| 571 | (-1, -1), |
| 572 | ), |
| 573 | ], |
| 574 | ), |
| 575 | # test case 2.1: no hit, accept 2 tokens |
| 576 | "accept_2_1": TestConfig( |
| 577 | num_prompt_tokens=B - 6, |
| 578 | num_generated_tokens=20, |
| 579 | num_accepted_tokens=2, |
| 580 | step_actions=[ |
| 581 | StepAction(0, B - 6, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 582 | StepAction(B - 6, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 583 | StepAction( |
| 584 | B - 4, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1) |
| 585 | ), |
| 586 | StepAction(B - 2, 4, [1, 1, 1, 1, 1], (1, 1), (2, 0)), |
| 587 | StepAction(B, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 588 | StepAction( |
| 589 | B + 2, |
| 590 | 4, |
| 591 | [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], |
| 592 | (-1, -1), |
| 593 | (-1, -1), |
| 594 | ), |
| 595 | ], |
| 596 | ), |
| 597 | # test case 2.2: no hit, accept 2 tokens |
| 598 | "accept_2_2": TestConfig( |
| 599 | num_prompt_tokens=B - 5, |
| 600 | num_generated_tokens=20, |
| 601 | num_accepted_tokens=2, |
| 602 | step_actions=[ |
| 603 | StepAction(0, B - 5, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 604 | StepAction(B - 5, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 605 | StepAction(B - 3, 4, [1, 1, 1, 1, 1], (1, 1), (-1, -1)), |
| 606 | StepAction(B - 1, 4, [1, 1, 1, 1, 1], (-1, -1), (1, 0)), |
| 607 | StepAction( |
| 608 | B + 1, |
| 609 | 4, |
| 610 | [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], |
| 611 | (-1, -1), |
| 612 | (-1, -1), |
| 613 | ), |
| 614 | StepAction(B + 3, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 615 | ], |
| 616 | ), |
| 617 | "accept_3_1": TestConfig( |
| 618 | num_prompt_tokens=B - 7, |
| 619 | num_generated_tokens=20, |
| 620 | num_accepted_tokens=3, |
| 621 | step_actions=[ |
| 622 | StepAction(0, B - 7, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 623 | StepAction(B - 7, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 624 | StepAction( |
| 625 | B - 4, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1) |
| 626 | ), |
| 627 | StepAction(B - 1, 4, [1, 1, 1, 1, 1], (2, 1), (1, 0)), |
| 628 | StepAction( |
| 629 | B + 2, |
| 630 | 4, |
| 631 | [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], |
| 632 | (-1, -1), |
| 633 | (-1, -1), |
| 634 | ), |
| 635 | StepAction(B + 5, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 636 | ], |
| 637 | ), |
| 638 | "accept_3_2": TestConfig( |
| 639 | num_prompt_tokens=B - 6, |
| 640 | num_generated_tokens=20, |
| 641 | num_accepted_tokens=3, |
| 642 | step_actions=[ |
| 643 | StepAction(0, B - 6, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 644 | StepAction(B - 6, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 645 | StepAction(B - 3, 4, [1, 1, 1, 1, 1], (2, 1), (3, 0)), |
| 646 | StepAction(B, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 647 | StepAction( |
| 648 | B + 3, |
| 649 | 4, |
| 650 | [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], |
| 651 | (-1, -1), |
| 652 | (-1, -1), |
| 653 | ), |
| 654 | ], |
| 655 | ), |
| 656 | "accept_3_3": TestConfig( |
| 657 | num_prompt_tokens=B - 5, |
| 658 | num_generated_tokens=20, |
| 659 | num_accepted_tokens=3, |
| 660 | step_actions=[ |
| 661 | StepAction(0, B - 5, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 662 | StepAction(B - 5, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 663 | StepAction(B - 2, 4, [1, 1, 1, 1, 1], (2, 1), (2, 0)), |
| 664 | StepAction( |
| 665 | B + 1, |
| 666 | 4, |
| 667 | [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], |
| 668 | (-1, -1), |
| 669 | (-1, -1), |
| 670 | ), |
| 671 | StepAction(B + 4, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 672 | ], |
| 673 | ), |
| 674 | "accept_4_1": TestConfig( |
| 675 | num_prompt_tokens=B - 7, |
| 676 | num_generated_tokens=20, |
| 677 | num_accepted_tokens=4, |
| 678 | step_actions=[ |
| 679 | StepAction(0, B - 7, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 680 | StepAction(B - 7, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 681 | StepAction(B - 3, 4, [1, 1, 1, 1, 1], (3, 1), (3, 0)), |
| 682 | StepAction( |
| 683 | B + 1, |
| 684 | 4, |
| 685 | [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], |
| 686 | (-1, -1), |
| 687 | (-1, -1), |
| 688 | ), |
| 689 | StepAction(B + 5, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 690 | ], |
| 691 | ), |
| 692 | "accept_4_2": TestConfig( |
| 693 | num_prompt_tokens=B - 6, |
| 694 | num_generated_tokens=25, |
| 695 | num_accepted_tokens=4, |
| 696 | step_actions=[ |
| 697 | StepAction(0, B - 6, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 698 | StepAction(B - 6, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 699 | StepAction(B - 2, 4, [1, 1, 1, 1, 1], (3, 1), (2, 0)), |
| 700 | StepAction( |
| 701 | B + 2, |
| 702 | 4, |
| 703 | [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], |
| 704 | (-1, -1), |
| 705 | (-1, -1), |
| 706 | ), |
| 707 | StepAction(B + 6, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 708 | ], |
| 709 | ), |
| 710 | "accept_4_3": TestConfig( |
| 711 | num_prompt_tokens=B - 5, |
| 712 | num_generated_tokens=25, |
| 713 | num_accepted_tokens=4, |
| 714 | step_actions=[ |
| 715 | StepAction(0, B - 5, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 716 | StepAction(B - 5, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 717 | StepAction(B - 1, 4, [1, 1, 1, 1, 1], (3, 1), (1, 0)), |
| 718 | StepAction( |
| 719 | B + 3, |
| 720 | 4, |
| 721 | [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], |
| 722 | (-1, -1), |
| 723 | (-1, -1), |
| 724 | ), |
| 725 | StepAction(B + 7, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 726 | ], |
| 727 | ), |
| 728 | "accept_4_4": TestConfig( |
| 729 | num_prompt_tokens=B - 4, |
| 730 | num_generated_tokens=25, |
| 731 | num_accepted_tokens=4, |
| 732 | step_actions=[ |
| 733 | StepAction(0, B - 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 734 | StepAction(B - 4, 4, [1, 1, 1, 1], (-1, -1), (3, 0)), |
| 735 | StepAction(B, 4, [1, 1, 1, 1, 1], (0, 1), (-1, -1)), |
| 736 | StepAction( |
| 737 | B + 4, |
| 738 | 4, |
| 739 | [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], |
| 740 | (-1, -1), |
| 741 | (-1, -1), |
| 742 | ), |
| 743 | ], |
| 744 | ), |
| 745 | "prompt_block_size": TestConfig( |
| 746 | num_prompt_tokens=B, |
| 747 | num_generated_tokens=10, |
| 748 | num_accepted_tokens=4, |
| 749 | step_actions=[ |
| 750 | StepAction(0, B, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 751 | StepAction(B, 4, [1, 1, 1, 1, 1], (0, 1), (-1, -1)), |
| 752 | ], |
| 753 | ), |
| 754 | "prompt_2_block_size": TestConfig( |
| 755 | num_prompt_tokens=B * 2, |
| 756 | num_generated_tokens=10, |
| 757 | num_accepted_tokens=4, |
| 758 | step_actions=[ |
| 759 | StepAction(0, B, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 760 | StepAction(B, B, [1, 1, 1, 1, 1], (0, 1), (-1, -1)), |
| 761 | StepAction( |
| 762 | B * 2, |
| 763 | 4, |
| 764 | [1, 1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1, 1], |
| 765 | (1, 2), |
| 766 | (-1, -1), |
| 767 | ), |
| 768 | ], |
| 769 | ), |
| 770 | "prompt_2_block_size_10": TestConfig( |
| 771 | num_prompt_tokens=B * 2 + 10, |
| 772 | num_generated_tokens=10, |
| 773 | num_accepted_tokens=4, |
| 774 | step_actions=[ |
| 775 | StepAction(0, B, [1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 776 | StepAction(B, B + 10, [1, 0, 1, 1, 1, 1], (0, 2), (-1, -1)), |
| 777 | StepAction( |
| 778 | B * 2 + 10, |
| 779 | 4, |
| 780 | [1, 0, 1, 1, 1, 1] if a else [0, 0, 1, 1, 1, 1], |
| 781 | (-1, -1), |
| 782 | (-1, -1), |
| 783 | ), |
| 784 | ], |
| 785 | ), |
| 786 | "prompt_3_block_size": TestConfig( |
| 787 | num_prompt_tokens=B * 3, |
| 788 | num_generated_tokens=10, |
| 789 | num_accepted_tokens=4, |
| 790 | step_actions=[ |
| 791 | StepAction(0, B * 2, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 792 | StepAction(B * 2, B, [0, 1, 1, 1, 1, 1], (1, 2), (-1, -1)), |
| 793 | StepAction( |
| 794 | B * 3, |
| 795 | 4, |
| 796 | [0, 1, 1, 1, 1, 1, 1] if a else [0, 0, 1, 1, 1, 1, 1], |
| 797 | (2, 3), |
| 798 | (-1, -1), |
| 799 | ), |
| 800 | ], |
| 801 | ), |
| 802 | "prompt_3_block_size_10": TestConfig( |
| 803 | num_prompt_tokens=B * 3 + 10, |
| 804 | num_generated_tokens=10, |
| 805 | num_accepted_tokens=4, |
| 806 | step_actions=[ |
| 807 | StepAction(0, B * 2, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 808 | StepAction(B * 2, B + 10, [0, 1, 0, 1, 1, 1, 1], (1, 3), (-1, -1)), |
| 809 | StepAction( |
| 810 | B * 3 + 10, |
| 811 | 4, |
| 812 | [0, 1, 0, 1, 1, 1, 1] if a else [0, 0, 0, 1, 1, 1, 1], |
| 813 | (-1, -1), |
| 814 | (-1, -1), |
| 815 | ), |
| 816 | ], |
| 817 | ), |
| 818 | "prompt_10_block_size": TestConfig( |
| 819 | num_prompt_tokens=B * 10, |
| 820 | num_generated_tokens=10, |
| 821 | num_accepted_tokens=4, |
| 822 | step_actions=[ |
| 823 | StepAction(0, B * 5, [0, 0, 0, 0, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 824 | StepAction( |
| 825 | B * 5, |
| 826 | B * 4, |
| 827 | [0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1], |
| 828 | (4, 8), |
| 829 | (-1, -1), |
| 830 | ), |
| 831 | StepAction( |
| 832 | B * 9, |
| 833 | B, |
| 834 | [0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1] |
| 835 | if a |
| 836 | else [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], |
| 837 | (8, 9), |
| 838 | (-1, -1), |
| 839 | ), |
| 840 | StepAction( |
| 841 | B * 10, |
| 842 | 4, |
| 843 | [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1] |
| 844 | if a |
| 845 | else [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], |
| 846 | (9, 10), |
| 847 | (-1, -1), |
| 848 | ), |
| 849 | ], |
| 850 | ), |
| 851 | "prompt_10_block_size_10": TestConfig( |
| 852 | num_prompt_tokens=B * 10 + 10, |
| 853 | num_generated_tokens=10, |
| 854 | num_accepted_tokens=4, |
| 855 | step_actions=[ |
| 856 | StepAction(0, B * 5, [0, 0, 0, 0, 1, 1, 1, 1], (-1, -1), (-1, -1)), |
| 857 | StepAction( |
| 858 | B * 5, |
| 859 | B * 4, |
| 860 | [0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1], |
| 861 | (4, 8), |
| 862 | (-1, -1), |
| 863 | ), |
| 864 | StepAction( |
| 865 | B * 9, |
| 866 | B + 10, |
| 867 | [0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1] |
| 868 | if a |
| 869 | else [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1], |
| 870 | (8, 10), |
| 871 | (-1, -1), |
| 872 | ), |
| 873 | ], |
| 874 | ), |
| 875 | } |
| 876 | return tests |
| 877 | |
| 878 | |
| 879 | def _run_mamba_prefix_cache_mrv1( |
| 880 | monkeypatch: pytest.MonkeyPatch, async_scheduling: bool |
| 881 | ): |
| 882 | # This test patches the V1 model runner, so pin V1 explicitly: MoE/hybrid |
| 883 | # models like Qwen3-Next now default to the V2 runner. |
| 884 | monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "0") |
| 885 | envs.disable_envs_cache() |
| 886 | global async_scheduling_mode |
| 887 | async_scheduling_mode = async_scheduling |
| 888 | run_ref_mamba_state_in_subprocess() |
| 889 | apply_patch(monkeypatch) |
| 890 | prompt_dataset = datasets.load_dataset("heheda/a_long_article") |
| 891 | full_prompt = prompt_dataset["train"][0]["text"] |
| 892 | tests = get_mamba_prefix_cache_step_configs(async_scheduling) |
| 893 | |
| 894 | engine = LLM( |
| 895 | model=MODEL, |
| 896 | load_format="dummy", |
| 897 | enable_prefix_caching=True, |
| 898 | block_size=BLOCK_SIZE, |
| 899 | mamba_cache_mode="align", |
| 900 | speculative_config={ |
| 901 | "method": "qwen3_next_mtp", |
| 902 | "num_speculative_tokens": num_speculative_tokens, |
| 903 | }, |
| 904 | max_num_batched_tokens=3072, |
| 905 | hf_overrides={"num_hidden_layers": NUM_HIDDEN_LAYERS}, |
| 906 | async_scheduling=async_scheduling, |
| 907 | seed=42, |
| 908 | ) |
| 909 | global prompt_token_ids |
| 910 | prompt_token_ids = engine.get_tokenizer().encode(full_prompt) |
| 911 | print(f"Token IDs length: {len(prompt_token_ids)}") |
| 912 | for test_case_name, test_config in tests.items(): |
| 913 | print(f"Running test case: {test_case_name}") |
| 914 | num_generated_tokens = test_config.num_generated_tokens |
| 915 | num_prompt_tokens = test_config.num_prompt_tokens |
| 916 | global num_accepted_tokens |
| 917 | num_accepted_tokens = test_config.num_accepted_tokens |
| 918 | sampling_params = SamplingParams( |
| 919 | temperature=0.0, max_tokens=num_generated_tokens |
| 920 | ) |
| 921 | global cur_step_action_idx |
| 922 | cur_step_action_idx = 0 |
| 923 | global step_actions |
| 924 | step_actions = test_config.step_actions |
| 925 | _ = engine.generate( |
| 926 | [TokensPrompt(prompt_token_ids=prompt_token_ids[:num_prompt_tokens])], |
| 927 | sampling_params, |
| 928 | ) |
| 929 | assert engine.llm_engine.engine_core.engine_core.scheduler.reset_prefix_cache() |
| 930 | print(f"End test case: {test_case_name}") |
| 931 | keys_to_check = [ |
| 932 | (action.postprocess_copy_idx[1] + 1) * BLOCK_SIZE |
| 933 | for action in test_config.step_actions |
| 934 | if action.postprocess_copy_idx and action.postprocess_copy_idx[0] != -1 |
| 935 | ] |
| 936 | mamba_state_ref = torch.load("mamba_kv_cache_dict_ref.pth") |
| 937 | check_mamba_state_equal(mamba_state_ref, mamba_kv_cache_dict, keys_to_check) |
| 938 | mamba_kv_cache_dict.clear() |
| 939 | del engine |
| 940 | torch.accelerator.empty_cache() |
| 941 | cleanup_dist_env_and_memory() |
| 942 | |
| 943 | |
| 944 | @create_new_process_for_each_test("spawn") |
| 945 | def test_mamba_prefix_cache_mrv1(monkeypatch: pytest.MonkeyPatch): |
| 946 | _run_mamba_prefix_cache_mrv1(monkeypatch, async_scheduling=False) |
| 947 | |
| 948 | |
| 949 | @create_new_process_for_each_test("spawn") |
| 950 | def test_mamba_prefix_cache_mrv1_async(monkeypatch: pytest.MonkeyPatch): |
| 951 | _run_mamba_prefix_cache_mrv1(monkeypatch, async_scheduling=True) |
| 952 | |
| 953 | |
| 954 | def _run_mamba_prefix_cache_mrv2( |
| 955 | monkeypatch: pytest.MonkeyPatch, async_scheduling: bool |
| 956 | ): |
| 957 | global async_scheduling_mode |
| 958 | async_scheduling_mode = async_scheduling |
| 959 | monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0") |
| 960 | monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1") |
| 961 | envs.disable_envs_cache() |
| 962 | |
| 963 | from vllm.v1.worker.gpu.model_runner import GPUModelRunner as MRV2GPUModelRunner |
| 964 | from vllm.v1.worker.gpu.model_states.mamba_hybrid import ( |
| 965 | MambaHybridModelState, |
| 966 | ) |
| 967 | from vllm.v1.worker.gpu.sample.output import SamplerOutput as MRV2SamplerOutput |
| 968 | |
| 969 | events: list[int] = [] |
| 970 | original_execute_model = MRV2GPUModelRunner.execute_model |
| 971 | original_sample = MRV2GPUModelRunner.sample |
| 972 | original_preprocess_state = MambaHybridModelState.preprocess_state |
| 973 | original_postprocess_state = MambaHybridModelState.postprocess_state |
| 974 | original_step_action_fn = InprocClient.get_output |
| 975 | original_allocate_slots = KVCacheManager.allocate_slots |
| 976 | captured: dict[str, Any] = {} |
| 977 | |
| 978 | def temporal_states(model_state, block_tables, kv_cache_config): |
| 979 | # Qwen3-Next keeps the temporal (ssm) state as the last Mamba cache. |
| 980 | forward_context = ( |
| 981 | model_state.vllm_config.compilation_config.static_forward_context |
| 982 | ) |
| 983 | mamba_groups = get_mamba_groups(kv_cache_config) |
| 984 | for group_ids in mamba_groups.values(): |
| 985 | for group_id in group_ids: |
| 986 | block_table = block_tables[group_id] |
| 987 | for layer_name in kv_cache_config.kv_cache_groups[group_id].layer_names: |
| 988 | yield forward_context[layer_name].kv_cache[-1], block_table |
| 989 | |
| 990 | def temporal_block(temporal_state, block_table, col): |
| 991 | # Resolving the block id for assertions is a deliberate D2H. |
| 992 | with gpu_sync_allowed(): |
| 993 | block_id = int(block_table[0, col].item()) |
| 994 | return temporal_state[block_id] |
| 995 | |
| 996 | def wrapped_preprocess_state( |
| 997 | self: MambaHybridModelState, |
| 998 | input_batch: Any, |
| 999 | block_tables: tuple[torch.Tensor, ...], |
| 1000 | kv_cache_config: KVCacheConfig, |
| 1001 | num_computed_tokens: torch.Tensor, |
| 1002 | ) -> None: |
| 1003 | captured["block_tables"] = block_tables |
| 1004 | captured["kv_cache_config"] = kv_cache_config |
| 1005 | expected = ( |
| 1006 | None if cur_step_action is None else cur_step_action.preprocess_copy_idx |
| 1007 | ) |
| 1008 | snapshots = [] |
| 1009 | if expected is not None and expected != (-1, -1): |
| 1010 | for temporal, bt in temporal_states(self, block_tables, kv_cache_config): |
| 1011 | snapshots.append( |
| 1012 | (temporal, bt, temporal_block(temporal, bt, expected[0]).clone()) |
| 1013 | ) |
| 1014 | ret = original_preprocess_state( |
| 1015 | self, input_batch, block_tables, kv_cache_config, num_computed_tokens |
| 1016 | ) |
| 1017 | if cur_step_action is not None: |
| 1018 | # Reading the GPU-side copy state back to assert on it is a |
| 1019 | # deliberate D2H. |
| 1020 | with gpu_sync_allowed(): |
| 1021 | req_idx = int(input_batch.idx_mapping[0].item()) |
| 1022 | src_col = int(self._mamba_src_col_gpu[req_idx].item()) |
| 1023 | off = int(self._mamba_src_off_gpu[req_idx].item()) |
| 1024 | dst = int(self._mamba_state_idx_gpu[req_idx].item()) |
| 1025 | actual = (-1, -1) if src_col < 0 or src_col == dst else (src_col + off, dst) |
| 1026 | assert actual == expected, ( |
| 1027 | f"V2 align preprocess copy: expected={expected}, " |
| 1028 | f"actual={actual}, {cur_step_action=}" |
| 1029 | ) |
| 1030 | # Comparing device tensors for the assertion is a deliberate D2H. |
| 1031 | with gpu_sync_allowed(): |
| 1032 | for temporal, bt, src_state in snapshots: |
| 1033 | torch.testing.assert_close( |
| 1034 | temporal_block(temporal, bt, expected[1]), src_state |
| 1035 | ) |
| 1036 | return ret |
| 1037 | |
| 1038 | def wrapped_postprocess_state( |
| 1039 | self: MambaHybridModelState, |
| 1040 | idx_mapping: torch.Tensor, |
| 1041 | num_sampled: torch.Tensor | int, |
| 1042 | num_computed_tokens: torch.Tensor | None = None, |
| 1043 | ) -> None: |
| 1044 | action = cur_step_action |
| 1045 | block_tables = captured.get("block_tables") |
| 1046 | kv_cache_config = captured.get("kv_cache_config") |
| 1047 | # The postprocess kernel does not expose its indices, so only the copy |
| 1048 | # case is checked, by effect: snapshot the src block, expect dst == src. |
| 1049 | if ( |
| 1050 | action is None |
| 1051 | or num_computed_tokens is None |
| 1052 | or block_tables is None |
| 1053 | or action.postprocess_copy_idx == (-1, -1) |
| 1054 | ): |
| 1055 | return original_postprocess_state( |
| 1056 | self, idx_mapping, num_sampled, num_computed_tokens |
| 1057 | ) |
| 1058 | expected = action.postprocess_copy_idx |
| 1059 | snapshots = [ |
| 1060 | (temporal, bt, temporal_block(temporal, bt, expected[0]).clone()) |
| 1061 | for temporal, bt in temporal_states(self, block_tables, kv_cache_config) |
| 1062 | ] |
| 1063 | ret = original_postprocess_state( |
| 1064 | self, idx_mapping, num_sampled, num_computed_tokens |
| 1065 | ) |
| 1066 | # Comparing device tensors for the assertion is a deliberate D2H. |
| 1067 | with gpu_sync_allowed(): |
| 1068 | for temporal, bt, src_state in snapshots: |
| 1069 | torch.testing.assert_close( |
| 1070 | temporal_block(temporal, bt, expected[1]), src_state |
| 1071 | ) |
| 1072 | return ret |
| 1073 | |
| 1074 | def wrapped_execute_model( |
| 1075 | self: MRV2GPUModelRunner, |
| 1076 | scheduler_output: SchedulerOutput, |
| 1077 | *args: Any, |
| 1078 | **kwargs: Any, |
| 1079 | ): |
| 1080 | events.extend( |
| 1081 | req.num_computed_tokens for req in scheduler_output.scheduled_new_reqs |
| 1082 | ) |
| 1083 | events.extend(scheduler_output.scheduled_cached_reqs.num_computed_tokens) |
| 1084 | if cur_step_action is not None: |
| 1085 | num_scheduled_tokens = next( |
| 1086 | iter(scheduler_output.num_scheduled_tokens.values()) |
| 1087 | ) |
| 1088 | assert num_scheduled_tokens == cur_step_action.num_scheduled_tokens |
| 1089 | ret = original_execute_model(self, scheduler_output, *args, **kwargs) |
| 1090 | if cur_step_action is not None and self.execute_model_state is not None: |
| 1091 | input_batch = self.execute_model_state.input_batch |
| 1092 | # Reading positions back to assert on them is a deliberate D2H. |
| 1093 | with gpu_sync_allowed(): |
| 1094 | start_pos = input_batch.positions[input_batch.query_start_loc[0]].item() |
| 1095 | assert cur_step_action.num_computed_tokens_start == start_pos |
| 1096 | return ret |
| 1097 | |
| 1098 | def fake_sample( |
| 1099 | self: MRV2GPUModelRunner, |
| 1100 | hidden_states: torch.Tensor, |
| 1101 | input_batch: Any, |
| 1102 | grammar_output: Any, |
| 1103 | ): |
| 1104 | if cur_step_action is None: |
| 1105 | return original_sample(self, hidden_states, input_batch, grammar_output) |
| 1106 | |
| 1107 | num_reqs = input_batch.num_reqs |
| 1108 | sampled_token_ids = torch.ones( |
| 1109 | (num_reqs, self.num_speculative_steps + 1), |
| 1110 | device=hidden_states.device, |
| 1111 | dtype=torch.int64, |
| 1112 | ) |
| 1113 | num_logits = async_tensor_h2d( |
| 1114 | input_batch.cu_num_logits_np[1 : num_reqs + 1] |
| 1115 | - input_batch.cu_num_logits_np[:num_reqs], |
| 1116 | device=hidden_states.device, |
| 1117 | ) |
| 1118 | accepted = torch.full_like(num_logits, num_accepted_tokens) |
| 1119 | num_sampled = torch.minimum(accepted, num_logits) |
| 1120 | prefill_lens = self.req_states.prefill_len.gpu[input_batch.idx_mapping] |
| 1121 | is_chunked_prefill = input_batch.seq_lens[:num_reqs] < prefill_lens |
| 1122 | num_sampled = torch.where(is_chunked_prefill, 0, num_sampled) |
| 1123 | num_rejected = torch.where(is_chunked_prefill, 0, num_logits - num_sampled) |
| 1124 | sampler_output = MRV2SamplerOutput( |
| 1125 | sampled_token_ids=sampled_token_ids, |
| 1126 | logprobs_tensors=None, |
| 1127 | num_nans=None, |
| 1128 | num_sampled=num_sampled, |
| 1129 | num_rejected=num_rejected, |
| 1130 | ) |
| 1131 | return sampler_output, num_sampled, num_rejected |
| 1132 | |
| 1133 | monkeypatch.setattr( |
| 1134 | InprocClient, |
| 1135 | "get_output", |
| 1136 | get_fake_step_action_fn(original_step_action_fn), |
| 1137 | ) |
| 1138 | monkeypatch.setattr( |
| 1139 | KVCacheManager, |
| 1140 | "allocate_slots", |
| 1141 | get_fake_allocate_slots_fn(original_allocate_slots), |
| 1142 | ) |
| 1143 | monkeypatch.setattr(MRV2GPUModelRunner, "execute_model", wrapped_execute_model) |
| 1144 | monkeypatch.setattr(MRV2GPUModelRunner, "sample", fake_sample) |
| 1145 | monkeypatch.setattr( |
| 1146 | MambaHybridModelState, "preprocess_state", wrapped_preprocess_state |
| 1147 | ) |
| 1148 | monkeypatch.setattr( |
| 1149 | MambaHybridModelState, "postprocess_state", wrapped_postprocess_state |
| 1150 | ) |
| 1151 | |
| 1152 | engine = LLM( |
| 1153 | model=MODEL, |
| 1154 | load_format="dummy", |
| 1155 | enforce_eager=True, |
| 1156 | skip_tokenizer_init=True, |
| 1157 | enable_prefix_caching=True, |
| 1158 | block_size=BLOCK_SIZE, |
| 1159 | mamba_cache_mode="align", |
| 1160 | speculative_config={ |
| 1161 | "method": "qwen3_next_mtp", |
| 1162 | "num_speculative_tokens": num_speculative_tokens, |
| 1163 | }, |
| 1164 | max_num_batched_tokens=3072, |
| 1165 | max_model_len=BLOCK_SIZE * 12, |
| 1166 | hf_overrides={"num_hidden_layers": NUM_HIDDEN_LAYERS}, |
| 1167 | async_scheduling=async_scheduling, |
| 1168 | seed=42, |
| 1169 | ) |
| 1170 | |
| 1171 | try: |
| 1172 | tests = get_mamba_prefix_cache_step_configs(async_scheduling) |
| 1173 | |
| 1174 | global step_actions |
| 1175 | global cur_step_action_idx |
| 1176 | global num_accepted_tokens |
| 1177 | for test_name, test_config in tests.items(): |
| 1178 | num_accepted_tokens = test_config.num_accepted_tokens |
| 1179 | cur_step_action_idx = 0 |
| 1180 | step_actions = test_config.step_actions |
| 1181 | sampling_params = SamplingParams( |
| 1182 | temperature=0.0, |
| 1183 | max_tokens=test_config.num_generated_tokens, |
| 1184 | ignore_eos=True, |
| 1185 | ) |
| 1186 | _ = engine.generate( |
| 1187 | [TokensPrompt(prompt_token_ids=[1] * test_config.num_prompt_tokens)], |
| 1188 | sampling_params=sampling_params, |
| 1189 | ) |
| 1190 | assert cur_step_action_idx == len(test_config.step_actions), test_name |
| 1191 | assert ( |
| 1192 | engine.llm_engine.engine_core.engine_core.scheduler.reset_prefix_cache() |
| 1193 | ) |
| 1194 | |
| 1195 | step_actions = [] |
| 1196 | cur_step_action_idx = 0 |
| 1197 | num_accepted_tokens = 1 |
| 1198 | prompt = TokensPrompt(prompt_token_ids=[1] * (BLOCK_SIZE * 2)) |
| 1199 | sampling_params = SamplingParams( |
| 1200 | temperature=0.0, |
| 1201 | max_tokens=1, |
| 1202 | ignore_eos=True, |
| 1203 | ) |
| 1204 | _ = engine.generate([prompt], sampling_params=sampling_params) |
| 1205 | first_event_count = len(events) |
| 1206 | _ = engine.generate([prompt], sampling_params=sampling_params) |
| 1207 | second_events = events[first_event_count:] |
| 1208 | prefix_hits = [ |
| 1209 | num_computed_tokens |
| 1210 | for num_computed_tokens in second_events |
| 1211 | if num_computed_tokens >= BLOCK_SIZE |
| 1212 | ] |
| 1213 | assert prefix_hits, ( |
| 1214 | "Expected the second identical prompt to hit prefix cache, " |
| 1215 | f"got events={second_events!r}" |
| 1216 | ) |
| 1217 | assert engine.llm_engine.engine_core.engine_core.scheduler.reset_prefix_cache() |
| 1218 | finally: |
| 1219 | del engine |
| 1220 | torch.accelerator.empty_cache() |
| 1221 | cleanup_dist_env_and_memory() |
| 1222 | |
| 1223 | |
| 1224 | @create_new_process_for_each_test() |
| 1225 | def test_mamba_prefix_cache_mrv2(monkeypatch: pytest.MonkeyPatch): |
| 1226 | _run_mamba_prefix_cache_mrv2(monkeypatch, async_scheduling=False) |
| 1227 | |
| 1228 | |
| 1229 | @create_new_process_for_each_test() |
| 1230 | def test_mamba_prefix_cache_mrv2_async(monkeypatch: pytest.MonkeyPatch): |
| 1231 | _run_mamba_prefix_cache_mrv2(monkeypatch, async_scheduling=True) |