Skip to content

ep: graph-safe worst-token HT dispatch+combine (internode + intranode) - #18

Open
fergusfinn wants to merge 11 commits into
upstream-basefrom
feat/ht-cudagraph-worst-tokens
Open

ep: graph-safe worst-token HT dispatch+combine (internode + intranode)#18
fergusfinn wants to merge 11 commits into
upstream-basefrom
feat/ht-cudagraph-worst-tokens

Conversation

@fergusfinn

@fergusfinn fergusfinn commented Jun 25, 2026

Copy link
Copy Markdown

Summary

Makes the DeepEP high-throughput dispatch/combine path CUDA-graph capturable
and replay-safe when num_worst_tokens is set, across both the internode
(RDMA, multi-node) and intranode (NVLink, single-node) kernels. The
num_worst_tokens == 0 path is byte-for-byte unchanged.

Single-node EP (where num_rdma_ranks == 1) routes through the intranode
path; multi-node EP routes through the internode path. Both need the same
graph-safety treatment, so they are fixed together here.

Internode (internode.cu)

  1. Gate the host-counter handshake. notify_dispatch spin-waits on the host
    to reset moe_recv_*_counter_mapped to -1 then writes the sum — a per-call
    GPU↔Python handshake. Under graph replay the host never runs, so the 2nd
    replay deadlocks in the spin, and the mid-flight write clobbers a concurrent
    eager dispatch sharing the Buffer. Both are now gated on num_worst_tokens == 0.
  2. Device-side per-expert recv counts. The notify kernel also writes
    per-expert counts to a device int32 tensor, returned in place of the host
    list when num_worst_tokens > 0, so graph-mode consumers never touch the host.
  3. Real combine total via gbl_rank_prefix_sum. In worst-token mode
    num_tokens is the padded row count; combine now reads the true received total
    from the device prefix sum instead of shipping garbage tail tokens.

Intranode (intranode.cu)

The intranode notify_dispatch kernel wrote the host-pinned
moe_recv_counter/moe_recv_expert_counter unconditionally. The host reads
these only on the num_worst_tokens == 0 path, but the kernel still wrote them on
every call, including graph replays. The single host-pinned counter is shared by
every dispatch on a Buffer, so a graph-replayed (worst-token) decode notify's
counter write, landing mid-flight after a subsequent host-synced eager dispatch
reset the counter to -1 but before that eager dispatch's own kernel completes,
made the eager dispatch's CPU spin-wait return a stale num_recv_tokens.
Downstream this surfaced non-deterministically as a combine-receiver hang (waiting
on a tail for tokens never dispatched) or an illegal memory access once the
wrongly-sized receive buffer fed the MoE/attention path. num_worst_tokens is now
threaded into the intranode notify_dispatch and both counter writes are gated on
num_worst_tokens == 0, mirroring the internode fix.

Tests

  • test_ht_cudagraph.py — capture layout→dispatch→expert→combine in one CUDA
    graph; N replays with fresh routing each replay, validated against an analytic
    oracle and the eager path; plus eager/replay interleave on one Buffer (the vLLM
    prefill-eager + decode-graphed condition).
  • test_ht_ragged.py — serving-shaped ragged/pressure coverage.

Validation

DeepSeek-V4-Flash, deepep_high_throughput, full decode CUDA graphs, mixed
prefill+decode serving benchmark (random, ISL/OSL 1024/1024, --ignore-eos):

  • Single-node 4×GH200 (EP4): without the patch the engine dies after ~250–670
    requests with an illegal memory access; with it, the concurrency sweep
    1024→2048→4096 completes 2048/4096/8192 requests at 100%, across two waves and
    two fresh boots, graphs confirmed active, gsm8k strict-match parity with eager
    (0.977 vs 0.973).
  • 2-node EP8: 4096/4096 successful across two waves, graphs active, no faults.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6593bb8fa9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

explicitly_destroy=True,
)
config = buffer.get_dispatch_config(num_ranks)
combine_config = buffer.get_combine_config(num_ranks)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the fixed 8-rank combine config in ragged test

In the documented 2-node x 4-GPU run this test asserts num_ranks == 8, so this line selects Buffer.get_combine_config(8), which is Config(..., rdma_chunked_send_tokens=6, ...). For the 2-RDMA-rank internode combine path, ep/src/internode.cu:3114 asserts num_max_rdma_chunked_send_tokens >= num_warps_per_forwarder, and num_warps_per_forwarder is 8, so the first combine throws before the ragged/interleave coverage runs. Use the same Config(..., 8, ...) workaround already applied in test_ht_cudagraph.py.

Useful? React with 👍 / 👎.

fergusfinn pushed a commit that referenced this pull request Jun 25, 2026
@fergusfinn fergusfinn changed the title ep: graph-safe worst-token HT internode dispatch+combine ep: graph-safe worst-token HT dispatch+combine (internode + intranode) Jun 26, 2026
PanJason and others added 4 commits June 26, 2026 19:10
- restore the sync loop exit so BARRIER remains posted through one representative queue

- keep QUIET handling separate from BARRIER semantics for CXI timeout debugging
- widen quiet and barrier wr id storage so FIFO ring bits are not truncated

- keep the existing negative sentinel while preserving high 32-bit queue indices
- post QUIET to every CXI D2H queue so sibling proxy queues are drained

- track the posted queue index and wait on the exact queue for completion
fergusfinn and others added 7 commits July 3, 2026 13:26
Two fixes, both inert when num_worst_tokens == 0:

1. notify_dispatch spin-waits for the host to reset the host-mapped
   recv counters to -1 before writing its sums. Host code does not
   re-execute under CUDA graph replay, so the second replay deadlocks
   on the stale counter. Skip the wait when num_worst_tokens > 0; the
   writes stay unconditional.

2. The combine NVL sender bounds its last (rank, channel) slot by
   num_tokens, which under num_worst_tokens is the padded input row
   count, so it sends garbage tail tokens and hits flow-control
   timeouts. Plumb the dispatch handle's recv_gbl_rank_prefix_sum
   (device tensor) into the kernel and use its last element as the
   real total.

(cherry picked from commit a953b7b)
(cherry picked from commit f600196)
…mode

CUDA-graph consumers of num_worst_tokens dispatch cannot read the
host-mapped per-expert counters without a sync, and the host list is
deliberately skipped in that mode. Have notify_dispatch also write the
counts to an optional device int32[num_local_experts] buffer, and have
dispatch() return that tensor in the num_recv_tokens_per_expert_list
slot when num_worst_tokens > 0 (downstream: masked grouped GEMM reads
it directly inside the graph).

(cherry picked from commit d19ddf1)
(cherry picked from commit 2750905)
2-node test: captures layout+dispatch+expert-standin+combine into one
torch.cuda.CUDAGraph with num_worst_tokens, replays 50x with fresh data
and routing per replay, validates against the normal host-synced path,
an analytic oracle, and the device expert-counts tensor; also checks
eager/replay interleaving on one Buffer. Validated on Isambard
(GH200/CXI, 2x4 ranks): all phases pass.

(cherry picked from commit f52c2ac)
(cherry picked from commit 2fda61a)
The host-synced protocol (host writes -1, kernel waits for -1 then
writes its sum, host polls) is serialized by the host polls themselves.
A replayed worst-tokens graph contains many notify kernels writing sums
with no polls pacing them, and callers may issue the next host-synced
eager dispatch while the graph is still executing: the eager call's -1
reset lands mid-flight, a graph sum clobbers it, and the eager kernel's
wait-for--1 spins forever (observed as 'DeepEP error: timeout (dispatch
CPU)' on all ranks under mixed replay/eager serving load). Worst-tokens
mode has no use for the host counters - consumers read the device-side
counts - so gate the stores, not just the waits, on num_worst_tokens==0.

Adds ep/bench/test_ht_ragged.py: extreme-skew eager dispatch shapes
(including the observed serving wedge shape and zero-token ranks) and a
no-sync replay/eager alternation phase that reproduces the clobber
window organically.

(cherry picked from commit efa5a9f)
(cherry picked from commit 43fda0a)
Both HT cudagraph tests aborted (SIGABRT, exit 134) at interpreter exit
after all checks passed: buffer.destroy() tears down the CUDA context
while captured graphs and their static tensors are still alive, so the
caching allocator's deferred frees hit a destroyed context inside
TensorImpl destructors, which throw from a noexcept path.

Free every CUDA object first (drop refs / move phase locals into a
nested frame), gc + empty_cache + synchronize, then destroy the buffer
and the process group. Validated on 2x4 GH200: both tests now exit 0.

(cherry picked from commit c63447f)
(cherry picked from commit 211cc22)
…ispatch test

The graph-mode contract returns an int32 device tensor of per-local-expert
recv counts where the legacy API returned an empty host list; validate it
against the reference counts instead of asserting emptiness.

(cherry picked from commit 5e65b25)
(cherry picked from commit a413a93)
@fergusfinn
fergusfinn force-pushed the feat/ht-cudagraph-worst-tokens branch from b23c38b to 97ebe5e Compare July 3, 2026 12:26
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.

2 participants