-
-
Notifications
You must be signed in to change notification settings - Fork 161
codegen: keep the packed clone when a length-bounded body reads a[k ± c] (#9259) #9274
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:fix/9259-length-bound-offset-reads
Aug 31, 2026
Merged
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,44 @@ | ||
| **An `arr.length`-bounded packed-f64 loop no longer loses its fast clone when the | ||
| body reads `a[k ± c]`** (#9259). It did not lose it partially — it lost it | ||
| entirely, taking the plain `a[k]` in the same loop with it: 70 ms → 36 ms on a | ||
| 4096-element accumulate loop, and 60 ms → 19 ms on a comparison loop, against | ||
| node's 13 ms and 16 ms. | ||
|
|
||
| The cause was a cascade rather than a missed element load. Three separate | ||
| predicates encode "an index this loop's guard covers" as a bare | ||
| `Expr::LocalGet(counter_id)`, so `a[k - 1]` — an `Expr::Binary` — matched none of | ||
| them. The matcher's body walker declined, the offset read fell back to a helper | ||
| call, and the clone's own call-free scan then discarded the whole clone. | ||
|
|
||
| Two matchers each covered half the shape and neither covered the combination. | ||
| `lower_packed_f64_versioned_for` understands the `i < arr.length` bound but | ||
| publishes `window_validated: false`; `lower_packed_f64_range_versioned_for` | ||
| validates the offset window but accepts only a literal or loop-invariant bound, | ||
| and per its own call-site comment runs only after the first declined. Since | ||
| `arr.length` is the idiomatic spelling, the natural form was the slow one. | ||
|
|
||
| The fix admits a constant offset and pays the same inline `icmp ult idx, len` a | ||
| foreign counter already pays, taking the fact's existing side exit when it fails | ||
| — a compare and a never-taken branch, not a call, so the clone stays call-free. | ||
| The machinery existed already; what was missing was letting an offset index reach | ||
| it. Matcher and lowering now share one index parser, deliberately: a matcher that | ||
| admits what the lowering declines is not a missed optimisation, it is the same | ||
| 9× regression arriving by another route. | ||
|
|
||
| Soundness rests on the guard being stronger than its flag name suggests. The | ||
| versioned guard ends in `js_array_is_numeric_f64_layout`, a whole-array property | ||
| that answers 0 for a holes-flagged array, so a passing guard means every | ||
| in-bounds slot is raw f64; `window_validated: false` is a statement about bounds, | ||
| not holes, and bounds are exactly what the inline check re-establishes. The | ||
| compare is unsigned, so a negative index (`a[k-1]` at `k == 0`) exceeds any | ||
| length and side-exits. Reads only — a store side exit re-executes the iteration, | ||
| harmless for a read and double-applying for a store. | ||
|
|
||
| Because the parser is shared, this also admits an offset on a foreign counter | ||
| (`a[j - 1]` for an enclosing loop's `j`), which is wider than the headline shape | ||
| and deliberate: the bounds check makes both cases identical. | ||
|
|
||
| `s += a[k] + a[k-1]` still pays a dynamic add — `accumulator_rhs_is_numeric` and | ||
| `has_numeric_index_fact` carry the same bare-`LocalGet` assumption — which is why | ||
| the comparison loop gains 3.2× and the accumulate loop 1.94×. That is being | ||
| addressed separately. | ||
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
191 changes: 191 additions & 0 deletions
191
crates/perry/tests/issue_9259_length_bound_offset_reads.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,191 @@ | ||
| //! Regression coverage for #9259: an `arr.length`-bounded packed-f64 loop kept | ||
| //! its fast clone when the body read `a[k]`, but lost it **entirely** — not | ||
| //! partially — as soon as the body also read `a[k ± c]`. | ||
| //! | ||
| //! The failure was a cascade, not a missed element load. Three separate | ||
| //! predicates encode "an index this loop's guard covers" as a bare | ||
| //! `Expr::LocalGet(counter_id)`, so `a[k - 1]` (an `Expr::Binary`) matched | ||
| //! none of them. The matcher's body walker therefore declined | ||
| //! (`read_body_is_safe == false`, reported as `clone_not_call_free`), the read | ||
| //! fell back to a helper CALL, and the clone's call-free scan then discarded | ||
| //! the whole clone — taking the fast path for the plain `a[k]` with it. The | ||
| //! measured cost was 8 ms -> 72 ms on a 4096-element loop, flipping the shape | ||
| //! from beating node to 5.5x behind it. | ||
| //! | ||
| //! The fix admits a constant offset on the loop's own counter and pays the | ||
| //! same inline `icmp ult idx, len` a foreign counter already pays, taking the | ||
| //! fact's existing side exit when it fails. Reads only: a store side exit | ||
| //! re-executes the iteration, which is harmless for a read and would | ||
| //! double-apply a store. | ||
| //! | ||
| //! What these tests pin is the *admission*, not a timing: that the clone is | ||
| //! emitted at all for the offset body, and that the results still match the | ||
| //! generic path under a moving collector. | ||
|
|
||
| use std::path::{Path, PathBuf}; | ||
| use std::process::{Command, Output}; | ||
|
|
||
| fn perry_bin() -> PathBuf { | ||
| PathBuf::from(env!("CARGO_BIN_EXE_perry")) | ||
| } | ||
|
|
||
| fn compile(dir: &Path, source: &str) -> (PathBuf, String) { | ||
| let entry = dir.join("main.ts"); | ||
| let output = dir.join("main_bin"); | ||
| std::fs::write(&entry, source).expect("write entry"); | ||
|
|
||
| let compile = Command::new(perry_bin()) | ||
| .current_dir(dir) | ||
| .arg("compile") | ||
| .arg(&entry) | ||
| .arg("-o") | ||
| .arg(&output) | ||
| .env("PERRY_NO_CACHE", "1") | ||
| .env("PERRY_LLVM_KEEP_IR", "1") | ||
| .output() | ||
| .expect("run perry compile"); | ||
| assert!( | ||
| compile.status.success(), | ||
| "perry compile failed\nstdout:\n{}\nstderr:\n{}", | ||
| String::from_utf8_lossy(&compile.stdout), | ||
| String::from_utf8_lossy(&compile.stderr) | ||
| ); | ||
| ( | ||
| output, | ||
| String::from_utf8_lossy(&compile.stderr).into_owned(), | ||
| ) | ||
| } | ||
|
|
||
| /// The emitted IR, located the way `PERRY_LLVM_KEEP_IR` reports it. | ||
| fn kept_ir(stderr: &str) -> String { | ||
| let path = stderr | ||
| .lines() | ||
| .find_map(|line| line.split("kept LLVM IR: ").nth(1)) | ||
| .map(str::trim) | ||
| .map(PathBuf::from) | ||
| .unwrap_or_else(|| panic!("PERRY_LLVM_KEEP_IR did not report an IR path\n{stderr}")); | ||
| std::fs::read_to_string(path).expect("read kept LLVM IR") | ||
| } | ||
|
|
||
| fn packed_blocks(ir: &str) -> usize { | ||
| ir.lines() | ||
| .filter(|line| line.starts_with("packed_f64") && line.trim_end().ends_with(':')) | ||
| .count() | ||
| } | ||
|
|
||
| fn run(bin: &Path, dir: &Path, moving_gc: bool) -> Output { | ||
| let mut command = Command::new(bin); | ||
| command.current_dir(dir); | ||
| if moving_gc { | ||
| command | ||
| .env("PERRY_GC_FORCE_EVACUATE", "1") | ||
| .env("PERRY_GC_VERIFY_EVACUATION", "1"); | ||
| } | ||
| command.output().expect("run compiled binary") | ||
| } | ||
|
|
||
| fn assert_stdout(output: &Output, expected: &str, moving_gc: bool) { | ||
| assert!( | ||
| output.status.success(), | ||
| "binary failed with moving_gc={moving_gc}\nstdout:\n{}\nstderr:\n{}", | ||
| String::from_utf8_lossy(&output.stdout), | ||
| String::from_utf8_lossy(&output.stderr) | ||
| ); | ||
| assert_eq!(String::from_utf8_lossy(&output.stdout), expected); | ||
| } | ||
|
|
||
| /// `a[k]` alone under an `arr.length` bound — the shape that always worked. | ||
| /// Present as the positive control: without it, a regression that stopped | ||
| /// admitting *every* packed loop would leave the offset test below passing | ||
| /// vacuously in the other direction. | ||
| const PLAIN: &str = r#" | ||
| function run(a: number[]): number { | ||
| let c = 0; | ||
| for (let r = 0; r < 20; r++) { | ||
| for (let k = 1; k < a.length; k++) { | ||
| if (a[k] > 0.0) c++; | ||
| } | ||
| } | ||
| return c; | ||
| } | ||
| const a: number[] = []; | ||
| for (let i = 0; i < 512; i++) a.push((i * 37) % 1000); | ||
| console.log(run(a)); | ||
| "#; | ||
|
|
||
| /// The #9259 shape: same bound, same array, one constant-offset read added. | ||
| const OFFSET: &str = r#" | ||
| function run(a: number[]): number { | ||
| let c = 0; | ||
| for (let r = 0; r < 20; r++) { | ||
| for (let k = 1; k < a.length; k++) { | ||
| if (a[k] > a[k - 1]) c++; | ||
| } | ||
| } | ||
| return c; | ||
| } | ||
| const a: number[] = []; | ||
| for (let i = 0; i < 512; i++) a.push((i * 37) % 1000); | ||
| console.log(run(a)); | ||
| "#; | ||
|
|
||
| #[test] | ||
| fn length_bounded_loop_keeps_its_clone_when_the_body_reads_an_offset() { | ||
| let dir = tempfile::tempdir().expect("tempdir"); | ||
| let (_, plain_stderr) = compile(dir.path(), PLAIN); | ||
| let plain = packed_blocks(&kept_ir(&plain_stderr)); | ||
| assert!( | ||
| plain > 0, | ||
| "positive control: the plain `a[k]` body must still get a packed clone, \ | ||
| otherwise the offset assertion below proves nothing" | ||
| ); | ||
|
|
||
| let dir2 = tempfile::tempdir().expect("tempdir"); | ||
| let (_, offset_stderr) = compile(dir2.path(), OFFSET); | ||
| let offset = packed_blocks(&kept_ir(&offset_stderr)); | ||
| assert!( | ||
| offset > 0, | ||
| "#9259: adding `a[k - 1]` to an `arr.length`-bounded body discarded the \ | ||
| ENTIRE packed clone (the offset read fell back to a helper call, and \ | ||
| the call-free scan then rejected the clone), so the plain `a[k]` in \ | ||
| the same loop lost its fast path too — 8ms -> 72ms" | ||
| ); | ||
| } | ||
|
|
||
| /// The offset read is bounds-checked against the live length and side-exits | ||
| /// rather than reading out of bounds, so the answer must match the generic | ||
| /// path — including when the collector is relocating underneath it. | ||
| #[test] | ||
| fn offset_reads_agree_with_the_generic_path_under_a_moving_collector() { | ||
| let dir = tempfile::tempdir().expect("tempdir"); | ||
| let (bin, _) = compile(dir.path(), OFFSET); | ||
| for moving_gc in [false, true] { | ||
| assert_stdout(&run(&bin, dir.path(), moving_gc), "9860\n", moving_gc); | ||
| } | ||
| } | ||
|
|
||
| /// `k - 1` is negative on the first iteration when the loop starts at 0. The | ||
| /// inline check is an UNSIGNED compare, so the negative index exceeds any | ||
| /// length and takes the side exit into the generic clone, which returns | ||
| /// `undefined` for the missing element exactly as the slow path does. | ||
| #[test] | ||
| fn a_negative_offset_index_side_exits_instead_of_reading_out_of_bounds() { | ||
| let source = r#" | ||
| function run(a: number[]): number { | ||
| let seen = 0; | ||
| for (let k = 0; k < a.length; k++) { | ||
| const prev = a[k - 1]; | ||
| if (prev === undefined) seen++; | ||
| } | ||
| return seen; | ||
| } | ||
| const a: number[] = []; | ||
| for (let i = 0; i < 64; i++) a.push(i * 1.5); | ||
| console.log(run(a)); | ||
| "#; | ||
| let dir = tempfile::tempdir().expect("tempdir"); | ||
| let (bin, _) = compile(dir.path(), source); | ||
| for moving_gc in [false, true] { | ||
| assert_stdout(&run(&bin, dir.path(), moving_gc), "1\n", moving_gc); | ||
| } | ||
| } |
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 | 🟡 Minor | ⚡ Quick win
Describe the bounds branch as conditional.
The inline check can take the side exit for negative or out-of-range offsets, as stated in Lines [33-34]. Replace “never-taken branch” with “conditional side exit” so the performance claim does not contradict the documented safety behavior.
Proposed wording
📝 Committable suggestion
🤖 Prompt for AI Agents