|
1 | 1 | # SPDX-License-Identifier: Apache-2.0 |
2 | 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | +import types |
| 4 | +from types import SimpleNamespace |
| 5 | +from unittest.mock import patch |
3 | 6 |
|
4 | 7 | import pytest |
5 | 8 | import torch |
6 | 9 |
|
| 10 | +from vllm.distributed.eplb.eplb_state import EplbLayerState |
| 11 | +from vllm.model_executor.layers.fused_moe.config import RoutingMethodType |
| 12 | +from vllm.model_executor.layers.fused_moe.routed_experts_capturer import ( |
| 13 | + RoutedExpertsCapturer, |
| 14 | +) |
| 15 | +from vllm.model_executor.layers.fused_moe.router.base_router import BaseRouter |
| 16 | + |
7 | 17 | pytestmark = pytest.mark.cpu_test |
8 | 18 |
|
| 19 | +_REC_MODULE = "vllm.model_executor.layers.fused_moe.routed_experts_capturer" |
9 | 20 |
|
10 | | -def test_bind_routing_capture_to_model_sets_layer_view(monkeypatch): |
11 | | - import vllm.model_executor.layers.fused_moe.layer as fused_moe_layer |
12 | | - import vllm.model_executor.layers.fused_moe.routed_experts_capturer as rec_mod |
13 | 21 |
|
14 | | - class _DummyMoEConfig: |
15 | | - is_sequence_parallel = False |
16 | | - dp_size = 1 |
| 22 | +def _capturer_with_buffer( |
| 23 | + *, |
| 24 | + max_tokens: int = 8, |
| 25 | + num_layers: int = 4, |
| 26 | + num_experts_per_tok: int = 2, |
| 27 | + dp_rank: int = 0, |
| 28 | +) -> RoutedExpertsCapturer: |
| 29 | + c = RoutedExpertsCapturer() |
| 30 | + c.dp_rank = dp_rank |
| 31 | + c._device_buffer = torch.full( |
| 32 | + (max_tokens, num_layers, num_experts_per_tok), |
| 33 | + -1, |
| 34 | + dtype=torch.int32, |
| 35 | + ) |
| 36 | + return c |
17 | 37 |
|
18 | | - class _DummyQuantMethod: |
19 | | - supports_internal_mk = True |
20 | 38 |
|
21 | | - class DummyFusedMoE: |
22 | | - _routing_replay_out: torch.Tensor |
| 39 | +class DummyRouter(BaseRouter): |
| 40 | + @property |
| 41 | + def routing_method_type(self) -> RoutingMethodType: |
| 42 | + return RoutingMethodType.FUSED_TOPK |
23 | 43 |
|
24 | | - def __init__(self, moe_layer_id): |
25 | | - self.moe_layer_id = moe_layer_id |
26 | | - self.moe_config = _DummyMoEConfig() |
27 | | - self.quant_method = _DummyQuantMethod() |
| 44 | + def _compute_routing( |
| 45 | + self, hidden_states, router_logits, indices_type, *, input_ids=None |
| 46 | + ): |
| 47 | + topk_ids = torch.tensor([[1, 2], [3, 4]], dtype=torch.int64) |
| 48 | + topk_weights = torch.ones_like(topk_ids, dtype=torch.float32) |
| 49 | + return topk_weights, topk_ids |
28 | 50 |
|
29 | | - monkeypatch.setattr(fused_moe_layer, "FusedMoE", DummyFusedMoE) |
| 51 | + def _apply_eplb_mapping(self, topk_ids: torch.Tensor) -> torch.Tensor: |
| 52 | + # Make mapping observable without requiring CUDA EPLB path. |
| 53 | + return topk_ids + 10 |
30 | 54 |
|
31 | | - num_layers, num_tokens, top_k = 4, 8, 2 |
32 | | - buffer = torch.zeros((num_layers, num_tokens, top_k), dtype=torch.int16) |
33 | 55 |
|
34 | | - class DummyDeviceCache: |
35 | | - def __init__(self, buf): |
36 | | - self.buffer = buf |
| 56 | +def _make_router(eplb_state: EplbLayerState | None = None) -> DummyRouter: |
| 57 | + return DummyRouter( |
| 58 | + top_k=2, |
| 59 | + global_num_experts=16, |
| 60 | + eplb_state=eplb_state, |
| 61 | + indices_type_getter=None, |
| 62 | + ) |
37 | 63 |
|
38 | | - class DummyCapturer: |
39 | | - def get_device_cache(self): |
40 | | - return DummyDeviceCache(buffer) |
41 | 64 |
|
42 | | - monkeypatch.setattr(rec_mod, "get_global_experts_capturer", lambda: DummyCapturer()) |
| 65 | +def test_base_router_capture_pre_eplb_mapping(): |
| 66 | + router = _make_router() |
| 67 | + captured = [] |
43 | 68 |
|
44 | | - m0 = DummyFusedMoE(moe_layer_id=0) |
45 | | - m2 = DummyFusedMoE(moe_layer_id=2) |
| 69 | + def capture_fn(ids): |
| 70 | + captured.append(ids.clone()) |
46 | 71 |
|
47 | | - class DummyModel: |
48 | | - def modules(self): |
49 | | - return iter([m0, m2]) |
| 72 | + router.set_capture_fn(capture_fn) |
| 73 | + topk_weights, topk_ids = router.select_experts( |
| 74 | + hidden_states=torch.empty(1), |
| 75 | + router_logits=torch.empty(1), |
| 76 | + ) |
50 | 77 |
|
51 | | - rec_mod.bind_routing_capture_to_model(DummyModel()) |
| 78 | + assert topk_weights.shape == topk_ids.shape |
| 79 | + assert len(captured) == 1 |
| 80 | + assert torch.equal(captured[0], torch.tensor([[1, 2], [3, 4]])) |
| 81 | + assert torch.equal(topk_ids, torch.tensor([[11, 12], [13, 14]])) |
52 | 82 |
|
53 | | - assert torch.equal(m0._routing_replay_out, buffer[0]) |
54 | | - assert torch.equal(m2._routing_replay_out, buffer[2]) |
55 | 83 |
|
| 84 | +def test_base_router_capture_with_eplb_enabled(): |
| 85 | + eplb_state = EplbLayerState() |
| 86 | + eplb_state.expert_load_view = torch.zeros(32, dtype=torch.int64) |
| 87 | + eplb_state.logical_to_physical_map = torch.arange(32).view(32, 1) |
| 88 | + eplb_state.logical_replica_count = torch.ones(32, dtype=torch.int64) |
| 89 | + eplb_state.should_record_tensor = torch.ones((), dtype=torch.bool) |
| 90 | + router = _make_router(eplb_state=eplb_state) |
56 | 91 |
|
57 | | -def test_bind_routing_capture_to_model_noop_when_disabled(monkeypatch): |
58 | | - import vllm.model_executor.layers.fused_moe.routed_experts_capturer as rec_mod |
| 92 | + captured = [] |
59 | 93 |
|
60 | | - class DummyCapturer: |
61 | | - def get_device_cache(self): |
62 | | - return None |
| 94 | + def capture_fn(ids): |
| 95 | + captured.append(ids.clone()) |
63 | 96 |
|
64 | | - monkeypatch.setattr(rec_mod, "get_global_experts_capturer", lambda: DummyCapturer()) |
| 97 | + router.set_capture_fn(capture_fn) |
| 98 | + _, topk_ids = router.select_experts( |
| 99 | + hidden_states=torch.empty(1), |
| 100 | + router_logits=torch.empty(1), |
| 101 | + ) |
65 | 102 |
|
66 | | - class DummyModel: |
67 | | - def modules(self): |
68 | | - return iter([]) |
| 103 | + assert len(captured) == 1 |
| 104 | + # Capture should see logical ids pre-EPLB mapping. |
| 105 | + assert torch.equal(captured[0], torch.tensor([[1, 2], [3, 4]])) |
| 106 | + # Our DummyRouter mapping adds +10. |
| 107 | + assert torch.equal(topk_ids, torch.tensor([[11, 12], [13, 14]])) |
69 | 108 |
|
70 | | - rec_mod.bind_routing_capture_to_model(DummyModel()) |
71 | 109 |
|
| 110 | +def test_gpu_model_runner_binds_router_capture(monkeypatch): |
| 111 | + from vllm.v1.worker import gpu_model_runner as gmr |
72 | 112 |
|
73 | | -# ========================================================================= |
74 | | -# Tests for device-cache routing replay architecture |
75 | | -# ========================================================================= |
| 113 | + class DummyFusedMoE: |
| 114 | + def __init__(self): |
| 115 | + self.layer_id = 7 |
| 116 | + self.router = _make_router() |
76 | 117 |
|
| 118 | + class DummyCapturer: |
| 119 | + def __init__(self): |
| 120 | + self.calls = [] |
77 | 121 |
|
78 | | -class TestRoutedExpertsDeviceCache: |
79 | | - """Tests for _RoutedExpertsDeviceCache (GPU buffer for routing data).""" |
| 122 | + def capture(self, layer_id, topk_ids): |
| 123 | + self.calls.append((layer_id, topk_ids)) |
80 | 124 |
|
81 | | - def test_allocation_shape_and_dtype(self): |
82 | | - """Device cache allocates (L, N, K) int16 buffer.""" |
83 | | - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import ( |
84 | | - _RoutedExpertsDeviceCache, |
85 | | - ) |
| 125 | + dummy_module = DummyFusedMoE() |
86 | 126 |
|
87 | | - cache = _RoutedExpertsDeviceCache( |
88 | | - num_hidden_layers=40, |
89 | | - max_num_batched_tokens=8192, |
90 | | - num_experts_per_tok=8, |
91 | | - device="cpu", |
92 | | - ) |
93 | | - assert cache.buffer.shape == (40, 8192, 8) |
94 | | - assert cache.buffer.dtype == torch.int16 |
| 127 | + # Patch the runtime import inside _bind_routed_experts_capturer. |
| 128 | + import vllm.model_executor.layers.fused_moe.layer as fused_moe_layer |
95 | 129 |
|
96 | | - def test_per_layer_view_is_contiguous(self): |
97 | | - """buffer[layer_id] gives contiguous (N, K) view for FlashInfer.""" |
98 | | - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import ( |
99 | | - _RoutedExpertsDeviceCache, |
100 | | - ) |
| 130 | + monkeypatch.setattr(fused_moe_layer, "FusedMoE", DummyFusedMoE) |
101 | 131 |
|
102 | | - cache = _RoutedExpertsDeviceCache( |
103 | | - num_hidden_layers=40, |
104 | | - max_num_batched_tokens=8192, |
105 | | - num_experts_per_tok=8, |
106 | | - device="cpu", |
| 132 | + dummy_self = types.SimpleNamespace( |
| 133 | + compilation_config=types.SimpleNamespace( |
| 134 | + static_forward_context={"dummy": dummy_module} |
107 | 135 | ) |
108 | | - layer_view = cache.buffer[0] |
109 | | - assert layer_view.is_contiguous() |
110 | | - assert layer_view.shape == (8192, 8) |
| 136 | + ) |
111 | 137 |
|
| 138 | + capturer = DummyCapturer() |
| 139 | + gmr.GPUModelRunner._bind_routed_experts_capturer(dummy_self, capturer) |
112 | 140 |
|
113 | | -class TestRoutedExpertsHostCache: |
114 | | - """Tests for _RoutedExpertsHostCache (per-request numpy buffer).""" |
| 141 | + assert dummy_module.router.capture_fn is not None |
| 142 | + dummy_module.router.capture_fn(torch.tensor([[5, 6]])) |
115 | 143 |
|
116 | | - def test_sentinel_initialization(self): |
117 | | - """Host cache initializes with zeros by default.""" |
118 | | - import numpy as np |
| 144 | + assert len(capturer.calls) == 1 |
| 145 | + layer_id, topk_ids = capturer.calls[0] |
| 146 | + assert layer_id == 7 |
| 147 | + assert torch.equal(topk_ids, torch.tensor([[5, 6]])) |
119 | 148 |
|
120 | | - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import ( |
121 | | - _RoutedExpertsHostCache, |
122 | | - ) |
123 | 149 |
|
124 | | - cache = _RoutedExpertsHostCache( |
125 | | - num_hidden_layers=40, |
126 | | - num_experts_per_tok=8, |
127 | | - max_model_len=1024, |
128 | | - ) |
129 | | - buf = cache.get_or_grow_buffer("req1", max_pos=100) |
130 | | - assert buf.dtype == np.int16 |
131 | | - assert (buf == 0).all(), "Host cache must initialize with zeros" |
132 | | - |
133 | | - def test_grow_preserves_existing_data(self): |
134 | | - """Growing the buffer preserves previously written data.""" |
135 | | - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import ( |
136 | | - _RoutedExpertsHostCache, |
137 | | - ) |
| 150 | +def test_gpu_model_runner_binding_stage(monkeypatch): |
| 151 | + from vllm.v1.worker import gpu_model_runner as gmr |
138 | 152 |
|
139 | | - cache = _RoutedExpertsHostCache( |
140 | | - num_hidden_layers=40, |
141 | | - num_experts_per_tok=8, |
142 | | - max_model_len=1024, |
143 | | - ) |
144 | | - buf = cache.get_or_grow_buffer("req1", max_pos=50) |
145 | | - buf[0, 0, 0] = 42 |
146 | | - buf2 = cache.get_or_grow_buffer("req1", max_pos=200) |
147 | | - assert buf2[0, 0, 0] == 42, "Data lost during buffer grow" |
148 | | - |
149 | | - def test_free_request_removes_buffer(self): |
150 | | - """Freeing a request removes its buffer.""" |
151 | | - from vllm.model_executor.layers.fused_moe.routed_experts_capturer import ( |
152 | | - _RoutedExpertsHostCache, |
153 | | - ) |
| 153 | + class DummyFusedMoE: |
| 154 | + def __init__(self): |
| 155 | + self.layer_id = 11 |
| 156 | + self.router = _make_router() |
| 157 | + |
| 158 | + class DummyCapturer: |
| 159 | + def __init__(self): |
| 160 | + self.calls = [] |
| 161 | + |
| 162 | + def capture(self, layer_id, topk_ids): |
| 163 | + self.calls.append((layer_id, topk_ids)) |
| 164 | + |
| 165 | + dummy_module = DummyFusedMoE() |
| 166 | + |
| 167 | + import vllm.model_executor.layers.fused_moe.layer as fused_moe_layer |
| 168 | + |
| 169 | + monkeypatch.setattr(fused_moe_layer, "FusedMoE", DummyFusedMoE) |
154 | 170 |
|
155 | | - cache = _RoutedExpertsHostCache( |
156 | | - num_hidden_layers=40, |
157 | | - num_experts_per_tok=8, |
158 | | - max_model_len=1024, |
| 171 | + dummy_self = types.SimpleNamespace( |
| 172 | + compilation_config=types.SimpleNamespace( |
| 173 | + static_forward_context={"dummy": dummy_module} |
159 | 174 | ) |
160 | | - cache.get_or_grow_buffer("req1", max_pos=50) |
161 | | - cache.free_request("req1") |
162 | | - assert cache.get_buffer("req1") is None |
| 175 | + ) |
| 176 | + |
| 177 | + # Before binding, no capture hook. |
| 178 | + assert dummy_module.router.capture_fn is None |
| 179 | + |
| 180 | + capturer = DummyCapturer() |
| 181 | + gmr.GPUModelRunner._bind_routed_experts_capturer(dummy_self, capturer) |
| 182 | + |
| 183 | + # After binding, hook should exist and be callable. |
| 184 | + assert callable(dummy_module.router.capture_fn) |
| 185 | + dummy_module.router.capture_fn(torch.tensor([[9, 10]])) |
| 186 | + assert len(capturer.calls) == 1 |
| 187 | + |
| 188 | + |
| 189 | +def test_routed_experts_capturer_single_dp_no_metadata(): |
| 190 | + """dp_metadata is None: capture writes the full topk_ids rows.""" |
| 191 | + capturer = _capturer_with_buffer(dp_rank=0) |
| 192 | + topk = torch.tensor([[1, 2], [3, 4], [5, 6]], dtype=torch.int32) |
| 193 | + ctx = SimpleNamespace(dp_metadata=None) |
| 194 | + with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx): |
| 195 | + capturer.capture(layer_id=0, topk_ids=topk) |
| 196 | + assert torch.equal(capturer._device_buffer[:3, 0, :], topk) |
| 197 | + assert capturer._device_buffer[3, 0, 0].item() == -1 |
| 198 | + |
| 199 | + |
| 200 | +def test_routed_experts_capturer_dp_naive_concatenated_all_ranks(): |
| 201 | + """n == sum(num_tokens_dp): slice this rank's segment from concatenated topk.""" |
| 202 | + capturer = _capturer_with_buffer(dp_rank=1) |
| 203 | + num_tokens_dp = torch.tensor([2, 3], dtype=torch.int32) |
| 204 | + ctx = SimpleNamespace( |
| 205 | + dp_metadata=SimpleNamespace(num_tokens_across_dp_cpu=num_tokens_dp) |
| 206 | + ) |
| 207 | + # Concatenated order: rank0 rows then rank1 rows. |
| 208 | + topk = torch.tensor( |
| 209 | + [[0, 1], [2, 3], [10, 11], [12, 13], [14, 15]], dtype=torch.int32 |
| 210 | + ) |
| 211 | + with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx): |
| 212 | + capturer.capture(layer_id=0, topk_ids=topk) |
| 213 | + want = topk[2:5] |
| 214 | + assert torch.equal(capturer._device_buffer[:3, 0, :], want) |
| 215 | + |
| 216 | + |
| 217 | +def test_routed_experts_capturer_dp_modular_local_tokens(): |
| 218 | + """n == token_num_per_dp: topk is already local to this DP rank.""" |
| 219 | + capturer = _capturer_with_buffer(dp_rank=1) |
| 220 | + num_tokens_dp = torch.tensor([2, 3], dtype=torch.int32) |
| 221 | + ctx = SimpleNamespace( |
| 222 | + dp_metadata=SimpleNamespace(num_tokens_across_dp_cpu=num_tokens_dp) |
| 223 | + ) |
| 224 | + topk = torch.tensor([[10, 11], [12, 13], [14, 15]], dtype=torch.int32) |
| 225 | + with patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx): |
| 226 | + capturer.capture(layer_id=0, topk_ids=topk) |
| 227 | + assert torch.equal(capturer._device_buffer[:3, 0, :], topk) |
| 228 | + |
| 229 | + |
| 230 | +def test_routed_experts_capturer_dp_unexpected_batch_raises(): |
| 231 | + """Mismatch between topk batch dim and DP layout: fail fast.""" |
| 232 | + capturer = _capturer_with_buffer(dp_rank=0) |
| 233 | + num_tokens_dp = torch.tensor([2, 3], dtype=torch.int32) |
| 234 | + ctx = SimpleNamespace( |
| 235 | + dp_metadata=SimpleNamespace(num_tokens_across_dp_cpu=num_tokens_dp) |
| 236 | + ) |
| 237 | + # total=5, local=2: n=1 matches neither naive (5) nor modular (2). |
| 238 | + topk = torch.tensor([[1, 2]], dtype=torch.int32) |
| 239 | + with ( |
| 240 | + patch(f"{_REC_MODULE}.get_forward_context", return_value=ctx), |
| 241 | + pytest.raises(AssertionError, match="unexpected topk_ids batch dim"), |
| 242 | + ): |
| 243 | + capturer.capture(layer_id=0, topk_ids=topk) |
| 244 | + assert capturer._device_buffer[0, 0, 0].item() == -1 |
0 commit comments