Skip to content

moe: add DeepEP V2 ElasticBuffer support to MoE token dispatcher - #24443

Open
dmvevents wants to merge 11 commits into
sgl-project:mainfrom
dmvevents:deepep-v2-elasticbuffer-support
Open

moe: add DeepEP V2 ElasticBuffer support to MoE token dispatcher#24443
dmvevents wants to merge 11 commits into
sgl-project:mainfrom
dmvevents:deepep-v2-elasticbuffer-support

Conversation

@dmvevents

@dmvevents dmvevents commented May 5, 2026

Copy link
Copy Markdown

Motivation

DeepEP V2 (deepseek-ai/DeepEP#605, merged 2026-04-29) introduces ElasticBuffer alongside the legacy Buffer. SGLang's MoE token dispatcher (python/sglang/srt/layers/moe/token_dispatcher/deepep.py) only knows how to build and drive the V1 Buffer, so there is currently no way to reach the V2 path from SGLang without monkeypatching DeepEPBuffer.get_deepep_buffer() at runtime.

The V2 API differs from V1 in ways that touch the whole dispatch/combine contract, not just the constructor:

  • MoE-shape ctor. ElasticBuffer(group, num_max_tokens_per_rank, hidden, num_topk, ...) instead of V1's NVL/RDMA byte-pool ctor.
  • No get_dispatch_layout(). V2 infers the layout internally from topk_idx, so the V1 pre-pass is gone at the call site.
  • 5-tuple dispatch. Dispatch returns (recv_x, recv_topk_idx, recv_topk_weights, EPHandle, event); num_recv_tokens_per_expert_list now lives on the EPHandle, not in the return tuple.
  • Typed event. The pybind requires deep_ep._C.EventHandle for previous_event; passing a V1 Buffer.capture() EventOverlap raises TypeError.
  • combine signature. V2 combine drops the V1 config kwarg and reuses the dispatch handle's SM count.

deep_ep.__init__ on a V2 install exports both Buffer (from buffers/legacy.py) and ElasticBuffer (from buffers/elastic.py), so SGLang's existing from deep_ep import Buffer, Config surface keeps working unchanged. This PR adds the missing consumer-side plumbing so ElasticBuffer is reachable and correct.

Parallel upstream work adopts the same HAVE_DEEP_EP_V2 probe shape, and this PR mirrors it so the frameworks stay consistent:

Modifications

All changes are in python/sglang/srt/layers/moe/token_dispatcher/deepep.py plus its unit test. The V2 path is opt-in behind SGLANG_DEEPEP_USE_V2=1; with it unset (or on a pre-V2 deep_ep install) the V1 Buffer path runs byte-identical to before.

  1. Probe + opt-in buffer build. A try: from deep_ep import ElasticBuffer probe sets have_deepep_v2. get_deepep_buffer() builds an ElasticBuffer when V2 is importable and the env var is set, otherwise the legacy Buffer. clean_buffer() skips clean_low_latency_buffer when the live object is an ElasticBuffer (V2 has no equivalent).

  2. Call-site translation in _dispatch_core / _combine_core (b65c871). Drops the V1 get_dispatch_layout() pre-pass; unpacks the 5-tuple and reads num_recv_tokens_per_expert_list off the EPHandle; re-derives the typed EventHandle via the elastic buffer's capture() for previous_event; drops the V1 config kwarg on combine. Mirrors the Megatron fused_a2a V2 path.

  3. Cache-slot fix (0bb2713). The V2 branch stored the buffer on cls._buffer, but the cache guard reads state.buffer — so every _get_buffer() (twice per MoE layer per forward) constructed and tore down a fresh ElasticBuffer (NCCL comm split + GIN ring alloc + QP setup, ~0.35 s each). On a 2-node TP16/EP16 run this was ~34 s/token of pure constructor churn. Caching to state.buffer fixes it (this bug ships inside this PR's earlier commit; the fix is in the same PR).

  4. num_max_tokens_per_rank sizing (b2959be). ElasticBuffer hard-asserts num_tokens <= num_max_tokens_per_rank on every dispatch (csrc/elastic/buffer.hpp:684). The env default (128) is tuned for the V1 low-latency decode batch; in normal mode a chunked-prefill batch dispatches up to chunked_prefill_size tokens at once. Two layers had to be fixed together: bound the ctor capacity by chunked_prefill_size / max_prefill_tokens, and pass num_max_tokens_per_rank=None at the dispatch call — because elastic.py resolves value_or(passed, ctor), so a passed decode value (128) silently overrides the prefill-sized ctor and re-trips the assert.

  5. Env-gated timing instrumentation (0bb2713, default-off). SGLANG_DEEPEP_V2_TIMING=1 wraps dispatch/combine in a cuda-synced _V2Timer that logs per-call and cumulative ms. Dead when unset.

  6. Hermetic unit test (84fd157). test/srt/test_deepep_v2_probe.py exercises the three probe states. The "neither installed" case now inserts a sys.modules['deep_ep'] = None sentinel so import deep_ep raises deterministically even on a host where deep_ep is genuinely installed (previously it only passed on CPU-only CI).

V2 fall-through matrix:

  • have_deepep_v2=False (pre-V2 install) → legacy Buffer, byte-identical to pre-patch.
  • have_deepep_v2=True, env unset → legacy Buffer, byte-identical. V2 latent.
  • have_deepep_v2=True, SGLANG_DEEPEP_USE_V2=1 → new ElasticBuffer path.
  • use_deepep=False → unchanged; module still imports.

Accuracy Tests

Validated end-to-end on 2× p5en.48xlarge (16× H200), TP16/EP16, --moe-a2a-backend deepep --deepep-mode normal, Qwen3-30B-A3B-FP8, cross-node dispatch/combine over the DeepEP V2 ElasticBuffer path (SGLANG_DEEPEP_USE_V2=1 confirmed on all 16 ranks in the server log: constructing deep_ep.ElasticBuffer).

Coherent generation through the V2 path (greedy, temperature=0):

prompt : "The capital of France is"
output : " Paris. The capital of the United Kingdom is London."

/v1/models returns 200 on both nodes; the probe unit test passes 3/3 on a pod with deep_ep V2 installed (was 2 pass / 1 fail before the hermetic fix), and skips cleanly on CPU-only hosts.

Note on CI hardware: this validation ran on AWS EFA (proxy-GIN transport). SGLang's EP CI runs on NVLink/InfiniBand, where the V2 kernels are exercised by the same ElasticBuffer API. The default path is unchanged, so existing EP tests under test/registered/ep/ and test/manual/ep/ run on the V1 Buffer and are unaffected.

Speed Tests and Profiling

AIPerf sweep on the same 2-node TP16/EP16 serve, ISL 512 / OSL 128, streaming chat completions:

Concurrency TTFT mean (ms) ITL mean (ms) Request latency mean (ms) Requests
1 308.0 143.9 18,583 8
4 402.9 146.8 19,045 32
8 478.7 146.2 19,050 64

ITL is flat across concurrency (decode-bound per user; the all-to-all transport is not the bottleneck at these shapes), and the full sweep runs with 0 buffer asserts and the server alive throughout — the sizing fix (modification #4) is what makes c=8 survive.

Kernel-level profiling with the shipped SGLANG_DEEPEP_V2_TIMING=1 instrumentation (reproducible from this PR): the V2 dispatch/combine kernels measure 7.2 ms / 6.6 ms p50 at TP16/EP16 cross-node. This is what surfaced the cache-slot bug (modification #3) — before the fix, wall time was ~34 s/token of constructor churn around ~14 ms of actual kernels; after, ~150 ms/token.

Checklist

  • Format your code according to the Format code with pre-commit. (pre-commit run --files python/sglang/srt/layers/moe/token_dispatcher/deepep.py — black-jupyter applied.)
  • Add unit tests according to the Run and add unit tests. (test/srt/test_deepep_v2_probe.py, hermetic across CPU-only and GPU hosts.)
  • Update documentation according to Write documentations. (No user-facing doc change; the path is opt-in and internal. Happy to add a note under EP docs if reviewers prefer.)
  • Provide accuracy and speed benchmark results. (Above — 2-node TP16/EP16 EFA, coherent output + AIPerf matrix + kernel p50.)
  • Follow the SGLang code style guidance.

Related

/cc @zhyncs @merrymercy @Ying1123


CI States

Latest PR Test (Base): ❌ Run #32297147893
Latest PR Test (Extra): ❌ Run #32297146926

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for DeepEP V2 by adding a probe for ElasticBuffer and an opt-in path gated by the SGLANG_DEEPEP_USE_V2 environment variable. It includes the implementation of _build_v2_buffer and a new unit test to verify the probe logic. Feedback includes suggestions to explicitly define ElasticBuffer as None on import failure for better static analysis, centralize the new environment variable within the Envs class, and remove an unused parameter in the _build_v2_buffer method.

Comment on lines +63 to +68
try:
from deep_ep import ElasticBuffer

have_deepep_v2 = True
except ImportError:
have_deepep_v2 = False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve code robustness and assist static analysis tools, it is recommended to explicitly define ElasticBuffer as None in the except block. This ensures the symbol is always present in the module namespace, even if the import fails.

Suggested change
try:
from deep_ep import ElasticBuffer
have_deepep_v2 = True
except ImportError:
have_deepep_v2 = False
try:
from deep_ep import ElasticBuffer
have_deepep_v2 = True
except ImportError:
ElasticBuffer = None
have_deepep_v2 = False

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied — the except ImportError block sets ElasticBuffer = None explicitly at the current head (9ca5af3). This thread can be resolved.

Comment on lines +191 to +192
if have_deepep_v2 and get_bool_env_var(
"SGLANG_DEEPEP_USE_V2", default="false"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The environment variable SGLANG_DEEPEP_USE_V2 should be added to the Envs class in python/sglang/srt/environ.py to maintain consistency with other DeepEP configurations and leverage the centralized environment management system. Using envs.SGLANG_DEEPEP_USE_V2.get() is preferred over direct calls to get_bool_env_var.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in c3ea3a6: SGLANG_DEEPEP_USE_V2 and SGLANG_DEEPEP_V2_TIMING are both registered in the Envs class (next to the existing SGLANG_DEEPEP_* entries in environ.py) and read via envs.<NAME>.get() at both call sites. This thread can be resolved.

cls,
group: dist.ProcessGroup,
hidden_size: int,
param_bytes: int,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The parameter param_bytes is passed to _build_v2_buffer but is not utilized within the method body. It should be removed from the function signature to improve code clarity and maintainability.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closing the loop in-thread: this was applied — _build_v2_buffer no longer takes param_bytes (V2's MoE-shape ctor doesn't need it; the V1 path still uses it for its byte-size hints). This thread can be resolved.

dmvevents added a commit to antonai-work/sglang-deepep-v2-efa that referenced this pull request May 7, 2026
* Add minimal SGLang overlay for Wave 29 smoke

Scaffold-only Dockerfile lacked pip install; would produce base-only image.
Adding minimal overlay:
- pip install sglang[all]==0.5.6.post2
- torch/nccl/nvshmem pin guards matching v0.2.5 base

Wave 29 will materialize full adapter (sgl-project/sglang#24443) + JIT cache.

* Add CodeBuild buildspec + pins.env + minimal overlay

Wave 29 prep:
- Minimal SGLang 0.5.6.post2 overlay (full adapter TBD)
- CodeBuild buildspec + pins.env matching sibling pattern
- NCCL/NVSHMEM pin guards for v0.2.5 base

Unblocks Plan A Wave 29 cross-node smoke.

---------

Co-authored-by: Anton Alexander <dmvevents@users.noreply.github.com>
dmvevents added a commit to dmvevents/sglang that referenced this pull request May 8, 2026
The pre-commit lint job flagged two files that exceeded black's
default 88-character line limit on multi-line if/assignment
continuations. Collapsed them back onto single lines to match what
black reformats them to:

  - python/sglang/srt/layers/moe/token_dispatcher/deepep.py
  - test/srt/test_deepep_v2_probe.py

No logic changes. Addresses lint check failure on PR sgl-project#24443.

Signed-off-by: Anton Alexander <dmvevents@users.noreply.github.com>
@dmvevents

Copy link
Copy Markdown
Author

Triage of the 9 red checks (from check-run logs on head f66ab63):

Check Root cause Status
lint black-jupyter wanted 3 multi-line if/assign collapsed to single lines (line-length only) Fixed in ecb4b0eblack --check is now clean on both files
pr-gate / pr-gate x3 sglang pr-gate.yml intentionally fails with PR is draft. Blocking CI. Expected behavior for a draft PR — resolves automatically when the PR is moved from Draft to Ready for review
call-gate / pr-gate x3 Same draft-blocking gate invoked from call-gate.yml Same — resolves on "Ready for review"
pr-test-finish Aggregator; reports call-gate: failure Transitively resolves once the gates pass
pr-test-amd-finish Aggregator; same Same

What actually needed a code change: 1 of 9 (lint). The other 8 are all symptoms of the PR being in Draft state — sglang's CI policy is to short-circuit every downstream check until the PR is marked ready. Keeping this in Draft until we get reviewer signal on the V2 opt-in design, but the lint fix is landed so the CI will be fully green the moment this flips to Ready.

Diff for the lint fix is purely formatting (-9 / +3 lines, 2 files, no logic change):

  • python/sglang/srt/layers/moe/token_dispatcher/deepep.py — collapsed get_bool_env_var call
  • test/srt/test_deepep_v2_probe.py — collapsed two multi-line continuations

@dmvevents

Copy link
Copy Markdown
Author

Agree on both. Pushing:

  1. deepep.py:68 — explicit ElasticBuffer = None in the except ImportError branch.
  2. deepep.py:279 — removing param_bytes from _build_v2_buffer (the V2 ElasticBuffer ctor derives buffer size from num_max_tokens_per_rank × hidden × num_topk, so the arg was never referenced in the body).

Single follow-up commit on the same branch.

@dmvevents

Copy link
Copy Markdown
Author

Pushed f9f58ed on deepep-v2-elasticbuffer-support:

  1. deepep.py:68ElasticBuffer = None in the except branch
  2. deepep.py:279 — dropped unused param_bytes from _build_v2_buffer

CI should pick it up automatically.

Comment on lines 745 to 760
packed_recv_hidden, self.packed_recv_count, self.handle, event, hook = (
buffer.low_latency_dispatch(
hidden_states,
topk_ids,
self.num_max_dispatch_tokens_per_rank,
self.num_experts,
use_fp8=use_fp8,
**(dict(use_nvfp4=True) if use_nvfp4 else dict()),
**(
dict(x_global_scale=input_global_scale)
if input_global_scale is not None
else dict()
),
async_finish=not self.return_recv_hook,
return_recv_hook=self.return_recv_hook,
**fp8_deepgemm_scale_opts,

@zeroRains zeroRains Jun 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ElasticBuffer does not have a low_latency_dispatch method.

I don't think that simply changing the construction of a buffer will allow direct use of DeepEP V2.

V2 allows a hybrid mode, which can uniformly use dispatch method to handle normal and low-latency calls.

Are there any other updates?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — V2 only exposes dispatch(); low_latency_dispatch() does not exist on ElasticBuffer. This PR is the probe + opt-in ctor only — the dispatch call sites at line 565 and line 746 are still V1 and unchanged. The PR body explicitly defers full V2 dispatch/combine wiring as a follow-up ("not yet end-to-end validated in this PR"), and the PR is in Draft for that reason. Full V2 dispatch path (single unified dispatch() covering both normal and low-latency, new 5-tuple return, EPHandle lifetime for low-latency cleanup) lands in a follow-up once this probe is in. Thanks for catching it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting my earlier reply here — it's stale on both points and your underlying objection was right. The PR is no longer draft (ready since Aug 4), and it no longer stops at the constructor: the V2 dispatch and combine call sites are now wired through ElasticBuffer.dispatch()/ElasticBuffer.combine() behind SGLANG_DEEPEP_USE_V2=1 (V2 infers layout internally, so there is no low_latency_dispatch on it by design — the dispatcher's V2 branches call the V2 API, not the V1 method names). Top-k sizing also now threads the model's real router_topk instead of 0. The branch is rebased onto current main (past the check-changes required base), which also postdates the new deepep_v2 A2A backend (#29525) — this PR is the narrower, opt-in path on the classic dispatcher and doesn't touch that backend; happy to reconcile the two if a maintainer prefers.

@dmvevents
dmvevents force-pushed the deepep-v2-elasticbuffer-support branch from 20af19d to d2d9df4 Compare August 2, 2026 19:44
dmvevents added a commit to dmvevents/sglang that referenced this pull request Aug 2, 2026
The pre-commit lint job flagged two files that exceeded black's
default 88-character line limit on multi-line if/assignment
continuations. Collapsed them back onto single lines to match what
black reformats them to:

  - python/sglang/srt/layers/moe/token_dispatcher/deepep.py
  - test/srt/test_deepep_v2_probe.py

No logic changes. Addresses lint check failure on PR sgl-project#24443.

Signed-off-by: Anton Alexander <dmvevents@users.noreply.github.com>
@dmvevents
dmvevents force-pushed the deepep-v2-elasticbuffer-support branch from d2d9df4 to 84fd157 Compare August 3, 2026 00:42
dmvevents added a commit to dmvevents/sglang that referenced this pull request Aug 3, 2026
The pre-commit lint job flagged two files that exceeded black's
default 88-character line limit on multi-line if/assignment
continuations. Collapsed them back onto single lines to match what
black reformats them to:

  - python/sglang/srt/layers/moe/token_dispatcher/deepep.py
  - test/srt/test_deepep_v2_probe.py

No logic changes. Addresses lint check failure on PR sgl-project#24443.

Signed-off-by: Anton Alexander <dmvevents@users.noreply.github.com>
dmvevents added a commit to dmvevents/sglang that referenced this pull request Aug 4, 2026
The pre-commit lint job flagged two files that exceeded black's
default 88-character line limit on multi-line if/assignment
continuations. Collapsed them back onto single lines to match what
black reformats them to:

  - python/sglang/srt/layers/moe/token_dispatcher/deepep.py
  - test/srt/test_deepep_v2_probe.py

No logic changes. Addresses lint check failure on PR sgl-project#24443.

Signed-off-by: Anton Alexander <dmvevents@users.noreply.github.com>
@dmvevents
dmvevents force-pushed the deepep-v2-elasticbuffer-support branch from b2959be to 43d6310 Compare August 4, 2026 03:16
@dmvevents
dmvevents marked this pull request as ready for review August 4, 2026 17:00
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@dmvevents

Copy link
Copy Markdown
Author

This PR is now marked ready for review — the branch carries measured 2-node TP16/EP16 evidence (accuracy + AIPerf sweep in the description).

pr-gate is currently blocking on the missing run-ci label. Could one of the merge oncalls tag-and-rerun CI when convenient? I don't have tag permission per CI_PERMISSIONS. Thanks! @zhyncs @merrymercy @Ying1123

@dmvevents

Copy link
Copy Markdown
Author

/rerun-failed-ci

markw14 pushed a commit to markw14/sglang that referenced this pull request Aug 7, 2026
Adds an opt-in DeepEP V2 code path alongside the existing V1 Buffer, based on
PR sgl-project#24443, plus the fixes required to make it run on H100 + RoCE.

1. Warm the NCCL communicator before construction.
   torch creates the ncclComm_t LAZILY, on first collective. ElasticBuffer takes
   the raw handle (elastic.py:301) straight into C++ (elastic.py:306
   calculate_elastic_buffer_size); on a group that has never carried a collective
   the handle is null and C++ dereferences it -> SIGSEGV. DeepEP own
   tests/elastic/test_ep.py never hits this because init_dist runs collectives
   during setup, which is why identical buffer parameters work there and crash
   here. One dummy all_reduce fixes it.

2. Pass the real num_topk instead of 0.
   PR sgl-project#24443 passes num_topk=0, which V2 defaults to 32
   (csrc/elastic/buffer.hpp:663), sizing the buffer for top-32 on a top-8 model
   -- about 4x (3.5 vs 0.9 GiB per rank).

3. Optional explicit SM count via SGLANG_DEEPEP_V2_NUM_SMS, default 0.
   0 is DeepEP own default and is already correct: dispatch resolves it with
   get_theoretical_num_sms (elastic.py:928) and combine reuses the dispatch
   handle (elastic.py:1086). The knob exists only for SM sweeps.

Requires --enable-symm-mem: SGLang derives NCCL_CUMEM_ENABLE from it
(entrypoints/engine.py:1222) and NCCL gates symmetric memory on cuMem
(init.cc:1674). Without it ncclDevCommCreate fails with
'Communicator does not support symmetric memory'.

Validated: DeepSeek-V3, TP32/DP32 with DP attention, 2 and 4 nodes, H100 +
8x400G RoCE, NCCL 2.30.7, DeepEP epv2-release. Serving only -- this branch makes
no throughput claim; see the note below.

NOTE ON SCOPE: upstream already has three open PRs for V2 support (sgl-project#24443,
sgl-project#29402, sgl-project#29525). This branch is a working integration for our cluster, not a
competing proposal. The num_topk finding in (2) is the part worth upstreaming,
as a review comment on sgl-project#24443.
@markw14

markw14 commented Aug 7, 2026

Copy link
Copy Markdown

num_topk = 0 makes DeepEP size the buffer for top-32 regardless of the model's actual top-k.

In csrc/elastic/buffer.hpp:

// NOTES: there are lots of `kNumTopk <= 32` restrictions, so we use 32 to calculate token size
num_topk = num_topk == 0 ? 32 : num_topk;

So passing 0 does not select a conservative-but-proportional hint — it pins the token layout to top-32. For DeepSeek-V3 (top-8) that oversizes the top-k-dependent portion of the dispatch/combine buffers by 4x.

For a concrete anchor, with EP_BUFFER_DEBUG=1 on 2 nodes / 16 ranks, num_max_tokens_per_rank=4096, hidden=7168, BF16 dispatch, passing the real num_topk=8 reports:

Initializing EP elastic buffer with 1218445312 bytes   (1.13 GiB/rank)

I have not measured the num_topk=0 variant at the same shape, so I am not quoting a ratio for the total allocation — but the 32-vs-8 factor applies to the per-token layout, and the buffer also scales with num_ranks, so the absolute cost grows with EP size.

The dispatcher does know the value: the TopKOutput carries it, and topk_idx.shape[1] is what dispatch() itself uses to auto-resolve SM count (deep_ep/buffers/elastic.py:928). Threading it into the constructor instead of 0 would avoid the oversizing without changing behaviour.

@dmvevents

Copy link
Copy Markdown
Author

@markw14 You're right, and the comment in my code was wrong about what 0 does — V2 pins the layout to the top-32 ceiling (num_topk = num_topk == 0 ? 32 : num_topk), it does not derive a proportional hint. Thanks for the EP_BUFFER_DEBUG anchor.

Fixed in 9ca5af3: the router's top-k is fixed per model and already known at dispatcher construction, so router_topk now threads from _DeepEPDispatcherImplBase through get_deepep_buffer() into _build_v2_buffer() and lands in the ElasticBuffer ctor. 0 remains the conservative ceiling only when a caller can't provide it, and the misleading comment is corrected. The V1 path ignores the new parameter, so the default path stays byte-identical.

I went with construction-time plumbing rather than topk_idx.shape[1] at first dispatch because the buffer is constructed (and cached) before the first TopKOutput exists on this path — same value, available earlier.

On the red checks: the previous head's 12 failures were all aggregator jobs (*-finish, pr-gate, check-changes) with no leaf test failing — same infra pattern as triaged earlier in this thread. CI should re-run on the new head.

@dmvevents

Copy link
Copy Markdown
Author

Measured update: 4-node EP32 (TP32/EP32) serve PASS on the V2 path

Since the top-k sizing fix (9ca5af3, thanks @markw14), we've run this PR's SGLANG_DEEPEP_USE_V2=1 path at 4-node scale to check it holds beyond the 2-node validation in the PR body.

Setup (public substrate, reproducible)

Gates (all 4 nodes)

Gate Result
ElasticBuffer ctor banner 32/32 ranks
DeepEP EFA detection (capping num_allocated_qps 129 → 6) 8/8 per node
socket fallback lines 0
barrier timeouts during serve 0
Greedy E2E completion coherent ("capital of France → Paris. …")

AIPerf (ISL 512 / OSL 128, streaming chat)

Concurrency TTFT avg (ms) ITL avg (ms) Requests
4 463.2 165.1 32
8 525.2 166.9 64

(c=1 ran first against a cold server so its TTFT is warmup-contaminated; omitted. 2-node EP16 reference on the same harness: ITL ~146 ms — the +20 ms/token at EP32 is the wider all-to-all fan-out, consistent with dispatch micro-bench p50 632 µs @ep32 vs ~265 µs @EP16.)

Operational findings from the 32-rank bring-up (for reviewers / future users)

  1. First-dispatch barrier timeout at 32 ranks — per-scheduler DeepGEMM JIT warmup skew can exceed ElasticBuffer's runtime num_gpu_timeout_secs=100 default. For the run above we applied a small ctor tweak on top of this PR exposing SGLANG_DEEPEP_V2_GPU_TIMEOUT_S / _CPU_TIMEOUT_S (defaults preserved). Not in this PR yet — happy to push it as one more commit here or keep it as a follow-up, whichever reviewers prefer.
  2. SGL_ENABLE_JIT_DEEPGEMM=0 is not a viable workaround for FP8 MoE — it routes into the deprecated forward_deepgemm_contiguous hard-assert. DeepGEMM stays on.
  3. --max-running-requests must be ≥ world size at TP32 (decode warmup issues a world-sized batch; triton backend sizes kv_indptr from it). Serving-config note, no code change needed.

The V2 opt-in path in this PR is otherwise unchanged from what was reviewed — findings 2/3 are deployment notes, and finding 1 is the only candidate code delta.

Still blocked on the run-ci label for CI to run — would appreciate an oncall tagging it when convenient.

DeepEP V2 (deepseek-ai/DeepEP#605, merged 2026-04-29) introduces
`ElasticBuffer` alongside the legacy `Buffer`. Both classes are
exported from `deep_ep.__init__`, so SGLang's existing V1 code path
continues to work unchanged on a V2 install.

This patch adds a second import probe (`have_deepep_v2`) next to the
existing `use_deepep` flag, then lets users opt in to the V2
`ElasticBuffer` ctor behind `SGLANG_DEEPEP_USE_V2=1`. The V1 path is
byte-identical when the env var is unset, so this is a backwards-
compatible addition.

V2 collapses the V1 NVL/RDMA byte-pool ctor into a single MoE-shape
ctor and derives the internal buffer size from `num_max_tokens_per_rank`,
`hidden`, and `num_topk`. `num_allocated_qps=0` asks V2 to auto-size
and auto-cap the Queue-Pair budget on AWS EFA (the 128-slot GIN ring
ceiling; see `_is_efa_fabric` in `deep_ep/buffers/elastic.py`).

Why:
- Removes the need for users to monkeypatch `deep_ep.Buffer` to reach
  V2 semantics, and mirrors the probe shape already used in
  NVIDIA/Megatron-LM's `fused_a2a.py` (`HAVE_DEEP_EP_V2`). Opt-in
  env-var gate keeps the change infra-bump-shaped, not a default
  switch flip.
- vLLM is landing a parallel native-V2 path in
  vllm-project/vllm#41183 (16 commits, active review). SGLang users
  running mixed-framework MoE stacks benefit from having V2 reachable
  in SGLang without a runtime monkeypatch.

V2 parity rules baked in:
- `num_max_tokens_per_rank` sourced from
  `envs.SGLANG_DEEPEP_NUM_MAX_DISPATCH_TOKENS_PER_RANK` when the
  caller did not pin it (matches the V1 low-latency path).
- `num_topk=0` at ctor time: DeepEP V2 accepts this and derives a
  conservative group-size-based hint, so we don't need to know the
  router topk at buffer construction.
- `clean_buffer()` skips the legacy `clean_low_latency_buffer` call
  when the underlying buffer is an ElasticBuffer (no equivalent on
  V2, per `deep_ep/buffers/elastic.py`).

Related:
- DeepEP V2 PR: deepseek-ai/DeepEP#605 (merged 2026-04-29)
- vLLM DeepEP V2: vllm-project/vllm#41183 (open)
- Megatron-LM DeepEP V2: NVIDIA/Megatron-LM#4632 (open)
Adds a lightweight guard for the `have_deepep_v2` / `use_deepep`
probe pattern introduced in the previous commit. Exercises the
three reachable states:

  - V1 `Buffer` only installed (legacy path)
  - V2 `ElasticBuffer` + `Buffer` both exported (typical V2 install)
  - `deep_ep` missing entirely

The test uses a synthetic `deep_ep` module (via sys.modules monkey
patch) so it runs on any CPU host without CUDA, NCCL, or EFA. The
test is a `skipTest`-tolerant guard: when the SGLang module stack
cannot be imported in the CI environment (for any upstream reason),
it skips rather than fails, so this does not introduce a new
dependency on a full SGLang install in the unit-test tier.
The pre-commit lint job flagged two files that exceeded black's
default 88-character line limit on multi-line if/assignment
continuations. Collapsed them back onto single lines to match what
black reformats them to:

  - python/sglang/srt/layers/moe/token_dispatcher/deepep.py
  - test/srt/test_deepep_v2_probe.py

No logic changes. Addresses lint check failure on PR sgl-project#24443.

Signed-off-by: Anton Alexander <dmvevents@users.noreply.github.com>
- deepep.py:68 — set ElasticBuffer = None in the except ImportError
  branch so static-analysis tools always see the symbol in module
  namespace (matches the have_deepep_v2 = False fallback shape).

- deepep.py:279 — drop unused param_bytes from _build_v2_buffer.
  DeepEP V2's ElasticBuffer ctor derives buffer size from
  num_max_tokens_per_rank * hidden * num_topk; the legacy V1
  byte-pool sizing is not used. The V1 path in get_buffer keeps
  param_bytes unchanged.
test_neither_installed asserted use_deepep is False, but the 'absent'
simulation only popped deep_ep from sys.modules. On any host where deep_ep
is genuinely installed (e.g. a GPU serving pod), the probe's 'import deep_ep'
re-imported the real package from site-packages, so use_deepep came back True
and the test failed. It only passed on CPU-only CI where deep_ep is absent.

Insert a sentinel sys.modules['deep_ep'] = None so 'import deep_ep' raises
ImportError deterministically on every host, matching the other two probe
states which already stub deep_ep explicitly. The finally-block pop + restore
already cleans the sentinel up.

Verified: unittest 3/3 OK on a cgk p5en pod with deep_ep V2 installed
(was 2 pass / 1 fail before this change).
…atcher

_dispatch_core: ElasticBuffer has no get_dispatch_layout — V2 dispatch infers
layout from topk_idx and returns (recv_x, recv_topk_idx, recv_topk_weights,
EPHandle, event) with num_recv_tokens_per_expert_list on the handle. Mirrors
the proven Megatron fused_a2a V2 translation.
_dispatch_core + _combine_core: V2 pybind requires deep_ep._C.EventHandle for
previous_event — re-derive via the elastic buffer capture() instead of passing
the V1 Buffer.capture() EventOverlap (TypeError otherwise). combine drops the
V1 config kwarg; num_sms=0 reuses the dispatch handle SM count.

Proven in-pod (2x p5en, proxy-Gin substrate): server up, ElasticBuffer on all
16 ranks, /v1/models 200. First-dispatch behavior still under live debug.
… timing

The V2 branch of get_deepep_buffer stored the buffer on cls._buffer, but the
cache guard reads state.buffer — so every _get_buffer() constructed a fresh
ElasticBuffer (NCCL comm split + ring alloc + QP setup, ~0.35s), twice per MoE
layer per forward. Measured on 2-node TP16/EP16 EFA: ~34s/token from ctor churn
while the actual V2 dispatch/combine kernels are 7.2ms/6.6ms p50 (SGLANG_DEEPEP_
V2_TIMING=1 instrumentation, included, default-off). Cache to state.buffer.
ElasticBuffer hard-asserts num_tokens <= num_max_tokens_per_rank on every
dispatch (csrc/elastic/buffer.hpp:684). The env default (128) targets the V1
low-latency decode path; in normal mode a chunked-prefill batch dispatches up
to chunked_prefill_size tokens at once — measured: concurrency 8 x ISL 512
aborts all 16 ranks on the assert. Size the V2 buffer to cover the prefill
chunk in normal mode.
pr-gate blocked all runs while the PR was draft; pull_request workflows
do not fire on ready_for_review, so a synchronize event is needed.
Review (markw14): passing num_topk=0 is not a proportional fallback --
DeepEP V2 pins the token layout to its top-32 ceiling
(csrc/elastic/buffer.hpp: `num_topk = num_topk == 0 ? 32 : num_topk`),
which oversizes the top-k-dependent regions of the dispatch/combine
buffers 4x for a top-8 model, and the absolute cost grows with
num_ranks.

The router's top-k is fixed per model and already known at dispatcher
construction, so thread router_topk from _DeepEPDispatcherImplBase
through get_deepep_buffer() into _build_v2_buffer() and pass it to the
ElasticBuffer ctor. 0 remains the conservative ceiling only when a
caller cannot provide it. The previous comment claiming V2 'falls back
to a group-size-based conservative hint' described behaviour that does
not exist and is corrected.

V1 path untouched (the new parameter is unused there), so the default
code path stays byte-identical.

Verified: py_compile clean; black --check and isort --check-only
(profile=black) clean.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
Both env vars now live in the Envs registry next to the existing
SGLANG_DEEPEP_* entries and are read via envs.<NAME>.get(), per repo
convention, instead of raw get_bool_env_var calls.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
@dmvevents
dmvevents force-pushed the deepep-v2-elasticbuffer-support branch from 9ca5af3 to c3ea3a6 Compare August 19, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants