mirror of
https://github.com/deepseek-ai/DeepEP.git
synced 2026-11-05 00:47:32 +00:00
[PR #745] [MERGED] [HybridEP] Fix aarch64 inter-node JIT, CUDA graph capture, and test EP topology #750
Labels
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
deepseek-ai/DeepEP#750
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/deepseek-ai/DeepEP/pull/745
Author: @QiaoK
Created: 9/1/2026
Status: ✅ Merged
Merged: 10/16/2026
Merged by: @jershi425
Base:
hybrid-ep← Head:fix/hybrid-ep-aarch64-jit-cudagraph-testtopo📝 Commits (6)
4f03978[HybridEP] Use the correct multiarch NIXL lib dir in the inter-node JIT8747c2a[HybridEP] Let the caller size the dispatch output so it can be capturedabb09bd[HybridEP] Derive the test EP topology from the buffer, not the launcher7869925[HybridEP] Cover capturing dispatch() in the graph testf186b2d[HybridEP] Derive the ragged test EP topology from the buffer too660cfa8[HybridEP] Print the derived EP topology in the tests📊 Changes
11 files changed (+152 additions, -39 deletions)
View changed files
📝
csrc/hybrid_ep/executor/executor.cu(+7 -2)📝
csrc/hybrid_ep/executor/executor.cuh(+4 -0)📝
csrc/hybrid_ep/hybrid_ep.cu(+4 -1)📝
csrc/hybrid_ep/hybrid_ep.cuh(+2 -1)📝
csrc/hybrid_ep/jit/compiler.cu(+11 -1)📝
csrc/hybrid_ep/pybind_hybrid_ep.cu(+2 -1)📝
deep_ep/hybrid_ep_buffer.py(+12 -0)📝
setup.py(+12 -7)📝
tests/test_graphed_hybrid_ep.py(+70 -9)📝
tests/test_hybrid_ep.py(+14 -9)📝
tests/test_ragged_hybrid_ep.py(+14 -8)📄 Description
Three independent bugs found while bringing HybridEP up on 2x GB200 nodes with the NIXL backend. None depends on the others, so they are separate commits and can be taken independently.
Rebased onto
15f4acfafter #744 (ragged dispatch) merged. All commits applied without conflict, and everything below was re-verified on that base.Two of them make HybridEP unusable on aarch64 / under CUDA graphs. The third makes a passing configuration look like a data-corruption bug, which is how I found the other two.
1. The inter-node JIT cannot link libnixl on aarch64
setup.pypicks the multiarch suffix fromplatform.machine():but
csrc/hybrid_ep/jit/compiler.cuhardcodes the x86_64 one. On aarch64 the JIT emits a-Lpointing at a directory that does not exist, so every inter-node NIXL compile fails at link time and the NIXL cross-node path is unreachable on GB200 / Grace-Hopper.Observed on baseline, with
NIXL_HOMEset correctly:Note the prefix is right and only the suffix is wrong.
Fix: select the same directory the extension links against. Since that suffix now appears in two places and only covers Debian-style layouts, both honour a
NIXL_LIB_DIRoverride, so other distributions and future architectures do not need another patch and the two selections cannot drift apart.2.
dispatch()cannot be CUDA-graph capturedHybridEPBuffer.dispatch()synchronized the stream to make the pinnednum_dispatched_tokens_tensorhost-readable:Synchronizing is illegal while a graph is being captured, so capturing a dispatch without an explicit token count fails:
This is not a hypothetical derived from reading the code: I hit it in TensorRT-LLM's
bench_moe_comm.py, which graph-captures the MoE layer, and it is what currently prevents that integration from using HybridEP at all.Deleting the synchronize is not a fix, and an earlier revision of this PR did exactly that. Thanks to the review for catching it. The underlying problem is that the no-permute dispatch has a data-dependent output shape, and the executor obtains that shape with a host read:
That read happens on the host at capture time, and both the allocation and the D2D copy sizes are frozen into the graph. The kernel that produces the real count is only recorded, never run, and with
handle=Nonemetadata_preprocessingallocates a fresh (uninitialized) pinned tensor for it. So removing the synchronize just replaces an illegal wait with a read of memory nothing wrote. Measured on baseline, capturing with an explicit count silently produced a 0-row output — the fresh pinned page read as zero.The other half of the problem is that
num_dispatched_tokensnever reached C++ at all.runtime.dispatch()took onlyhidden, probs, scaling_factor, handle, with_probs, so passing a count merely skipped the synchronize and the value itself was discarded, leaving the executor reading the pinned tensor regardless.Fix: plumb
num_dispatched_tokensthrough to the executor and honour it, so a caller that needs static shapes can supply an upper bound and get a graph whose allocation and copies are sized by that bound. When no count is supplied under capture, raise an error that says so instead of capturing a shape that cannot be known. Eager behaviour is unchanged: with no count the executor still reads the tensor back, after the same synchronize as before.tests/test_graphed_hybrid_ep.pycould not catch any of this, because it capturesdispatch_with_permute(..., num_permuted_tokens=...)and therefore always supplies a count, so the no-permute path was never captured. A case covering it is included, checking both halves of the contract: capture without a count is refused with an actionable message, and a supplied count decides the output shape. The count used deliberately differs from the one the warmup leaves in the pinned buffer, so an implementation that reads the tensor back instead of honouring it fails the test.3. The tests derive EP topology from the launcher instead of the buffer
The tests only read the detected NVLink-domain width when
USE_MNNVLwas set, and otherwise assumed one domain per physical node holding--num-processesranks. On MNNVL hardware a domain can span several nodes, so the buffer forms a wider domain than the launcher implies.The dispatched prob vector is indexed by rank-within-domain, so when the two disagree the reference slices the wrong expert columns and dispatch appears to return corrupt probabilities:
Hidden states still compare clean because they are indexed by global rank, which makes this look like a transport bug specific to probs.
Diagnosis. On 2x GB200 nodes sharing an MNNVL clique the buffer reports
num_of_hybrid_ep_ranks_per_nvlink_domain=8andnum_of_nodes=1, sodispatched_probsis 64 wide (8 local experts x 8 ranks). The tests assumed 4 ranks/node and sliced at(rank % 4) * 8, so ranks 0-3 were correct by coincidence and ranks 4-7 compared against the columns belonging to ranks 0-3. Re-slicing the same run at the buffer-derived offset(rank % 8) * 8gives 0 mismatches on all 8 ranks, i.e. the transported data was correct throughout.Fix: take the topology from the buffer unconditionally.
USE_MNNVLis still honoured by the C++ allocator as a switch to disable fabric allocation (USE_MNNVL=0); it just no longer selects how the reference is built. The now-unused Python-sideUSE_MNNVLconstant is removed from the tests.tests/test_ragged_hybrid_ep.py, added by #744 after this PR was opened, derives its topology the same way and so inherits the same bug. It is fixed here too, in its own commit. Unfixed it reportsRagged dispatch probs mismatch ... 18.43%, the same signature as above.A/B verification
Same test, same nodes, same container; the only difference is the source tree, selected with
PYTHONPATHso both stay built and nothing is rebuilt between the A and B runs.58397eatest_hybrid_ep.pyFailed to compile the code,-L .../lib/x86_64-linux-gnurc=0)cudaErrorStreamCaptureInvalidatednum_dispatched_tokenstest_hybrid_ep.pyDispatch probs mismatch ... 18.22%rc=0)test_graphed_hybrid_ep.py(with the new case)rc=0)test_ragged_hybrid_ep.pyRagged dispatch probs mismatch ... 18.43%rc=0)Row 6 was run with the same library and only the test file swapped, so it isolates the test-side topology bug. Its baseline run ends
rc=1on one node andrc=124on the other, the deadlock described below.Rows 2 and 3 are the two halves of the added test case; run standalone against both trees they give the contrast above, and row 5 is the same check as committed.
On provenance: bugs 1 and 3 were both observed with the stock
tests/test_hybrid_ep.py, unmodified. Bug 2 had no native test that could reach it, for the reason given in its section, and the failure it restates was first seen in TensorRT-LLM.Two notes on how the baseline failures terminate, so the logs are not surprising: in row 4 rank 1 raises the assertion and the remaining ranks then block in the following collective, and in row 1 all ranks raise the compile error together.
Performance
test_hybrid_ep.pyrun back to back on both trees, same allocation and nodes, bothrc=0(measured on the pre-rebase base58397ea; the delta this PR adds is unchanged by the rebase). The baseline was given the test-only topology fix so it reaches the benchmark section, so the library is the only difference:Dispatch, combine, permute/unpermute and the fused paths are flat across BF16 and FP8. The worst case (FP8 dispatch with probs, 383.0 -> 384.3 us) is well inside run-to-run noise; the min/max spread within a single run is around 7% for the same kernels.
No kernel code changes here. The only hot-path addition is one host-side integer comparison in
dispatch_postprocesschoosing between the caller's count and reading the tensor back, and with no count supplied the eager path runs exactly the sequence it did before.How to reproduce
Environment used: 2x GB200 nodes, 4 GPUs each (EP=8),
nvcr.io/nvidia/pytorch:26.07-py3, DeepEP built withHYBRID_EP_MULTINODE=1 USE_NIXL=1, UCX and NIXL installed, an etcd reachable from both nodes, and the defaults fromtests/test_hybrid_ep.py(hidden 7168, 8 local experts, top-k 8, 4096 tokens/rank).Common environment:
fabric configuration (default auto-detect; on GB200 both nodes join one MNNVL domain) — reproduces bug 3:
NIXL cross-node configuration — reproduces bug 1. Fabric allocation has to be disabled and the domain pinned to one physical node, otherwise the two nodes form a single NVLink domain and the inter-node path is never taken:
CUDA graph capture — reproduces bug 2. This is now covered by
tests/test_graphed_hybrid_ep.py, so on this branch it is just:To see the baseline behaviour, copy that file onto the baseline tree and run it there, or use this standalone version:
On baseline this reports an unclear
cudaErrorStreamCaptureInvalidatedfor case 1 and0 rowsfor case 2; on this branch case 1 namesnum_dispatched_tokensand case 2 returns exactlytargetrows.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.