Skip to content

Commit afdeb08

Browse files
committed
perf: overlap shared expert with routed branch on ep decode
1 parent 82dcb79 commit afdeb08

7 files changed

Lines changed: 355 additions & 32 deletions

File tree

rtp_llm/models_py/model_desc/generic_moe.py

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from typing import Any, Dict, Optional
2+
from typing import Any, Dict, Optional, Tuple
33

44
import torch
55
from torch import nn
@@ -33,6 +33,24 @@
3333

3434
logger = logging.getLogger(__name__)
3535

36+
# One shared-expert side stream per device, reused by every MoE layer. It has to
37+
# exist before the first captured forward, because a captured fork records an edge
38+
# to an already-running stream, and reusing one avoids per-forward churn.
39+
_SHARED_EXPERT_STREAMS: Dict[int, torch.cuda.Stream] = {}
40+
41+
42+
def _ensure_shared_expert_stream(
43+
device: torch.device,
44+
) -> Optional[torch.cuda.Stream]:
45+
if device.type != "cuda" or not torch.cuda.is_available():
46+
return None
47+
index = device.index if device.index is not None else torch.cuda.current_device()
48+
stream = _SHARED_EXPERT_STREAMS.get(index)
49+
if stream is None:
50+
stream = torch.cuda.Stream(device=torch.device("cuda", index))
51+
_SHARED_EXPERT_STREAMS[index] = stream
52+
return stream
53+
3654

3755
class GenericMoeLayer(nn.Module):
3856
"""Generic MoE layer supporting both Qwen3 and internal model."""
@@ -131,18 +149,17 @@ def __init__(
131149
# use_ep_unified_allreduce routed partial, scattered \
132150
# shared partial / add -> all_reduce
133151
#
134-
# these two differ only in how routed becomes a full-width partial:
135-
# pure TP leaves it that way, EP scatter-adds this rank's token slice
152+
# pure TP leaves routed a full-width partial; EP has to scatter-add
153+
# this rank's token slice to get one.
136154
#
137155
# two collectives
138156
# use_ep_shared_allreduce routed complete via all_gather \
139157
# shared partial -> all_reduce / add
140158
#
141-
# no flag, ffn_tp_size > 1 routed complete \
142-
# shared complete / add
143-
#
144-
# no collective
145-
# no flag, ffn_tp_size == 1 routed and shared already complete -> add
159+
# no flag: each branch completes on its own, then a local add. The
160+
# router reduces or gathers according to its own tp size, which follows
161+
# the attention view and is independent of ffn_tp_size; DenseMLP
162+
# all-reduces the shared output unless ffn_tp_size == 1.
146163
#
147164
# counts are Group.TP only; the EP all_to_all is not included
148165
self.use_ep_unified_allreduce = (
@@ -165,6 +182,14 @@ def __init__(
165182
and self.ffn_tp_size == router_tp_size
166183
and router.supports_skip_tp_allreduce
167184
)
185+
# The shared branch has no data dependency on the routed branch, so it
186+
# can run on a side stream alongside the whole routed chain.
187+
self.shared_expert_stream: Optional[torch.cuda.Stream] = None
188+
self.shared_expert_ready: Optional[torch.cuda.Event] = None
189+
if self.use_ep_unified_allreduce:
190+
self.shared_expert_stream = _ensure_shared_expert_stream(self.w1.device)
191+
if self.shared_expert_stream is not None:
192+
self.shared_expert_ready = torch.cuda.Event()
168193
if logger.isEnabledFor(logging.DEBUG):
169194
logger.debug(
170195
"GenericMoE unified TP all-reduce %s, EP unified all-reduce %s "
@@ -204,6 +229,41 @@ def _gate_shared_expert_output(
204229
return torch.sigmoid(gate_output) * shared_expert_output
205230
return shared_expert_output
206231

232+
def _shared_expert_partial(
233+
self, hidden_states: torch.Tensor
234+
) -> Tuple[torch.Tensor, Optional[torch.cuda.Event]]:
235+
"""Produce the gated TP-partial shared output for use_ep_unified_allreduce.
236+
237+
Returns the buffer plus, when the work was issued on the side stream,
238+
the event the router must wait on before accumulating into it.
239+
"""
240+
assert self.shared_expert is not None
241+
if self.shared_expert_stream is None:
242+
return (
243+
self._gate_shared_expert_output(
244+
hidden_states,
245+
self.shared_expert(hidden_states, skip_allreduce=True),
246+
),
247+
None,
248+
)
249+
250+
stream = self.shared_expert_stream
251+
current = torch.cuda.current_stream(hidden_states.device)
252+
stream.wait_stream(current)
253+
# record_stream stops the allocator from handing these blocks to a later
254+
# allocation once their references drop, while the side stream still
255+
# reads them.
256+
hidden_states.record_stream(stream)
257+
with torch.cuda.stream(stream):
258+
shared_partial = self._gate_shared_expert_output(
259+
hidden_states,
260+
self.shared_expert(hidden_states, skip_allreduce=True),
261+
)
262+
shared_partial.record_stream(current)
263+
assert self.shared_expert_ready is not None
264+
self.shared_expert_ready.record(stream)
265+
return shared_partial, self.shared_expert_ready
266+
207267
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
208268
num_tokens, _ = hidden_states.shape
209269
router_logits = self.gate(hidden_states)
@@ -250,17 +310,29 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
250310

251311
if self.use_ep_unified_allreduce:
252312
assert self.shared_expert is not None
253-
row_scatter_target = self._gate_shared_expert_output(
254-
hidden_states,
255-
self.shared_expert(hidden_states, skip_allreduce=True),
256-
)
257-
experts_output = self.fused_moe(
258-
hidden_states=hidden_states,
259-
topk_weights=topk_weights,
260-
topk_ids=topk_ids,
261-
activation="SiGLU",
262-
row_scatter_target=row_scatter_target,
313+
# Issued before fused_moe and on a side stream, so the shared branch
314+
# overlaps the routed chain instead of delaying it.
315+
row_scatter_target, shared_ready = self._shared_expert_partial(
316+
hidden_states
263317
)
318+
try:
319+
experts_output = self.fused_moe(
320+
hidden_states=hidden_states,
321+
topk_weights=topk_weights,
322+
topk_ids=topk_ids,
323+
activation="SiGLU",
324+
row_scatter_target=row_scatter_target,
325+
row_scatter_ready=shared_ready,
326+
)
327+
except Exception:
328+
# The router joins on the success path. When the routed chain
329+
# raises instead, the side stream is still writing the buffer,
330+
# so anything the caller does after unwinding would race with it.
331+
if shared_ready is not None:
332+
torch.cuda.current_stream(hidden_states.device).wait_event(
333+
shared_ready
334+
)
335+
raise
264336
return all_reduce(experts_output, group=Group.TP, inplace=True)
265337

266338
# In pure-TP mode both the routed experts and the shared expert produce
@@ -292,9 +364,9 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
292364
)
293365
experts_output = all_reduce(experts_output, group=Group.TP)
294366
elif self.use_ep_shared_allreduce:
295-
# Skipping DenseMLP's reduce to redo it here saves no collective;
296-
# it just puts the gate ahead of the reduction, which is
297-
# equivalent for a rank-consistent gate.
367+
# The router already reassembled the routed output with an
368+
# all_gather, so it is complete and cannot join a merged
369+
# reduction; only the shared branch still needs one.
298370
shared_expert_output = self._gate_shared_expert_output(
299371
hidden_states, shared_expert_output
300372
)

rtp_llm/models_py/model_desc/test/generic_moe_allreduce_test.py

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
1-
"""CPU contract tests for GenericMoeLayer's unified TP all-reduce path."""
1+
"""Contract tests for GenericMoeLayer's unified TP all-reduce paths.
22
3+
Mostly CPU tensors; the side-stream shared expert needs a real CUDA graph.
4+
"""
5+
6+
import inspect
7+
from functools import partial
38
from types import SimpleNamespace
4-
from unittest import TestCase, main
9+
from unittest import TestCase, main, skipUnless
510
from unittest.mock import MagicMock, Mock, patch
611

712
import torch
813

914
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+
)
1120
from rtp_llm.models_py.modules.factory.fused_moe.defs.fused_moe import FusedMoe
1221
from rtp_llm.models_py.modules.hybrid.dense_mlp import DenseMLP
1322
from rtp_llm.utils.model_weight import W
1423

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+
1529

1630
def _make_layer(
1731
*,
@@ -288,6 +302,149 @@ def test_row_scatter_reduces_both_branches_once(self, mock_all_reduce):
288302
torch.testing.assert_close(result, expected_input * 2)
289303
self.assertTrue(layer.shared_expert.call_args.kwargs["skip_allreduce"])
290304
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+
)
291448

292449

293450
if __name__ == "__main__":

0 commit comments

Comments
 (0)