-
-
Notifications
You must be signed in to change notification settings - Fork 158
perf(codegen): packed clones read a foreign counter inline (10_nested_loops 51 → 17 ms, Node parity) #9161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
proggeramlug
merged 2 commits into
PerryTS:main
from
proggeramlug:perf/packed-clone-foreign-reads
Aug 30, 2026
Merged
perf(codegen): packed clones read a foreign counter inline (10_nested_loops 51 → 17 ms, Node parity) #9161
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| A packed loop's fast clone can now read its array at an **enclosing** loop's | ||
| counter, not only at its own. | ||
|
|
||
| The clone's element read is a raw slot load with no bounds check, licensed by the | ||
| index being the loop's own induction variable — which the loop bound already | ||
| proved in range. Any other index had no such proof, so `arr[i]` beside `arr[j]` | ||
| in a nested loop was rejected outright by the body matcher, and the whole inner | ||
| loop fell to the typed-feedback tier: a registered guard **call** plus a boxed | ||
| fallback per element, for both reads, on every iteration. | ||
|
|
||
| A foreign index now takes the same raw load behind one inline `icmp ult idx, len` | ||
| against the live `ArrayHeader.length` — the same word `expr/index.rs`'s store | ||
| guard reads — and branches to the fact's existing side exit when it fails. That | ||
| exit is the one the hole arm already uses, so mid-body exit needed no new | ||
| machinery. | ||
|
|
||
| **Read-only bodies only.** A side exit re-executes the iteration in the slow | ||
| clone, which is harmless for a read and would double-apply a store, so the | ||
| relaxation lives in `expr_is_packed_f64_loop_safe` and is deliberately not shared | ||
| with the store matchers beside it. | ||
|
|
||
| Measured on an idle Mac mini, self-timed, min of three: `benchmarks/suite/10_nested_loops.ts` | ||
| 51 → 17 ms against Node's 16 — 3.3× Node to parity. `16_matrix_multiply` also | ||
| improves (121 → 100 ms) since its inner reads are the same shape. No other | ||
| benchmark in the 17-entry sweep moved. | ||
|
|
||
| Verified against Node on a five-case differential covering the shapes where a | ||
| side exit is observable: the plain nested accumulation, a labelled `break` out of | ||
| the inner loop mid-iteration, an `if` guarding the accumulation, a mixed-layout | ||
| array whose guard must fail into the slow clone, and an accumulator read by the | ||
| outer loop between inner passes. All byte-identical. `perry-codegen`: 30 suites | ||
| pass, including a new IR pin. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
87 changes: 87 additions & 0 deletions
87
crates/perry-codegen/src/expr/index_get/foreign_counter.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| //! The foreign-counter packed-clone read (#9161). | ||
| //! | ||
| //! Split out of `index_get.rs`, which sits at the 2000-line cap. This is the | ||
| //! `arr[i]` case where `i` is a live i32 counter of some OTHER loop than the | ||
| //! clone's own — see `foreign_packed_loop_read` for why a fact carrying its | ||
| //! own per-element exit is declined. | ||
|
|
||
| use super::super::*; | ||
| use super::packed_f64_loop_fact; | ||
|
|
||
| /// An active packed-loop fact for `arr_id` plus a foreign i32 index local: | ||
| /// `arr[i]` where `i` is not the clone's counter. Declines a fact that already | ||
| /// carries its own per-element exit condition (holes, a validated window), so | ||
| /// the bounds-checked load never stacks two side exits on one read. | ||
| pub(crate) fn foreign_packed_loop_read( | ||
| ctx: &FnCtx<'_>, | ||
| arr_id: u32, | ||
| index: &Expr, | ||
| ) -> Option<(PackedF64LoopFact, u32)> { | ||
| let Expr::LocalGet(idx_id) = index else { | ||
| return None; | ||
| }; | ||
| if !ctx.i32_counter_slots.contains_key(idx_id) || !ctx.integer_locals.contains(idx_id) { | ||
| return None; | ||
| } | ||
| let fact = ctx | ||
| .packed_f64_loop_facts | ||
| .iter() | ||
| .rev() | ||
| .find(|fact| { | ||
| fact.array_local_id == arr_id | ||
| && fact.index_local_id != *idx_id | ||
| && !fact.allow_holes | ||
| && !fact.window_validated | ||
| })? | ||
| .clone(); | ||
| Some((fact, *idx_id)) | ||
| } | ||
|
|
||
| /// #6011: decompose a packed-loop index expression into `(counter_local_id, | ||
| /// constant_offset)`. Matches `i`, `i + c`, `c + i`, and `i - c` with a small | ||
| /// |c| — exactly the shapes the packed-f64 range loop matcher admits, so any | ||
| /// offset seen here on a fact-carrying (array, counter) pair is inside the | ||
| /// range guard's validated window. | ||
| pub(crate) fn packed_f64_loop_index_parts(index: &Expr) -> Option<(u32, i32)> { | ||
| use perry_hir::BinaryOp; | ||
| match index { | ||
| Expr::LocalGet(id) => Some((*id, 0)), | ||
| Expr::Binary { op, left, right } if matches!(op, BinaryOp::Add | BinaryOp::Sub) => { | ||
| let (id, offset) = match (left.as_ref(), right.as_ref()) { | ||
| (Expr::LocalGet(id), Expr::Integer(c)) => { | ||
| let offset = if matches!(op, BinaryOp::Sub) { | ||
| c.checked_neg()? | ||
| } else { | ||
| *c | ||
| }; | ||
| (*id, offset) | ||
| } | ||
| (Expr::Integer(c), Expr::LocalGet(id)) if matches!(op, BinaryOp::Add) => (*id, *c), | ||
| _ => return None, | ||
| }; | ||
| let offset = i32::try_from(offset).ok()?; | ||
| if offset.unsigned_abs() > 64 { | ||
| return None; | ||
| } | ||
| Some((id, offset)) | ||
| } | ||
| _ => None, | ||
| } | ||
| } | ||
|
|
||
| /// Look up a packed-f64 loop fact for `(arr, index-expr)`. Zero-offset | ||
| /// indices match any fact; non-zero offsets only match hole-tolerant facts | ||
| /// (established by the range guard, which validated the whole offset window — | ||
| /// the length-bound guard of the classic matcher only proves `i` itself). | ||
| pub(crate) fn packed_f64_loop_fact_for_index( | ||
| ctx: &FnCtx<'_>, | ||
| arr_id: u32, | ||
| index: &Expr, | ||
| ) -> Option<(PackedF64LoopFact, u32, i32)> { | ||
| let (idx_id, offset) = packed_f64_loop_index_parts(index)?; | ||
| let fact = packed_f64_loop_fact(ctx, arr_id, idx_id)?; | ||
| if offset != 0 && !fact.allow_holes && !fact.window_validated { | ||
| return None; | ||
| } | ||
| Some((fact, idx_id, offset)) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Reject foreign counters that the clone body can update before the read.
Line 5308 accepts any other i32 counter. The safe-body matcher still accepts
Expr::UpdateandExpr::LocalSetfor that local because it only protectsarr_idand this clone'scounter_id.For
i++; sum = sum + arr[i] + arr[j]in an inner clone,ican reacharr.lengthbeforearr[i]. The bounds check then takes the side exit afteri++. The slow clone re-executes the iteration and appliesi++again. This changes the counter, loop control, and result.Require proof that the foreign counter has no writes in the cloned body, or reject this shape. Add a side-exit regression for this case.
🤖 Prompt for AI Agents