Skip to content

[Fix] nccl: Disable graph buffer registration whenever a captured graph can hold a collective - #43216

Merged
Oasis-Git merged 6 commits into
sgl-project:mainfrom
zhihengy:zhihengy/nccl-graph-register-decode-collectives
Oct 22, 2026
Merged

Oasis-Git merged 6 commits into
sgl-project:mainfrom
zhihengy:zhihengy/nccl-graph-register-decode-collectives

Conversation

@zhihengy

@zhihengy zhihengy commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

NCCL_GRAPH_REGISTER is on by default and registers the send/recv buffers of every graph-captured collective for the lifetime of the graph. Those buffers are graph-pool temporaries, and the pool hands the same addresses to other tensors, so a replay can find its registration pointing at memory that now belongs to something else.

Both existing guards key on a feature flag rather than on that aliasing:

  • enable_tp_lm_head_all_to_all additionally requires PD-decode, DP attention, dp_size > 1 and tp_size == dp_size
  • the pausable-pool check (dcf4e18) requires enable_memory_saver plus SGLANG_MEMORY_SAVER_CUDA_GRAPH, which is off by default

A plain multi-node TP deployment matches neither, so the registration stays on and the hang is reachable.

Features: more likely to appear under large batch size (> 128); not directly related to torch_memory_saver; currently does not appear in PREFILL stage but we still use any stage as gate to not regressing from the previous gate.

Reproduction

Two TP16 engines spanning two H200 nodes each, decode batch 128, CUDA graph on, radix cache on, max-running-requests 128, a 501B MoE model. Load is 160 concurrent 4096-token generations per engine plus a pause/flush/continue cycle every 120s. enable_tp_lm_head_all_to_all and enable_memory_saver are both false in every run below.

Every TP rank hits the 300s scheduler watchdog within the same second:

cuEventSynchronize
  cudaEventSynchronize
    torch/cuda/streams.py:254  synchronize
      batch_result_processor.py:927  process_batch_result_decode
        scheduler.py:4785  process_batch_result
          scheduler.py:1999  event_loop_overlap

[TP0..TP7] Scheduler watchdog timeout (watchdog_timeout=300.0, soft=False)
scheduler.cur_batch_for_debug.batch_size()=128
run peak decode batch NCCL NCCL_GRAPH_REGISTER result
1 66 2.29.7 default (on) clean for 1h
2 128 2.29.7 default (on) hang at 19 min
3 128 2.29.7 default (on) hang at 17 min
4 128 2.29.7 default (on), dcf4e18 applied hang at 37 min
5 128 2.29.7 0 clean for 5h — 76k requests, 70M tokens, 148 cycles
6 128 2.32.3 default (on) hang at 32 min
7 128 2.32.3 0 clean for 40 min

Run 4 is why the dcf4e18 cherry-pick alone is not enough: it is in the tree, neither of its conditions matches, so the variable is never set and the hang is unchanged.

Runs 6 and 7 rule out a stale NCCL as the cause. 2.32.3 carries the fix for NVIDIA/nccl#2389, which has a similar signature, and the hang still reproduces with the registration on and still disappears with it off.

Two further conditions were tested and are not required: NCCL_PXN_DISABLE=1 still hangs (7 min), and all of the above runs have the TP LM-head all-to-all and the memory saver off. Disabling GDR (NCCL_NET_GDR_LEVEL=0) does hold for 50 min with the registration on, which points at the network registration path, but that is not something this layer can detect at argument-resolution time.

Cost

Same engine, same tree, same load, 10 minutes each:

registration requests tokens
on 1,765 2,060,061
off 1,781 2,066,577

No measurable throughput difference. Single run per configuration, not repeated for variance. Worth noting that the non-registered path is the one every deployment used before the default flipped, so it is not a new or lightly-exercised code path.

Change

One condition replaces both: max(tp_size, ep_size, dp_size) > 1 with any graph phase still enabled means a capture can hold a collective, which is exactly when a registration can outlive the pool's claim on the address. It errs toward disabling — later passes can still narrow cuda_graph_config, and a false positive costs nothing measurable.

Keying on any phase rather than decode alone keeps the pausable-pool case that dcf4e18 added: with the decode capture off and the memory saver on, a prefill capture still lands in that pool.

The test that asserted the variable stays unset without the TP LM-head all-to-all encoded the old contract and now asserts the new one. Added alongside it: the pausable pool in isolation, a prefill-only capture, a single rank, and a fully disabled graph.

Draft while the deployment-shape coverage gets a second opinion — happy to narrow the condition if there is a configuration where the registration is known to be safe and worth keeping. The root cause is still open: these runs establish which conditions reproduce it and which do not, not the mechanism inside NCCL.


CI States

Latest PR Test (Base): ✅ Run #39685648032
Latest PR Test (Extra): ❌ Run #39685648027
Latest PR Test (AMD ROCm 10): ❌ Run #39685648048

…can capture a collective

Both guards key on a feature flag rather than on the aliasing they protect
against, so a plain multi-node TP deployment -- no DP attention, no memory
saver -- falls through and keeps NCCL_GRAPH_REGISTER on.

Reproduced with two TP16 engines across two H200 nodes each, decode batch 128,
CUDA graph on: every TP rank hits the 300s scheduler watchdog in the same
second, blocked in cuEventSynchronize under process_batch_result_decode.
Running the same workload with NCCL_GRAPH_REGISTER=0 stayed clean for 5h
(76k requests, 70M tokens, 148 pause/resume cycles).

Throughput A/B at batch 128, 10 min each: 2,060,061 tokens with the
registration on versus 2,066,577 with it off, so the guard can track the
hazard instead of enumerating the shapes that expose it.

The replaced test asserted the variable stays unset without the TP LM-head
all-to-all, which this deliberately changes; the isolating cases for the
pausable pool, a single rank, and a disabled decode graph take its place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… phase

A decode-only check drops the case the pausable-pool guard used to cover: with
--enable-memory-saver and the decode capture off, a prefill capture still puts
its collectives in that pool, and the registration stayed on. Keying on any
non-disabled phase restores it and keeps the single condition.

Adds the regression case; it fails on the previous revision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zhihengy zhihengy changed the title fix(nccl): disable graph buffer registration whenever a decode graph can capture a collective fix(nccl): disable graph buffer registration whenever a captured graph can hold a collective Oct 12, 2026
@zhihengy
zhihengy marked this pull request as ready for review October 12, 2026 22:24

@Oasis-Git Oasis-Git left a comment

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.

[Codex]: Three non-blocking suggestions from source inspection. I did not run local tests or E2E validation.

),
**kwargs,
)
fields.update(kwargs)

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.

[Codex]: Could we add a regression case with attn_dp_size=1, tp_size=2, enable_tp_lm_head_all_to_all=False, and enable_memory_saver=False, asserting that an initially unset NCCL_GRAPH_REGISTER becomes 0? The helper defaults to attention DP, so the new cases without LM-head all-to-all still do not directly cover the plain TP deployment that motivates this change.

capture into a pausable pool, which the pool guard used to cover.

Errs toward True. Later passes can still narrow cuda_graph_config, and
turning the registration off has no measured throughput cost, so a false

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.

[Codex]: Could we qualify this docstring as a conservative workaround and limit the throughput statement to the measured deployment? The PR description says the internal NCCL root cause remains open and the performance comparison has one run per configuration. The aliasing explanation above reads as an established mechanism, and that measurement does not establish the cost of disabling registration across all deployment shapes covered by this guard.

view = resolving_view(server_args)
if max(view.tp_size, view.ep_size, view.dp_size) <= 1:
return False
config = getattr(view, "cuda_graph_config", None)

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.

[Codex]: Could this use direct access to view.cuda_graph_config, retaining the None check if needed? The resolution pipeline initializes this field before handle_data_parallelism, and that handler already accesses it directly in the attention-DP branch. The getattr fallback appears unnecessary and can hide a missing-field contract violation.

@github-actions

This comment has been minimized.

@zhihengy zhihengy changed the title fix(nccl): disable graph buffer registration whenever a captured graph can hold a collective [Fix] nccl: Disable graph buffer registration whenever a captured graph can hold a collective Oct 21, 2026
@Oasis-Git

Copy link
Copy Markdown
Collaborator

[Codex]: Use /tag-and-rerun-ci to trigger base CI, as requested.

@Oasis-Git

Copy link
Copy Markdown
Collaborator

/tag-and-rerun-ci

@github-actions github-actions Bot added the run-ci CI: run the baseline test suite on this PR label Oct 21, 2026
@Oasis-Git

Copy link
Copy Markdown
Collaborator

[Codex]: Base CI attempt 2 for 8c615a8 was blocked before tests ran: the check-maintenance gate requires rebasing onto current main containing 6a39ddf. This is a rebase prerequisite, not a test failure. Base run, attempt 2 · Failing gate. The standard /tag-and-rerun-ci handler also retried other unsuccessful workflows, including Extra; no Extra opt-in was requested or added by this command.

@Jiminator Jiminator added the bypass-fail-fast CI: a failing job no longer aborts its siblings (lint still gates) label Oct 21, 2026
@Oasis-Git

Copy link
Copy Markdown
Collaborator

@Oasis-Git
Oasis-Git merged commit 786cdfc into sgl-project:main Oct 22, 2026
189 of 205 checks passed
detain pushed a commit to detain/sglang that referenced this pull request Oct 29, 2026
…ph can hold a collective (sgl-project#43216)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jimmy Shong <69131491+Jiminator@users.noreply.github.com>
(cherry picked from commit 786cdfc)
(cherry picked from commit 6c22672fd6ebefe21e735b341653e2300d3a0eff)
detain added a commit to detain/sglang that referenced this pull request Oct 29, 2026
… / qsaring / hicachectrl), header bump to qwennext-stack-295-2026-10-28, tip 44134b3, CAVEATS re-pin table + sgl-project#43216 boot-needle bullet + sgl-project#33029 NOT-NEEDED bullet + merge-sgl-project#5 owed refresh

21 picks, ZERO conflicts at the integrator; per-wave file-set and
byte-parity proofs, entry-209 subsumption map (290), the QSA remedy
adjudication with the layout-equivalence proof (285/286/287), the
entry-199 non-interference machine check (294), the flag-collision
record (295) and the full battery attribution are in the entries.
Nothing pushed.
detain added a commit to detain/sglang that referenced this pull request Oct 29, 2026
… CAVEATS + wanted_stack) -- the 'no explicit-override warning / operator setting silently wins' note is FALSE against the landed code: parallel_hook.py:445-449 logs a warning when NCCL_GRAPH_REGISTER was set explicitly, and that block PREDATES the pick. Recorded what actually moved (the two narrow triggers -> _graph_may_capture_collectives, our entry-sgl-project#38936-era _graph_pool_is_pausable helper subsumed and deleted, no dangling callers) and turned the warning line into the live boot needle.
detain added a commit to detain/sglang that referenced this pull request Oct 29, 2026
… header STACK/tip updated, 5 owed-paragraphs marked HISTORY, CAVEATS top bullet = MERGE-sgl-project#5 ERA (PROD BOOT FORBIDDEN until B3: the 209 clamp + 218 ordering rule with the trailing_endpoint 17P->8P/9F proof-of-life-when-restored), wanted_stack Section C consumed (C0a CLOSED with the four arrival shas, C1-C6 ARRIVED per the merge-message D-list, C9 OPT-IN-verified, C10 sgl-project#45090 taken + the operator-owed /root-harness check, 33-trailer families spot-verified IN-RANGE, sgl-project#43216 DEDUPED); plus the kernels kvcache store_cache_fp8 registry graft found by the damage battery (kernels_namespace 107P/0F restored); battery damage map recorded in full in entry 310
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bypass-fail-fast CI: a failing job no longer aborts its siblings (lint still gates) run-ci CI: run the baseline test suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants