feat(distributed): self-clearing barrier signals in host collective kernels - #2279
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. |
3dc3113 to
c012907
Compare
|
Added a multicore signal-reuse ST (two back-to-back calls through ONE shared signal, distinct per-round payloads, |
|
@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 |
…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.
…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.
…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.
…ent NPU safety Replace AtomicAdd(-1) with Set(0) in the barrier self-clearing epilogue. On non-coherent NPU silicon, a new AIV task dispatch may read a stale cached value; AtomicAdd reads-modifies-writes that stale value, while Set unconditionally writes 0. Matches the same fix already applied to the reduce_scatter kernel.
84729fb to
07f8459
Compare
The Set(0) reset was introduced on the premise that a new AIV task dispatch may read a stale cached value for a signal cell on non-coherent silicon, so that AtomicAdd would read-modify-write stale data. Hardware measurement does not support that premise: the wrong results it was meant to address came from missing intra-core pipeline dependencies in the reduce_scatter kernel, not from signal cache coherence. Every cache-directed remedy tried against those failures - dcci variants, atomic-mode resets, extra barriers, and this reset form - left the failure rate unchanged. AtomicAdd(-1) is what the protocol needs: the reset must stay commutative with a fast peer's next-call +1. A peer that has already cleared its own cells may re-enter the kernel and notify us while we are still in the epilogue; Set(0) wipes that in-flight credit and deadlocks the peer's following wait, whereas the subtraction commutes with it. Exactly one credit per peer cell arrives per call, so -1 restores zero. Verified on hardware over 100 rounds each for the single-call and the signal-reuse paths of the host barrier ST.
The host reduce_scatter kernel reduced the whole [NR, SIZE] window on every
rank and sliced afterwards, so each rank paid NR times the remote reads, the
vector work and the write-back needed for the chunk it actually keeps. It
also had to raise a read-complete barrier per UB tile, because every rank
wrote rows that its peers were still reading.
Reduce only row `my_rank` instead. Rank r reads row r from each peer and
writes only row r of its own window; no peer ever reads row r there, so reads
and writes are region-disjoint across ranks. That removes the intra-call WAR
hazard, so the store stays inside the tile loop and one read-complete barrier
covers the whole row - the credit total is a constant 2 regardless of SIZE,
matching the InCore lowering, instead of growing with the tile count.
Two intra-core pipeline dependencies were also missing, which is what made
this kernel return wrong sums in 13 of 30 hardware runs:
MTE2 -> MTE3 the own-row TLOAD lands before the TSTORE
MTE3 -> MTE2 this tile's TSTORE lands before the next tile's TLOAD
Neither was expressed, so the store raced the load that produced it. The
failure rate tracked timing, which is why perturbing the signal protocol
appeared to help or hurt without ever addressing the cause. The corrected
synchronisation was derived by writing this same algorithm in PTOAS MLIR,
letting `ptoas --enable-insert-sync` insert the pipeline events, and matching
this kernel's instruction stream against the generated one.
The epilogue returns to AtomicAdd(-2) for the reason given in the barrier
kernel commit: the reset must commute with a peer's next-call credit.
Verified on hardware over 100 rounds each for the single-call and a
back-to-back signal-reuse case of the host reduce_scatter ST, across four
device pairs.
…ignal
The kernel built each signal cell as a 1x1 GlobalTensor with an explicit
shape and stride, which is what the generic lowering path emits. comm::Signal
is an alias for exactly that tensor:
using Signal = GlobalTensor<int32_t, Shape<1,1,1,1,1>, Stride<1,1,1,1,1>,
Layout::ND>;
and the two spellings are interchangeable here. TNOTIFY reads only data();
TWAIT walks the 5-D shape, but with every extent at 1 the traversal visits
index 0 alone, so neither the stride nor the layout is ever consulted. Using
the alias drops the local type aliases and two lines per call site, and
matches how every other hand-written collective kernel addresses its signals.
No behavioural change intended. Verified on hardware over 100 rounds of the
single-call path and 50 of the signal-reuse path.
… explicit SynthesizeAllReduceSignals rejected every HOST-rail allreduce inside a for or while loop, but the reason it gives - that the synthesized signal binding cannot be allocated once per dynamic iteration - only applies when the call omits its signal. With an explicit signal there is nothing to synthesize: the pass already returns such a call unchanged, and the lowered kernel now restores the signal cells to zero before returning, so the same buffer can be carried across iterations. Gate the check on the omitted-signal case and say so in the diagnostic. The two unit tests that asserted the explicit-signal loop was rejected now assert it is accepted.
…r credits hw-native-sys#2279 (merged) added a self-clearing signal epilogue to the pull-model ring kernel: it reset every peer cell with TNOTIFY(-1) per round, matching the RoundBarrier's notify-all credit pattern. The TPUT push model replaces the barrier with the O(1) NeighborBarrier, whose credit pattern is different: * NeighborBarrier notifies only the two ring neighbours per round, so only the left/right cells of each used row carry a +1 (and for nranks == 2 both neighbours are the same peer, so that single cell carries +2 from two AtomicAdd notifies). * Resetting all P-1 cells (the hw-native-sys#2279 loop) would therefore corrupt the unused cells to -1 and, for nranks == 2, leave +1 stale credit in the one used cell — reintroducing the exact stale-credit barrier failure hw-native-sys#2279 fixed. The epilogue now branches on kUseNeighborBarrier: reset the two neighbour cells with TNOTIFY(-1) each (twice on the shared cell when nranks == 2, since left == right), and keep the all-peer reset for the RoundBarrier fallback. Verified against the hw-native-sys#2279 ring signal-reuse ST contract (one signal buffer reused across back-to-back calls).
…nal reuse The ring host builtin is now self-clearing (adapted epilogue): after the final barrier it restores every used barrier row to zero — the two neighbour cells per round for NeighborBarrier (twice on the shared cell when nranks == 2), all P-1 cells for the RoundBarrier fallback — so a single signal buffer can be reused across back-to-back calls like the other host builtins (hw-native-sys#2279). Adds the note to docs 42 in en + zh (parity).
…r credits hw-native-sys#2279 (merged) added a self-clearing signal epilogue to the pull-model ring kernel: it reset every peer cell with TNOTIFY(-1) per round, matching the RoundBarrier's notify-all credit pattern. The TPUT push model replaces the barrier with the O(1) NeighborBarrier, whose credit pattern is different: * NeighborBarrier notifies only the two ring neighbours per round, so only the left/right cells of each used row carry a +1 (and for nranks == 2 both neighbours are the same peer, so that single cell carries +2 from two AtomicAdd notifies). * Resetting all P-1 cells (the hw-native-sys#2279 loop) would therefore corrupt the unused cells to -1 and, for nranks == 2, leave +1 stale credit in the one used cell — reintroducing the exact stale-credit barrier failure hw-native-sys#2279 fixed. The epilogue now branches on kUseNeighborBarrier: reset the two neighbour cells with TNOTIFY(-1) each (twice on the shared cell when nranks == 2, since left == right), and keep the all-peer reset for the RoundBarrier fallback. Verified against the hw-native-sys#2279 ring signal-reuse ST contract (one signal buffer reused across back-to-back calls).
…nal reuse The ring host builtin is now self-clearing (adapted epilogue): after the final barrier it restores every used barrier row to zero — the two neighbour cells per round for NeighborBarrier (twice on the shared cell when nranks == 2), all P-1 cells for the RoundBarrier fallback — so a single signal buffer can be reused across back-to-back calls like the other host builtins (hw-native-sys#2279). Adds the note to docs 42 in en + zh (parity).
…r credits hw-native-sys#2279 (merged) added a self-clearing signal epilogue to the pull-model ring kernel: it reset every peer cell with TNOTIFY(-1) per round, matching the RoundBarrier's notify-all credit pattern. The TPUT push model replaces the barrier with the O(1) NeighborBarrier, whose credit pattern is different: * NeighborBarrier notifies only the two ring neighbours per round, so only the left/right cells of each used row carry a +1 (and for nranks == 2 both neighbours are the same peer, so that single cell carries +2 from two AtomicAdd notifies). * Resetting all P-1 cells (the hw-native-sys#2279 loop) would therefore corrupt the unused cells to -1 and, for nranks == 2, leave +1 stale credit in the one used cell — reintroducing the exact stale-credit barrier failure hw-native-sys#2279 fixed. The epilogue now branches on kUseNeighborBarrier: reset the two neighbour cells with TNOTIFY(-1) each (twice on the shared cell when nranks == 2, since left == right), and keep the all-peer reset for the RoundBarrier fallback. Verified against the hw-native-sys#2279 ring signal-reuse ST contract (one signal buffer reused across back-to-back calls).
…nal reuse The ring host builtin is now self-clearing (adapted epilogue): after the final barrier it restores every used barrier row to zero — the two neighbour cells per round for NeighborBarrier (twice on the shared cell when nranks == 2), all P-1 cells for the RoundBarrier fallback — so a single signal buffer can be reused across back-to-back calls like the other host builtins (hw-native-sys#2279). Adds the note to docs 42 in en + zh (parity).
…nCore composite (#2280) ## Summary Replaces the **pull-model** engine of the ring allreduce on **both rails** with a **TPUT push model** (remote write), enabling O(1) `NeighborBarrier` on the HOST builtin and eliminating the pull-model NPU memory-ordering gap. - **HOST builtin** (`builtin.tensor.allreduce_ring`): reduce-scatter + allgather converted from `TLOAD`/`TSTORE` pull to `pto::comm::TPUT` push — `TPUT<AtomicAdd>` remote-accumulate for RS, non-atomic `TPUT` for AG. Ordering is `pipe_barrier(PIPE_ALL)` around every transfer + `dsb(DSB_DDR)` before `TNOTIFY` (mirrors the in-tree allgather/all_to_all host builtins; not a GM fence). The O(P²) `RoundBarrier` is replaced by the O(1) `NeighborBarrier` (notify/wait the two ring neighbours only), which is NPU-safe because the TPUT write pipeline orders the data ahead of the signal — the pull model could not provide that. - **InCore composite** (`LowerTensorRingAllReduceRule`): replaces `pld.tile.remote_load` pulls with `pld.tile.put` pushes (non-atomic TPUT + local reduce, **preserving Sum/Max/Min/Prod**). Race-free per-subchunk protocol: own-value read → ready barrier → push to right neighbour → push-done barrier → local read+reduce+store; barrier credits stay 2 per subchunk (signal shape unchanged). Ragged/arbitrary lengths and FP16 are preserved via balanced segments + valid shapes, with the shared VEC staging tile narrowed per transfer via `tile.set_validshape`. ## Requires PTOAS >= v0.55 (pypto pins v0.57) **This PR depends on [PTOAS v0.55](https://github.com/hw-native-sys/PTOAS/releases/tag/v0.55)** (release: [hw-native-sys/PTOAS#1069](hw-native-sys/PTOAS#1069), fixed in [PR #1079](hw-native-sys/PTOAS#1079)). The InCore composite's `pld.tile.put` transfers carry the **exact ragged `valid_cols`** as the partition-view extent. PTOAS ≤ v0.54 rejects dynamic partition-view shapes for `pto.comm.tput` (`'pto.comm.tput' op expects dst to have a positive static shape`), so the pure push model cannot compile below v0.55. The HOST builtin does not depend on this (its kernel is hand-written), but the composite rail does. The requirement is satisfied by the current pin: pypto now pins **PTOAS v0.57** (via #2291). The PR is rebased onto current `main` (2026-08-26, was 76 commits behind; re-rebased twice 2026-08-27 — first onto the #2530 runtime bump adopting the `ChipTensor`→`TaskTensor` kernel rename, then onto #2542 adopting the `42-lower_host_tensor_collectives` → `43-…` docs rename, with the PR's ring-doc edits re-homed) and merges cleanly. The UT tests pin the push structure (`pld.tile.put` + `tile.create` staging tile instead of `pld.tile.remote_load`). ## Rebased — merge-order with #2279 (self-clearing signals) resolved The rebase picked up #2279's self-clearing signal epilogue, which was written for the **pull-model `RoundBarrier`** (reset every peer's cell with `TNOTIFY(-1)` per round). That credit pattern does **not** match the push model's `NeighborBarrier`: - `NeighborBarrier` credits only the **two ring neighbours** per round — a single cell when `nranks == 2`, where both neighbours are the same peer and the cell carries two +1s. - The #2279 loop would corrupt the unused cells to −1 and, for `nranks == 2`, leave +1 stale credit in the one used cell — reintroducing the exact stale-credit barrier failure #2279 fixed. The epilogue now branches on `kUseNeighborBarrier`: it restores only the two neighbour cells per used row with `TNOTIFY(-1, AtomicAdd)` (twice on the shared cell when `nranks == 2`), keeping the all-peer reset for the `RoundBarrier` fallback. The ring builtin is therefore **self-clearing and signal-reuse-safe** across back-to-back calls, matching the other host builtins (#2279). The ring signal-reuse ST (`test_l3_host_tensor_allreduce_ring.py` reuse leg) is the NPU gate for the adapted epilogue. ## Issues this PR addresses - **#2242 (ring unaligned-data handling)**: the pull-model dcci-flush tail gap (item 1) is **moot** — the push model needs no cacheline flush (the receiver reads data the sender wrote remotely via TPUT, never a locally-TSTORE'd line). The 32-byte transfer-alignment concern (item 2) is handled by narrowing the staging tile's column mask (`ColMaskInternal` / `tile.set_validshape`) to the exact (possibly ragged) transfer extent, so partial tails transfer exactly and never over-read/overwrite adjacent slots. - **#2213 (PTOAS dynamic partition-view)**: closed as superseded by #2524; the `>= v0.55` dependency it describes is satisfied by the v0.57 pin. ## Verification (NPU silicon, 910B2, PTOAS v0.55) All on real NPUs (8x 910B2), P=2 and P=4: - `tests/st/distributed/test_l3_host_tensor_allreduce_ring.py` — HOST ring, P=2/4 ✅ (with `NeighborBarrier` enabled) - `tests/st/distributed/collectives/test_l3_tensor_allreduce_ring_intrinsic.py` — InCore ring, P=2/4, sizes {1, 17, 4097, 65537} (ragged + >UB), Sum/Max/Min/Prod, FP16 ✅ - `tests/st/distributed/collectives/test_l3_allreduce_ring.py` + `test_l3_ring_sizing_prewarm.py` — no regression ✅ - UTs: `test_lower_host_tensor_collectives.py`, `test_host_orch_distributed.py`, `test_lower_composite_ops.py` (+ numerical) all green ✅ **Total: 25/25 ST + 190 UT passed** (pre-rebase). The 2026-08-26 rebase + epilogue adaptation re-ran the ring UTs (173/174, the one failure is a pre-existing parser `TileView(pad=…)` roundtrip gap on main, unrelated to this PR); the 2026-08-27 re-rebases (onto the #2530 runtime bump and the #2542 docs rename) each re-ran the same 173/174. NPU ST should be re-confirmed for the signal-reuse leg. ## Trade-off note (ReduceOp) The HOST builtin is `ReduceOp::kSum` only by construction, so its `TPUT<AtomicAdd>` RS is fine. The composite keeps non-atomic push + local reduce to preserve Sum/Max/Min/Prod; only a remote-atomic `TPUT<AtomicAdd>` variant would be Sum-only (`AtomicType` has no `AtomicMax/Min`). ## Follow-ups (not in this PR) - **#2310** — lift the HOST-rail in-loop `pld.tensor.allreduce` restriction via shared-signal synthesis (the other half of the #2279 review). - **TPUT_ASYNC** (pto-isa) for an overlapped / IBing forward phase — optional perf follow-on (simpler #1383 / plan 50). ## Review notes - Addresses CodeRabbit feedback: the allgather ready-barrier rationale is corrected (counters are per-round; the real guarantee is the previous round's push-done barrier every rank passes before round k), and the `nranks == 2` `NeighborBarrier` behaviour is documented.
…r lineage (#2504) ## Summary Synthesize one shared allreduce signal per **data-buffer lineage (device-coverage) group** in a host-orchestration function — hoisted to the top of the body — instead of a fresh signal per implicit-signal call. Every implicit-signal `pld.tensor.allreduce` call is rewritten to pass the signal shared by calls over the same data buffer, including calls inside `for` / `while` loops. This lifts the host-rail in-loop restriction that previously forced an error (`Allreduce rejected inside loop`) for the *implicit*-signal (single-argument) form. It is safe because the host builtin kernels now self-clear their barrier cells after every call (landed in #2279), so a reused signal is correct across back-to-back and loop-carried calls. Signals are keyed by **data-buffer lineage** rather than one-per-function: tracing each data argument back through `pld.tensor.window` to its `pld.tensor.alloc_window_buffer` LHS means a single function that implicitly reduces buffers over *different* device subsets gets *distinct* signals. Each `(data, signal)` pair therefore stays in its own comm-domain scope — a single per-function signal would merge both subsets into one scope and make `LowerHostTensorCollectives` reject every call. Closes #2310. ## Why this is now unblocked #2279 folded the *explicit*-signal in-loop lift into itself (`CheckAllReduceCall` now rejects only the omitted-signal case). This PR covers the remaining *implicit*-signal (synthesized) case — the one #2310 tracks. ## What changed - **`SynthesizeAllReduceSignals`** (`synthesize_allreduce_signals_pass.cpp`): synthesize one shared signal per data-buffer lineage group instead of per call or per function. An `AllReduceSignalNeedFinder` pre-scan records the 1-arg calls (`{data, core_num}`) and each `Var`'s defining RHS; `ResolveLineageKey` follows a data `Var`'s def chain (through `Var` aliases, `pld.tensor.window`, `pld.tensor.allreduce`, and the target args of `all_to_all`/`allgather`/`all_to_all_v`) back to its `alloc_window_buffer` LHS. Calls are partitioned by that lineage key in first-appearance order, each group gets one hoisted `world_size` / `alloc_window_buffer` / `window` binding sized to the group's widest `core_num`, and `AllReduceSignalSynthesizer` resolves each 1-arg call's signal through a `SignalLookup` keyed by lineage. The 2-arg (explicit-signal) path is unchanged, and the loop-depth rejection path is removed. - **`MaterializeCommDomainScopes`** (`materialize_comm_domain_scopes_pass.cpp`): handle SSA loop-carried data. `ConvertToSSA` runs before this pass, and a loop-carried value is an `IterArg` (its own `ObjectKind`, so `As<Var>` misses it): `ResolveWindowAlloc` / `ResolveWindowRecord` / the dispatch-arg site use `AsVarLike` and resolve an `IterArg` through its `initValue_` (same pattern as `InferTileMemorySpace` #2547). The view substitution additionally re-types collective results and loop carries (iter_args/return_vars) with the shared `WindowBuffer` so the typecheck verifier's pointer-identity check holds across the loop-carry edges. - **Simplify** (`simplify_pass.cpp`): remap `DistributedTensorType::window_buffer_` in lockstep with the `CommDomainScopeStmt` slots. `Simplify` folds the synthesized signal size `world_size * 1 * 4` → `world_size * 4`, minting a fresh `WindowBuffer` for the scope slot; previously the type rebuild left `window_buffer_` pointing at the pre-fold object, so `DistributedCodegen::ScopeForWindowBuffer`'s pointer-identity scan failed with "not a slot of any open CommDomainScopeStmt". This surfaced only in distributed sim (the shared signal allocates inside a foldable expression). - **Pass properties** (`pass_properties.h`): record the SSA single-assignment assumption on `kSynthesizeAllReduceSignalsProperties` (SSAForm is deliberately not declared `required` — `kInitMemRefProperties` invalidates it and nothing re-produces it); multi-assignment robustness is a tracked follow-up shared with `MaterializeCommDomainScopes`. - **Unit tests** (`test_materialize_comm_domain_scopes.py`): the harness now runs `ConvertToSSA` first (matching the real pipeline; `name_hint` assertions updated for the SSA renames); loop-rejection tests became positive shared-signal/in-loop acceptance tests; added `test_implicit_allreduce_over_distinct_subsets_gets_distinct_signals` (two buffers dispatched to `{0,1}` and `{2,3}`, each implicitly reduced → two distinct signals and two comm-domain scopes) and `test_implicit_allreduce_core_num_widening_sizes_signal_to_max` (two implicit calls, `core_num` 1 and 4, share one signal sized to 4 — the `allow_wider_lanes` contract). `test_simplify_pass.py` adds `TestDistributedWindowBufferRemap::test_window_buffer_remapped_in_lockstep_with_scope_slot`, asserting the view Var's `window_buffer` and the scope slot are the same object post-fold (reverting the `simplify_pass.cpp` hunk fails it). - **System test** (`test_l3_host_tensor_allreduce.py`): add `test_host_tensor_allreduce_loop` covering implicit-signal allreduce in a loop, with per-round distinct inputs so a missed epilogue reset on a reused signal cannot pass spuriously. - **Docs** (en + zh): rewrite the pass algorithm for the shared-signal-per-lineage model; drop the "Allreduce rejected inside loop" debugging row; note signal reusability for the self-clearing host builtins and that distinct data buffers get distinct signals. ## Test plan - Sim UT: full `tests/ut` green on head `0ebfb2f0` — 10632 passed / 14 skipped / 3 xfailed (2 unrelated Docker-environment failures: `test_repository_is_clean` runs `git ls-files` from inside the container against a worktree `.git` outside the mount; `test_symlinked_import_path_still_names_the_caller` is bypassed by the editable install). Includes the new distinct-subset, core_num-widening, and Simplify window-buffer-remap regression UTs. - Distributed sim ST (`a2a3sim`, `--shm-size=4g`): `test_l3_host_tensor_allreduce{,_multicore}.py` = **15 passed / 6 failed**; the 6 failures are pre-existing baselines reproduced identically on pristine `origin/main` (Max/Min are an NPU-only gate; the two multicore `p2-c4-wide-stride` cases are documented flaky under load). The new `test_host_tensor_allreduce_loop` passes P=2/P=4. - Pre-commit: green (ruff, pyright, clang-format, cpplint, markdownlint, repo lints) — including the review-round fixes for pyright typing, `misc-include-cleaner` (`pypto/ir/core.h` for `ObjectKind`), and `check-op-name-literals`. - NPU ST (pending, developer): `tests/st/distributed/test_l3_host_tensor_allreduce.py` P=2 / P=4. ## Notes - Rebased on latest `origin/main` (`28cf265e`) with all four review-round items addressed (IterArg handling in both passes, SSA-first test harness, Simplify/core_num regression UTs, SSA-dependency comment). - Per-lineage keying (rather than one-per-function) addresses a correctness gap surfaced in review: implicit allreduces over disjoint device subsets must not share a signal. - Loop-carried lineage resolution keys every iteration to the carry's first-iteration lineage; a loop ping-ponging between *different-coverage* windows is a documented limitation.
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.