# # Copyright (c) 2026 Huawei Technologies Co., Ltd. All Rights Reserved. # Copyright 2023 The vLLM team. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for AscendExtractHiddenStatesProposer. This test file follows the pattern from vllm's test_extract_hidden_states.py, with Ascend-specific additions for ACL graph differences. """ from __future__ import annotations from unittest.mock import patch import numpy as np import pytest import torch from vllm.config import CacheConfig, CUDAGraphMode, VllmConfig, set_current_vllm_config from vllm_ascend.ascend_config import init_ascend_config from vllm_ascend.spec_decode.extract_hidden_states_proposer import ( AscendExtractHiddenStatesProposer, ) from vllm_ascend.utils import vllm_version_is @pytest.fixture(autouse=True) def _no_pin_memory(): if vllm_version_is("0.23.0"): with patch( "vllm.v1.spec_decode.extract_hidden_states.is_pin_memory_available", return_value=False, ): yield else: with patch( "vllm.v1.spec_decode.extract_hidden_states.PIN_MEMORY", False, ): yield class MockCachedRequestState: """Mock CachedRequestState for testing (same pattern as vllm).""" def __init__(self, req_id: str, token_ids: list[int]): self.req_id = req_id self.token_ids = token_ids def get_token_id(self, position: int) -> int: if 0 <= position < len(self.token_ids): return self.token_ids[position] return 0 class MockInputBatch: """Mock InputBatch for testing (same pattern as vllm).""" def __init__( self, num_reqs: int, req_ids: list[str], vocab_size: int, num_tokens_no_spec: list[int] | None = None, ): self.num_reqs = num_reqs self.req_ids = req_ids self.vocab_size = vocab_size if num_tokens_no_spec is None: self.num_tokens_no_spec = np.array([5] * num_reqs, dtype=np.int64) else: self.num_tokens_no_spec = np.array(num_tokens_no_spec, dtype=np.int64) def _create_vllm_config(num_speculative_tokens: int = 1, layer_ids: list[int] | None = None): """Create a VllmConfig for testing (simplified version of vllm's pattern).""" from unittest.mock import MagicMock if layer_ids is None: layer_ids = [1, 2, 3, 4] vllm_config = MagicMock(spec=VllmConfig) vllm_config.speculative_config = MagicMock() vllm_config.speculative_config.num_speculative_tokens = num_speculative_tokens vllm_config.speculative_config.draft_tensor_parallel_size = 1 vllm_config.speculative_config.draft_model_config = MagicMock() vllm_config.speculative_config.draft_model_config.uses_xdrope_dim = 0 vllm_config.speculative_config.draft_model_config.uses_mrope = False vllm_config.speculative_config.disable_padded_drafter_batch = False vllm_config.cache_config = MagicMock(spec=CacheConfig) vllm_config.cache_config.block_size = 16 vllm_config.scheduler_config = MagicMock() vllm_config.scheduler_config.max_num_batched_tokens = 1024 vllm_config.scheduler_config.max_num_seqs = 32 vllm_config.model_config = MagicMock() vllm_config.model_config.dtype = torch.float16 vllm_config.model_config.max_model_len = 2048 vllm_config.model_config.uses_mrope = False vllm_config.model_config.uses_xdrope_dim = 0 vllm_config.model_config.hf_text_config = MagicMock(spec=[]) vllm_config.model_config.hf_text_config.to_dict = MagicMock(return_value={}) vllm_config.model_config.get_hidden_size = MagicMock(return_value=4096) vllm_config.compilation_config = MagicMock() vllm_config.parallel_config.tensor_parallel_size = 1 vllm_config.parallel_config.data_parallel_rank = 0 vllm_config.parallel_config.data_parallel_size = 1 vllm_config.parallel_config.prefill_context_parallel_size = 1 vllm_config.parallel_config.enable_expert_parallel = False vllm_config.additional_config = None init_ascend_config(vllm_config) return vllm_config def test_proposer_initialization(): """Test that the proposer initializes correctly (matches vllm pattern).""" from unittest.mock import MagicMock vllm_config = _create_vllm_config(num_speculative_tokens=1, layer_ids=[1, 2, 3, 4]) device = torch.device("cpu") runner = MagicMock() runner.pin_memory = False runner.pcp_size = 1 runner.dcp_size = 1 with set_current_vllm_config(vllm_config): proposer = AscendExtractHiddenStatesProposer(vllm_config=vllm_config, device=device, runner=runner) # Verify it's an instance of ExtractHiddenStatesProposer from vllm.v1.spec_decode.extract_hidden_states import ExtractHiddenStatesProposer assert isinstance(proposer, ExtractHiddenStatesProposer) assert proposer.runner == runner def test_dummy_run_basic(): """Test dummy_run with Ascend-specific ACL graph signature. This is Ascend-specific because ACL graph capture has different parameters than CUDA graph capture. """ from unittest.mock import MagicMock, patch vllm_config = _create_vllm_config() device = torch.device("cpu") runner = MagicMock() runner.pin_memory = False runner.pcp_size = 1 runner.dcp_size = 1 with set_current_vllm_config(vllm_config): proposer = AscendExtractHiddenStatesProposer(vllm_config=vllm_config, device=device, runner=runner) proposer.model = MagicMock() proposer.dp_rank = 0 proposer.hidden_states = torch.zeros(1024, 4096, dtype=torch.float16) runner._sync_metadata_across_dp.return_value = (16, None, CUDAGraphMode.NONE) with patch("vllm_ascend.spec_decode.extract_hidden_states_proposer.set_forward_context") as mock_context: mock_context.return_value.__enter__ = MagicMock(return_value=None) mock_context.return_value.__exit__ = MagicMock(return_value=None) proposer.dummy_run(num_tokens=16) proposer.model.assert_called_once() def test_dummy_run_syncs_metadata_across_dp_as_draft_model(): """dummy_run must issue the same drafter DP sync as propose() does on busy ranks (via _determine_batch_execution_and_padding), mirroring llm_base_proposer.dummy_run. Regression guard for the multi-DP deadlock: if idle DP ranks running the dummy path skip the drafter sync while busy ranks perform it, the DP cpu_group collectives desynchronize and all ranks hang. """ from unittest.mock import MagicMock, patch vllm_config = _create_vllm_config() device = torch.device("cpu") runner = MagicMock() runner.pin_memory = False runner.pcp_size = 1 runner.dcp_size = 1 with set_current_vllm_config(vllm_config): proposer = AscendExtractHiddenStatesProposer(vllm_config=vllm_config, device=device, runner=runner) proposer.model = MagicMock() proposer.dp_rank = 0 proposer.hidden_states = torch.zeros(1024, 4096, dtype=torch.float16) synced_tensor = torch.tensor([16, 16], dtype=torch.int32) runner._sync_metadata_across_dp.return_value = (16, synced_tensor, CUDAGraphMode.NONE) with patch("vllm_ascend.spec_decode.extract_hidden_states_proposer.set_forward_context") as mock_context: mock_context.return_value.__enter__ = MagicMock(return_value=None) mock_context.return_value.__exit__ = MagicMock(return_value=None) proposer.dummy_run(num_tokens=16) runner._sync_metadata_across_dp.assert_called_once() args, kwargs = runner._sync_metadata_across_dp.call_args assert (args and args[0] == 16) or kwargs.get("num_tokens") == 16 assert kwargs.get("is_draft_model") is True # The synced tensor must be the one forwarded to set_forward_context. _, ctx_kwargs = mock_context.call_args assert ctx_kwargs["num_tokens_across_dp"] is synced_tensor def test_prepare_next_token_ids_padded(): """Test prepare_next_token_ids_padded (matches vllm's test pattern). Since num_speculative_tokens == 1, sampled_token_ids has shape (batch_size, 1). For each request we either use the sampled token (if valid and not discarded) or a backup token from the request state. Note: Ascend uses indices/count pattern instead of GPU's boolean mask. """ from unittest.mock import MagicMock device = torch.device("cpu") vllm_config = _create_vllm_config() runner = MagicMock() runner.pin_memory = False runner.pcp_size = 1 runner.dcp_size = 1 with set_current_vllm_config(vllm_config): proposer = AscendExtractHiddenStatesProposer(vllm_config=vllm_config, device=device, runner=runner) # Setup test data (same pattern as vllm) num_requests = 4 req_ids = [f"req_{i + 1}" for i in range(num_requests)] gpu_input_batch = MockInputBatch( num_reqs=num_requests, req_ids=req_ids, vocab_size=100, num_tokens_no_spec=[11, 16, 21, 26], # Different seq_lens: [10, 15, 20, 25] ) requests = {} for req_id in req_ids: idx = int(req_id.split("_")[1]) # Different token sequences for each request mock_request = MockCachedRequestState(req_id, list(range(15 + idx * 5))) requests[req_id] = mock_request # sampled_token_ids shape: [batch_size, 1] sampled_token_ids = torch.tensor( [ [1], # valid, use 1 [4], # valid, use 4 [-1], # invalid, use backup [2], # discarded, use backup ], dtype=torch.int64, device=device, ) # Ascend uses indices/count pattern (different from GPU's boolean mask) discard_request_indices = torch.tensor([3], dtype=torch.int64) num_discarded_requests = 1 next_token_ids, valid_sampled_tokens_count = proposer.prepare_next_token_ids_padded( sampled_token_ids=sampled_token_ids, requests=requests, gpu_input_batch=gpu_input_batch, discard_request_indices=discard_request_indices, num_discarded_requests=num_discarded_requests, ) # Verify results # valid_sampled_tokens_count tracks token validity (not discard status) expected_valid_counts = torch.tensor([1, 1, 0, 1], dtype=torch.int32) assert torch.equal(valid_sampled_tokens_count, expected_valid_counts) # next_token_ids: use sampled if valid and not discarded, else backup # Request 1: valid (1), use 1 # Request 2: valid (4), use 4 # Request 3: invalid (-1), use backup (seq_len=10, token at pos 10 = 10) # Request 4: discarded, use backup (seq_len=15, token at pos 15 = 15) assert next_token_ids[0].item() == 1 assert next_token_ids[1].item() == 4 assert next_token_ids[2].item() == 20 # backup from request state assert next_token_ids[3].item() == 25 # backup from request state # Verify return dtypes assert next_token_ids.dtype == torch.int32 assert valid_sampled_tokens_count.dtype == torch.int32 def _make_batch_desc(num_tokens: int): """Build a minimal mock for ``CudagraphDispatcher.dispatch``'s batch_desc.""" from unittest.mock import MagicMock batch_desc = MagicMock() batch_desc.num_tokens = num_tokens return batch_desc def _build_proposer_for_padding_test(data_parallel_size: int = 1): """Shared helper for _determine_batch_execution_and_padding tests. Returns a proposer whose ``runner``, ``cudagraph_dispatcher``, and DP-related attributes are mocked so we can drive ``_determine_batch_execution_and_padding`` without an NPU. """ from unittest.mock import MagicMock vllm_config = _create_vllm_config() vllm_config.parallel_config.data_parallel_size = data_parallel_size runner = MagicMock() runner.pin_memory = False runner.pcp_size = 1 runner.dcp_size = 1 with set_current_vllm_config(vllm_config): proposer = AscendExtractHiddenStatesProposer(vllm_config=vllm_config, device=torch.device("cpu"), runner=runner) proposer.dp_rank = 0 proposer.cudagraph_dispatcher = MagicMock() return proposer, runner def test_determine_batch_execution_and_padding_asserts_when_runner_is_none(): """Constructing without a runner must fail fast with a clear message. Regression guard for the AttributeError that would otherwise be raised on ``self.runner._pad_for_sequence_parallelism(...)`` at the entry of the override. """ vllm_config = _create_vllm_config() with set_current_vllm_config(vllm_config): proposer = AscendExtractHiddenStatesProposer(vllm_config=vllm_config, device=torch.device("cpu"), runner=None) proposer.cudagraph_dispatcher = type("D", (), {"dispatch": staticmethod(lambda *a, **kw: (None, None))})() with pytest.raises(AssertionError, match="requires a runner reference"): proposer._determine_batch_execution_and_padding(num_tokens=4) def test_determine_batch_execution_and_padding_dp1_sp_pads_and_skips_sync(): """With DP=1, SP-pads ``num_tokens`` but never calls DP sync. Verifies the ``data_parallel_size == 1`` early-out and that the runner's ``_pad_for_sequence_parallelism`` is still consulted so the cache_only forward gets an SP-aligned input. """ proposer, runner = _build_proposer_for_padding_test(data_parallel_size=1) # Simulate TP=4 SP padding: round 6 up to 8 runner._pad_for_sequence_parallelism = lambda n: ((n + 3) // 4) * 4 proposer.cudagraph_dispatcher.dispatch.return_value = ( CUDAGraphMode.NONE, _make_batch_desc(num_tokens=8), ) cudagraph_mode, num_tokens_padded, num_tokens_across_dp = proposer._determine_batch_execution_and_padding( num_tokens=6 ) assert num_tokens_padded == 8 assert num_tokens_across_dp is None # no DP sync when dp_size == 1 assert cudagraph_mode == CUDAGraphMode.NONE # Dispatcher saw the SP-padded value, not the raw 6. args, kwargs = proposer.cudagraph_dispatcher.dispatch.call_args assert args[0] == 8 or kwargs.get("num_tokens") == 8 or args == (8,) runner._sync_metadata_across_dp.assert_not_called() def test_determine_batch_execution_and_padding_dp2_uses_runner_sync(): """With DP>1, must call ``runner._sync_metadata_across_dp`` (Ascend's shape ``[2, dp_size]`` path) and must NOT call upstream ``coordinate_batch_across_dp`` (shape ``[4, dp_size]``). This is the core regression test for the gloo ``op.preamble.length 8 vs 4`` shape mismatch on the DP cpu_group. """ from unittest.mock import patch proposer, runner = _build_proposer_for_padding_test(data_parallel_size=2) runner._pad_for_sequence_parallelism = lambda n: ((n + 3) // 4) * 4 proposer.cudagraph_dispatcher.dispatch.side_effect = [ # First dispatch (pre-sync) with SP-padded num_tokens=8 (CUDAGraphMode.NONE, _make_batch_desc(num_tokens=8)), # Re-dispatch after sync; the agreed value happens to also be 8 (CUDAGraphMode.NONE, _make_batch_desc(num_tokens=8)), ] # Pretend both DP ranks agreed on 8 tokens sync_tensor = torch.tensor([8, 8], dtype=torch.int32) runner._sync_metadata_across_dp.return_value = (8, sync_tensor, CUDAGraphMode.NONE) with patch("vllm.v1.spec_decode.extract_hidden_states.coordinate_batch_across_dp") as mock_upstream_coord: cudagraph_mode, num_tokens_padded, num_tokens_across_dp = proposer._determine_batch_execution_and_padding( num_tokens=6 ) # Upstream DP sync must NOT be used (it would post a [4, dp_size] # tensor and break gloo on the shared cpu_group). mock_upstream_coord.assert_not_called() # Runner sync called once, with the SP-padded value and is_draft_model=True. runner._sync_metadata_across_dp.assert_called_once() call_kwargs = runner._sync_metadata_across_dp.call_args.kwargs assert call_kwargs["num_tokens"] == 8 # SP-padded 6 -> 8 assert call_kwargs["is_draft_model"] is True assert num_tokens_padded == 8 assert num_tokens_across_dp is not None assert num_tokens_across_dp[proposer.dp_rank].item() == 8 def test_determine_batch_execution_and_padding_dp2_keeps_tp_aligned_for_main_forward(): """If the runner's SP padding produces a TP-aligned value, the final ``num_tokens_padded`` returned to the proposer (and downstream main forward) is guaranteed to be TP-aligned too. Regression guard for the ``reduce_scatter`` assertion ``input.shape[0] % world_size == 0``. """ proposer, runner = _build_proposer_for_padding_test(data_parallel_size=2) tp = 4 runner._pad_for_sequence_parallelism = lambda n: ((n + tp - 1) // tp) * tp proposer.cudagraph_dispatcher.dispatch.side_effect = [ (CUDAGraphMode.NONE, _make_batch_desc(num_tokens=8)), (CUDAGraphMode.NONE, _make_batch_desc(num_tokens=8)), ] runner._sync_metadata_across_dp.return_value = ( 8, torch.tensor([8, 8], dtype=torch.int32), CUDAGraphMode.NONE, ) _mode, num_tokens_padded, _across = proposer._determine_batch_execution_and_padding(num_tokens=6) # The whole point of the fix: never returns 6 (which would crash # SP reduce_scatter as 6 % 4 != 0). assert num_tokens_padded % tp == 0