Conversation
DeepEPv2Buffer.get_buffer() is only ever called from _DeepEPv2Impl.dispatch(), so the process-wide ElasticBuffer is constructed on the first dispatch -- which for a decode graph is inside torch.cuda.graph(). ElasticBuffer.__init__ reaches ncclDevCommCreate, which allocates, synchronises and calls into the driver, all illegal during stream capture, so CUDA invalidates the capture and NCCL reports its generic backend failure. Add a third _pre_initialize_* slot in BaseRunner.warmup(), alongside the flashinfer allreduce and fi_a2a workspaces that exist for exactly this reason, and a public DeepEPv2Dispatcher.prepare_buffer() for it to call.
whn09
requested review from
BBuf,
Edwardf0t1,
Fridge003,
HaiShaw,
Ying1123,
ch-wan,
hnyls2002,
ispobock and
merrymercy
as code owners
September 11, 2026 10:41
This was referenced Sep 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
DeepEPv2Buffer.get_buffer()is reached from exactly one place —_DeepEPv2Impl.dispatch()(
token_dispatcher/deepep_v2.py:329, and:405for combine). So the process-wideElasticBufferis constructed on the first dispatch, and for a decode CUDA graph thatfirst dispatch happens inside
torch.cuda.graph().ElasticBuffer.__init__reachesncclDevCommCreate(DeepEPcsrc/kernels/backend/nccl.cu:188), which allocates, synchronises and calls into the driver— none of it legal during stream capture. CUDA invalidates the capture and NCCL surfaces its
generic backend error:
That message names GIN, so it invites a GIN-tuning response, and it is not a GIN problem:
the identical construction with the identical arguments succeeds when it happens one moment
earlier, outside the capture. I lost about a day to this before instrumenting it.
Observed rather than argued. A standalone probe builds the same buffer with the same
arguments, once outside a capture and once inside, on 8× H200 with
TP=EP=8:--deepep-v2-mode direct,NCCL_GIN_TYPE=2cudaErrorStreamCaptureInvalidated--deepep-v2-mode hybrid,NCCL_GIN_TYPE=5cudaErrorStreamCaptureInvalidatedSo on this fabric it is neither the GIN type nor the mode — it is the ordering.
Scope, stated honestly, because it is narrower than "everyone hits this". Whether the
lazy build survives the capture depends on the GIN backend, and I have a counter-example of
my own: a single-node 8× B300 host on InfiniBand with
NCCL_GIN_TYPE=3and--deepep-v2-mode directcaptured decode graphs and served happily on an unpatched image(3 launches, capture 61.59 s / 18.48 GB pool). Same sglang, same lazy build, no failure. It
fails on EFA hosts — both the type-2 CPU proxy and type-5 GDAKI, single- and two-node.
So the honest claim is not "this always breaks", it is: the current code depends on the GIN
backend tolerating an allocating, synchronising, collective constructor during stream capture,
which CUDA does not promise anyone. Where it works today it works by luck, and the probe
above shows the graph is genuinely invalidated the moment you look. Every
_get_buffer()callsite in the tree is inside
dispatch()/combine(), so there is no configuration in which auser can get the buffer built early themselves.
And the one place this can fire is the one place the workaround is unavailable. For
deepep_v2, prefill never captures at all —disable_tc_piecewise_cudagraph_if_incompatibleturns off tc_piecewise for any
moe_a2a_backend != "none", anddisable_breakable_cudagraph_if_incompatiblelists("none", "deepep", "megamoe", "flashinfer")as validated, whichdeepep_v2is not. So the only capture in adeepep_v2server is the decode graph, and "just pass--disable-cuda-graph" means runninga decode role without graphs. Nobody deploys that: it is ~3× end-to-end on this model and
3.27× on the B300/IB host above, and it is every token, not just TTFT. SGLang's own error
text says as much ("Not recommended. Huge performance loss").
So on EFA this is not "a bug with a workaround" —
deepep_v2decode is unusable, and wherethe bug is currently latent the workaround is equally unacceptable.
Modifications
BaseRunner.warmup()gets a third pre-initialize slot,_pre_initialize_deepep_v2_elastic_buffer(), immediately after the two that already existfor precisely this reason —
_pre_initialize_flashinfer_allreduce_workspace()("must runbefore CG capture to keep broadcasts/barriers outside the capture context") and
_pre_initialize_fi_a2a_workspace()("must run before CG capture (it syncs the stream +barriers cross-rank, uncapturable)"). Same class of problem, same slot, so no new concept
is introduced.
warmup()is called from bothdecode_cuda_graph_runner.capture()andprefill_cuda_graph_runner.capture(), so one insertion covers both.It returns immediately unless
get_moe_a2a_backend().is_deepep_v2(), and walksmodel.modules()for the firstDeepEPv2Dispatcher. One call is both necessary andsufficient: the buffer is process-wide and its construction is collective, so every rank
must build exactly once, in lockstep.
New
DeepEPv2Dispatcher.prepare_buffer(), so the runner is not reaching through twoprivate attributes (
dispatcher._impl._get_buffer()).It delegates to
_impl._get_buffer()rather than re-deriving the constructor arguments,and that is the load-bearing detail:
get_buffer()caches on an exact 7-element key(group, hidden_size, router_topk, num_max_dispatch_tokens_per_rank, use_fp8_dispatch, allow_hybrid_mode, world_size). A pre-build assembled from recomputed arguments thatdiffer in any one element is a cache miss, so the dispatcher would go on to build a
second buffer inside the capture — reintroducing this exact bug while appearing to have
fixed it.
I also considered the existing
model.prepare_before_cuda_graph_capturehook (used byqwen3_5.py). It did not seem right here: that hook is for model-owned resources and wouldneed re-implementing per architecture, whereas the ElasticBuffer is owned by the dispatcher
and is process-wide. Happy to move it if you prefer.
Accuracy Tests
Before/after on 8× H200,
--moe-a2a-backend deepep_v2 --deepep-v2-mode hybrid,TP=EP=8,decode CUDA graphs enabled:
Capture cuda graph failed: NCCL exception (nccl.cu:188): 3at startupInitialized DeepEP v2 ElasticBuffer: ... num_bytes=...logged beforeCapture cuda graph begin, capture completes, server servesNumerics after the fix were checked separately against the same model on
--moe-a2a-backend deepep(v1) by comparing per-token logprobs on a fixed 12-prompt set:10 of 12 rows bit-identical, the 2 differing rows being the only two longer than the v2
arm's prefill chunk. Not part of this PR's claim — the change adds no math — but it is why I
am confident the pre-built buffer is the same buffer the dispatch path would have built.
I have not added a unit test. Reproducing it needs a real multi-rank
ElasticBuffer, a liveNCCL DevComm and an active capture, which is well outside what CI exercises today (nothing in
CI exercises
deepep_v2at all, which is also how #37211 stayed broken). If you would likethe standalone probe contributed as a manual/registered test I am glad to send it — it builds
the buffer twice and asserts the in-capture attempt raises, and it runs in ~30 s.
Speed Tests and Profiling
Not applicable in the usual sense: unpatched, the configuration cannot start. Against
--disable-cuda-graph, which is the only other way to run it, keeping decode graphs is worthroughly 3× end-to-end.
The pre-build itself is one collective allocation that used to happen anyway, moved earlier,
so steady-state performance is unchanged.
Checklist
Review and Merge Process
cc @MengYu10151 (a3ae667, #35634 — the DeepEPv2 ElasticBuffer backend). I cannot add the
run-cilabel or trigger CI myself, so this needs someone with write access to start it.Related: #37211 fixes the other thing that stops
deepep_v2from completing a forward pass(
ep_scatter_from_psummissing two kernel args) and is still open. The two are independent —this one is capture-time, that one is forward-time — but a
deepep_v2server needs both.