diff --git a/CLAUDE.md b/CLAUDE.md index 10c50edd67..f203c64956 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1610 +**Current Version:** 0.5.1611 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index ae9c2e20c9..6d2ea4f8b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5579,7 +5579,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "base64 0.22.1", @@ -5643,7 +5643,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-dispatch", "serde", @@ -5651,7 +5651,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "cc", "libc", @@ -5660,7 +5660,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "aho-corasick", "anyhow", @@ -5677,7 +5677,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "perry-hir", @@ -5685,7 +5685,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "perry-hir", @@ -5693,7 +5693,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "perry-dispatch", @@ -5702,7 +5702,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "perry-hir", @@ -5710,7 +5710,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "base64 0.22.1", @@ -5722,7 +5722,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "perry-hir", @@ -5730,7 +5730,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "async-trait", "clap", @@ -5754,14 +5754,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "serde", "serde_json", @@ -5769,7 +5769,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1610" +version = "0.5.1611" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "clap", @@ -5795,7 +5795,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "block2", "objc2", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "argon2", "perry-ffi", @@ -5814,7 +5814,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "bcrypt", "perry-ffi", @@ -5822,7 +5822,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-ffi", "rusqlite", @@ -5830,7 +5830,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-ffi", "scraper", @@ -5838,7 +5838,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-ffi", "perry-runtime", @@ -5846,7 +5846,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "chrono", "cron", @@ -5856,7 +5856,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-ffi", "rust_decimal", @@ -5864,7 +5864,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5872,7 +5872,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-ffi", "perry-runtime", @@ -5880,14 +5880,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fetch" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "bytes", "lazy_static", @@ -5900,7 +5900,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "base64 0.22.1", "bytes", @@ -5932,7 +5932,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "lazy_static", "perry-ffi", @@ -5942,7 +5942,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "lru", "perry-ffi", @@ -5951,7 +5951,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "chrono", "perry-ffi", @@ -5959,7 +5959,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "bson", "futures-util", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "chrono", "perry-ffi", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "bytes", "perry-ffi", @@ -5998,7 +5998,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6017,7 +6017,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "lettre", "perry-ffi", @@ -6027,7 +6027,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "notify", "perry-ffi", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-ffi", "printpdf", @@ -6047,7 +6047,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-ffi", "sqlx", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "fast_image_resize", "image", @@ -6067,7 +6067,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "lazy_static", "perry-ffi", @@ -6076,7 +6076,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "perry-ffi", @@ -6096,7 +6096,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-ffi", "perry-runtime", @@ -6105,7 +6105,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "futures-util", "lazy_static", @@ -6118,7 +6118,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "brotli", "flate2", @@ -6128,7 +6128,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6138,7 +6138,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "perry-api-manifest", @@ -6158,11 +6158,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1610" +version = "0.5.1611" [[package]] name = "perry-parser" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "perry-diagnostics", @@ -6175,7 +6175,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perex", "regex", @@ -6183,7 +6183,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "ahash", "base64 0.22.1", @@ -6241,14 +6241,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6333,21 +6333,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "dirs", "perry-ffi", @@ -6357,7 +6357,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "base64 0.22.1", "jni", @@ -6372,7 +6372,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "rand 0.10.2", "serde", @@ -6382,7 +6382,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "base64 0.22.1", "block2", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "base64 0.22.1", "block2", @@ -6439,7 +6439,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1610" +version = "0.5.1611" [[package]] name = "perry-ui-test" @@ -6450,11 +6450,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1610" +version = "0.5.1611" [[package]] name = "perry-ui-tvos" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "base64 0.22.1", "block2", @@ -6471,7 +6471,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "base64 0.22.1", "block2", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "block2", "libc", @@ -6502,7 +6502,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "base64 0.22.1", "libc", @@ -6521,7 +6521,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "base64 0.22.1", "libc", @@ -6534,7 +6534,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "anyhow", "base64 0.22.1", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1610" +version = "0.5.1611" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 91e85f109f..acf162abca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -323,7 +323,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1610" +version = "0.5.1611" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10718-array-index-hoist.md b/changelog.d/10718-array-index-hoist.md new file mode 100644 index 0000000000..cfcf2b480f --- /dev/null +++ b/changelog.d/10718-array-index-hoist.md @@ -0,0 +1,9 @@ +**Indexed reads on an ordinary `Array` no longer re-prove a loop-invariant receiver on every element.** + +An indexed read cost **87 instructions per element** — against 6 for the same arithmetic on a `Float64Array` and 16 for node — and none of it was a runtime call. 56 of the 87 were loop-invariant receiver revalidation re-executed every iteration: the NaN-box tag and handle-band test, the forwarding-flag follow, and a six-load live-head guard. + +perry already had tiers that hoist that proof into the loop preheader. They were declining at one gate, `array_static_type_excluded` — a *declared static type* test in front of a tier that is otherwise fully runtime-guarded — so `const a: number[]` got it and plain `new Array(400)`, which infers `Array`, did not. Ordinary JavaScript never reached the tier it already had. + +Separately, `a[i] += 1` cost **948** instructions per element, 3.7× the identical `a[i] = a[i] + 1`, and no annotation helped: the compound-assignment spill temporaries were minted as `Type::Any`, erasing the receiver's array-ness and the index's integer-ness before codegen saw the statement. + +Array read **87 → 13.5** (node 16.3), `a[i] += 1` **948 → 273**, `a[i] += b[i]` **1025 → 347**. A particle simulation over four numeric arrays spends **60.9% fewer instructions** and **59% less peak RSS**. The bare loop and both `Float64Array` paths are unchanged to the instruction. diff --git a/changelog.d/10718-array-store-hoist.md b/changelog.d/10718-array-store-hoist.md new file mode 100644 index 0000000000..648ff78110 --- /dev/null +++ b/changelog.d/10718-array-store-hoist.md @@ -0,0 +1,9 @@ +**Stores to an ordinary `Array` element no longer re-prove a loop-invariant receiver on every element.** + +An indexed write cost **105 instructions per element** — against 8 for the same store to a `Float64Array` and 12 for node — with zero runtime calls. **51 of the 105 were loop-invariant** receiver revalidation, and a further 42 was a write-barrier decision provable away from the value's type. + +This widens the store admission the way #10731 widened reads. The gate was `has_materialization_hazard`, which a trailing `console.log` is enough to set. + +`a[i] = k + i` **105 → 17.4**, `a[i] = a[i] + 1` **256 → 24.5** (node 18.7), `a[i] = a[i] + b[i]` **333 → 35.9**. The bare loop, both `Float64Array` paths and the indexed read are unchanged to the instruction. + +Note this moves none of the five real programs in #10695 — their loop bodies are multi-statement or contain calls, which no current tier admits (#10741) — and `a[i] += 1` is unaffected because its lowering is two statements (#10743). diff --git a/changelog.d/10743-compound-assign-alias-fold.md b/changelog.d/10743-compound-assign-alias-fold.md new file mode 100644 index 0000000000..7dea1f4e14 --- /dev/null +++ b/changelog.d/10743-compound-assign-alias-fold.md @@ -0,0 +1,11 @@ +**`a[i] += 1` reaches the same loop tier as `a[i] = a[i] + 1`.** + +The two spellings are the same operation and node compiles both to the same cost. perry compiled them **11× apart** — 277 instructions per element against 24 — and the slow one was the idiomatic spelling. + +HIR lowers a compound member assignment into two immutable alias `Let`s plus the store, so the base and the key are each evaluated exactly once and before the right-hand side. The classic range-loop matcher admits exactly ONE statement, so the lowering guaranteed the statement could never reach the tier. Annotating the array changed nothing: the obstacle is the statement count, not type information. + +The temporaries stay. They are load-bearing — an RHS call can reassign the bindings they were read from, and the store must still land at the index evaluated before it ran. Instead the matcher folds them, and only for the guarded fast clones: the slow clone lowers the statements as written, so a failed guard and every side exit still execute the specified evaluation order. Inside the matched subset the fold is exact, because the body walk is a whitelist that admits no call, closure, `await`, update or assignment anywhere in the statement — nothing can write the locals the aliases read. + +`a[i] += 1` **277 → 25.5**, `a[i] -= 1` **208 → 27.5**, `a[i] += b[i]` **347 → 35.9** (identical to `a[i] = a[i] + b[i]`), `a[i] *= 1` **206 → 25.5**, `a[i] |= 0` **236 → 52.5**. The bare loop, both `Float64Array` paths, the indexed read and write, and both expanded spellings are unchanged — their emitted LLVM IR is byte-identical. + +This needs none of #10741's mid-iteration side-exit discipline: the folded-away statements perform no stores, so there is nothing to un-do when a guard fails partway. It also moves none of the five real programs in #10695 — their loop bodies are still multi-statement or contain calls, which no current tier admits. diff --git a/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs b/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs index 09b5817ffa..e4f3c17141 100644 --- a/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs +++ b/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs @@ -507,11 +507,42 @@ fn idxset_recv_global_ir() -> String { op: UpdateOp::Increment, prefix: false, }), - body: vec![Stmt::Expr(Expr::IndexSet { - object: Box::new(Expr::LocalGet(G_ID)), - index: Box::new(Expr::LocalGet(IDX_ID)), - value: Box::new(Expr::LocalGet(VAL_ID)), - })], + // #10718 store side: the body carries a SECOND statement, and + // that is load-bearing for this probe rather than incidental. + // + // Widening the packed-f64 range loop's STORE admission to + // element-type-erased array bindings (`Array` — which is + // exactly `g`'s type here) made this loop qualify for the + // versioned tier. The tier is correct on it — the fast copy + // stores only values its per-store check proved are genuine + // doubles, and everything else side-exits into a slow copy that + // keeps the full barriered store (`idxset.inbounds.barrier` -> + // `js_write_barrier_slot_validated_parent`, plus + // `js_write_barrier_slot` on both extend paths and the numeric + // note) — but the slow copy reaches the store through the + // `idxset.inbounds` receiver arm, not through `recv_global`. + // The stem would then have had NO live witness anywhere, which + // is the one thing this census exists to prevent. + // + // `packed_f64_range_loop_body_collect` admits exactly ONE + // statement, so a second one keeps this probe on the + // un-versioned receiver ladder it is here to cover, without + // touching what it asserts. If a future tier learns to admit + // multi-statement store bodies, this probe goes red again — + // deliberately — and must be re-shaped, not deleted. + body: vec![ + Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(G_ID)), + index: Box::new(Expr::LocalGet(IDX_ID)), + value: Box::new(Expr::LocalGet(VAL_ID)), + }), + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(VAL_ID)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + ], }, Stmt::Return(Some(Expr::LocalGet(G_ID))), ], diff --git a/crates/perry-codegen/src/stmt/compound_alias_fold_tests.rs b/crates/perry-codegen/src/stmt/compound_alias_fold_tests.rs new file mode 100644 index 0000000000..27fb963079 --- /dev/null +++ b/crates/perry-codegen/src/stmt/compound_alias_fold_tests.rs @@ -0,0 +1,262 @@ +//! #10743: the compound-assignment alias fold, and the shapes it declines. +//! +//! `a[i] += 1` is lowered by HIR's `hoist_compound_member_assign` into two +//! immutable alias `Let`s plus the store, so the base and the key are each +//! evaluated exactly once and before the right-hand side. The classic +//! range-loop matcher admits exactly ONE statement, so the idiomatic spelling +//! could never reach the tier that makes the expanded `a[i] = a[i] + 1` fast: +//! measured 277 instructions per element against 24 for the expanded form on +//! the same array, and annotating the array changed nothing, because the +//! obstacle is the statement count rather than type information. +//! +//! The canonical body below is transcribed from a `--print-hir` dump of +//! `for (let i = 0; i < 400; i++) a[i] += 1;`, not guessed: +//! +//! ```text +//! Let { id: 5, name: "__cmpd_base_5", mutable: false, init: Some(LocalGet(1)) } +//! Let { id: 6, name: "__cmpd_key_6", mutable: false, init: Some(LocalGet(4)) } +//! Expr(IndexSet { object: LocalGet(5), index: LocalGet(6), +//! value: Binary { Add, IndexGet { LocalGet(5), LocalGet(6) }, +//! Integer(1) } }) +//! ``` +//! +//! Every `declines_*` test here is a guard's witness: it is the test that goes +//! red when that condition is deleted from the fold. + +#![cfg(test)] + +use perry_hir::types::Type; +use perry_hir::{BinaryOp, Expr, Stmt}; + +use super::loops::packed_f64_range_loop_compound_alias_fold; + +const ARRAY: u32 = 1; +const COUNTER: u32 = 4; +const BASE_TEMP: u32 = 5; +const KEY_TEMP: u32 = 6; + +fn temp(id: u32, name: &str, mutable: bool, init: Expr) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::Number, + mutable, + init: Some(init), + } +} + +/// `__cmpd_base_5[__cmpd_key_6] = __cmpd_base_5[__cmpd_key_6] + 1` +fn alias_store() -> Stmt { + Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(BASE_TEMP)), + index: Box::new(Expr::LocalGet(KEY_TEMP)), + value: Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(BASE_TEMP)), + index: Box::new(Expr::LocalGet(KEY_TEMP)), + }), + right: Box::new(Expr::Integer(1)), + }), + }) +} + +/// What the store must fold to: `a[i] = a[i] + 1`, the shape the tier already +/// admits and already beats node on. +fn expanded_store() -> Stmt { + Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(ARRAY)), + index: Box::new(Expr::LocalGet(COUNTER)), + value: Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ARRAY)), + index: Box::new(Expr::LocalGet(COUNTER)), + }), + right: Box::new(Expr::Integer(1)), + }), + }) +} + +fn canonical_body() -> Vec { + vec![ + temp(BASE_TEMP, "__cmpd_base_5", false, Expr::LocalGet(ARRAY)), + temp(KEY_TEMP, "__cmpd_key_6", false, Expr::LocalGet(COUNTER)), + alias_store(), + ] +} + +fn debug(stmts: &[Stmt]) -> String { + format!("{stmts:?}") +} + +#[test] +fn folds_the_canonical_compound_assignment_to_the_expanded_store() { + let folded = + packed_f64_range_loop_compound_alias_fold(&canonical_body()).expect("shape must fold"); + assert_eq!( + debug(&folded), + debug(std::slice::from_ref(&expanded_store())), + "the fold must produce exactly the expanded spelling" + ); +} + +#[test] +fn folds_an_arithmetic_key_initialiser() { + // `a[i * 2 + 1] += 1` spills the whole index expression into the key temp. + let key = Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::LocalGet(COUNTER)), + right: Box::new(Expr::Integer(2)), + }), + right: Box::new(Expr::Integer(1)), + }; + let body = vec![ + temp(BASE_TEMP, "__cmpd_base_5", false, Expr::LocalGet(ARRAY)), + temp(KEY_TEMP, "__cmpd_key_6", false, key.clone()), + alias_store(), + ]; + let folded = packed_f64_range_loop_compound_alias_fold(&body).expect("shape must fold"); + let text = debug(&folded); + assert!( + !text.contains("LocalGet(5)") && !text.contains("LocalGet(6)"), + "no alias id may survive the fold: {text}" + ); + assert!( + text.contains("Mul"), + "the key tree must be substituted: {text}" + ); +} + +#[test] +fn declines_a_mutable_alias() { + // Guard: `mutable: false`. A writable binding is not an alias -- nothing + // here proves its value at the store is the value it was bound to. + let mut body = canonical_body(); + if let Stmt::Let { mutable, .. } = &mut body[0] { + *mutable = true; + } + assert!(packed_f64_range_loop_compound_alias_fold(&body).is_none()); +} + +#[test] +fn declines_a_user_named_binding() { + // Guard: the `__cmpd_` name. The fold's argument rests on these temps + // being the compiler's own compound-assign spills, read only by the one + // statement they were minted for. A user `const` in the loop body belongs + // to the general multi-statement tier (#10741), not here. + let mut body = canonical_body(); + if let Stmt::Let { name, .. } = &mut body[0] { + *name = "userConst".to_string(); + } + assert!(packed_f64_range_loop_compound_alias_fold(&body).is_none()); +} + +#[test] +fn declines_an_initialiser_outside_the_stable_grammar() { + // Guard: `packed_f64_range_loop_alias_init_is_stable`. An element read is + // not re-evaluation-safe the way a local read is -- the folded statement + // evaluates the key tree twice. + let mut body = canonical_body(); + if let Stmt::Let { init, .. } = &mut body[1] { + *init = Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ARRAY)), + index: Box::new(Expr::LocalGet(COUNTER)), + }); + } + assert!(packed_f64_range_loop_compound_alias_fold(&body).is_none()); +} + +#[test] +fn declines_a_body_longer_than_two_aliases_and_a_store() { + let mut body = canonical_body(); + body.insert(0, temp(7, "__cmpd_base_7", false, Expr::LocalGet(ARRAY))); + assert!(packed_f64_range_loop_compound_alias_fold(&body).is_none()); +} + +#[test] +fn declines_a_body_with_no_aliases() { + // A single statement is already the shape the tier takes; the fold must + // not claim it, or it would clear and rebuild an access map for nothing. + assert!( + packed_f64_range_loop_compound_alias_fold(std::slice::from_ref(&expanded_store())) + .is_none() + ); +} + +#[test] +fn declines_a_repeated_alias_id() { + // Two bindings for one id would make the substitution order-dependent. + let body = vec![ + temp(BASE_TEMP, "__cmpd_base_5", false, Expr::LocalGet(ARRAY)), + temp(BASE_TEMP, "__cmpd_key_5", false, Expr::LocalGet(COUNTER)), + alias_store(), + ]; + assert!(packed_f64_range_loop_compound_alias_fold(&body).is_none()); +} + +#[test] +fn declines_when_the_last_statement_is_not_an_expression() { + let body = vec![ + temp(BASE_TEMP, "__cmpd_base_5", false, Expr::LocalGet(ARRAY)), + temp(KEY_TEMP, "__cmpd_key_6", false, Expr::LocalGet(COUNTER)), + Stmt::Return(Some(Expr::LocalGet(BASE_TEMP))), + ]; + assert!(packed_f64_range_loop_compound_alias_fold(&body).is_none()); +} + +#[test] +fn declines_an_alias_without_an_initialiser() { + let body = vec![ + Stmt::Let { + id: BASE_TEMP, + name: "__cmpd_base_5".to_string(), + ty: Type::Number, + mutable: false, + init: None, + }, + temp(KEY_TEMP, "__cmpd_key_6", false, Expr::LocalGet(COUNTER)), + alias_store(), + ]; + assert!(packed_f64_range_loop_compound_alias_fold(&body).is_none()); +} + +#[test] +fn the_logical_assignment_shape_folds_but_stays_unversionable() { + // `a[i] ||= 3` spills the same two aliases but ends in `Expr::Logical`, + // whose right operand is the store. The fold is shape-agnostic, so it + // rewrites the statement -- and the classic body walk then declines it, + // because `packed_f64_range_loop_pure_expr_collect` has no `IndexSet` arm. + // This test pins the second half of that sentence: if a future widening + // admits `Logical`, the short-circuit semantics have to be re-argued. + let body = vec![ + temp(BASE_TEMP, "__cmpd_base_5", false, Expr::LocalGet(ARRAY)), + temp(KEY_TEMP, "__cmpd_key_6", false, Expr::LocalGet(COUNTER)), + Stmt::Expr(Expr::Logical { + op: perry_hir::LogicalOp::Or, + left: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(BASE_TEMP)), + index: Box::new(Expr::LocalGet(KEY_TEMP)), + }), + right: Box::new(Expr::IndexSet { + object: Box::new(Expr::LocalGet(BASE_TEMP)), + index: Box::new(Expr::LocalGet(KEY_TEMP)), + value: Box::new(Expr::Integer(3)), + }), + }), + ]; + let folded = packed_f64_range_loop_compound_alias_fold(&body).expect("shape folds"); + let mut accesses = std::collections::BTreeMap::new(); + assert!( + !super::loops::packed_f64_range_loop_body_collect( + &folded, + COUNTER, + None, + &mut accesses, + None, + ), + "a logical compound assignment must not be admitted by the classic walk" + ); +} diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index b471d5f88b..96f5fbf8c7 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -1581,6 +1581,13 @@ struct PackedF64RangeLoop { /// mid-iteration side exit could double-apply earlier statement effects /// on re-execution). dense: bool, + /// #10743: the body the GUARDED CLONES are lowered from, when it is not + /// the body as written. Set only by the compound-assignment alias fold + /// (`packed_f64_range_loop_compound_alias_fold`); `None` means both + /// clones lower the same statements. The slow clone always lowers the + /// original body, so a failed guard executes the specified evaluation + /// order. + fast_body: Option>, } /// #6011: range-preguarded packed-f64 versioned loop. @@ -1615,6 +1622,20 @@ fn range_loop_reject(reason: &'static str) -> Option { } None } +/// Positive twin of [`range_loop_reject`]: the decline traces say which gate +/// said no, and nothing said yes. A fixture that claims to exercise a guarded +/// fast path needs to be able to show it REACHED it — #10746 shipped a GC +/// stress whose fixtures were all declined at `body_not_admissible` before the +/// guard under test ever ran, and the traces available at the time could not +/// have revealed that. +fn range_loop_trace(what: &str) { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + if *ON.get_or_init(|| std::env::var("PERRY_PACKED_LOOP_TRACE").as_deref() == Ok("1")) { + eprintln!("[range-loop] admitted: {what}"); + } +} + fn match_packed_f64_range_loop( ctx: &FnCtx<'_>, init: Option<&Stmt>, @@ -1729,6 +1750,7 @@ fn match_packed_f64_range_loop( && !ctx.boxed_vars.contains(&id) && !ctx.closure_captures.contains_key(&id) }; + let mut fast_body: Option> = None; let dense = if packed_f64_range_loop_body_collect( body, counter_id, @@ -1737,6 +1759,22 @@ fn match_packed_f64_range_loop( Some(&affine_leaf_ok), ) { false + } else if let Some(folded) = packed_f64_range_loop_compound_alias_fold(body).filter(|folded| { + // #10743: `a[i] += 1` is three statements only because of its + // spec-mandated alias temporaries. Retry the CLASSIC walk on the + // folded statement; everything else about the tier is unchanged. + accesses.clear(); + packed_f64_range_loop_body_collect( + folded, + counter_id, + bound_local, + &mut accesses, + Some(&affine_leaf_ok), + ) + }) { + range_loop_trace("compound_assign_alias_fold"); + fast_body = Some(folded); + false } else { // The classic shape (one statement, counter-offset indices, stores // allowed, hole-tolerant with side exits) didn't match. Try the @@ -1823,7 +1861,9 @@ fn match_packed_f64_range_loop( { return range_loop_reject("dense_written_not_addressable"); } - } else if !packed_loop_array_binding_is_eligible(ctx, arr_id) { + } else if !packed_loop_array_binding_is_eligible(ctx, arr_id) + && !written_untyped_binding_is_guardable(ctx, arr_id) + { return range_loop_reject("written_binding_not_eligible"); } } else if !packed_loop_array_binding_storage_is_addressable(ctx, arr_id) @@ -1878,26 +1918,38 @@ fn match_packed_f64_range_loop( // every loop entry, so those static facts are not load-bearing // here; a wrong hint is one failed guard -> slow loop. Classic // (side-exiting, hole-tolerant) written arrays keep the full set. - if !local_allows_packed_f64_loop_store(ctx, arr_id) { + // #10718 store side: the two remaining tests below are DECLARED + // STATIC TYPE / static fact-graph tests standing in front of a + // tier whose every correctness obligation is discharged at + // runtime. `written_untyped_binding_is_guardable` admits the + // ordinary untyped-JavaScript array binding alongside them — see + // that function for why the guard, not the hint, is what holds. + let untyped_guardable = written_untyped_binding_is_guardable(ctx, arr_id); + if !local_allows_packed_f64_loop_store(ctx, arr_id) && !untyped_guardable { return range_loop_reject("store_local_not_allowed"); } if !dense && !ctx .native_facts .packed_f64_eligible_for_guarded_store(arr_id) + && !untyped_guardable { return range_loop_reject("store_not_fact_eligible"); } } else if !local_is_number_array(ctx, arr_id) - && !(dense && local_is_untyped_candidate(ctx, arr_id)) + && !local_is_guardable_untyped_array(ctx, arr_id) { - // #6750 follow-up: read-only DENSE accesses also admit bindings - // with no usable static type (`any` function parameters — the - // bcryptjs S-box shape). The entry guards/probes re-validate the - // ACTUAL runtime value, so a wrong hint costs one failed guard → - // slow loop, never correctness. Known non-array static types stay - // excluded so ordinary object/string index loops don't grow dead - // guard chains. + // #6750 follow-up: read-only accesses also admit bindings with no + // usable static ELEMENT type — an `any` parameter (the bcryptjs + // S-box shape) and, since #10718, the ordinary untyped-JavaScript + // `const a = new Array(n)` / `const a = []` binding, whose element + // type erases to `any`. The entry guards/probes re-validate the + // ACTUAL runtime value — plain-array shape, descriptors, prototype + // pollution, frozen/sealed, the whole index window, and raw-f64 + // (hole-tolerant) packedness — so a wrong hint costs one failed + // guard → slow loop, never correctness. Known non-array and + // known-non-numeric static types stay excluded so ordinary + // object/string index loops don't grow dead guard chains. return range_loop_reject("array_static_type_excluded"); } } @@ -1907,9 +1959,173 @@ fn match_packed_f64_range_loop( bound, arrays: accesses.into_values().collect(), dense, + fast_body, }) } +/// #10743: fold a compound member assignment's alias `Let`s into its store. +/// +/// HIR's `hoist_compound_member_assign` lowers `a[i] += 1` into two immutable +/// alias bindings followed by the store: +/// +/// ```text +/// Let __cmpd_base_7 = LocalGet(a) +/// Let __cmpd_key_8 = LocalGet(i) +/// Expr(IndexSet { object: LocalGet(7), index: LocalGet(8), +/// value: Binary { Add, IndexGet { LocalGet(7), LocalGet(8) }, 1 } }) +/// ``` +/// +/// Those temporaries are LOAD-BEARING in HIR and must not be removed there. +/// The specification evaluates the base and the key exactly once and BEFORE +/// the right-hand side, and an RHS call can reassign the very bindings they +/// were read from — `a[i] += (() => { i = 1; return 5; })()` must store at the +/// index `i` held before the arrow ran. Deleting the spill in the lowering is +/// a spec violation, not an optimisation, which is why this lives in the +/// matcher instead. +/// +/// Inside the matched subset the aliases are provably redundant. +/// [`packed_f64_range_loop_pure_expr_collect`] is a WHITELIST walk: it admits +/// no call, no closure, no `await`, no `Update` and no assignment anywhere in +/// the statement, and [`packed_f64_range_loop_store_collect`] routes every +/// part of the store through it. So nothing between the alias binding and the +/// store can write the locals the aliases read, and the folded statement has +/// exactly the semantics of the three it replaces. +/// +/// The fold is used ONLY for the guarded fast clones. The slow clone keeps +/// the statements as written, so a failed guard — and every side exit — still +/// executes the specified evaluation order. +/// +/// Why it is worth a pass of its own: `a[i] += 1` measured 277 instructions +/// per element against 24 for the byte-identical `a[i] = a[i] + 1`, purely +/// because the lowering hands the matcher three statements and the matcher +/// takes one. Unlike the general multi-statement tier (#10741) this needs no +/// mid-iteration side-exit discipline, because the folded-away statements +/// perform no stores: there is nothing to un-do when a guard fails partway. +pub(super) fn packed_f64_range_loop_compound_alias_fold(body: &[Stmt]) -> Option> { + use perry_hir::Expr; + // A computed key spills two temps, a static property (`o.f += 1`) one. + // Anything longer is not this shape. + if body.len() < 2 || body.len() > 3 { + return None; + } + let (aliases, last) = body.split_at(body.len() - 1); + let mut map: std::collections::HashMap = std::collections::HashMap::new(); + for stmt in aliases { + let Stmt::Let { + id, + name, + mutable: false, + init: Some(init), + .. + } = stmt + else { + return None; + }; + // The compiler's own compound-assign spills only. A user `const` in + // the loop body is the general multi-statement tier's problem, not + // this one, and admitting it here would widen the claim above beyond + // what the name guarantees (these temps are read only by the one + // statement they were minted for). + if !name.starts_with("__cmpd_") { + return None; + } + if !packed_f64_range_loop_alias_init_is_stable(init) { + return None; + } + // Fold through the aliases bound earlier, so the replacement stored + // here is itself alias-free; a repeated id would make that untrue. + let mut init = init.clone(); + packed_f64_range_loop_substitute_locals(&mut init, &map); + if map.insert(*id, init).is_some() { + return None; + } + } + if map.is_empty() { + return None; + } + let [Stmt::Expr(expr)] = last else { + return None; + }; + let mut folded = expr.clone(); + packed_f64_range_loop_substitute_locals(&mut folded, &map); + // Defence in depth rather than a witnessed guard, and labelled as such: + // a WRITE to an alias (`LocalSet`/`Update`) carries its target in a field + // the substitution above does not visit, so a surviving mention would + // mean the fold was partial. No shape reaching here from source can + // produce one — `pure_expr_collect` rejects both nodes outright — so this + // cannot be made to fail by a fixture, and it is a structural assertion, + // not a check the differential suite witnesses. + if map + .keys() + .any(|id| packed_f64_range_loop_expr_touches_local(&folded, *id)) + { + return None; + } + Some(vec![Stmt::Expr(folded)]) +} + +/// The grammar an alias initialiser may take for +/// [`packed_f64_range_loop_compound_alias_fold`]: local reads, numeric +/// literals, and `+`/`-`/`*` over them. +/// +/// Two properties are needed and both follow from the grammar. The tree is +/// side-effect-free, so the folded statement may evaluate a key twice — once +/// for the read index, once for the store index — where the original +/// evaluated it once. And its value cannot change between those two +/// evaluations, because the admitted statement writes no local at all. +fn packed_f64_range_loop_alias_init_is_stable(init: &perry_hir::Expr) -> bool { + use perry_hir::{BinaryOp, Expr}; + match init { + Expr::LocalGet(_) | Expr::Integer(_) | Expr::Number(_) => true, + Expr::Binary { op, left, right } => { + matches!(op, BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul) + && packed_f64_range_loop_alias_init_is_stable(left) + && packed_f64_range_loop_alias_init_is_stable(right) + } + _ => false, + } +} + +/// Replace every `LocalGet(id)` in `expr` with `map[id]`. Replacements are +/// alias-free by construction, so a substituted subtree is not re-walked. +fn packed_f64_range_loop_substitute_locals( + expr: &mut perry_hir::Expr, + map: &std::collections::HashMap, +) { + use perry_hir::Expr; + if let Expr::LocalGet(id) = expr { + if let Some(replacement) = map.get(id) { + *expr = replacement.clone(); + return; + } + } + perry_hir::walker::walk_expr_children_mut(expr, &mut |child| { + packed_f64_range_loop_substitute_locals(child, map); + }); +} + +/// Does `expr` mention `id` in ANY position — read, write, or update? +/// Wider than [`expr_mentions_local`], which only sees reads. +fn packed_f64_range_loop_expr_touches_local(expr: &perry_hir::Expr, id: u32) -> bool { + use perry_hir::Expr; + let hit = match expr { + Expr::LocalGet(found) | Expr::LocalSet(found, _) | Expr::Update { id: found, .. } => { + *found == id + } + _ => false, + }; + if hit { + return true; + } + let mut found = false; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + if !found { + found = packed_f64_range_loop_expr_touches_local(child, id); + } + }); + found +} + /// #9253: is `index` an integer-producing expression over the loop counter and /// loop-invariant integer locals — `k`, `i * size + k`, `k * size + j`? /// @@ -2108,7 +2324,7 @@ fn packed_f64_range_loop_index_offset(index: &perry_hir::Expr, counter_id: u32) /// Body walk for [`match_packed_f64_range_loop`]: exactly one expression /// statement whose single side effect happens after all potential side exits. -fn packed_f64_range_loop_body_collect( +pub(super) fn packed_f64_range_loop_body_collect( body: &[Stmt], counter_id: u32, bound_local: Option, @@ -3197,12 +3413,21 @@ fn lower_packed_f64_range_versioned_for( // is exact for the whole loop — and, unlike a `@perry_global_*` load, // a non-escaping alloca is promotable to a register even with the fast // loop's raw inttoptr element stores in the way. - let written_local = match body { + // + // #10743: the GUARDED CLONES lower `fast_body` — the body as written + // unless the compound-assignment alias fold rewrote it. The SLOW clone + // always lowers `body`, so a failed guard executes the evaluation order + // the specification requires. Caching a module global is shared by both + // copies, which stays correct for the folded shape by the same argument + // as for every other matched body: it contains no call, closure or await, + // so nothing can write the global between the two loop entries. + let fast_body: &[Stmt] = matched.fast_body.as_deref().unwrap_or(body); + let written_local = match fast_body { [Stmt::Expr(perry_hir::Expr::LocalSet(id, _))] => Some(*id), _ => None, }; let mut global_override_ids: Vec = Vec::new(); - for gid in packed_f64_range_loop_invariant_global_reads(ctx, body, written_local) { + for gid in packed_f64_range_loop_invariant_global_reads(ctx, fast_body, written_local) { let Some(global_name) = ctx.module_globals.get(&gid).cloned() else { continue; }; @@ -3334,7 +3559,7 @@ fn lower_packed_f64_range_versioned_for( init, condition, update, - body, + fast_body, "masked_window_ta_i32", "for.packed_f64_range_fast_ta_i32", true, @@ -3349,7 +3574,7 @@ fn lower_packed_f64_range_versioned_for( init, condition, update, - body, + fast_body, "masked_window_ta_u32", "for.packed_f64_range_fast_ta_u32", false, @@ -3364,7 +3589,7 @@ fn lower_packed_f64_range_versioned_for( init, condition, update, - body, + fast_body, "masked_window_ta_f64", "for.packed_f64_range_fast_ta_f64", false, @@ -3427,7 +3652,7 @@ fn lower_packed_f64_range_versioned_for( let mut acc_scope = emit_range_loop_accumulator_admission( ctx, &matched, - body, + fast_body, &slow_pre_label, "packed_f64_range.fast_i32", ); @@ -3456,7 +3681,7 @@ fn lower_packed_f64_range_versioned_for( init, condition, update, - body, + fast_body, "for.packed_f64_range_fast_i32", Some((matched.counter_id, bound_i32.clone())), )?; @@ -3473,7 +3698,7 @@ fn lower_packed_f64_range_versioned_for( let mut acc_scope = emit_range_loop_accumulator_admission( ctx, &matched, - body, + fast_body, &slow_pre_label, "packed_f64_range.fast", ); @@ -3502,7 +3727,7 @@ fn lower_packed_f64_range_versioned_for( init, condition, update, - body, + fast_body, "for.packed_f64_range_fast", Some((matched.counter_id, bound_i32.clone())), )?; @@ -3529,7 +3754,7 @@ fn lower_packed_f64_range_versioned_for( let mut acc_scope = emit_range_loop_accumulator_admission( ctx, &matched, - body, + fast_body, &slow_pre_label, "packed_f64_range.classic", ); @@ -3558,7 +3783,7 @@ fn lower_packed_f64_range_versioned_for( init, condition, update, - body, + fast_body, "for.packed_f64_range_fast", Some((matched.counter_id, bound_i32.clone())), )?; @@ -5968,6 +6193,126 @@ pub(super) fn local_is_untyped_candidate(ctx: &FnCtx<'_>, local_id: u32) -> bool ) } +/// #10718: a READ-only range-loop receiver whose static type carries no usable +/// element proof, but which is not known to be a non-array or a non-numeric +/// array. +/// +/// Two shapes qualify, and the difference matters: +/// +/// * [`local_is_untyped_candidate`] — no stable type proof at all, or +/// `any`/`unknown`. This is #6750's population (an `any` parameter). +/// * an ARRAY binding whose element type erases to `any`/`unknown`. This is +/// what `const a = new Array(n)`, `const a = []` and every untyped +/// JavaScript array infer, and it was the gate that kept ordinary JS off +/// every hoisted element tier: annotating the identical program +/// `const a: number[] = new Array(n)` cost 13.4 instructions per element +/// against 87 for the same source without the annotation (#10718). +/// +/// Admitting these is a hint, never a claim: `packed_f64_array_loop_range_guard` +/// re-proves plain-array shape, forwarding, index descriptors, `Array.prototype` +/// / `Object.prototype` index pollution, a recorded custom array prototype, +/// frozen / sealed / non-extensible flags, the capacity/length sanity bounds, +/// the whole index window against the LIVE length, and raw-f64-or-holes +/// packedness of every slot, at every loop entry — and the matched body admits +/// no call, closure or await, so nothing can invalidate that between the guard +/// and the last iteration. A receiver that is not what the hint suggested fails +/// the guard and runs the unchanged slow loop. +/// +/// A declared non-numeric array (`string[]`, `Foo[]`) and every known non-array +/// type stay excluded: their guard would be dead weight on every loop entry. +fn local_is_guardable_untyped_array(ctx: &FnCtx<'_>, local_id: u32) -> bool { + local_is_untyped_candidate(ctx, local_id) + || local_array_binding_element_type_is_erased(ctx, local_id) +} + +/// True when the binding's type says "an Array" but says nothing usable about +/// its ELEMENT type. +/// +/// `const a = new Array(n)` records `Generic { base: "Array", type_args: [] }` +/// — an `Array` with no type argument, i.e. `Array`. `[]`, `any[]`, +/// `unknown[]`, `Array` and `Array` land here too. These are the +/// ordinary untyped-JavaScript array bindings; before #10718 every one of them +/// missed the hoisted element tiers, which is why `const a = new Array(400)` +/// cost 87 instructions per element and `const a: number[] = new Array(400)` +/// cost 13.4 on the identical program. +/// +/// This reads `local_type_hint` rather than `stable_local_type_proof` on +/// purpose, and the read is a DISPATCH HINT ONLY: it selects which loops are +/// offered to the range tier, and every offered loop is admitted by +/// `js_typed_feedback_packed_f64_range_loop_guard`, which re-proves the live +/// receiver at loop entry (see [`local_is_guardable_untyped_array`]). A stale +/// or reassigned binding therefore fails the guard and runs the unchanged slow +/// loop; it can never produce a wrong element value. +fn element_type_is_erased(ty: &perry_hir::types::Type) -> bool { + matches!( + ty, + perry_hir::types::Type::Any | perry_hir::types::Type::Unknown + ) +} + +fn local_array_binding_element_type_is_erased(ctx: &FnCtx<'_>, local_id: u32) -> bool { + match ctx.local_type_hint(&local_id) { + Some(perry_hir::types::Type::Array(elem)) => element_type_is_erased(elem.as_ref()), + Some(perry_hir::types::Type::Generic { base, type_args }) if base == "Array" => { + type_args.is_empty() || (type_args.len() == 1 && element_type_is_erased(&type_args[0])) + } + _ => false, + } +} + +/// #10718 store side: may a WRITTEN range-loop receiver be admitted on the +/// strength of the loop-entry guard alone, with no declared element type and +/// no static fact-graph claim? +/// +/// #10731 widened the READ admission to ordinary untyped JavaScript arrays and +/// took `Array` element reads from 87 instructions to 13.5. It deliberately +/// left the STORE side alone, because a raw slot store on an unproven element +/// type pulls in frozen/sealed, the write barrier and the pointer-free layout +/// note. Every one of those is discharged, and none of them by a static hint: +/// +/// * **frozen / sealed / non-extensible** — `packed_f64_array_loop_range_guard` +/// reads `OBJ_FLAG_FROZEN | OBJ_FLAG_SEALED | OBJ_FLAG_NO_EXTEND` off the GC +/// header at every loop entry and declines. A store that must be ignored in +/// sloppy mode or throw in strict mode therefore never reaches the fast copy +/// at all; it runs the unchanged generic store in the slow loop. +/// * **index accessors / `defineProperty` descriptors** — the same guard +/// declines on `OBJ_FLAG_ARRAY_DESCRIPTORS`. +/// * **a setter on `Array.prototype` / `Object.prototype`, or a recorded custom +/// array prototype** — declined by the three prototype-pollution flags in +/// `plain_array_index_guard`. This is what makes a store INTO A HOLE safe: +/// with no inherited index property, defining the element on an in-bounds +/// index is exactly what `[[Set]]` does. +/// * **the write barrier** — the fast store writes a value the per-store check +/// proved is a genuine double. A double carries no heap edge, so there is no +/// edge for the barrier to record. A NaN-boxed non-double side-exits to the +/// slow loop BEFORE the store, where the generic path runs barrier and all. +/// * **the pointer-free layout note** — the guard proved every slot in the +/// window is raw f64 or `TAG_HOLE`, and the fast store only ever writes a +/// double, so the array's numeric layout is an invariant of the fast copy +/// rather than something a note must maintain. +/// * **growing through the store, and out-of-bounds** — the guard proves the +/// loop's whole static index window against the LIVE `length`, so no admitted +/// index is `>= length` and the fast copy can never need to extend. +/// * **a proxied / subclassed / `arguments`-like receiver** — `GC_TYPE_ARRAY` +/// plus the forwarding-flag test in `plain_array_index_guard`; a Proxy is not +/// a `GC_TYPE_ARRAY` head. +/// * **anything changing mid-loop** — the matched body admits no call, closure +/// or await, so no user code can run between the guard and the last +/// iteration to freeze, seal, `defineProperty` or pollute a prototype. +/// +/// So the binding test that remains is a DISPATCH HINT: which loops are worth +/// offering the guard. Being wrong costs one failed guard and the unchanged +/// slow loop, never a wrong store. What it must still enforce is the two +/// STORAGE facts the guard cannot see — the binding is read by a plain load +/// (not a closure cell or box) and has not been scalar-replaced — because +/// those decide whether the emitted code is looking at the array the guard +/// validated. +fn written_untyped_binding_is_guardable(ctx: &FnCtx<'_>, local_id: u32) -> bool { + local_is_guardable_untyped_array(ctx, local_id) + && packed_loop_array_binding_storage_is_addressable(ctx, local_id) + && !ctx.scalar_replaced_arrays.contains_key(&local_id) +} + fn local_allows_packed_f64_loop_store(ctx: &FnCtx<'_>, local_id: u32) -> bool { matches!( local_array_element_type(ctx, local_id), diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 768cbea9f5..34931bcadf 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -22,6 +22,8 @@ mod boxed_slot_no_root_tests; mod cached_field_index_return; #[cfg(test)] mod class_field_loop_tests; +#[cfg(test)] +mod compound_alias_fold_tests; mod counter_range; mod element_shape_carried; mod element_shape_loop; diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index bd7229bc94..b909747c68 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -2732,6 +2732,183 @@ fn packed_f64_loop_store_update_versions_with_side_exit() { ); } +/// `const a = new Array(400)` as HIR records it: an `Array` binding with NO +/// type argument, i.e. `Array`. The store tier admits exactly this +/// ordinary untyped-JavaScript binding on the strength of its loop-entry +/// guard; a declared `number[]` array literal is declined earlier, at +/// `store_not_fact_eligible`, so it cannot carry this regression. +fn untyped_array_let(id: u32, name: &str, len: i64) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::Generic { + base: "Array".to_string(), + type_args: Vec::new(), + }, + mutable: false, + init: Some(Expr::New { + class_name: "Array".to_string(), + args: vec![int(len)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + } +} + +/// #10743: `a[i] += 1` reaches the classic range tier. +/// +/// HIR's `hoist_compound_member_assign` lowers a compound member assignment +/// into two immutable alias `Let`s plus the store, so the base and the key are +/// each evaluated exactly once and before the right-hand side. The classic +/// range matcher admits exactly ONE statement, so the idiomatic spelling could +/// never reach the tier that makes the expanded `a[i] = a[i] + 1` fast: 277 +/// instructions per element against 24 for the expanded form on the same +/// array, and a `number[]` annotation changed nothing, because the obstacle is +/// the statement count. +/// +/// The temporaries cannot be removed in the lowering -- an RHS call can +/// reassign the bindings they were read from, and the store must still land at +/// the index evaluated before it ran -- so the fold lives in the matcher and +/// applies to the GUARDED CLONES only. +/// +/// This body is transcribed from a `--print-hir` dump of the source, not +/// guessed. +fn compound_alias_body(array_id: u32, counter_id: u32, base_temp: u32, key_temp: u32) -> Vec { + let alias = |id: u32, name: &str, init: Expr| Stmt::Let { + id, + name: name.to_string(), + ty: Type::Number, + mutable: false, + init: Some(init), + }; + vec![ + alias(base_temp, "__cmpd_base_5", local(array_id)), + alias(key_temp, "__cmpd_key_6", local(counter_id)), + Stmt::Expr(Expr::IndexSet { + object: Box::new(local(base_temp)), + index: Box::new(local(key_temp)), + value: Box::new(add( + Expr::IndexGet { + object: Box::new(local(base_temp)), + index: Box::new(local(key_temp)), + }, + int(1), + )), + }), + ] +} + +#[test] +fn compound_assign_alias_body_reaches_the_range_tier() { + let module = module_with_classes_and_params( + "compound_alias_fold.ts", + Vec::new(), + Vec::new(), + Type::Number, + vec![ + untyped_array_let(1, "values", 400), + Stmt::For { + init: Some(Box::new(number_let(4, "i", true, int(0)))), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(local(4)), + right: Box::new(int(400)), + }), + update: Some(increment(4)), + body: compound_alias_body(1, 4, 5, 6), + }, + Stmt::Return(Some(index_get(1, int(0)))), + ], + ); + + let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); + assert!( + ir.contains("call i32 @js_typed_feedback_packed_f64_range_loop_guard("), + "the compound-assign body should earn the classic range guard:\n{ir}" + ); + // Exactly one versioned loop exists in this module, so the clone below is + // unambiguously the compound-assign loop's. Counting the GUARD CALL, not + // the block label: every block of the fast copy carries the loop's label + // prefix, so a label count says four for one loop. + assert_eq!( + ir.matches("call i32 @js_typed_feedback_packed_f64_range_loop_guard(") + .count(), + 1, + "expected exactly one versioned range loop in this module:\n{ir}" + ); + let fast_start = ir + .find("\nfor.packed_f64_range_fast") + .map(|pos| pos + 1) + .expect("expected a range fast clone"); + let fast_end = ir[fast_start..] + .find("\nfor.packed_f64_range_slow") + .map(|off| fast_start + off) + .expect("expected a range slow clone"); + let fast_clone = &ir[fast_start..fast_end]; + // The point of the whole exercise: the fast clone's read and write are the + // inline packed pair, not the generic OBJECT property path the compound + // spelling used to fall to. + for forbidden in [ + "js_object_get_index_polymorphic", + "js_object_set_index_polymorphic", + "js_dyn_index_set_strict", + ] { + assert!( + !fast_clone.contains(forbidden), + "compound fast clone must not call {forbidden}:\n{fast_clone}" + ); + } + assert!( + fast_clone.contains("store double"), + "compound fast clone should store a raw double inline:\n{fast_clone}" + ); + // ... and the SLOW clone still lowers the statements as written, so a + // failed guard executes the specified evaluation order. + let slow = &ir[fast_end..]; + assert!( + slow.contains("for.packed_f64_range_slow"), + "expected the slow clone to survive:\n{ir}" + ); +} + +#[test] +fn a_mutable_leading_binding_is_not_folded() { + // The fold's argument is that these are the compiler's own immutable + // compound-assign spills. Flip `mutable` and the loop must stay on the + // generic path -- this is the test that goes red if that condition is + // deleted. + let mut body = compound_alias_body(1, 4, 5, 6); + if let Stmt::Let { mutable, .. } = &mut body[0] { + *mutable = true; + } + let module = module_with_classes_and_params( + "compound_alias_mutable.ts", + Vec::new(), + Vec::new(), + Type::Number, + vec![ + untyped_array_let(1, "values", 400), + Stmt::For { + init: Some(Box::new(number_let(4, "i", true, int(0)))), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(local(4)), + right: Box::new(int(400)), + }), + update: Some(increment(4)), + body, + }, + Stmt::Return(Some(index_get(1, int(0)))), + ], + ); + let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); + assert!( + !ir.contains("\nfor.packed_f64_range_fast"), + "a mutable leading binding must not reach the range tier:\n{ir}" + ); +} + #[test] fn masked_window_dense_store_inlines_raw_store_without_calls() { // `for (let i = 0; i < 64; i++) a[i & 7] = i` — a masked static-window diff --git a/crates/perry-hir/src/lower/compound_assign_temp_type_tests.rs b/crates/perry-hir/src/lower/compound_assign_temp_type_tests.rs new file mode 100644 index 0000000000..934a6734e7 --- /dev/null +++ b/crates/perry-hir/src/lower/compound_assign_temp_type_tests.rs @@ -0,0 +1,152 @@ +//! #10718: the compound-assignment spill must carry the SOURCE binding's type. +//! +//! `a[i] += 1` is desugared by `hoist_compound_member_assign` into two +//! immutable temps so base and key are each evaluated exactly once. Those temps +//! used to be minted with `ty: Type::Any`, which erased BOTH the receiver's +//! array-ness and the index's integer-ness before codegen ever saw the +//! statement — so every element tier declined and the read and the write fell +//! to `js_object_get_index_polymorphic` / `js_object_set_index_polymorphic`, +//! the generic OBJECT property path. Measured on `for (i) a[i] += 1` over an +//! ordinary 400-element array: 948 instructions per element, against 16 for +//! `a[i] = k + i` on the same array, and a `number[]` annotation did not help +//! because the erasure happens here. +//! +//! This is a VERDICT test: the desugar is semantically correct either way and +//! prints the same numbers, so only the recorded type distinguishes +//! "optimizable" from "structurally excluded". Behaviour is covered +//! differentially against node. + +#![cfg(test)] + +use crate::types::Type; +use crate::{Module, Stmt}; +use perry_diagnostics::SourceCache; + +fn lower(src: &str) -> Module { + let src = src.to_string(); + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = perry_parser::parse_typescript_with_cache( + &src, + "compound_assign_temp_type.ts", + &mut cache, + ) + .expect("parse should succeed"); + crate::lower_module(&parsed.module, "test", "compound_assign_temp_type.ts") + .expect("lower should succeed") + }) + .expect("spawn lower thread") + .join() + .expect("lower thread panicked") +} + +/// `(name, ty)` of every `Stmt::Let` whose name starts with `__cmpd_`. +fn cmpd_temps(stmts: &[Stmt]) -> Vec<(String, Type)> { + let mut out = Vec::new(); + fn walk(stmts: &[Stmt], out: &mut Vec<(String, Type)>) { + for stmt in stmts { + match stmt { + Stmt::Let { name, ty, .. } if name.starts_with("__cmpd_") => { + out.push((name.clone(), ty.clone())); + } + Stmt::For { init, body, .. } => { + if let Some(init) = init { + walk(std::slice::from_ref(init.as_ref()), out); + } + walk(body, out); + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => walk(body, out), + Stmt::If { + then_branch, + else_branch, + .. + } => { + walk(then_branch, out); + if let Some(else_branch) = else_branch { + walk(else_branch, out); + } + } + _ => {} + } + } + } + walk(stmts, &mut out); + out +} + +fn temp(temps: &[(String, Type)], tag: &str) -> Type { + temps + .iter() + .find(|(name, _)| name.starts_with(&format!("__cmpd_{tag}_"))) + .unwrap_or_else(|| panic!("no __cmpd_{tag}_* temp among {temps:?}")) + .1 + .clone() +} + +#[test] +fn declared_number_array_compound_assign_keeps_both_types() { + let module = lower( + "const a: number[] = new Array(4);\n\ + for (let i = 0; i < 4; i++) a[i] += 1;\n", + ); + let temps = cmpd_temps(&module.init); + assert_eq!( + temp(&temps, "base"), + Type::Array(Box::new(Type::Number)), + "the base temp must carry the receiver's declared array type" + ); + assert_eq!( + temp(&temps, "key"), + Type::Number, + "the key temp must carry the counter's Number type, or every \ + integer-index proof is erased before codegen" + ); +} + +#[test] +fn untyped_new_array_compound_assign_keeps_the_erased_array_type() { + // `new Array(n)` records `Generic { base: "Array", type_args: [] }`. That + // is a WEAKER claim than `number[]` but still says "an Array", which is + // what the range-loop tier's admission keys on. `Any` said nothing. + let module = lower( + "const a = new Array(4);\n\ + for (let i = 0; i < 4; i++) a[i] += 1;\n", + ); + let temps = cmpd_temps(&module.init); + assert!( + matches!(temp(&temps, "base"), Type::Generic { ref base, .. } if base == "Array"), + "the base temp must stay an Array type, got {:?}", + temp(&temps, "base") + ); + assert_eq!(temp(&temps, "key"), Type::Number); +} + +#[test] +fn a_non_local_base_still_spills_as_any() { + // The copy is restricted to a bare `LocalGet` source, where the temp is an + // immutable snapshot of exactly one binding and its type is the source's + // type by construction. A call result has no binding to copy from, so the + // temp keeps `Any` — if this ever becomes a type, the copy has been + // widened past its proof. + let module = lower( + "function f() { return [1, 2, 3]; }\n\ + f()[1] += 1;\n", + ); + let temps = cmpd_temps(&module.init); + assert_eq!(temp(&temps, "base"), Type::Any); +} + +#[test] +fn a_string_array_compound_assign_does_not_gain_a_numeric_type() { + // The copy is exact: a `string[]` receiver must report `string[]`, never a + // numeric array. This is the twin that fails if the copy is ever replaced + // by a guess. + let module = lower( + "const a: string[] = [\"x\"];\n\ + a[0] += \"y\";\n", + ); + let temps = cmpd_temps(&module.init); + assert_eq!(temp(&temps, "base"), Type::Array(Box::new(Type::String))); +} diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index 83bda62ad3..1b08d31c8e 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -1402,11 +1402,35 @@ pub(crate) fn hoist_compound_member_assign( let mut stmts: Vec = Vec::new(); let spill = |ctx: &mut LoweringContext, stmts: &mut Vec, tag: &str, init: Expr| -> LocalId { + // #10718: carry the SOURCE binding's type onto the temp instead of + // erasing it to `Any`. + // + // The temp is an immutable snapshot of exactly one binding's value + // (`init` is a bare `LocalGet`), so its type is the source's type + // by construction — which is why the copy is restricted to that + // one shape. Erasing it cost the whole element-access tier stack: + // `a[i] += 1` on an ordinary array spilled `a` and `i` into two + // `Any` temps, which erased BOTH the receiver's array-ness and the + // index's integer-ness, so the read and the write fell all the way + // to `js_object_get_index_polymorphic` / + // `js_object_set_index_polymorphic` — the generic OBJECT property + // path, with per-element index stringification and shape/descriptor + // table work. Measured: 948 instructions per element for + // `a[i] += 1` against 16 for `a[i] = k + i` on the same array, and + // a `number[]` annotation did not help because this erasure + // happens before codegen ever sees it. + let ty = match &init { + Expr::LocalGet(src) => ctx + .lookup_local_type_by_id(*src) + .cloned() + .unwrap_or(Type::Any), + _ => Type::Any, + }; let id = ctx.fresh_local(); stmts.push(Stmt::Let { id, name: format!("__cmpd_{}_{}", tag, id), - ty: Type::Any, + ty, mutable: false, init: Some(init), }); diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index d6ae8d5f0d..502cca09fe 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -138,6 +138,8 @@ mod bun_sql_tests; #[cfg(test)] mod collection_view_tests; #[cfg(test)] +mod compound_assign_temp_type_tests; +#[cfg(test)] mod for_multi_decl_tests; #[cfg(test)] mod for_of_counter_tests; diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 4b0187379e..17c3fa7baa 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -1211,6 +1211,72 @@ pub(crate) unsafe fn rebuild_array_numeric_raw_f64_allow_holes(arr: *mut ArrayHe true } +/// #10718: WINDOW-scoped variant of +/// [`rebuild_array_numeric_raw_f64_allow_holes`], for the classic +/// (hole-tolerant, side-exiting) packed-f64 range loop. +/// +/// The array-wide rebuild proves an invariant over `[0, length)` and, on +/// success, records it in the header so later loop entries are O(1). That is +/// exactly right when it succeeds — and needlessly fatal when it does not: a +/// single non-numeric slot ANYWHERE disqualifies a loop that only ever reads +/// `[min_idx, max_idx_exclusive)`, and because the failure clears the layout +/// flags, every re-entry walks the array again. Measured: an untyped +/// 400-element array holding one string at index 399, read by +/// `for (i = 0; i < 399; i++) s += a[i]` inside an outer loop, paid a full +/// 400-slot walk per outer iteration. +/// +/// This checks only the slots the guarded clone can touch. It canonicalizes +/// numeric slots in the window exactly as the array-wide walk does (an +/// INT32-boxed integer becomes raw f64 bits), tolerates `TAG_HOLE` — the +/// classic tier's loads hole-check and side-exit — and fails on the first slot +/// that is neither. +/// +/// It deliberately records NOTHING in the header and does NOT call +/// `layout_init_pointer_free`: slots outside the window are unexamined and may +/// still hold heap pointers, which the collector must keep tracing. The cost +/// is that a window-only admission re-walks its window on each loop entry; +/// that is bounded by the window, and the array-wide fast path above still +/// serves every array that really is numeric throughout. +/// +/// # Safety +/// +/// `arr` must be a live, non-forwarded `GC_TYPE_ARRAY` head whose +/// `length <= capacity`, and `[min_idx, max_idx_exclusive)` must lie within +/// `[0, length)` — `packed_f64_array_loop_range_guard` proves all of that +/// before calling. +pub(crate) unsafe fn array_window_is_numeric_raw_f64_allow_holes( + arr: *mut ArrayHeader, + min_idx: i32, + max_idx_exclusive: i32, +) -> bool { + if arr.is_null() || min_idx < 0 { + return false; + } + let len = i64::from((*arr).length); + let min = i64::from(min_idx); + let max = i64::from(max_idx_exclusive).min(len); + if min >= max { + // An empty window: the clone runs zero iterations, so there is nothing + // to prove. (The caller has already rejected `max_idx_exclusive > len`.) + return true; + } + let elements = array_elements_ptr(arr); + for i in min..max { + let slot_bits = array_slot_bits(arr, i as usize); + if slot_bits == crate::value::TAG_HOLE { + continue; + } + let Some(number) = value_bits_to_number(slot_bits) else { + return false; + }; + if number.to_bits() != slot_bits { + // GC_STORE_AUDIT(POINTER_FREE): raw-f64 rewrite stores numeric payloads only. + std::ptr::write(elements.add(i as usize) as *mut f64, number); + } + } + true +} + /// Dense-window variant of [`rebuild_array_numeric_raw_f64_allow_holes`] for /// the read-only masked-index range loop: after the hole-tolerant rebuild, /// additionally require that `[min_idx, max_idx_exclusive)` contains NO holes. diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index f085ceffce..c44149cf53 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -142,9 +142,10 @@ pub(crate) use self::generic_object::{ object_splice, }; pub(crate) use self::header::{ - array_has_arguments_object_flag, js_array_is_numeric_f64_layout_resolved, - mark_array_as_arguments_object, rebuild_array_numeric_raw_f64_allow_holes, - rebuild_array_numeric_raw_f64_dense_window, rebuild_array_numeric_raw_f64_dense_window_i32, + array_has_arguments_object_flag, array_window_is_numeric_raw_f64_allow_holes, + js_array_is_numeric_f64_layout_resolved, mark_array_as_arguments_object, + rebuild_array_numeric_raw_f64_allow_holes, rebuild_array_numeric_raw_f64_dense_window, + rebuild_array_numeric_raw_f64_dense_window_i32, }; pub use self::header::{ js_array_clear_numeric_layout, js_array_declare_all_pointer_elements, diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index ffb9b526d6..9e2352cfa2 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -1480,7 +1480,14 @@ fn packed_f64_array_loop_range_guard( if min_idx < 0 || i64::from(max_idx_exclusive) > i64::from(len) { return false; } - crate::array::rebuild_array_numeric_raw_f64_allow_holes(arr) + if crate::array::rebuild_array_numeric_raw_f64_allow_holes(arr) { + return true; + } + // #10718: the array-wide invariant can fail on a slot this loop never + // touches. The clone reads only `[min_idx, max_idx_exclusive)`, so a + // window-scoped proof is the whole requirement; it records nothing, so + // the array-wide claim above stays the authority for everyone else. + crate::array::array_window_is_numeric_raw_f64_allow_holes(arr, min_idx, max_idx_exclusive) } } diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 116f75b6ff..199ece7b6a 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -234,6 +234,9 @@ const BUILD_CACHE_ENV_EXCLUSIONS: &[&str] = &[ // returns `None` either way — the rejection is what the caller already got // without the flag, so the emitted code is identical. An input, rather than // an exclusion, would make every trace run miss the cache for nothing. + // #10743 added the positive twin, `range_loop_trace`, on the same terms: it + // prints which admission a matched loop took and returns nothing, so the + // emitted object is byte-identical with the flag on and off. "PERRY_PACKED_LOOP_TRACE", // Entry outlining report output is observational only. "PERRY_OUTLINE_ENTRY_REPORT", diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index b2abe964e4..1f2672e0f7 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -401,6 +401,14 @@ "classification": "runtime-validated", "reason": "A falsy-local fold consumes only the private method clone's proof: its public wrapper bit-compares the live argument with TAG_UNDEFINED, candidate discovery rejects user writes and closure capture, and the proof API rejects every remaining reassigned binding." }, + { + "path": "crates/perry-codegen/src/stmt/loops.rs", + "function": "local_array_binding_element_type_is_erased", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "#10718: the declared/inferred type only selects which read-only range loops are OFFERED to the packed-f64 range tier; it licenses no load. Every offered loop enters through js_typed_feedback_packed_f64_range_loop_guard, which re-proves the live receiver at loop entry - plain-array GC type, no forwarding, no index descriptors, no Array.prototype/Object.prototype index pollution, no recorded custom array prototype, not frozen/sealed/non-extensible, capacity and length sanity, the whole index window inside the LIVE length, and raw-f64-or-holes packedness of every slot - and the matched body admits no call, closure or await, so nothing can invalidate that between the guard and the last iteration. A stale or reassigned binding fails the guard and runs the unchanged slow loop." + }, { "path": "crates/perry-codegen/src/stmt/loops.rs", "function": "local_array_element_type",