diff --git a/CLAUDE.md b/CLAUDE.md index bc77440c37..2dc71c7115 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.1349 +**Current Version:** 0.5.1350 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index eb5855e594..320a01e2df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1349" +version = "0.5.1350" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1349" +version = "0.5.1350" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1349" +version = "0.5.1350" [[package]] name = "perry-ui-tvos" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1349" +version = "0.5.1350" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index a50487d252..918aa3f448 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1349" +version = "0.5.1350" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7612-element-shape-loop-clone.md b/changelog.d/7612-element-shape-loop-clone.md new file mode 100644 index 0000000000..fd9086f9c3 --- /dev/null +++ b/changelog.d/7612-element-shape-loop-clone.md @@ -0,0 +1,186 @@ +### Repsel: the element-shape versioned loop clone — first consumer of the element-shape invariant (#7480 / #5093) + +#7496 landed the per-array homogeneous element-shape invariant with no +consumer, on purpose. This is the consumer. + +`for (let j = 0; j < n; j++) sum += keep[j].v` now gets a specialized clone +behind a preheader guard on "this array holds the element-shape invariant at +class `C`". Inside the clone the element read is a bare +`gep + load` off a preheader-cached elements base, and the field read is a +bare raw-f64 slot load. The existing generic body survives unchanged as the +cold arm, so nothing that fails the guard changes at all. + +**Measured on the #7480 access shape** — 200k-element array, 50 sweeps, +in-program timing, best-of-9 with the two arms interleaved so machine drift +hits both equally, checksums equal: + +| kernel | before | after | node | speedup | +|---|--:|--:|--:|--:| +| `keep: Node[]`, `sum += keep[j].v` | 41 ms | **13 ms** | 13 ms | **3.15×** — now at parity with node (was 3.15× behind) | +| `keep: {v,w}[]` (object literal) | 86 ms | 86 ms | 10 ms | 1.00× — deliberately out of scope, see below | + +**Size** (#7566's discipline, both traps avoided — runtime trip counts so +nothing unrolls, arrays escaping through `console.log` so nothing is +scalar-replaced), measured on the module object file rather than the binary so +the runtime archive does not blur it: + +| probe | base | this | delta | +|---|--:|--:|--:| +| 40 loops, **none** qualifying | 83,000 B | 83,000 B | **+0 B, object bytes byte-identical** | +| 40 loops, all qualifying | 93,904 B | 108,872 B | +14,968 B (+15.9%, ~374 B per cloned loop) | + +The runtime archive grows 232 B for the single `keepalive-anchors` static. + +## Mid-loop revocation: which mechanism, and its failure mode + +A store inside the loop body, or inside anything the body calls, can revoke +the invariant mid-iteration, and a specialized body reading a revoked array is +a **miscompile**, not a slow path. Of the three options — restrict the body, +re-check per back-edge, guard-at-entry plus deopt — this ships the first, and +enforces it twice: + +1. **By shape (the matcher).** A single store-free `acc = ` + statement over `arr[counter].field` reads, numeric locals, numeric literals + and pure arithmetic / `Math`. No stores, calls, closures, `await`, or + updates other than the counter's. A catch-all that silently admitted an + unknown expression is the failure this walker refuses to have. +2. **By construction (the lowering).** After the fast clone is emitted, every + one of its blocks is scanned for a GC-unsafe call. If any call survived, + the deref block branches *unconditionally* to the slow clone and the fast + blocks are left as unreachable code. A clone whose call-freeness is + unproven is never entered. + +Call-freeness is exactly the right property because **every** way to revoke +the invariant is a runtime call: an element store (`gc::layout_note_slot` → +`note_element_store`), a length change (`push`/`pop`/`shift`/`splice`/ +`length = n`, all caught by the record's pinned `verified_len`), `delete` +(a `TAG_HOLE` store through the same funnel), `defineProperty` on the array +(`OBJ_FLAG_ARRAY_DESCRIPTORS`), prototype surgery +(`invalidate_all_element_shapes`) — and so is every allocation that could move +the array. Codegen's *inline* element store is the one path that can skip the +note, and only when the array is statically proven numeric and pointer-free, +which an element-shape array can never be. + +**Failure mode: conservative, never unsound.** Anything that writes, calls, or +reads a field the analysis cannot type simply gets no clone. The residual risk +is a *silent loss of the optimization* — a lowering change that starts +emitting a call inside an admitted body would make the whole clone dead code +with nothing failing. `stmt/element_shape_loop_tests.rs` is the gate for that: +it asserts the fast blocks appear in the emitted IR **and** that the fast +clone contains no `call` at all. + +A useful consequence of the same rule, verified in the emitted IR: under +`PERRY_GC_MOVING_LOOP_POLLS=1` the back-edge safepoint is itself a call, so +the scan fails and the deref block emits an *unconditional* +`br label %element_shape.loop.slow.preheader`. The clone stands down in exactly +the configuration where a mid-loop collection could move the array — with no +special case for it anywhere in the code. + +**Sabotage, both directions.** Breaking the guard (every shape fact discarded, +per-element check never side-exits) turns the gap test into a **SIGBUS at the +`subclass:` case** — the `Array`-subclass `ObjectHeader` read as an +`ArrayHeader`, i.e. #7603's fault reproduced on demand. Breaking the clone +*selection* (matcher never fires) leaves the gap test byte-identical to node +while the IR census drops to the same zero the base compiler emits — so the +fallback is behaviour-neutral, and the census is measuring the clone rather +than something incidental: + +| arm | clone blocks | `js_array_ensure_element_shape` calls | gap test | +|---|--:|--:|---| +| base (`main`) | 0 | 0 | identical to node | +| this | 1 of each | 1 | identical to node | +| sabotage: never-specialize | 0 | 0 | identical to node | +| sabotage: always-specialize | 1 of each | 1 | **SIGBUS, exit 138** | + +## The guard tests the live header, and the brand is explicit + +The preheader calls `js_array_ensure_element_shape` — #7496's own query +surface, which reads the array's current `GcHeader` bit and record and +self-heals when the record went stale. No inline reimplementation, so no +drift; #7501's lesson (a static declaration gets revoked at runtime) is +answered by construction. + +Sequencing is load-bearing and is documented at the emitter. The +`GC_TYPE_ARRAY` brand test comes **first**, so the pointer handed to the +runtime is already branded — an `Array` subclass instance is a plain +`ObjectHeader` whose fields overlay `ArrayHeader`'s (#7573/#7603), and reading +one as an array is how #7603's SIGSEGV happened. The elements base pointer is +derived only **after** the guard call returns, from a fresh load of the +array's rooted slot: the call can allocate, and an allocation can move the +array. + +## What the invariant does not prove, and what that costs + +`element_class_of_bits` proves `POINTER_TAG`, a readable `GcHeader`, +`GC_TYPE_OBJECT`, `OBJECT_TYPE_REGULAR` and `class_id == C` for every element +in the verified prefix — exactly the predicates the element-read tier and the +front half of the field-read precheck spend per iteration, so the clone drops +them. It proves nothing about the per-*object* facts a raw-f64 slot load needs: +`keys_array` identity (a `delete elem.f` compacts the packed slots while +preserving `class_id`), `field_count`, the per-object descriptor flag, and the +typed-layout intact bit. Dropping those would be the miscompile, so the clone +keeps a residual per-element check — collapsed to one 4-byte load of the three +contiguous header bytes plus two more loads, AND-reduced into a single branch +that side-exits to the slow clone. Emitted fast body: **zero calls, one +branch, no volatile gate load** (the gate is hoisted, which is sound for +exactly the same reason the clone is). + +Folding those facts into the invariant is the natural next slice, and it needs +an invalidation surface for `delete` / `defineProperty` / typed downgrade that +#7496 deliberately did not open. It should land the way #7496 did: invariant +first, matrix second, consumer third. + +## Scope + +Declared element types (`keep: Node[]`) only. #7480's own object-literal +kernel (`keep: {v,w}[]`) stays where it is, because `receiver_class_name` +returning `None` for an `Object`-typed element is also what makes the +number-context field-read helper decline — so a wider matcher would buy only a +block of dead fast-clone IR. Reaching it 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 needs its own gap-suite A/B. + +Element classes with a base class are declined: an inherited layout is not +described by the packed slot index alone, and a native base (`extends Array`) +is the #7573/#7603 hazard itself. + +## Files + +- `crates/perry-codegen/src/stmt/element_shape_loop.rs` — matcher + lowering, + and the full revocation argument +- `crates/perry-codegen/src/expr/element_shape_guard.rs` — the preheader guard + and the per-element residual check, with an anti-drift test that + reconstructs the header mask from the individual runtime constants and + sabotages each fact it must reject +- `crates/perry-codegen/src/expr/property_get/helpers.rs` — the consumption + hook, next to #5093's class-field one +- `crates/perry-runtime/src/array/element_shape.rs` — exactly ONE + `keepalive-anchors` static, for the one symbol codegen now emits a call to; + the other four stay unanchored and dead-strippable +- `test-files/test_gap_repsel_element_shape_loop_clone.ts` — the hot shape plus + every hazard: mid-loop store revocation (direct and through a call), + revocation between two entries of the same loop, subclass receiver (both a + `const` and a plainly-typed parameter), shape-mismatched and heterogeneous + arrays, holes / sparse / `delete`, empty array, per-element typed-layout + downgrade, deleted field, own accessor, frozen element, prototype surgery, + every length mutation, and a bound past the array's length. Named into the + `test_gap_repsel*` glob so the GC root-dominance corpus picks it up + permanently. + +## Verification + +- Gap test byte-identical to node, exit 0. +- Gap-suite family A/B (`array` / `object` / `class` / `repsel` / `new` / + `prop`, 71 tests) against a same-session `main` build: **verdict sets + identical** — 70 PASS / 1 FAIL on both arms, the one failure + (`test_gap_prop_plan_cache_invalidation`) pre-existing and present on both. + This is #6377's gate, and it is the one that matters for a proof PR. +- GC root-dominance, curated corpus (129/129 sources compiled, 149 modules), + **both gated modes**: `--moving-only` 0 violations with **40/40 seeded + violations caught**, and `--unrooted-allocas --moving-only` 0 violations over + 7,860 GC-capable allocas. The allowlist is empty and stays empty. +- `cargo test -p perry-runtime --no-fail-fast` 1880 passed / 0 failed; + `cargo test -p perry-codegen --lib` 685 passed / 0 failed (7 new). +- `cargo fmt --all --check`, file-size cap, addr-class ratchet, GC store-site + inventory, workspace-architecture policy: all clean. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 3f6c55ce17..a8c9d721eb 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -987,6 +987,7 @@ pub(super) fn compile_closure( masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), + element_shape_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index c67e7d8f02..54a1352105 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -794,6 +794,7 @@ pub(super) fn compile_module_entry( masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), + element_shape_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), local_slot_reps: HashMap::new(), // #7109: this entry body selects canonical i32/u32/Str on the same @@ -1460,6 +1461,7 @@ pub(super) fn compile_module_entry( masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), + element_shape_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), local_slot_reps: HashMap::new(), // #7109: this entry body selects canonical i32/u32/Str on the same diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 855c6b5da8..946fb7286a 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -781,6 +781,7 @@ pub(super) fn compile_function( masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), + element_shape_loop_facts: Vec::new(), // Specialized entries seed the canonical-i32 registry with their raw // i32 params (empty otherwise — identical to the pre-phase behavior). local_slot_reps: spec_i32_param_slots diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index c384174272..e109ab52d0 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -518,6 +518,7 @@ pub(super) fn compile_method( masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), + element_shape_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, @@ -1578,6 +1579,7 @@ pub(super) fn compile_static_method( masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), + element_shape_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, diff --git a/crates/perry-codegen/src/expr/element_shape_guard.rs b/crates/perry-codegen/src/expr/element_shape_guard.rs new file mode 100644 index 0000000000..934d39eb33 --- /dev/null +++ b/crates/perry-codegen/src/expr/element_shape_guard.rs @@ -0,0 +1,336 @@ +//! repsel #7480 / #5093: the emitted-IR half of the element-shape versioned +//! loop clone. +//! +//! Two emitters live here. +//! +//! * [`emit_element_shape_loop_preheader_check`] — run ONCE, before the fast +//! clone. It brands the receiver as a genuine array, asks the runtime +//! whether the per-array homogeneous element-shape invariant +//! (`array/element_shape.rs`, #7496) holds at a specific class id, checks +//! that the verified prefix covers the whole index range, and caches the +//! elements base pointer plus the two loop-invariant words the residual +//! check needs. +//! * [`emit_element_shape_field_load`] — run per access inside the fast clone. +//! It is a bare element load followed by the *residual* facts the +//! element-shape invariant does NOT cover, AND-reduced into one predicate +//! with a single side exit. +//! +//! ## What the invariant proves, and what it does not +//! +//! `js_array_ensure_element_shape` returning class id `C` means every element +//! in `[0, verified_len)` passed `element_class_of_bits`: `POINTER_TAG`, a +//! readable `GcHeader` (which rejects the handle bands and implausible +//! magnitudes), `obj_type == GC_TYPE_OBJECT`, `object_type == +//! OBJECT_TYPE_REGULAR`, and `class_id == C`. That is exactly the set of +//! predicates the element-read tier and the *front half* of the class-field +//! precheck spend per iteration, so the clone drops them. +//! +//! It proves nothing about the per-OBJECT facts a raw-f64 slot load needs — +//! `keys_array` identity (a `delete elem.f` compacts the packed slots while +//! preserving `class_id`), `field_count`, the per-object descriptor flag, or +//! the typed-layout intact bit. Those stay per element, but collapse to ONE +//! 4-byte header load + two more loads and a single branch, because the three +//! header bytes the check needs are contiguous. +//! +//! ## Why the residual check is not just paranoia +//! +//! Dropping it would be a miscompile, not a slow path: an element whose typed +//! layout downgraded holds a NaN-boxed value where the clone would read a raw +//! `double`. `element_class_of_bits` deliberately does not look at that bit +//! (#7496 keeps the invariant's maintenance matrix small enough to enumerate), +//! so the consumer pays for it. Folding it into the invariant is the +//! documented extension (see `stmt/element_shape_loop.rs`), and needs an +//! invalidation surface for `delete` / `defineProperty` / typed downgrade that +//! does not exist today. + +use crate::types::{DOUBLE, I1, I32, I64, I8}; + +use super::FnCtx; + +// --------------------------------------------------------------------------- +// Mirrors of runtime constants. The emitted IR is textual, so these are +// literal decimals — `constants_match_the_runtime` in the test module below is +// the anti-drift gate, and it is the reason every constant here is named after +// the runtime symbol it reproduces (#7602's precedent). +// --------------------------------------------------------------------------- + +/// `0x7FFD` — NaN-box tag for heap pointers. +const POINTER_TAG_HI16: &str = "32765"; +/// `0x0FFFFF` — handles are <= this; real objects are above. +const HANDLE_BAND_TOP: &str = "1048575"; +/// `GC_TYPE_ARRAY`. The subclass brand (#7573/#7603): a `class X extends +/// Array` instance is a plain `ObjectHeader` and must never reach the clone. +const GC_TYPE_ARRAY: &str = "1"; + +/// Mask over the `i32` at `obj - 8`, which spans `GcHeader { obj_type: u8 @-8, +/// gc_flags: u8 @-7, _reserved: u16 @-6 }` on every (little-endian) target +/// Perry emits for. Selects, low to high: +/// +/// | bits | field | want | +/// |---|---|---| +/// | 0–7 (`0x0000_00FF`) | `obj_type` | `GC_TYPE_OBJECT` (2) | +/// | 15 (`0x0000_8000`) | `gc_flags & GC_FLAG_FORWARDED` (0x80) | clear | +/// | 27 (`0x0800_0000`) | `_reserved & OBJ_FLAG_HAS_DESCRIPTORS` (0x800) | clear | +/// | 28 (`0x1000_0000`) | `_reserved & GC_OBJ_TYPED_LAYOUT_INTACT` (0x1000) | set | +/// +/// One load + one `and` + one `icmp` replaces the three loads and six ALU ops +/// the per-access class-field precheck spends on the same four facts. +const ELEM_HEADER_MASK: &str = "402686207"; // 0x1800_80FF +/// The value [`ELEM_HEADER_MASK`] must produce: `obj_type == GC_TYPE_OBJECT`, +/// not forwarded, no per-object descriptors, typed layout intact. +const ELEM_HEADER_EXPECT: &str = "268435458"; // 0x1000_0002 + +/// Emit the once-per-loop element-shape guard into the current block chain. +/// +/// Leaves `ctx.current_block` on an UNTERMINATED block holding the accumulated +/// `i1` predicate, exactly like +/// [`super::class_field_inline_guard::emit_class_field_loop_preheader_check`]: +/// the caller lowers the fast clone, proves it call-free, and only then +/// terminates with `cond_br(shape_ok, fast, slow)`. Never entering a clone +/// whose call-freeness is unproven is the whole revocation argument. +/// +/// Sequencing is load-bearing. The brand test comes first so the pointer +/// handed to the runtime is known to be a real array; the guard call comes +/// next; and the elements base pointer is derived only AFTERWARDS, from a +/// fresh load of the array's rooted slot — the guard call can allocate, and an +/// allocation can move the array, so a base pointer derived before it could be +/// a from-space address. +/// +/// Returns `(elements_base, expected_keys, shape_ok)`. +pub(crate) fn emit_element_shape_loop_preheader_check( + ctx: &mut FnCtx, + array_local_id: u32, + expected_class_id: &str, + keys_global_name: &str, + bound_i32: &str, + slow_label: &str, +) -> anyhow::Result<(String, String, String)> { + let brand_idx = ctx.new_block("element_shape.loop.preheader.brand"); + let query_idx = ctx.new_block("element_shape.loop.preheader.query"); + let deref_idx = ctx.new_block("element_shape.loop.preheader.deref"); + let brand_label = ctx.block_label(brand_idx); + let query_label = ctx.block_label(query_idx); + let deref_label = ctx.block_label(deref_idx); + + // (1) Receiver is a heap pointer at all. A basic block has no + // short-circuit, so nothing may be dereferenced until this branch is taken. + let arr0 = super::lower_expr(ctx, &perry_hir::Expr::LocalGet(array_local_id))?; + let handle0 = { + let blk = ctx.block(); + let bits0 = blk.bitcast_double_to_i64(&arr0); + let tag0 = blk.lshr(I64, &bits0, "48"); + let is_ptr0 = blk.icmp_eq(I64, &tag0, POINTER_TAG_HI16); + let handle0 = blk.and(I64, &bits0, crate::nanbox::POINTER_MASK_I64); + let above0 = blk.icmp_ugt(I64, &handle0, HANDLE_BAND_TOP); + let ok0 = blk.and(I1, &is_ptr0, &above0); + blk.cond_br(&ok0, &brand_label, slow_label); + handle0 + }; + + // (2) SUBCLASS BRAND (#7573/#7603). `class X extends Array` instances are + // plain `ObjectHeader`s that overlay `ArrayHeader` field for field, so + // `length`/`capacity`/`elements[0]` would read `object_type`/`class_id`/ + // `parent_class_id‖field_count`. The runtime's `array_gc_header` makes the + // same test, but it is repeated here so the raw pointer handed across the + // call below is already branded, and so the emitted IR carries the brand + // where a reviewer (and the IR census) can see it. + ctx.current_block = brand_idx; + { + let blk = ctx.block(); + let gt_addr = blk.sub(I64, &handle0, "8"); + let gt_ptr = blk.inttoptr(I64, >_addr); + let gc_type = blk.load(I8, >_ptr); + let is_array = blk.icmp_eq(I8, &gc_type, GC_TYPE_ARRAY); + blk.cond_br(&is_array, &query_label, slow_label); + } + + // (3) The live-header query. `js_array_ensure_element_shape` establishes + // the invariant by scan on first visit and confirms it in O(1) afterwards; + // either way it reads the array's CURRENT `GcHeader` bit and its record, + // and self-heals (clearing the bit) when the record went stale. Static + // declarations are never consulted — #7501's lesson. + ctx.current_block = query_idx; + { + let blk = ctx.block(); + let class_id = blk.call(I32, "js_array_ensure_element_shape", &[(I64, &handle0)]); + let cid_ok = blk.icmp_eq(I32, &class_id, expected_class_id); + blk.cond_br(&cid_ok, &deref_label, slow_label); + } + + // (4) Post-call re-derivation. Everything from here to the end of the fast + // clone is call-free, so THIS pointer is the pointer the clone uses. + ctx.current_block = deref_idx; + let arr1 = super::lower_expr(ctx, &perry_hir::Expr::LocalGet(array_local_id))?; + let keys_load = format!("@{keys_global_name}"); + let blk = ctx.block(); + let bits1 = blk.bitcast_double_to_i64(&arr1); + let tag1 = blk.lshr(I64, &bits1, "48"); + let is_ptr1 = blk.icmp_eq(I64, &tag1, POINTER_TAG_HI16); + let handle1 = blk.and(I64, &bits1, crate::nanbox::POINTER_MASK_I64); + let above1 = blk.icmp_ugt(I64, &handle1, HANDLE_BAND_TOP); + + // Re-brand. Safe to dereference unconditionally: (1) already proved this + // slot holds a heap pointer and only a GC move could have changed it, and + // a move preserves both properties. `is_ptr1`/`above1` are folded into the + // predicate anyway so a surprise cannot license the clone. + let gt_addr1 = blk.sub(I64, &handle1, "8"); + let gt_ptr1 = blk.inttoptr(I64, >_addr1); + let gc_type1 = blk.load(I8, >_ptr1); + let is_array1 = blk.icmp_eq(I8, &gc_type1, GC_TYPE_ARRAY); + + // `ArrayHeader { length: u32 @0, capacity: u32 @4 }`. The invariant's + // query requires `verified_len == length`, and nothing has run since, so + // `length >= bound` is exactly "the verified prefix covers every index the + // loop reads". The matcher already pinned `start >= 0`. + let len_ptr = blk.inttoptr(I64, &handle1); + let length = blk.load(I32, &len_ptr); + let len_ok = blk.icmp_uge(I32, &length, bound_i32); + + // Elements base: `arr + size_of::()`. + let base_addr = blk.add(I64, &handle1, "8"); + let elements_base = blk.inttoptr(I64, &base_addr); + + // Hoisted loop-invariant words for the residual check. The volatile gate + // load is hoistable here for the same reason the class-field preheader + // check hoists it: flipping it requires a runtime call, and the fast clone + // makes none. + let expected_keys = blk.load(I64, &keys_load); + let gate = blk.load_volatile(I8, "@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"); + let gate_ok = blk.icmp_eq(I8, &gate, "0"); + + let mut acc = blk.and(I1, &is_ptr1, &above1); + acc = blk.and(I1, &acc, &is_array1); + acc = blk.and(I1, &acc, &len_ok); + acc = blk.and(I1, &acc, &gate_ok); + + // No terminator: the caller branches after proving the clone call-free. + Ok((elements_base, expected_keys, acc)) +} + +/// Emit one `arr[i].field` read inside the fast clone: bare element load, +/// residual per-element check with a single side exit, bare raw-f64 slot load. +/// +/// `idx_i32` must be the loop counter's canonical i32 (non-negative, and +/// `< bound <= length` by the preheader), and `fact` must be the fact the +/// preheader installed for `(array, counter)`. +pub(crate) fn emit_element_shape_field_load( + ctx: &mut FnCtx, + fact: &super::ElementShapeLoopFact, + idx_i32: &str, + field_index: u32, +) -> String { + let field_index_str = field_index.to_string(); + let max_field_index_str = fact.max_field_index.to_string(); + let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + + let load_idx = ctx.new_block("element_shape.load"); + let load_label = ctx.block_label(load_idx); + + let elem_ptr = { + let blk = ctx.block(); + // The element-shape invariant proved every slot in the verified prefix + // is a POINTER_TAG object of the guarded class, so the unbox needs no + // tag test and no handle-band test — the two checks that make up the + // element-read tier. + let idx64 = blk.sext(I32, idx_i32, I64); + let slot_ptr = blk.gep(I64, &fact.elements_base, &[(I64, &idx64)]); + let elem_bits = blk.load(I64, &slot_ptr); + let elem_handle = blk.and(I64, &elem_bits, crate::nanbox::POINTER_MASK_I64); + let elem_ptr = blk.inttoptr(I64, &elem_handle); + + // Residual per-OBJECT facts (see the module docs for why they cannot + // come from the array-level invariant). + let hdr_ptr = blk.gep(I8, &elem_ptr, &[(I64, "-8")]); + let hdr = blk.load(I32, &hdr_ptr); + let hdr_masked = blk.and(I32, &hdr, ELEM_HEADER_MASK); + let hdr_ok = blk.icmp_eq(I32, &hdr_masked, ELEM_HEADER_EXPECT); + + let fc_ptr = blk.gep(I8, &elem_ptr, &[(I64, "12")]); + let field_count = blk.load(I32, &fc_ptr); + let fc_ok = blk.icmp_ugt(I32, &field_count, &max_field_index_str); + + let ka_ptr = blk.gep(I8, &elem_ptr, &[(I64, "16")]); + let keys_array = blk.load(I64, &ka_ptr); + let ka_ok = blk.icmp_eq(I64, &keys_array, &fact.expected_keys); + + let mut ok = blk.and(I1, &hdr_ok, &fc_ok); + ok = blk.and(I1, &ok, &ka_ok); + // One branch per access. The side exit resumes the CURRENT iteration + // in the slow clone; the matcher guarantees no effect of this + // iteration has committed yet, so re-executing cannot double-apply. + blk.cond_br(&ok, &load_label, &fact.side_exit_label); + elem_ptr + }; + + ctx.current_block = load_idx; + let blk = ctx.block(); + let fields_base = blk.gep(I8, &elem_ptr, &[(I64, &header_skip)]); + let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_index_str)]); + blk.load(DOUBLE, &field_ptr) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Anti-drift gate (#7602's precedent). Every literal above reproduces a + /// runtime constant; if one moves, this fails instead of the emitted IR + /// silently testing the wrong bit — which for `GC_OBJ_TYPED_LAYOUT_INTACT` + /// would be a raw `double` read of a NaN-boxed slot. + #[test] + fn constants_match_the_runtime() { + assert_eq!(POINTER_TAG_HI16, (0x7FFDu64).to_string()); + assert_eq!(HANDLE_BAND_TOP, (0x0F_FFFFu64).to_string()); + assert_eq!(GC_TYPE_ARRAY, "1"); + + // Reconstruct the header mask from the individual runtime constants, + // positioned by their byte offsets inside the i32 at `obj - 8`. + let obj_type_mask = 0x0000_00FFu32; + let forwarded = u32::from(0x80u8) << 8; // GC_FLAG_FORWARDED @ -7 + let has_descriptors = 0x0800u32 << 16; // OBJ_FLAG_HAS_DESCRIPTORS @ -6 + let typed_intact = 0x1000u32 << 16; // GC_OBJ_TYPED_LAYOUT_INTACT @ -6 + let mask = obj_type_mask | forwarded | has_descriptors | typed_intact; + let expect = u32::from(2u8) /* GC_TYPE_OBJECT */ | typed_intact; + + assert_eq!(ELEM_HEADER_MASK, mask.to_string(), "header mask drifted"); + assert_eq!( + ELEM_HEADER_EXPECT, + expect.to_string(), + "header expectation drifted" + ); + + // Sabotage direction: the mask must actually reject each fact. + let good = expect; + assert_eq!(good & mask, expect); + assert_ne!((good | forwarded) & mask, expect, "forwarded not rejected"); + assert_ne!( + (good | has_descriptors) & mask, + expect, + "descriptors not rejected" + ); + assert_ne!( + (good & !typed_intact) & mask, + expect, + "typed-layout downgrade not rejected" + ); + assert_ne!((good ^ 1) & mask, expect, "wrong obj_type not rejected"); + } + + /// The mask reads three adjacent header bytes as one little-endian i32. + /// Perry emits for aarch64/x86_64 only; assert the assumption explicitly + /// so a future big-endian target trips here rather than in a field load. + #[test] + fn header_word_assumes_little_endian_targets() { + for triple in [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-linux-android", + "x86_64-pc-windows-msvc", + ] { + assert!( + !triple.starts_with("s390") && !triple.starts_with("powerpc64-"), + "{triple} is big-endian; ELEM_HEADER_MASK byte positions are LE-only" + ); + } + } +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e86a342177..0511a5b11a 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -819,6 +819,15 @@ pub(crate) struct FnCtx<'a> { /// current iteration. pub class_field_loop_facts: Vec, + /// repsel #7480 / #5093: scoped loop-versioning facts for element-shape + /// loops (`for (…) sum += arr[i].field`). Pushed only around the FAST + /// clone of `lower_element_shape_versioned_for` + /// (`stmt/element_shape_loop.rs`). Inside that clone `arr[i].field` + /// lowers to a bare element load plus a small residual per-element check + /// with a single side exit — no element-read tier, no guard call, no + /// per-access volatile gate load. + pub element_shape_loop_facts: Vec, + /// Parallel i32 counter slots for integer loop counters that are /// used as bounded array indices. When a for-loop counter is in /// `integer_locals` AND appears in `bounded_index_pairs`, `lower_for` @@ -1552,6 +1561,69 @@ pub(crate) struct ClassFieldLoopFact { pub fields: std::collections::BTreeMap, } +/// #5093 / repsel #7480: one fact per (array, counter, versioned loop) — the +/// element-shape clone's licence to read `arr[i].field` with no guard. +/// +/// Pushed only around the FAST clone of `lower_element_shape_versioned_for` +/// (`stmt/element_shape_loop.rs`). See that module for the full safety +/// argument; the short version is that the preheader proved +/// +/// * `arr` is a genuine `GC_TYPE_ARRAY` (never an `Array` subclass, which is +/// a plain `ObjectHeader` — #7573/#7603), +/// * the runtime's homogeneous element-shape invariant holds for `arr` at +/// exactly `class_name`'s class id (`js_array_ensure_element_shape`), +/// * the verified prefix covers every index the loop reads, +/// +/// and the lowering proved the fast clone is call-free, so nothing can revoke +/// the invariant or move the array while the clone runs. +#[derive(Debug, Clone)] +pub(crate) struct ElementShapeLoopFact { + /// LocalId of the loop-invariant array the preheader guarded. + pub array_local_id: u32, + /// LocalId of the loop counter used as the element index. + pub index_local_id: u32, + pub scope_id: u32, + /// Class the preheader proved every element in the verified prefix has. + pub class_name: String, + /// SSA name of the elements base pointer (`arr_handle + 8`), derived in + /// the preheader AFTER the guard call, so it cannot be a pre-move address. + pub elements_base: String, + /// SSA name of the hoisted `@perry_class_keys_` load. + pub expected_keys: String, + /// Slow clone's preheader label. The per-element residual check (see + /// `expr::element_shape_guard`) branches here on a miss; the slow clone + /// re-executes the current iteration, which is safe because the matcher + /// admits no body that commits an effect before the read. + pub side_exit_label: String, + /// property name -> packed slot index, every entry a declared raw-f64 + /// candidate validated by the matcher. + pub fields: std::collections::BTreeMap, + /// Largest packed slot index the loop touches — the per-element + /// `field_count` check covers every tracked access with one compare. + 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, + 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 + { + return None; + } + fact.fields.get(property).map(|idx| (fact, *idx)) + }) +} + /// Find the innermost active class-field loop fact covering /// `(recv_local_id, class_name, property)`. Returns the fact and the packed /// slot index of the field. @@ -1781,6 +1853,7 @@ mod index_set_typed_array; mod instance_misc1; pub(crate) use instance_misc1::builtin_parent_reserved_class_id; pub(crate) mod class_field_inline_guard; +pub(crate) mod element_shape_guard; mod js_runtime; mod literals_vars; mod logical_collections; diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index 5e3966d868..8196ed3862 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -355,6 +355,80 @@ 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/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index f198a7aa93..3e3d93522f 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -44,6 +44,11 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { // Refs #488: bulk push for `arr.push(...src)` spread call. module.declare_function("js_array_push_spread_f64", I64, &[I64, I64]); module.declare_function("js_array_get_f64", DOUBLE, &[I64, I32]); + // repsel #7480 / #5093: the element-shape versioned loop's preheader + // guard. Establishes-or-confirms the per-array homogeneous element-shape + // invariant and returns the proven class id (0 = no proof). O(n) on the + // first visit, O(1) after — see `array/element_shape.rs`. + module.declare_function("js_array_ensure_element_shape", I32, &[I64]); module.declare_function("js_array_get_index_or_string", DOUBLE, &[I64, DOUBLE]); module.declare_function("js_array_numeric_get_f64_unboxed", DOUBLE, &[I64, I32]); module.declare_function("js_array_set_f64", VOID, &[I64, I32, DOUBLE]); diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs new file mode 100644 index 0000000000..ae1538e9a2 --- /dev/null +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -0,0 +1,577 @@ +//! repsel #7480 / #5093: the **element-shape versioned loop clone** — the +//! first consumer of the per-array homogeneous element-shape invariant +//! (`perry-runtime/src/array/element_shape.rs`, #7496, matrix #7608). +//! +//! ## The shape, and why it costs what it costs +//! +//! ```text +//! for (let j = 0; j < n; j++) sum += keep[j].v; +//! ``` +//! +//! `keep[j]` yields an untyped `JSValue`, so `.v` re-enters the field-read +//! diamond that repsel 3b deleted for proven locals. #7480 measured the pure +//! shape at 6.2× node and localized the cost precisely: not out-of-line guard +//! *calls*, but **stacked inline diamonds** — an element-read tier +//! (tag / handle-band / `GC_TYPE_ARRAY` / descriptor tests, a branch and a +//! phi) feeding a field-read precheck (a volatile gate load, tag and band +//! tests again, then seven dependent header loads), every iteration. +//! +//! Almost every one of those predicates is answered *once, for the whole +//! array*, by the element-shape invariant. This module hoists that one +//! question into a preheader and clones the body against the answer. +//! +//! ## Mid-loop revocation — the chosen mechanism, and its failure mode +//! +//! The invariant is construction-maintained and self-healing, but a store +//! inside the loop body (or inside anything the body calls) can revoke it +//! mid-iteration, and a specialized body reading a revoked array is a +//! **miscompile**, not a slow path. +//! +//! Of the three options in the design space — restrict the body, re-check per +//! back-edge, or deopt on runtime invalidation — this ships the **first**: +//! *the clone is admitted only for bodies that provably cannot revoke*. That +//! is enforced twice, at two different levels, and the second enforcement is +//! the load-bearing one: +//! +//! 1. **By shape (the matcher).** The body must be a single +//! `acc = ` statement over tracked `arr[i].field` reads, +//! numeric locals, literals and pure arithmetic / `Math`. No store of any +//! kind, no call, no closure, no `await`, no update other than the +//! counter's. +//! 2. **By construction (the lowering).** After the fast clone is emitted, +//! every one of its blocks is scanned for a GC-unsafe call +//! (`LlBlock::contains_gc_unsafe_call`). If ANY call survived — because +//! some lowering path we did not predict emitted one — the deref block +//! branches *unconditionally* to the slow clone and the fast blocks are +//! left as unreachable code. A clone whose call-freeness is unproven is +//! never entered. +//! +//! Call-freeness is exactly the right property, because **every** way to +//! revoke the invariant is a runtime call: +//! +//! | revocation | funnel | is a call | +//! |---|---|---| +//! | `arr[i] = x` (different class / non-object / hole) | `gc::layout_note_slot` → `note_element_store` | yes | +//! | `arr.push` / `pop` / `shift` / `splice` / `arr.length = n` | length change ⇒ `verified_len` mismatch on next query | yes | +//! | `delete arr[i]` | `TAG_HOLE` store through the same funnel | yes | +//! | `Object.defineProperty(arr, i, …)` | `OBJ_FLAG_ARRAY_DESCRIPTORS` ⇒ `array_admits_element_proof` | yes | +//! | prototype surgery on the element class | `invalidate_all_element_shapes` | yes | +//! | a GC moving the array | `layout_transfer` → `transfer_element_shape` | needs an allocation | +//! +//! Codegen's *inline* element store is the one path that can skip the note, +//! and it does so only when the array is statically proven numeric and +//! pointer-free — which an element-shape array (whose slots are NaN-boxed +//! pointers) can never be. It is also excluded by the matcher anyway, which +//! admits no stores at all. +//! +//! **Failure mode of this choice:** it is conservative, never unsound. A loop +//! that writes anything, calls anything, or reads a field the analysis cannot +//! type simply does not get a clone and runs exactly as it does today. The +//! risk that remains is a *silent loss of the optimization* — a lowering +//! change that starts emitting a call inside a body the matcher still admits +//! would make the whole clone dead code with no test failing. That is why +//! `element_shape_loop_tests.rs` asserts the fast blocks appear in the emitted +//! IR AND that the fast clone contains no `call` at all: the IR census is the +//! regression gate for the optimization, and the call-free scan is the +//! regression gate for correctness. +//! +//! The rejected options, for the record. A per-back-edge re-check (one load +//! and compare of the header bit plus the shape id) is cheap, but it does not +//! actually discharge the hazard: the bit can be revoked *between* the check +//! and the read within one iteration, and the residual per-element facts (see +//! `expr::element_shape_guard`) would still be needed. Guard-at-entry plus +//! runtime invalidation deopt needs an on-stack-replacement mechanism Perry +//! does not have. +//! +//! ## Extension plan (write-up for #5093 / #7480) +//! +//! The clone still pays a residual per-element check — `keys_array` identity, +//! `field_count`, the per-object descriptor flag and the typed-layout intact +//! bit — because the array-level invariant deliberately does not cover them. +//! Folding them into `element_class_of_bits` would make the reads bare, but it +//! needs an invalidation surface for `delete elem.f`, `defineProperty(elem)` +//! and typed-layout downgrade that does not exist today; #7496 kept the +//! maintenance matrix small precisely by not opening that surface. That is the +//! natural next slice, and it should land the way #7496 did: invariant first, +//! matrix second, consumer third. + +use anyhow::Result; +use perry_hir::Stmt; + +use super::loops::{ + emit_js_value_is_number, local_bound_is_loop_invariant, local_has_readable_slot, + loop_counter_bounds_are_safe, loop_counter_entry_i32_range_is_safe, lower_for_after_init, + lower_for_after_init_with_i32_bound, CLASS_FIELD_LOOP_CLASS_DENYLIST, + CLASS_FIELD_LOOP_PROP_DENYLIST, +}; +use crate::expr::{lower_expr, FnCtx}; +use crate::types::{DOUBLE, I1, I32}; + +/// Loop bound: a literal, or a loop-invariant local / module global that is +/// materialized to i32 once in the preheader. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ElementShapeLoopBound { + Constant(i64), + Local(u32), +} + +#[derive(Debug)] +struct ElementShapeVersionedLoop { + counter_id: u32, + bound: ElementShapeLoopBound, + array_id: u32, + class_name: String, + expected_class_id: u32, + keys_global_name: String, + /// property name -> packed slot index. + fields: std::collections::BTreeMap, +} + +/// Effect-free expression walk for the element-shape loop. +/// +/// Admits exactly: tracked `arr[counter].prop` reads on ONE array, numeric +/// locals, numeric literals, and pure arithmetic / `Math` (libm intrinsics +/// cannot trigger a GC). Everything else bails the whole match — a catch-all +/// that silently accepted an unknown expression would be the #6377 shape, and +/// worse here, because an unadmitted expression is what would smuggle a call +/// into a body the revocation argument assumes is call-free. +fn element_shape_loop_pure_expr_collect( + ctx: &FnCtx<'_>, + expr: &perry_hir::Expr, + counter_id: u32, + array: &mut Option, + props: &mut std::collections::BTreeSet, +) -> bool { + use perry_hir::Expr; + match expr { + Expr::PropertyGet { + object, property, .. + } => { + let Expr::IndexGet { object, index } = object.as_ref() else { + return false; + }; + let (Expr::LocalGet(arr_id), Expr::LocalGet(idx_id)) = + (object.as_ref(), index.as_ref()) + else { + return false; + }; + // The index must be the loop counter itself. An offset index + // (`arr[j + 1]`) would need the preheader's `length >= bound` + // check widened; deliberately out of the first slice. + if *idx_id != counter_id || *arr_id == counter_id { + return false; + } + match array { + Some(a) if *a == *arr_id => {} + Some(_) => return false, // one array per loop + None => *array = Some(*arr_id), + } + props.insert(property.clone()); + true + } + // A bare read of the array or the counter as a VALUE could flow it + // into arbitrary lowering; only scalar reads the analysis proves + // numeric are admitted. + Expr::LocalGet(id) => { + array.is_none_or(|a| a != *id) && crate::type_analysis::is_numeric_expr(ctx, expr) + } + Expr::Number(_) | Expr::Integer(_) => true, + 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, right, counter_id, array, props) + } + Expr::NumberCoerce(operand) => { + element_shape_loop_pure_expr_collect(ctx, operand, counter_id, array, props) + } + Expr::MathImul(left, right) | Expr::MathPow(left, right) => { + 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::MathMin(values) | Expr::MathMax(values) => values + .iter() + .all(|e| element_shape_loop_pure_expr_collect(ctx, e, counter_id, array, props)), + Expr::MathAbs(value) + | Expr::MathSqrt(value) + | Expr::MathFloor(value) + | Expr::MathCeil(value) + | Expr::MathRound(value) + | Expr::MathTrunc(value) + | Expr::MathSign(value) + | Expr::MathF16round(value) => { + element_shape_loop_pure_expr_collect(ctx, value, counter_id, array, props) + } + _ => false, + } +} + +/// 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. +/// +/// **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. +fn element_class_name(ctx: &FnCtx<'_>, array_id: u32, counter_id: u32) -> Option { + 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)), + }, + ) +} + +/// Match `for (let j = k0; j < B; j++) acc = `. +/// +/// The single-statement, store-free body is the revocation argument (see the +/// module docs) AND the side-exit protocol: the residual per-element check +/// fires before the accumulator's `LocalSet` commits, so resuming the current +/// iteration in the slow clone cannot double-apply anything. +fn match_element_shape_versioned_loop( + ctx: &FnCtx<'_>, + init: Option<&Stmt>, + condition: Option<&perry_hir::Expr>, + update: Option<&perry_hir::Expr>, + body: &[Stmt], +) -> Option { + use perry_hir::{CompareOp, Expr, UpdateOp}; + + // Oversized modules full-outline the class-field diamonds for code size; + // a clone that re-inlines them there would fight that decision. + if crate::codegen::full_outline_ic_enabled() { + return None; + } + if !ctx.pending_labels.is_empty() { + return None; + } + + let (counter_id, start) = match init? { + Stmt::Let { + id, + init: Some(init_expr), + .. + } => { + let start = match init_expr { + Expr::Integer(n) => *n, + Expr::Number(n) if n.is_finite() && n.fract() == 0.0 => *n as i64, + _ => return None, + }; + (*id, start) + } + _ => return None, + }; + if !(0..=i64::from(i32::MAX)).contains(&start) { + return None; + } + + let (op, left, right) = match condition? { + Expr::Compare { op, left, right } => (*op, left.as_ref(), right.as_ref()), + _ => return None, + }; + if !matches!(op, CompareOp::Lt) || !matches!(left, Expr::LocalGet(id) if *id == counter_id) { + return None; + } + let bound = match right { + Expr::Integer(k) if (0..=i64::from(i32::MAX)).contains(k) => { + ElementShapeLoopBound::Constant(*k) + } + Expr::LocalGet(bound_id) if *bound_id != counter_id => { + if ctx.boxed_vars.contains(bound_id) { + return None; + } + if !local_has_readable_slot(ctx, *bound_id) + && !ctx.module_globals.contains_key(bound_id) + { + return None; + } + if !local_bound_is_loop_invariant(condition?, update, body, *bound_id) { + return None; + } + ElementShapeLoopBound::Local(*bound_id) + } + _ => return None, + }; + + if !matches!( + update?, + Expr::Update { + id, + op: UpdateOp::Increment, + .. + } if *id == counter_id + ) { + return None; + } + if !local_has_readable_slot(ctx, counter_id) + || ctx.boxed_vars.contains(&counter_id) + || !ctx.integer_locals.contains(&counter_id) + || !loop_counter_bounds_are_safe(ctx, counter_id, update, body) + || !loop_counter_entry_i32_range_is_safe(init, counter_id) + { + return None; + } + + // Store-free single-statement body: a scalar accumulator over tracked + // element-field reads. NOTHING else is admitted (see the module docs). + let [Stmt::Expr(Expr::LocalSet(acc_id, value))] = body else { + return None; + }; + if *acc_id == counter_id + || !ctx.locals.contains_key(acc_id) + || ctx.boxed_vars.contains(acc_id) + || ctx.module_globals.contains_key(acc_id) + || ctx.shadow_slot_map.contains_key(acc_id) + || !crate::type_analysis::is_numeric_expr(ctx, &Expr::LocalGet(*acc_id)) + { + return None; + } + let mut array: Option = None; + let mut props: std::collections::BTreeSet = std::collections::BTreeSet::new(); + if !element_shape_loop_pure_expr_collect(ctx, value, counter_id, &mut array, &mut props) { + return None; + } + let array_id = array?; + if props.is_empty() || array_id == *acc_id || array_id == counter_id { + return None; + } + if let ElementShapeLoopBound::Local(bound_id) = bound { + if bound_id == array_id || bound_id == *acc_id { + return None; + } + } + + // The array must be loop-invariant and directly addressable — no boxing, + // no POD / scalar-replacement alias (those take other lowering paths). + if ctx.boxed_vars.contains(&array_id) + || ctx.pod_records.contains_key(&array_id) + || ctx.scalar_replaced.contains_key(&array_id) + || ctx.scalar_replaced_arrays.contains_key(&array_id) + || ctx.array_row_aliases.contains_key(&array_id) + { + return None; + } + if !ctx.locals.contains_key(&array_id) && !ctx.module_globals.contains_key(&array_id) { + return None; + } + if !local_bound_is_loop_invariant(condition?, update, body, array_id) { + return None; + } + + let class_name = element_class_name(ctx, array_id, counter_id)?; + if CLASS_FIELD_LOOP_CLASS_DENYLIST.contains(&class_name.as_str()) { + return None; + } + let class = ctx.classes.get(&class_name)?; + if !class.computed_members.is_empty() { + return None; + } + // An `extends`-ing element class would put the field's slot behind an + // inherited layout the packed index does not describe on its own; and a + // native base (`extends Array`) is exactly the #7573/#7603 hazard. Decline. + if class.extends_name.is_some() { + return None; + } + let expected_class_id = *ctx.class_ids.get(&class_name)?; + let keys_global_name = ctx.class_keys_globals.get(&class_name)?.clone(); + + let mut fields = std::collections::BTreeMap::new(); + for prop in props { + if CLASS_FIELD_LOOP_PROP_DENYLIST.contains(&prop.as_str()) { + return None; + } + // Accessors route through synthesized __get_/__set_ methods before the + // class-field diamond; mirror that dispatch gate exactly. + if ctx + .methods + .contains_key(&(class_name.clone(), format!("__get_{prop}"))) + || ctx + .methods + .contains_key(&(class_name.clone(), format!("__set_{prop}"))) + { + return None; + } + let field_index = crate::type_analysis::class_field_global_index(ctx, &class_name, &prop)?; + let raw_f64 = crate::type_analysis::class_field_declared_type(ctx, &class_name, &prop) + .as_ref() + .is_some_and(crate::typed_shape::type_is_raw_f64_candidate); + if !raw_f64 { + return None; + } + fields.insert(prop, field_index); + } + + Some(ElementShapeVersionedLoop { + counter_id, + bound, + array_id, + class_name, + expected_class_id, + keys_global_name, + fields, + }) +} + +/// Lower the matched loop as a guarded fast clone plus the unchanged generic +/// body, modeled on `lower_class_field_versioned_for`. +/// +/// SAFETY (miscompile class — see the module docs): between the preheader's +/// post-guard re-derivation of the elements base pointer and the end of the +/// fast clone, NO call may be emitted. The matcher enforces this by shape and +/// the scan below enforces it by construction; call-free ⇒ allocation-free ⇒ +/// no GC ⇒ the array cannot move, and none of the revocation funnels can run. +pub(super) fn lower_element_shape_versioned_for( + ctx: &mut FnCtx<'_>, + init: Option<&Stmt>, + condition: Option<&perry_hir::Expr>, + update: Option<&perry_hir::Expr>, + body: &[Stmt], +) -> Result { + let Some(matched) = match_element_shape_versioned_loop(ctx, init, condition, update, body) + else { + return Ok(false); + }; + // The fast clone reads the counter through its canonical i32 slot; without + // one it would win nothing (and the element GEP would need an fptosi). + if !ctx.i32_counter_slots.contains_key(&matched.counter_id) { + return Ok(false); + } + + let fast_pre_idx = ctx.new_block("element_shape.loop.fast.preheader"); + let slow_pre_idx = ctx.new_block("element_shape.loop.slow.preheader"); + let merge_idx = ctx.new_block("element_shape.loop.merge"); + let fast_pre_label = ctx.block_label(fast_pre_idx); + let slow_pre_label = ctx.block_label(slow_pre_idx); + let merge_label = ctx.block_label(merge_idx); + + // One-time i32 materialization of the bound. A non-number / NaN / + // fractional / out-of-range bound keeps full JS trip-count semantics in + // the slow clone. + let bound_i32: String = match matched.bound { + ElementShapeLoopBound::Constant(k) => k.to_string(), + ElementShapeLoopBound::Local(bound_id) => { + let bound_d = lower_expr(ctx, &perry_hir::Expr::LocalGet(bound_id))?; + let is_number = emit_js_value_is_number(ctx, &bound_d); + let range_idx = ctx.new_block("element_shape.loop.bound.range"); + let convert_idx = ctx.new_block("element_shape.loop.bound.convert"); + let check_idx = ctx.new_block("element_shape.loop.shape_check"); + let range_label = ctx.block_label(range_idx); + let convert_label = ctx.block_label(convert_idx); + let check_label = ctx.block_label(check_idx); + ctx.block() + .cond_br(&is_number, &range_label, &slow_pre_label); + + ctx.current_block = range_idx; + let ge_zero = ctx.block().fcmp("oge", &bound_d, "0.0"); + let le_max = { + let max_literal = format!("{:.1}", i32::MAX as f64); + ctx.block().fcmp("ole", &bound_d, &max_literal) + }; + let in_range = ctx.block().and(I1, &ge_zero, &le_max); + ctx.block() + .cond_br(&in_range, &convert_label, &slow_pre_label); + + ctx.current_block = convert_idx; + let bound_i32 = ctx.block().fptosi(DOUBLE, &bound_d, I32); + let roundtrip = ctx.block().sitofp(I32, &bound_i32, DOUBLE); + let is_integral = ctx.block().fcmp("oeq", &roundtrip, &bound_d); + ctx.block() + .cond_br(&is_integral, &check_label, &slow_pre_label); + + ctx.current_block = check_idx; + bound_i32 + } + }; + + let expected_class_id_str = matched.expected_class_id.to_string(); + let (elements_base, expected_keys, shape_ok) = + crate::expr::element_shape_guard::emit_element_shape_loop_preheader_check( + ctx, + matched.array_id, + &expected_class_id_str, + &matched.keys_global_name, + &bound_i32, + &slow_pre_label, + )?; + // Deliberately unterminated: it branches into the fast clone only after + // the clone is PROVEN call-free below. + let deref_idx = ctx.current_block; + + let max_field_index = matched + .fields + .values() + .copied() + .max() + .expect("matcher requires >= 1 tracked field"); + let scope_id = ctx.next_loop_proof_scope_id(); + let fast_scan_start = ctx.func.num_blocks(); + ctx.current_block = fast_pre_idx; + ctx.element_shape_loop_facts + .push(crate::expr::ElementShapeLoopFact { + array_local_id: matched.array_id, + index_local_id: matched.counter_id, + scope_id, + class_name: matched.class_name.clone(), + elements_base, + expected_keys, + side_exit_label: slow_pre_label.clone(), + fields: matched.fields.clone(), + max_field_index, + }); + let lowered = lower_for_after_init_with_i32_bound( + ctx, + init, + condition, + update, + body, + "for.element_shape_fast", + Some((matched.counter_id, bound_i32)), + ); + ctx.element_shape_loop_facts + .retain(|fact| fact.scope_id != scope_id); + lowered?; + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + let fast_scan_end = ctx.func.num_blocks(); + + // Compile-time verification of the revocation argument. See the module + // docs: call-free is precisely "no funnel that can revoke the invariant, + // and no allocation that can move the array, runs while the clone does". + let fast_clone_call_free = !ctx.func.blocks()[fast_pre_idx].contains_gc_unsafe_call() + && (fast_scan_start..fast_scan_end) + .all(|idx| !ctx.func.blocks()[idx].contains_gc_unsafe_call()); + ctx.current_block = deref_idx; + if fast_clone_call_free { + ctx.block() + .cond_br(&shape_ok, &fast_pre_label, &slow_pre_label); + } else { + ctx.block().br(&slow_pre_label); + } + + ctx.current_block = slow_pre_idx; + lower_for_after_init(ctx, init, condition, update, body, "for.element_shape_slow")?; + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + + ctx.current_block = merge_idx; + Ok(true) +} diff --git a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs new file mode 100644 index 0000000000..a1a47d22e8 --- /dev/null +++ b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs @@ -0,0 +1,400 @@ +//! repsel #7480 / #5093: the element-shape versioned loop clone must actually +//! be REACHED, and its fast clone must actually be free of the two diamonds it +//! exists to delete. +//! +//! These are IR-census tests for the same reason +//! `class_field_loop_tests.rs` is (#7287, and CLAUDE.md's "a gate must assert +//! its subject was live"): a versioned-loop matcher that declines every loop +//! still compiles, still prints the right answer, and still emits a different +//! object file than an unoptimized build. Only the emitted block labels +//! distinguish "implemented" from "reached". +//! +//! Each assertion here is paired with its negative: a loop the matcher MUST +//! decline (a store in the body, a subclass-capable element class, a +//! non-counter index) asserts the fast blocks are ABSENT, so a matcher that +//! quietly widened to admit an unsound shape fails here rather than in the gap +//! suite. + +use crate::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{ + BinaryOp, Class, ClassField, CompareOp, Expr, Module, ModuleInitKind, Stmt, UpdateOp, +}; + +fn ir_opts() -> CompileOptions { + CompileOptions { + target: None, + is_entry_module: true, + non_entry_module_prefixes: Vec::new(), + nextjs_path_init_modules: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_ffi_aliases: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: crate::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +fn node_class(extends_name: Option<&str>) -> Class { + Class { + id: 202, + name: "Node".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: extends_name.map(str::to_string), + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![ + ClassField { + name: "v".to_string(), + key_expr: None, + ty: Type::Number, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }, + ClassField { + name: "w".to_string(), + key_expr: None, + ty: Type::Number, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }, + ], + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + } +} + +/// `keep[].v` +fn elem_field(array_id: u32, index: Expr) -> Expr { + Expr::PropertyGet { + object: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(array_id)), + index: Box::new(index), + }), + property: "v".to_string(), + byte_offset: 0, + } +} + +/// `sum = sum + keep[j].v` — the #7480 access shape. +fn accumulate_stmt(sum_id: u32, array_id: u32, index: Expr) -> Stmt { + Stmt::Expr(Expr::LocalSet( + sum_id, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(sum_id)), + right: Box::new(elem_field(array_id, index)), + }), + )) +} + +const ARRAY_ID: u32 = 1; +const SUM_ID: u32 = 2; +const COUNTER_ID: u32 = 7; + +/// `const keep: Node[] = []; let sum = 0; for (let j = 0; j < N; j++) ` +fn element_shape_module(body: Vec, extends_name: Option<&str>) -> Module { + let mut m = Module::new("element_shape_loop.ts"); + m.classes = vec![node_class(extends_name)]; + m.init = vec![ + Stmt::Let { + id: ARRAY_ID, + name: "keep".to_string(), + ty: Type::Array(Box::new(Type::Named("Node".to_string()))), + mutable: false, + init: Some(Expr::Array(Vec::new())), + }, + Stmt::Let { + id: SUM_ID, + name: "sum".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Number(0.0)), + }, + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: COUNTER_ID, + name: "j".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(COUNTER_ID)), + right: Box::new(Expr::Integer(1_000_000)), + }), + update: Some(Expr::Update { + id: COUNTER_ID, + op: UpdateOp::Increment, + prefix: false, + }), + body, + }, + ]; + m.init_kind = ModuleInitKind::Eager; + m +} + +fn emit(m: &Module) -> String { + String::from_utf8(compile_module(m, ir_opts()).unwrap()).expect("LLVM IR should be UTF-8") +} + +/// The blocks that exist only when the clone was really built AND entered. +const CLONE_LABELS: [&str; 5] = [ + "element_shape.loop.preheader.brand", + "element_shape.loop.preheader.query", + "element_shape.loop.preheader.deref", + "element_shape.loop.fast.preheader", + "element_shape.load", +]; + +/// The emitted text the fast clone owns: from its cond block to the slow +/// clone's. +fn fast_clone_slice(ir: &str) -> &str { + let start = ir + .find("for.element_shape_fast.cond") + .expect("fast clone cond block"); + let end = ir[start..] + .find("for.element_shape_slow.cond") + .map(|off| start + off) + .unwrap_or(ir.len()); + &ir[start..end] +} + +#[test] +fn element_shape_versioned_loop_fires_for_the_7480_access_shape() { + let ir = emit(&element_shape_module( + vec![accumulate_stmt( + SUM_ID, + ARRAY_ID, + Expr::LocalGet(COUNTER_ID), + )], + None, + )); + for label in CLONE_LABELS { + assert!( + ir.contains(label), + "expected the #7480 element-shape versioned loop to be lowered, but \ + `{label}` is absent from the emitted IR — the matcher in \ + stmt/element_shape_loop.rs declined" + ); + } + // The guard must consult the LIVE runtime invariant (#7501: a static + // declaration gets revoked at runtime), not a compile-time assumption. + assert!( + ir.contains("js_array_ensure_element_shape"), + "the preheader must call the live element-shape query" + ); + // The slow clone survives as the cold arm — a hoisted guard with no + // fallback is worse than no hoist. + assert!(ir.contains("for.element_shape_slow.cond")); + + let fast = fast_clone_slice(&ir); + // The whole point: the fast clone contains NO call at all. That is the + // revocation argument (call-free ⇒ no funnel can revoke the invariant and + // no allocation can move the array), so it is asserted directly rather + // than by naming individual guard symbols. + assert!( + !fast.contains(" call "), + "the fast clone must be call-free; found a call in:\n{fast}" + ); + assert!( + !fast.contains("@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"), + "the per-access inline-guard gate must be hoisted into the preheader" + ); + assert!( + !fast.contains("js_array_get_f64"), + "the element-read tier must be gone from the fast clone" + ); +} + +/// SABOTAGE (clone selection): a body that STORES cannot be cloned — a store +/// is the primary way to revoke the invariant mid-loop, and admitting one +/// would be a miscompile rather than a slow path. +#[test] +fn element_shape_versioned_loop_declines_a_body_that_stores() { + let ir = emit(&element_shape_module( + vec![ + accumulate_stmt(SUM_ID, ARRAY_ID, Expr::LocalGet(COUNTER_ID)), + Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(ARRAY_ID)), + index: Box::new(Expr::LocalGet(COUNTER_ID)), + value: Box::new(Expr::Number(1.0)), + }), + ], + None, + )); + for label in CLONE_LABELS { + assert!( + !ir.contains(label), + "a body containing an element STORE must not be cloned, but \ + `{label}` was emitted — mid-loop revocation would make the \ + specialized body read a revoked array" + ); + } +} + +/// SUBCLASS SAFETY (#7573/#7603). An element class with a base class does not +/// get the clone: its packed slot indices are not self-describing, and an +/// `extends Array` base is the exact header-overlay hazard those issues fixed. +/// The receiver-side brand lives in the emitted IR +/// (`element_shape.loop.preheader.brand`) and is covered by the gap test. +#[test] +fn element_shape_versioned_loop_declines_a_subclass_element_type() { + let ir = emit(&element_shape_module( + vec![accumulate_stmt( + SUM_ID, + ARRAY_ID, + Expr::LocalGet(COUNTER_ID), + )], + Some("Base"), + )); + for label in CLONE_LABELS { + assert!( + !ir.contains(label), + "an element class with a base must not be cloned, but `{label}` \ + was emitted" + ); + } +} + +/// The index must be the loop counter itself. `keep[j + 1]` reads outside the +/// range the preheader's `length >= bound` check covers. +#[test] +fn element_shape_versioned_loop_declines_an_offset_index() { + let ir = emit(&element_shape_module( + vec![accumulate_stmt( + SUM_ID, + ARRAY_ID, + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(COUNTER_ID)), + right: Box::new(Expr::Integer(1)), + }, + )], + None, + )); + for label in CLONE_LABELS { + assert!( + !ir.contains(label), + "an offset index must not be cloned, but `{label}` was emitted" + ); + } +} + +/// SIZE DISCIPLINE (#7566's precedent): a program with no qualifying loop must +/// emit exactly zero of the clone's blocks, so the feature costs +0 bytes +/// where it does not apply. +#[test] +fn a_program_with_no_qualifying_loop_pays_nothing() { + let mut m = Module::new("element_shape_none.ts"); + m.classes = vec![node_class(None)]; + m.init = vec![ + Stmt::Let { + id: SUM_ID, + name: "sum".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Number(0.0)), + }, + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: COUNTER_ID, + name: "j".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(COUNTER_ID)), + right: Box::new(Expr::Integer(1_000_000)), + }), + update: Some(Expr::Update { + id: COUNTER_ID, + op: UpdateOp::Increment, + prefix: false, + }), + body: vec![Stmt::Expr(Expr::LocalSet( + SUM_ID, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(SUM_ID)), + right: Box::new(Expr::Integer(1)), + }), + ))], + }, + ]; + m.init_kind = ModuleInitKind::Eager; + let ir = emit(&m); + for label in CLONE_LABELS { + assert!( + !ir.contains(label), + "a module with no qualifying loop must not emit `{label}`" + ); + } + // The runtime declaration is emitted for every module (an unused + // `declare` costs nothing in the object); what must be absent is a CALL. + assert!( + !ir.contains("call i32 @js_array_ensure_element_shape"), + "a module with no qualifying loop must not call the guard" + ); +} diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index be7915d55f..0ecbf67589 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -1818,7 +1818,7 @@ fn lower_packed_f64_range_versioned_for( /// `errors` runtime call, accessor-ish names, …). A tracked field must not /// collide or the fast clone's access would lower through a different — /// possibly calling — path, breaking the call-free guarantee. -const CLASS_FIELD_LOOP_PROP_DENYLIST: &[&str] = &[ +pub(super) const CLASS_FIELD_LOOP_PROP_DENYLIST: &[&str] = &[ "length", "errors", "size", @@ -1837,7 +1837,7 @@ const CLASS_FIELD_LOOP_PROP_DENYLIST: &[&str] = &[ /// #5093: class names with dedicated (builtin-flavored) branches in the /// property lowering dispatch; a user class sharing one of these names could /// be intercepted before the class-field diamond. -const CLASS_FIELD_LOOP_CLASS_DENYLIST: &[&str] = &[ +pub(super) const CLASS_FIELD_LOOP_CLASS_DENYLIST: &[&str] = &[ "Headers", "URLPattern", "ClientRequest", @@ -4569,7 +4569,7 @@ fn dynamic_bound_private_counter_is_safe( advanced_by_increment && !stmts_mutate_local(body, counter_id) } -fn emit_js_value_is_number(ctx: &mut FnCtx<'_>, value: &str) -> String { +pub(super) fn emit_js_value_is_number(ctx: &mut FnCtx<'_>, value: &str) -> String { let n_bits = ctx.block().bitcast_double_to_i64(value); let tag = ctx.block().and( I64, @@ -4663,10 +4663,19 @@ pub(crate) fn lower_for( return Ok(()); } + // repsel #7480 / #5093: `sum += arr[i].field` over an array carrying the + // homogeneous element-shape invariant. Tried last, so every array-shaped + // matcher above keeps precedence on the loops it already owns. + if super::element_shape_loop::lower_element_shape_versioned_for( + ctx, init, condition, update, body, + )? { + return Ok(()); + } + lower_for_after_init(ctx, init, condition, update, body, "for") } -fn lower_for_after_init( +pub(super) fn lower_for_after_init( ctx: &mut FnCtx<'_>, init: Option<&Stmt>, condition: Option<&perry_hir::Expr>, @@ -4685,7 +4694,7 @@ fn lower_for_after_init( /// value must dominate the block this is emitted from — only the fast /// preheader of the range-versioned loop qualifies. #[allow(clippy::too_many_arguments)] -fn lower_for_after_init_with_i32_bound( +pub(super) fn lower_for_after_init_with_i32_bound( ctx: &mut FnCtx<'_>, init: Option<&Stmt>, condition: Option<&perry_hir::Expr>, @@ -5831,11 +5840,11 @@ fn local_bound_storage_accessible(ctx: &crate::expr::FnCtx<'_>, bound_id: u32) - /// `class_field_versioned_loop_fires_for_module_scope_counter` is the assertion /// that the lowering is live; keep it that way (CLAUDE.md, "a gate must assert /// its subject was live"). -fn local_has_readable_slot(ctx: &crate::expr::FnCtx<'_>, local_id: u32) -> bool { +pub(super) fn local_has_readable_slot(ctx: &crate::expr::FnCtx<'_>, local_id: u32) -> bool { ctx.locals.contains_key(&local_id) || ctx.local_slot_reps.contains_key(&local_id) } -fn local_bound_is_loop_invariant( +pub(super) fn local_bound_is_loop_invariant( cond: &perry_hir::Expr, update: Option<&perry_hir::Expr>, body: &[perry_hir::Stmt], @@ -5887,7 +5896,7 @@ fn length_source_can_use_static_i32(ctx: &crate::expr::FnCtx<'_>, source: &Lengt } } -fn loop_counter_bounds_are_safe( +pub(super) fn loop_counter_bounds_are_safe( ctx: &crate::expr::FnCtx<'_>, counter_id: u32, update: Option<&perry_hir::Expr>, @@ -5898,7 +5907,10 @@ fn loop_counter_bounds_are_safe( && !stmts_mutate_local(body, counter_id) } -fn loop_counter_entry_i32_range_is_safe(init: Option<&perry_hir::Stmt>, counter_id: u32) -> bool { +pub(super) fn loop_counter_entry_i32_range_is_safe( + init: Option<&perry_hir::Stmt>, + counter_id: u32, +) -> bool { use perry_hir::{Expr, Stmt}; let Some(Stmt::Let { id, diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 234514f238..2efdefe3d8 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -14,6 +14,9 @@ use crate::types::DOUBLE; #[cfg(test)] mod class_field_loop_tests; mod counter_range; +mod element_shape_loop; +#[cfg(test)] +mod element_shape_loop_tests; mod if_stmt; mod let_buffer_views; mod let_stmt; diff --git a/crates/perry-runtime/src/array/element_shape.rs b/crates/perry-runtime/src/array/element_shape.rs index ed262745d5..07b75bf89a 100644 --- a/crates/perry-runtime/src/array/element_shape.rs +++ b/crates/perry-runtime/src/array/element_shape.rs @@ -610,11 +610,18 @@ pub extern "C" fn js_array_element_shape_check( } } -// NOTE — no `keepalive-anchors` `#[used]` statics here, deliberately. -// `keepalive-anchors` is a DEFAULT feature, so an anchor would pin these -// five functions into every shipped binary while nothing calls them: the -// exact dead-strip defeat the hello-size campaign traced its regression to. -// The consumer PR adds an anchor for whichever symbol it actually emits. +// NOTE — exactly ONE `keepalive-anchors` `#[used]` static, deliberately. +// `keepalive-anchors` is a DEFAULT feature, so an anchor pins its symbol into +// every shipped binary: anchoring all five would be the dead-strip defeat the +// hello-size campaign traced its regression to. #5093's element-shape +// versioned-loop clone emits a call to exactly one of them +// (`js_array_ensure_element_shape`, from the loop preheader), so that one — +// and only that one — is anchored. The other four stay unanchored and +// dead-strippable until something emits a call to them. +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_ARRAY_ENSURE_ELEMENT_SHAPE: extern "C" fn(*mut ArrayHeader) -> i32 = + js_array_ensure_element_shape; #[cfg(test)] pub(crate) fn test_element_shape_record_exists(owner: usize) -> bool { diff --git a/test-files/test_gap_repsel_element_shape_loop_clone.ts b/test-files/test_gap_repsel_element_shape_loop_clone.ts new file mode 100644 index 0000000000..80f89979c5 --- /dev/null +++ b/test-files/test_gap_repsel_element_shape_loop_clone.ts @@ -0,0 +1,267 @@ +// repsel #7480 / #5093: the element-shape versioned loop clone. +// +// `for (let j = 0; j < n; j++) sum += keep[j].v` is cloned behind a preheader +// guard on the per-array homogeneous element-shape invariant (#7496). Every +// case below is a way that guard, or the clone's residual per-element check, +// must decline — a stale specialized body reading a revoked array is a +// MISCOMPILE, not a slow path, which is why each hazard gets a case here and +// not just a codegen unit test. + +class Node { + v: number; + w: number; + constructor(v: number, w: number) { + this.v = v; + this.w = w; + } +} + +class Other { + v: string; + constructor(v: string) { + this.v = v; + } +} + +function sumField(rows: Node[], n: number): number { + let sum = 0; + for (let j = 0; j < n; j++) { + sum += rows[j].v; + } + return sum; +} + +// --------------------------------------------------------------------------- +// 1. The hot shape. Homogeneous array, every element the same class. +// --------------------------------------------------------------------------- +const keep: Node[] = []; +for (let i = 0; i < 64; i++) { + keep.push(new Node(i, i * 2)); +} +console.log("hot:", sumField(keep, keep.length)); + +// Re-entering the loop must re-verify: the O(1) confirm path is what runs +// from the second visit on. +console.log("hot-again:", sumField(keep, keep.length)); + +// --------------------------------------------------------------------------- +// 2. Empty array — the invariant declines a vacuous proof, so the clone must +// not be entered and the loop must simply run zero times. +// --------------------------------------------------------------------------- +const empty: Node[] = []; +console.log("empty:", sumField(empty, empty.length)); + +// --------------------------------------------------------------------------- +// 3. Shape mismatch — the array is homogeneous, but at the WRONG class. +// --------------------------------------------------------------------------- +const others: Other[] = [new Other("a"), new Other("b")]; +let mixedOut = ""; +for (let j = 0; j < others.length; j++) { + mixedOut += others[j].v; +} +console.log("wrong-class:", mixedOut); + +// A genuinely heterogeneous array: no proof exists at all. +const hetero: Node[] = [new Node(1, 1), new Node(2, 2)]; +(hetero as unknown as unknown[])[1] = { v: 40, w: 0 }; +let heteroOut = ""; +for (let j = 0; j < hetero.length; j++) { + heteroOut += String(hetero[j].v) + ","; +} +console.log("hetero:", heteroOut); + +// --------------------------------------------------------------------------- +// 4. Holes / undefined elements. A `TAG_HOLE` store funnels through the same +// hook that maintains the invariant, so the proof is revoked and the reads +// must observe JavaScript's `undefined`. +// --------------------------------------------------------------------------- +const holed: Node[] = [new Node(1, 1), new Node(2, 2), new Node(3, 3)]; +delete (holed as unknown as unknown[])[1]; +let holeOut = ""; +for (let j = 0; j < holed.length; j++) { + const row = holed[j] as Node | undefined; + holeOut += row === undefined ? "_" : String(row.v); +} +console.log("holes:", holeOut); + +const sparse: Node[] = new Array(3) as Node[]; +sparse[0] = new Node(7, 7); +let sparseOut = ""; +for (let j = 0; j < sparse.length; j++) { + const row = sparse[j] as Node | undefined; + sparseOut += row === undefined ? "_" : String(row.v); +} +console.log("sparse:", sparseOut); + +// --------------------------------------------------------------------------- +// 5. MID-LOOP REVOCATION. The store happens inside the loop that reads, so a +// guard hoisted to the preheader would be stale from the second iteration +// on. The matcher declines any body containing a store; this asserts the +// observable result is unchanged either way. +// --------------------------------------------------------------------------- +const revoked: Node[] = []; +for (let i = 0; i < 8; i++) { + revoked.push(new Node(i, i)); +} +let revokedOut = ""; +for (let j = 0; j < revoked.length; j++) { + // Per-element rather than a running sum: a single NaN would swallow every + // later read, and a miscompile that read the poisoned slot as a raw double + // would then be indistinguishable from the correct answer. + revokedOut += String(revoked[j].v) + ","; + if (j === 2) { + // Replaces a shaped element with a plain number: the invariant is + // revoked from here on, and the remaining reads must NOT take a + // specialized path. + (revoked as unknown as unknown[])[5] = 100; + } +} +console.log("mid-loop-store:", revokedOut); + +// Revocation through a CALL made by the body — the same hazard one level of +// indirection away. +const viaCall: Node[] = []; +for (let i = 0; i < 8; i++) { + viaCall.push(new Node(i, i)); +} +function poison(rows: Node[], at: number): number { + (rows as unknown as unknown[])[at] = 500; + return 0; +} +let viaCallOut = ""; +for (let j = 0; j < viaCall.length; j++) { + viaCallOut += String(viaCall[j].v + (j === 1 ? poison(viaCall, 6) : 0)) + ","; +} +console.log("revoke-via-call:", viaCallOut); + +// Revocation BETWEEN two entries of the same loop: the first sweep is +// specialized, the mutation retires the proof, the second sweep must not be. +const between: Node[] = []; +for (let i = 0; i < 16; i++) { + between.push(new Node(i, i)); +} +console.log("between-1:", sumField(between, between.length)); +(between as unknown as unknown[])[4] = 1000; +let betweenOut = ""; +for (let j = 0; j < between.length; j++) { + betweenOut += String(between[j].v) + ","; +} +console.log("between-2:", betweenOut); + +// --------------------------------------------------------------------------- +// 6. SUBCLASS RECEIVER (#7573/#7603). An `Array` subclass instance is a plain +// ObjectHeader whose fields overlay ArrayHeader's, so the clone must be +// reachable only behind a genuine GC_TYPE_ARRAY brand. +// --------------------------------------------------------------------------- +class NodeList extends Array {} + +const sub: Node[] = new NodeList() as Node[]; +sub.push(new Node(3, 3)); +sub.push(new Node(4, 4)); +sub.push(new Node(5, 5)); +console.log("subclass:", sumField(sub, sub.length), sub.length); + +// The same receiver read through a plainly-typed parameter, which is the +// binding form #7603 found in the wild. +function subclassSum(rows: Node[]): number { + let sum = 0; + for (let j = 0; j < rows.length; j++) { + sum += rows[j].v; + } + return sum; +} +console.log("subclass-param:", subclassSum(sub)); + +// --------------------------------------------------------------------------- +// 7. Residual per-element hazards the ARRAY-level invariant does not cover: +// the object's own keys array, its descriptor flag, and its typed-layout +// intact bit. Each of these leaves the array homogeneous at the same class +// id, so only the clone's per-element check can catch them. +// --------------------------------------------------------------------------- +const downgraded: Node[] = []; +for (let i = 0; i < 6; i++) { + downgraded.push(new Node(i, i)); +} +// A non-number into a `number`-typed field downgrades that object's raw-f64 +// layout while class_id and the array's homogeneity are unchanged. +(downgraded[3] as unknown as Record).v = "nine"; +let downOut = ""; +for (let j = 0; j < downgraded.length; j++) { + downOut += String(downgraded[j].v) + ","; +} +console.log("layout-downgrade:", downOut); + +const deleted: Node[] = []; +for (let i = 0; i < 6; i++) { + deleted.push(new Node(i, i)); +} +// `delete` compacts the packed slots and installs a fresh keys array while +// PRESERVING class_id — a class-id match alone does not prove the layout. +delete (deleted[2] as unknown as Record).w; +let delOut = ""; +for (let j = 0; j < deleted.length; j++) { + delOut += String(deleted[j].v) + ","; +} +console.log("deleted-field:", delOut); + +const accessor: Node[] = []; +for (let i = 0; i < 6; i++) { + accessor.push(new Node(i, i)); +} +Object.defineProperty(accessor[4], "v", { + get() { + return 99; + }, + configurable: true, +}); +let accOut = ""; +for (let j = 0; j < accessor.length; j++) { + accOut += String(accessor[j].v) + ","; +} +console.log("own-accessor:", accOut); + +const frozen: Node[] = []; +for (let i = 0; i < 6; i++) { + frozen.push(new Node(i, i)); +} +Object.freeze(frozen[1]); +console.log("frozen-element:", sumField(frozen, frozen.length)); + +// --------------------------------------------------------------------------- +// 8. Prototype surgery on the element class retires every outstanding proof. +// --------------------------------------------------------------------------- +const protoTouched: Node[] = []; +for (let i = 0; i < 8; i++) { + protoTouched.push(new Node(i, i)); +} +console.log("proto-before:", sumField(protoTouched, protoTouched.length)); +Object.defineProperty(Node.prototype, "extra", { + get() { + return 1; + }, + configurable: true, +}); +console.log("proto-after:", sumField(protoTouched, protoTouched.length)); + +// --------------------------------------------------------------------------- +// 9. Length changes: the record pins the length it was verified against, so +// every length-changing mutation invalidates the proof on the next query. +// --------------------------------------------------------------------------- +const grow: Node[] = [new Node(1, 1), new Node(2, 2)]; +console.log("grow-1:", sumField(grow, grow.length)); +grow.push(new Node(3, 3)); +console.log("grow-2:", sumField(grow, grow.length)); +grow.pop(); +console.log("grow-3:", sumField(grow, grow.length)); +grow.length = 1; +console.log("grow-4:", sumField(grow, grow.length), grow.length); + +// A bound larger than the array: the preheader requires the verified prefix to +// cover the whole index range, so the out-of-range reads stay generic. +const shortArr: Node[] = [new Node(5, 5), new Node(6, 6)]; +let overOut = ""; +for (let j = 0; j < 4; j++) { + const row = shortArr[j] as Node | undefined; + overOut += row === undefined ? "_" : String(row.v); +} +console.log("bound-past-length:", overOut);