Skip to content

Make the elastic GIN signal reads backend-agnostic (fix latent PROXY layout mismatch) - #706

Open
KeitaW wants to merge 1 commit into
deepseek-ai:mainfrom
KeitaW:pr/gin-barrier-backend-agnostic-read
Open

Make the elastic GIN signal reads backend-agnostic (fix latent PROXY layout mismatch)#706
KeitaW wants to merge 1 commit into
deepseek-ai:mainfrom
KeitaW:pr/gin-barrier-backend-agnostic-read

Conversation

@KeitaW

@KeitaW KeitaW commented Jul 28, 2026

Copy link
Copy Markdown

Problem

The elastic/ GIN signal waits read a signal by casting ncclGin::_ginHandle to ncclGinGdakiGPUContext*, indexing that by contextId, and loading signals_table.buffer:

  • deep_ep/include/deep_ep/common/comm.cuh:166-167 — the GIN device barrier
  • deep_ep/include/deep_ep/impls/pp_send_recv.cuh:24-26 — the PP send/recv wait

_ginHandle is an opaque, backend-owned pointer. NCCL only ever reaches it via ncclGinCall<ncclGinApi_GetSignalPtr>, and the per-backend specializations cast it to different structs:

backend what _ginHandle points at specialization
GDAKI ncclGinGdakiGPUContext[contextId] nccl_device/gin/gdaki/gin_gdaki.h
PROXY ncclGinProxyGpuCtx_t[contextId] nccl_device/gin/proxy/gin_proxy.h

So the cast hardcodes one backend's layout. The two structs are independent declarations with different sizes, so + contextId strides by the wrong amount over a PROXY array. Measured with sizeof/offsetof on the 2.30.7 headers (both are standard-layout, so the offsets are the whole story):

GDAKI ncclGinGdakiGPUContext PROXY ncclGinProxyGpuCtx_t
sizeof 88 72
byte offset of the signal-array pointer 40 (signals_table.buffer) 40 (signals)

At contextId == 0 the two offsets coincide, so the cast reads the right pointer — by luck, not by contract. At contextId == 1 the cast loads 8 bytes from absolute byte 88 + 40 = 128, while the correct PROXY field sits at 72 + 40 = 112. Byte 128 is offset 56 of PROXY element 1, which is ncclGinProxyGpuCtx_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. ncclGinGdakiGPUContext is only declared when NCCL_GIN_GDAKI_ENABLE is 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 + contextId pointer 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, and comm.cuh:156 (const ncclGin gin(nccl_dev_comm, 0, ...), under the comment "Use QP 0 to do barrier" at comm.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:

comm.cuh:145-146
    for (int i = global_warp_idx; i < num_qps; i += kNumSMs * kNumWarps)
        ncclGin(nccl_dev_comm, i, NCCL_GIN_RESOURCE_SHARING_CTA).flush(ncclCoopWarp());

with num_qps up to nccl_dev_comm.ginContextCount. Whenever the barrier follows the flush loop off QP 0, the wait would poll an address that is never signalled, spin to timeout_while, and hit ptx::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:

-            const auto gdaki = static_cast<struct ncclGinGdakiGPUContext*>(gin._ginHandle) + gin.contextId;
-            const auto signal_ptr = reinterpret_cast<uint64_t*>(__ldg(reinterpret_cast<uint64_t*>(&gdaki->signals_table.buffer))) + signal_idx;
             timeout_while<kNumTimeoutCycles>([=](const bool& is_last_check) {
-                const auto signal = ptx::ld_acquire_sys<uint64_t>(signal_ptr);
+                const auto signal = gin.readSignal(signal_idx, 64, cuda::memory_order_acquire);

Two files, one call site each, no signature or control-flow changes.

readSignal is declared on the public ncclGin interface (nccl_device/gin.h:344) and routes through ncclGinCall<ncclGinApi_GetSignalPtr>, so it picks the accessor for the backend NCCL actually negotiated. Notably this keeps DeepEP's own timeout_while loop — only the read is delegated. That is why waitSignal() is not the right substitute here, and why this does not need to wait for the NCCL-side timeout support that the TODO(NCCL) at comm.cuh:160 refers to.

Equivalent by construction, not by inspection:

  • GDAKI: its GetSignalPtr returns {signals_table.buffer + id, offset = 0}, and readSignal computes (raw - offset) & mask. With offset == 0 and bits == 64 the mask is all ones, so the value is identical to what the removed load produced.
  • PROXY: the subtracted offset comes from signalOffsets, which is allocated zeroed and is written only by ncclGinApi_ResetSignal. DeepEP never calls resetSignal, so the offset stays 0 and the comparison is unchanged.
  • pp_send_recv.cuh compares against a target that may legitimately be negative (send_count - num_max_inflight_tensors + 1). readSignal returns uint64_t, so the result is cast back to int64_t to 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 config before after
NCCL_GIN_GDAKI_ENABLE=0 (PROXY only) failsexpression must be a pointer to a complete object type, at both call sites compiles (70,480 B object)
NCCL_GIN_GDAKI_ENABLE=1 (DOCA headers present) compiles (90,048 B) compiles (91,024 B)

Differential PTX, run on the GDAKI configuration — the only one where both versions build, so the change is the sole variable:

before after
64-bit system-scope acquire loads on the signal poll 4 4
64-bit acquire loads at weaker scope 0 0
new relaxed-load opcode kinds introduced 0

Scope, ordering, width and count of the poll are preserved. The one opcode difference: ld.acquire.sys.L1::no_allocate.global.u64 becomes ld.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 and cuda::atomic_ref::load does 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 _ginHandle use or GDAKI-struct cast anywhere in deep_ep/; the trap() sites, the timeout_while wrappers, and the timeout printf text 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 two elastic/ headers. Both branches are single commits on the same base, the changed file sets are disjoint, and git merge-tree reports a conflict-free merge in both orders with both changes present in the resulting tree.

…DAKI cast

The GIN barrier and the PP send/recv wait both read a signal by casting the
opaque GIN handle to `ncclGinGdakiGPUContext*`, indexing it by `contextId`, and
loading `signals_table.buffer`:

  deep_ep/include/deep_ep/common/comm.cuh:166
  deep_ep/include/deep_ep/impls/pp_send_recv.cuh:24

`ncclGin::_ginHandle` is an opaque, backend-owned pointer. NCCL reaches it only
through `ncclGinCall<ncclGinApi_GetSignalPtr>`, whose per-backend specializations
cast it to different structs:

  nccl_device/gin/gdaki/gin_gdaki.h        ncclGinGdakiGPUContext[contextId]
  nccl_device/gin/proxy/gin_proxy.h        ncclGinProxyGpuCtx_t[contextId]

So the cast hardcodes one backend's layout. The two structs are independent
declarations of different size, so `+ contextId` strides by the wrong amount over
a PROXY array. Measured with sizeof/offsetof on the 2.30.7 headers (both structs
are standard-layout):

  sizeof:                       GDAKI 88   PROXY 72
  signal-array pointer offset:  GDAKI 40   PROXY 40

At contextId == 0 the offsets coincide, so the cast reads the right pointer by
luck rather than by contract. At contextId == 1 it loads 8 bytes from absolute
byte 88+40 = 128, while the correct PROXY field is at 72+40 = 112; byte 128 is
offset 56 of PROXY element 1, i.e. `ncclGinProxyGpuCtx_t::lastIssuedGet`. The
wait would poll the per-peer get-index array as if it were the signal array.

Today every call site takes QP 0, so the bug is latent rather than active
(comm.cuh:156 constructs `ncclGin(nccl_dev_comm, 0, ...)` under the comment
"Use QP 0 to do barrier" at comm.cuh:153). It is one refactor from live: the
flush loop directly above already iterates `ncclGin(comm, i, ...)` for
`i < ginContextCount` (comm.cuh:145-146), so moving the barrier off QP 0 would
make the wait spin on an address that is never signalled until
`timeout_while` fires `ptx::trap()`. Because CUDA 719 is sticky and gets
attributed to whichever launch runs next, the resulting report would name the
barrier as the faulting kernel while the real fault is the cast -- the same
misattribution pattern as deepseek-ai#704.

There is a second, independent consequence: `ncclGinGdakiGPUContext` is only
declared when `NCCL_GIN_GDAKI_ENABLE` is 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.

Fix: call the public `gin.readSignal(signal_idx, 64, memory_order_acquire)`,
which dispatches on the negotiated backend. This keeps DeepEP's own
`timeout_while` loop, so it is not blocked on NCCL adding a timeout to
`waitSignal` (the TODO at comm.cuh:160); only the read is delegated.

Equivalent by construction, not by inspection:
- GDAKI's `GetSignalPtr` returns `{signals_table.buffer + id, offset = 0}` and
  `readSignal` computes `(raw - offset) & mask`; with `bits = 64` the mask is
  all ones, so the returned value is identical to the previous load.
- Under PROXY the subtracted offset is a zero-initialised array that is only
  ever written by `resetSignal`, which DeepEP does not call.
- `pp_send_recv.cuh` compares against a target that may be negative
  (`send_count - num_max_inflight_tensors + 1`), so the result is cast back to
  `int64_t` to keep that comparison signed.

Verified, no behaviour claims beyond what was measured:
- nvcc 12.8, sm_90, real NCCL device headers (2.30.7), DeepEP's own JIT flags,
  both call-site templates force-instantiated so the bodies are codegen'd:
    * PROXY-only (GDAKI disabled): before = does not compile
      ("expression must be a pointer to a complete object type" at both sites);
      after = compiles.
    * GDAKI enabled: before and after both compile.
- Differential PTX on the GDAKI configuration, where both versions build, so the
  only variable is this change: the signal poll still emits exactly 4 system-
  scope 64-bit acquire loads, none at a weaker scope, and no new relaxed load.
  The one opcode difference is that `ld.acquire.sys.L1::no_allocate.global.u64`
  becomes `ld.acquire.sys.b64`, i.e. the L1-no-allocate cache hint on the spin
  poll is dropped. That hint is a performance property, and this change makes no
  performance claim -- it has not been measured on a multi-node GIN job.

This is a correctness/portability change only.

Signed-off-by: Keita Watanabe <keitaw09@gmail.com>
// casting the opaque GIN handle to one backend's context struct: the
// handle layout differs per backend, so a direct cast is only correct
// for GDAKI and only at context 0.
const auto signal = gin.readSignal(signal_idx, 64, cuda::memory_order_acquire);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔵 suggestion: Non-blocking: the previous inline-asm poll used ld.acquire.sys.L1::no_allocate.global.u64; cuda::atomic_ref::load (via readSignal) will emit ld.acquire.sys.b64 without the L1::no_allocate hint. Scope/ordering/width/count are preserved, but the cache hint on the busy-wait spin poll is lost. As acknowledged in the PR this is a performance property with no measurement made; if the hint is considered worth keeping on the poll, consider restoring it without reintroducing the cast.

🤖 v3

@ds-review-bot

Copy link
Copy Markdown
Collaborator

🤖 ds-review-bot Code Review

v6

The changes correctly replace backend-specific signal access with NCCL's dispatched API while preserving signed comparison semantics where required.

v4

⚠️ 未完成评审(no_result_file:模型未产出结果文件)

v3

The change makes the elastic GIN signal reads backend-agnostic by replacing the direct cast of the opaque ncclGin::_ginHandle to a GDAKI-specific context struct with the public, backend-dispatched readSignal accessor. Both intended call sites are correctly modified:

  1. deep_ep/include/deep_ep/common/comm.cuh (GIN device barrier, line 171): replaced the static_cast&lt;ncclGinGdakiGPUContext*&gt;(...) + contextId / signals_table.buffer load with gin.readSignal(signal_idx, 64, cuda::memory_order_acquire). The surrounding timeout_while loop, target comparison, and timeout printf are left unchanged — only the read is delegated, which is the right substitute over waitSignal() since DeepEP still needs its own timeout handling.
  2. deep_ep/include/deep_ep/impls/pp_send_recv.cuh (PP send/recv wait, line 29): replaced the same cast pattern with static_cast&lt;int64_t&gt;(gin.gin.readSignal(signal_idx, 64, cuda::memory_order_acquire)). The result of readSignal (uint64_t) is correctly cast back to int64_t so the comparison stays signed, since target may legitimately be negative (send_count - num_max_inflight_tensors + 1); an unsigned comparison there would have changed behavior.

Both changes keep DeepEP's own timeout_while loop, trap sites, and timeout printf text unchanged. A grep confirms no remaining references to _ginHandle, ncclGinGdakiGPUContext, or signals_table anywhere in deep_ep/. The change is minimal, correctly scoped to the two files, and addresses both the PROXY layout-mismatch bug (wrong stride at contextId != 0) and the build dependency on DOCA/GDAKI headers. The implementation matches the issue's described intent exactly.

The change is correct and I recommend it be merged. The one non-blocking observation (dropped L1::no_allocate cache hint on the spin poll) is captured as a suggestion comment below.

Files reviewed: 2
Issues found: 🔵 1 suggestion
Inline comments posted: 1

⚠️ Parse warning: [v4] no_result_file:模型未产出结果文件

@dmvevents

Copy link
Copy Markdown

We run the PROXY backend on EFA (p5en/H200, IBGDA unavailable), so this PR's fix path is the one we exercise — here is a measured PROXY data point, since the description notes the direct cast is only correct for GDAKI at context 0 and no PROXY measurement existed.

Verdict: correctness-clean and performance-neutral at EP16 on proxy-Gin/EFA.

Setup: 2× p5en.48xlarge (H200, 16 EFA NICs/node), EP16 = 2 nodes × 8 GPUs, experts=384 hidden=7168 topk=8 tokens=128, NCCL 2.30.4 (readSignal present in the device headers, 5 hits), proxy-Gin (NCCL_GIN_TYPE=2), aws-ofi-nccl plugin. The PR's two hunks were applied behind a build flag with fail-loud anchors (matched with zero drift); the A/B toggle for round 2 was a binary swap of the two kept _C.so variants, sha256-recorded per launch.

launch binary dispatch med (n) dispatch min–max combine med (n)
BEFORE baseline 286.1 µs (47) 251.0–343.0 320.7 µs (31)
AFTER #706 325.5 µs (46) 296.5–345.7 320.7 µs (31)
BEFORE2 baseline 313.4 µs (44) 241.9–338.5 321.6 µs (31)
AFTER2 #706 305.3 µs (46) 270.8–332.7 332.4 µs (30)

Pooled across the interleaved launches: dispatch +2.1%, combine +1.0% — inside launch-to-launch noise on this fabric (the two identical-binary BEFORE launches differ by 9.5% of median between themselves, which is why we interleaved and pooled rather than trusting a single launch per side). All 4 launches passed every gate: 8/8 ranks DONE per node, 0 timeouts, 0 tracebacks, 0 num_recv_tokens: -1.

So on at least one non-GDAKI backend the fix costs nothing measurable at EP16 while closing the wrong-layout read. Happy to re-run at other shapes or EP32 when capacity allows.

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.

3 participants