ep: graph-safe worst-token HT dispatch+combine (internode + intranode) - #18
ep: graph-safe worst-token HT dispatch+combine (internode + intranode)#18fergusfinn wants to merge 11 commits into
Conversation
There was a problem hiding this comment.
💡 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) |
There was a problem hiding this comment.
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 👍 / 👎.
… internode dispatch+combine
- 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
…roxy-queue fixes
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)
…aph replay safety
b23c38b to
97ebe5e
Compare
429ba28 to
d56e349
Compare
Summary
Makes the DeepEP high-throughput dispatch/combine path CUDA-graph capturable
and replay-safe when
num_worst_tokensis set, across both the internode(RDMA, multi-node) and intranode (NVLink, single-node) kernels. The
num_worst_tokens == 0path is byte-for-byte unchanged.Single-node EP (where
num_rdma_ranks == 1) routes through the intranodepath; multi-node EP routes through the internode path. Both need the same
graph-safety treatment, so they are fixed together here.
Internode (
internode.cu)notify_dispatchspin-waits on the hostto reset
moe_recv_*_counter_mappedto-1then writes the sum — a per-callGPU↔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.per-expert counts to a device
int32tensor, returned in place of the hostlist when
num_worst_tokens > 0, so graph-mode consumers never touch the host.gbl_rank_prefix_sum. In worst-token modenum_tokensis the padded row count; combine now reads the true received totalfrom the device prefix sum instead of shipping garbage tail tokens.
Intranode (
intranode.cu)The intranode
notify_dispatchkernel wrote the host-pinnedmoe_recv_counter/moe_recv_expert_counterunconditionally. The host readsthese only on the
num_worst_tokens == 0path, but the kernel still wrote them onevery 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
-1but 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_tokensis nowthreaded into the intranode
notify_dispatchand both counter writes are gated onnum_worst_tokens == 0, mirroring the internode fix.Tests
test_ht_cudagraph.py— capture layout→dispatch→expert→combine in one CUDAgraph; 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, mixedprefill+decode serving benchmark (random, ISL/OSL 1024/1024,
--ignore-eos):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).