ep: CUDA-graph-safe HT internode dispatch+combine (worst-tokens mode) - #3
ep: CUDA-graph-safe HT internode dispatch+combine (worst-tokens mode)#3fergusfinn wants to merge 7 commits into
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
73a5d4c to
a8f0ab0
Compare
There was a problem hiding this comment.
Summary
This PR introduces CUDA-graph capture/replay safety for the high-throughput (HT) internode dispatch+combine path when using num_worst_tokens mode. The changes consist of three main components:
-
Core correctness fixes (commit 1b53d21): Two critical fixes that prevent deadlock and data corruption during CUDA graph replay:
- Skip host counter reset wait in
notify_dispatchwhennum_worst_tokens > 0 - Fix combine NVL sender bounds to use actual received token count from
gbl_rank_prefix_suminstead of paddednum_tokens
- Skip host counter reset wait in
-
Device-visible expert counts (commit 85a9711): Expose per-expert recv counts as a device tensor for CUDA-graph consumers who cannot access host-mapped counters without syncing.
-
Comprehensive test coverage (commit 73a5d4c): New
test_ht_cudagraph.pywith multi-phase validation including capture, replay with fresh routing, eager/replay interleaving, and multi-shape capture scenarios.
Verdict: Needs minor changes before approval. The core fixes are well-targeted and the test coverage is excellent, but there are potential issues with the pack_token_in_nvl_ranks() implementation and a config parameter change that need clarification.
Research notes
- Fetched CUDA Programming Guide section on stream capture (6.2.8.7): Confirms that captured operations must be replayable without host intervention, validating the approach of skipping host-counter waits in
num_worst_tokensmode. - The
NUM_MAX_NVL_PEERSrelaxation from== 8to<= 8is appropriate for supporting configurations with fewer than 8 NVL peers per RDMA rank. - The packing function replaces unsafe reinterpret_cast patterns that assumed exactly 8 bools fit in uint64_t.
Suggested next steps
- Blocking: Verify
pack_token_in_nvl_ranks()handles the case whereNUM_MAX_NVL_PEERS < 8correctly (the shift operation assumes each bool occupies 8 bits). - Non-blocking: Document the rationale for changing the 8-rank combine config parameter from 6 to 8 in
buffer.pyline 777. - Non-blocking: Consider adding a comment explaining why
recv_expert_counts_deviceis written unconditionally even whennum_worst_tokens == 0could theoretically reach that code path.
General findings
Positive observations
-
Excellent test coverage: The new
test_ht_cudagraph.pycovers all critical scenarios:- Phase 0: Eager warmup with analytic oracle validation
- Phase 1: Graph capture
- Phase 2: Multiple replays with fresh data/routing per replay
- Phase 3: Interleaved eager/replay (mimics vLLM serving pattern)
- Phase 3b: Multi-shape capture on single Buffer
- Phase 4: Performance timing comparison
-
Minimal invasive changes: The core fixes are surgical - only modifying the specific code paths that cause issues under CUDA graph replay, with guards like
num_worst_tokens == 0 &&to preserve existing behavior for non-graph usage. -
Good API design: Making
recv_expert_countsoptional (only allocated whennum_worst_tokens > 0) avoids unnecessary overhead for non-graph consumers.
Potential concerns
-
The
pack_token_in_nvl_ranks()function usesi * 8bit shifts, which works for up to 8 NVL peers but could silently produce incorrect results ifNUM_MAX_NVL_PEERSis ever increased beyond 8 without updating the function. -
The config change in buffer.py line 777 (4th parameter from 6 to 8) appears related to fixing the "broken for 2 RDMA ranks" issue mentioned in the test file comments, but lacks explicit documentation in the code.
There was a problem hiding this comment.
Summary
This PR (3 commits on base cxi-ep) makes the high-throughput internode dispatch+combine path CUDA-graph safe by eliminating host-side counter synchronization when num_worst_tokens > 0. Key changes:
- notify_dispatch: Skip spin-wait on
moe_recv_*_counter_mappedwhennum_worst_tokens > 0; write per-expert counts to device tensorrecv_expert_counts_devicefor graph-mode consumers. - dispatch/combine kernels: Use
gbl_rank_prefix_sum[num_ranks - 1]instead ofnum_tokensfor token_end_idx bounds in worst-tokens mode (avoids padded-size over-read). - Python bindings: Allocate
recv_expert_countsdevice tensor andgbl_rank_prefix_sumprefix-sum tensor; thread pointers through C++ layer. - Test: New
test_ht_cudagraph.pyvalidates capture→replay correctness with fresh data/routing across 50 replays, plus multi-shape interleaving.
Verdict: Needs minor fixes before approval — see Blocking items on recv_expert_counts_device null-check placement and potential uninitialized read in combine NVL sender.
Research notes
- Fetched OWASP guidance on side-channel timing (not directly applicable; this is HPC MoE routing).
- Reviewed CUDA Graphs documentation: device-side tensors must be allocated once and reused; host-sync breaks capture semantics.
- Checked libfabric CXI transport patterns: host-pinned atomics required for cross-node barriers.
- Cross-referenced DeepEP upstream (vllm-project/DeepEP) for similar worst-tokens patterns.
Suggested next steps
- Blocking: Move
recv_expert_counts_device != nullptrnull-check outside thethread_id < num_nvl_expertsguard (internode.cu:347) — currently only threads withthread_id < num_nvl_expertsevaluate the condition, but the guard should protect the write, not gate which threads attempt it. - Blocking: Initialize
token_start_idx = 0, token_end_idx = 0for all lanes in combine NVL sender (internode.cu:2356-2358); currently onlylane_id < kNumRDMARankslanes set these, risking undefined values if early-exit checks use them. - Non-blocking: Add EP_HOST_ASSERT that
recv_expert_counts_devicesize equalsnum_local_expertsin notify_dispatch wrapper. - Nit: Remove stale comment about EFA piggybacked atomics (line ~1114 in dispatch diff).
General findings
- Correctness: The delta from
num_tokens→gbl_rank_prefix_sum[num_ranks - 1]in combine is sound; prefix-sum is written by notify_dispatch before any rank enters dispatch/combine data movement. - Resource handling:
recv_expert_countsallocated only whennum_worst_tokens > 0, correctly gated; no leaks observed. - Testing:
test_ht_cudagraph.pyis thorough — covers warmup, replay fidelity, eager/replay interleave, and multi-shape serving patterns. Consider adding an OOM-stress test (capture at max batch, replay 100×). - Performance: No regressions expected; worst-tokens path removes host sync entirely. Normal path (
num_worst_tokens == 0) unchanged. - CXI transport: Large addition (~900 LOC across rdma.cpp, proxy.cpp, cxi_transport.cpp). Out of scope for deep review here but flagged for follow-up: verify
cxi_barrier_slot_offsetbounds andkAtomicBufferSize - kCxiBarrierBytesmath.
General findings (auto-demoted from inline due to pre-validation)
- Blocking
ep/src/internode.cu:347— The null-checkif (recv_expert_counts_device != nullptr)guards the write, but only threads withthread_id < num_nvl_expertsreach this point. Ifnum_nvl_experts < NUM_MAX_NVL_PEERS(possible with many experts), some expert slots may never be written. Move the null-check inside the loop or ensure allthread_id < num_experts / num_ranksthreads participate.- (demoted: code self-check failed at ep/src/internode.cu:347: diff has
recv_expert_counts_device[thread_id] = sum;, model claimed// Device-visible copy for CUDA-graph (num_worst_tokens) consumers,)
- (demoted: code self-check failed at ep/src/internode.cu:347: diff has
- Blocking
ep/src/internode.cu:2356— Variablestoken_start_idxandtoken_end_idxare declared but only initialized for lanes wherelane_id < kNumRDMARanks. If an early-exit condition (__all_sync(token_start_idx >= token_end_idx)) evaluates before theif (lane_id < kNumRDMARanks)block completes, uninitialized values may be used.- (demoted: code self-check failed at ep/src/internode.cu:2356: diff has
? gbl_rank_prefix_sum[num_ranks - 1], model claimed// Get tasks for each RDMA lane)
- (demoted: code self-check failed at ep/src/internode.cu:2356: diff has
- Nit
ep/src/uccl_ep.cc:546— Fix fromnum_ranks→num_rdma_ranksis correct (was clearing too much memory). Consider adding a comment: "Clear only RDMA-rank entries, not all ranks."- (demoted: line 546 (side=RIGHT) is not part of any diff hunk in ep/src/uccl_ep.cc)
- Nit
ep/bench/test_ht_cudagraph.py:137— Hardcoded config override for 8-rank case is well-documented in the comment above. Consider upstreaming this fix toBuffer.get_combine_config()to avoid per-test workarounds.- (demoted: code self-check failed at ep/bench/test_ht_cudagraph.py:137: diff has
num_worst_tokens = num_tokens * num_ranks, model claimedcombine_config = Config(Buffer.num_sms, 4, 256, 8, 128))
- (demoted: code self-check failed at ep/bench/test_ht_cudagraph.py:137: diff has
There was a problem hiding this comment.
Summary
This PR introduces CUDA-graph-safe high-throughput internode dispatch+combine for worst-tokens mode through three focused commits: (1) fixing spin-wait deadlocks during graph replay, (2) exposing per-expert receive counts as device tensors for graph consumers, and (3) adding comprehensive capture/replay tests. The implementation is well-designed with appropriate defensive coding patterns.
Verdict: Needs minor changes before approval - see findings below.
Research notes
-
CUDA Graphs semantics: Per NVIDIA's CUDA Graphs documentation, graphs capture kernel launches and dependencies but cannot re-execute host code during replay. This validates the fix to skip host-counter spin-waits when
num_worst_tokens > 0. -
PyTorch CUDAGraph API: The torch.cuda.CUDAGraph docs confirm that
replay()re-executes captured work with fresh tensor contents but identical launch parameters - validating the test's approach of copying new data/routing before each replay. -
OWASP / Security: No security-sensitive surfaces identified - this is internal communication infrastructure without user input handling, auth boundaries, or secret exposure.
Suggested next steps
- Add zero-token edge case test (Non-blocking) - Expand
test_ht_cudagraph.pyto cover--num-tokens=0scenario - Clarify docstring (Nit) - Update
dispatch()docstring to reflect that device tensor is returned instead of empty list in worst-tokens mode - Improve spin-wait comment (Nit) - Explain why short-circuit
&&works but wrappedifcaused deadlock
General findings
Correctness
The core fixes are sound:
- Spin-wait skip prevents deadlock during CUDA graph replay (host doesn't reset counters on replay)
- Device tensor for expert counts enables graph consumers to read counts without host sync
- Combine kernel bounds fix using
gbl_rank_prefix_sum[num_ranks - 1]correctly handles padded vs actual token counts
Test Coverage Gap (Non-blocking)
The test exercises many scenarios but defaults to 512 tokens/rank. Zero-token edge cases (relevant for DP-attention idle micro-batches) have defensive code but no explicit test coverage.
Code Quality
Clean implementation with good comments. The 8-rank combine config workaround in the test (lines 149-154) hints at a pre-existing configuration issue worth tracking separately.
General findings (auto-demoted from inline due to pre-validation)
- Non-blocking
ep/src/internode.cu:289— The spin-wait fix is correct for CUDA graph safety. However, the comment at lines 286-291 could be clearer about why wrapping withif (num_worst_tokens == 0) { ... }caused deadlock while the short-circuit&&condition doesn't.- (demoted: code self-check failed at ep/src/internode.cu:289: diff has
// know why. num_worst_tokens = 0, but somehow wrapping it with the, model claimedwhile (num_worst_tokens == 0 &&)
- (demoted: code self-check failed at ep/src/internode.cu:289: diff has
- Non-blocking
ep/bench/test_ht_cudagraph.py:120— Test coverage gap - the default 512 tokens/rank doesn't exercise zero-token edge cases.- (demoted: code self-check failed at ep/bench/test_ht_cudagraph.py:120: diff has
parser = argparse.ArgumentParser(), model claimedparser.add_argument("--num-tokens", type=int, default=512))
- (demoted: code self-check failed at ep/bench/test_ht_cudagraph.py:120: diff has
There was a problem hiding this comment.
Summary
This PR makes the HT (high-throughput) internode dispatch+combine path CUDA-graph safe by addressing two critical issues:
-
Host counter synchronization bypass: When
num_worst_tokens > 0(graph mode), kernels skip waiting for and writing host-mapped counters, preventing deadlock during graph replay when host code doesn't re-execute. -
Correct token bounds in combine: Uses
gbl_rank_prefix_sum[num_ranks - 1]instead of paddednum_tokensto determine actual received token count, preventing garbage tail token transmission.
The changes are well-reasoned, follow CUDA graph best practices, and include comprehensive test coverage (test_ht_cudagraph.py for correctness validation, test_ht_ragged.py for reproducing serving-shaped race conditions). The code is ready to merge pending one minor clarification.
Research notes
-
CUDA Graphs documentation (NVIDIA CUDA C++ Programming Guide, section 6.2.8.7): Confirms that during graph replay, host code does not re-execute. This validates the fix for skipping host counter synchronization in graph mode - the host-side reset to -1 never happens on replay, so kernels waiting for it would deadlock indefinitely.
-
Stream Capture restrictions (section 6.2.8.7.3): Prohibits operations that depend on host execution during capture/replay. The PR correctly moves counter storage from host-mapped memory to device tensors for graph-mode consumers.
Suggested next steps
-
Non-blocking: Add explicit assertion that
num_experts % group_size == 0when allocatingrecv_expert_countstensor inbuffer.py:1578, or document this as a pre-existing assumption inherited from the C++ side (uccl_ep.cc:936-937). -
Consider adding a brief comment in
test_ht_ragged.pyPhase R explaining why the deliberate lack of synchronization between replay and eager steps is intentional (to reproduce the race condition being fixed).
General findings
No blocking issues found. The core fixes are sound:
-
The conditional
if (num_worst_tokens == 0)guards on host counter writes (internode.cu:300, 334, 350) correctly prevent graph-replayed kernels from clobbering concurrent eager dispatch counter resets. -
The device-visible
recv_expert_counts_devicewrite at line 354-355 intentionally lacks anum_worst_tokensguard because the pointer is only non-null in graph mode (Buffer.py passesNone/0 otherwise). This implicit coupling is correct but could benefit from an explicit comment. -
The
gbl_rank_prefix_sumparameter is properly plumbed through all layers (Python → C++ binding → CUDA kernel) and written before being read in the combine kernel, ensuring correct token bounds.
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.
…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).
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.
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.
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.
…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.
cad6344 to
2225a77
Compare
Report host-mapped recv counters and per-peer count-arrival state when dispatch times out. Sentinel-fill the count-exchange recv region before notify_dispatch so timeout readback can distinguish missing peer slots from arrived counts.
2225a77 to
1fa245f
Compare
Makes the high-throughput internode path CUDA-graph capturable and replay-safe when
num_worst_tokensis set. All changes are inert whennum_worst_tokens == 0.Stacking: this PR is now stacked on #8 (
pr997-quiet-all-rings), which owns the CXI transport/sync-drain base. This PR's code changes are generic high-throughput internode changes (internode.cu,uccl_ep.cc, Python wrapper/tests) and do not touch the CXI transport oruccl_ibgdasync path.Commits
notify_dispatchskips host-counter waits in worst-tokens mode, because host code does not re-execute during replay. Combine also uses the real received total fromrecv_gbl_rank_prefix_suminstead of the padded row count.notify_dispatchoptionally writes per-expert recv counts to a deviceint32[num_local_experts]tensor, anddispatch()returns it in the counts slot under worst-tokens mode.ep/bench/test_ht_cudagraph.py,ep/bench/test_ht_ragged.py, and the existing internode test coverage validate graph replay, ragged shapes, device counts, and eager/replay interleaving.Validation from the original stack: all phases passed on 2x4 GH200 over Slingshot/CXI (jobs 5132248 and 5132662; eager 0.76 vs replay 0.72 ms/step at test size). Known cosmetic issue at the time: the test printed
cudaErrorContextIsDestroyedat interpreter exit afterbuffer.destroy(); the teardown-ordering test fix is included in this PR.