diff --git a/CLAUDE.md b/CLAUDE.md index ee50f2a1ae..cb5b4e1ded 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.1382 +**Current Version:** 0.5.1383 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 71296fdd68..f43cd35176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1382" +version = "0.5.1383" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1382" +version = "0.5.1383" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1382" +version = "0.5.1383" [[package]] name = "perry-ui-tvos" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1382" +version = "0.5.1383" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index b6afd2f39c..0d2620e6ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1382" +version = "0.5.1383" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7669-repsel-object-literal-element-shape.md b/changelog.d/7669-repsel-object-literal-element-shape.md new file mode 100644 index 0000000000..314288f5ac --- /dev/null +++ b/changelog.d/7669-repsel-object-literal-element-shape.md @@ -0,0 +1,78 @@ +### repsel: object-literal element types reach the element-shape loop clone (#7480) + +`for (let j = 0; j < n; j++) sum += keep[j].v` over a **`keep: {v, w}[]`** — +#7480's own kernel — went from **408 ms to 12 ms** on the pinned quiet mini +(200k elements × 50 sweeps, 7 interleaved rounds, checksums equal in every +cell). That is **parity with node (12 ms) and bun (12 ms)**, down from 34× +node. The named-class arm the #7612 consumer already covered is unchanged at +13 ms, and the region-local arm `collectors/ptr_shape_elements.rs` (#7034 §3) +already covered is unchanged at 14–15 ms. + +**Root cause.** `stmt/element_shape_loop.rs::element_class_name` resolved +`Array(Named(C))` only, so a declared object-literal element type never +produced a class id and #7480's kernel never reached the clone. It now also +resolves the declared object type to the `__AnonShape_` class its +literals allocate, by matching the declared property order against the module's +anon shapes. The name cannot be recomputed — `mint_anon_shape_class` keys its +FNV hash on the literal's *inferred value* types (`{v: 1}` tags `i`, not `n`) +while the annotation says `number` — so the class is looked up, not derived, +and an ambiguous field-name list **declines** rather than guessing (`ctx.classes` +is a `HashMap`; "first match wins" would make the emitted code depend on +iteration order). + +**`receiver_class_name` is deliberately unchanged.** Widening it to type an +`Object`-typed element read is the #6377 blast radius #7612 refused. The clone +was made self-contained instead: its `ElementShapeLoopFact` already carried the +class name and packed slot index, so the three sites that would otherwise +re-derive the class from the receiver now consult one predicate, +`expr::element_shape_loop_fact_for_property_get` — the raw-f64 field lowering +(its interception moved *above* the `receiver_class_name` gate), +`type_analysis::is_numeric_expr`'s `PropertyGet` arm, and `expr::binary`'s +arithmetic-operand router. All three are scoped to the fast clone, so +`keep[j].v` anywhere else is byte-for-byte what it was. The predicate includes +the canonical-i32 counter slot, because answering `yes` is a promise that the +read takes the bare-load lowering and `is_numeric_expr` bets a raw `double` on +that promise. + +**The issue's cost model was wrong, and the correction is the design point.** +#7480 recorded "no out-of-line guard *calls*, the cost is stacked inline +diamonds"; the object-literal arm actually carried three calls per iteration, +the third being `js_dynamic_string_or_number_add` — with no resolvable class +the accumulator also loses its numeric proof, so `+` is not an `fadd`. That was +recorded as "a second, separable lever". It is not one: the clone is admitted +only if it is provably call-free (`LlBlock::contains_gc_unsafe_call` counts +every non-`llvm.` call), so resolving the element class *without* restoring the +numeric proof emits the clone, fails the call-free test, branches +unconditionally to the slow arm and buys zero at a cost in code size. Anything +gated on call-freeness has this shape. The numeric claim inside the clone is +also stronger than the annotation it replaces: the residual per-element check +already proves `GC_OBJ_TYPED_LAYOUT_INTACT`, i.e. that the slot holds a raw +double. + +**An existing gate that could not fail.** `fast_clone_slice` in +`element_shape_loop_tests.rs` sliced from the first *substring* match of +`for.element_shape_fast.cond`, which is the `br label %…` terminator of the +fast preheader — four lines above the slow preheader — and every assertion made +against the result is a negative (`!fast.contains(" call ")`, …). The IR census +that exists to prove the clone is call-free had therefore been **vacuous since +#7612**, on the code that then shipped the #7660 SIGBUS. It now finds the block +*definition* and asserts the slice contains the cloned body and its element +load, so it cannot pass on an empty subject again (#7024/#7025 family). + +**Coverage.** Seven new IR-census tests: one positive that asserts the clone is +reached, call-free, and `fadd`-accumulating (all three together, because any +one alone is inert), plus sabotage cases for an ambiguous shape, a tie a field +type *can* break, an optional property, a shape no literal allocates, a +reordered shape, and a read outside the clone that must stay on the by-name +path. `test-files/test_gap_repsel_element_shape_loop_clone.ts` gains an +object-literal section covering the #7660 growth-forwarding shapes on this arm +(callee-built, callee-filled, a 17-element prefix, and a module-global array +read from inside a function), an inline `rows.length` bound the matcher rejects, +a layout downgrade that forces the residual check's side exit mid-loop, two +anon shapes sharing field names, and a mixed numeric/string shape the matcher +must decline — byte-identical to the Node 26.5.1 oracle. + +`docs/engine-plan.md` item 6 is closed. What remains of Route A — a `Ptr` +element representation that survives *outside* a loop for the parameter/global +case — needs the type-visibility change above with its own gap-suite A/B, and +is scoped against the region-local case already being covered. diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index 7cfe31b07c..5ea824f5dc 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -58,7 +58,19 @@ fn lower_arithmetic_operand(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<(String, return Ok((value, true)); } } - if expr_may_return_boxed_value_from_raw_f64_fallback(ctx, expr) { + // repsel #7480 step 3: a tracked `arr[i].field` read inside an + // element-shape fast clone routes to the raw-f64 lowering WITHOUT the + // boxed-fallback test below. That test asks `receiver_class_name`, which + // by design does not resolve an object-literal element type, so the read + // would otherwise fall through to `lower_expr` — a generic diamond, whose + // calls then fail the clone's call-free admission and cost the clone + // entirely. The predicate is left alone rather than widened: this read has + // no boxed fallback at all (the residual per-element check proves the slot + // is a raw double before the load), so claiming one here would be a lie + // that other consumers of that predicate would read. + let in_element_shape_clone = matches!(expr, Expr::PropertyGet { object, property, .. } + if crate::expr::element_shape_loop_fact_for_property_get(ctx, object, property).is_some()); + if in_element_shape_clone || expr_may_return_boxed_value_from_raw_f64_fallback(ctx, expr) { if let Some(value) = super::property_get::lower_raw_f64_class_field_get_for_number_context(ctx, expr)? { diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 1900983249..1417a83c58 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1605,21 +1605,56 @@ pub(crate) struct ElementShapeLoopFact { pub max_field_index: u32, } -/// Find the innermost active element-shape loop fact covering -/// `(array_local_id, index_local_id, class_name, property)`. Returns the fact -/// and the packed slot index of the field. -pub(crate) fn element_shape_loop_fact_lookup<'f>( - facts: &'f [ElementShapeLoopFact], - array_local_id: u32, - index_local_id: u32, - class_name: &str, +/// Find the innermost active element-shape loop fact covering a +/// `PropertyGet`'s receiver: answers `Some((fact, packed_slot_index))` exactly +/// when `object.property` is a tracked `arr[counter].field` read inside an +/// element-shape fast clone. +/// +/// The single entry point for the three sites that must agree about that read +/// — the field lowering itself +/// (`expr::property_get::lower_raw_f64_class_field_get_for_number_context`), +/// `type_analysis::is_numeric_expr`, and `expr::binary`'s arithmetic-operand +/// router. #7480 step 3 made the clone self-contained by routing all three +/// through the fact instead of through `receiver_class_name`, which by design +/// does not resolve an object-literal element type; the fact's own +/// `class_name` is therefore the authoritative answer rather than a filter on +/// one the caller supplies. +/// +/// `(array, counter)` already identifies one loop — a counter local is minted +/// per `for`, and the matcher admits exactly one array per loop. Cheap +/// early-out first: outside a fast clone the fact vector is empty. +/// +/// The **canonical-i32 counter slot is part of the predicate**, not a +/// precondition the caller re-checks. Answering `Some` is a promise that the +/// read really does take the bare-load lowering, and `is_numeric_expr` bets a +/// raw `double` on that promise: if the field lowering declined for want of an +/// i32 slot while the numeric predicate still said yes, the operand would be +/// consumed as a real double while the generic lowering handed back a NaN-boxed +/// value. The matcher declines the whole loop without that slot +/// (`lower_element_shape_versioned_for`), so today the two can't disagree — +/// asking here keeps them unable to disagree if the matcher is ever widened. +pub(crate) fn element_shape_loop_fact_for_property_get<'f>( + ctx: &'f FnCtx<'_>, + object: &perry_hir::Expr, property: &str, ) -> Option<(&'f ElementShapeLoopFact, u32)> { - facts.iter().rev().find_map(|fact| { - if fact.array_local_id != array_local_id - || fact.index_local_id != index_local_id - || fact.class_name != class_name - { + use perry_hir::Expr; + if ctx.element_shape_loop_facts.is_empty() { + return None; + } + let Expr::IndexGet { object, index } = object else { + return None; + }; + let (Expr::LocalGet(array_local_id), Expr::LocalGet(index_local_id)) = + (object.as_ref(), index.as_ref()) + else { + return None; + }; + if !ctx.i32_counter_slots.contains_key(index_local_id) { + return None; + } + ctx.element_shape_loop_facts.iter().rev().find_map(|fact| { + if fact.array_local_id != *array_local_id || fact.index_local_id != *index_local_id { return None; } fact.fields.get(property).map(|idx| (fact, *idx)) diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index fc6c84f2c3..36ea0727c8 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -331,6 +331,81 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( } } + // repsel #7480 / #5093: inside the fast clone of an ELEMENT-shape + // versioned loop, `arr[i].field` in number context lowers to a bare + // element load plus the residual per-element check, with no element-read + // tier and no guard call (see stmt/element_shape_loop.rs). + // + // #7480 step 3: this sits ABOVE the `receiver_class_name` gate on purpose. + // The clone's element class can be one that resolver does not answer for — + // an object-literal element type (`keep: {v: number}[]`) resolves to its + // `__AnonShape_` only inside the matcher, which is where that + // resolution is kept so it cannot un-gate anything else (#6377). Every + // fact this consults was validated by the matcher when the fact was built: + // the class has no computed members and no base, the property is not an + // accessor and is not denylisted, and its declared type is a raw-f64 + // candidate at the packed slot index carried here. So the lowering needs + // nothing from the receiver's static type, and asking for it would have + // made the whole clone dead IR. + if let Some((fact, field_index)) = + crate::expr::element_shape_loop_fact_for_property_get(ctx, object, property) + .map(|(fact, idx)| (fact.clone(), idx)) + { + if let Expr::IndexGet { object: array, .. } = object.as_ref() { + if let Expr::LocalGet(arr_id) = array.as_ref() { + // The counter's canonical i32 slot is what the matcher + // required; without it there is nothing to index with. + if let Some(slot) = ctx.i32_counter_slots.get(&fact.index_local_id).cloned() { + let idx_i32 = ctx.block().load(I32, &slot); + let value = crate::expr::element_shape_guard::emit_element_shape_field_load( + ctx, + &fact, + &idx_i32, + field_index, + ); + let lowered = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: value.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ElementShapeFieldGet", + Some(*arr_id), + "element_shape_loop.raw_f64_load", + &lowered, + Some(BoundsState::Guarded { + guard_id: "element_shape_loop_preheader_check".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + Some(*arr_id), + "consumed", + "element_shape_loop_preheader_check", + None, + )], + Vec::new(), + false, + false, + vec![ + format!("field={property}"), + format!("class={}", fact.class_name), + "loop_versioning=element_shape".to_string(), + "index_range=nonnegative_i32".to_string(), + "length_range=guarded_i32".to_string(), + "element_shape=homogeneous_class".to_string(), + ], + ); + return Ok(Some(value)); + } + } + } + } + let Some(class_name) = receiver_class_name(ctx, object) else { return Ok(None); }; @@ -368,80 +443,6 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( return Ok(None); }; - // repsel #7480 / #5093: inside the fast clone of an ELEMENT-shape - // versioned loop, `arr[i].field` in number context lowers to a bare - // element load plus the residual per-element check, with no element-read - // tier and no guard call (see stmt/element_shape_loop.rs). Checked before - // the class-field fact because the receiver shapes are disjoint - // (`IndexGet` vs `LocalGet`) and this one is the cheaper lowering. - if !ctx.element_shape_loop_facts.is_empty() { - if let Expr::IndexGet { object, index } = object.as_ref() { - if let (Expr::LocalGet(arr_id), Expr::LocalGet(idx_id)) = - (object.as_ref(), index.as_ref()) - { - let hit = crate::expr::element_shape_loop_fact_lookup( - &ctx.element_shape_loop_facts, - *arr_id, - *idx_id, - &class_name, - property, - ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - .map(|(fact, _)| fact.clone()); - if let Some(fact) = hit { - // The counter's canonical i32 slot is what the matcher - // required; without it there is nothing to index with. - if let Some(slot) = ctx.i32_counter_slots.get(idx_id).cloned() { - let idx_i32 = ctx.block().load(I32, &slot); - let value = crate::expr::element_shape_guard::emit_element_shape_field_load( - ctx, - &fact, - &idx_i32, - field_index, - ); - let lowered = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: value.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ElementShapeFieldGet", - Some(*arr_id), - "element_shape_loop.raw_f64_load", - &lowered, - Some(BoundsState::Guarded { - guard_id: "element_shape_loop_preheader_check".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - Some(*arr_id), - "consumed", - "element_shape_loop_preheader_check", - None, - )], - Vec::new(), - false, - false, - vec![ - format!("field={property}"), - "loop_versioning=element_shape".to_string(), - "index_range=nonnegative_i32".to_string(), - "length_range=guarded_i32".to_string(), - "element_shape=homogeneous_class".to_string(), - ], - ); - return Ok(Some(value)); - } - } - } - } - } - // #5093 loop versioning: inside the fast clone of a class-field versioned // loop, a tracked number-context field read on the proven receiver lowers // to a bare slot load on the preheader-cached object pointer — no shape diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs index 3bc269463e..ae36c4bbad 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -176,9 +176,24 @@ fn element_shape_loop_pure_expr_collect( array.is_none_or(|a| a != *id) && crate::type_analysis::is_numeric_expr(ctx, expr) } Expr::Number(_) | Expr::Integer(_) => true, + // NOTE (#7480 step 3): deliberately NOT gated on + // `is_numeric_expr(ctx, expr)`. `BinaryOp` is arithmetic/bitwise only + // (no `in`/`instanceof`), so the sole hazard the whole-expression test + // covered was `+` on a possibly-string operand — and every leaf this + // walk admits is numeric by the time the match is ACCEPTED: numeric + // locals and literals by their own arms, and tracked `arr[j].field` + // reads because the caller rejects the whole loop unless every + // collected property is a declared raw-f64 candidate on the resolved + // element class. + // + // The gate had to go for the object-literal kernel: at match time no + // fact is installed yet, so `is_numeric_expr` cannot see through + // `keep[j].v` (its `PropertyGet` arm resolves the owner through + // `receiver_class_name`, which by design does not type an + // object-literal element). Keeping it would have declined #7480's own + // kernel before the class resolver was ever consulted. Expr::Binary { left, right, .. } => { - crate::type_analysis::is_numeric_expr(ctx, expr) - && element_shape_loop_pure_expr_collect(ctx, left, counter_id, array, props) + element_shape_loop_pure_expr_collect(ctx, left, counter_id, array, props) && element_shape_loop_pure_expr_collect(ctx, right, counter_id, array, props) } Expr::NumberCoerce(operand) => { @@ -208,35 +223,156 @@ fn element_shape_loop_pure_expr_collect( /// Resolve the class every element of `array_id` must have for the clone to /// fire. /// -/// Exactly one source: `receiver_class_name` on the `IndexGet`, i.e. a -/// declared element type (`keep: Node[]`) — the same path Perry already uses -/// to resolve `items[2].display()`. It does not have to be *right*: the -/// preheader compares the class id the runtime invariant reports against this -/// one, so a wrong answer costs the clone, never correctness. +/// Two sources, tried in order: +/// +/// 1. `receiver_class_name` on the `IndexGet`, i.e. a declared *named* element +/// type (`keep: Node[]`) — the same path Perry already uses to resolve +/// `items[2].display()`. +/// 2. #7480 step 3: an **object-literal element type** (`keep: {v: number}[]`), +/// resolved to the `__AnonShape_` class the literals actually +/// allocate ([`anon_shape_class_for_element_type`]). This is #7480's own +/// kernel and the whole measured gap: 408 ms against node's 12 on +/// 200k × 50 before this resolved, 12 ms after, where the named-class arm +/// was already 13 ms. /// -/// **Deliberately NOT resolved: object-literal element types** -/// (`keep: {v: number}[]`), which is #7480's own kernel — an object literal -/// lowers to `New { __AnonShape_ }`, so a class id genuinely exists, and -/// matching the declared object type's property order against this module's -/// `__AnonShape_*` classes would find it. It is left out on purpose, because -/// the class name alone is not enough: `receiver_class_name` returning `None` -/// is also what makes `lower_raw_f64_class_field_get_for_number_context` -/// decline, so the read would still lower through the generic diamond, the -/// clone would fail its call-free check, and the only thing the wider matcher -/// would buy is a block of dead fast-clone IR. Reaching that kernel means -/// teaching `static_type_of` / `receiver_class_name` to type an `Object`-typed -/// property read — which is precisely the #6377 "more type visibility un-gates -/// latent fast paths" change, and belongs in its own PR with its own gap-suite -/// A/B. Measured cost of the omission: the object-literal kernel stays at -/// ~88 ms while the named-class kernel goes 43 → 16 ms. +/// Neither has to be *right*: the preheader compares the class id the runtime +/// invariant reports against this one, so a wrong answer costs the clone, +/// never correctness. The annotation stays a hint, never layout. fn element_class_name(ctx: &FnCtx<'_>, array_id: u32, counter_id: u32) -> Option { - crate::type_analysis::receiver_class_name( + if let Some(named) = crate::type_analysis::receiver_class_name( ctx, &perry_hir::Expr::IndexGet { object: Box::new(perry_hir::Expr::LocalGet(array_id)), index: Box::new(perry_hir::Expr::LocalGet(counter_id)), }, - ) + ) { + return Some(named); + } + anon_shape_class_for_element_type(ctx, array_id) +} + +/// Content-addressed synthetic class every closed-shape object literal lowers +/// to (`perry-hir/src/lower/context.rs::mint_anon_shape_class`). +const ANON_SHAPE_PREFIX: &str = "__AnonShape_"; + +/// #7480 step 3: resolve `keep: {v: number, w: number}[]` to the +/// `__AnonShape_` class its literals allocate. +/// +/// **Why not widen `receiver_class_name`.** That is the #6377 blast radius +/// #7612 deliberately refused — every consumer of the receiver-class resolver +/// would start seeing a class for an `Object`-typed read, un-gating latent +/// fast paths this change never measured. The resolver therefore lives here, +/// in the matcher, and the fast clone is made self-contained instead: its +/// field read carries its own `class_name` + packed slot index on +/// `ElementShapeLoopFact`, and the three predicates that would otherwise have +/// re-derived the class from the receiver +/// (`lower_raw_f64_class_field_get_for_number_context`, `is_numeric_expr`, +/// `lower_arithmetic_operand`'s routing test) consult that fact instead. All +/// three are scoped to the fast clone, where the guard has already proven the +/// element's class *and* — via the residual check's +/// `GC_OBJ_TYPED_LAYOUT_INTACT` bit — that the slot really holds a raw double. +/// +/// **Why the hash cannot be recomputed.** `mint_anon_shape_class` keys the +/// FNV hash on the literal's *inferred value* types (`{v: 1}` tags `i`, not +/// `n`), while the annotation says `number`. So the class is found by matching +/// the declared property order against the module's anon shapes, not by +/// recomputing the name. +/// +/// Ambiguity declines rather than guesses: two anon shapes can share a field +/// name list (`{v: n, w: n}` vs `{v: s, w: s}`), so candidates are narrowed by +/// field-type compatibility and a still-ambiguous set returns `None`. That +/// keeps the answer independent of `ctx.classes` iteration order, which is a +/// `HashMap`'s. +fn anon_shape_class_for_element_type(ctx: &FnCtx<'_>, array_id: u32) -> Option { + use perry_hir::types::Type as HirType; + + let elem = match ctx.local_types.get(&array_id)? { + HirType::Array(elem) => elem.as_ref(), + // `new Array<{v: number}>(n)` locals carry the generic spelling. + HirType::Generic { base, type_args } if base == "Array" && type_args.len() == 1 => { + &type_args[0] + } + _ => return None, + }; + let HirType::Object(obj) = elem else { + return None; + }; + // Only a CLOSED shape names a layout: an index signature, a method + // signature (which `property_order` does not record) or an optional + // property all mean the runtime object may not have exactly these slots. + if obj.index_signature.is_some() { + return None; + } + let order = obj.property_order.as_ref()?; + if order.is_empty() || order.len() != obj.properties.len() { + return None; + } + if obj.properties.values().any(|p| p.optional) { + return None; + } + + let candidates: Vec<&str> = ctx + .classes + .iter() + .filter(|(name, class)| { + name.starts_with(ANON_SHAPE_PREFIX) + // The clone's packed slot indices describe ONE class's own + // fields; an inherited layout or a computed key would not be + // self-describing. Anon shapes never have either, so this is + // a belt-and-braces check that keeps the invariant local. + && class.extends_name.is_none() + && class.computed_members.is_empty() + && class.fields.len() == order.len() + && class + .fields + .iter() + .zip(order) + .all(|(f, want)| f.key_expr.is_none() && f.name == *want) + // Field types are checked for EVERY candidate, not only to + // break a tie: "the declared shape and the class agree" should + // mean the same thing whether or not a second shape happens to + // share the field names, otherwise a one-candidate module and a + // two-candidate one apply different rules to the same pair. + && class.fields.iter().all(|f| { + obj.properties + .get(&f.name) + .is_some_and(|p| anon_shape_field_type_is_compatible(&p.ty, &f.ty)) + }) + }) + .map(|(name, _)| name.as_str()) + .collect(); + match candidates.as_slice() { + [only] => Some((*only).to_string()), + _ => None, + } +} + +/// Candidate filter for [`anon_shape_class_for_element_type`]: is a +/// synthesized anon-shape field type (inferred from the literal's VALUES) +/// consistent with the declared property type (an annotation)? +/// +/// Deliberately coarse, because the two sides are not the same kind of fact. +/// `Number`/`Int32` are one bucket (`{v: 1}` infers `Int32` for a +/// `number`-declared property), as are `String`/`StringLiteral`, and an +/// `any`/`unknown` on EITHER side rules nothing out: a declared `any` names no +/// layout, and an inferred `Any` just means the lowering could not type that +/// literal's value expression (`{v: i, w: f()}`), which is not evidence of +/// disagreement. Making that arm one-sided would have silently declined the +/// very common `{v: , w: }` shape. +/// +/// Being wrong here costs the clone and never correctness — the preheader +/// still compares the class id the runtime invariant reports. +fn anon_shape_field_type_is_compatible( + declared: &perry_hir::types::Type, + actual: &perry_hir::types::Type, +) -> bool { + use perry_hir::types::Type as T; + match (declared, actual) { + (T::Any | T::Unknown, _) | (_, T::Any | T::Unknown) => true, + (T::Number | T::Int32, T::Number | T::Int32) => true, + (T::String | T::StringLiteral(_), T::String | T::StringLiteral(_)) => true, + (d, a) => d == a, + } } /// Match `for (let j = k0; j < B; j++) acc = `. diff --git a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs index 7f2c67a881..313e3d088b 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs @@ -122,6 +122,51 @@ fn node_class(extends_name: Option<&str>) -> Class { } } +/// A closed-shape object literal's synthesized class, exactly as +/// `perry-hir`'s `mint_anon_shape_class` builds it: content-addressed name, no +/// base, no computed members, fields in source order with `init: None`. +fn anon_shape_class(id: u32, name: &str, fields: &[(&str, Type)]) -> Class { + let mut class = node_class(None); + class.id = id; + class.name = name.to_string(); + class.fields = fields + .iter() + .map(|(field, ty)| ClassField { + name: (*field).to_string(), + key_expr: None, + ty: ty.clone(), + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }) + .collect(); + class +} + +/// The declared element type of #7480's own kernel: `{ v: number; w: number }`. +fn object_element_type(fields: &[(&str, Type)], optional: bool) -> Type { + let mut properties = std::collections::HashMap::new(); + let mut property_order = Vec::new(); + for (name, ty) in fields { + property_order.push((*name).to_string()); + properties.insert( + (*name).to_string(), + perry_hir::types::PropertyInfo { + ty: ty.clone(), + optional, + readonly: false, + }, + ); + } + Type::Object(perry_hir::types::ObjectType { + name: None, + properties, + property_order: Some(property_order), + index_signature: None, + }) +} + /// `keep[].v` fn elem_field(array_id: u32, index: Expr) -> Expr { Expr::PropertyGet { @@ -194,6 +239,29 @@ fn element_shape_module(body: Vec, extends_name: Option<&str>) -> Module { m } +/// `const keep: [] = []; let sum = 0; for (let j = 0; j < N; j++) sum += +/// keep[j].v` with an explicit set of module classes — the object-literal +/// twin of [`element_shape_module`] (#7480 step 3). +fn object_element_module(elem: Type, classes: Vec) -> Module { + let mut m = element_shape_module( + vec![accumulate_stmt( + SUM_ID, + ARRAY_ID, + Expr::LocalGet(COUNTER_ID), + )], + None, + ); + m.classes = classes; + // A silent skip here would leave the array typed `Node[]` and every + // object-literal test below would quietly be re-testing the named-class + // path — passing for the wrong reason. Assert the shape instead. + let Some(Stmt::Let { ty, .. }) = m.init.first_mut() else { + panic!("element_shape_module's first init statement should be the array `Let`"); + }; + *ty = Type::Array(Box::new(elem)); + m +} + fn emit(m: &Module) -> String { String::from_utf8(compile_module(m, ir_opts()).unwrap()).expect("LLVM IR should be UTF-8") } @@ -220,17 +288,47 @@ fn block_slice<'a>(ir: &'a str, label: &str) -> &'a str { &body[..end] } -/// The emitted text the fast clone owns: from its cond block to the slow -/// clone's. +/// Byte offset of the DEFINITION of block `label` — a line that begins at +/// column 0 with `