feat(distributed): self-clearing barrier signals in host collective kernels - #2279
feat(distributed): self-clearing barrier signals in host collective kernels#2279georgebisbas wants to merge 5 commits into
Conversation
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughKernel templates for mesh allreduce and ring allreduce now include epilogue logic that clears used signal-barrier credits by atomically decrementing peer signal cells after the final barrier. New integration tests validate that a shared signal buffer works correctly across three consecutive allreduce calls for both mesh and ring variants. ChangesAllreduce signal buffer reuse
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 987fe66206
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/st/distributed/test_l3_host_tensor_allreduce_ring.py`:
- Line 237: Update the inputs construction in the distributed allreduce test to
add a distinct round-specific offset to each tensor generated by
_make_rank_inputs, ensuring every reuse round has a different expected reduction
while preserving the existing rank-specific values.
In `@tests/st/distributed/test_l3_host_tensor_allreduce.py`:
- Around line 272-279: Fix _build_host_allreduce_signal_reuse so its rounds
parameter has a valid contract: either remove the configurable rounds argument
and use the fixed three-stage setup, or generate host_orch’s stages and
corresponding output writes from rounds. Ensure all indices remain valid for
values below three and every allocated output slice is written for values above
three.
- Line 411: Update the inputs construction in the distributed allreduce test to
add a round-dependent offset to each round’s data before stacking, while
preserving distinct rank inputs within every round. Ensure later-round expected
results cannot match stale results from earlier rounds.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 9dba039a-16f1-43ea-9869-9fae17e15947
📒 Files selected for processing (4)
python/pypto/runtime/builtins/collectives/allreduce/templates/kernel.cpp.inpython/pypto/runtime/builtins/collectives/allreduce_ring/templates/kernel.cpp.intests/st/distributed/test_l3_host_tensor_allreduce.pytests/st/distributed/test_l3_host_tensor_allreduce_ring.py
5155b8e to
d953a78
Compare
d953a78 to
7798149
Compare
YunjiQin
left a comment
There was a problem hiding this comment.
Reviewed the full diff and cross-checked every kernel's credit accounting against the InCore protocol in lower_composite_ops_pass.cpp. The direction is right and the bug analysis (sim is sequentially consistent, so stale credits are only observable on silicon) is accurate.
The AtomicAdd(-N) epilogues in allreduce (mesh), allreduce_ring, barrier, allgather, all_to_all all check out — I verified the credit counts individually and they are exact and race-free:
| Kernel | Credits per peer cell | Epilogue | |
|---|---|---|---|
allreduce (mesh) |
1 + num_chunks |
-(read_done_expected - 1) |
correct |
allreduce_ring |
1 per row, 2*(NR-1)+1 rows |
-1 per row |
correct — round ends at exactly expected_rounds - 1, no row missed |
barrier / allgather / all_to_all |
1 | -1 |
correct |
Also confirmed: every early-return path (numel <= 0, invalid nranks, ring signal-shape guard) returns before any notify, so no negative cells are left behind; and a local TNOTIFY(signal_base + peer, ...) is the identity mapping of CommRemotePtr(ctx, ptr, my_rank), matching the InCore self-notify.
Three things need to change before this can land, plus one I'd suggest splitting out.
1. Set(0) epilogue in broadcast / reduce_scatter must become AtomicAdd(-credits) — blocking
The protocol this PR mirrors states the invariant explicitly (lower_composite_ops_pass.cpp:100-102):
For the same reason
kSetmust never be mixed withkAtomicAddon the same cells — a set could clobber an already advanced counter.
broadcast and reduce_scatter notify with AtomicAdd(+1) in the body but reset with Set(0), which is exactly the forbidden combination. Concrete deadlock (reduce_scatter, P >= 2):
rank B: last wait passes -> Set(0) on its own cells -> returns -> next call starts
-> AtomicAdd(+1) into rank A's cell[B]
rank A: last wait passes only now -> Set(0) wipes B's freshly written credit
rank A: next call waits cell[B] >= 1 -> hangs forever (B will not notify again)
Broadcast has the same shape: the root can return once it has collected every non-root's read-complete notify, and its next-call +1 into a non-root's cell[root] lands while that non-root is still between its own last notify and its Set(0).
The comment's premise — "an owner never writes again until the next call (host-serialized)" — is what fails: the next call is the owner's next write, and the window is precisely between "my last wait passes" and "I execute Set(0)". Per-rank streams are dispatched independently; there is no implicit cross-rank sync between kernel launches.
Worth noting the InCore rail already ships AtomicAdd(-N) for both of these collectives:
LowerTensorBroadcastRule—EmitBarrierx1 +EmitEpilogueReset(total=1)LowerTensorReduceScatterRule—EmitBarrierx2 (ready + post-reduce) +EmitEpilogueReset(total=2)
The only NotifyOp::kSet in that whole file (:2133) is all_to_all_v publishing recv_counts data, not a barrier signal. So the two rails currently disagree on the protocol for the same op.
On the "AtomicAdd deadlocks on 910B" observation: an off-by-one in the credit count looks like a much more likely root cause than a hardware issue. Broadcast's expected ends at num_tiles + 1, so the -expected written in the PR description over-subtracts by one, leaving the cell at -1; the next call's Ge(1) then needs +2 to release — which is exactly the reported symptom. Correct counts:
// broadcast — every cell receives exactly num_tiles credits this call
const int32_t credits = expected - 1;
// reduce_scatter — ready(1) + one per tile, identical to the mesh allreduce
const int32_t credits = read_done_expected - 1;Please re-test on silicon with these before concluding that Set is required. If it still deadlocks, that would implicate the other five kernels and the already-merged InCore path equally, and the root cause needs to be found before settling the protocol — rather than shipping two mutually exclusive schemes in one commit.
2. Remove the redundant notifies in broadcast
Two layers of redundancy in the restructured loop:
- Non-roots notify every peer, but only the root's cell is ever waited on. A non-root waits only on
cell[kRoot], so of itsP-1notifies per tile,P-2land in another non-root'scell[me]where nobody ever reads them — they just accumulate garbage credits (which is part of whySet(0)looked necessary). - The root's per-tile read-complete wait is not needed within a call. The root writes tile
tonly in iterationt, and each iteration has a distinctbase, so there is no intra-call WAR. That wait only exists to stop the root from returning and letting its window be reused by the next host statement — which needs to happen once, after the loop. That is the "final read-complete round" the PR description proposes; the code does it per tile, which puts every rank in lockstep on every tile and paces the whole broadcast at the slowest readerTtimes.
int32_t expected = 1;
for (int64_t base = 0; base < numel; base += kTileCount) {
// ... shape / stride ...
if (my_rank == kRoot) {
TLOAD(stage_tile, target_g); /* ... */ TSTORE(target_g, stage_tile); /* ... */
pipe_barrier(PIPE_ALL);
for (int peer = 0; peer < nranks; ++peer) { // root only: data-ready
if (peer == my_rank) continue;
pto::comm::Signal sig(CommRemotePtr(comm_ctx, signal_base + my_rank, peer));
pto::comm::TNOTIFY(sig, static_cast<int32_t>(1), pto::comm::NotifyOp::AtomicAdd);
}
} else {
pipe_barrier(PIPE_ALL);
pto::comm::Signal sig(signal_base + kRoot);
pto::comm::TWAIT(sig, expected, pto::comm::WaitCmp::GE);
// TLOAD(root's target + base) -> TSTORE(own target + base)
}
++expected;
pipe_barrier(PIPE_ALL);
}
const int32_t tiles = expected - 1;
// One read-complete round after the loop, then a role-aware credit reset.
if (my_rank == kRoot) {
for (int peer = 0; peer < nranks; ++peer) {
if (peer == my_rank) continue;
pto::comm::Signal sig(signal_base + peer);
pto::comm::TWAIT(sig, static_cast<int32_t>(1), pto::comm::WaitCmp::GE);
}
for (int peer = 0; peer < nranks; ++peer) { // each non-root sent exactly 1 credit
if (peer == my_rank) continue;
pto::comm::Signal self_sig(signal_base + peer);
pto::comm::TNOTIFY(self_sig, static_cast<int32_t>(-1), pto::comm::NotifyOp::AtomicAdd);
}
} else {
pto::comm::Signal sig(CommRemotePtr(comm_ctx, signal_base + my_rank, kRoot));
pto::comm::TNOTIFY(sig, static_cast<int32_t>(1), pto::comm::NotifyOp::AtomicAdd);
pto::comm::Signal self_sig(signal_base + kRoot); // root sent one credit per tile
pto::comm::TNOTIFY(self_sig, -tiles, pto::comm::NotifyOp::AtomicAdd);
}
pipe_barrier(PIPE_ALL);Remote atomics per tile drop from P*(P-1) to P-1, plus a one-off 2*(P-1) at the end. For P=8 broadcasting 1M elements (4096 tiles) that is roughly 229k -> 29k, about 8x fewer. Non-roots no longer write each other's cells at all, which also removes the last motivation for Set(0).
No new hazard: the root's tile t+1 self-copy and a non-root's tile t read touch different regions, and the ordering that matters (root stores before it notifies; a non-root waits for that notify before it loads) is unchanged.
3. Contradictory error message and docs
synthesize_allreduce_signals_pass.cpp:190-191still tells users "The signal protocol is single-use and cannot reuse a signal across dynamic invocations." — which directly contradicts the op descriptions and docs this PR updates. Users would see both "self-clearing, reusable" and "single-use" from the same release. It should state the real reason (the HOST-rail signal synthesis limitation), not a protocol property that no longer holds.docs/{en,zh}/user/distributed/04-debugging.md: the new remediation "or bind an explicit signal before the loop" does not work.CheckAllReduceCall(call)runs before theargs_.size() == 2branch in all three entry points (AssignStmt/EvalStmt/ReturnStmt), so an explicit signal is rejected just the same. Please drop that half-sentence.- If two different epilogue schemes end up surviving (see #1), the docs' blanket claim that
pld.tensor.*signals are reusable needs to be scoped to "between calls of the same collective" — a buffer shared across aSet-based and anAtomicAdd-based collective would corrupt in both directions.
4. Lifting the in-loop allreduce restriction — suggest a follow-up PR
Worth recording since this PR's premise removes the stated justification for it.
pl.range is not unrolled — UnrollLoops only expands ForKind::Unroll (its own error message says "use pl.range() instead"). So the for rd in pl.range(ROUNDS) in this PR's broadcast / reduce_scatter reuse tests is a live ForStmt that reaches codegen and passes in sim. That is direct evidence the host path (MaterializeCommDomainScopes -> LowerHostTensorCollectives -> codegen) already handles a host collective inside a loop; allreduce is blocked solely by the CheckAllReduceCall guard.
The two paths differ in cost though:
args_.size() == 2(explicit signal) — the pass synthesizes nothing here, it just visits the args and returns. With the signal now restarting at generation 1 every call and cross-rank skew bounded to one call, this is the same situation as back-to-back calls. Moving therepeating_scope_depth_check after theargs_.size() == 1branch is enough, plus afor rd in pl.range(N): allreduce(data, signal)ST.args_.size() == 1(synthesized signal) —MakeSignalBindinginsertspld.system.world_size+pld.tensor.alloc_window_buffer+pld.tensor.windowimmediately before the allreduce statement, so inside a loop it would allocate a window buffer per iteration. That is wrong regardless of the barrier protocol (repeated allocation under one name, and every rank must land on the same symmetric window). The alloc needs hoisting to the nearest non-loop scope — or straight to thehost_orchentry, since one self-clearing signal serves every iteration.
Suggest doing this separately: it touches a different pass and needs its own tests, and folding it in would blur the silicon-validation scope of this PR — which right now should be focused on whether broadcast / reduce_scatter still deadlock once they use AtomicAdd(-credits).
7798149 to
fb09fe1
Compare
|
@YunjiQin thanks for the thorough review — all three blocking/actionable items are addressed on the pushed head 1.
2. Broadcast redundant notifies removed. Restructured to your suggested shape: the root is the only per-tile notifier (one 3. Contradictory error message and docs fixed. 4. In-loop allreduce lift is not folded into this PR, as you recommended — tracked separately (issue + plan) so the silicon-validation scope stays on broadcast/reduce_scatter Verified in sim: gate UT 74 + materialize UT 39 passed; all 7 host-collective ST files 34/34 passed (including broadcast + reduce_scatter signal-reuse at P=2/P=4). NPU verification pending on the developer gate. |
fb09fe1 to
3dc3113
Compare
Rebased onto origin/main + multicore integrationRebased the branch onto the latest 1. Allreduce epilogue now resets the block's own signal lane. The self-clearing epilogue previously reset 2. Adapted the #2160 multicore ST test to self-clearing semantics. The multicore test observed signal lanes with Sim verification (a2a3sim): UT 121 passed; ST 37/37 passed (base + signal-reuse for all seven collectives P=2/P=4, plus the three multicore cases). NPU (onboard) verification is still pending. |
…ernels The host collective builtin kernels used single-shot credit barriers: each call issues TNOTIFY(+1)/TWAIT(Ge(1)) (or Set(1)) but never restored the INT32 signal cells, so after a call every peer's cell was left non-zero. Any reuse of the same signal buffer made the next call's Ge(1) wait pass spuriously on stale credits — the barrier stopped synchronizing and results were corrupted. The sim executor is sequentially consistent, so this was invisible in CI sim runs but real on non-coherent NPU silicon. Add a self-clearing epilogue to every host collective builtin kernel (allreduce mesh/ring, reduce_scatter, broadcast, barrier, allgather, all_to_all) that mirrors the InCore composite credit-barrier protocol already shipped for pld.tensor.* collectives (hw-native-sys#2175). After the final barrier of each call, every rank locally subtracts the credits it accumulated from every peer's signal cell via a local-address TNOTIFY AtomicAdd(-N), so the signal is provably all-zero again before the kernel returns. All bodies notify with AtomicAdd only, and every reset is AtomicAdd(-N) — never Set, which can clobber an already advanced counter and deadlock the next call. Broadcast is restructured so only the root notifies during the tile loop (non-roots wait solely on the root's cell) and the read-complete barrier runs once after the loop; this drops remote atomics from P*(P-1) per tile to P-1 per tile plus a one-off 2*(P-1) round. Reduce_scatter subtracts read_done_expected - 1, matching the mesh allreduce credit count. Rebased onto the multicore HOST AllReduce (hw-native-sys#2160): the allreduce epilogue resets the block's own signal lane (peer * signal_stride + block_idx) instead of the rank's first cell, and the multicore ST test waits for each lane to self-clear back to zero (Eq(0)) instead of observing >= 1 after the call — under self-clearing semantics a lane at zero proves the owning block started and completed its epilogue. A multicore signal-reuse ST runs two back-to-back calls through ONE shared signal and checks both rounds' output plus the post-call zero lanes, proving the per-lane epilogue makes the multicore signal reusable across calls. The synthesized-signal rejection and its docs now state the real reason for the HOST in-loop restriction (signal synthesis cannot allocate a fresh buffer per dynamic iteration) instead of claiming the protocol is single-use. Add signal-reuse system tests — back-to-back calls through ONE shared signal, with distinct per-round payloads so a stale-credit pass fails loudly — for all seven collectives at P=2/P=4 (barrier and reduce_scatter at P=2). Update docs (en/zh) to describe host collective signals as self-clearing and reusable rather than single-shot. Sim UT: 121 passed. Sim ST: 39/39 passed (base + signal-reuse for all seven collectives P=2/P=4, plus multicore lanes P2/P4 x core_num 2/4 and multicore signal-reuse P2 x c2/c4). NPU verification (developer gate) pending for the AtomicAdd(-N) reset.
3dc3113 to
c012907
Compare
|
Added a multicore signal-reuse ST (two back-to-back calls through ONE shared signal, distinct per-round payloads, |
…e epilogue The asymmetric read-complete round (non-root remote AtomicAdd → root local TWAIT alone) deadlocked on non-coherent NPU silicon: the root polled a locally-cached zero while the remote update bypassed the cache. Revert to the symmetric notify-all-peers + wait-all pattern that every other host collective builtin uses and is verified reliable on silicon. The tile loop now has every rank (root and non-root) remote-notify all peers and locally wait for all peers per tile, followed by a symmetric local AtomicAdd(-tiles) credit reset epilogue — no role-aware branching. NPU verification (8× 910B2, PTOAS 0.54): - test_host_tensor_broadcast[2]: PASSED - test_host_tensor_broadcast_signal_reuse[2]: PASSED - test_host_tensor_broadcast_signal_reuse[4]: PASSED - test_multicore_output_and_signal_lanes[p2-c4-wide-stride]: PASSED - test_multicore_output_and_signal_lanes[p4-c2-multichunk]: PASSED - test_multicore_allreduce_signal_reuse[reuse-p2-c2-idle-lane]: PASSED
Non-coherent NPU silicon cannot guarantee a consume_step AIV task sees the previous SPMD allreduce kernel's AtomicAdd(-N) writes from cache. The TWAIT(Eq 0) + pl.load(signal) in the reuse test's consume_step reads stale values and fails. Output correctness across back-to-back rounds is already the definitive proof of correct signal reuse — the same proven pattern used by the single-core test_host_tensor_allreduce _signal_reuse which passes on CI. Direct signal lane verification for the same kernel parameters remains covered by test_multicore_output_and_signal_lanes[p2-c4-wide-stride].
…er signal reset On non-coherent NPU silicon, a new AIV task dispatch may read stale cached signal cell values. AtomicAdd(-N) reads-modifies-writes that stale value, which can leave signal cells nonzero, causing asymmetric synchronization between ranks and deadlocking subsequent calls. Use NotifyOp::Set(0) to unconditionally write zero — safe because the per-tile pipe_barrier(PIPE_ALL) guarantees all peers' AtomicAdd credits have already landed. Fixes test_host_tensor_reduce_scatter_signal_reuse[2] SCHEDULER_TIMEOUT.
|
@YunjiQin — heads-up on two follow-up fixes pushed after the on-silicon NPU gate flagged failures: Fix 1: Multicore allreduce signal reuse test (676a341)
Fix 2: Reduce scatter signal reuse (aa2172b)
Both verified locally on NPU devices 0,1. The |
…dead code - all_to_all_v Host kernel: convert Phase 2 barrier from Set(1) to AtomicAdd(+1) and add self-clearing AtomicAdd(-1) epilogue, matching all_to_all, allgather, and barrier. The InCore path already had this via LowerCompositeOpsPass; this closes the Host gap so the docs in collective.cpp / tensor_ops.py (IR+DSL) are now consistent with the code. - reduce_scatter kernel: remove unused barrier_count variable left over after the AtomicAdd(-N) -> Set(0) change.
Motivation
The host collective builtin kernels use single-shot credit barriers: each call issues
TNOTIFY(+1)/TWAIT(Ge(1))(orSet(1)) but never restores the INT32 signal cells, so after a call every peer's cell is left non-zero. Any reuse of the same signal buffer makes the next call'sGe(1)wait pass spuriously on stale credits — the barrier stops synchronizing and results are corrupted. The sim executor is sequentially consistent, so this is invisible in CI sim runs but real on non-coherent NPU silicon.Change
Add a self-clearing epilogue to every host collective builtin kernel that mirrors the InCore composite credit-barrier protocol already shipped for
pld.tensor.*collectives (#2175,lower_composite_ops_pass.cppEmitEpilogueReset): after the final barrier of the call, each rank locally resets every peer's signal cell, so the signal is provably all-zero again before the kernel returns.allreduce(mesh)+1+ one per UB chunkTNOTIFY(-(read_done_expected - 1), AtomicAdd)allreduce_ring+1per usedRoundBarrierrowTNOTIFY(-1, AtomicAdd)per row × peerreduce_scatter+1+ one per tileTNOTIFY(0, Set)broadcastTNOTIFY(-expected, AtomicAdd)barrier/allgather/all_to_all+1(single barrier)TNOTIFY(-1, AtomicAdd)All bodies use
AtomicAddin the main loop (single-writer pattern: each rank writes into its own cell on every peer). In the epilogue, most kernels restore zero withAtomicAdd(-N)on the local address.reduce_scatterusesSet(0)because on non-coherent NPU silicon a new AIV task dispatch may read a stale cached cell value —AtomicAddwould read-modify-write that stale value, whileSetunconditionally writes zero.Setis safe here because the per-tilepipe_barrier(PIPE_ALL)guarantees all peers' credits have already landed; there are no in-flight writes to clobber.Broadcast additionally gains a final read-complete round after its tile loop: each rank notifies peers only after its last tile read finishes, so a fast root cannot return and have its window reused while a slow peer still reads the last tile (mirrors the ring kernel's final
RoundBarrier).Tests
Signal-reuse STs — back-to-back calls through ONE shared signal, with distinct per-round payloads so a stale-credit pass fails loudly — for all seven collectives at P=2/P=4 (barrier and reduce_scatter at P=2):
Verification
--forked --platform=a2a3sim --device=0,1,2,3), excluding 4 pre-existing Max/Min/FP16 sim SIGSEGVs (reproduced on unmodified kernels).cpp.intemplates + Python tests changed)Follow-up
The in-loop allreduce restriction lift (shared per-
host_orchsignal synthesis inSynthesizeAllReduceSignals) is tracked separately as #2310, per the review recommendation.