|
1 | | -"""CPU contract tests for GenericMoeLayer's unified TP all-reduce path.""" |
| 1 | +"""Contract tests for GenericMoeLayer's unified TP all-reduce paths. |
2 | 2 |
|
| 3 | +Mostly CPU tensors; the side-stream shared expert needs a real CUDA graph. |
| 4 | +""" |
| 5 | + |
| 6 | +import inspect |
| 7 | +from functools import partial |
3 | 8 | from types import SimpleNamespace |
4 | | -from unittest import TestCase, main |
| 9 | +from unittest import TestCase, main, skipUnless |
5 | 10 | from unittest.mock import MagicMock, Mock, patch |
6 | 11 |
|
7 | 12 | import torch |
8 | 13 |
|
9 | 14 | from rtp_llm.models_py.distributed.collective_torch import Group |
10 | | -from rtp_llm.models_py.model_desc.generic_moe import GenericMoeLayer |
| 15 | +from rtp_llm.models_py.model_desc.generic_moe import ( |
| 16 | + _SHARED_EXPERT_STREAMS, |
| 17 | + GenericMoeLayer, |
| 18 | + _ensure_shared_expert_stream, |
| 19 | +) |
11 | 20 | from rtp_llm.models_py.modules.factory.fused_moe.defs.fused_moe import FusedMoe |
12 | 21 | from rtp_llm.models_py.modules.hybrid.dense_mlp import DenseMLP |
13 | 22 | from rtp_llm.utils.model_weight import W |
14 | 23 |
|
| 24 | +# This file also runs as generic_moe_allreduce_test_rocm. Only the CUDA |
| 25 | +# low-latency router advertises supports_row_scatter_finalize, so the side |
| 26 | +# stream never runs on ROCm. |
| 27 | +_CUDA_GRAPH_READY = torch.cuda.is_available() and torch.version.hip is None |
| 28 | + |
15 | 29 |
|
16 | 30 | def _make_layer( |
17 | 31 | *, |
@@ -288,6 +302,149 @@ def test_row_scatter_reduces_both_branches_once(self, mock_all_reduce): |
288 | 302 | torch.testing.assert_close(result, expected_input * 2) |
289 | 303 | self.assertTrue(layer.shared_expert.call_args.kwargs["skip_allreduce"]) |
290 | 304 | self.assertNotIn("skip_tp_allreduce", fused_moe.call_args.kwargs) |
| 305 | + # CPU weights mean no side stream, so the router needs no join. |
| 306 | + self.assertIsNone(fused_moe.call_args.kwargs["row_scatter_ready"]) |
| 307 | + |
| 308 | + @patch("rtp_llm.models_py.model_desc.generic_moe.all_reduce") |
| 309 | + def test_a_failed_routed_chain_joins_the_shared_branch(self, _): |
| 310 | + # The router does the join on the success path. When the routed chain |
| 311 | + # raises, forward has to do it, or the side stream stays outstanding. |
| 312 | + layer = _make_layer(ep_size=2, supports_row_scatter_finalize=True) |
| 313 | + hidden_states, _, _, _, fused_moe = _configure_forward(layer) |
| 314 | + ready = object() |
| 315 | + layer._shared_expert_partial = Mock( |
| 316 | + return_value=(torch.zeros_like(hidden_states), ready) |
| 317 | + ) |
| 318 | + fused_moe.side_effect = RuntimeError("dispatch failed") |
| 319 | + waited = [] |
| 320 | + stream = SimpleNamespace(wait_event=waited.append) |
| 321 | + |
| 322 | + with patch("torch.cuda.current_stream", return_value=stream): |
| 323 | + with self.assertRaisesRegex(RuntimeError, "dispatch failed"): |
| 324 | + layer(hidden_states) |
| 325 | + |
| 326 | + self.assertEqual(waited, [ready]) |
| 327 | + |
| 328 | + @patch("rtp_llm.models_py.model_desc.generic_moe.all_reduce") |
| 329 | + def test_layer_only_passes_keywords_fused_moe_accepts(self, _): |
| 330 | + # The fused_moe mock accepts any keyword, so a layer-only keyword passes |
| 331 | + # every other test here and only fails once a real FusedMoe is called. |
| 332 | + accepted = set(inspect.signature(FusedMoe.forward).parameters) |
| 333 | + cases = ( |
| 334 | + ("ep_unified", dict(ep_size=2, supports_row_scatter_finalize=True)), |
| 335 | + ("ep_shared", dict(ep_size=2, supports_row_scatter_finalize=False)), |
| 336 | + ("pure_tp", dict(ep_size=1)), |
| 337 | + ("ffn_tp_one", dict(ffn_tp_size=1)), |
| 338 | + ) |
| 339 | + for name, kwargs in cases: |
| 340 | + with self.subTest(name=name): |
| 341 | + layer = _make_layer(**kwargs) |
| 342 | + hidden_states, _, _, _, fused_moe = _configure_forward(layer) |
| 343 | + |
| 344 | + layer(hidden_states) |
| 345 | + |
| 346 | + unexpected = set(fused_moe.call_args.kwargs) - accepted |
| 347 | + self.assertFalse( |
| 348 | + unexpected, f"not FusedMoe.forward parameters: {unexpected}" |
| 349 | + ) |
| 350 | + |
| 351 | + |
| 352 | +@skipUnless(_CUDA_GRAPH_READY, "needs a CUDA device") |
| 353 | +class SharedExpertSideStreamCaptureTest(TestCase): |
| 354 | + """Covers _shared_expert_partial's side-stream branch under real capture. |
| 355 | +
|
| 356 | + CPU weights leave shared_expert_stream unset, so only a CUDA device reaches |
| 357 | + this branch, and an unclosed fork only shows up at capture end. |
| 358 | + """ |
| 359 | + |
| 360 | + SHARED_FACTOR = 3.0 |
| 361 | + |
| 362 | + def setUp(self): |
| 363 | + _SHARED_EXPERT_STREAMS.clear() |
| 364 | + self.device = torch.device("cuda", torch.cuda.current_device()) |
| 365 | + |
| 366 | + def _stub_layer(self): |
| 367 | + """Stand-in for ``self``: this path only needs these four attributes.""" |
| 368 | + stream = _ensure_shared_expert_stream(self.device) |
| 369 | + self.assertIsNotNone(stream) |
| 370 | + stub = SimpleNamespace( |
| 371 | + # A row-parallel shared expert leaves a TP-partial sum; scaling |
| 372 | + # stands in for that work without needing real weights. |
| 373 | + shared_expert=lambda x, skip_allreduce: x * self.SHARED_FACTOR, |
| 374 | + shared_expert_gate=None, |
| 375 | + shared_expert_stream=stream, |
| 376 | + shared_expert_ready=torch.cuda.Event(), |
| 377 | + ) |
| 378 | + stub._gate_shared_expert_output = partial( |
| 379 | + GenericMoeLayer._gate_shared_expert_output, stub |
| 380 | + ) |
| 381 | + return stub |
| 382 | + |
| 383 | + def _fork_and_join(self, stub, hidden): |
| 384 | + shared_partial, ready = GenericMoeLayer._shared_expert_partial(stub, hidden) |
| 385 | + self.assertIs(ready, stub.shared_expert_ready) |
| 386 | + # The join the router performs in _finalize_row_scatter. |
| 387 | + torch.cuda.current_stream().wait_event(ready) |
| 388 | + return shared_partial |
| 389 | + |
| 390 | + def test_one_stream_per_device_is_reused(self): |
| 391 | + # Every MoE layer shares one side stream per device. |
| 392 | + self.assertIs( |
| 393 | + _ensure_shared_expert_stream(self.device), |
| 394 | + _ensure_shared_expert_stream(self.device), |
| 395 | + ) |
| 396 | + |
| 397 | + def test_capture_closes_the_fork_and_replay_recomputes(self): |
| 398 | + stub = self._stub_layer() |
| 399 | + hidden = torch.ones(8, 16, device=self.device) |
| 400 | + out = torch.empty_like(hidden) |
| 401 | + |
| 402 | + # torch.cuda.graph requires the work to have run once outside capture. |
| 403 | + out.copy_(self._fork_and_join(stub, hidden)) |
| 404 | + torch.cuda.synchronize() |
| 405 | + |
| 406 | + # Capture fails here if the side stream is still outstanding. |
| 407 | + graph = torch.cuda.CUDAGraph() |
| 408 | + with torch.cuda.graph(graph): |
| 409 | + out.copy_(self._fork_and_join(stub, hidden)) |
| 410 | + |
| 411 | + # A new input value distinguishes a real replay from leftover contents. |
| 412 | + hidden.fill_(2.0) |
| 413 | + graph.replay() |
| 414 | + torch.cuda.synchronize() |
| 415 | + torch.testing.assert_close(out, torch.full_like(out, 2.0 * self.SHARED_FACTOR)) |
| 416 | + |
| 417 | + def test_graphs_per_batch_size_share_the_stream_and_event(self): |
| 418 | + # Decode captures one graph per batch size into a single memory pool |
| 419 | + # (cuda_graph_runner.cc passes one shared_graph_pool_ to every capture), |
| 420 | + # and every graph reuses the device's side stream and the layer's event. |
| 421 | + stub = self._stub_layer() |
| 422 | + pool = torch.cuda.graph_pool_handle() |
| 423 | + captured = [] |
| 424 | + forked_on = set() |
| 425 | + for num_tokens in (4, 8): |
| 426 | + hidden = torch.ones(num_tokens, 16, device=self.device) |
| 427 | + out = torch.empty_like(hidden) |
| 428 | + out.copy_(self._fork_and_join(stub, hidden)) |
| 429 | + torch.cuda.synchronize() |
| 430 | + |
| 431 | + graph = torch.cuda.CUDAGraph() |
| 432 | + with torch.cuda.graph(graph, pool=pool): |
| 433 | + out.copy_(self._fork_and_join(stub, hidden)) |
| 434 | + forked_on.add((id(stub.shared_expert_stream), id(stub.shared_expert_ready))) |
| 435 | + captured.append((graph, hidden, out)) |
| 436 | + |
| 437 | + self.assertEqual(len(forked_on), 1, "captures used different stream/event") |
| 438 | + |
| 439 | + for index, (graph, hidden, _) in enumerate(captured): |
| 440 | + hidden.fill_(index + 2.0) |
| 441 | + graph.replay() |
| 442 | + torch.cuda.synchronize() |
| 443 | + |
| 444 | + for index, (_, _, out) in enumerate(captured): |
| 445 | + torch.testing.assert_close( |
| 446 | + out, torch.full_like(out, (index + 2.0) * self.SHARED_FACTOR) |
| 447 | + ) |
291 | 448 |
|
292 | 449 |
|
293 | 450 | if __name__ == "__main__": |
|
0 commit comments