comm: lift the GIN scale-out ceiling by making the rail barrier a counting barrier - #5
Open
KeitaW wants to merge 1 commit into
Open
comm: lift the GIN scale-out ceiling by making the rail barrier a counting barrier#5KeitaW wants to merge 1 commit into
KeitaW wants to merge 1 commit into
Conversation
…nting barrier
On EFA the shipped default refuses to initialize past 22 NVLink domains. Measured on
p6-b200.48xlarge: 22 domains complete, 23 refuse at `nccl.cu` with
gin_config.gin_indexed_signals_cnt >= (num_rdma_ranks - 1) and
"GIN indexed-signal budget cannot give each peer rail team a dedicated signal..."
The assert is correct and load-bearing, not redundant: `gin_barrier_wo_local_sync` really did
need one indexed-signal slot per peer, so the rail barrier's demand grew with the team while
the per-context budget did not. A GIN signal id is not free on EFA -- one id is one
`gdaki_sc_endpoint`, a complete QP and CQ -- so the budget is
`gin_indexed_signals_for(c) = (kTotalQPBudget - 2c)/c`, which is 21 at the shipped 11 contexts.
21 slots is 22 domains, and the 23rd is refused. Raising the budget is not available: it is a
NIC resource, independently measured at 512 completion counters per NIC (256 QPs at two
counters each), exactly what `kTotalQPBudget = 256` already assumes.
The fix removes the team-size term instead. A barrier is a counting predicate and does not need
to distinguish senders, so every peer now adds 1 to the SAME signal id and the waiter advances
its shadow by `kNumRanks - 1`. That costs ONE slot whatever the team size. The pattern is the
one the unordered data path already relies on (`hybrid_combine_unordered.cuh`'s
`num_expected_arrivals` wait), and `SignalAdd{.., 1}` matches that precedent.
This is scoped to the RAIL instantiation, deliberately, because the two teams have opposite
requirements:
* Only rail has a ceiling. The unordered-hybrid arm of `NCCLSymmetricMemoryContext` asks for
`gin_indexed_signals_cnt`, the per-context budget above. The direct / ordered arm asks for
`num_ranks + 2 * 2`, commented "Customized RDMA barrier needs extra signals" -- the world
barrier's per-peer slots are already budgeted there and scale with the team.
* Only world is used as a release barrier. Every rail call site passes `kFlushStores = false`
(`hybrid_{dispatch,combine}{,_unordered}.cuh`, `barrier.cuh`), so the rail barrier never even
issues the QP flush; it is a pure synchronisation point. The hybrid kernels' two "ensure data
arrival" barriers pass `do_scaleout = false` and run over NVLink. By contrast
`dispatch.cuh` and `combine.cuh` use the world path with `kFlushStores = true` and then read
what peers wrote.
* A counting barrier cannot carry release. Its counter is anonymous, so a peer one round ahead
can supply an increment standing in for a delayed current-round arrival: the count reaches
its target without every distinct peer having arrived. "Everyone arrived" survives; "every
peer's prior writes are visible to me" does not. Signal strength does not repair this --
strong signals order a sender's own prior puts, they never say which sender incremented.
Identity is the missing half and only per-peer slots have it, so this would hold on
InfiniBand too.
So the world body is left unchanged apart from the four-space re-indent that the new
`if constexpr` block forces around it, and can be reviewed as untouched. To check that
mechanically, strip comments and whitespace from the block in both revisions and compare --
746 characters of code, identical.
A second, live defect had to be fixed for the counting barrier to be safe at all. The barrier
hardcoded signal id 0 and nothing stopped the data path from producing that same id. In
`cached_mode` the unordered hybrid kernels run with zero notify warps, which makes
`kQPStartIdx = 0` and puts data channels on context 0 -- the barrier's own context --
where 0-based `channel_to_signal_id` yields id 0 for channel 0 part 0. NCCL addresses a shadow
by (context, signal) alone, so that was one shared counter with each side inflating the other's
arrival count. `kNumReservedBarrierSignals` now takes one id off the bottom of every context's
id space and `data_signal_id()` is the single place the offset is applied, so both derivations
in `comm.cuh` shift together. Note the offset is added AFTER the per-part multiply; going
through `get_qp_signal_id` would scale it by `kNumParts` and burn ids.
Two invariants were repaired alongside. `all_gin_context_counts_cover_warps()` compared a
cross-context TOTAL against a warp count, which says nothing about the most crowded context;
it is replaced by a serviceability check over every legal context count, verified non-vacuous
(forcing the reservation to 200 fails the build). `compute_part_allocation` is split into a pure
`_raw` and a diagnosing wrapper so the invariant can be a `static_assert` -- a `printf` or a
throwing assert reached during constant evaluation makes the expression non-constant.
`gpu_barrier` gains a static assert, mirrored by a host assert in `NCCLSymmetricMemoryContext`,
that a GIN scale-up and a GIN scale-out barrier are never live concurrently: their id ranges
overlap, since world's per-peer slots start at 0 and rail's counting slot IS 0. No instantiation
can violate it today; the host assert is what fails early, because these kernels are
NVRTC-generated and a device static assert would surface as a JIT exception in production.
Cost, measured with `compute_part_allocation` at the shipped context count: the reservation
costs one part at 16-17 SMs (3 -> 2) and one channel per SM at 51-52 SMs (4 -> 3, with the host
warning). No second id is needed, so the usable per-context budget stays at 20.
Verified on 36 x p6-b200.48xlarge (8x B200, 8 EFA/node, efa kmod 3.3.0g), treatment and control
built from the same base and each pod printing the md5 of the JIT headers it compiled:
23 domains, stock -> refuses at init, verbatim assert above
23 domains, this change -> completes, correctness checks pass
32 domains, this change -> completes (256 ranks)
22 domains, this change -> completes
2 domains, this change -> completes
3 domains, direct mode -> completes; exercises the world instantiation
3 domains, direct, stock -> completes; control for the row above
Barrier cost at 22 domains is unchanged to within run-to-run spread, and the counting barrier is
never slower than the per-peer one: 8.2% faster at 2 domains, 4.9% at 8, converging to no
difference by 22. The single-counter hotspot does not materialise through 32 domains -- marginal
cost 3.38 us/domain over 22->32 against the stock barrier's 3.25 us/domain over 16->22.
End-to-end dispatch and combine throughput is unchanged.
Remaining limitations and untested paths are tracked separately rather than in this change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
csrc/kernels/backend/nccl.cuasserts atBufferinit that the per-context GIN indexed-signalbudget can give one dedicated signal to every rail peer:
num_rdma_ranksisncclTeamRail(comm).nRanks, the number of NVLink domains. The supply is(kTotalQPBudget - 2c)/cforccontexts — a constant in the domain count — while therequirement grows linearly with it. The two cross:
At 8 GPUs per domain the shipped default stops at 176 ranks. Raising the context count
lowers the reachable domain count, since supply and context count are inversely coupled:
tuning for QP parallelism tightens the scale-out ceiling.
A GIN signal id is not free on EFA. One id is one
gdaki_sc_endpoint, a complete QP and CQ,which is why the budget is expressed in QPs. Raising the budget is not available either. The
boundary has been measured directly in request space on B200: a devComm's request costs one
endpoint for each context's data path plus one per signal id, and requests totalling 256
endpoints allocate while 257 fail. Three independent context/signal splits land on exactly
256 and allocate, each failing at the next step up.
kTotalQPBudget = 256is therefore themeasured constant at the granularity it is written in — one endpoint carries one QP and one
CQ, and the cells do not separate which of those is the scarce object, only that the cap is
one per endpoint. The scope is one devComm's request rather than the device total; those
measurements are being submitted separately.
A second defect on the same ids
Independent of the ceiling, the barrier's ids were already colliding with the data path.
dispatch.hppsetsnum_notify_warps = 0incached_mode, which makeskQPStartIdx = 0andplaces data channels on context 0 — the barrier's own context.
channel_to_signal_idis0-based, so channel 0 / part 0 derived id 0: exactly the barrier's slot.
NCCL addresses a signal's shadow by (context, signal) alone:
No team term, no tag term — so that is a genuinely shared 64-bit counter, with each side
inflating the other's arrival count.
Why the assert is load-bearing
Two independent consumers draw on the per-context budget, and only one scales with the domain
count:
(sm, channel, part), no peer termceil(channels/qp) × num_partsnum_rdma_ranks − 1Deleting the assert would convert a loud init failure into the barrier indexing outside the
provisioned range — the silent mode the header already warns about, where no counts arrive and
dispatch times out with all-zero received counts.
Fix
A barrier is a counting predicate and does not need to distinguish senders. Every peer now adds
1 to the same signal id, and the waiter advances its shadow by
kNumRanks - 1and pollsthat one signal. Message count is unchanged; the slot requirement drops from
N−1to onewhatever the team size, which removes the team-size term from the init check.
This is the pattern the unordered data path already relies on — one signal accumulating
increments from many remote senders, polled against a shadow advanced by the expected delta
(
hybrid_combine_unordered.cuh, thenum_expected_arrivalswait).SignalAdd{.., 1}matchesthat precedent exactly, and avoids the documented rule that
Incmay not be mixed with othersignal operators without an intervening
reset().Scoped to the rail team, deliberately
The two team instantiations have opposite requirements, so only rail is converted.
Only rail has a ceiling. The unordered-hybrid arm requests
gin_indexed_signals_cnt, theper-context budget above. The direct / ordered arm requests
num_ranks + 2 * 2, commented"Customized RDMA barrier needs extra signals" — the world barrier's per-peer slots are already
budgeted there and scale with the team.
Only world is used as a release barrier. Every rail call site passes
kFlushStores = false,so the rail barrier never even issues the QP flush; it is a pure synchronisation point. The
hybrid kernels' two "ensure data arrival" barriers pass
do_scaleout = falseand run overNVLink. By contrast
dispatch.cuhandcombine.cuhuse the world path withkFlushStores = trueand then read what peers wrote.A counting barrier cannot carry release. Its counter is anonymous, so a peer one round ahead
can supply an increment standing in for a delayed current-round arrival: the count reaches its
target without every distinct peer having arrived. "Everyone arrived" survives; "every peer's
prior writes are visible to me" does not. Signal strength does not repair this — strong signals
order a sender's own prior puts, they never say which sender incremented. Identity is the
missing half and only per-peer slots have it, so this holds on InfiniBand too.
The world body is therefore left unchanged apart from the four-space re-indent the new
if constexprforces around it. Stripping comments and whitespace from the block in bothrevisions gives 746 characters of identical code.
Reserving an id the data path cannot produce
Collapsing to one slot is not enough on its own, because of the collision above.
kNumReservedBarrierSignalstakes one id off the bottom of every context's id space, anddata_signal_id()is the single place the offset is applied, so both derivations incomm.cuhshift together.
The reservation is applied uniformly across contexts although only QP 0 needs it: the tuner
budgets for the worst context anyway, and a uniform offset keeps the id derivation independent
of which QP a channel landed on. The offset goes in after the per-part multiply — inside
channel_to_signal_idit would be scaled bykNumPartsand burn ids, so that function stays0-based and keeps its host unit test unchanged.
One id, not two, because a GIN scale-up and a GIN scale-out barrier can never be live
concurrently — now asserted rather than left implicit, since their id ranges overlap (world's
per-peer slots start at 0 and rail's counting slot is 0):
A disjunction of three sufficient conditions. The stricter
kIsScaleupNVLink or kNumScaleoutRanks <= 1would be wrong: it rejectsbarrier.cuh'ssequential path, which issues rail and world from two separate, globally ordered calls and is
safe for that reason. The hazard is concurrency, so the condition is about concurrency.
The static assert is a backstop, not the gate. These kernels are NVRTC-generated from
runtime values, so a violation surfaces as a JIT exception on first launch rather than as a
build failure. The same condition is an
EP_HOST_ASSERTinNCCLSymmetricMemoryContext, whereboth values are known and it fails at
Bufferinit.Two invariants repaired alongside
all_gin_context_counts_cover_warps()compared a cross-context total against a warp count.Ids are per-context, so an aggregate says nothing about the busiest context; at ctx=13 the
worst-case launch already needed 19 ids against 17 and was relying on the tuner to cut channels.
It is replaced by the real contract: every legal context count must remain serviceable at
the worst-case launch. Verified to bite — forcing the reservation to 200 fails the build.
To make that assertable,
compute_part_allocationis split into a pure_rawand a diagnosingwrapper: a
printfor a throwing assert reached during constant evaluation makes the expressionnon-constant, so the invariant could not otherwise call the shipping math — and duplicating the
math is exactly the drift the split prevents.
Cost
Host-compiled the shipped tuner across 24
(SMs, channels/SM)shapes at the default contextcount. The reservation costs one part at 16–17 SMs (3 → 2) and one channel per SM at 51–52 SMs
(4 → 3, with the host warning). Usable per-context budget goes 21 → 20; a second id would have
cost roughly three times as many shapes, which is what the concurrency assert buys.
One
__syncthreads()is added. One slot means one waiter, and the per-peer layout had everythread wait on its own slot, which implicitly guaranteed that no thread in the block ran ahead
of the barrier — that has to be restored explicitly.
The timeout diagnostic changes: with one counting slot the stalled peer is no longer
identifiable, so the message prints how many of the expected arrivals are missing plus the
signal id. The previous message never printed the slot index either, so per-peer attribution was
not available before.
Validation
36 × p6-b200.48xlarge, 8× B200 per node, one NVLink domain per node, 8 EFA per node, NCCL
2.31.2, aws-ofi-nccl with the EFA-GDA GIN backend, libfabric 2.6.0. Treatment and control built
from the same base and differing only in the DeepEP source tree; every rank printed the md5 of
the JIT headers it compiled, so each arm is provably the tree it claims.
--allow-hybrid-mode 0--allow-hybrid-mode 0Host-side, with no GPU, since these headers are NCCL-free by design: the serviceability
invariant compiles for every legal context count and fails when the reservation is forced to
200; and every id the data path can derive falls in
[1, provisioned−1]across all legalcontext counts, with and without notify warps.
Performance
Barrier in isolation, mean over runs in both job orders; the 8-rank spread within a run is under
1%. The counting barrier is never slower:
The saving is a fixed ~2.2 µs per call rather than a scaling one, so it fades as a percentage
while the barrier's absolute cost grows. The single-counter hotspot does not materialise through
32 domains: marginal cost is 3.38 µs/domain over 22→32 against the per-peer barrier's
3.25 µs/domain over 16→22, and the slope falls again over 26→32. End-to-end dispatch and combine
throughput is unchanged.
Note on #3
This change is textually independent of #3 (different files), but on Blackwell no kernel builds
until #3 lands, so the validation above was run with both applied. Remaining limitations and
untested paths are tracked separately rather than in this PR.