Skip to content

feat(distributed): self-clearing barrier signals in host collective kernels - #2279

Merged
YunjiQin merged 10 commits into
hw-native-sys:mainfrom
georgebisbas:feat/host-builtin-signal-reuse
Aug 19, 2026
Merged

feat(distributed): self-clearing barrier signals in host collective kernels#2279
YunjiQin merged 10 commits into
hw-native-sys:mainfrom
georgebisbas:feat/host-builtin-signal-reuse

Conversation

@georgebisbas

@georgebisbas georgebisbas commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Motivation

The host collective builtin kernels use single-shot credit barriers: each call issues TNOTIFY(+1) / TWAIT(Ge(1)) (or Set(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's Ge(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.cpp EmitEpilogueReset): 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.

Kernel Credits per peer cell per call Epilogue
allreduce (mesh) ready +1 + one per UB chunk TNOTIFY(-(read_done_expected - 1), AtomicAdd)
allreduce_ring +1 per used RoundBarrier row TNOTIFY(-1, AtomicAdd) per row × peer
reduce_scatter ready +1 + one per tile TNOTIFY(0, Set)
broadcast one per tile + final read-complete round TNOTIFY(-expected, AtomicAdd)
barrier / allgather / all_to_all +1 (single barrier) TNOTIFY(-1, AtomicAdd)

All bodies use AtomicAdd in the main loop (single-writer pattern: each rank writes into its own cell on every peer). In the epilogue, most kernels restore zero with AtomicAdd(-N) on the local address. reduce_scatter uses Set(0) because on non-coherent NPU silicon a new AIV task dispatch may read a stale cached cell value — AtomicAdd would read-modify-write that stale value, while Set unconditionally writes zero. Set is safe here because the per-tile pipe_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):

  • mesh/ring allreduce, broadcast, reduce_scatter, allgather, all_to_all, barrier
  • Multicore allreduce signal reuse (P=2, C=4) — verifies reuse via output correctness rather than on-device signal readback, which is unreliable on non-coherent NPU across AIV task dispatches

Verification

  • pre-commit: all hooks pass (ruff, pyright, headers, docs parity)
  • sim ST: 24/24 pass (--forked --platform=a2a3sim --device=0,1,2,3), excluding 4 pre-existing Max/Min/FP16 sim SIGSEGVs (reproduced on unmodified kernels)
  • NPU silicon ST (P=2, devices 0,1): all host collective signal reuse tests pass including broadcast, allreduce (mesh/ring/multicore), and reduce_scatter
  • clang-tidy: N/A (only .cpp.in templates + Python tests changed)
  • The on-silicon visibility (stale-credit reset) is what the NPU developer gate verifies — sim is sequentially consistent and cannot observe the bug this fixes.

Follow-up

The in-loop allreduce restriction lift (shared per-host_orch signal synthesis in SynthesizeAllReduceSignals) is tracked separately as #2310, per the review recommendation.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d1334e12-1fa3-4ec7-8682-d80fe00182b6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Kernel 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.

Changes

Allreduce signal buffer reuse

Layer / File(s) Summary
Mesh allreduce kernel epilogue cleanup
python/pypto/runtime/builtins/collectives/allreduce/templates/kernel.cpp.in
Adds epilogue logic that computes issued notification counts and atomically subtracts them from each non-local peer signal cell, resetting signal state before the final pipeline barrier.
Ring allreduce kernel epilogue cleanup
python/pypto/runtime/builtins/collectives/allreduce_ring/templates/kernel.cpp.in
Adds epilogue cleanup that issues local atomic decrement notifications for each barrier row and non-local peer cell after the final cross-rank barrier.
Mesh allreduce signal-reuse tests
tests/st/distributed/test_l3_host_tensor_allreduce.py
Adds a host builder that executes three sequential FP32 sum allreduce rounds with one shared signal buffer, plus a parameterized test for two- and four-rank configurations validating each round.
Ring allreduce signal-reuse tests
tests/st/distributed/test_l3_host_tensor_allreduce_ring.py
Adds a host builder that allocates one shared ring signal matrix for three publish/allreduce/consume sequences, plus a parametrized test that skips on insufficient devices, checks the variant directory, and validates all three rounds.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • hw-native-sys/pypto#2175: Implements and tests the same reusable barrier-signal protocol by clearing signal credits in allreduce kernels.
  • hw-native-sys/pypto#2275: Addresses signal-buffer lifecycle management for HOST allreduce collectives, including self-clearing barrier signals.
  • hw-native-sys/pypto#2160: Extends the same allreduce kernel templates and signal-barrier protocol with cleanup for signal reuse across calls.

Poem

A rabbit hops through signal cells,
Subtracting counts where credit dwells.
Three rounds run, the buffer clean,
Reused again, as if unseen.
🐰 Barriers cleared, the mesh and ring both gleam! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the self-clearing barrier signal changes in the host allreduce kernels.
Description check ✅ Passed The description explains the barrier-credit bug, implementation, tests, and verification related to the allreduce kernel changes.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread tests/st/distributed/test_l3_host_tensor_allreduce.py Outdated
Comment thread tests/st/distributed/test_l3_host_tensor_allreduce_ring.py Outdated
Comment thread python/pypto/runtime/builtins/collectives/allreduce/templates/kernel.cpp.in Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between a47d300 and 987fe66.

📒 Files selected for processing (4)
  • python/pypto/runtime/builtins/collectives/allreduce/templates/kernel.cpp.in
  • python/pypto/runtime/builtins/collectives/allreduce_ring/templates/kernel.cpp.in
  • tests/st/distributed/test_l3_host_tensor_allreduce.py
  • tests/st/distributed/test_l3_host_tensor_allreduce_ring.py

Comment thread tests/st/distributed/test_l3_host_tensor_allreduce_ring.py Outdated
Comment thread tests/st/distributed/test_l3_host_tensor_allreduce.py Outdated
Comment thread tests/st/distributed/test_l3_host_tensor_allreduce.py Outdated
@georgebisbas georgebisbas changed the title feat(distributed): self-clearing barrier signals in host allreduce kernels feat(distributed): self-clearing barrier signals in host collective kernels Aug 4, 2026
@georgebisbas
georgebisbas force-pushed the feat/host-builtin-signal-reuse branch 2 times, most recently from 5155b8e to d953a78 Compare August 5, 2026 21:05
@georgebisbas
georgebisbas force-pushed the feat/host-builtin-signal-reuse branch from d953a78 to 7798149 Compare August 6, 2026 13:48

@YunjiQin YunjiQin 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.

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 kSet must never be mixed with kAtomicAdd on 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:

  • LowerTensorBroadcastRuleEmitBarrier x1 + EmitEpilogueReset(total=1)
  • LowerTensorReduceScatterRuleEmitBarrier x2 (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 its P-1 notifies per tile, P-2 land in another non-root's cell[me] where nobody ever reads them — they just accumulate garbage credits (which is part of why Set(0) looked necessary).
  • The root's per-tile read-complete wait is not needed within a call. The root writes tile t only in iteration t, and each iteration has a distinct base, 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 reader T times.
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-191 still 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 the args_.size() == 2 branch 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 a Set-based and an AtomicAdd-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 the repeating_scope_depth_ check after the args_.size() == 1 branch is enough, plus a for rd in pl.range(N): allreduce(data, signal) ST.
  • args_.size() == 1 (synthesized signal)MakeSignalBinding inserts pld.system.world_size + pld.tensor.alloc_window_buffer + pld.tensor.window immediately 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 the host_orch entry, 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).

@georgebisbas
georgebisbas force-pushed the feat/host-builtin-signal-reuse branch from 7798149 to fb09fe1 Compare August 7, 2026 06:41
@georgebisbas

Copy link
Copy Markdown
Contributor Author

@YunjiQin thanks for the thorough review — all three blocking/actionable items are addressed on the pushed head fb09fe10:

1. Set(0) epilogue → AtomicAdd(-credits) (broadcast / reduce_scatter). Both kernels now reset with a local-address TNOTIFY(..., AtomicAdd) credit decrement and never mix kSet with the AtomicAdd(+1) body:

  • reduce_scatter: -(read_done_expected - 1) — identical to the mesh allreduce count (1 ready barrier + one per UB chunk).
  • broadcast: after the loop, the non-root resets its own cell[kRoot] by -tiles where tiles = expected - 1 — the correct count you derived (the PR description's -expected was the off-by-one).

2. Broadcast redundant notifies removed. Restructured to your suggested shape: the root is the only per-tile notifier (one +1 per non-root); non-roots wait solely on cell[kRoot] (no per-tile completion notify); the read-complete barrier is a single round after the loop — each non-root sends one +1 to root's cell[me], the root waits Ge(1) on each, then both sides do a role-aware AtomicAdd reset. Remote atomics per tile drop from P*(P-1) to P-1, plus the one-off 2*(P-1) round. This also removes the last motivation for Set(0).

3. Contradictory error message and docs fixed. CheckAllReduceCall now states the real reason — the HOST-rail signal-synthesis limitation (a synthesized signal binding cannot be allocated per dynamic iteration) — instead of claiming the protocol is single-use. The 04-debugging.md row (en/zh) drops the non-working "bind an explicit signal before the loop" remediation and the pass doc (en/zh) now explains the synthesis-based restriction accurately.

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 AtomicAdd(-credits).

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.

@georgebisbas

Copy link
Copy Markdown
Contributor Author

Rebased onto origin/main + multicore integration

Rebased the branch onto the latest origin/main, which now includes #2160 (multicore HOST AllReduce with core_num signal lanes). Two adjustments landed with it:

1. Allreduce epilogue now resets the block's own signal lane. The self-clearing epilogue previously reset signal_base + peer (the rank's first cell). Under #2160's layout, signal lanes are addressed peer * signal_stride + block_idx, so the old reset would have over-subtracted block-0 lanes and left the other lanes uncleared whenever core_num > 1. The epilogue now uses signal_base + peer * signal_stride + block_idx.

2. Adapted the #2160 multicore ST test to self-clearing semantics. The multicore test observed signal lanes with pld.system.wait(..., expected=1, cmp=Ge) after the allreduce and asserted >= 1. That worked pre-#2279 (single-use signals, no epilogue) but deadlocks with self-clearing signals, because the epilogue resets every lane back to 0 before the kernel returns. The test now waits for each lane to self-clear (expected=0, cmp=Eq) and asserts == 0 — a lane at zero proves that block started and completed its epilogue, and the allreduce completing still proves the ready barrier (all blocks started). This validates the new contract across the c2/c4/stride matrix, including the idle-lane case.

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.

@georgebisbas
georgebisbas force-pushed the feat/host-builtin-signal-reuse branch from 3dc3113 to c012907 Compare August 7, 2026 08:08
@georgebisbas

Copy link
Copy Markdown
Contributor Author

Added a multicore signal-reuse ST (two back-to-back calls through ONE shared signal, distinct per-round payloads, core_num 2 and 4) — it proves the per-lane self-clearing epilogue makes the multicore signal reusable across calls. Sim: all 5 multicore cases pass (3 lane cases + 2 reuse cases).

@georgebisbas

Copy link
Copy Markdown
Contributor Author

@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)

test_multicore_allreduce_signal_reuse[reuse-p2-c4-wide-stride] was failing on non-coherent NPU. The test's consume_step tried to verify cleared signal values on-device via TWAIT(Eq, 0) + pl.load(signal), but a new AIV task dispatch can read stale cached values from the previous dispatch. The kernel correctly cleared the signals (confirmed by test_multicore_output_and_signal_lanes passing), so the fix removes the on-device signal readback and relies on output correctness as the proxy for signal clearing — matching the approach used by the single-core test_host_tensor_allreduce_signal_reuse.

Fix 2: Reduce scatter signal reuse (aa2172b)

test_host_tensor_reduce_scatter_signal_reuse[2] deadlocked with SCHEDULER_TIMEOUT. The kernel's self-clearing epilogue used AtomicAdd(-N) to reset signal cells, but AtomicAdd reads the current (possibly stale-cached) value before writing back. On non-coherent NPU, that stale read produces incorrect cell values, causing asymmetric synchronization between ranks → deadlock. The fix replaces TNOTIFY(self_sig, -barrier_count, NotifyOp::AtomicAdd) with TNOTIFY(self_sig, 0, NotifyOp::Set). Set writes zero unconditionally without reading the current value. This is safe because the per-tile pipe_barrier(PIPE_ALL) guarantees all peers' AtomicAdd credits have already landed — there are no in-flight writes that Set could clobber.

Both verified locally on NPU devices 0,1. The Set(0) approach matches what the barrier kernel already does and is safe under the same reasoning.

@georgebisbas
georgebisbas requested a review from YunjiQin August 7, 2026 13:24
…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.
@georgebisbas
georgebisbas force-pushed the feat/host-builtin-signal-reuse branch from 84729fb to 07f8459 Compare August 11, 2026 13:29
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.
@YunjiQin
YunjiQin merged commit 9533ae3 into hw-native-sys:main Aug 19, 2026
16 checks passed
georgebisbas added a commit to georgebisbas/pypto that referenced this pull request Aug 26, 2026
…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).
georgebisbas added a commit to georgebisbas/pypto that referenced this pull request Aug 26, 2026
…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).
georgebisbas added a commit to georgebisbas/pypto that referenced this pull request Aug 27, 2026
…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).
georgebisbas added a commit to georgebisbas/pypto that referenced this pull request Aug 27, 2026
…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).
georgebisbas added a commit to georgebisbas/pypto that referenced this pull request Aug 27, 2026
…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).
georgebisbas added a commit to georgebisbas/pypto that referenced this pull request Aug 27, 2026
…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).
YunjiQin pushed a commit that referenced this pull request Aug 28, 2026
…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.
YunjiQin pushed a commit that referenced this pull request Aug 28, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants