mirror of
https://github.com/deepseek-ai/DeepEP.git
synced 2026-11-05 00:47:32 +00:00
[PR #706] Make the elastic GIN signal reads backend-agnostic (fix latent PROXY layout mismatch) #721
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#721
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/706
Author: @KeitaW
Created: 7/28/2026
Status: 🔄 Open
Base:
main← Head:pr/gin-barrier-backend-agnostic-read📝 Commits (1)
1c90922elastic: read GIN signals through the backend-dispatched API, not a GDAKI cast📊 Changes
2 files changed (+10 additions, -7 deletions)
View changed files
📝
deep_ep/include/deep_ep/common/comm.cuh(+5 -3)📝
deep_ep/include/deep_ep/impls/pp_send_recv.cuh(+5 -4)📄 Description
Problem
The
elastic/GIN signal waits read a signal by castingncclGin::_ginHandletoncclGinGdakiGPUContext*, indexing that bycontextId, and loadingsignals_table.buffer:deep_ep/include/deep_ep/common/comm.cuh:166-167— the GIN device barrierdeep_ep/include/deep_ep/impls/pp_send_recv.cuh:24-26— the PP send/recv wait_ginHandleis an opaque, backend-owned pointer. NCCL only ever reaches it viancclGinCall<ncclGinApi_GetSignalPtr>, and the per-backend specializations cast it to different structs:_ginHandlepoints atncclGinGdakiGPUContext[contextId]nccl_device/gin/gdaki/gin_gdaki.hncclGinProxyGpuCtx_t[contextId]nccl_device/gin/proxy/gin_proxy.hSo the cast hardcodes one backend's layout. The two structs are independent declarations with different sizes, so
+ contextIdstrides by the wrong amount over a PROXY array. Measured withsizeof/offsetofon the 2.30.7 headers (both are standard-layout, so the offsets are the whole story):ncclGinGdakiGPUContextncclGinProxyGpuCtx_tsizeofsignals_table.buffer)signals)At
contextId == 0the two offsets coincide, so the cast reads the right pointer — by luck, not by contract. AtcontextId == 1the cast loads 8 bytes from absolute byte88 + 40 = 128, while the correct PROXY field sits at72 + 40 = 112. Byte 128 is offset 56 of PROXY element 1, which isncclGinProxyGpuCtx_t::lastIssuedGet— so the wait would poll NCCL's per-peer get-index array as if it were the signal array.There is a second, independent consequence.
ncclGinGdakiGPUContextis only declared whenNCCL_GIN_GDAKI_ENABLEis set, which pulls in the DOCA GPUNetIO headers (nccl_device/gin/gin_device_api.h). On an NCCL install built without GDAKI, these two headers do not compile at all — the type is incomplete, so the+ contextIdpointer arithmetic is ill-formed. Since the JIT passes only-I ${nccl_root}/include(csrc/jit/compiler.hpp),elastic/currently carries an implicit build dependency on a DOCA header tree.Why it is latent today, and how it would surface
Every call site currently takes QP 0 —
impls/barrier.cuh:23, andcomm.cuh:156(const ncclGin gin(nccl_dev_comm, 0, ...), under the comment "Use QP 0 to do barrier" atcomm.cuh:153) — which is exactly the coincidentally-correct case.It is one refactor from live. The flush loop immediately above the barrier already iterates all contexts:
with
num_qpsup tonccl_dev_comm.ginContextCount. Whenever the barrier follows the flush loop off QP 0, the wait would poll an address that is never signalled, spin totimeout_while, and hitptx::trap().That failure would be reported badly. CUDA 719 is sticky: once raised, every subsequent launch on the context returns it, so the error lands on whichever library makes the next launch-API call. The report would name the barrier while the actual fault is the cast — the same misattribution pattern described in #704.
Change
Call the public, backend-dispatched accessor instead:
Two files, one call site each, no signature or control-flow changes.
readSignalis declared on the publicncclGininterface (nccl_device/gin.h:344) and routes throughncclGinCall<ncclGinApi_GetSignalPtr>, so it picks the accessor for the backend NCCL actually negotiated. Notably this keeps DeepEP's owntimeout_whileloop — only the read is delegated. That is whywaitSignal()is not the right substitute here, and why this does not need to wait for the NCCL-side timeout support that theTODO(NCCL)atcomm.cuh:160refers to.Equivalent by construction, not by inspection:
GetSignalPtrreturns{signals_table.buffer + id, offset = 0}, andreadSignalcomputes(raw - offset) & mask. Withoffset == 0andbits == 64the mask is all ones, so the value is identical to what the removed load produced.signalOffsets, which is allocated zeroed and is written only byncclGinApi_ResetSignal. DeepEP never callsresetSignal, so the offset stays 0 and the comparison is unchanged.pp_send_recv.cuhcompares against a target that may legitimately be negative (send_count - num_max_inflight_tensors + 1).readSignalreturnsuint64_t, so the result is cast back toint64_tto keep that comparison signed — an unsigned compare there would change behaviour.Verification
Toolchain: nvcc 12.8 (V12.8.93),
-arch=sm_90, real NCCL 2.30.7 device headers staged in the install layout, using DeepEP's own JIT flags (-std=c++20 -O3 --expt-relaxed-constexpr --expt-extended-lambda). Both affected kernel templates are force-instantiated in the test TU so the bodies are actually type-checked and codegen'd — an uninstantiated template proves nothing.Compile matrix (the PROXY-only column is the negative control):
NCCL_GIN_GDAKI_ENABLE=0(PROXY only)expression must be a pointer to a complete object type, at both call sitesNCCL_GIN_GDAKI_ENABLE=1(DOCA headers present)Differential PTX, run on the GDAKI configuration — the only one where both versions build, so the change is the sole variable:
Scope, ordering, width and count of the poll are preserved. The one opcode difference:
ld.acquire.sys.L1::no_allocate.global.u64becomesld.acquire.sys.b64— i.e. the L1-no-allocate cache hint on the spin poll is dropped, because the hint came from the hand-written inline asm andcuda::atomic_ref::loaddoes not emit it.That hint is a performance property, and this PR makes no performance claim. It has not been measured on a multi-node GIN job. If the hint is considered worth keeping on the poll, it can be restored without going back to the cast, and I am happy to do that in this PR.
Also checked, with positive controls on every "absent" assertion: no remaining
_ginHandleuse or GDAKI-struct cast anywhere indeep_ep/; thetrap()sites, thetimeout_whilewrappers, and the timeoutprintftext are all unchanged.Scope
Correctness and portability only — no behaviour change on the GDAKI path, no performance claim. Independent of #704: that one touches
csrc/kernels/legacy/internode_ll.cu, this one touches only the twoelastic/headers. Both branches are single commits on the same base, the changed file sets are disjoint, andgit merge-treereports a conflict-free merge in both orders with both changes present in the resulting tree.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.