Skip to content

feat(passes): rotate slots by the iteration counter, not the induction variable - #2922

Open
georgebisbas wants to merge 3 commits into
hw-native-sys:mainfrom
georgebisbas:feat/slots-general-rotation
Open

georgebisbas wants to merge 3 commits into
hw-native-sys:mainfrom
georgebisbas:feat/slots-general-rotation

Conversation

@georgebisbas

@georgebisbas georgebisbas commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

What this offers

LowerPipelineToSlots is the cheap pipeline lowering: one loop body, with each per-stage buffer becoming slot i % F of a single allocation. It required step == 1, so any pipeline with a different step was declined and handed to LowerPipelineLoops, which replicates the body F times.

That is not a corner case. Across the workspace's real model corpus:

Root step == 1 step != 1
pypto-lib/models 0 86
pypto/tests 58 76

Every explicit-step pipeline in the model corpus has a step that is not 1 — K_TILE, QUANT_TILE, KV_TILE, 128, 64, 32, 512 and ~40 other values — spread over 47 files, with the deepseek_v4_* variants carrying 5–9 each. Their bodies hold top-level pl.loads, so they satisfy every other gate. Today all of them take the replication path.

Measured, same planner, one variable changed

pl.pipeline(0, 256, 64, stage=2) (4 iterations, step = 64, 2 slots), compiled with the PTOAS planner in both runs and ptoas at --pto-level=level2; the only difference is whether the gate admits a non-unit step:

before (main @ 731191cf) after
pto.alloc_multi_tile 0 1 (count=2)
pto.multi_tile_get 0 1
pto.tload 3 2
pto.alloc_tile 4 1
loop stride step %c128_index ← rewritten to F × step step %c64_index ← the user's own step
emitted kernel C++ 203 lines / 7979 B 166 lines / 6417 B
slot-keyed (dynamic) sync ops 0 2

203 → 166 lines (−37 lines) on this shape, and the reduction scales with the pipeline depth (F) and with how much work the body carries — because the body itself is no longer multiplied. Every later pass sees one copy instead of F: CanonicalizeIOOrder's topological sort, InitMemRef, MemoryReuse's O(S·M²) pack, AllocateMemoryAddr, and ptoas's own parse and pass pipeline. There is also no remainder dispatch and no pipeline_membership machinery, since bounds, step and iter_args are left untouched.

The IR, before and after

Before — the loop runs at step 128 (F × 64), and the body holds the replicated pair: two alloc_tiles, two tloads, with maxsi clamps on the rewritten offsets.

scf.for %i__idx_v0 = %c0_index to %c256_index step %c128_index {
  %t__ssa_v0 = pto.alloc_tile valid_row = %c64_index valid_col = %c64_index
  %1 = arith.maxsi %i__idx_v0, %c0_index : index
  %2 = pto.partition_view %a__ssa_v0_view, offsets = [%1, %c0_index], sizes = [%c64_index, %c64_index]
  pto.tload ins(%2 …) outs(%t__ssa_v0 …)
  %0 = pto.alloc_tile valid_row = %c64_index valid_col = %c64_index
  %3 = arith.addi %i__idx_v0, %c64_index : index
  %4 = arith.maxsi %3, %c0_index : index
  %5 = pto.partition_view %a__ssa_v0_view, offsets = [%4, %c0_index], sizes = [%c64_index, %c64_index]
  pto.tload ins(%5 …) outs(%0 …)
  %acc_next__ssa_v0 = pto.alloc_tile valid_row = %c64_index valid_col = %c64_index
  pto.tadd ins(%seed__ssa_v0, %t__ssa_v0 …)
  pto.tadd ins(%acc_next__ssa_v0, %0 …)
}

After — one body, the loop keeps step 64, and the per-stage buffer is a slot selected by the rotation counter:

scf.for %i__idx_v0 = %c0_index to %c256_index step %c64_index {
  %0 = arith.subi %i__idx_v0, %c0_index : index      // counter = (iv - start) / step
  %1 = arith.divsi %0, %c64_index : index
  %2 = arith.remsi %1, %c2_index : index             // slot    = counter % F
  %t__ssa_v0 = pto.multi_tile_get %pipe_t__ssa_v0_mb[%2]
  %3 = arith.maxsi %i__idx_v0, %c0_index : index
  %4 = pto.partition_view %a__ssa_v0_view, offsets = [%3, %c0_index], sizes = [%c64_index, %c64_index]
  pto.tload ins(%4 …) outs(%t__ssa_v0 …)
  pto.tadd ins(%seed__ssa_v0, %t__ssa_v0 …)
}

(type suffixes elided for width; artifacts reproduced with probe19_ab.py)

Why the old gate is safe to relax

The gate was documented as protecting a real property, so this is a falsification, not an assumption:

ptoas matches the affine form of the slot index to decide which accesses share a slot, and that match is what earns the rotation its per-slot dynamic event ids — handing it a folded byte offset defeats the analysis. A general ((iv - start) / step) % F would have to be materialized as an intermediate SSA value, risking the loss of exactly the analysis this transform exists to trigger.

That is stricter than ptoas is. In PTOAS/lib/PTO/Transforms/Passes/SlotAffineAnalysis.cpp:

  • findMultiTileSlotExpr returns whatever pto.multi_tile_get carries — the slot value, not a required form;
  • extractSlotForm's first case accepts arith.remui X, %const for any X; only peelAddSubConst is shape-sensitive, and it peels at most four add/sub-of-constant steps;
  • compareSlotSSA reasons about (innerSym + offset) mod N, and the only thing it needs is that two slots share the identity of that value. A divsi is a perfectly good symbol.

Measured against ptoas 0.65: a step = 64 loop slotted this way produces the same ptoas output as the equivalent step == 1 loop — 171 lines, set_flag=8, wait_flag=8, identical after normalising event ids.

And the property the gate protected is delivered by the new form, not lost. A slot-keyed sync is an ordinary set_flag/wait_flag whose event-id operand is a runtime value (the EmitC lowering of pto.set_flag_dyn; the literal set_flag_dyn never appears in generated C++):

Artifact static ids slot-keyed
unit-step rotation, stage=2 12 2
stepped rotation, stage=2 (this change) 14 2
two regions in one body 18 4
matrix kernel, replication path (contrast) 18 0

The stepped form earns the same per-slot keying as the unit-step form; the replication path still earns none.

What it costs — stated, not glossed

The kDisjoint inference degrades to kUnknown for an (iv + c) % N pair. The constant peel now sits above the division, so the two sides carry different symbols. The callers treat kUnknown conservatively — the dependency is kept — so this is a precision loss, never a wrong answer. It is documented in both languages in this PR.

What this does not do

  • It does not unlock the matmul K-loop. That loop is still declined, and not on this gate: CollectCandidates collects top-level pl.tile.load only, and an AutoTileMatmulL0 K-loop body tile.extracts from an already-loaded tile. Changing that is a separate, larger item (and it runs into the L0 budget — L0A/L0B are 64 KB each, and a two-slot region of 128×128 fp16 K-blocks is exactly 64 KB).
  • It does not change the default planner path. The pass is gated on memory_planner=PTOAS; under the default PyPTO planner it returns every function untouched, so that path is unchanged.
  • No runtime claim is made. The emitted sync structure matches, but per-slot event accounting is exactly what must be right on silicon and the failure mode is a race, not a crash — see the verification section below.

Verification (agent, sim Docker)

Run on the pushed commit after a clean rm -rf build _skbuild && pip install --no-build-isolation -q ".[dev]" in the container that measured.

Suite Result
tests/ut/ir/transforms/test_lower_pipeline_to_slots.py 24 passed
tests/ut/codegen/test_multi_buffer_codegen.py 32 passed — adds SteppedPipelinedLoad + 4 tests
tests/ut/codegen 1396 passed, 2 failed — both failures are the pre-existing test_orchestration_codegen_graph.py pair, reproduced on unmodified origin/main
repo pre-commit hooks (5 files) all pass — clang-format, cpplint, ruff check/format, pyright, structural checks

step == 1 loops keep the literal iv % factor, so their emitted IR is unchanged — the change is additive.

Developer verification (NPU) — passed 2026-09-28

The gate this change needs is numeric, not "it did not crash": the slot rotation and the replicated fallback must compute the same numbers on silicon. Run on the frozen head 70f72359 — checkout in a pypto3-hw-native-sys:cann9 container joined to the host queue; every job through task-submit on hng-atlas01 (a2a3, one card per run):

Step Result
Regression sweep — tests/st/runtime/ops/test_memory_planner_ptoas.py on device (PTOAS among the planners) 21 passed — double-buffer, constant-slot and Acc-rotating shapes all green
Stepped A/B — pl.pipeline(0, 256, 64, stage=2) and pl.pipeline(64, 320, 64, stage=2), each compiled under PTOAS (slots) and PyPTO (replicated) and run against one golden 4 passed — both lowerings match the golden on device, for both the zero and the non-zero start shapes
Compile evidence that binds the device runs to the new path PTOAS: one alloc_multi_tile count=2 + one multi_tile_get, loop keeps step %c64; PyPTO: no region, replicated (step %c128)

Probe sources and the full command/task record: hw-native-sys/slots-verify/ and plan 125 § NPU verification record in pypto-3.0-notes.


Pushed by the agent at the developer's request; per the branch-and-PR standard the agent stops at the push and the developer opens the PR. The NPU verification above was run on 2026-09-28 and passed.

@coderabbitai

coderabbitai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Review in 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: Advanced
  • Run ID: 8f81b7e1-3f40-4c1d-9cfd-640a7b43c8e9

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 2ccf3602-4838-4f35-abb5-b385311b00b7

📥 Commits

Reviewing files that changed from the base of the PR and between 63412dd and dad0d20.

📒 Files selected for processing (5)
  • docs/en/dev/passes/30-lower_pipeline_to_slots.md
  • docs/zh/dev/passes/30-lower_pipeline_to_slots.md
  • src/ir/transforms/lower_pipeline_to_slots_pass.cpp
  • tests/ut/codegen/test_multi_buffer_codegen.py
  • tests/ut/ir/transforms/test_lower_pipeline_to_slots.py

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.


📝 Walkthrough

Walkthrough

The pipeline-to-slots pass now accepts loops with positive constant steps and computes slot indices from the iteration count for non-unit steps. Documentation and tests describe and check the updated eligibility rules, slot rotation, and code-generation behavior.

Changes

Stepped Pipeline Slot Rotation

Layer / File(s) Summary
Eligibility and slot-index construction
src/ir/transforms/lower_pipeline_to_slots_pass.cpp, docs/en/dev/passes/30-lower_pipeline_to_slots.md, docs/zh/dev/passes/30-lower_pipeline_to_slots.md
The pass accepts constant starts and positive constant steps. Unit-step loops retain the aligned-start requirement and use iv % factor; other constant steps use ((iv - start) / step) % factor. Both documentation versions describe these rules and note that counter-based indices can make kDisjoint inference conservative.
Stepped-loop validation
tests/ut/ir/transforms/test_lower_pipeline_to_slots.py, tests/ut/codegen/test_multi_buffer_codegen.py
Transform tests check stepped-loop admission, counter-based slot rotation, and decline when slot allocation exceeds the Vec budget. Code-generation tests check PTOAS slot allocation and compare it with PyPTO’s replicated output.

Priority: ➖ Normal

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

Change: Feature

Merge Risk: ⚪ Minimal · up to dad0d

The documented eligibility rules match the implementation; no identified issue remains to resolve before merging.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to dad0d

Eligible stepped loops now share on-chip tile slots. The new index is bounded and unsupported loops retain their fallback, but synchronization of the new index has not been established for every admitted loop shape. Incorrect slot reuse could produce wrong results or stall a kernel. No new privilege boundary is evident.

Retained concerns

  • Medium · reliability · inferred: The newly emitted division-then-remainder slot index reaches multi-tile access, but downstream per-slot synchronization recognition is not established for the expanded set of loop shapes. A recognition mismatch could compromise slot-reuse ordering and kernel result integrity or progress.
Security review details

Security Blast Radius

  • inferred — The independently affected execution scope is kernels compiled through the PTOAS planner with eligible stepped pipeline loops. Evidence does not establish tenant, service, credential, or network exposure.

Trust Boundaries and Controls

  • observed — Constant-shape, candidate-eligibility, capacity, and planner gates still constrain the new path. No bypass to a privileged sink is visible in the traced compiler-to-codegen flow.

Resilience and Maintainability Implications

  • inferred — Fallback is preserved before binding, but once a loop is admitted its correctness depends on downstream recognition and lifecycle of the new slot expression. The available emission tests do not establish those runtime properties.

Hardening Proposals

  • proposed — Verify downstream per-slot event identities and execution outcomes against the replication fallback across supported starts, steps, slot counts, repeated invocations, and interrupted execution before relying on the broadened path.
🚥 Pre-merge checks | ✅ 4
✅ 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 and concisely describes the main change: slot rotation now uses the iteration counter instead of the induction variable.
Description check ✅ Passed The description is directly related to the changeset and explains the supported stepped loops, slot-index computation, planner behavior, trade-offs, and verification results.

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

A rabbit counts each step with care
Two slots turn as loops pass by
A steady hop, a counter there
The tests trace each slot in flight
Then nibble clover by the sky

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

@github-actions github-actions 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

Reviewed dad0d2009acf818f708565ee8f606963c9bcd452 against base 63412dd65c7914d5444bec73c90a0dc990f3ef08.

Automated assessment of untrusted PR content; not a guarantee of correctness.

Reviewed the five-file PR diff from the verified merge-base 731191c. The supplied discussion contains no earlier findings or disputed P0–P2 threads to reassess. One P2 issue remains.

1 finding(s) posted as inline comments.

No automatic approval: Review contains findings or is incomplete.

Comment thread src/ir/transforms/lower_pipeline_to_slots_pass.cpp
github-actions[bot]
github-actions Bot previously approved these changes Sep 27, 2026

@github-actions github-actions 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

Reviewed 70f723593c421bab05e70852a53789665645e248 against base 63412dd65c7914d5444bec73c90a0dc990f3ef08.

Automated assessment of untrusted PR content; not a guarantee of correctness.

Reviewed the full five-file diff from merge-base 731191c. The earlier P2 overflow finding is fixed: the new gate rejects negative starts, and a nonnegative start with a positive step keeps iv - start within the signed index range. No actionable P0–P2 issues remain.

Approval policy: passed.

…n variable

`LowerPipelineToSlots` required `step == 1` and an aligned `start`, so any
pipeline with a different step was declined and replicated by
`LowerPipelineLoops` — F body copies, F x the IR through every later pass, and
F x the emitted code. That is not a corner: the workspace's model corpus has 86
explicit-step pipelines and not one of them uses `step == 1` (K_TILE,
QUANT_TILE, KV_TILE, 128, 64, 32, ...), across 47 files.

The gate's stated reason was that ptoas matches the *affine form* of the slot
index, so `((iv - start) / step) % F` would "risk losing exactly the analysis
this transform exists to trigger". Measured against ptoas 0.65 that is stricter
than ptoas is: a step-64 load loop slotted this way produces the same ptoas
output as the equivalent unit-step loop (171 lines, set_flag=8, wait_flag=8).
`PTOAS/lib/PTO/Transforms/Passes/SlotAffineAnalysis.cpp` shows why —
`findMultiTileSlotExpr` returns whatever `pto.multi_tile_get` carries, and
`extractSlotForm` accepts `arith.remui X, %const` for any `X`. Only the
*identity* of that value decides whether two accesses are provably the same
slot; its arithmetic shape never does.

So this admits any compile-time-constant `step > 0` and emits
`((iv - start) / step) % factor` when the induction variable is not already the
rotation counter. `step == 1` loops keep the literal `iv % factor`, so their
emitted IR is unchanged. A runtime `step` or `start` is still declined: the slot
would depend on a value the analysis cannot bound.

What is given up: the kDisjoint inference for an `(iv + c) % N` pair. The
constant peel now sits above the division, so the two sides carry different
symbols and the relation degrades to kUnknown, which the callers treat
conservatively — the dependency is kept. Precision, not correctness.

Verified in sim Docker (PTOAS planner, ptoas --pto-level=level2):
  * tests/ut/ir/transforms/test_lower_pipeline_to_slots.py   22 passed
  * tests/ut/codegen/test_multi_buffer_codegen.py            32 passed
    (SteppedPipelinedLoad + four tests pinning the counter-based slot)
  * tests/ut/codegen                                        1396 passed,
    2 failed — both the pre-existing test_orchestration_codegen_graph.py pair
  * all repo pre-commit hooks pass

Not verified: behaviour on silicon. The sync-op counts match, but per-slot event
accounting is what has to be right at runtime and the failure mode is a race,
not a crash — see the plan's Developer verification section.
`LoopShapeAllowsSlots` accepted any constant `start`, but both emitted slot forms
need the induction variable non-negative. `iv % factor` is a signed remainder, so
`start = -2, step = 1, factor = 2` emits `(-1) % 2 == -1` — a slot before the
region — and the counter form's `iv - start` is not representable for a
sufficiently negative start (`pl.pipeline(INT64_MIN, 1, 1 << 61, stage=3)` reaches
`iv = 0`, where `0 - INT64_MIN` overflows and the wrapped quotient selects slot
`-1` of a three-slot allocation).

Requiring `start >= 0` closes both: `iv >= start >= 0` keeps the subtraction
non-negative and bounded by the loop's own span, so both remainders land in
`[0, factor)`. Loops that start negative now decline and keep the
`LowerPipelineLoops` replication path, which handles arbitrary bounds.

Tests, both in `TestFallback`: `test_a_negative_start_is_declined` pins the
pre-existing `-2` hole the old `start % factor == 0` test admitted, and
`test_a_start_whose_counter_would_overflow_is_declined` pins the five-iteration
`INT64_MIN` example.
@georgebisbas
georgebisbas force-pushed the feat/slots-general-rotation branch from 70f7235 to fd2f5ae Compare October 8, 2026 10:07
@github-actions
github-actions Bot dismissed their stale review October 8, 2026 10:12

Superseded by a new Codex review

@github-actions github-actions 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

Reviewed fd2f5aed6bd7b894ebc0ec8b1953cfd4ea271638 against base be5168bbb5477493e38a244fa4af780278bdad37.

Automated assessment of untrusted PR content; not a guarantee of correctness.

Reviewed the full five-file diff. The earlier P2 counter-subtraction finding is fixed by the nonnegative-start gate. A separate terminal-increment overflow remains for newly admitted stepped loops.

1 finding(s) posted as inline comments.

No automatic approval: Review contains findings or is incomplete.

}
// `((iv - start) / step) % factor` is well-formed: `iv >= start >= 0` keeps
// the subtraction non-negative and bounded by the loop's own span.
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Reject stepped loops whose final induction update overflows

This gate admits pl.pipeline(INT64_MAX - 4, INT64_MAX, 3, stage=3). It has two valid iterations, at INT64_MAX - 4 and INT64_MAX - 1, but slot lowering retains a scf.for whose update after the second iteration computes INT64_MAX + 2. That overflows the signed index and can make the loop continue incorrectly or hang. Previously, LowerPipelineLoops emitted two tail copies with no loop because the trip count is below the stage count. Decline this shape or lower it with an overflow-safe iteration counter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 450672f.

You are right: for step > 1 the retained scf.for computes iv + step after the last iteration, which can overshoot stop by up to step - 1. pl.pipeline(INT64_MAX - 4, INT64_MAX, 3, stage=3) would reach INT64_MAX - 1 + 3. step == 1 cannot overshoot, so that form is unchanged.

LoopShapeAllowsSlots now declines a stepped loop unless stop is a constant with stop <= INT64_MAX - (step - 1). A runtime stop cannot be bounded, so it declines as well; stepped loops were not slotted at all before this PR, so those stay on replication exactly as on main. Tests: your example is declined, the tight boundary stop == INT64_MAX - (step - 1) is still slotted, and a stepped loop with a runtime stop is declined. The English and Chinese pass docs describe the rule.

A stepped loop (step > 1) admitted by LoopShapeAllowsSlots keeps its
scf.for, which computes iv + step after the last iteration. That update can
overshoot stop by up to step - 1, so pl.pipeline(INT64_MAX - 4, INT64_MAX, 3,
stage=3) overflows the signed index and can keep the loop running.

Require a constant stop no larger than INT64_MAX - (step - 1) for step > 1.
step == 1 cannot overshoot and keeps accepting a runtime stop; a stepped loop
with a runtime stop was never slotted before this series and stays on
replication.

@github-actions github-actions 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

Reviewed 450672f2ce647ba785f862ab84ab596b5eae302a against base be5168bbb5477493e38a244fa4af780278bdad37.

Automated assessment of untrusted PR content; not a guarantee of correctness.

Reviewed the full five-file diff. Both disputed P2 findings are fixed in the current code: negative starts are declined, and stepped loops require a constant stop that leaves room for the final induction update. No actionable P0–P2 issues remain.

Approval policy: passed.

This branch has not been deployed

No deployments
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.

1 participant