diff --git a/CLAUDE.md b/CLAUDE.md index 2f56f10f27..a7c97bf047 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.1548 +**Current Version:** 0.5.1549 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index da894067f5..f270a9084f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5690,7 +5690,7 @@ checksum = "4b2094dda4d997bf73a372cb660d02e9abdb0a13cbf834ddd2b2f8847bffe2cf" [[package]] name = "perry" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "base64 0.22.1", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-dispatch", "serde", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "cc", "libc", @@ -5771,7 +5771,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "aho-corasick", "anyhow", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "perry-hir", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "perry-hir", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "perry-dispatch", @@ -5814,7 +5814,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "perry-hir", @@ -5822,7 +5822,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "base64 0.22.1", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "perry-hir", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "async-trait", @@ -5870,14 +5870,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "serde", "serde_json", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1548" +version = "0.5.1549" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "clap", @@ -5911,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "block2", "objc2", @@ -5921,7 +5921,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "argon2", "perry-ffi", @@ -5930,7 +5930,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "reqwest", @@ -5939,7 +5939,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "bcrypt", "perry-ffi", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "rusqlite", @@ -5955,7 +5955,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "scraper", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "perry-runtime", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "chrono", "cron", @@ -5981,7 +5981,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "chrono", "perry-ffi", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "rust_decimal", @@ -5997,7 +5997,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "serde_json", @@ -6005,7 +6005,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -6013,7 +6013,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "perry-runtime", @@ -6021,14 +6021,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "bytes", "http-body-util", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "bytes", "lazy_static", @@ -6059,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "base64 0.22.1", "bytes", @@ -6091,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "lazy_static", "perry-ffi", @@ -6101,7 +6101,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "lru", "perry-ffi", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "chrono", "perry-ffi", @@ -6129,7 +6129,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "bson", "futures-util", @@ -6141,7 +6141,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "chrono", "perry-ffi", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "nanoid", "perry-ffi", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "bytes", "perry-ffi", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "lettre", "perry-ffi", @@ -6206,7 +6206,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "notify", "perry-ffi", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "printpdf", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "sqlx", @@ -6235,7 +6235,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "perry-runtime", @@ -6244,7 +6244,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "governor", "perry-ffi", @@ -6252,7 +6252,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "fast_image_resize", "image", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "lazy_static", "perry-ffi", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "perry-ffi", @@ -6292,7 +6292,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "perry-runtime", @@ -6301,7 +6301,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "uuid", @@ -6309,7 +6309,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "perry-validation", @@ -6318,7 +6318,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "futures-util", "lazy_static", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "brotli", "flate2", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6351,7 +6351,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "perry-api-manifest", @@ -6372,11 +6372,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1548" +version = "0.5.1549" [[package]] name = "perry-parser" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "perry-diagnostics", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perex", "regex", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "ahash", "anyhow", @@ -6458,14 +6458,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6560,14 +6560,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "perry-hir", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "perry-ffi", "perry-ui-model", @@ -6584,7 +6584,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "base64 0.22.1", "itoa", @@ -6602,7 +6602,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "rand 0.10.2", "serde", @@ -6612,7 +6612,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "base64 0.22.1", "block2", @@ -6652,7 +6652,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "base64 0.22.1", "block2", @@ -6669,7 +6669,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1548" +version = "0.5.1549" [[package]] name = "perry-ui-test" @@ -6680,11 +6680,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1548" +version = "0.5.1549" [[package]] name = "perry-ui-tvos" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "base64 0.22.1", "block2", @@ -6701,7 +6701,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "base64 0.22.1", "block2", @@ -6718,7 +6718,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "block2", "libc", @@ -6732,7 +6732,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "base64 0.22.1", "libc", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "base64 0.22.1", "libc", @@ -6764,7 +6764,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "anyhow", "base64 0.22.1", @@ -6780,7 +6780,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "idna", "regex", @@ -6790,7 +6790,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1548" +version = "0.5.1549" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 022d212597..5f937808b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1548" +version = "0.5.1549" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10098-json-lazy-array-movable-and-brand.md b/changelog.d/10098-json-lazy-array-movable-and-brand.md new file mode 100644 index 0000000000..f016f012a7 --- /dev/null +++ b/changelog.d/10098-json-lazy-array-movable-and-brand.md @@ -0,0 +1,37 @@ +### Performance + +- **Lazy JSON arrays are collectable by a minor, and indexed reads no longer re-classify + the receiver (#10098, #10118).** + + Two independent costs on the same object. A lazy cluster was born into old-gen and + pinned there, so a **dead** one held its whole element graph live through the + remembered set until a full collection — which on `records_array_16k:scan` never + arrived: the arena rebaselined its own trigger 134M->268M->536M->1073M while + `old_in_use` climbed past 48 MB, and every minor reported `survival_permille=996`, + `copied_objects=0`, `freed_bytes=0`. + + `GC_TYPE_LAZY_ARRAY` was pinned for two concrete reasons, both removed the way + `GC_TYPE_REGEXP` removed its own: `json_tape_store` keyed a tape by its owner's + address (now rekeyed through `json_tape_store::owner_moved` + + `GcMoveHookKind::LazyArrayTape`), and the copying minor's flip ran no per-object + finalize hook, so a header dying young leaked its tape (now + `finalize_dead_copied_minor_from_space_lazy_tapes`, wired in beside + map/set/errors/regex). The cluster's generation is still decided ONCE, by cache size + against the pointer-bearing threshold, so #7546's rule that header, cache and bitmap + share a generation holds; large clusters stay old exactly as before. + + Separately, the indexed inline cache's brand check rejected `GC_TYPE_LAZY_ARRAY` + outright, forcing every read through four layers of re-classification. The brand test + now decides on the **tag alone**, with the kind and index guards moved into their own + block, and the shared pointer proof is computed once in the entry block instead of per + tier. + + | row | before | after | vs Node | vs Bun | + |---|---:|---:|---:|---:| + | `records_array_16k:scan` peak RSS | 205 MiB | **51 MiB** | below | below | + | `records_array_1m:scan` peak RSS | 159 MiB | **75 MiB** | below | below | + | `records_array_16k:scan` CPU | — | **-10.1%** | | | + | `records_array_1m:scan` CPU | — | **-5.5%** | | | + | `records_array_20m:repeat` | — | — | | **0.93x** | + + The access window showed zero separated regressions. diff --git a/changelog.d/10123-json-wide-object-birth-generation.md b/changelog.d/10123-json-wide-object-birth-generation.md new file mode 100644 index 0000000000..a8a5adf933 --- /dev/null +++ b/changelog.d/10123-json-wide-object-birth-generation.md @@ -0,0 +1,72 @@ +### Fixed + +- **Repeated `JSON.parse` of a wide object held 5x the memory it needed (#10123).** + Peak RSS on `wide_1m:parse` (a 50,000-field document, 64 parses) was **220 MiB against a + live set of ~0** — Node holds 136 MiB and Bun 71 MiB on the same workload. + + `arena/allocators.rs` already states the failure mode: a large pointer-bearing object is + stamped `GC_FLAG_TENURED`, and a minor never sweeps old-gen, so its cost "is not its own + bytes, it is every object it can reach, held live through the remembered set by a container + nothing refers to any more". A wide document's **property storage** and its **shape-keys + array** are exactly that container. Above 16,384 fields each crosses + `LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES` (128 KB), is born tenured, and then holds its + whole field or key set live long after the document itself is dead. + + Confirmed to the byte with `PERRY_GC_CENSUS`, fixtures straddling 131,072 bytes at 64 parses + (retained shape-keys arrays / live / peak RSS): + + | fields | keys array | retained | live | peak RSS | + |---:|---:|---:|---:|---:| + | 16,300 | 130,416 B | 0 | 0.0 MB | 29 MiB | + | 16,500 | 132,016 B | 30 | 22.7 MB | 98 MiB | + | 50,000 | 400,016 B | 15 | 34.3 MB | 177 MiB | + + The census named it outright: `shape_keys_arrays {count: 15}`, `slot_tags {string: 750000}` + — 15 arrays x 50,000 keys = 750,000 live strings. The same binary under `PERRY_GEN_GC=0` + reports **336 bytes live**. + + Fixed by admitting a wide JSON document's own storage into the nursery past that threshold, + for as long as the copier can still move it (`JsonWideBirthScope`, ceiling 512 KB — half a + nursery block, inside `copying::MAX_YOUNG_MOVE_BYTES`). The check is reached only from the + cold large-object branch of `arena_alloc_gc`, behind a short-circuiting `&&`, so no + allocation hot path gains work. + + **`wide_1m:parse`: 220 MiB -> 40 MiB and 0.20s -> 0.15s** — below Node's 136 MiB and Bun's + 71 MiB, and faster than before. + + **Scoped and type-masked deliberately.** Raising the constant globally reaches the same + 40 MiB, but it also moves ordinary ARRAY element storage into the nursery, which other rows + do not need and cannot afford: `records_array_8m:scan` 643 -> 710 MiB with CPU 0.91s -> + 1.20s. Only a document's own object storage and its keys array are admitted. Measured in a + single binary across `records_array_8m:scan`, `records_array_20m:parse`, + `records_array_1m:scan`, `records_array_16k:scan` and `heterogeneous_1m:parse`, the scoped + change is **byte-identical to baseline on every one of them**. + + Three tests that hardcoded a field count and then asserted `pointer_in_old_gen` now derive + their width from the governing ceiling, so the old-gen path stays covered whatever that + constant is — previously they silently depended on it being 128 KB and failed on their + premise rather than their subject. + +- **Large JSON record arrays are allocated once, at their estimated size (#10123).** The + direct parser already pre-sized `[{...}]` arrays from `remaining_bytes / 96`, but clamped + the estimate at 16,384 slots — a 131,088-byte allocation, **16 bytes over** the + 131,072-byte pointer-bearing birth threshold. Every large record array was therefore born + old on its very first allocation and then doubled twice more in old-gen (131 → 262 → + 524 KB for 59,000 rows), and an old array of young records keeps them alive through the + remembered set after the document dies. On `records_object_8m:parse` + `remembered_set/array` was the origin of 98% of minor survivors, across three minors and no + full collection. + + The estimate is now used as-is: one allocation, admitted into the nursery when it fits the + JSON young-birth ceiling (raised to 768 KB, three quarters of a nursery block, so a 7.1 MB + document's 593 KB estimate qualifies) and born old in a single allocation past it. + Measured in one binary against the previous clamp, all 50 matrix cells: + + | row | before | after | + |---|---:|---:| + | `records_object_8m:parse` | 187 MiB / 167 ms | **118 MiB** / 206 ms | + | `records_array_20m:parse`, `:scan`, `:sparse` | 256 MiB | **240 MiB** | + | `records_object_20m:parse` | 256 MiB | **240 MiB** | + + `records_object_8m:parse` reaches parity with the better of Node and Bun on RSS (1.68× → + 1.05×) while its CPU stays at 0.88× of the better engine. No other cell moved outside noise. diff --git a/changelog.d/10147-batch-old-page-unregistration.md b/changelog.d/10147-batch-old-page-unregistration.md new file mode 100644 index 0000000000..ed1309c80f --- /dev/null +++ b/changelog.d/10147-batch-old-page-unregistration.md @@ -0,0 +1,3 @@ +### GC: batch dead old-object page unregistration per sweep step + +Sweeping a dead old-generation object removed it from the page-object index one object at a time: two `Vec` allocations, a deferral flush, and a linear `position` search in the page's object list before `swap_remove`. A full collection that frees whole pages of small objects was quadratic in objects per page (15.8% of all samples on `records_array_8m:scan`). The sweep now invalidates each dead header immediately, as before, and queues its page-index removal; a step's worth (flushed at 4,096 entries and at every step boundary) is removed in one pass per page with a sorted binary-search `retain`, and page metadata is decremented and refreshed once per page. diff --git a/changelog.d/10150-json-traversal-feedback.md b/changelog.d/10150-json-traversal-feedback.md new file mode 100644 index 0000000000..d8d251d3ad --- /dev/null +++ b/changelog.d/10150-json-traversal-feedback.md @@ -0,0 +1,3 @@ +### JSON.parse picks eager parsing when this thread's lazy arrays keep being traversed + +A top-level JSON array parses onto a validating tape and materializes elements on demand, which wins when a program reads a few elements but pays for two tokenizations when it then walks every element. A per-thread evidence score now decides the route for tape-eligible parses: each lazy array created counts against traversal, each traversal flip or completed in-order scan counts for it, and once traversal is the norm eligible parses go eagerly, with one in 16 still taking the tape so a program that stops traversing drifts back. Parse-only and sparse-access programs never leave the tape. `records_array_16k:scan` 1.23x -> 0.78x and `records_array_1m:scan` 1.11x -> 0.98x of the better of Node and Bun; other JSON matrix cells unchanged. diff --git a/changelog.d/10168-json-parse-depth-in-descent.md b/changelog.d/10168-json-parse-depth-in-descent.md new file mode 100644 index 0000000000..cb8b737c8e --- /dev/null +++ b/changelog.d/10168-json-parse-depth-in-descent.md @@ -0,0 +1,7 @@ +### perf(json): bound nesting inside the direct parser instead of pre-scanning every document + +`JSON.parse` re-read every byte of a direct-parsed document before parsing it, to decide whether the recursive descent could overflow the native stack (`nesting_depth_exceeds`, the 1000-level handoff to the heap-stack parser). The "already validated" shortcut that was meant to skip the re-scan on repeated parses lived on the string-token reuse cache, which is only populated for a source under 2 MB that contains one large string value, so no record document ever hit it: every parse of a 20 MB record array, of a record object of any size, and (since the traversal-feedback change) every eagerly re-routed scan paid a whole-document scan on top of the parse. `sample` attributed 6.2 % of `records_array_20m:roundtrip` to the scan alone. + +`DirectParser` now counts open containers as it descends (`enter_container`/`leave_container`, on every recursive entry including the shaped-record path and the typed top-level array) and aborts with `depth_exceeded` when the 1000th nested container would open. Valid documents never scan. A failed direct parse is re-routed to the heap-stack parser when it hit the bound or when the cold classifier says the document is deep, so malformed deep input keeps the exact error kinds it had (the cross-entry error-ordering test is unchanged). The only remaining pre-scan is the forced-tape-above-16 MB case, where an over-budget document must fail before its native tape is reserved. The `direct_depth_validated` cache flag and its two accessors are gone. + +Measured on the same tree (two builds, one self-contained worker per arm, interleaved best-of-3, `/usr/bin/time -l`, loaded shared host): direct-parse rows drop 10-13 % CPU (`records_array_20m` parse 164.6 → 145.7 ms, sparse 165.0 → 143.3, scan 169.2 → 148.3, roundtrip 195.8 → 186.2; `records_object_20m:parse` 166.0 → 144.1; `records_object_8m:parse` 209.2 → 186.5; `records_object_1m:parse` 172.6 → 152.9; the eagerly re-routed `records_array_16k`/`1m`/`8m` scan rows 133.9 → 119.9 / 170.9 → 154.2 / 145.3 → 131.3), the control rows are unchanged (lazy-tape `records_array_1m` parse 168.8 → 168.2, roundtrip 177.7 → 177.5; `wide_1m:parse` 173.9 → 173.8; `small_record:parse` 161.7 → 162.4), and peak RSS is identical on every row. diff --git a/changelog.d/10171-json-record-loop-clone.md b/changelog.d/10171-json-record-loop-clone.md new file mode 100644 index 0000000000..399aafa09d --- /dev/null +++ b/changelog.d/10171-json-record-loop-clone.md @@ -0,0 +1,76 @@ +Make the element-shape versioned loop clone (#7480 / #5093) fire for +`JSON.parse`'d record arrays. Loops of the shape +`for (let i = 0; i < count; i++) sum += rows[7].id` and +`for (let i = 0; i < count; i++) { const index = i % length; sum += rows[index].id; }` +over an `any`-typed parsed array now run in the call-free fast clone instead of +the generic element-read + field-read diamonds. + +The clone keyed every proof on a compile-time class, which structurally +excluded the case record-processing code is written against: a parsed record is +`class_id == 0` with an ordinary birth ShapeId, and `rows: any` resolves to no +class at all. Four separate things had to change, each of which independently +kept the clone dark: + +* **Runtime.** `array/element_shape.rs` refused `class_id == 0` outright, so no + parsed record array could ever carry an element-shape proof. Class 0 is now + admitted keyed on the exact ordinary ShapeId — strictly narrower than the + class-level identity, and necessarily so, since "same class" is vacuous when + the class is 0. New FFI: `js_array_ensure_element_shape_ordinary` (the + ShapeId for a class-0 proof, 0 for a class-keyed one — the two are + deliberately not interchangeable) and `js_shape_ordinary_inline_slot_for_key` + (the inline slot a plain ordinary shape assigns to a key, or -1). The key + crosses as the whole NaN-box, not a masked pointer, because a short property + name reaches the string pool as an SSO immediate whose masked low bits are + packed characters rather than an address. +* **The brand test ran before the head repair.** `JSON.parse` of a top-level + array in [1 KB, 16 MB] hands back a `GC_TYPE_LAZY_ARRAY` header, which the + preheader's `GC_TYPE_ARRAY` brand rejected — before the repair step that + would have materialized it. The repair now runs first and the brand is + applied to the repaired head. `js_array_refresh_local_head` is safe on an + unbranded value by construction: it resolves through `clean_arr_ptr`, which + returns null for every tracked non-array. +* **The residual per-element check required `GC_OBJ_TYPED_LAYOUT_INTACT`.** A + parsed record never has that bit — `object/json_construction.rs` finishes + every record with `layout_init_pointer_free` or `layout_mark_unknown`, and + both clear it — so the clone would have been emitted, entered, and then + side-exited on the first element of every loop. The shape-keyed residual + drops that conjunct and buys the same claim per read, from the value: the + loaded word is tag-tested as a Number and a string / boolean / null `id` + side-exits to the slow clone. +* **The index had to be the counter.** `rows[7]` and + `const d = i % n; rows[d]` are now admitted, each with its own preheader + bounds obligation (`length > k`, `1 <= m <= length`) so the clone still pays + no per-read bounds test. The derived binding is virtual inside the clone — + its `Let` emits one `srem i32` rather than the generic `%` lowering, which is + a runtime call and would delete the clone rather than slow it. The matcher + admits exactly one index form per loop. + +The revocation argument is unchanged: it never mentioned classes, and every +funnel that retires a class-keyed proof retires a shape-keyed one. Call-free +is still the whole admission test, enforced by the matcher and by the +post-emission scan of every block the clone owns. + +Measured with `benchmarks/json_performance/.work/fixtures/records_array_*.json` +and a `rows: any` access worker, five interleaved rounds, best of five, on one +compiler binary per arm. Checksums agree across all four engines. + +| cell | before | after | node 26.5.1 | bun 1.3.14 | +|---|---|---|---|---| +| 16k repeat | 5.98 | **4.29** | 3.21 | 3.55 | +| 16k sequential | 12.78 | **4.29** | 4.68 | 4.68 | +| 1m repeat | 5.96 | **4.29** | 4.03 | 4.12 | +| 1m sequential | 16.43 | **4.32** | 9.97 | 10.72 | +| 20m repeat | 5.45 | **4.29** | 3.58 | 5.01 | +| 20m sequential | 18.30 | **4.56** | 7.85 | 7.72 | + +(ns per iteration.) Instructions retired per iteration, the same loops minus a +zero-iteration run: repeat 116 -> 16, sequential 166-184 -> 24-25. The +wall-clock win is smaller than the instruction win because a 16-instruction +body is latency-bound on the element load, not instruction-bound. + +No-regression cells (`benchmarks/json_performance/worker.ts`, five interleaved +rounds): `heterogeneous_1m parse` -2.2%, `records_array_1m parse` +0.2%, +`records_array_1m sparse` +0.2%, and `records_array_16k scan` **-30.1%** — the +counter-indexed loop inside that cell is the same clone, over an array the +preheader now materializes in bulk instead of leaving lazy for per-element +reads. diff --git a/crates/perry-codegen/src/expr/element_shape_guard.rs b/crates/perry-codegen/src/expr/element_shape_guard.rs index 5db287d909..d2ec6ce903 100644 --- a/crates/perry-codegen/src/expr/element_shape_guard.rs +++ b/crates/perry-codegen/src/expr/element_shape_guard.rs @@ -81,6 +81,27 @@ const ELEM_HEADER_MASK: &str = "402686207"; // 0x1800_80FF /// not forwarded, no per-object descriptors, typed layout intact. const ELEM_HEADER_EXPECT: &str = "268435458"; // 0x1000_0002 +/// #10123: [`ELEM_HEADER_MASK`] without the typed-layout conjunct. +/// +/// A `JSON.parse`'d record has no typed layout and never will: +/// `object/json_construction.rs` finishes it with `layout_init_pointer_free` +/// or `layout_mark_unknown`, and BOTH clear `GC_OBJ_TYPED_LAYOUT_INTACT` +/// explicitly. Keeping the bit in the mask would side-exit every element of +/// every parsed record array — the clone would be emitted, entered, and then +/// leave the loop on its first read. +/// +/// What the bit bought the class-keyed arm was "the slot holds a raw +/// `double`". The shape-keyed arm buys that differently and per read: the +/// loaded word is NaN-boxed, so it is tested with the same Number-tag range +/// check the preheader applies to the accumulator (`emit_js_value_is_number`), +/// and a non-Number (a string `id`, a `null`, a boxed INT32) side-exits to the +/// slow clone. That is not weaker — it is the same claim, established from the +/// value instead of from a layout declaration. +const ELEM_HEADER_SHAPE_MASK: &str = "134250751"; // 0x0800_80FF +/// The value [`ELEM_HEADER_SHAPE_MASK`] must produce: `obj_type == +/// GC_TYPE_OBJECT`, not forwarded, no per-object descriptors. +const ELEM_HEADER_SHAPE_EXPECT: &str = "2"; // 0x0000_0002 + /// Where the fast clone's trip count comes from. /// /// The two arms differ in *which* fact the preheader has to prove. A bound the @@ -104,6 +125,61 @@ pub(crate) enum ElementShapeLoopTripCount<'a> { ArrayLength, } +/// #10123: which identity the preheader proves for the elements. +#[derive(Clone, Copy, Debug)] +pub(crate) enum ElementShapeGuardKind<'a> { + /// The original arm: every element's `ObjectHeader::class_id` is + /// `expected_class_id`, and the expected ShapeId is the class's canonical + /// one, loaded from the global beside `keys_global_name`. + Class { + expected_class_id: &'a str, + keys_global_name: &'a str, + }, + /// The shape arm: every element carries the SAME exact ordinary ShapeId, + /// whatever it is. This is what a class-less record array can prove, and + /// the shape id itself becomes the expected one — there is no class, so + /// there is no canonical keys global to load it from. `properties` are the + /// names whose inline slots the preheader resolves against that shape. + Shape { + properties: &'a std::collections::BTreeSet, + }, +} + +/// #10123: the preheader obligation that makes every index the clone reads +/// in-bounds, WITHOUT a per-read test. +/// +/// Each arm is the whole bounds argument for one [`super::ElementShapeIndex`] +/// spelling, discharged once. Getting one wrong is an out-of-bounds read, not +/// a slow path, which is why they are named after the index form rather than +/// folded into a single "check the index" helper. +#[derive(Clone, Copy, Debug)] +pub(crate) enum ElementShapeIndexBound<'a> { + /// `arr[j]`: the counter IS the index, so the trip-count obligation + /// (`length >= bound`, or `bound == length`) already covers every read. + FromTripCount, + /// `arr[k]`: one constant index, in range iff `length > k`. + Constant(i64), + /// `arr[j % m]`: `srem` of a non-negative counter by `m` lands in + /// `[0, m)`, so `m <= length` covers every read. `m` was materialized as a + /// nonzero, non-negative i32 before the guard. + Modulus(&'a str), +} + +/// Everything the fast clone needs from the preheader. +pub(crate) struct ElementShapeGuardOutputs { + /// Elements base pointer, derived after the last call in the preheader. + pub elements_base: String, + /// i32 SSA of the ShapeId every element must still carry per read. + pub expected_shape_id: String, + /// The accumulated `i1` the caller ANDs its own conditions into. + pub shape_ok: String, + /// The clone's i32 trip count. + pub bound_i32: String, + /// Shape-keyed only: property -> i64 SSA of its inline slot index, each + /// proven non-negative before the clone is reachable. + pub field_slots: std::collections::BTreeMap, +} + /// Emit the once-per-loop element-shape guard into the current block chain. /// /// Leaves `ctx.current_block` on an UNTERMINATED block holding the accumulated @@ -113,47 +189,66 @@ pub(crate) enum ElementShapeLoopTripCount<'a> { /// 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, in four steps and this order: +/// Sequencing is load-bearing, in five steps and this order: /// /// 1. the receiver is a heap pointer at all; -/// 2. the **brand** test, so the pointer handed to the runtime is known to be -/// a real array and not an `extends Array` instance (#7573/#7603); -/// 3. the **growth-forwarding repair** (#7480) — the binding may hold a stale -/// head, and steps below read `length` and the elements base off the raw -/// pointer, where a forwarding stub is not merely wrong but *plausibly* +/// 2. the **growth-forwarding repair** (#7480) — the binding may hold a stale +/// head, and the steps below read `length` and the elements base off the +/// raw pointer, where a forwarding stub is not merely wrong but *plausibly* /// wrong (see the block comment). It goes before the guard call, not after, /// because the refresh can itself allocate; +/// 3. the **brand** test, so the pointer handed to the runtime is known to be +/// a real array and not an `extends Array` instance (#7573/#7603); /// 4. the runtime guard call when static construction did not already prove -/// the class, and only THEN the elements base — derived from a fresh load of -/// the array's rooted slot, because either preceding helper can allocate -/// and an allocation can move the array, so a base derived before it could -/// be a from-space address. +/// the identity, plus (shape-keyed) one inline-slot query per tracked +/// property; +/// 5. and only THEN the elements base — derived from a fresh load of the +/// array's rooted slot, because any preceding helper can allocate and an +/// allocation can move the array, so a base derived before it could be a +/// from-space address. /// -/// Returns `(elements_base, expected_shape_id, shape_ok, bound_i32)`. +/// **Why the repair now precedes the brand** (#10123; it used to follow it). +/// `JSON.parse` of a top-level array hands back a `GC_TYPE_LAZY_ARRAY` header, +/// which the brand test rejects — so the clone declined the single most common +/// record array in the language before the repair that would have materialized +/// it ever ran. `js_array_refresh_local_head` is safe on an unbranded value by +/// construction: it tests `POINTER_TAG`, then `is_plausible_heap_addr`, then +/// resolves through `clean_arr_ptr`, which returns null for every tracked +/// non-array (an `extends Array` instance included) and materializes a lazy +/// one. Anything it cannot resolve comes back unchanged, and the brand test — +/// now applied to the REPAIRED head — rejects it exactly as before. pub(crate) fn emit_element_shape_loop_preheader_check( ctx: &mut FnCtx, array_local_id: u32, - expected_class_id: &str, - keys_global_name: &str, + kind: ElementShapeGuardKind<'_>, trip_count: ElementShapeLoopTripCount<'_>, + index_bound: ElementShapeIndexBound<'_>, slow_label: &str, statically_proven: bool, -) -> anyhow::Result<(String, String, String, String)> { - let brand_idx = ctx.new_block("element_shape.loop.preheader.brand"); +) -> anyhow::Result { let repair_idx = ctx.new_block("element_shape.loop.preheader.repair"); + let brand_idx = ctx.new_block("element_shape.loop.preheader.brand"); let query_idx = (!statically_proven).then(|| ctx.new_block("element_shape.loop.preheader.query")); + let slots_idx = match kind { + ElementShapeGuardKind::Shape { properties } if !properties.is_empty() => { + Some(ctx.new_block("element_shape.loop.preheader.slots")) + } + _ => None, + }; let deref_idx = ctx.new_block("element_shape.loop.preheader.deref"); - let brand_label = ctx.block_label(brand_idx); let repair_label = ctx.block_label(repair_idx); + let brand_label = ctx.block_label(brand_idx); let query_label = query_idx.map(|idx| ctx.block_label(idx)); + let slots_label = slots_idx.map(|idx| ctx.block_label(idx)); let deref_label = ctx.block_label(deref_idx); - let post_repair_label = query_label.as_deref().unwrap_or(&deref_label); + let post_query_label = slots_label.as_deref().unwrap_or(&deref_label); + let post_brand_label = query_label.as_deref().unwrap_or(post_query_label); // (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"); @@ -161,28 +256,10 @@ pub(crate) fn emit_element_shape_loop_preheader_check( 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 `class_id`/`parent_class_id` - // (the ShapeId)/`keys_array` (#8113). 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, &repair_label, slow_label); + blk.cond_br(&ok0, &repair_label, slow_label); } - // (2b) GROWTH-FORWARDING REPAIR (#7480). The binding may hold a *stale* + // (2) GROWTH-FORWARDING REPAIR (#7480). The binding may hold a *stale* // array head: `js_array_grow` allocates the larger array elsewhere and // leaves a forwarding stub at the old address, and only the bindings the // growing code itself wrote through are re-pointed. Every runtime entry @@ -204,7 +281,7 @@ pub(crate) fn emit_element_shape_loop_preheader_check( // BEFORE the query call, not after, because `js_array_refresh_local_head` // can allocate (a lazy array materializes inside `clean_arr_ptr`) — putting // it here keeps the "no call after the base is derived" invariant intact, - // and the write-back means step (4)'s re-load of the rooted slot picks up + // and the write-back means step (5)'s re-load of the rooted slot picks up // the repaired head no matter what the query call moved. ctx.current_block = repair_idx; { @@ -233,35 +310,118 @@ pub(crate) fn emit_element_shape_loop_preheader_check( let handler = blk.and(I64, &bitsr, crate::nanbox::POINTER_MASK_I64); let abover = blk.icmp_ugt(I64, &handler, HANDLE_BAND_TOP); let okr = blk.and(I1, &is_ptrr, &abover); - blk.cond_br(&okr, post_repair_label, slow_label); + blk.cond_br(&okr, &brand_label, slow_label); } - // (3) The live-header query for arrays whose construction is not statically - // contained. `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. Type declarations are - // never sufficient — #7501's lesson. The static arm is stronger: E1--E5 - // proves every dense slot is a fresh exact-class allocation for the whole - // native region. + // (3) 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 `class_id`/`parent_class_id` + // (the ShapeId)/`keys_array` (#8113). 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. + // + // It reads the REPAIRED head: a lazy-array header is not `GC_TYPE_ARRAY` + // and would fail here, which is the whole reason step (2) now runs first. + ctx.current_block = brand_idx; + { + let arrb = super::lower_expr(ctx, &perry_hir::Expr::LocalGet(array_local_id))?; + let blk = ctx.block(); + let bitsb = blk.bitcast_double_to_i64(&arrb); + let handleb = blk.and(I64, &bitsb, crate::nanbox::POINTER_MASK_I64); + let gt_addr = blk.sub(I64, &handleb, "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, post_brand_label, slow_label); + } + + // (4) The live-header query for arrays whose construction is not statically + // contained. `js_array_ensure_element_shape[_ordinary]` 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. Type + // declarations are never sufficient — #7501's lesson. The static arm is + // stronger: E1--E5 proves every dense slot is a fresh exact-class + // allocation for the whole native region. // // Deliberately re-loads the (now repaired) binding rather than reusing the // repair block's handle: `js_array_refresh_local_head` can allocate, so a // handle derived before it is a pre-move address. + let mut queried_shape_id: Option = None; if let Some(query_idx) = query_idx { ctx.current_block = query_idx; let arrq = super::lower_expr(ctx, &perry_hir::Expr::LocalGet(array_local_id))?; - { + let blk = ctx.block(); + let bitsq = blk.bitcast_double_to_i64(&arrq); + let handleq = blk.and(I64, &bitsq, crate::nanbox::POINTER_MASK_I64); + match kind { + ElementShapeGuardKind::Class { + expected_class_id, .. + } => { + let class_id = blk.call(I32, "js_array_ensure_element_shape", &[(I64, &handleq)]); + let cid_ok = blk.icmp_eq(I32, &class_id, expected_class_id); + blk.cond_br(&cid_ok, post_query_label, slow_label); + } + // #10123: there is no compile-time id to compare against — the + // answer IS the expected ShapeId. Zero means "no class-0 proof", + // which covers both "not homogeneous" and "homogeneous but + // class-keyed", and both take the slow clone. + ElementShapeGuardKind::Shape { .. } => { + let shape_id = blk.call( + I32, + "js_array_ensure_element_shape_ordinary", + &[(I64, &handleq)], + ); + let ok = blk.icmp_ne(I32, &shape_id, "0"); + blk.cond_br(&ok, post_query_label, slow_label); + queried_shape_id = Some(shape_id); + } + } + } + + // (4b) #10123: resolve each tracked property to an inline slot ONCE, + // against the shape the query just proved. A class-keyed clone bakes this + // in at compile time from the class's field list; a record array has no + // class, so the shape table answers instead. A `-1` (absent key, a + // class-kind or mutated shape, a key the shape spilled) declines the clone + // — never a guess at an offset. + let mut field_slots: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + if let (Some(slots_idx), ElementShapeGuardKind::Shape { properties }) = (slots_idx, kind) { + ctx.current_block = slots_idx; + let shape_id = queried_shape_id + .clone() + .expect("the shape-keyed arm always emits the runtime query"); + let mut all_ok: Option = None; + for property in properties { + let key_idx = ctx.strings.intern(property); + let key_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); let blk = ctx.block(); - let bitsq = blk.bitcast_double_to_i64(&arrq); - let handleq = blk.and(I64, &bitsq, crate::nanbox::POINTER_MASK_I64); - let class_id = blk.call(I32, "js_array_ensure_element_shape", &[(I64, &handleq)]); - let cid_ok = blk.icmp_eq(I32, &class_id, expected_class_id); - blk.cond_br(&cid_ok, &deref_label, slow_label); + // The WHOLE NaN-box, not a masked pointer: a short property name + // ("id") reaches the pool as an SSO immediate whose masked low bits + // are packed characters rather than an address. The runtime + // compares by content across both representations. + let key_box = blk.load(DOUBLE, &key_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let slot = blk.call( + I32, + "js_shape_ordinary_inline_slot_for_key", + &[(I32, &shape_id), (I64, &key_bits)], + ); + let ok = blk.icmp_sgt(I32, &slot, "-1"); + all_ok = Some(match all_ok { + Some(acc) => blk.and(I1, &acc, &ok), + None => ok, + }); + let slot64 = blk.sext(I32, &slot, I64); + field_slots.insert(property.clone(), slot64); } + let all_ok = all_ok.expect("a non-empty property set emits at least one query"); + ctx.block().cond_br(&all_ok, &deref_label, slow_label); } - // (4) Post-proof re-derivation. Everything from here to the end of the fast + // (5) Post-proof 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))?; @@ -283,27 +443,55 @@ pub(crate) fn emit_element_shape_loop_preheader_check( // `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`. + // the comparisons below are 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 (bound_i32, len_ok) = match trip_count { - ElementShapeLoopTripCount::Bound(bound) => { - (bound.to_string(), blk.icmp_uge(I32, &length, bound)) + let bound_i32 = match trip_count { + ElementShapeLoopTripCount::Bound(bound) => bound.to_string(), + ElementShapeLoopTripCount::ArrayLength => length.clone(), + }; + + // The TRIP-COUNT obligation, which exists only when the counter is also + // the index. + // + // #10123: this used to be unconditional, and that made the whole + // shape-keyed arm dead on its own benchmark. `for (i = 0; i < 1000000; + // i++) sum += rows[7].id` over a 7,600-element array asks the preheader to + // prove `length >= 1000000`, which is false — so the clone was emitted, + // was branched into by a `cond_br` the IR census could see, and was never + // once entered at run time. The counter is not an index here; the verified + // prefix has nothing to say about the trip count, and `ElementShapeIndex` + // carries its own obligation instead. + let trip_ok = match (trip_count, index_bound) { + (ElementShapeLoopTripCount::Bound(bound), ElementShapeIndexBound::FromTripCount) => { + Some(blk.icmp_uge(I32, &length, bound)) } + // A caller-materialized bound is already a non-negative i32 + // (`materialize_loop_i32`), and the counter indexes nothing. + (ElementShapeLoopTripCount::Bound(_), _) => None, // #7480 step 4: `for (j = 0; j < arr.length; j++)` — the trip count IS // the length this block just read, so "the verified prefix covers every // index" is true by construction and there is nothing to compare it - // against. What still has to be proven is that the u32 fits a - // non-negative i32: the clone's counter is an i32 and the emitted trip - // test is signed, so a length above `i32::MAX` would read as negative - // and run zero iterations while the slow clone ran billions. No such - // array is allocatable today (it would need 32 GB of element slots), - // which is exactly why the check is one `icmp` rather than a comment. - ElementShapeLoopTripCount::ArrayLength => { - let fits = blk.icmp_sgt(I32, &length, "-1"); - (length.clone(), fits) - } + // against. What still has to be proven, for EVERY index form, is that + // the u32 fits a non-negative i32: the clone's counter is an i32 and + // the emitted trip test is signed, so a length above `i32::MAX` would + // read as negative and run zero iterations while the slow clone ran + // billions. No such array is allocatable today (it would need 32 GB of + // element slots), which is exactly why the check is one `icmp` rather + // than a comment. + (ElementShapeLoopTripCount::ArrayLength, _) => Some(blk.icmp_sgt(I32, &length, "-1")), + }; + + // #10123: the INDEX obligation, one per spelling. `FromTripCount` adds + // nothing — the trip-count test above already covers every read — and the + // other two are compared UNSIGNED for the same reason: `length` is a `u32` + // read into an i32, so a hypothetical 2^31-element array must not read as + // negative and pass. + let index_ok = match index_bound { + ElementShapeIndexBound::FromTripCount => None, + ElementShapeIndexBound::Constant(k) => Some(blk.icmp_ugt(I32, &length, &k.to_string())), + ElementShapeIndexBound::Modulus(modulus) => Some(blk.icmp_uge(I32, &length, modulus)), }; // Logical elements base, including a consumed queue prefix. @@ -314,40 +502,65 @@ pub(crate) fn emit_element_shape_loop_preheader_check( // 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 shape_global = crate::typed_shape::shape_id_global_name_from_keys_global(keys_global_name); - let expected_shape_id = blk.load(I32, &format!("@{shape_global}")); + let expected_shape_id = match kind { + ElementShapeGuardKind::Class { + keys_global_name, .. + } => { + let shape_global = + crate::typed_shape::shape_id_global_name_from_keys_global(keys_global_name); + blk.load(I32, &format!("@{shape_global}")) + } + // The query's answer, which dominates this block. + ElementShapeGuardKind::Shape { .. } => queried_shape_id + .clone() + .expect("the shape-keyed arm always emits the runtime query"), + }; 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); + if let Some(trip_ok) = trip_ok { + acc = blk.and(I1, &acc, &trip_ok); + } + if let Some(index_ok) = index_ok { + acc = blk.and(I1, &acc, &index_ok); + } acc = blk.and(I1, &acc, &gate_ok); // No terminator: the caller branches after proving the clone call-free. - Ok((elements_base, expected_shape_id, acc, bound_i32)) + Ok(ElementShapeGuardOutputs { + elements_base, + expected_shape_id, + shape_ok: acc, + bound_i32, + field_slots, + }) } /// Emit one `arr[i].field` read inside the fast clone: bare element load, /// an optional residual per-element check, then a 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)`. +/// `idx_i32` must be an index the preheader discharged a bounds obligation for +/// (see [`ElementShapeIndexBound`]), and `fact` must be the fact the preheader +/// installed for this array. pub(crate) fn emit_element_shape_field_load( ctx: &mut FnCtx, fact: &super::ElementShapeLoopFact, idx_i32: &str, - field_index: u32, + field_slot: &super::ElementShapeFieldSlot, ) -> String { - let field_index_str = field_index.to_string(); let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let (slot_ty, slot_value) = match field_slot { + super::ElementShapeFieldSlot::Packed(index) => (I64, index.to_string()), + super::ElementShapeFieldSlot::Runtime(reg) => (I64, reg.clone()), + }; 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 + // is a POINTER_TAG object of the guarded identity, 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)]); @@ -364,8 +577,13 @@ pub(crate) fn emit_element_shape_field_load( // cannot come from the runtime 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 (mask, expect) = if fact.shape_keyed { + (ELEM_HEADER_SHAPE_MASK, ELEM_HEADER_SHAPE_EXPECT) + } else { + (ELEM_HEADER_MASK, ELEM_HEADER_EXPECT) + }; + let hdr_masked = blk.and(I32, &hdr, mask); + let hdr_ok = blk.icmp_eq(I32, &hdr_masked, expect); // #8113: the ShapeId moved from header offset 8 to 4. let sid_ptr = blk.gep(I8, &elem_ptr, &[(I64, "4")]); @@ -383,8 +601,28 @@ pub(crate) fn emit_element_shape_field_load( 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) + let field_ptr = blk.gep(DOUBLE, &fields_base, &[(slot_ty, &slot_value)]); + let value = blk.load(DOUBLE, &field_ptr); + + // #10123: the shape-keyed arm's representation check. + // + // A class-keyed clone reads a RAW double, licensed by + // `GC_OBJ_TYPED_LAYOUT_INTACT` in the residual mask above. A record's slot + // holds a NaN-boxed JSValue instead, and the two coincide exactly when the + // value is a Number — so the same tag-range test the preheader applies to + // the accumulator is applied here, per read, and a string / boolean / null + // `id` side-exits to the slow clone rather than being consumed as a raw + // double. Without it `sum += rows[i].id` over `{"id": "7"}` would add the + // bit pattern of a string pointer. + if fact.shape_keyed { + let is_number = crate::stmt::emit_js_value_is_number(ctx, &value); + let ok_idx = ctx.new_block("element_shape.number"); + let ok_label = ctx.block_label(ok_idx); + ctx.block() + .cond_br(&is_number, &ok_label, &fact.side_exit_label); + ctx.current_block = ok_idx; + } + value } #[cfg(test)] @@ -417,6 +655,46 @@ mod tests { "header expectation drifted" ); + // #10123's shape-keyed pair is the SAME three header facts with the + // typed-layout conjunct removed, derived here rather than restated so + // a drift in any shared constant moves both. + let shape_mask = obj_type_mask | forwarded | has_descriptors; + let shape_expect = u32::from(2u8) /* GC_TYPE_OBJECT */; + assert_eq!( + ELEM_HEADER_SHAPE_MASK, + shape_mask.to_string(), + "shape-keyed header mask drifted" + ); + assert_eq!( + ELEM_HEADER_SHAPE_EXPECT, + shape_expect.to_string(), + "shape-keyed header expectation drifted" + ); + // It must still reject the three facts it DOES cover, and must + // deliberately NOT depend on the typed-layout bit — a parsed record + // never has it, so a mask that kept it would side-exit every element. + assert_eq!(shape_expect & shape_mask, shape_expect); + assert_eq!( + (shape_expect | typed_intact) & shape_mask, + shape_expect, + "the shape-keyed mask must ignore the typed-layout bit" + ); + assert_ne!( + (shape_expect | forwarded) & shape_mask, + shape_expect, + "forwarded not rejected" + ); + assert_ne!( + (shape_expect | has_descriptors) & shape_mask, + shape_expect, + "descriptors not rejected" + ); + assert_ne!( + (shape_expect ^ 1) & shape_mask, + shape_expect, + "wrong obj_type not rejected" + ); + // Sabotage direction: the mask must actually reject each fact. let good = expect; assert_eq!(good & mask, expect); diff --git a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs index 6f989e12d8..4f3fd5c8db 100644 --- a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs +++ b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs @@ -106,15 +106,33 @@ pub(super) fn lower_inline_dyn_typed_array_get( // ABA-proof for a value held by live code: the arena rewrites `obj_type` // before it hands the address out again, and a live reference keeps the // typed array alive. + // The brand test is the FIRST thing every indexed read on an unknown + // receiver executes, and until #10118 it computed the whole guard set -- + // element kind, both index range checks, three ANDs -- before finding out + // the receiver was not a typed array at all. A `JSON.parse` array, and any + // ordinary Array behind an erased receiver, paid that on every element + // read forever. Decide on the tag alone and leave; the rest of the guard + // set is only meaningful once the tag says typed array. + let kind_guard_idx = ctx.new_block("tav.get.kind_guard"); + let kind_guard_label = ctx.block_label(kind_guard_idx); ctx.current_block = brand_idx; - let entry_guard = { + let is_typed_array = { let blk = ctx.block(); let obj_bits = blk.bitcast_double_to_i64(obj_box); let raw = blk.and(I64, &obj_bits, pointer_mask); let gc_type_addr = blk.sub(I64, &raw, "8"); let gc_type_ptr = blk.inttoptr(I64, &gc_type_addr); let gc_type = blk.load(I8, &gc_type_ptr); - let is_typed_array = blk.icmp_eq(I8, &gc_type, "11"); // GC_TYPE_TYPED_ARRAY + blk.icmp_eq(I8, &gc_type, "11") // GC_TYPE_TYPED_ARRAY + }; + ctx.block() + .cond_br(&is_typed_array, &kind_guard_label, &slow_label); + + ctx.current_block = kind_guard_idx; + let entry_guard = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(obj_box); + let raw = blk.and(I64, &obj_bits, pointer_mask); let kind_addr = blk.add(I64, &raw, "8"); let kind_ptr = blk.inttoptr(I64, &kind_addr); let kind_i8 = blk.load(I8, &kind_ptr); @@ -127,9 +145,7 @@ pub(super) fn lower_inline_dyn_typed_array_get( // result is never poison there. let idx_ge0 = blk.fcmp("oge", idx_d, "0.0"); let idx_lt = blk.fcmp("olt", idx_d, "4294967296.0"); - // AND-reduce all guards. - let g = blk.and(I1, &is_typed_array, &kind_ok); - let g = blk.and(I1, &g, &idx_ge0); + let g = blk.and(I1, &kind_ok, &idx_ge0); blk.and(I1, &g, &idx_lt) }; ctx.block().cond_br(&entry_guard, &fast_label, &slow_label); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index bcc1eb6f32..a0df121336 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -2096,6 +2096,76 @@ pub(crate) struct ClassFieldLoopFact { pub fields: std::collections::BTreeMap, } +/// #10123: where the fast clone's element index comes from. +/// +/// The class-keyed arm admits [`Self::Counter`] only — the preheader's +/// `length >= bound` check is then exactly "the verified prefix covers every +/// index the loop reads". The other two arms index somewhere the trip count +/// says nothing about, so each carries its OWN preheader obligation (see +/// `expr::element_shape_guard::ElementShapeIndexBound`) and the fast clone +/// still pays no per-read bounds test. +/// +/// The counter arm's obligation is also DROPPED for the other two, and that is +/// not an optimization: leaving it in made the whole shape-keyed arm dead on +/// its own benchmark, because `for (i = 0; i < 1000000; i++) sum += +/// rows[7].id` over a 7,600-element array asks it to prove `length >= +/// 1000000`. See the `trip_ok` comment in the guard emitter. +#[derive(Debug, Clone)] +pub(crate) enum ElementShapeIndex { + /// `arr[j]` — the counter, read from its canonical i32 slot. + Counter, + /// `arr[7]` — a compile-time constant in `0..=i32::MAX`. The preheader + /// proved `length > k`. + Constant(i64), + /// `const d = j % m; … arr[d]` — the shape every sequential-access loop + /// over a parsed record array is written in. + /// + /// `d` is VIRTUAL in the clone exactly like #7771's element binding: its + /// `Let` emits an `srem i32` into `slot` (`stmt/let_stmt.rs`) instead of + /// the generic `%` lowering, which would be a runtime call and would + /// therefore DELETE the clone rather than slow it. The preheader proved + /// `1 <= m <= length`, and the counter is non-negative, so + /// `srem(counter, m)` is in `[0, length)` with no per-read test. + DerivedMod { + /// The `const` local the body binds; nothing else may read it. + local_id: u32, + /// i32 SSA value of the modulus, materialized in the preheader. + modulus_i32: String, + /// Entry-block i32 alloca the clone writes the derived index to. + slot: String, + }, +} + +impl ElementShapeIndex { + /// Does computing this index read the counter's canonical i32 slot? + /// + /// `Constant` is a literal, so the clone never touches the counter at all + /// except for the trip test — which + /// `lower_for_after_init_with_i32_bound` already falls back to a double + /// compare for. This is why `for (i = 0; i < n; i++) sum += rows[7].id` + /// gets a clone: its counter is neither index-used nor i32-bounded, so + /// `stmt/let_stmt.rs` mints no i32 slot for it, and demanding one would + /// have declined the benchmark's own `repeat` shape. + /// + /// The matcher, the fact lookup and the field lowering must all ask this + /// same question: a lookup that answered `Some` for a read the lowering + /// then declined would hand `is_numeric_expr` a raw-double promise the + /// generic path does not keep. + pub(crate) fn needs_counter_i32_slot(&self) -> bool { + !matches!(self, ElementShapeIndex::Constant(_)) + } +} + +/// #10123: where one tracked property's value sits inside an element. +#[derive(Debug, Clone)] +pub(crate) enum ElementShapeFieldSlot { + /// Class-keyed: the compile-time packed field index. + Packed(u32), + /// Shape-keyed: an i64 SSA value produced once in the preheader by + /// `js_shape_ordinary_inline_slot_for_key`, proven non-negative there. + Runtime(String), +} + /// #5093 / repsel #7480: one fact per (array, counter, versioned loop) — the /// element-shape clone's licence to read `arr[i].field` with no guard. /// @@ -2111,12 +2181,32 @@ pub(crate) struct ClassFieldLoopFact { /// /// and the lowering proved the fast clone is call-free, so nothing can revoke /// the invariant or move the array while the clone runs. +/// +/// #10123 added a second, SHAPE-keyed arm. It proves the same thing about a +/// different identity: the preheader asks +/// `js_array_ensure_element_shape_ordinary` for the exact ordinary ShapeId +/// every element carries, which is what a `JSON.parse`'d record array (class +/// 0, an ordinary birth shape) can prove and a class id is not. The three +/// facts the clone then reads per element — the slot index, the expected +/// ShapeId, and that the loaded word really is a Number — all come from that +/// arm's preheader instead of from a compile-time class. #[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. + /// LocalId of the loop counter. It is the element index only when + /// [`Self::index`] is [`ElementShapeIndex::Counter`]; it is always the + /// local whose canonical i32 slot the clone reads. pub index_local_id: u32, + /// #10123: how the clone computes the element index. + pub index: ElementShapeIndex, + /// #10123: true when the preheader proved an exact ordinary ShapeId rather + /// than a class id. The per-element residual check then drops the + /// typed-layout conjunct (a parsed record's layout is + /// `GC_LAYOUT_UNKNOWN`/pointer-free, never a typed layout) and adds a + /// Number-tag test on the loaded word, because a shape proves which slot + /// holds `field` but not what representation the value in it has. + pub shape_keyed: bool, pub scope_id: u32, /// Class the preheader proved every element in the verified prefix has. pub class_name: String, @@ -2134,9 +2224,11 @@ pub(crate) struct ElementShapeLoopFact { /// the exact ShapeId, descriptor-free state, and raw-f64 layout for every /// field this clone reads. When true, no per-object residual is needed. pub statically_layout_proven: bool, - /// property name -> packed slot index, every entry a declared raw-f64 - /// candidate validated by the matcher. - pub fields: std::collections::BTreeMap, + /// property name -> the slot the clone reads it from. Class-keyed entries + /// are compile-time packed indices validated by the matcher as declared + /// raw-f64 candidates; shape-keyed ones are the preheader's live query + /// results (#10123). + pub fields: std::collections::BTreeMap, /// #7771: the body's `const r = arr[counter]` binding, when the matcher /// admitted the element-binding form. Inside the fast clone the `Let` /// itself emits nothing (`stmt/let_stmt.rs`) and every `r.field` read @@ -2183,27 +2275,41 @@ pub(crate) fn element_shape_loop_fact_for_property_get<'f>( ctx: &'f FnCtx<'_>, object: &perry_hir::Expr, property: &str, -) -> Option<(&'f ElementShapeLoopFact, u32)> { +) -> Option<(&'f ElementShapeLoopFact, &'f ElementShapeFieldSlot)> { use perry_hir::Expr; if ctx.element_shape_loop_facts.is_empty() { return None; } match object { Expr::IndexGet { object, index } => { - let (Expr::LocalGet(array_local_id), Expr::LocalGet(index_local_id)) = - (object.as_ref(), index.as_ref()) - else { + let Expr::LocalGet(array_local_id) = object.as_ref() else { return None; }; - if !ctx.i32_counter_slots.contains_key(index_local_id) { - return None; - } ctx.element_shape_loop_facts.iter().rev().find_map(|fact| { - if fact.array_local_id != *array_local_id || fact.index_local_id != *index_local_id + if fact.array_local_id != *array_local_id + || (fact.index.needs_counter_i32_slot() + && !ctx.i32_counter_slots.contains_key(&fact.index_local_id)) { return None; } - fact.fields.get(property).map(|idx| (fact, *idx)) + // #10123: the index SPELLING must be the one the fact's + // preheader discharged a bounds obligation for. A fact built + // for `arr[7]` says nothing about `arr[j]` in the same body, + // and the matcher's one-index-form rule means a body mixing + // them was never admitted — this is what keeps that true at + // the read. + let spelled = match (&fact.index, index.as_ref()) { + (ElementShapeIndex::Counter, Expr::LocalGet(id)) => *id == fact.index_local_id, + (ElementShapeIndex::Constant(k), Expr::Integer(n)) => *n == *k, + (ElementShapeIndex::DerivedMod { local_id, .. }, Expr::LocalGet(id)) => { + *id == *local_id + } + _ => false, + }; + if !spelled { + return None; + } + fact.fields.get(property).map(|slot| (fact, slot)) }) } // #7771: `r.field` through the clone's element binding. The matcher @@ -2217,7 +2323,7 @@ pub(crate) fn element_shape_loop_fact_for_property_get<'f>( { return None; } - fact.fields.get(property).map(|idx| (fact, *idx)) + fact.fields.get(property).map(|slot| (fact, slot)) }), _ => None, } diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index 3e3cb82e23..51a8e8dbe6 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -359,24 +359,43 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( // candidate at the packed slot index carried here. So the lowering needs // nothing from the receiver's static type, and asking for it would have // made the whole clone dead IR. - if let Some((fact, field_index)) = + if let Some((fact, field_slot)) = crate::expr::element_shape_loop_fact_for_property_get(ctx, object, property) - .map(|(fact, idx)| (fact.clone(), idx)) + .map(|(fact, slot)| (fact.clone(), slot.clone())) { // Both receiver spellings — `arr[j].field` and #7771's `r.field` // through the clone's element binding — resolve to the fact's own // array; the report below must not re-derive it from the expression // shape, which the binding form does not carry. let arr_id = fact.array_local_id; - // The counter's canonical i32 slot is what the matcher required; - // without it there is nothing to index with. - if let Some(slot) = ctx.i32_counter_slots.get(&fact.index_local_id).cloned() { - let idx_i32 = ctx.block().load(I32, &slot); + // The counter's canonical i32 slot is what the matcher required for + // every index form that reads the counter; without it there is nothing + // to index with. A constant index reads no counter and needs none. + let counter_slot = ctx.i32_counter_slots.get(&fact.index_local_id).cloned(); + if counter_slot.is_some() || !fact.index.needs_counter_i32_slot() { + // #10123: the index the preheader discharged a bounds obligation + // for. All three forms are i32 and in `[0, length)` by the time + // they reach the GEP, which is why the fast clone pays no per-read + // bounds test in any of them. + let idx_i32 = match &fact.index { + crate::expr::ElementShapeIndex::Counter => { + let slot = counter_slot.expect("checked above"); + ctx.block().load(I32, &slot) + } + crate::expr::ElementShapeIndex::Constant(k) => k.to_string(), + // The derived `const d = j % m` binding's own slot, written by + // the `Let` arm in `stmt/let_stmt.rs` earlier in this same + // iteration. + crate::expr::ElementShapeIndex::DerivedMod { slot, .. } => { + let slot = slot.clone(); + ctx.block().load(I32, &slot) + } + }; let value = crate::expr::element_shape_guard::emit_element_shape_field_load( ctx, &fact, &idx_i32, - field_index, + &field_slot, ); let lowered = LoweredValue { semantic: SemanticKind::JsNumber, diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index 0aab0163ed..4f1ff8e459 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -172,10 +172,19 @@ pub(crate) fn emit_shadow_slot_clear(ctx: &mut FnCtx<'_>, slot_idx: u32) { // a moving collection rewrites like any root, and every later user of a // shared slot index binds before use. The slow clone, lowered after the // fact is popped, keeps its clear. + // + // #10123's derived index (`const d = j % m`) is the same case for the same + // reason: its `Let` emits one `srem` into a private i32 alloca, never a + // shadow bind, so a lexical-death clear would be the clone's only call. if ctx.element_shape_loop_facts.iter().any(|fact| { + let virtual_binding = match &fact.index { + crate::expr::ElementShapeIndex::DerivedMod { local_id, .. } => Some(*local_id), + _ => None, + }; fact.element_binding - .and_then(|id| ctx.shadow_slot_map.get(&id)) - == Some(&slot_idx) + .into_iter() + .chain(virtual_binding) + .any(|id| ctx.shadow_slot_map.get(&id) == Some(&slot_idx)) }) { return; } diff --git a/crates/perry-codegen/src/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index f8b78d9708..72e9122781 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -60,6 +60,10 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { // 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]); + // #10123: the shape-keyed sibling — establish-or-confirm for a class-0 + // (plain-object) element array, returning the exact ordinary ShapeId every + // element carries, or 0. + module.declare_function("js_array_ensure_element_shape_ordinary", 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/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 4a945389d5..0da4b188c1 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1153,6 +1153,10 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { ); module.declare_function("js_build_class_keys_array", I64, &[I32, I32, PTR, I32]); module.declare_function("js_object_shape_id_for_keys", I32, &[I64, I32]); + // #10123: (shape_id, NaN-boxed key) -> inline slot index, or -1. The + // element-shape loop clone's shape-keyed preheader resolves each tracked + // property once against the shape the runtime just proved. + module.declare_function("js_shape_ordinary_inline_slot_for_key", I32, &[I32, I64]); module.declare_function( "js_gc_typed_shape_id_for_keys", I32, diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs index 95eab00544..1ce4f0cd8d 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -40,8 +40,10 @@ //! binding whose only uses are tracked `r.field` reads (#7766: the shape //! the `for…of` desugar emits, and the form a parameter array reaches the //! clone through — the binding is virtual in the fast clone: its `Let` -//! emits nothing and the reads lower through the fact). No store of any -//! kind, no call, no closure, no `await`, no update other than the +//! emits nothing and the reads lower through the fact), or, since #10123, +//! by exactly one `const d = i % m` binding whose only use is as the +//! element index (equally virtual: its `Let` emits one `srem`). 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 @@ -88,12 +90,52 @@ //! runtime invalidation deopt needs an on-stack-replacement mechanism Perry //! does not have. //! +//! ## The SHAPE-keyed arm (#10123) +//! +//! Everything above keys on a compile-time CLASS, which excluded the one array +//! shape record-processing code is actually written against: `JSON.parse`'d +//! objects are `class_id == 0` with an ordinary birth ShapeId, and the class +//! resolver has nothing to resolve for a `rows: any` receiver. The clone +//! therefore never fired for a parsed record array — the measured case it +//! exists for. +//! +//! The second arm proves the same thing about a different identity: the +//! preheader asks `js_array_ensure_element_shape_ordinary` for the exact +//! ordinary ShapeId every element carries, then asks +//! `js_shape_ordinary_inline_slot_for_key` where each tracked property sits in +//! THAT shape. Both answers are loop-invariant values, so the clone's read is +//! still one bare offset load. +//! +//! **The revocation argument is unchanged**, because it never mentioned +//! classes: every funnel in the table above retires a shape-keyed proof +//! exactly as it retires a class-keyed one (`note_element_store` compares the +//! record, which for a class-0 proof is the exact ShapeId; a length change +//! fails `verified_len`; prototype surgery bumps the generation). Call-free is +//! still the whole admission test. +//! +//! Two things ARE different, and both are narrowings: +//! +//! * the per-element residual drops `GC_OBJ_TYPED_LAYOUT_INTACT`. A parsed +//! record never has that bit — `object/json_construction.rs` finishes every +//! record with `layout_init_pointer_free` or `layout_mark_unknown`, and both +//! clear it — so keeping it would have side-exited on the FIRST element of +//! every loop while every IR-census assertion still passed. What the bit +//! bought was "this slot holds a raw `double`"; the shape arm buys the same +//! claim per read, from the value, with the Number-tag test +//! `emit_js_value_is_number` and a side exit to the slow clone. +//! * the index grammar widens to `arr[k]` and `const d = j % m; arr[d]` — the +//! `repeat` and `sequential` shapes of real access code. Each carries its +//! own preheader bounds obligation (`length > k`, `m <= length`), so the +//! clone still pays no per-read bounds test, and the matcher admits exactly +//! ONE index form per loop so a fact can never be consulted for a spelling +//! whose obligation was not discharged. +//! //! ## 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 +//! The clone still pays a residual per-element check — the exact ShapeId, the +//! per-object descriptor flag and (class arm) the typed-layout intact bit — +//! because the array-level invariant deliberately does not cover them. Folding +//! them into `element_identity_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 @@ -128,33 +170,95 @@ enum ElementShapeLoopBound { ArrayLength(u32), } +/// #10123: which identity the clone keys the elements on. +#[derive(Debug)] +enum ElementShapeIdentity { + /// The original arm. A compile-time class supplies the id the preheader + /// compares against, the canonical keys global the expected ShapeId is + /// loaded from, and a packed slot index per property. + Class { + class_name: String, + expected_class_id: u32, + keys_global_name: String, + /// property name -> packed slot index. + packed_fields: std::collections::BTreeMap, + /// The native-region E1--E5 proof already establishes every element's + /// exact class for this array's whole lifetime. When true, the + /// preheader need not rebuild the weaker runtime invariant by scanning + /// the array. + statically_class_proven: bool, + /// The same contained group proof also established that every requested + /// field remains a raw-f64 slot, so per-object residual checks are + /// redundant inside the call-free clone. + statically_layout_proven: bool, + }, + /// #10123: an `any`-typed array of plain objects — canonically the result + /// of `JSON.parse`. There is no class and no compile-time layout; the + /// preheader asks the runtime for the exact ordinary ShapeId every element + /// carries and for each tracked property's inline slot in that shape, and + /// the per-element residual adds a Number-tag test on the loaded word. + Shape, +} + +/// #10123: the index spelling the whole body uses. +/// +/// One form per loop, deliberately: each carries its own preheader bounds +/// obligation (`expr::element_shape_guard::ElementShapeIndexBound`), so a body +/// mixing `arr[j]` with `arr[7]` would need both discharged and both matched +/// at every read. The matcher declines such a body instead. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MatchedIndex { + Counter, + Constant(i64), + DerivedMod { local_id: u32, modulus_id: u32 }, +} + +impl MatchedIndex { + /// Mirror of [`crate::expr::ElementShapeIndex::needs_counter_i32_slot`], + /// asked before the fact exists. + fn needs_counter_i32_slot(self) -> bool { + !matches!(self, MatchedIndex::Constant(_)) + } +} + #[derive(Debug)] struct ElementShapeVersionedLoop { counter_id: u32, bound: ElementShapeLoopBound, array_id: u32, - class_name: String, - expected_class_id: u32, - keys_global_name: String, - /// The native-region E1--E5 proof already establishes every element's - /// exact class for this array's whole lifetime. When true, the preheader - /// need not rebuild the weaker runtime invariant by scanning the array. - statically_class_proven: bool, - /// The same contained group proof also established that every requested - /// field remains a raw-f64 slot, so per-object residual checks are - /// redundant inside the call-free clone. - statically_layout_proven: bool, - /// property name -> packed slot index. - fields: std::collections::BTreeMap, + identity: ElementShapeIdentity, + /// Tracked property names, in both arms. The class arm additionally + /// carries a packed slot index per name inside its `identity`. + props: std::collections::BTreeSet, + index: MatchedIndex, /// #7771: the body's `const r = arr[counter]` binding in the two-statement /// form; `None` for the original single-statement accumulator body. element_binding: Option, accumulator_id: u32, } +/// The locals the pure-expression walk reasons about. +#[derive(Clone, Copy)] +struct PureExprScope { + counter_id: u32, + accumulator_id: u32, + element_binding: Option, + /// #10123: `(d, m)` for a body whose first statement is + /// `const d = counter % m`. + derived: Option<(u32, u32)>, +} + +/// What the walk collects. +#[derive(Default)] +struct PureExprFacts { + array: Option, + props: std::collections::BTreeSet, + index: Option, +} + /// Effect-free expression walk for the element-shape loop. /// -/// Admits exactly: tracked `arr[counter].prop` reads on ONE array, numeric +/// Admits exactly: tracked `arr[].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 @@ -163,11 +267,8 @@ struct ElementShapeVersionedLoop { fn element_shape_loop_pure_expr_collect( ctx: &FnCtx<'_>, expr: &perry_hir::Expr, - counter_id: u32, - accumulator_id: u32, - element_binding: Option, - array: &mut Option, - props: &mut std::collections::BTreeSet, + scope: &PureExprScope, + out: &mut PureExprFacts, ) -> bool { use perry_hir::Expr; match expr { @@ -175,46 +276,56 @@ fn element_shape_loop_pure_expr_collect( object, property, .. } => match object.as_ref() { Expr::IndexGet { object, index } => { - let (Expr::LocalGet(arr_id), Expr::LocalGet(idx_id)) = - (object.as_ref(), index.as_ref()) - else { + let Expr::LocalGet(arr_id) = object.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 { + if *arr_id == scope.counter_id { return false; } - match array { - Some(a) if *a == *arr_id => {} + // #10123: the admitted index spellings. `Counter` is the + // original one, and the two additions are the shapes real + // record-access loops are written in (`rows[7]`, and + // `const d = i % n; rows[d]`). An offset index (`arr[j + 1]`) + // is still out: it would need a bounds obligation of its own. + let Some(form) = match_index_form(index.as_ref(), scope) else { + return false; + }; + match out.index { + Some(seen) if seen == form => {} + Some(_) => return false, + None => out.index = Some(form), + } + match out.array { + Some(a) if a == *arr_id => {} Some(_) => return false, // one array per loop - None => *array = Some(*arr_id), + None => out.array = Some(*arr_id), } - props.insert(property.clone()); + out.props.insert(property.clone()); true } // #7771: `r.field` through the body's `const r = arr[counter]` // binding is the same tracked read spelled through the Let the // body match admitted; the binding already pins (array, counter), // so only the property is left to record. - Expr::LocalGet(recv_id) if element_binding == Some(*recv_id) => { - props.insert(property.clone()); + Expr::LocalGet(recv_id) if scope.element_binding == Some(*recv_id) => { + out.props.insert(property.clone()); true } _ => false, }, - // A bare read of the array, the counter, or the element binding as a - // VALUE could flow it into arbitrary lowering; only scalar reads the - // analysis proves numeric are admitted. The element binding is - // excluded EXPLICITLY rather than via the numeric test: a bare `r` - // would hand out a reference the clone's skipped `Let` never bound - // (#7771), and betting that exclusion on a type predicate is the + // A bare read of the array, the counter, the element binding or the + // derived index as a VALUE could flow it into arbitrary lowering; only + // scalar reads the analysis proves numeric are admitted. The element + // binding and the derived index are excluded EXPLICITLY rather than via + // the numeric test: both are bound by a `Let` the fast clone does not + // lower generically, so a bare read would hand out a reference nothing + // bound (#7771), and betting that exclusion on a type predicate is the // #6377 shape this walk's docs warn about. Expr::LocalGet(id) => { - element_binding != Some(*id) - && array.is_none_or(|a| a != *id) - && (*id == accumulator_id || crate::type_analysis::is_numeric_expr(ctx, expr)) + scope.element_binding != Some(*id) + && scope.derived.map(|(d, _)| d) != Some(*id) + && out.array.is_none_or(|a| a != *id) + && (*id == scope.accumulator_id || crate::type_analysis::is_numeric_expr(ctx, expr)) } Expr::Number(_) | Expr::Integer(_) => true, // NOTE (#7480 step 3): deliberately NOT gated on @@ -225,7 +336,8 @@ fn element_shape_loop_pure_expr_collect( // locals and literals by their own arms, and tracked `arr[j].field` // reads because the caller rejects the whole loop unless every // collected property is a declared raw-f64 candidate on the resolved - // element class. + // element class (class arm) or the emitted read tag-tests the loaded + // word and side-exits when it is not a Number (#10123's shape arm). // // The gate had to go for the object-literal kernel: at match time no // fact is installed yet, so `is_numeric_expr` cannot see through @@ -234,63 +346,19 @@ fn element_shape_loop_pure_expr_collect( // object-literal element). Keeping it would have declined #7480's own // kernel before the class resolver was ever consulted. Expr::Binary { left, right, .. } => { - element_shape_loop_pure_expr_collect( - ctx, - left, - counter_id, - accumulator_id, - element_binding, - array, - props, - ) && element_shape_loop_pure_expr_collect( - ctx, - right, - counter_id, - accumulator_id, - element_binding, - array, - props, - ) + element_shape_loop_pure_expr_collect(ctx, left, scope, out) + && element_shape_loop_pure_expr_collect(ctx, right, scope, out) + } + Expr::NumberCoerce(operand) => { + element_shape_loop_pure_expr_collect(ctx, operand, scope, out) } - Expr::NumberCoerce(operand) => element_shape_loop_pure_expr_collect( - ctx, - operand, - counter_id, - accumulator_id, - element_binding, - array, - props, - ), Expr::MathImul(left, right) | Expr::MathPow(left, right) => { - element_shape_loop_pure_expr_collect( - ctx, - left, - counter_id, - accumulator_id, - element_binding, - array, - props, - ) && element_shape_loop_pure_expr_collect( - ctx, - right, - counter_id, - accumulator_id, - element_binding, - array, - props, - ) + element_shape_loop_pure_expr_collect(ctx, left, scope, out) + && element_shape_loop_pure_expr_collect(ctx, right, scope, out) } - Expr::MathMin(values) | Expr::MathMax(values) => values.iter().all(|e| { - element_shape_loop_pure_expr_collect( - ctx, - e, - counter_id, - accumulator_id, - element_binding, - array, - props, - ) - }), + Expr::MathMin(values) | Expr::MathMax(values) => values + .iter() + .all(|e| element_shape_loop_pure_expr_collect(ctx, e, scope, out)), Expr::MathAbs(value) | Expr::MathSqrt(value) | Expr::MathFloor(value) @@ -298,19 +366,32 @@ fn element_shape_loop_pure_expr_collect( | Expr::MathRound(value) | Expr::MathTrunc(value) | Expr::MathSign(value) - | Expr::MathF16round(value) => element_shape_loop_pure_expr_collect( - ctx, - value, - counter_id, - accumulator_id, - element_binding, - array, - props, - ), + | Expr::MathF16round(value) => element_shape_loop_pure_expr_collect(ctx, value, scope, out), _ => false, } } +/// Classify one `arr[]` subscript. `None` declines the whole match. +fn match_index_form(index: &perry_hir::Expr, scope: &PureExprScope) -> Option { + use perry_hir::Expr; + match index { + Expr::LocalGet(id) if *id == scope.counter_id => Some(MatchedIndex::Counter), + Expr::LocalGet(id) => match scope.derived { + Some((derived_id, modulus_id)) if derived_id == *id => Some(MatchedIndex::DerivedMod { + local_id: derived_id, + modulus_id, + }), + _ => None, + }, + // `0..=i32::MAX`, so the preheader's `length > k` compare is an i32 + // one and the emitted index needs no conversion. + Expr::Integer(k) if (0..=i64::from(i32::MAX)).contains(k) => { + Some(MatchedIndex::Constant(*k)) + } + _ => None, + } +} + /// Resolve the class every element of `array_id` must have for the clone to /// fire. /// @@ -509,7 +590,112 @@ fn anon_shape_field_type_is_compatible( } } -/// Match `for (let j = k0; j < B; j++) acc = `. +/// Lower one of the fast clone's VIRTUAL body bindings, or report that `id` is +/// not one. Called from `stmt/let_stmt.rs` before the generic `Let` lowering. +/// +/// Two shapes, both admitted by the matcher and neither lowered generically: +/// +/// * **#7771's element binding** (`const r = arr[j]`) emits NOTHING. The +/// matcher admitted the body only because every use of `r` is a tracked +/// `r.field` read, and each of those lowers through +/// `element_shape_loop_fact_for_property_get` to a bare element load. +/// Lowering the generic `IndexGet` would put a runtime-call diamond inside +/// the clone, fail its call-free admission scan, and DELETE the clone rather +/// than slow it (#7690's lesson). +/// * **#10123's derived index** (`const d = j % m`) emits one `srem i32`. `%` +/// on two possibly-untyped operands is a runtime call, with the same +/// consequence. The preheader already proved `m` is an integral +/// `1..=i32::MAX` and materialized it as an i32, and the counter is a +/// non-negative i32, so the whole statement is one instruction. +/// +/// Both are sound for the same four reasons: nothing reads the binding bare +/// inside the clone (the matcher's walk excludes both explicitly), the clone +/// is call-free so no GC observes the slot mid-loop, `const` scoping means +/// nothing after the loop can read it, and a residual-check side exit re-runs +/// the current iteration in the slow clone, whose OWN `Let` binds the real +/// slot before any use. The facts are popped before the slow clone lowers, so +/// this cannot fire there. +/// +/// Answering `false` for want of the counter's i32 slot cannot happen — the +/// matcher requires one for the derived-index form — and would only cost the +/// clone, never correctness. +pub(super) fn lower_virtual_clone_binding(ctx: &mut FnCtx<'_>, id: u32) -> bool { + if ctx + .element_shape_loop_facts + .iter() + .any(|fact| fact.element_binding == Some(id)) + { + return true; + } + let Some((counter_slot, modulus_i32, derived_slot)) = + ctx.element_shape_loop_facts.iter().rev().find_map(|fact| { + let crate::expr::ElementShapeIndex::DerivedMod { + local_id, + modulus_i32, + slot, + } = &fact.index + else { + return None; + }; + if *local_id != id { + return None; + } + Some(( + ctx.i32_counter_slots.get(&fact.index_local_id)?.clone(), + modulus_i32.clone(), + slot.clone(), + )) + }) + else { + return false; + }; + let blk = ctx.block(); + let counter = blk.load(I32, &counter_slot); + let derived = blk.srem(I32, &counter, &modulus_i32); + blk.store(I32, &derived, &derived_slot); + true +} + +/// Is `array_id` an untyped receiver — the shape-keyed arm's entry condition? +/// +/// The class resolver having declined is not on its own enough: a `Node[]` +/// whose class the module does not define, or a shape the anon-shape resolver +/// found ambiguous, are both "declined" and both name a layout this arm would +/// be guessing about. Requiring the declared type to be absent / `any` / +/// `unknown` keeps #10123 to exactly the receivers that carry no layout claim +/// at all, which is what `JSON.parse` hands back. +fn array_is_untyped(ctx: &FnCtx<'_>, array_id: u32) -> bool { + use perry_hir::types::Type; + matches!( + ctx.local_type_hint(&array_id) + .map(|ty| resolve_type_alias(ctx, ty)), + None | Some(Type::Any) | Some(Type::Unknown) + ) +} + +/// A modulus local for #10123's `const d = counter % m` index. +/// +/// `m` must be readable and provably unchanged for the loop's duration: the +/// preheader materializes it ONCE and the clone's `srem` uses that value for +/// every iteration, so a body that could rewrite `m` would derive indices +/// against a stale bound. `local_bound_is_loop_invariant` answers exactly +/// that (it looks for WRITES, so the `const d = counter % m` read itself is +/// not a mutation). +fn modulus_local_is_admissible( + ctx: &FnCtx<'_>, + modulus_id: u32, + condition: &perry_hir::Expr, + update: Option<&perry_hir::Expr>, + body: &[Stmt], +) -> bool { + !ctx.boxed_vars.contains(&modulus_id) + && !ctx.closure_captures.contains_key(&modulus_id) + && (local_has_readable_slot(ctx, modulus_id) + || ctx.module_globals.contains_key(&modulus_id)) + && local_bound_is_loop_invariant(condition, update, body, modulus_id) +} + +/// Match `for (let j = k0; j < B; j++) acc = ].field>`. /// /// The single-statement, store-free body is the revocation argument (see the /// module docs) AND the side-exit protocol: the residual per-element check @@ -522,7 +708,7 @@ fn match_element_shape_versioned_loop( update: Option<&perry_hir::Expr>, body: &[Stmt], ) -> Option { - use perry_hir::{CompareOp, Expr, UpdateOp}; + use perry_hir::{BinaryOp, 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. @@ -613,50 +799,87 @@ fn match_element_shape_versioned_loop( return None; } - // Store-free body, in one of two admitted shapes (see the module docs): + // Store-free body, in one of three admitted shapes (see the module docs): // - // 1. `acc = ` — the original single - // statement; + // 1. `acc = ].field>` — the original + // single statement; // 2. `const r = arr[j]; acc = ` — #7771's - // element-binding form, the shape real read loops are written in. - // The binding is VIRTUAL inside the fast clone: its `Let` emits - // nothing (`stmt/let_stmt.rs`) and every `r.field` lowers through - // the fact, so the revocation argument (no store, no call in the - // clone) is unchanged. `const`-only, deliberately: a `var` binding - // is function-scoped and observable after the loop, where the - // skipped `Let` would leave the slot holding its pre-loop value. + // element-binding form, the shape real read loops are written in; + // 3. `const d = j % m; acc = ` — + // #10123's derived-index form, the shape every sequential pass over a + // parsed record array is written in. + // + // In 2 and 3 the binding is VIRTUAL inside the fast clone: its `Let` + // emits nothing (form 2) or one `srem i32` (form 3) rather than the + // generic lowering (`stmt/let_stmt.rs`), so the revocation argument (no + // store, no call in the clone) is unchanged. `const`-only, deliberately: a + // `var` binding is function-scoped and observable after the loop, where + // the skipped `Let` would leave the slot holding its pre-loop value. // // NOTHING else is admitted. - let (element_binding, acc_id, value) = match body { - [Stmt::Expr(Expr::LocalSet(acc_id, value))] => (None, acc_id, value), + let mut element_binding: Option<(u32, u32)> = None; + let mut derived: Option<(u32, u32)> = None; + let (acc_id, value) = match body { + [Stmt::Expr(Expr::LocalSet(acc_id, value))] => (acc_id, value), [Stmt::Let { id, mutable: false, - init: Some(Expr::IndexGet { object, index }), + init: Some(binding_init), .. }, Stmt::Expr(Expr::LocalSet(acc_id, value))] => { - let (Expr::LocalGet(arr_id), Expr::LocalGet(idx_id)) = - (object.as_ref(), index.as_ref()) - else { - return None; - }; - // Same receiver/index discipline as the walk's IndexGet arm: the - // fetch must be `arr[counter]` exactly. - if *idx_id != counter_id || *arr_id == counter_id || *id == counter_id { - return None; - } - // The binding must be a plain, loop-owned const local. A boxed or - // captured binding lives in a cell the skipped `Let` would leave - // stale for an observer outside the clone; a module-global id is - // not a body-scoped binding at all. - if *id == *arr_id + // The binding must be a plain, loop-owned const local in either + // form. A boxed or captured binding lives in a cell the clone's + // replacement `Let` would leave stale for an observer outside the + // clone; a module-global id is not a body-scoped binding at all. + if *id == counter_id || ctx.boxed_vars.contains(id) || ctx.module_globals.contains_key(id) || ctx.closure_captures.contains_key(id) { return None; } - (Some((*id, *arr_id)), acc_id, value) + match binding_init { + Expr::IndexGet { object, index } => { + let (Expr::LocalGet(arr_id), Expr::LocalGet(idx_id)) = + (object.as_ref(), index.as_ref()) + else { + return None; + }; + // Same receiver/index discipline as the walk's IndexGet + // arm: the fetch must be `arr[counter]` exactly. + if *idx_id != counter_id || *arr_id == counter_id || *id == *arr_id { + return None; + } + element_binding = Some((*id, *arr_id)); + } + // #10123. `%` on two locals, the left one the counter. The + // modulus is validated below (it must be readable and + // loop-invariant), and the RANGE obligation — `1 <= m` so the + // `srem` cannot divide by zero, `m <= length` so every derived + // index is in bounds — is discharged in the preheader, which + // is also where a non-number / fractional `m` sends the loop + // to the slow clone. + Expr::Binary { + op: BinaryOp::Mod, + left, + right, + } => { + let (Expr::LocalGet(num_id), Expr::LocalGet(modulus_id)) = + (left.as_ref(), right.as_ref()) + else { + return None; + }; + if *num_id != counter_id || *modulus_id == counter_id || *id == *modulus_id { + return None; + } + if !modulus_local_is_admissible(ctx, *modulus_id, condition?, update, body) { + return None; + } + derived = Some((*id, *modulus_id)); + } + _ => return None, + } + (acc_id, value) } _ => return None, }; @@ -664,39 +887,53 @@ fn match_element_shape_versioned_loop( || !ctx.locals.contains_key(acc_id) || ctx.boxed_vars.contains(acc_id) || ctx.module_globals.contains_key(acc_id) - // The declared type is only a candidate. The lowering validates the - // accumulator's current NaN-box tag in the preheader before installing - // the numeric fact for the fast clone. - || !matches!(ctx.local_type_hint(acc_id), Some(perry_hir::types::Type::Number | perry_hir::types::Type::Int32)) { return None; } // The binding form pins the array before the walk runs, so a body mixing // `r.field` with `other[j].field` is declined by the walk's one-array rule. - let mut array: Option = element_binding.map(|(_, arr_id)| arr_id); - let element_binding = element_binding.map(|(id, _)| id); - if element_binding == Some(*acc_id) { + let scope = PureExprScope { + counter_id, + accumulator_id: *acc_id, + element_binding: element_binding.map(|(id, _)| id), + derived, + }; + if scope.element_binding == Some(*acc_id) || derived.map(|(d, _)| d) == Some(*acc_id) { return None; } - let mut props: std::collections::BTreeSet = std::collections::BTreeSet::new(); - if !element_shape_loop_pure_expr_collect( - ctx, - value, - counter_id, - *acc_id, - element_binding, - &mut array, - &mut props, - ) { + let mut facts = PureExprFacts { + array: element_binding.map(|(_, arr_id)| arr_id), + ..PureExprFacts::default() + }; + if !element_shape_loop_pure_expr_collect(ctx, value, &scope, &mut facts) { return None; } - let array_id = array?; - if props.is_empty() || array_id == *acc_id || array_id == counter_id { + let array_id = facts.array?; + if facts.props.is_empty() || array_id == *acc_id || array_id == counter_id { return None; } + // The element-binding form carries no subscript at the reads, so its index + // form comes from the binding rather than from the walk. + let index = match element_binding { + Some(_) => MatchedIndex::Counter, + None => facts.index?, + }; + if let MatchedIndex::DerivedMod { + local_id, + modulus_id, + } = index + { + if modulus_id == array_id || modulus_id == *acc_id || local_id == array_id { + return None; + } + } match bound { ElementShapeLoopBound::Local(bound_id) => { - if bound_id == array_id || bound_id == *acc_id || Some(bound_id) == element_binding { + if bound_id == array_id + || bound_id == *acc_id + || Some(bound_id) == scope.element_binding + || derived.map(|(d, _)| d) == Some(bound_id) + { return None; } } @@ -728,7 +965,7 @@ fn match_element_shape_versioned_loop( // #7480: the preheader must be able to write the growth-forwarding-repaired // head BACK into the binding (see // `expr::element_shape_guard::emit_element_shape_loop_preheader_check` - // step 2b). A closure-captured array lives in a capture cell that a plain + // step 2). A closure-captured array lives in a capture cell that a plain // slot store would not update, so the two views could disagree; decline // rather than repair only half of them. if ctx.closure_captures.contains_key(&array_id) { @@ -738,11 +975,72 @@ fn match_element_shape_versioned_loop( return None; } - let class_name = element_class_name(ctx, array_id, counter_id)?; - if CLASS_FIELD_LOOP_CLASS_DENYLIST.contains(&class_name.as_str()) { + for prop in &facts.props { + if CLASS_FIELD_LOOP_PROP_DENYLIST.contains(&prop.as_str()) { + return None; + } + } + + let identity = match element_class_name(ctx, array_id, counter_id) { + Some(class_name) => { + // The class arm keeps its ORIGINAL grammar. Its bounds argument is + // "the trip count covers every index", which only `Counter` + // satisfies, and widening it here would change the emitted code + // for receivers #10123 never measured. + if index != MatchedIndex::Counter { + return None; + } + match_class_identity(ctx, array_id, &class_name, &facts.props)? + } + // #10123. A receiver that declares no element type at all is the + // `JSON.parse` case: nothing is known statically, so everything is + // asked of the runtime in the preheader. + None if array_is_untyped(ctx, array_id) => ElementShapeIdentity::Shape, + None => return None, + }; + + // The declared accumulator type is only a candidate: the lowering + // validates the accumulator's current NaN-box tag in the preheader before + // installing the numeric fact for the fast clone. The class arm keeps its + // original Number/Int32 requirement; the shape arm also admits an + // untyped accumulator, because `let sum = 0; sum += rows[i].id` over an + // `any` array is the shape this optimization exists for and HIR widens + // `sum` to `Any` exactly when the element read is untyped. The preheader + // tag check is what makes that safe, and it is emitted either way. + let accumulator_hint_ok = match ctx.local_type_hint(acc_id) { + Some(perry_hir::types::Type::Number | perry_hir::types::Type::Int32) => true, + None | Some(perry_hir::types::Type::Any | perry_hir::types::Type::Unknown) => { + matches!(identity, ElementShapeIdentity::Shape) + } + _ => false, + }; + if !accumulator_hint_ok { + return None; + } + + Some(ElementShapeVersionedLoop { + counter_id, + bound, + array_id, + identity, + props: facts.props, + index, + element_binding: scope.element_binding, + accumulator_id: *acc_id, + }) +} + +/// Resolve the class arm's compile-time facts, or decline. +fn match_class_identity( + ctx: &FnCtx<'_>, + array_id: u32, + class_name: &str, + props: &std::collections::BTreeSet, +) -> Option { + if CLASS_FIELD_LOOP_CLASS_DENYLIST.contains(&class_name) { return None; } - let class = ctx.classes.get(&class_name)?; + let class = ctx.classes.get(class_name)?; if !class.computed_members.is_empty() { return None; } @@ -752,59 +1050,97 @@ fn match_element_shape_versioned_loop( 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 expected_class_id = *ctx.class_ids.get(class_name)?; + let keys_global_name = ctx.class_keys_globals.get(class_name)?.clone(); let statically_class_proven = ctx .native_facts .exact_element_class(array_id) .is_some_and(|proven| proven == class_name); - let mut fields = std::collections::BTreeMap::new(); + let mut packed_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}"))) + .contains_key(&(class_name.to_string(), format!("__get_{prop}"))) || ctx .methods - .contains_key(&(class_name.clone(), format!("__set_{prop}"))) + .contains_key(&(class_name.to_string(), 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) + 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); + packed_fields.insert(prop.clone(), field_index); } let statically_layout_proven = statically_class_proven && ctx .native_facts .exact_numeric_element_fields(array_id) - .is_some_and(|proven| fields.keys().all(|field| proven.contains(field))); + .is_some_and(|proven| packed_fields.keys().all(|field| proven.contains(field))); - Some(ElementShapeVersionedLoop { - counter_id, - bound, - array_id, - class_name, + Some(ElementShapeIdentity::Class { + class_name: class_name.to_string(), expected_class_id, keys_global_name, + packed_fields, statically_class_proven, statically_layout_proven, - fields, - element_binding, - accumulator_id: *acc_id, }) } +/// Materialize a NaN-boxed local as an i32 in `[min, max]`, taking the slow +/// clone on a non-number, out-of-range or fractional value. +/// +/// Leaves `ctx.current_block` on a fresh block dominated by all three checks, +/// and returns the i32 SSA value. Call-free by construction — the whole point +/// is that the clone's hot path reads an i32 it can trust. +fn materialize_loop_i32( + ctx: &mut FnCtx<'_>, + local_id: u32, + min: i32, + max: i32, + slow_label: &str, + label_prefix: &str, +) -> Result { + let value = lower_expr(ctx, &perry_hir::Expr::LocalGet(local_id))?; + let is_number = emit_js_value_is_number(ctx, &value); + let range_idx = ctx.new_block(&format!("{label_prefix}.range")); + let convert_idx = ctx.new_block(&format!("{label_prefix}.convert")); + let done_idx = ctx.new_block(&format!("{label_prefix}.ok")); + let range_label = ctx.block_label(range_idx); + let convert_label = ctx.block_label(convert_idx); + let done_label = ctx.block_label(done_idx); + ctx.block().cond_br(&is_number, &range_label, slow_label); + + ctx.current_block = range_idx; + let ge_min = { + let min_literal = format!("{:.1}", f64::from(min)); + ctx.block().fcmp("oge", &value, &min_literal) + }; + let le_max = { + let max_literal = format!("{:.1}", f64::from(max)); + ctx.block().fcmp("ole", &value, &max_literal) + }; + let in_range = ctx.block().and(I1, &ge_min, &le_max); + ctx.block().cond_br(&in_range, &convert_label, slow_label); + + ctx.current_block = convert_idx; + let as_i32 = ctx.block().fptosi(DOUBLE, &value, I32); + let roundtrip = ctx.block().sitofp(I32, &as_i32, DOUBLE); + let is_integral = ctx.block().fcmp("oeq", &roundtrip, &value); + ctx.block().cond_br(&is_integral, &done_label, slow_label); + + ctx.current_block = done_idx; + Ok(as_i32) +} + /// Lower the matched loop as a guarded fast clone plus the unchanged generic /// body, modeled on `lower_class_field_versioned_for`. /// @@ -824,9 +1160,15 @@ pub(super) fn lower_element_shape_versioned_for( 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) { + // A counter- or modulo-derived index reads the counter through its + // canonical i32 slot; without one the element GEP would need an fptosi and + // the clone would win nothing. A CONSTANT index never reads the counter + // (#10123), and its loops are exactly the ones `stmt/let_stmt.rs` mints no + // i32 slot for — the counter is neither index-used nor i32-bounded — so + // demanding one there would decline the shape this arm was written for. + if matched.index.needs_counter_i32_slot() + && !ctx.i32_counter_slots.contains_key(&matched.counter_id) + { return Ok(false); } @@ -844,38 +1186,30 @@ pub(super) fn lower_element_shape_versioned_for( let materialized_bound: Option = match matched.bound { ElementShapeLoopBound::ArrayLength(_) => None, ElementShapeLoopBound::Constant(k) => Some(k.to_string()), - ElementShapeLoopBound::Local(bound_id) => Some({ - 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 - }), + ElementShapeLoopBound::Local(bound_id) => Some(materialize_loop_i32( + ctx, + bound_id, + 0, + i32::MAX, + &slow_pre_label, + "element_shape.loop.bound", + )?), + }; + + // #10123: the same materialization for a derived index's modulus, with a + // floor of 1 — `srem` by zero is undefined behaviour, and `x % 0` is NaN + // in JS, so a zero modulus is a slow-clone case rather than something the + // clone may compute. + let modulus_i32: Option = match matched.index { + MatchedIndex::DerivedMod { modulus_id, .. } => Some(materialize_loop_i32( + ctx, + modulus_id, + 1, + i32::MAX, + &slow_pre_label, + "element_shape.loop.modulus", + )?), + _ => None, }; let trip_count = match &materialized_bound { @@ -884,24 +1218,113 @@ pub(super) fn lower_element_shape_versioned_for( } None => crate::expr::element_shape_guard::ElementShapeLoopTripCount::ArrayLength, }; - let expected_class_id_str = matched.expected_class_id.to_string(); - let (elements_base, expected_shape_id, shape_ok, bound_i32) = - crate::expr::element_shape_guard::emit_element_shape_loop_preheader_check( - ctx, - matched.array_id, - &expected_class_id_str, - &matched.keys_global_name, - trip_count, - &slow_pre_label, - matched.statically_class_proven, - )?; + let index_bound = match (&matched.index, &modulus_i32) { + (MatchedIndex::Counter, _) => { + crate::expr::element_shape_guard::ElementShapeIndexBound::FromTripCount + } + (MatchedIndex::Constant(k), _) => { + crate::expr::element_shape_guard::ElementShapeIndexBound::Constant(*k) + } + (MatchedIndex::DerivedMod { .. }, Some(modulus)) => { + crate::expr::element_shape_guard::ElementShapeIndexBound::Modulus(modulus.as_str()) + } + (MatchedIndex::DerivedMod { .. }, None) => { + unreachable!("a derived index always materializes its modulus") + } + }; + let expected_class_id_str = match &matched.identity { + ElementShapeIdentity::Class { + expected_class_id, .. + } => expected_class_id.to_string(), + ElementShapeIdentity::Shape => String::new(), + }; + let guard_kind = match &matched.identity { + ElementShapeIdentity::Class { + keys_global_name, .. + } => crate::expr::element_shape_guard::ElementShapeGuardKind::Class { + expected_class_id: expected_class_id_str.as_str(), + keys_global_name: keys_global_name.as_str(), + }, + ElementShapeIdentity::Shape => { + crate::expr::element_shape_guard::ElementShapeGuardKind::Shape { + properties: &matched.props, + } + } + }; + let statically_proven = matches!( + matched.identity, + ElementShapeIdentity::Class { + statically_class_proven: true, + .. + } + ); + let guard = crate::expr::element_shape_guard::emit_element_shape_loop_preheader_check( + ctx, + matched.array_id, + guard_kind, + trip_count, + index_bound, + &slow_pre_label, + statically_proven, + )?; let accumulator = lower_expr(ctx, &perry_hir::Expr::LocalGet(matched.accumulator_id))?; let accumulator_is_number = emit_js_value_is_number(ctx, &accumulator); - let fast_path_ok = ctx.block().and(I1, &shape_ok, &accumulator_is_number); + let fast_path_ok = ctx.block().and(I1, &guard.shape_ok, &accumulator_is_number); // Deliberately unterminated: it branches into the fast clone only after // the clone is PROVEN call-free below. let deref_idx = ctx.current_block; + let (shape_keyed, statically_layout_proven, fields, report_class) = match &matched.identity { + ElementShapeIdentity::Class { + class_name, + packed_fields, + statically_layout_proven, + .. + } => ( + false, + *statically_layout_proven, + packed_fields + .iter() + .map(|(prop, index)| { + ( + prop.clone(), + crate::expr::ElementShapeFieldSlot::Packed(*index), + ) + }) + .collect(), + class_name.clone(), + ), + ElementShapeIdentity::Shape => ( + true, + false, + guard + .field_slots + .iter() + .map(|(prop, slot)| { + ( + prop.clone(), + crate::expr::ElementShapeFieldSlot::Runtime(slot.clone()), + ) + }) + .collect(), + "".to_string(), + ), + }; + let fact_index = match matched.index { + MatchedIndex::Counter => crate::expr::ElementShapeIndex::Counter, + MatchedIndex::Constant(k) => crate::expr::ElementShapeIndex::Constant(k), + MatchedIndex::DerivedMod { local_id, .. } => crate::expr::ElementShapeIndex::DerivedMod { + local_id, + modulus_i32: modulus_i32 + .clone() + .expect("a derived index always materializes its modulus"), + // Entry-block alloca: LLVM lowers a non-entry `alloca` as a real + // stack bump with no restore, and this one is written once per + // iteration. + slot: ctx.func.alloca_entry(I32), + }, + }; + let scope_id = ctx.next_loop_proof_scope_id(); let fast_scan_start = ctx.func.num_blocks(); ctx.current_block = fast_pre_idx; @@ -909,13 +1332,15 @@ pub(super) fn lower_element_shape_versioned_for( .push(crate::expr::ElementShapeLoopFact { array_local_id: matched.array_id, index_local_id: matched.counter_id, + index: fact_index, + shape_keyed, scope_id, - class_name: matched.class_name.clone(), - elements_base, - expected_shape_id, + class_name: report_class, + elements_base: guard.elements_base, + expected_shape_id: guard.expected_shape_id, side_exit_label: slow_pre_label.clone(), - statically_layout_proven: matched.statically_layout_proven, - fields: matched.fields.clone(), + statically_layout_proven, + fields, element_binding: matched.element_binding, numeric_accumulator: matched.accumulator_id, }); @@ -926,7 +1351,7 @@ pub(super) fn lower_element_shape_versioned_for( update, body, "for.element_shape_fast", - Some((matched.counter_id, bound_i32)), + Some((matched.counter_id, guard.bound_i32)), ); ctx.element_shape_loop_facts .retain(|fact| fact.scope_id != scope_id); @@ -973,6 +1398,27 @@ pub(super) fn lower_element_shape_versioned_for( None, ), }; + let (mode, described) = match &matched.identity { + ElementShapeIdentity::Class { + class_name, + statically_class_proven, + statically_layout_proven, + .. + } => ( + if *statically_layout_proven { + "statically layout-proven" + } else if *statically_class_proven { + "statically proven" + } else { + "runtime-guarded" + }, + format!("class {class_name}"), + ), + ElementShapeIdentity::Shape => ( + "runtime-guarded", + "runtime ordinary shape (class-less records)".to_string(), + ), + }; crate::opt_report::select( crate::opt_report::Position::Local, &name, @@ -981,17 +1427,9 @@ pub(super) fn lower_element_shape_versioned_for( "Ptr", 1, Some(format!( - "element-shape loop clone ({}): class {}, {} tracked field(s); \ + "element-shape loop clone ({mode}): {described}, {} tracked field(s); \ element reads in this loop lower to offset loads behind the preheader guard", - if matched.statically_layout_proven { - "statically layout-proven" - } else if matched.statically_class_proven { - "statically proven" - } else { - "runtime-guarded" - }, - matched.class_name, - matched.fields.len() + matched.props.len() )), ); } diff --git a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs index 3961e289d8..c78cfc72e5 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs @@ -369,9 +369,15 @@ fn assert_fast_clone_is_entered(ir: &str) { } /// The emitted text the fast clone owns: exactly the blocks named -/// `for.element_shape_fast.*` and any `element_shape.load` blocks its -/// runtime-guarded field reads branch into. A statically layout-proven clone -/// keeps the field load directly in its body and owns no such side-exit block. +/// `for.element_shape_fast.*`, any `element_shape.load` blocks its +/// runtime-guarded field reads branch into, and (#10123) any +/// `element_shape.number` blocks a shape-keyed read's tag test branches into. +/// A statically layout-proven clone keeps the field load directly in its body +/// and owns no such side-exit block. +/// +/// Every block the clone can execute must be listed here, not just the ones a +/// given assertion is about: the negatives below (call-free, no element-read +/// tier) are only true of the clone if the slice really is the whole clone. /// /// #7480 step 3 — ANTI-VACUITY. This used to slice from the first *substring* /// occurrence of `for.element_shape_fast.cond`, which is the @@ -402,7 +408,8 @@ fn fast_clone_slice(ir: &str) -> String { // belongs to whichever block was last opened. if !line.starts_with(char::is_whitespace) && trimmed.ends_with(':') { in_fast_block = trimmed.starts_with("for.element_shape_fast.") - || trimmed.starts_with("element_shape.load"); + || trimmed.starts_with("element_shape.load") + || trimmed.starts_with("element_shape.number"); } if in_fast_block { owned.push_str(line); @@ -1609,3 +1616,12 @@ fn element_binding_form_through_a_parameter_gets_the_clone() { let ir = emit(&m); assert_clone_fires_call_free(&ir, "parameter binding form"); } + +/// #10123's shape-keyed cases, split out because this file crosses the repo's +/// 2000-line cap otherwise. A CHILD module rather than a sibling: every helper +/// above — `emit`, `block_slice`, `fast_clone_slice`, +/// `assert_clone_fires_call_free`, the class-arm module builders the +/// "still takes the class arm" case compares against — is private to this +/// module, and duplicating them is how two IR censuses drift apart. +#[path = "element_shape_shape_keyed_tests.rs"] +mod shape_keyed; diff --git a/crates/perry-codegen/src/stmt/element_shape_shape_keyed_tests.rs b/crates/perry-codegen/src/stmt/element_shape_shape_keyed_tests.rs new file mode 100644 index 0000000000..94ade02cc7 --- /dev/null +++ b/crates/perry-codegen/src/stmt/element_shape_shape_keyed_tests.rs @@ -0,0 +1,491 @@ +//! #10123: the SHAPE-keyed element-shape versioned loop clone — the arm that +//! serves `JSON.parse`'d record arrays, where the element identity is a +//! runtime ShapeId rather than a compile-time class. +//! +//! A child module of `element_shape_loop_tests` (see the `mod` declaration +//! there), so `use super::*` brings that file's helpers and the class-arm +//! fixtures the negatives here compare against. + +use super::*; + +// --------------------------------------------------------------------------- +// #10123 — SHAPE-KEYED clones over an untyped (`any`) record array. +// +// This is the `JSON.parse` shape: `function run(rows: any, count: number)`, +// with the array's element identity known only at run time. Four things had to +// change together for it to fire, and every one of them is asserted below, +// because each alone still compiles and still prints the right answer: +// +// 1. the preheader asks `js_array_ensure_element_shape_ordinary` (the +// class-keyed query returns 0 for a class-0 record array, so a clone +// built on it would never be entered); +// 2. it resolves each tracked property to an inline slot with +// `js_shape_ordinary_inline_slot_for_key` (there is no class to bake a +// packed index from); +// 3. the residual per-element mask DROPS the typed-layout bit — a parsed +// record never has it, so the class-keyed mask would side-exit on the +// FIRST element of every loop while every label assertion still passed; +// 4. the loaded word is tag-tested as a Number, because a shape says which +// slot holds `id` and nothing about what is in it. +// --------------------------------------------------------------------------- + +const ROWS_ID: u32 = 31; +const COUNT_ID: u32 = 32; +const MOD_ID: u32 = 33; +const U_SUM_ID: u32 = 34; +const U_COUNTER_ID: u32 = 35; +const U_INDEX_ID: u32 = 36; + +/// `rows[].` +fn untyped_elem_field(index: Expr, prop: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ROWS_ID)), + index: Box::new(index), + }), + property: prop.to_string(), + byte_offset: 0, + } +} + +/// `sum = sum + ` +fn untyped_accumulate(value: Expr) -> Stmt { + Stmt::Expr(Expr::LocalSet( + U_SUM_ID, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(U_SUM_ID)), + right: Box::new(value), + }), + )) +} + +/// `const index = i % n;` +fn derived_index_stmt(modulus: Expr) -> Stmt { + Stmt::Let { + id: U_INDEX_ID, + name: "index".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Binary { + op: BinaryOp::Mod, + left: Box::new(Expr::LocalGet(U_COUNTER_ID)), + right: Box::new(modulus), + }), + } +} + +/// The benchmark's own function, parametrized: +/// +/// ```text +/// function run(rows: , count: number, n: number): number { +/// let sum: = 0; +/// for (let i = 0; i < count; i++) +/// return sum; +/// } +/// ``` +fn untyped_param_module(rows_ty: Type, sum_ty: Type, body: Vec) -> Module { + let mut m = Module::new("element_shape_loop.ts"); + m.functions = vec![perry_hir::Function { + id: 901, + name: "run".to_string(), + type_params: Vec::new(), + params: vec![ + perry_hir::Param { + id: ROWS_ID, + name: "rows".to_string(), + ty: rows_ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + perry_hir::Param { + id: COUNT_ID, + name: "count".to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + perry_hir::Param { + id: MOD_ID, + name: "n".to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + ], + return_type: Type::Number, + body: vec![ + Stmt::Let { + id: U_SUM_ID, + name: "sum".to_string(), + ty: sum_ty, + mutable: true, + init: Some(Expr::Integer(0)), + }, + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: U_COUNTER_ID, + name: "i".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(U_COUNTER_ID)), + right: Box::new(Expr::LocalGet(COUNT_ID)), + }), + update: Some(Expr::Update { + id: U_COUNTER_ID, + op: UpdateOp::Increment, + prefix: false, + }), + body, + }, + Stmt::Return(Some(Expr::LocalGet(U_SUM_ID))), + ], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }]; + m.init_kind = ModuleInitKind::Eager; + m +} + +/// The four shape-keyed obligations, asserted together. +fn assert_shape_keyed_clone(ir: &str, what: &str) { + assert_clone_fires_call_free(ir, what); + assert!( + ir.contains("call i32 @js_array_ensure_element_shape_ordinary"), + "{what}: the preheader must ask the SHAPE query — the class-keyed one \ + answers 0 for a class-0 record array, so a clone guarded on it could \ + never be entered" + ); + assert!( + !ir.contains("call i32 @js_array_ensure_element_shape("), + "{what}: an untyped array has no class to compare against" + ); + assert!( + ir.contains("call i32 @js_shape_ordinary_inline_slot_for_key"), + "{what}: each tracked property's inline slot must be resolved once in \ + the preheader; there is no class to bake a packed index from" + ); + let fast = fast_clone_slice(ir); + assert!( + fast.contains("134250751"), + "{what}: the fast clone must use the shape-keyed residual mask \ + (0x0800_80FF); emitted:\n{fast}" + ); + assert!( + !fast.contains("402686207"), + "{what}: the class-keyed mask requires GC_OBJ_TYPED_LAYOUT_INTACT, \ + which a JSON record NEVER has — using it here would side-exit on the \ + first element of every loop while every label assertion still passed; \ + emitted:\n{fast}" + ); + assert!( + fast.contains("element_shape.number"), + "{what}: a shape says which slot holds the field, not what is in it — \ + the loaded word must be tag-tested as a Number before it is consumed \ + as a raw double; emitted:\n{fast}" + ); + assert!( + fast.contains("getelementptr double"), + "{what}: the read must still be a bare offset load" + ); +} + +#[test] +fn an_untyped_record_array_gets_a_shape_keyed_clone() { + let ir = emit(&untyped_param_module( + Type::Any, + Type::Number, + vec![untyped_accumulate(untyped_elem_field( + Expr::LocalGet(U_COUNTER_ID), + "v", + ))], + )); + assert_shape_keyed_clone(&ir, "counter-indexed untyped array"); +} + +#[test] +fn a_constant_index_gets_a_shape_keyed_clone_with_a_hoisted_bounds_check() { + // `for (let i = 0; i < count; i++) sum += rows[7].id` — the benchmark's + // `repeat` mode. The trip count says NOTHING about index 7, so the + // preheader owes its own `length > 7`, and the clone owes no per-read + // bounds test in exchange. + let ir = emit(&untyped_param_module( + Type::Any, + Type::Number, + vec![untyped_accumulate(untyped_elem_field( + Expr::Integer(7), + "v", + ))], + )); + assert_shape_keyed_clone(&ir, "constant index"); + let deref = block_slice(&ir, "element_shape.loop.preheader.deref"); + assert!( + deref.contains("icmp ugt i32") && deref.contains(", 7"), + "the preheader must prove `length > 7` before the clone is reachable; \ + emitted:\n{deref}" + ); + // THE LIVENESS ASSERTION. The trip-count obligation `length >= bound` is + // the counter-index arm's, and emitting it here made the whole clone + // UNENTERABLE for its own benchmark: `for (i = 0; i < 1000000; i++) sum += + // rows[7].id` over a 7,600-element array asks the preheader to prove + // `length >= 1000000`. The `cond_br` into the fast clone was emitted, every + // census assertion passed, and the loop ran the slow clone a million times. + // The counter indexes nothing here, so the verified prefix has nothing to + // say about the trip count. + assert!( + !deref.contains("icmp uge i32"), + "a constant index must NOT carry the counter arm's `length >= bound` \ + obligation — the counter indexes nothing, and requiring it makes the \ + clone unenterable whenever the loop runs more times than the array is \ + long; emitted:\n{deref}" + ); + let fast = fast_clone_slice(&ir); + assert!( + !fast.contains("icmp ult i32") && !fast.contains("icmp ugt i32"), + "the bounds obligation is discharged ONCE in the preheader; a per-read \ + test in the clone would mean it was not; emitted:\n{fast}" + ); +} + +/// The counter-index arm must KEEP the obligation the test above forbids: it +/// is the only thing that makes `arr[j]` in bounds for every `j < bound`. +#[test] +fn a_counter_index_keeps_the_trip_count_obligation() { + let ir = emit(&untyped_param_module( + Type::Any, + Type::Number, + vec![untyped_accumulate(untyped_elem_field( + Expr::LocalGet(U_COUNTER_ID), + "v", + ))], + )); + assert_shape_keyed_clone(&ir, "counter index"); + let deref = block_slice(&ir, "element_shape.loop.preheader.deref"); + assert!( + deref.contains("icmp uge i32"), + "`arr[j]` is in bounds only because the verified prefix covers the \ + whole trip count; emitted:\n{deref}" + ); +} + +#[test] +fn a_derived_modulo_index_gets_a_shape_keyed_clone_with_an_srem_in_the_body() { + // `const index = i % n; sum += rows[index].id` — the benchmark's + // `sequential` mode, and the shape every wrap-around pass over a record + // array is written in. The generic `%` lowering is a runtime call, which + // inside this clone would DELETE it (#7690), so the `Let` must become one + // `srem`. + let ir = emit(&untyped_param_module( + Type::Any, + Type::Number, + vec![ + derived_index_stmt(Expr::LocalGet(MOD_ID)), + untyped_accumulate(untyped_elem_field(Expr::LocalGet(U_INDEX_ID), "v")), + ], + )); + assert_shape_keyed_clone(&ir, "derived modulo index"); + let fast = fast_clone_slice(&ir); + assert!( + fast.contains("srem i32"), + "the derived index must be one `srem` inside the clone; emitted:\n{fast}" + ); + assert!( + ir.contains("element_shape.loop.modulus.range"), + "the modulus must be materialized as a validated i32 in the preheader \ + — `srem` by zero is UB and `x % 0` is NaN in JS" + ); + let deref = block_slice(&ir, "element_shape.loop.preheader.deref"); + assert!( + deref.contains("icmp uge i32"), + "the preheader must prove `modulus <= length`, which is what makes \ + every derived index in bounds with no per-read test; emitted:\n{deref}" + ); + // Same liveness assertion as the constant-index case: the ONE `icmp uge` + // in this block must be the modulus obligation, not the counter arm's + // `length >= bound`. `for (i = 0; i < 1000000; i++) { const d = i % n; … }` + // over a short array is the benchmark's `sequential` mode, and demanding + // `length >= 1000000` made the clone unenterable there. + assert_eq!( + deref.matches("icmp uge i32").count(), + 1, + "a derived index owes exactly ONE length comparison (`modulus <= \ + length`); a second one is the counter arm's trip-count obligation, \ + which makes the clone unenterable whenever the loop runs more times \ + than the array is long; emitted:\n{deref}" + ); +} + +#[test] +fn a_shape_keyed_clone_admits_an_untyped_accumulator() { + // `let sum = 0; sum += rows[i].id` widens `sum` to `Any` in HIR exactly + // when the element read is untyped — which is every program this clone + // exists for. The preheader's tag check on the accumulator is what makes + // the numeric fact sound, and it is emitted either way. + let ir = emit(&untyped_param_module( + Type::Any, + Type::Any, + vec![untyped_accumulate(untyped_elem_field( + Expr::LocalGet(U_COUNTER_ID), + "v", + ))], + )); + assert_shape_keyed_clone(&ir, "untyped accumulator"); +} + +#[test] +fn a_class_typed_array_still_takes_the_class_arm() { + // #10123 must not steal the loops #7480 already owns: a declared element + // class still compares a class id and bakes a packed slot index. + let ir = emit(&element_shape_module( + vec![accumulate_stmt( + SUM_ID, + ARRAY_ID, + Expr::LocalGet(COUNTER_ID), + )], + None, + )); + assert_fast_clone_is_entered(&ir); + assert!( + ir.contains("call i32 @js_array_ensure_element_shape("), + "a declared element class must keep the class-keyed query" + ); + // CALLS, not declarations: both shape-keyed helpers are declared in every + // module by `runtime_decls`, so a bare substring search would be vacuous. + assert!( + !ir.contains("call i32 @js_array_ensure_element_shape_ordinary") + && !ir.contains("call i32 @js_shape_ordinary_inline_slot_for_key"), + "a declared element class must not pay the shape-keyed runtime queries" + ); + let fast = fast_clone_slice(&ir); + assert!( + fast.contains("402686207") && !fast.contains("134250751"), + "the class arm keeps the typed-layout conjunct in its residual mask" + ); +} + +// --------------------------------------------------------------------------- +// #10123 SABOTAGE — shapes the shape-keyed arm must decline. +// --------------------------------------------------------------------------- + +#[test] +fn a_body_mixing_index_forms_declines() { + // Each index form carries its OWN preheader bounds obligation, and the + // fact records exactly one. A body reading both `rows[i]` and `rows[7]` + // would have one of them discharged and the other not. + let ir = emit(&untyped_param_module( + Type::Any, + Type::Number, + vec![untyped_accumulate(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(untyped_elem_field(Expr::LocalGet(U_COUNTER_ID), "v")), + right: Box::new(untyped_elem_field(Expr::Integer(7), "w")), + })], + )); + assert!( + !ir.contains("element_shape.loop.fast.preheader"), + "a body mixing index forms must decline the clone" + ); +} + +#[test] +fn a_modulo_index_with_the_operands_swapped_declines() { + // `const index = n % i` is not a bounded index at all — it is bounded by + // the COUNTER, which grows. Only `counter % modulus` is admitted. + let ir = emit(&untyped_param_module( + Type::Any, + Type::Number, + vec![ + Stmt::Let { + id: U_INDEX_ID, + name: "index".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Binary { + op: BinaryOp::Mod, + left: Box::new(Expr::LocalGet(MOD_ID)), + right: Box::new(Expr::LocalGet(U_COUNTER_ID)), + }), + }, + untyped_accumulate(untyped_elem_field(Expr::LocalGet(U_INDEX_ID), "v")), + ], + )); + assert!( + !ir.contains("element_shape.loop.fast.preheader"), + "`modulus % counter` must decline the clone" + ); +} + +#[test] +fn an_untracked_local_index_declines() { + // A local that is neither the counter nor the body's own derived binding + // has no range the preheader proved anything about. + let ir = emit(&untyped_param_module( + Type::Any, + Type::Number, + vec![untyped_accumulate(untyped_elem_field( + Expr::LocalGet(MOD_ID), + "v", + ))], + )); + assert!( + !ir.contains("element_shape.loop.fast.preheader"), + "an arbitrary local index must decline the clone" + ); +} + +#[test] +fn a_denylisted_property_declines_the_shape_keyed_arm() { + // `length`, `name`, `constructor`, … are answered by the runtime or by the + // prototype, not out of an inline slot, so a shape's key position for one + // would be the wrong answer even when it exists. + let ir = emit(&untyped_param_module( + Type::Any, + Type::Number, + vec![untyped_accumulate(untyped_elem_field( + Expr::LocalGet(U_COUNTER_ID), + "length", + ))], + )); + assert!( + !ir.contains("element_shape.loop.fast.preheader"), + "a denylisted property must decline the clone" + ); +} + +#[test] +fn a_declared_but_unresolvable_element_type_still_declines() { + // The shape-keyed arm is entered only by a receiver that declares NO + // element type. `Widget[]` where `Widget` names no module class is a + // declined CLASS resolution, not an untyped receiver: the annotation is a + // layout claim this arm would be second-guessing. + let mut m = untyped_param_module( + Type::Array(Box::new(Type::Named("Widget".to_string()))), + Type::Number, + vec![untyped_accumulate(untyped_elem_field( + Expr::LocalGet(U_COUNTER_ID), + "v", + ))], + ); + m.classes = Vec::new(); + let ir = emit(&m); + assert!( + !ir.contains("element_shape.loop.fast.preheader"), + "an unresolvable declared element type must decline both arms" + ); +} diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index f9bdad3bc5..28ec377674 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -27,25 +27,11 @@ pub(crate) fn lower_let( ty: &perry_hir::types::Type, mutable: bool, ) -> Result<()> { - // #7771: inside an element-shape fast clone, the tracked - // `const r = arr[j]` binding is VIRTUAL. The matcher admitted the body - // only because every use of `r` is a tracked `r.field` read, and each of - // those lowers through `element_shape_loop_fact_for_property_get` to a - // bare element load — so the binding itself emits nothing. Lowering the - // generic `IndexGet` here would put a runtime-call diamond inside the - // clone, fail its call-free admission scan, and DELETE the clone rather - // than slow it (#7690's lesson). Skipping is sound: nothing reads `r` - // bare inside the clone (matcher), the clone is call-free so no GC - // observes the slot mid-loop, `const` scoping means nothing after the - // loop can read it, and a residual-check side exit re-runs the current - // iteration in the slow clone, whose OWN `Let` binds the slot before any - // use. The fact is popped before the slow clone lowers, so this arm - // cannot fire there. - if ctx - .element_shape_loop_facts - .iter() - .any(|fact| fact.element_binding == Some(id)) - { + // #7771 / #10123: inside an element-shape fast clone the body's `const` + // binding is VIRTUAL — the element binding emits nothing and the derived + // index emits one `srem`. Both live with the clone, which is where their + // soundness arguments and the preheader that validated them are. + if super::element_shape_loop::lower_virtual_clone_binding(ctx, id) { return Ok(()); } // `let C = SomeClass` aliases the local `C` to the class diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index 03dd401291..e3677034e2 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -375,12 +375,17 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { return true; } // repsel #7480 step 3: inside an element-shape fast clone a tracked - // `arr[i].field` read is a GUARD-PROVEN raw double — the preheader - // pinned the element class and the per-element residual check - // requires `GC_OBJ_TYPED_LAYOUT_INTACT`, so the slot cannot hold a - // NaN-boxed value. This is a stronger proof than the declared-type - // answer below, and it is the ONLY one available for an - // object-literal element type, whose owner class + // `arr[i].field` read is a GUARD-PROVEN raw double. The class-keyed + // arm gets that from the residual check's + // `GC_OBJ_TYPED_LAYOUT_INTACT` conjunct, which says the slot holds + // a raw `double` rather than a NaN-boxed value; #10123's + // shape-keyed arm gets it from a Number-tag test on the loaded word + // that side-exits to the slow clone when it fails + // (`expr::element_shape_guard::emit_element_shape_field_load`). + // Either way the value this predicate licenses a consumer to treat + // as an f64 has been proven to be one. This is a stronger proof + // than the declared-type answer below, and it is the ONLY one + // available for an object-literal element type, whose owner class // `receiver_class_name` deliberately does not resolve. // // It is also load-bearing rather than a bonus: without it diff --git a/crates/perry-runtime/src/arena/allocators.rs b/crates/perry-runtime/src/arena/allocators.rs index 1e2e87c5e1..28ef1cab56 100644 --- a/crates/perry-runtime/src/arena/allocators.rs +++ b/crates/perry-runtime/src/arena/allocators.rs @@ -428,7 +428,11 @@ pub fn arena_alloc_gc(size: usize, align: usize, obj_type: u8) -> *mut u8 { // slots per minor because of it). let total = gc_padded_total_size(size, align); super::alloc_sample::note(total, obj_type); - if crate::gc::is_large_object_total_size_for_type(total, obj_type) { + // `&&` short-circuits, so the scope check is reached only by an allocation + // the size test has ALREADY called large -- never on the hot path (#10123). + if crate::gc::is_large_object_total_size_for_type(total, obj_type) + && !crate::gc::json_wide_birth_permits(total, obj_type) + { let user_ptr = arena_alloc_gc_old(size, align, obj_type); unsafe { let header = user_ptr.sub(GC_HEADER_SIZE) as *mut GcHeader; diff --git a/crates/perry-runtime/src/arena/mod.rs b/crates/perry-runtime/src/arena/mod.rs index ecf81353a9..ad3b0bf4e1 100644 --- a/crates/perry-runtime/src/arena/mod.rs +++ b/crates/perry-runtime/src/arena/mod.rs @@ -28,6 +28,8 @@ mod walk; #[cfg(test)] mod tests; #[cfg(test)] +mod tests_batch_unregister; +#[cfg(test)] mod tests_promoted_runs; // Cross-sibling shared types/thread-locals (used by sibling modules via @@ -152,8 +154,8 @@ pub(crate) use page_meta::{ old_page_account_swept_object, old_page_clear_dirty, old_page_mark_dirty, old_page_meta_snapshot, old_page_summary, old_pages_begin_gc_cycle, old_pages_reset_sweep_accounting, record_arena_object_start, unregister_old_object_pages, - HeapGeneration, HeapSpace, OldArenaPageObjectCursor, OldArenaSourceBlockSelection, OldPageMeta, - OldPageSummary, + unregister_old_objects_batch, HeapGeneration, HeapSpace, OldArenaPageObjectCursor, + OldArenaSourceBlockSelection, OldPageMeta, OldPageSummary, }; #[cfg(test)] diff --git a/crates/perry-runtime/src/arena/page_meta/mod.rs b/crates/perry-runtime/src/arena/page_meta/mod.rs index 708b16b90f..f2f0b180b6 100644 --- a/crates/perry-runtime/src/arena/page_meta/mod.rs +++ b/crates/perry-runtime/src/arena/page_meta/mod.rs @@ -1285,6 +1285,115 @@ pub(crate) fn unregister_old_object_pages(header_addr: usize, total_size: usize) update_old_page_meta_for_object(&removed_pages, false); } +/// Batched [`unregister_old_object_pages`] for the dead old objects one sweep +/// step found. +/// +/// The per-object remover allocates two `Vec`s, flushes the deferral buffer, +/// borrows both tables, and then finds the header in its page's object list +/// with a linear `position` before `swap_remove`. Freeing every object on a +/// page that way is quadratic in objects per page, and a full collection frees +/// whole pages of small objects: on records_array_8m:scan (~720k dead records +/// per full) it was 15.8% of ALL samples, the single largest cost of a full +/// collection. The registration side already batches for the same reason +/// (`flush_deferred_old_page_registrations_batch`); this is its mirror. +/// +/// One flush, one run materialization per touched page, then one `retain` per +/// page against that page's dead headers (sorted, so membership is a binary +/// search). Page-meta decrements for a page are applied together and its +/// reset/refresh run once: allocated bytes and object counts only fall here, +/// so "both reached zero at some point" and "both are zero at the end" are the +/// same event, and `reset_cycle_sweep_accounting` / `refresh_policy_bits` are +/// pure recomputes of the page's own fields. +/// +/// `scratch` is caller-owned and reused across steps, so a flush allocates +/// nothing once warm (the #7624 lesson: re-growing staging buffers per batch +/// cost +31 MB peak RSS on json_pipeline). +pub(crate) fn unregister_old_objects_batch( + dead: &[(usize, usize)], + scratch: &mut Vec<(usize, usize, usize)>, +) { + if dead.is_empty() { + return; + } + flush_deferred_old_page_registrations(); + scratch.clear(); + for &(header_addr, total_size) in dead { + if header_addr == 0 || total_size == 0 { + continue; + } + let object_end = header_addr + total_size; + let first_page = generation_page_for_addr(header_addr); + let last_page = generation_page_for_addr(object_end - 1); + for page in first_page..=last_page { + let page_base = generation_page_base(page); + let overlap_start = header_addr.max(page_base); + let overlap_end = object_end.min(page_base + GENERATION_PAGE_SIZE); + if overlap_start < overlap_end { + scratch.push((page, header_addr, overlap_end - overlap_start)); + } + } + } + // RUN REMOVER, as the per-object path: expand before touching membership. + // Done before borrowing the index, because expansion writes it. + if OLD_GEN_PAGE_PROMOTED_RUNS_NONEMPTY.with(Cell::get) { + let mut last = usize::MAX; + for &(page, _, _) in scratch.iter() { + if page != last { + materialize_promoted_page_runs(core::iter::once(page)); + last = page; + } + } + } + scratch.sort_unstable_by_key(|&(page, header, _)| (page, header)); + OLD_GEN_PAGE_OBJECTS.with(|index| { + OLD_GEN_PAGE_META.with(|meta| { + let mut index = index.borrow_mut(); + let mut meta = meta.borrow_mut(); + let mut start = 0; + while start < scratch.len() { + let page = scratch[start].0; + let mut end = start; + while end < scratch.len() && scratch[end].0 == page { + end += 1; + } + let group = &scratch[start..end]; + let mut removed_bytes = 0usize; + let mut removed_objects = 0usize; + let mut remove_page = false; + if let Some(headers) = index.get_mut(&page) { + headers.retain(|&addr| { + match group.binary_search_by_key(&addr, |&(_, header, _)| header) { + Ok(i) => { + removed_bytes = removed_bytes.saturating_add(group[i].2); + removed_objects += 1; + false + } + Err(_) => true, + } + }); + remove_page = headers.is_empty(); + } + if remove_page { + index.remove(&page); + } + if removed_objects != 0 { + let page_meta = meta + .entry(page) + .or_insert_with(|| OldPageMeta::zero_for_page(page)); + page_meta.allocated_bytes = + page_meta.allocated_bytes.saturating_sub(removed_bytes); + page_meta.object_count = page_meta.object_count.saturating_sub(removed_objects); + if page_meta.allocated_bytes == 0 && page_meta.object_count == 0 { + page_meta.reset_cycle_sweep_accounting(); + } + page_meta.refresh_policy_bits(); + } + start = end; + } + }); + }); +} + pub(crate) fn old_pages_begin_gc_cycle() { // #7624 CYCLE START: all three cycle constructors route through here // (`gc/mod.rs`'s minor, `gc/cycle.rs`'s `new_full`, `gc/policy.rs`'s diff --git a/crates/perry-runtime/src/arena/tests.rs b/crates/perry-runtime/src/arena/tests.rs index 9dadc3a0a0..ab319b8053 100644 --- a/crates/perry-runtime/src/arena/tests.rs +++ b/crates/perry-runtime/src/arena/tests.rs @@ -211,7 +211,7 @@ fn old_page_meta(page: usize) -> OldPageMeta { old_page_meta_for_tests(page).expect("old page metadata should be registered") } -fn old_header_and_size(user_ptr: usize) -> (usize, usize) { +pub(super) fn old_header_and_size(user_ptr: usize) -> (usize, usize) { let header_addr = user_ptr - GC_HEADER_SIZE; let total_size = unsafe { (*(header_addr as *const GcHeader)).size as usize }; (header_addr, total_size) diff --git a/crates/perry-runtime/src/arena/tests_batch_unregister.rs b/crates/perry-runtime/src/arena/tests_batch_unregister.rs new file mode 100644 index 0000000000..c21ca33a73 --- /dev/null +++ b/crates/perry-runtime/src/arena/tests_batch_unregister.rs @@ -0,0 +1,123 @@ +//! The batched old-object page unregister must be indistinguishable from the +//! per-object remover it replaces on the full sweep's hot path. +//! +//! Both are driven over the SAME population in one fresh arena: remove a subset +//! one object at a time and snapshot, restore the subset, remove it again in +//! one batch and snapshot, then require the two snapshots to be identical -- +//! page membership as walked, and every touched page's metadata. The subset +//! mixes page-spanning objects, fully emptied pages and partial pages, because +//! those are the three shapes the grouping and the per-page reset/refresh +//! have to get right. + +use super::page_meta::{ + old_arena_walk_objects_on_pages, old_object_page_overlaps, old_page_meta_for_tests, + register_old_object_pages, unregister_old_object_pages, unregister_old_objects_batch, + OldPageMeta, GENERATION_PAGE_SIZE, +}; +use super::tests::old_header_and_size as header_and_size; +use super::*; +use crate::gc::GC_TYPE_STRING; + +type Snapshot = (Vec, Vec<(usize, Option)>); + +fn snapshot(pages: &[usize]) -> Snapshot { + let mut set = crate::fast_hash::new_ptr_hash_set(); + for &page in pages { + set.insert(page); + } + let mut members = Vec::new(); + old_arena_walk_objects_on_pages(&set, |header| members.push(header as usize)); + members.sort_unstable(); + let metas = pages + .iter() + .map(|&page| (page, old_page_meta_for_tests(page))) + .collect(); + (members, metas) +} + +#[test] +fn batched_unregister_leaves_the_same_index_and_metadata_as_per_object_removal() { + super::tests::run_with_fresh_arenas(|| { + // Many small objects per page, plus objects wider than a page. + let mut objects = Vec::new(); + for i in 0..600 { + let size = if i % 97 == 0 { + GENERATION_PAGE_SIZE + 512 + } else { + 72 + }; + let ptr = arena_alloc_gc_old(size, 8, GC_TYPE_STRING) as usize; + objects.push(header_and_size(ptr)); + } + let mut pages: Vec = objects + .iter() + .flat_map(|&(h, s)| old_object_page_overlaps(h, s).into_iter().map(|(p, _)| p)) + .collect(); + pages.sort_unstable(); + pages.dedup(); + + // Every third object, every page-spanning object, and EVERY object on + // one chosen page, so at least one page empties completely. + let emptied_page = pages[pages.len() / 2]; + let subset: Vec<(usize, usize)> = objects + .iter() + .enumerate() + .filter(|&(i, &(h, s))| { + i % 3 == 0 + || s > GENERATION_PAGE_SIZE + || old_object_page_overlaps(h, s) + .iter() + .any(|&(p, _)| p == emptied_page) + }) + .map(|(_, &o)| o) + .collect(); + assert!(subset.len() > 200 && subset.len() < objects.len()); + + let before = snapshot(&pages); + + for &(h, s) in &subset { + unregister_old_object_pages(h, s); + } + let per_object = snapshot(&pages); + assert_ne!(per_object, before, "the subset must actually be removed"); + + for &(h, s) in &subset { + register_old_object_pages(h, s); + } + assert_eq!( + snapshot(&pages).0, + before.0, + "restoring the subset must restore page membership" + ); + + let mut scratch = Vec::new(); + unregister_old_objects_batch(&subset, &mut scratch); + let batched = snapshot(&pages); + + assert_eq!( + batched.0, per_object.0, + "batched removal must leave exactly the headers per-object removal leaves" + ); + for ((page, a), (_, b)) in per_object.1.iter().zip(batched.1.iter()) { + let fields = |m: &Option| { + m.map(|m| { + ( + m.allocated_bytes, + m.object_count, + m.live_bytes, + m.dead_bytes, + m.live_object_count, + m.dead_object_count, + m.evacuation_eligible, + ) + }) + }; + assert_eq!(fields(a), fields(b), "page {page:#x} metadata diverged"); + } + assert!( + per_object.1.iter().any(|(p, m)| *p == emptied_page + && m.map_or(true, |m| m.object_count == 0 && m.allocated_bytes == 0)), + "the chosen page must be emptied, or the reset path went unexercised" + ); + }); +} diff --git a/crates/perry-runtime/src/array/element_shape.rs b/crates/perry-runtime/src/array/element_shape.rs index 29f0f245cc..1fa9316ca2 100644 --- a/crates/perry-runtime/src/array/element_shape.rs +++ b/crates/perry-runtime/src/array/element_shape.rs @@ -143,6 +143,11 @@ struct ElementShapeRecord { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct ElementShapeProof { pub(crate) class_id: u32, + /// The exact ordinary ShapeId every element carries. Meaningful for a + /// class-keyed proof too, but it is the WHOLE identity of a `class_id == 0` + /// one (#10123) — the element-shape loop clone keys its per-element + /// residual check on it. + pub(crate) ordinary_shape_id: u32, pub(crate) verified_len: u32, pub(crate) epoch: u64, } @@ -289,11 +294,23 @@ fn element_identity_of_bits(value_bits: u64) -> Option<(u32, u32)> { }) { return None; } - let class_id = (*obj).class_id; - if class_id == 0 { - return None; - } - Some((class_id, shape_id)) + // #10123: a class-0 ordinary object is admitted, keyed on the exact + // ShapeId the descriptor probe above just validated. Every + // `JSON.parse`'d record is one (`object/json_construction.rs` stamps + // `class_id = 0` and an ordinary birth shape), and refusing them here + // is the reason no element-shape proof was ever established for a + // parsed record array. + // + // The identity is never `(0, 0)`: `shape_descriptor_by_id` answers + // `None` for id 0, so the `Ordinary` test above already rejected it. + // And a class-0 proof cannot be mistaken for a class-keyed one by an + // existing consumer, because every one of them compares against a + // NONZERO class id — `js_array_ensure_element_shape` keeps returning + // `class_id`, so a class-0 proof reads to them exactly as "no proof". + // What makes the identity usable is that a class-0 record's shape is + // compared EXACTLY (see `element_matches_record`), which is strictly + // narrower than the class-level match. + Some(((*obj).class_id, shape_id)) } } @@ -326,6 +343,18 @@ fn element_matches_record(value_bits: u64, record: ElementShapeRecord) -> bool { EXACT_SHAPE_STORE_HITS.with(|hits| hits.set(hits.get().wrapping_add(1))); return true; } + // #10123: a class-0 proof is the exact ShapeId and NOTHING else. The + // two fallbacks below are class-level, and "same class" is vacuous for + // class 0 — every plain object shares it — so reaching them would + // widen a shape proof into no proof at all. Both already fail closed + // for class 0 (`array_subclass_named_prefix_token_matches_class` + // returns false, and `element_identity_of_validated_object` requires a + // nonzero class), and `class_zero_record_declines_a_different_shape` + // pins that; this makes the rule local rather than a coincidence of + // two other functions. + if record.class_id == 0 { + return false; + } // Object-backed Array subclasses publish a move-stable, class-wide // ordinary-prefix proof precisely because their numeric tail mints a // different ShapeId on every push/pop. Once present, that token is a @@ -498,6 +527,7 @@ pub(crate) unsafe fn element_shape_proof(arr: *const ArrayHeader) -> Option i32 { unsafe { ensure_element_shape(arr).map_or(0, |p| p.class_id as i32) } } +/// #10123: establish-or-confirm for a **class-0** (plain-object) element +/// array, returning the exact ordinary ShapeId every element carries, or `0`. +/// +/// The sibling above answers the class question and therefore answers `0` for +/// a `JSON.parse`'d record array — correctly, since those records have no +/// class. This entry point answers the question that array CAN answer: they +/// all carry one exact ShapeId. A class-KEYED proof deliberately reports `0` +/// here rather than its shape id: the two proofs are not interchangeable +/// (`element_matches_record` matches a class-keyed record at class level, so +/// its `ordinary_shape_id` is the shape of the first element and not a +/// per-element guarantee), and handing one out would licence a shape-keyed +/// clone on a proof that never checked shapes. +#[no_mangle] +pub extern "C" fn js_array_ensure_element_shape_ordinary(arr: *mut ArrayHeader) -> i32 { + unsafe { + match ensure_element_shape(arr) { + Some(proof) if proof.class_id == 0 => proof.ordinary_shape_id as i32, + _ => 0, + } + } +} + /// The O(1) query with no scan: the proven `class_id`, or `0`. #[no_mangle] pub extern "C" fn js_array_element_shape_class(arr: *const ArrayHeader) -> i32 { @@ -776,19 +828,25 @@ pub extern "C" fn js_array_element_shape_check( } } -// NOTE — exactly ONE `keepalive-anchors` `#[used]` static, deliberately. +// NOTE — anchor EXACTLY the entries codegen emits a call to, and no more. // `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 +// every shipped binary: anchoring all six 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. +// versioned-loop clone emits a call from its preheader to +// `js_array_ensure_element_shape` (the class-keyed arm) or to +// `js_array_ensure_element_shape_ordinary` (#10123's shape-keyed arm), so +// those two — and only those two — are 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(feature = "keepalive-anchors")] +#[used] +static KEEP_ARRAY_ENSURE_ELEMENT_SHAPE_ORDINARY: extern "C" fn(*mut ArrayHeader) -> i32 = + js_array_ensure_element_shape_ordinary; + #[cfg(test)] pub(crate) fn test_element_shape_record_exists(owner: usize) -> bool { ELEMENT_SHAPES.with(|m| m.borrow().contains_key(&owner)) diff --git a/crates/perry-runtime/src/array/element_shape_tests.rs b/crates/perry-runtime/src/array/element_shape_tests.rs index 2fe5bc67e6..c90194d586 100644 --- a/crates/perry-runtime/src/array/element_shape_tests.rs +++ b/crates/perry-runtime/src/array/element_shape_tests.rs @@ -661,3 +661,265 @@ fn pruning_dead_owners_removes_their_records() { "pruning must not disturb a live array's proof" ); } + +// --------------------------------------------------------------------------- +// #10123 — CLASS-0 (plain-object) element arrays. +// +// A `JSON.parse`'d record has `class_id == 0` and an ordinary birth ShapeId +// (`object/json_construction.rs`), so the class-keyed invariant declined every +// parsed record array: `element_identity_of_bits` refused class 0 outright and +// no proof was ever established. The proof below is keyed on the EXACT ShapeId +// instead, which is strictly narrower than a class-level match — and it has to +// be, because "same class" is vacuous when the class is 0. +// --------------------------------------------------------------------------- + +/// A keys array of heap-allocated interned property names, the shape a +/// parser's canonical keys array has. +fn keys_array(names: &[&str]) -> *mut ArrayHeader { + let keys = crate::array::js_array_alloc_with_length(names.len() as u32); + for (index, name) in names.iter().enumerate() { + let string = crate::js_string_from_bytes(name.as_ptr(), name.len() as u32); + crate::array::js_array_set(keys, index as u32, crate::JSValue::string_ptr(string)); + } + keys +} + +/// `(keys, shape_id)` for a plain ordinary shape over `names`. +fn record_shape(names: &[&str]) -> (*mut ArrayHeader, u32) { + let keys = keys_array(names); + let shape_id = crate::object::shapes::shape_id_for_keys_ensure(keys, names.len() as u32); + assert_ne!(shape_id, 0, "test premise: the keys must mint a shape"); + (keys, shape_id) +} + +/// One class-0 ordinary record, birthed exactly the way +/// `object/json_construction.rs` births a parsed one: class 0, the shape +/// stamped from the canonical keys array, values in key order. +fn json_like_record(keys: *mut ArrayHeader, shape_id: u32, field_count: u32) -> f64 { + let obj = + crate::object::js_object_alloc_class_inline_keys_stamped(0, 0, field_count, keys, shape_id); + unsafe { + assert_eq!( + (*obj).class_id, + 0, + "test premise: the fixture record must be class 0" + ); + assert_eq!( + (*obj).parent_class_id, + shape_id, + "test premise: the fixture record must carry the requested shape" + ); + } + crate::value::js_nanbox_pointer(obj as i64) +} + +#[test] +fn a_homogeneous_class_zero_record_array_proves_on_its_shape_id() { + let _serialized = test_serialize(); + let (keys, shape_id) = record_shape(&["id", "name"]); + let mut arr = js_array_alloc(4); + for _ in 0..4 { + arr = push(arr, json_like_record(keys, shape_id, 2)); + } + + let established = unsafe { ensure_element_shape(arr) } + .expect("a homogeneous class-0 record array must prove (#10123)"); + assert_eq!( + established.class_id, 0, + "the proof stays class 0 — every class-keyed consumer must read it as `no class`" + ); + assert_eq!(established.ordinary_shape_id, shape_id); + assert_eq!(established.verified_len, 4); + + // The FFI the clone's shape-keyed preheader calls. + assert_eq!( + js_array_ensure_element_shape_ordinary(arr), + shape_id as i32, + "the shape-keyed entry point must hand back the exact ShapeId" + ); + // ... and the class-keyed one still answers 0, so a class-keyed preheader + // comparing against its own (nonzero) class id takes the slow clone. + assert_eq!( + js_array_ensure_element_shape(arr), + 0, + "a class-0 proof must never read as a class proof" + ); +} + +#[test] +fn a_mixed_shape_class_zero_record_array_declines() { + let _serialized = test_serialize(); + let (keys_a, shape_a) = record_shape(&["id", "name"]); + let (keys_b, shape_b) = record_shape(&["id", "name", "extra"]); + assert_ne!(shape_a, shape_b, "test premise: two distinct shapes"); + let mut arr = js_array_alloc(4); + arr = push(arr, json_like_record(keys_a, shape_a, 2)); + arr = push(arr, json_like_record(keys_a, shape_a, 2)); + arr = push(arr, json_like_record(keys_b, shape_b, 3)); + + assert!( + unsafe { ensure_element_shape(arr) }.is_none(), + "a record with a DIFFERENT key set must decline the whole array — this is \ + the heterogeneous JSON case, and admitting it would licence a field read \ + at a slot the second shape does not have" + ); + assert_eq!(js_array_ensure_element_shape_ordinary(arr), 0); +} + +#[test] +fn class_zero_record_declines_a_different_shape() { + // The load-bearing narrowing: `element_matches_record`'s class-level + // fallbacks are vacuous for class 0 (every plain object shares it), so a + // class-0 record is matched on its exact ShapeId and nothing else. + let _serialized = test_serialize(); + let (keys_a, shape_a) = record_shape(&["id", "name"]); + let (keys_b, shape_b) = record_shape(&["id", "flag"]); + assert_ne!(shape_a, shape_b); + let mut arr = js_array_alloc(4); + for _ in 0..3 { + arr = push(arr, json_like_record(keys_a, shape_a, 2)); + } + assert_eq!( + unsafe { ensure_element_shape(arr) } + .expect("proven") + .ordinary_shape_id, + shape_a + ); + + js_array_set_f64(arr, 1, json_like_record(keys_b, shape_b, 2)); + + assert!( + proof(arr).is_none(), + "a same-class (class 0) but different-SHAPE store must clear the proof" + ); + unsafe { assert!(!test_element_shape_bit_set(arr)) }; +} + +#[test] +fn class_zero_proof_keeps_a_same_shape_store_and_clears_a_primitive_one() { + let _serialized = test_serialize(); + let (keys, shape_id) = record_shape(&["id", "name"]); + let mut arr = js_array_alloc(4); + for _ in 0..3 { + arr = push(arr, json_like_record(keys, shape_id, 2)); + } + let established = unsafe { ensure_element_shape(arr) }.expect("proven"); + + js_array_set_f64(arr, 1, json_like_record(keys, shape_id, 2)); + let kept = proof(arr).expect("a same-shape store must keep the proof"); + assert_eq!(kept.epoch, established.epoch, "the proof identity survives"); + assert_eq!(kept.ordinary_shape_id, shape_id); + + js_array_set_f64(arr, 1, 42.0); + assert!( + proof(arr).is_none(), + "a primitive store must clear a class-0 proof like any other" + ); +} + +#[test] +fn a_class_keyed_proof_reports_no_ordinary_shape_id_to_the_shape_keyed_entry() { + // The two proofs are not interchangeable: a class-keyed record matches at + // CLASS level, so its `ordinary_shape_id` is the first element's shape and + // not a per-element guarantee. Handing it to the shape-keyed clone would + // licence an exact-shape read on a proof that never checked shapes. + let _serialized = test_serialize(); + let arr = built_from_pushes(CLASS_A, 3); + assert_eq!(js_array_ensure_element_shape(arr), CLASS_A as i32); + assert_eq!( + js_array_ensure_element_shape_ordinary(arr), + 0, + "a class-keyed proof must not hand its shape id to the shape-keyed clone" + ); + assert_eq!( + proof(arr).expect("proven").class_id, + CLASS_A, + "and the class-keyed proof itself is unchanged" + ); +} + +#[test] +fn class_zero_admission_does_not_disturb_class_keyed_proofs() { + let _serialized = test_serialize(); + let arr = built_from_pushes(CLASS_A, 4); + let established = proof(arr).expect("proven"); + assert_eq!(established.class_id, CLASS_A); + assert_ne!( + established.ordinary_shape_id, 0, + "a class instance still carries an exact ordinary shape id" + ); + + // A class-B store still clears; a class-A store with a DIFFERENT shape + // still keeps through the class-level fallback (the behaviour #10123 must + // not narrow for a nonzero class). + js_array_set_f64(arr, 1, instance(CLASS_A)); + assert!(proof(arr).is_some(), "a same-class store must still keep"); + js_array_set_f64(arr, 1, instance(CLASS_B)); + assert!(proof(arr).is_none(), "a different-class store must clear"); +} + +// --------------------------------------------------------------------------- +// #10123 — the shape's key -> inline slot query the clone's preheader asks. +// --------------------------------------------------------------------------- + +#[test] +fn the_ordinary_slot_query_answers_the_key_position() { + let _serialized = test_serialize(); + let (_keys, shape_id) = record_shape(&["id", "name", "score"]); + for (index, name) in ["id", "name", "score"].iter().enumerate() { + let key = crate::js_string_from_bytes(name.as_ptr(), name.len() as u32); + assert_eq!( + crate::object::shapes::js_shape_ordinary_inline_slot_for_key( + shape_id, + crate::JSValue::string_ptr(key).bits(), + ), + index as i32, + "slot k must be key position k for a plain birth-stamped shape" + ); + } +} + +#[test] +fn the_ordinary_slot_query_declines_an_absent_key_and_an_unknown_shape() { + let _serialized = test_serialize(); + let (_keys, shape_id) = record_shape(&["id", "name"]); + let missing = crate::js_string_from_bytes(b"nope".as_ptr(), 4); + assert_eq!( + crate::object::shapes::js_shape_ordinary_inline_slot_for_key( + shape_id, + crate::JSValue::string_ptr(missing).bits(), + ), + -1 + ); + assert_eq!( + crate::object::shapes::js_shape_ordinary_inline_slot_for_key(0, { + let key = crate::js_string_from_bytes(b"id".as_ptr(), 2); + crate::JSValue::string_ptr(key).bits() + }), + -1, + "shape id 0 names no descriptor" + ); +} + +#[test] +fn the_ordinary_slot_query_matches_an_sso_immediate_against_a_heap_key() { + // Codegen hands the key over as the whole NaN-boxed pool value, and a + // short property name ("id") reaches the pool as an SSO IMMEDIATE whose + // masked low bits are packed characters, not an address. A pointer-only + // comparison would answer -1 for exactly the key names this optimization + // exists for. + let _serialized = test_serialize(); + let (_keys, shape_id) = record_shape(&["id", "name"]); + let heap = crate::js_string_from_bytes(b"id".as_ptr(), 2); + let sso = unsafe { crate::string::short_ascii_sso_bits(heap) } + .expect("test premise: `id` fits the SSO immediate form"); + assert_ne!( + sso, + crate::JSValue::string_ptr(heap).bits(), + "test premise: the two representations really are different bits" + ); + assert_eq!( + crate::object::shapes::js_shape_ordinary_inline_slot_for_key(shape_id, sso), + 0 + ); +} diff --git a/crates/perry-runtime/src/gc/copying_phase.rs b/crates/perry-runtime/src/gc/copying_phase.rs index 5c693f7005..9bfbc6b5e2 100644 --- a/crates/perry-runtime/src/gc/copying_phase.rs +++ b/crates/perry-runtime/src/gc/copying_phase.rs @@ -99,7 +99,7 @@ impl CopyingMinorPhaseDiag { let mut out = String::new(); write!( out, - "root_scan={}/{} copy_evacuation={}/{}/{} remembered_set_young_logs={}/{}/{} promotion={}/{}/{} dead_owner_side_table_pruning={}{} from_space_finalization={}/map:{}/{}+set:{}/{}+errors:{}/{}+regex:{}/{} forwarding_fixups={} block_reset_flip={} other={} phase_sum_us={}", + "root_scan={}/{} copy_evacuation={}/{}/{} remembered_set_young_logs={}/{}/{} promotion={}/{}/{} dead_owner_side_table_pruning={}{} from_space_finalization={}/map:{}/{}+set:{}/{}+errors:{}/{}+regex:{}/{}+lazytape:{}/{} forwarding_fixups={} block_reset_flip={} other={} phase_sum_us={}", self.root_scan_ns / 1000, scan_us, self.copy_evacuation_ns / 1000, @@ -122,6 +122,8 @@ impl CopyingMinorPhaseDiag { finalization.errors, finalization.regex_ns / 1000, finalization.regexps, + finalization.lazy_tape_ns / 1000, + finalization.lazy_tapes, self.forwarding_fixups_ns / 1000, self.block_reset_flip_ns / 1000, other_ns / 1000, @@ -145,6 +147,8 @@ pub(super) struct CopiedMinorFinalizationDiag { pub(super) regexps: usize, pub(super) dead_owner_ns: u64, pub(super) dead_owner_detail: String, + pub(super) lazy_tapes: usize, + pub(super) lazy_tape_ns: u64, } /// Finalize the side allocations whose from-space owners just died. The @@ -175,6 +179,10 @@ pub(super) fn finalize_dead_copied_minor_from_space_side_allocations() -> Copied out.regexps = crate::regex::finalize_dead_copied_minor_from_space_regexps(); out.regex_ns = start.map_or(0, |start| start.elapsed().as_nanos() as u64); + let start = diag.then(Instant::now); + out.lazy_tapes = crate::json_tape_store::finalize_dead_copied_minor_from_space_lazy_tapes(); + out.lazy_tape_ns = start.map_or(0, |start| start.elapsed().as_nanos() as u64); + let start = diag.then(Instant::now); out.dead_owner_detail = super::dead_owner::prune_dead_owner_side_tables_copied_minor(); out.dead_owner_ns = start.map_or(0, |start| start.elapsed().as_nanos() as u64); diff --git a/crates/perry-runtime/src/gc/oldgen.rs b/crates/perry-runtime/src/gc/oldgen.rs index 0c0ef47b5d..0a96aea979 100644 --- a/crates/perry-runtime/src/gc/oldgen.rs +++ b/crates/perry-runtime/src/gc/oldgen.rs @@ -1345,6 +1345,8 @@ impl IncrementalSweepState { struct ArenaSweepObjectsState { cursor: crate::arena::ArenaObjectCursor, + /// Dead old headers awaiting one batched page-index removal (see `sweep_batch`). + pending_old_unregister: sweep_batch::PendingOldUnregister, block_snapshots: Vec, block_has_live: Vec, resettable_general_n: usize, @@ -1404,6 +1406,7 @@ impl ArenaSweepObjectsState { crate::arena::old_pages_reset_sweep_accounting(); Self { cursor: crate::arena::ArenaObjectCursor::new(crate::arena::ArenaWalkOrder::BlockIndex), + pending_old_unregister: Default::default(), block_snapshots, block_has_live: vec![false; n_blocks], resettable_general_n: crate::arena::general_block_count(), @@ -1445,14 +1448,18 @@ impl ArenaSweepObjectsState { fn step(&mut self, budget: usize) -> bool { let mut remaining = budget; + let mut done = false; while remaining > 0 { let Some((header_ptr, block_idx)) = self.cursor.next() else { - return true; + done = true; + break; }; remaining -= 1; self.process_object(header_ptr as *mut GcHeader, block_idx); } - false + // Never leave a dead header in the page index across a step boundary. + self.pending_old_unregister.flush(); + done } fn block_has_live(&self) -> &[bool] { @@ -1624,7 +1631,7 @@ impl ArenaSweepObjectsState { gc_type_clear_dead_payload_side_tables((*header).obj_type, user_ptr as usize); } if self.reclaim_dead_old_blocks && dead_old { - invalidate_dead_old_arena_header(header, total_size); + self.pending_old_unregister.defer(header, total_size); } else { (*header).gc_flags = flags & !(GC_FLAG_FORWARDED | GC_FLAG_MARKED); } @@ -1643,7 +1650,7 @@ impl ArenaSweepObjectsState { } finalize_dead_arena_payload(header, user_ptr, self.overflow_active); if self.reclaim_dead_old_blocks && dead_old { - invalidate_dead_old_arena_header(header, total_size); + self.pending_old_unregister.defer(header, total_size); } } } @@ -1656,6 +1663,7 @@ enum ArenaSweepCleanupSubphase { Done, } +mod sweep_batch; mod sweep_cleanup; use sweep_cleanup::*; diff --git a/crates/perry-runtime/src/gc/oldgen/sweep_batch.rs b/crates/perry-runtime/src/gc/oldgen/sweep_batch.rs new file mode 100644 index 0000000000..db13089b1c --- /dev/null +++ b/crates/perry-runtime/src/gc/oldgen/sweep_batch.rs @@ -0,0 +1,46 @@ +//! Batched page-index removal for the dead old objects a sweep step frees. +//! +//! `invalidate_dead_old_arena_header` unregisters each dead old object from the +//! page index on its own, and that removal is quadratic in objects per page (see +//! `arena::unregister_old_objects_batch`). A full collection frees whole pages +//! of small objects, so the sweep queues them here and removes a step's worth in +//! one pass. The header fields are still invalidated immediately, exactly as +//! before, so no walker can read a dead header as a live object in between. + +use super::super::*; + +/// Flush once this many dead headers are queued, so a single unbudgeted sweep +/// of a large heap never stages an unbounded buffer. +const FLUSH_AT: usize = 4096; + +#[derive(Default)] +pub(super) struct PendingOldUnregister { + dead: Vec<(usize, usize)>, + scratch: Vec<(usize, usize, usize)>, +} + +impl PendingOldUnregister { + /// Invalidate a dead old header now and queue its page-index removal. + /// + /// # Safety + /// + /// `header` must be a dead old-gen arena header of `total_size` bytes. + pub(super) unsafe fn defer(&mut self, header: *mut GcHeader, total_size: usize) { + (*header).obj_type = 0; + (*header).gc_flags = 0; + (*header)._reserved = 0; + self.dead.push((header as usize, total_size)); + if self.dead.len() >= FLUSH_AT { + self.flush(); + } + } + + /// Remove every queued header from the page index. + pub(super) fn flush(&mut self) { + if self.dead.is_empty() { + return; + } + crate::arena::unregister_old_objects_batch(&self.dead, &mut self.scratch); + self.dead.clear(); + } +} diff --git a/crates/perry-runtime/src/gc/tests/alloc.rs b/crates/perry-runtime/src/gc/tests/alloc.rs index ec7b11cd34..5d58365976 100644 --- a/crates/perry-runtime/src/gc/tests/alloc.rs +++ b/crates/perry-runtime/src/gc/tests/alloc.rs @@ -481,10 +481,14 @@ fn test_gc_type_metadata_covers_all_declared_types() { arena_walkable: true, rewrite_descriptor_kind: GcRewriteDescriptorKind::LazyArray, layout_slot_kind: GcLayoutSlotKind::None, - // #7539: NOT movable. The tape registry is keyed by the header - // address, and callers outside `json_tape` hold raw header - // pointers across allocations. - movable: false, + // Movable since the tape registration follows its owner + // (`GcMoveHookKind::LazyArrayTape`) and a header dying in a + // copying minor's from-space gives its tape back + // (`finalize_dead_copied_minor_from_space_lazy_tapes`). Those two + // were the whole reason for `false`; pinning cost a dead cluster's + // entire element graph, held live through the remembered set until + // a full collection. + movable: true, // #7539: the tape is a `json_tape_store` side allocation, not // inline payload. Inline, it made the header as large as the tape // (~2.4 MB on a 10k-record blob), which `arena_alloc_gc` routed @@ -493,7 +497,7 @@ fn test_gc_type_metadata_covers_all_declared_types() { external_byte_policy: GcExternalBytePolicy::SideAllocation, large_object_policy: GcLargeObjectPolicy::OldArenaWhenOverThreshold, pointer_free: false, - move_hook_kind: GcMoveHookKind::None, + move_hook_kind: GcMoveHookKind::LazyArrayTape, rewrite_hook_kind: GcRewriteHookKind::None, finalize_hook_kind: GcFinalizeHookKind::LazyArrayTape, }, diff --git a/crates/perry-runtime/src/gc/tests/helper_stores.rs b/crates/perry-runtime/src/gc/tests/helper_stores.rs index b3752e2f70..d20458f8d8 100644 --- a/crates/perry-runtime/src/gc/tests/helper_stores.rs +++ b/crates/perry-runtime/src/gc/tests/helper_stores.rs @@ -6,8 +6,14 @@ use std::fmt::Write as _; /// born-tenured threshold — see `copying.rs`'s `OLD_BORN_ELEMENTS`. These two /// fixtures assert that a materialized JSON object / regex result array is an /// OLD-generation birth holding young children, so they must actually be one. +/// +/// #10123: a JSON-constructed object's storage is admitted young up to +/// `LARGE_OBJECT_STORAGE_YOUNG_BIRTH_CEILING_BYTES`, which is HIGHER than the +/// flat pointer-bearing threshold. Derive from the ceiling that actually +/// governs this fixture, or the "is an old-generation birth" premise silently +/// stops holding and the test fails on its premise rather than its subject. const OLD_BORN_FIELDS: u32 = - (crate::gc::LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES / 8) as u32 + 64; + (crate::gc::LARGE_OBJECT_STORAGE_YOUNG_BIRTH_CEILING_BYTES / 8) as u32 + 64; unsafe fn alloc_old_test_map( capacity: u32, diff --git a/crates/perry-runtime/src/gc/tests/lazy_tape_side_alloc.rs b/crates/perry-runtime/src/gc/tests/lazy_tape_side_alloc.rs index dee22b81a6..ba6533dbc4 100644 --- a/crates/perry-runtime/src/gc/tests/lazy_tape_side_alloc.rs +++ b/crates/perry-runtime/src/gc/tests/lazy_tape_side_alloc.rs @@ -133,22 +133,25 @@ fn test_old_generation_growth_does_not_scale_with_tape_size() { ); } -/// The header allocation no longer scales with the tape — but it stays in the -/// OLD generation and born tenured, exactly where a multi-megabyte inline-tape -/// header always landed. +/// The header allocation no longer scales with the tape, and a LARGE cluster +/// still lands in the old generation born tenured. /// -/// That is the load-bearing half of this test, not a leftover. `json_tape_store` -/// keys a tape by its owner's address, and every caller outside `json_tape` -/// holds raw `*mut LazyArrayHeader` across allocations — -/// `json::stringify_api::try_stringify_lazy_array` reads `blob_bytes` off a raw -/// header and then allocates the result string. Letting the shrunken header -/// fall into the nursery made it movable for the first time and the copying -/// minor relocated it out from under those callers: `field_access` went -/// non-deterministic, emitting a JSON string of NUL bytes for -/// `JSON.stringify(parsed)` on 3 of 60 iterations. If a future change routes -/// the header allocation back through `arena_alloc_gc`, this fails. +/// It stays there on size now, not on principle. The old-gen request used to be +/// unconditional because `json_tape_store` keys a tape by its owner's address +/// and the copying minor's flip runs no finalize hook, so a nursery header +/// would orphan or leak its tape — `JSON.stringify(parsed)` emitted a string of +/// NUL bytes on 3 of 60 `field_access` iterations when that was tried. +/// `GcMoveHookKind::LazyArrayTape` and +/// `json_tape_store::finalize_dead_copied_minor_from_space_lazy_tapes` remove +/// both reasons, so the generation is decided by cache size — and #7546's rule +/// that header, cache and bitmap share one generation still decides it once. +/// +/// This fixture is `big_blob()`, whose cache is far over the large-object line, +/// so it must still be old: if a future change made even a large cluster +/// nursery-resident, the promotion behaviour this test pins would stop being +/// exercised. #[test] -fn test_lazy_header_is_small_but_stays_old_gen_and_immovable() { +fn test_large_lazy_cluster_is_still_born_old_and_tenured() { let _guard = GcTestIsolationGuard::new(); let blob = big_blob(); let tape_bytes = tape_bytes_of(&blob); @@ -158,12 +161,13 @@ fn test_lazy_header_is_small_but_stays_old_gen_and_immovable() { assert!( crate::arena::pointer_in_old_gen(lazy as usize), - "the header must stay old-gen: callers outside json_tape hold raw \ - header pointers across allocations" + "a cluster this large must still be born old — otherwise the \ + large-object promotion path here stops being exercised" ); assert!( - !crate::gc::gc_type_is_movable(crate::gc::GC_TYPE_LAZY_ARRAY), - "a lazy array must not be movable — its tape is keyed by its address" + crate::gc::gc_type_is_movable(crate::gc::GC_TYPE_LAZY_ARRAY), + "the type is movable now: the tape registration follows its owner and \ + a from-space death gives the tape back" ); unsafe { let header = (lazy as *const u8).sub(GC_HEADER_SIZE) as *const GcHeader; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs index af1ee42c93..64d80d299a 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs @@ -270,19 +270,25 @@ fn test_json_tape_lazy_get_header_handle_survives_copied_minor_gc() { JsonTapeSafepointHookGuard::new(crate::json_tape::JsonTapeSafepoint::LazyArrayRooted); let hdr = unsafe { test_alloc_lazy_json_array(input) }; let original_hdr = hook.fired_ptr(); - // #7539: the header is old-gen and immovable by construction, so a - // copied minor at the safepoint CANNOT relocate it — that is the - // property `try_stringify_lazy_array` and the array accessors rely on - // when they hold a raw header across an allocation. What must still be - // true is that `alloc_lazy_array` hands back the address the collector - // sees, i.e. the one its own rooted handle resolves to. - assert_eq!( + // A small lazy cluster is nursery-resident and movable now: pinning it + // meant a minor could never reclaim a dead one, and it held its whole + // element graph live through the remembered set. So the header DOES + // relocate here — the hook observed the address before the collection + // it triggered — and what `alloc_lazy_array` owes its caller is the + // REFRESHED address, read back through its own rooted handle. + assert!( + !crate::arena::pointer_in_old_gen(hdr as usize), + "a two-element cluster is small, so it must be nursery-resident" + ); + assert_ne!( hdr as usize, original_hdr, - "the lazy header must not move across a copied-minor GC" + "a nursery header must relocate across the safepoint, or this test \ + proves nothing about the refresh" ); - assert!( - crate::arena::pointer_in_old_gen(hdr as usize), - "…because it is old-gen, which is what makes that guaranteed" + assert_eq!( + unsafe { (*hdr).magic }, + crate::json_tape::LAZY_ARRAY_MAGIC, + "the returned address must be the live header, not the stale one" ); hdr }; @@ -293,11 +299,18 @@ fn test_json_tape_lazy_get_header_handle_survives_copied_minor_gc() { let value = unsafe { crate::json_tape::lazy_get(hdr_handle.get_raw_mut_ptr(), 0) }; let original_hdr = hook.fired_ptr(); let hdr_after = hdr_handle.get_raw_mut_ptr::(); - assert_eq!( + assert_ne!( hdr_after as usize, original_hdr, - "the lazy header must not move across a copied-minor GC (#7539)" + "a nursery header must relocate across lazy_get's safepoint too" ); unsafe { + assert_eq!( + (*hdr_after).magic, + crate::json_tape::LAZY_ARRAY_MAGIC, + "the handle must resolve to the live header after the move" + ); + // The cache the relocated header points at must be the one lazy_get + // wrote through: a stale cache edge would read back as an empty bitmap. let bitmap = (*hdr_after).materialized_bitmap; assert!(!bitmap.is_null()); assert_ne!(*bitmap & 1, 0, "cold lazy_get should cache element 0"); @@ -375,10 +388,16 @@ fn test_json_tape_lazy_get_records_its_cache_store_as_an_external_edge() { // Born-old header. That is the shape the #7538 workload had and the only // one where the in-object/external distinction bites — a nursery header is // traced directly and its descriptor reaches the cache without any - // remembered-set entry at all. #7539 moved the tape into a side allocation - // but deliberately kept the header in the old generation, so this premise - // still holds by construction rather than by the header being large. - let elements = 4096; + // remembered-set entry at all. + // + // The cluster's generation is decided by its cache size now, against the + // POINTER-BEARING threshold (128 KB), so the premise has to be bought with + // element count rather than assumed: 20 000 JSValues is ~156 KB, safely + // over the line. 4096 elements used to suffice only because every lazy + // header was born old unconditionally, and at 32 KB it would now be a + // NURSERY cluster — this test would still pass its later assertions while + // exercising none of the containment branch it exists for. + let elements = 20_000; let mut input = String::with_capacity(elements * 8 + 2); input.push('['); for i in 0..elements { @@ -492,13 +511,19 @@ fn test_json_tape_force_materialize_sparse_cache_handles_survive_copied_minor_gc ); let original_arr = hook.fired_ptr(); let hdr_after = hdr_handle.get_raw_mut_ptr::(); - assert_eq!( + // A four-element cluster is nursery-resident, so the header relocates here + // as well: the rooted handle, not the address, is what keeps a caller right. + assert_ne!( hdr_after as usize, before_force_hdr, - "the lazy header must not move across a copied-minor GC (#7539)" + "a small lazy header is young, so force materialization must relocate it" + ); + assert_eq!( + unsafe { (*hdr_after).magic }, + crate::json_tape::LAZY_ARRAY_MAGIC, + "…and the handle must still resolve to the live header" ); - // The MATERIALIZED ARRAY is young and does move — which is the handle - // refresh this test is really about, and the reason the header being - // stable does not make it vacuous. + // The MATERIALIZED ARRAY moves too, and its handle must refresh for the + // same reason. assert_ne!( arr as usize, original_arr, "force materialization should refresh the rooted array handle" diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/json_construction.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/json_construction.rs index 651dae8111..288a49701b 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/json_construction.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/json_construction.rs @@ -63,9 +63,10 @@ fn json_construction_wide_old_record_remembers_sparse_pointer_pages() { gc_register_mutable_root_scanner(json_parse_mutable_root_scanner); let source = format!( "{{{}}}", - (0..20_000) + (0..super::json_key_lifetime::fields_born_old()) .map(|i| { - let value = if i % 509 == 0 || i == 19_999 { + let value = if i % 509 == 0 || i == super::json_key_lifetime::fields_born_old() - 1 + { format!(r#"{{"child":{i}}}"#) } else { i.to_string() @@ -87,13 +88,13 @@ fn json_construction_wide_old_record_remembers_sparse_pointer_pages() { let last_child = |value: crate::JSValue| { assert_eq!( crate::object::object_live_slot_count(value.as_pointer()), - 20_000 + super::json_key_lifetime::fields_born_old() as u32 ); let slots = value .as_pointer::() .add(std::mem::size_of::()) .cast::(); - (*slots.add(19_999)).bits() + (*slots.add(super::json_key_lifetime::fields_born_old() - 1)).bits() }; let last_before = last_child(value); assert!(crate::JSValue::from_bits(last_before).is_pointer()); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/json_key_lifetime.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/json_key_lifetime.rs index 271ee03aa3..048572aace 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/json_key_lifetime.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/json_key_lifetime.rs @@ -1,10 +1,26 @@ //! Cache eviction must release ownership, including the backing key storage. use super::*; +/// Field count whose storage exceeds the birth-generation ceiling that governs +/// a JSON-constructed object, so this fixture is born OLD whatever that +/// constant is. +/// +/// #10123: hardcoding a width silently pinned these tests to +/// `LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES == 128 KB`. When the JSON +/// construction path gained a higher young-birth ceiling the fixture turned +/// young and the tests failed on their PREMISE (`pointer_in_old_gen`) rather +/// than on anything they were written to check. Derived, the old-gen path stays +/// covered at any ceiling. +pub(super) fn fields_born_old() -> usize { + (crate::gc::LARGE_OBJECT_STORAGE_YOUNG_BIRTH_CEILING_BYTES + / std::mem::size_of::()) + + 1024 +} + fn wide_source() -> String { format!( "{{{}}}", - (0..50_000) + (0..fields_born_old()) .map(|i| format!("\"field_{i}\":{i}")) .collect::>() .join(",") @@ -44,7 +60,7 @@ fn json_discarded_wide_keys_release_storage_after_cache_eviction() { let value = parse(&source); assert_eq!( crate::object::object_live_slot_count(value.as_pointer()), - 50_000 + fields_born_old() as u32 ); } assert_eq!( @@ -129,11 +145,15 @@ fn json_retained_wide_object_keeps_evicted_keys_through_minor_and_full_gc() { let root = scope.root_nanbox_u64(value.bits()); let keys = crate::object::object_keys_array(value.as_pointer()); assert!(crate::arena::pointer_in_old_gen(keys as usize)); - let last_before = crate::array::js_array_get(keys, 49_999).bits(); + let last_idx = (fields_born_old() - 1) as u32; + let last_before = crate::array::js_array_get(keys, last_idx).bits(); gc_collect_minor(); let live = crate::JSValue::from_bits(root.get_nanbox_u64()); let keys = crate::object::object_keys_array(live.as_pointer()); - assert_ne!(last_before, crate::array::js_array_get(keys, 49_999).bits()); + assert_ne!( + last_before, + crate::array::js_array_get(keys, last_idx).bits() + ); assert_output(live, &source); collect_full(); assert_output(crate::JSValue::from_bits(root.get_nanbox_u64()), &source); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/json_tape_owned.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/json_tape_owned.rs index aa0811f4ba..607f1215db 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/json_tape_owned.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/json_tape_owned.rs @@ -221,7 +221,19 @@ fn json_owned_tape_roots_its_blob_through_copied_minor_during_construction() { JsonTapeSafepointHookGuard::new(crate::json_tape::JsonTapeSafepoint::LazyArrayRooted); let lazy = unsafe { crate::json_tape::alloc_lazy_array_from_scratch(&mut entries, 0, 2, text) }; assert_eq!(entries.capacity(), 0); - assert_eq!(hook.fired_ptr(), lazy as usize); + // The header is movable now, so the collection this hook fires inside + // relocates it: the hook saw the pre-move address and `alloc_lazy_array` + // returns the refreshed one, read back through its own rooted handle. + assert_ne!( + hook.fired_ptr(), + lazy as usize, + "a nursery header must relocate across its construction safepoint" + ); + assert_eq!( + unsafe { (*lazy).magic }, + crate::json_tape::LAZY_ARRAY_MAGIC, + "…and the returned address must be the live header" + ); assert!(gc_collection_count() > before_gc); assert_ne!( unsafe { (*lazy).blob_str }, @@ -246,32 +258,48 @@ fn json_owned_tapes_remain_independent_and_release_only_dead_owners() { let _guard = CopyingNurseryTestGuard::new(2); let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let before = crate::json_tape_store::registered_bytes(); + // Both owners are movable and nursery-resident now, so a raw header read + // after any collection has to come back through a root. This test's roots + // are the shadow slots, which the collector rewrites — so re-read them + // rather than keeping the addresses `owned_small` returned. + let slot = |i: u32| -> *mut crate::json_tape::LazyArrayHeader { + (js_shadow_slot_get(i) & 0x0000_FFFF_FFFF_FFFF) as *mut crate::json_tape::LazyArrayHeader + }; let first = unsafe { owned_small(b"[1,2,3]") }; js_shadow_slot_set(0, ptr_bits(first as usize)); let first_bytes = crate::json_tape_store::registered_bytes() - before; let second = unsafe { owned_small(b"[4,5,6,7]") }; js_shadow_slot_set(1, ptr_bits(second as usize)); let both = crate::json_tape_store::registered_bytes(); + let (first, second) = (slot(0), slot(1)); assert_ne!(unsafe { (*first).tape }, unsafe { (*second).tape }); let _ = gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Direct)); assert_eq!(crate::json_tape_store::registered_bytes(), both); assert_eq!( - unsafe { crate::json_tape::LazyArrayHeader::blob_bytes(first) }, + unsafe { crate::json_tape::LazyArrayHeader::blob_bytes(slot(0)) }, b"[1,2,3]" ); assert_eq!( - unsafe { crate::json_tape::LazyArrayHeader::blob_bytes(second) }, + unsafe { crate::json_tape::LazyArrayHeader::blob_bytes(slot(1)) }, b"[4,5,6,7]" ); js_shadow_slot_set(0, crate::JSValue::undefined().bits()); + // These owners are nursery-resident now, and nursery reclamation belongs to + // a minor: `finalize_dead_copied_minor_from_space_lazy_tapes` is what gives + // a dead owner's tape back, the way Map/Set/Error/RegExp give theirs back. + // A full mark-sweep alone leaves the mark bits of a nursery object to the + // minor, so it neither clears them nor reclaims here — asserting on a full + // sweep would be asserting against the wrong pass. let _ = gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Direct)); + let _ = gc_collect_minor_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Direct)); assert_eq!( crate::json_tape_store::registered_bytes(), - both - first_bytes + both - first_bytes, + "a dead nursery owner must give its tape back on a minor" ); - let array = unsafe { crate::json_tape::force_materialize_lazy(second) }; + let array = unsafe { crate::json_tape::force_materialize_lazy(slot(1)) }; assert_eq!(unsafe { (*array).length }, 4); assert_eq!(crate::json_tape_store::registered_bytes(), before); } @@ -345,6 +373,12 @@ fn json_lazy_assignment_roots_heap_key_and_value_during_materialization() { crate::arena::ProtectionModeGuard::set(crate::arena::FromSpaceProtection::PoisonOnly); register_runtime_handle_root_scanner_for_tests(); let lazy = unsafe { owned_small(b"[1,2,3]") }; + // The header is movable now, and this test forces an evacuation through + // `js_dyn_index_set_strict`. Root it: reading `(*lazy).materialized` off + // the pre-collection address afterwards reads poisoned from-space and + // faults on the `0xDEADBEEFBAADF0DE` fill. + let lazy_scope = RuntimeHandleScope::new(); + let lazy_handle = lazy_scope.root_raw_mut_ptr(lazy); let key = crate::js_string_from_bytes(b"1".as_ptr(), 1); let bytes = b"owned mutation value survives movement"; let stored = crate::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); @@ -364,7 +398,12 @@ fn json_lazy_assignment_roots_heap_key_and_value_during_materialization() { string_bits(stored as usize), "stored string must move" ); - let array = unsafe { (*lazy).materialized }; + // The header may have moved during the collection above, so read the + // materialized backing through the handle's scope rather than pinning a + // raw pointer across it (#7341). + let array = lazy_handle.with_mut_ptr(|lazy: *mut crate::json_tape::LazyArrayHeader| unsafe { + (*lazy).materialized + }); assert!(!array.is_null()); let actual = crate::array::js_array_get(array, 1); assert_eq!(actual.bits(), result.to_bits()); diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 7eadbecc8f..14cbdd4cff 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -119,6 +119,97 @@ pub const LARGE_OBJECT_THRESHOLD_BYTES: usize = 16 * 1024; /// block. pub const LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES: usize = 128 * 1024; +/// Birth-generation ceiling for JSON-constructed storage (#10123). +/// +/// Three quarters of a nursery block. Wide-object storage needs only half +/// (a 50,000-field document is 400 KB), but a record array sized once from the +/// parser's `remaining / 96` estimate does not: a 7.1 MB document of 59,000 rows +/// estimates 74,145 slots, 593 KB. Admitting that single allocation young is +/// what lets a minor reclaim the array and its records together after the +/// document dies. Still inside the 1 MiB `arena::BLOCK_SIZE` and +/// `copying::MAX_YOUNG_MOVE_BYTES`, so anything admitted here stays movable. +/// Past it storage is born old as before. +pub const LARGE_OBJECT_STORAGE_YOUNG_BIRTH_CEILING_BYTES: usize = 768 * 1024; + +/// Object types a [`JsonWideBirthScope`] may keep young past the threshold. +pub mod json_wide_birth { + /// Nothing (the scope is closed). + pub const NONE: u8 = 0; + /// A wide object's own property storage. + pub const OBJECTS: u8 = 1; + /// A parse shape-keys array. + pub const KEYS_ARRAY: u8 = 2; +} + +crate::perry_thread_local! { + /// Read ONLY from the cold large-object branch of `arena_alloc_gc` (and its + /// `ConstructionBatch` twin), never from an allocation hot path. + static JSON_WIDE_BIRTH_MASK: std::cell::Cell = + const { std::cell::Cell::new(json_wide_birth::NONE) }; +} + +/// Keep a wide JSON document's own storage young past the birth threshold +/// (#10123), for as long as the copier can still move it. +/// +/// The threshold's rationale is that a large pointer-bearing object is stamped +/// `GC_FLAG_TENURED` and a minor never sweeps old-gen, so its cost "is not its +/// own bytes, it is every object it can reach, held live through the remembered +/// set by a container nothing refers to any more". A wide document's property +/// storage and its shape-keys array are exactly that container: above 16,384 +/// fields each crosses 128 KB, is born tenured, and then holds its whole field +/// or key set live after the document itself is dead. Measured at 64 parses, +/// retained shape-keys arrays / peak RSS: 16,300 fields -> 0 / 29 MiB, +/// 16,500 -> 30 / 98 MiB, 50,000 -> 15 / 177 MiB -- the step landing exactly on +/// the constant. +/// +/// **Scoped, and type-masked, on purpose.** Raising the constant globally also +/// moved ordinary ARRAY element storage into the nursery, which those rows do +/// not need and which cost them RSS they could not afford (records_array_8m:scan +/// 442 -> 643 MiB against Node 156 / Bun 166). Only a document's own object +/// storage and its keys array are admitted here; array element storage keeps the +/// flat threshold. +pub struct JsonWideBirthScope(u8); + +impl JsonWideBirthScope { + /// Admit wide-object property storage for the duration of a parse. + pub fn objects() -> Self { + Self(JSON_WIDE_BIRTH_MASK.with(|c| c.replace(json_wide_birth::OBJECTS))) + } + + /// Admit a parse shape-keys array around its single allocation site. + pub fn keys_array() -> Self { + Self(JSON_WIDE_BIRTH_MASK.with(|c| c.replace(json_wide_birth::KEYS_ARRAY))) + } + + /// Admit a JSON-constructed array allocation: a record array sized once + /// from the parser's estimate (see `ConstructionArray::presized_records`). + pub fn arrays() -> Self { + Self(JSON_WIDE_BIRTH_MASK.with(|c| c.replace(json_wide_birth::KEYS_ARRAY))) + } +} + +impl Drop for JsonWideBirthScope { + fn drop(&mut self) { + JSON_WIDE_BIRTH_MASK.with(|c| c.set(self.0)); + } +} + +/// May an already-oversized allocation still be born young? +/// +/// Called ONLY once the size test has already said "large", so the thread-local +/// read never touches the allocation hot path. The ceiling is the copier's own +/// refusal point: past it a young object could not be moved, which is the one +/// thing birth-young must not promise falsely. +#[inline] +pub fn json_wide_birth_permits(total_size: usize, obj_type: u8) -> bool { + if total_size > LARGE_OBJECT_STORAGE_YOUNG_BIRTH_CEILING_BYTES { + return false; + } + let mask = JSON_WIDE_BIRTH_MASK.with(|c| c.get()); + (mask == json_wide_birth::OBJECTS && obj_type == GC_TYPE_OBJECT) + || (mask == json_wide_birth::KEYS_ARRAY && obj_type == GC_TYPE_ARRAY) +} + #[inline] pub fn is_large_object_total_size(total_size: usize) -> bool { total_size > LARGE_OBJECT_THRESHOLD_BYTES @@ -137,6 +228,26 @@ pub fn large_object_threshold_for_type(obj_type: u8) -> usize { if obj_type == GC_TYPE_BUFFER { return LARGE_OBJECT_THRESHOLD_BYTES; } + // #10123 NOTE: the widening is SCOPED (see `JsonWideBirthScope`), not a + // blanket change to this constant. A WIDE OBJECT's property storage is the + // threshold's own rationale warns about -- "every object it can reach, held + // live through the remembered set by a container nothing refers to any + // more". Above 16,384 fields it crosses 128 KB, is born tenured, and then + // holds its whole field set live after the object itself is dead. Measured + // at 64 parses of one document, retained shape-keys arrays / peak RSS: + // 16,300 fields -> 0 / 29 MiB, 16,500 -> 30 / 98 MiB, 50,000 -> 15 / 177 MiB; + // the step lands exactly on the constant. + // + // Widened for OBJECT storage ONLY, not for arrays. That is not a guess: on + // this benchmark matrix `wide_1m` is the only row with a large + // GC_TYPE_OBJECT birth (400,024 B, once per parse), while every large birth + // on records_array_8m/20m is GC_TYPE_ARRAY element storage (131 KB - 2 MB). + // Widening those too bought RSS those rows did not need and cost it + // elsewhere: records_array_8m:scan 444 -> 710 MiB, 20m:parse 267 -> 314 MiB. + // + // Still inside the copier's structural ceilings (1 MB nursery block, + // `copying::MAX_YOUNG_MOVE_BYTES`), so an object admitted here is movable -- + // the one thing birth-young must not promise falsely. match gc_type_info(obj_type) { Some(info) if !info.pointer_free => LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES, _ => LARGE_OBJECT_THRESHOLD_BYTES, @@ -237,6 +348,10 @@ pub(crate) enum GcMoveHookKind { /// `GC_TYPE_REGEXP` is movable, and both tables use the payload address as /// their key. RegExpSideTables, + /// Rekey a lazy JSON array's tape registration. `json_tape_store` keys a + /// tape by its owner's address, which is precisely what kept + /// `GC_TYPE_LAZY_ARRAY` immovable and old-gen until this existed. + LazyArrayTape, } #[allow(dead_code)] @@ -468,14 +583,22 @@ pub(super) static GC_TYPE_INFO_BY_ID: [Option; MALLOC_KIND_BUCKET_CO true, GcRewriteDescriptorKind::LazyArray, GcLayoutSlotKind::None, - // NOT movable. `json_tape_store` keys a lazy array's tape by its - // header address, and every caller outside `json_tape` holds raw - // header pointers across allocations. The header is allocated old-gen - // and born tenured (`json_tape::alloc_lazy_header_bytes`), so nothing - // relocates it today; saying so here is what keeps old-page defrag - // from ever doing so. `true` was vacuous before #7539 anyway — the - // header was multi-megabyte and never left the old generation. - false, + // Movable since the tape registration learned to follow its owner + // (`GcMoveHookKind::LazyArrayTape`) and a header dying in a copying + // minor's from-space learned to give its tape back + // (`json_tape_store::finalize_dead_copied_minor_from_space_lazy_tapes`). + // Those two were the whole reason this was `false`: the registry keys + // a tape by its owner's address, and the flip runs no finalize hooks. + // + // Pinning was not free. A lazy cluster born old is never swept by a + // minor, so a DEAD one still holds its entire element graph live + // through the remembered set until a full collection — which on a + // parse-and-scan loop never arrives. Measured on + // `records_array_16k:scan`: every minor promoted essentially the whole + // nursery (`survival_permille=996`, `copied_objects=0`, + // `freed_bytes=0`) while `old_in_use` climbed past 48 MB, for 205 MiB + // peak RSS against Node's 62 MiB. + true, // #7539: the tape is a `json_tape_store` side allocation now, not // inline payload. Keeping it inline made the header ~2.4 MB on a // 10 k-record blob, which `arena_alloc_gc` routed into the old @@ -485,7 +608,7 @@ pub(super) static GC_TYPE_INFO_BY_ID: [Option; MALLOC_KIND_BUCKET_CO GcExternalBytePolicy::SideAllocation, GcLargeObjectPolicy::OldArenaWhenOverThreshold, false, - GcMoveHookKind::None, + GcMoveHookKind::LazyArrayTape, GcRewriteHookKind::None, GcFinalizeHookKind::LazyArrayTape, )), @@ -821,6 +944,9 @@ pub(crate) fn gc_type_after_payload_move(obj_type: u8, old_user: usize, new_user GcMoveHookKind::RegExpSideTables => { crate::regex::regex_header_moved_for_gc(old_user, new_user); } + GcMoveHookKind::LazyArrayTape => { + crate::json_tape_store::owner_moved(old_user, new_user); + } } } @@ -844,6 +970,13 @@ pub(crate) fn gc_type_clear_dead_payload_side_tables(obj_type: u8, user_ptr: usi GcMoveHookKind::ErrorSideTables => { crate::node_submodules::diagnostics_gc::error_side_tables_clear_dead(user_ptr); } + GcMoveHookKind::LazyArrayTape => { + // The tape is released by `GcFinalizeHookKind::LazyArrayTape` and, + // for a header that dies in a copying minor's from-space, by + // `finalize_dead_copied_minor_from_space_lazy_tapes`. Releasing it + // a third time here would be sound (the release is idempotent) but + // would hide which pass actually owns the reclaim. + } GcMoveHookKind::RegExpSideTables => { crate::regex::regex_header_clear_dead_for_gc(user_ptr); } diff --git a/crates/perry-runtime/src/json/construction_array.rs b/crates/perry-runtime/src/json/construction_array.rs index 3e7cceabf0..c361734d0b 100644 --- a/crates/perry-runtime/src/json/construction_array.rs +++ b/crates/perry-runtime/src/json/construction_array.rs @@ -11,6 +11,36 @@ pub(super) struct ConstructionArray { } impl ConstructionArray { + /// Allocate a record array ONCE at its estimated final size (#10123). + /// + /// The previous clamp of 16,384 slots is a 131,088-byte allocation -- 16 + /// bytes over the 131,072-byte pointer-bearing birth threshold -- so every + /// large record array was born OLD on its very first allocation and then + /// doubled twice more in old-gen (131 -> 262 -> 524 KB for 59,000 rows). + /// An old array of young records keeps them alive through the remembered + /// set after the document dies, and no full is ever scheduled for it. + /// + /// Sizing to the estimate removes the doubling chain. Up to the JSON + /// young-birth ceiling the single allocation is kept in the nursery, so a + /// minor reclaims the array and its records together once the document is + /// dead; past it the array is still one old allocation rather than four. + pub(super) unsafe fn presized_records( + batch: &mut Option, + estimated_len: usize, + ) -> Self { + let slot = std::mem::size_of::(); + let header = std::mem::size_of::() + crate::gc::GC_HEADER_SIZE; + let young_slots = (crate::gc::LARGE_OBJECT_STORAGE_YOUNG_BIRTH_CEILING_BYTES + .saturating_sub(header)) + / slot; + let capacity = estimated_len.clamp(16, (u32::MAX / 2) as usize); + if capacity <= young_slots { + let _young = crate::gc::JsonWideBirthScope::arrays(); + return Self::new(batch, capacity as u32); + } + Self::new(batch, capacity as u32) + } + pub(super) unsafe fn new( batch: &mut Option, capacity: u32, @@ -149,3 +179,50 @@ impl ConstructionArray { self.ptr } } + +#[cfg(test)] +mod tests { + use super::*; + + /// #10123: a record array sized from the parser estimate is ONE allocation, + /// born young when it fits the JSON young-birth ceiling and old past it. + /// The previous 16,384-slot clamp was a 131,088-byte first allocation -- + /// 16 bytes over the pointer-bearing threshold -- so every large record + /// array began life tenured and then doubled twice more in old-gen. + #[test] + fn presized_record_array_is_one_allocation_in_the_right_generation() { + let slot = std::mem::size_of::(); + let header = std::mem::size_of::() + crate::gc::GC_HEADER_SIZE; + let young_slots = + (crate::gc::LARGE_OBJECT_STORAGE_YOUNG_BIRTH_CEILING_BYTES - header) / slot; + unsafe { + let _suppress = crate::gc::GcSuppressScope::new(); + + // records_*_8m shape: 7.1 MB / 96 -> 74,145 slots, 593 KB. + let mut batch = None; + let young = ConstructionArray::presized_records(&mut batch, 74_145); + assert!(74_145 <= young_slots, "fixture must sit under the ceiling"); + assert!( + !crate::arena::pointer_in_old_gen(young.ptr as usize), + "an estimate under the young-birth ceiling must be born young" + ); + assert!((*young.ptr).capacity >= 74_145); + + // It must not need to grow for the rows it was sized for. + let mut young = young; + let before = young.ptr; + for i in 0..59_000 { + young.push(&mut batch, JSValue::number(i as f64)); + } + assert_eq!(young.ptr, before, "a presized record array must not regrow"); + + // records_*_20m shape: past the ceiling the single allocation is old. + let mut batch = None; + let old = ConstructionArray::presized_records(&mut batch, young_slots + 1_000); + assert!( + crate::arena::pointer_in_old_gen(old.ptr as usize), + "an estimate past the ceiling keeps the old-gen birth" + ); + } + } +} diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index 0951812113..a050ed3dfd 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -29,6 +29,7 @@ mod parse_inline_object; mod parse_reuse; mod parse_scalar; mod parser; +pub(crate) mod traversal_feedback; // `pub(crate)` so `gc::mod` can register `scan_raw_json_key_root_mut` (#7211): // the interned `"rawJSON"` key is a GC root. pub(crate) mod raw_json; @@ -87,9 +88,8 @@ pub(crate) unsafe fn test_json_stringify_record_output(bits: u64) -> Option *mut // construction helper publishes its pointer layout and, for large arrays // born in old generation, remembers young key strings before return. let _suppressed = crate::gc::GcSuppressScope::new(); + // #10123: same reasoning as the object storage -- a wide document's keys + // array crosses the threshold, is born tenured, and then holds its whole + // key set live long after every instance has died. + let _wide = crate::gc::JsonWideBirthScope::keys_array(); let mut batch = crate::arena::ConstructionBatch::new(); let mut array = construction_array::ConstructionArray::new(&mut batch, keys.len() as u32); for &key_ptr in keys { diff --git a/crates/perry-runtime/src/json/parse_api.rs b/crates/perry-runtime/src/json/parse_api.rs index fe465e0f8b..e294c9f190 100644 --- a/crates/perry-runtime/src/json/parse_api.rs +++ b/crates/perry-runtime/src/json/parse_api.rs @@ -84,14 +84,14 @@ fn throw_range_error(message: &str) -> ! { crate::exception::js_throw(range_error_value(message)) } -/// Select the heap-stack parser before recursive validation or materialization -/// gets close to the smallest worker-thread stack. -/// -/// `js_json_parse` and `js_json_parse_result` are separate implementations of -/// the same flow, and the typed-array path is a third. Sharing the decision is -/// what keeps them from drifting — the first version of this fix guarded only -/// one of the three and appeared to do nothing at all, because the entry point -/// codegen actually calls was one of the other two. +/// Whole-document nesting classifier for the cold paths only: a direct parse +/// that failed (malformed, or deeper than the native bound?) and a forced tape +/// above the lazy size ceiling. Valid documents never pay it: `DirectParser` +/// bounds its own recursion (`enter_container`), so the three parse entries +/// (`js_json_parse`, `js_json_parse_result`, the typed-array path) share one +/// decision inside the descent instead of a scan each. The first version of +/// the depth fix guarded only one of the three and appeared to do nothing at +/// all, because the entry point codegen actually calls was one of the others. fn requires_iterative_parse(bytes: &[u8]) -> bool { // Every nesting level requires an opening byte, even in malformed input. // A scalar root is parsed and then `finish` rejects any second token, so @@ -119,6 +119,87 @@ fn json_parse_entry_depth_bound_preserves_the_first_excess_opening() { assert!(!requires_iterative_parse(quoted.as_bytes())); } +#[cfg(test)] +fn direct_parse_depth_exceeded(input: &[u8], shape_keys: Option<&[u8]>) -> bool { + let saved_roots = parse_root_save_len(); + let exceeded = { + let _suppress = crate::gc::GcSuppressScope::new(); + unsafe { + match shape_keys { + Some(keys) => { + let shape = build_shape_hint(keys.as_ptr(), keys.len() as u32, 1) + .expect("one packed key builds a shape hint"); + let mut parser = DirectParser::with_shape(input, shape); + parser.parse_array_typed(); + let _ = parser.finish(); + parser.depth_exceeded() + } + None => { + let mut parser = DirectParser::new(input); + parser.parse_value(); + let _ = parser.finish(); + parser.depth_exceeded() + } + } + } + }; + parse_root_restore(saved_roots); + exceeded +} + +/// The descent replaces the whole-document pre-scan, so it must draw the +/// same line: at the bound stays direct, one past it aborts with the flag, +/// and neither closers, quoted openers nor a shallow syntax error count. +/// +/// The bound is sized for the release runtime's frames; a debug test build's +/// parser frames are several times larger, so the 1000-level cases run on a +/// roomy worker thread rather than the harness's default stack. +#[test] +fn direct_parser_bounds_nesting_inside_the_descent() { + std::thread::Builder::new() + .name("json-depth-bound".into()) + .stack_size(256 * 1024 * 1024) + .spawn(direct_parser_bounds_nesting_inside_the_descent_body) + .expect("worker thread starts") + .join() + .expect("depth-bound checks do not panic"); +} + +#[cfg(test)] +fn direct_parser_bounds_nesting_inside_the_descent_body() { + let limit = crate::json::parser::MAX_RECURSIVE_NESTING_DEPTH; + let mut at_bound = vec![b'['; limit]; + at_bound.extend(std::iter::repeat_n(b']', limit)); + assert!(!direct_parse_depth_exceeded(&at_bound, None)); + assert!(direct_parse_depth_exceeded(&vec![b'['; limit + 1], None)); + assert!(direct_parse_depth_exceeded( + &b"{\"a\":".repeat(limit + 1), + None + )); + assert!(!direct_parse_depth_exceeded(&vec![b'}'; limit + 1], None)); + let quoted = format!("\"{}\"", "[".repeat(limit + 1)); + assert!(!direct_parse_depth_exceeded(quoted.as_bytes(), None)); + assert!(!direct_parse_depth_exceeded(b"[?,[[[[", None)); + + // The shaped-record path counts the outer array and every record level. + let typed_records = |records: usize| { + let mut input = b"[".to_vec(); + input.extend_from_slice(&b"{\"x\":".repeat(records)); + input.push(b'1'); + input.extend(std::iter::repeat_n(b'}', records)); + input.push(b']'); + input + }; + assert!(direct_parse_depth_exceeded( + &typed_records(limit), + Some(b"x\0") + )); + assert!(!direct_parse_depth_exceeded( + &typed_records(limit - 1), + Some(b"x\0") + )); +} + fn exceeds_iterative_budget(bytes: &[u8]) -> bool { crate::json::parser::nesting_depth_exceeds( bytes, @@ -243,13 +324,6 @@ unsafe fn parse_result_slow(text_ptr: *const StringHeader, len: usize) -> Result } } } - if !cached_parse_source_is_direct(text_ptr, len) && requires_iterative_parse(bytes) { - if exceeds_iterative_budget(bytes) { - return Err(range_error_value(&iterative_budget_message())); - } - return try_parse_deep_iterative(text_ptr, len) - .ok_or_else(|| syntax_error_value("JSON parse error: malformed deep document")); - } // #7341: root the source string BEFORE the collection points, then // re-derive the input slice from the rooted value. @@ -280,8 +354,8 @@ unsafe fn parse_result_slow(text_ptr: *const StringHeader, len: usize) -> Result let mut parser = DirectParser::new_batched_from_string(bytes, source); let result = parser.parse_value(); let parse_ok = parser.finish(); + let depth_exceeded = parser.depth_exceeded(); if parse_ok { - validate_cached_parse_source(source, len); remember_parse_object_template(source, len, result); } parse_root_push(result); @@ -289,11 +363,18 @@ unsafe fn parse_result_slow(text_ptr: *const StringHeader, len: usize) -> Result super::stringify_flat::finish_parse_gc_accounting(); crate::gc::gc_schedule_parse_boundary_collection_if_pressure(); gc_allocation.finish(); + // Re-derive the source from its root before releasing it: a failed parse + // may still hand the document to the heap-stack parser, which installs + // its own root, and nothing in between collects. + let text = parse_root_get(text_root).as_string_ptr(); parse_root_restore(text_root); super::parse_scalar::clear_oversized_key_cache(); if !parse_ok { + if failed_direct_parse_is_deep(depth_exceeded, text, len) { + return parse_deep_or_error(text, len); + } return Err(syntax_error_value("JSON parse error: malformed input")); } @@ -395,15 +476,16 @@ unsafe fn parse_slow(text_ptr: *const StringHeader, len: usize) -> JSValue { } } } - let use_tape = tape_route_eligible(len, bytes); + // Eligible top-level arrays stay lazy unless this thread's lazy arrays have + // been getting fully traversed; see `traversal_feedback`. + let use_tape = tape_route_eligible(len, bytes) + && !(matches!(tape_mode_from_env(), TapeMode::Auto) + && super::traversal_feedback::prefer_eager()); // The tape's explicit stack proves shallow/deep admission in its syntax - // pass. Keep the preflight for direct parses and forced oversized tapes: - // a huge over-budget input must fail before reserving its native tape. - let preflight_depth = !use_tape || len > LAZY_MAX_BLOB_BYTES; - if preflight_depth - && !cached_parse_source_is_direct(text_ptr, len) - && requires_iterative_parse(bytes) - { + // pass, and the direct parser bounds its own descent. Only a forced tape + // above the lazy size ceiling still needs the whole-document scan: a huge + // over-budget input must fail before reserving its native tape. + if use_tape && len > LAZY_MAX_BLOB_BYTES && requires_iterative_parse(bytes) { return parse_deep_or_throw(text_ptr, len); } @@ -464,16 +546,9 @@ unsafe fn parse_slow(text_ptr: *const StringHeader, len: usize) -> JSValue { parse_root_restore(text_root); return result; } - // A malformed or over-budget tape must not enter recursion without - // the old depth check. Re-derive the source after the entry collection. - let text = parse_root_get(text_root).as_string_ptr(); - let bytes = std::slice::from_raw_parts(crate::string::string_data(text), len); - if !preflight_depth && requires_iterative_parse(bytes) { - // No collection occurs between releasing this root and the deep - // helper installing its own source root. - parse_root_restore(text_root); - return parse_deep_or_throw(text, len); - } + // A declined tape (malformed, or past the iterative budget) falls + // through to the direct parser, whose own depth accounting hands deep + // input to the heap-stack parser after the failed descent. } // #64 follow-up: opportunistic pre-parse cleanup. When parse runs in a @@ -525,8 +600,8 @@ unsafe fn parse_slow(text_ptr: *const StringHeader, len: usize) -> JSValue { let mut parser = DirectParser::new_batched_from_string(bytes, source); let result = parser.parse_value(); let parse_ok = parser.finish(); + let depth_exceeded = parser.depth_exceeded(); if parse_ok { - validate_cached_parse_source(source, len); remember_parse_object_template(source, len, result); } parse_root_push(result); @@ -538,6 +613,10 @@ unsafe fn parse_slow(text_ptr: *const StringHeader, len: usize) -> JSValue { super::stringify_flat::finish_parse_gc_accounting(); crate::gc::gc_schedule_parse_boundary_collection_if_pressure(); gc_allocation.finish(); + // Re-derive the source from its root before releasing it: a failed parse + // may still hand the document to the heap-stack parser, which installs + // its own root, and nothing in between collects. + let text = parse_root_get(text_root).as_string_ptr(); parse_root_restore(text_root); // Keep key intern cache across parses — scan_parse_roots marks cached @@ -547,6 +626,9 @@ unsafe fn parse_slow(text_ptr: *const StringHeader, len: usize) -> JSValue { super::parse_scalar::clear_oversized_key_cache(); if !parse_ok { + if failed_direct_parse_is_deep(depth_exceeded, text, len) { + return parse_deep_or_throw(text, len); + } throw_syntax_error("JSON parse error: malformed input"); } @@ -566,6 +648,31 @@ unsafe fn parse_deep_or_throw(text: *const StringHeader, len: usize) -> JSValue } } +/// The `Result` form of `parse_deep_or_throw`, for `js_json_parse_result`. +unsafe fn parse_deep_or_error(text: *const StringHeader, len: usize) -> Result { + let bytes = std::slice::from_raw_parts(crate::string::string_data(text), len); + if exceeds_iterative_budget(bytes) { + return Err(range_error_value(&iterative_budget_message())); + } + try_parse_deep_iterative(text, len) + .ok_or_else(|| syntax_error_value("JSON parse error: malformed deep document")) +} + +/// A failed direct parse goes to the heap-stack parser when the descent +/// aborted at its bound, or when the document nests past that bound anyway: +/// malformed deep input keeps reporting through the path it always used, and +/// only failed parses pay the whole-document scan. +unsafe fn failed_direct_parse_is_deep( + depth_exceeded: bool, + text: *const StringHeader, + len: usize, +) -> bool { + depth_exceeded || { + let bytes = std::slice::from_raw_parts(crate::string::string_data(text), len); + requires_iterative_parse(bytes) + } +} + /// v0.5.210: tape-mode selector. Cached at first JSON.parse so we /// pay the env-var lookup once per process, not once per parse. #[derive(Copy, Clone)] @@ -644,6 +751,7 @@ unsafe fn try_parse_via_tape(text_root: usize, len: usize) -> Option { let len = crate::json_tape::count_array_length(tape_entries, 0); let hdr = crate::json_tape::alloc_lazy_array_from_scratch(tape_entries, 0, len, text_ptr); + super::traversal_feedback::note_lazy_array_created(); Some(JSValue::object_ptr(hdr as *mut u8)) } else { Some(crate::json_tape::materialize_from_idx( @@ -725,12 +833,6 @@ pub unsafe extern "C" fn js_json_parse_typed_array( return js_json_parse(text_ptr); } - // Deep input uses the generic entry's heap-stack fallback. The shape fast - // path is deliberately retained for ordinary payloads only. - if requires_iterative_parse(bytes) { - return js_json_parse(text_ptr); - } - // Same pre-parse cleanup + GC suppression as `js_json_parse` — // root before the collection point and re-derive the source bytes after it. let text_root = parse_root_push(JSValue::string_ptr(text_ptr as *mut StringHeader)); @@ -764,16 +866,24 @@ pub unsafe extern "C" fn js_json_parse_typed_array( let mut parser = DirectParser::with_shape(bytes, shape); let result = parser.parse_array_typed(); let parse_ok = parser.finish(); + let depth_exceeded = parser.depth_exceeded(); parse_root_push(result); crate::gc::gc_unsuppress(); super::stringify_flat::finish_parse_gc_accounting(); gc_allocation.finish(); + // Re-derive the source from its root before releasing it: a failed parse + // may still hand the document to the heap-stack parser, which installs + // its own root, and nothing in between collects. + let text = parse_root_get(text_root).as_string_ptr(); parse_root_restore(text_root); super::parse_scalar::clear_oversized_key_cache(); if !parse_ok { + if failed_direct_parse_is_deep(depth_exceeded, text, len) { + return parse_deep_or_throw(text, len); + } throw_syntax_error("JSON parse error: malformed input"); } diff --git a/crates/perry-runtime/src/json/parse_reuse.rs b/crates/perry-runtime/src/json/parse_reuse.rs index d5b6b1e4b0..b5da700088 100644 --- a/crates/perry-runtime/src/json/parse_reuse.rs +++ b/crates/perry-runtime/src/json/parse_reuse.rs @@ -19,7 +19,6 @@ struct ParseStringCacheEntry { token_start: u32, token_end: u32, value: *const StringHeader, - direct_depth_validated: bool, } #[derive(Clone, Copy)] @@ -120,7 +119,6 @@ pub(crate) fn remember_parse_string( token_start: token_start as u32, token_end: token_end as u32, value, - direct_depth_validated: false, }); crate::gc::runtime_write_barrier_root_nanbox( crate::JSValue::string_ptr(source.cast_mut()).bits(), @@ -131,31 +129,6 @@ pub(crate) fn remember_parse_string( }); } -#[inline] -pub(crate) fn cached_parse_source_is_direct( - source: *const StringHeader, - source_len: usize, -) -> bool { - PARSE_STRING_CACHE.with(|cache| { - cache.borrow().as_ref().is_some_and(|entry| { - entry.source == source - && entry.source_len as usize == source_len - && entry.direct_depth_validated - }) - }) -} - -#[inline] -pub(crate) fn validate_cached_parse_source(source: *const StringHeader, source_len: usize) { - PARSE_STRING_CACHE.with(|cache| { - if let Some(entry) = cache.borrow_mut().as_mut() { - if entry.source == source && entry.source_len as usize == source_len { - entry.direct_depth_validated = true; - } - } - }); -} - #[inline] unsafe fn parse_template_value(value: JSValue) -> Option { if !value.is_pointer() { @@ -335,7 +308,6 @@ pub(crate) fn test_seed_root_scanner_slots( token_start: 0, token_end: 1, value: string_value, - direct_depth_validated: false, }); }); let mut values = [EMPTY_PARSE_TEMPLATE_VALUE; PARSE_OBJECT_TEMPLATE_MAX_FIELDS]; diff --git a/crates/perry-runtime/src/json/parser.rs b/crates/perry-runtime/src/json/parser.rs index 832b3f4641..209f76917b 100644 --- a/crates/perry-runtime/src/json/parser.rs +++ b/crates/perry-runtime/src/json/parser.rs @@ -259,6 +259,14 @@ pub(crate) struct DirectParser<'a> { input: &'a [u8], pos: usize, valid: bool, + /// Containers open on the native stack. The descent bounds itself at + /// `MAX_RECURSIVE_NESTING_DEPTH` instead of relying on a whole-document + /// nesting pre-scan, which re-read every byte of a large record document + /// on every parse. + depth: usize, + /// Set when the bound aborted the parse. The value is invalid; the entry + /// points hand such input to the heap-stack parser. + depth_exceeded: bool, /// Issue #179 typed-parse: if Some, the top-level value is /// expected to be `Array` matching this shape. Each /// record uses the fast path; mismatches silently fall through @@ -298,6 +306,8 @@ impl<'a> DirectParser<'a> { input, pos: 0, valid: true, + depth: 0, + depth_exceeded: false, shape: None, hot_shape_len: 0, hot_shape_keys: [std::ptr::null(); 8], @@ -347,6 +357,8 @@ impl<'a> DirectParser<'a> { input, pos: 0, valid: true, + depth: 0, + depth_exceeded: false, shape: Some(shape), hot_shape_len: 0, hot_shape_keys: [std::ptr::null(); 8], @@ -460,12 +472,57 @@ impl<'a> DirectParser<'a> { JSValue::null() } + /// True once the descent aborted at the native recursion bound. + #[inline] + pub(crate) fn depth_exceeded(&self) -> bool { + self.depth_exceeded + } + + /// Account one container on the native stack, or abort the parse at the + /// bound. Every recursive container entry goes through this, including + /// the shaped-record path, so the three parse entries cannot drift. + #[inline(always)] + fn enter_container(&mut self) -> bool { + if self.depth >= MAX_RECURSIVE_NESTING_DEPTH { + self.depth_exceeded = true; + self.valid = false; + return false; + } + self.depth += 1; + true + } + + #[inline(always)] + fn leave_container(&mut self) { + self.depth -= 1; + } + + #[inline] + unsafe fn parse_array_nested(&mut self) -> JSValue { + if !self.enter_container() { + return JSValue::null(); + } + let value = self.parse_array(); + self.leave_container(); + value + } + + #[inline] + unsafe fn parse_object_shaped_nested(&mut self, shape: *const ObjectShapeHint) -> JSValue { + if !self.enter_container() { + return JSValue::null(); + } + let value = self.parse_object_shaped(&*shape); + self.leave_container(); + value + } + pub(crate) unsafe fn parse_value(&mut self) -> JSValue { self.skip_whitespace(); match self.peek() { Some(b'"') => self.parse_string_value(), Some(b'{') => self.parse_object(), - Some(b'[') => self.parse_array(), + Some(b'[') => self.parse_array_nested(), Some(b't') => self.parse_true(), Some(b'f') => self.parse_false(), Some(b'n') => self.parse_null(), @@ -873,6 +930,16 @@ impl<'a> DirectParser<'a> { // without the array-outer shape). return self.parse_value_generic(); } + if !self.enter_container() { + return JSValue::null(); + } + let value = self.parse_array_typed_body(); + self.leave_container(); + value + } + + /// The top-level typed array; its container level is already accounted. + unsafe fn parse_array_typed_body(&mut self) -> JSValue { self.advance(); self.skip_whitespace(); @@ -906,7 +973,7 @@ impl<'a> DirectParser<'a> { // Per-element: shaped object or generic value (if element // isn't an object, fall back). let value = if self.peek() == Some(b'{') { - self.parse_object_shaped(&*shape_ptr) + self.parse_object_shaped_nested(shape_ptr) } else { self.parse_value_generic() }; @@ -941,8 +1008,8 @@ impl<'a> DirectParser<'a> { self.skip_whitespace(); match self.peek() { Some(b'"') => self.parse_string_value(), - Some(b'{') => self.parse_object_untyped(), - Some(b'[') => self.parse_array(), + Some(b'{') => self.parse_object(), + Some(b'[') => self.parse_array_nested(), Some(b't') => self.parse_true(), Some(b'f') => self.parse_false(), Some(b'n') => self.parse_null(), @@ -957,7 +1024,12 @@ impl<'a> DirectParser<'a> { // `parse_object` here the only callers are (a) untyped parses // and (b) nested objects inside a shaped record — both want // generic behavior. Delegate to `parse_object_untyped`. - self.parse_object_untyped() + if !self.enter_container() { + return JSValue::null(); + } + let value = self.parse_object_untyped(); + self.leave_container(); + value } pub(crate) unsafe fn parse_object_untyped(&mut self) -> JSValue { @@ -1180,9 +1252,9 @@ impl<'a> DirectParser<'a> { } // Same `[{...}]` pre-size heuristic as the typed path. // Preserve the object-leading estimate on large record arrays. - let array = super::construction_array::ConstructionArray::new( + let array = super::construction_array::ConstructionArray::presized_records( &mut self.batch, - ((self.input.len() - self.pos) / 96).clamp(16, 16_384) as u32, + (self.input.len() - self.pos) / 96, ); self.parse_array_tail(array, saved_roots) } diff --git a/crates/perry-runtime/src/json/traversal_feedback.rs b/crates/perry-runtime/src/json/traversal_feedback.rs new file mode 100644 index 0000000000..f3017b0305 --- /dev/null +++ b/crates/perry-runtime/src/json/traversal_feedback.rs @@ -0,0 +1,177 @@ +//! Per-thread feedback: do this thread's lazy JSON arrays get fully traversed? +//! +//! A top-level JSON array parses onto a validating tape and materializes its +//! elements on demand. That wins whenever the caller touches a few elements -- +//! `parse` and `sparse` shapes run at ~0.4x the better of Node and Bun -- but a +//! program that then walks EVERY element pays for two tokenizations: the tape +//! build, and the per-record reparse the scan flip hands to the direct parser. +//! Profiled on records_array_1m:scan: 26.7% tape build, 43.1% record reparse, +//! 1.13x the better engine; on records_array_16k:scan, 1.23x. The same inputs +//! parsed eagerly run at 0.97x and 0.76x. +//! +//! No size threshold can pick between the two, because the direct parser is +//! also the WRONG choice for some arrays nobody traverses: heterogeneous_1m's +//! 32 record shapes cost it 2.89x CPU and 3.31x RSS where the tape takes 0.35x +//! and 0.66x. So the decision follows behaviour instead. Every lazy array +//! created costs a point of evidence; every traversal flip earns two. Once the +//! score shows traversal is the norm, eligible parses go eagerly, and one in +//! [`RESAMPLE_EVERY`] still goes lazily so a program that stops traversing +//! drifts back. Parse-only and sparse-access programs never flip, so they never +//! leave the tape. +//! +//! Measured across all 50 JSON matrix cells in one binary, against the tape-only +//! route: records_array_16k:scan 1.23x -> 0.78x, records_array_1m:scan +//! 1.11x -> 0.98x, records_array_8m:scan CPU 0.93x -> 0.77x and RSS 190 -> +//! 163 MiB; every other cell unchanged. +//! +//! Only element-by-element reads in `lazy_get_rooted` count (the flip, or an +//! in-order read of the last element). Stringify, +//! revivers, array methods and mutation also force materialization, but none of +//! them is evidence that a scan would have been cheaper eagerly. + +use std::cell::Cell; + +/// Evidence at or above which an eligible parse goes eagerly. +const PREFER_EAGER_AT: u8 = 4; +/// Ceiling on accumulated evidence, so a long traversal history is unlearned +/// within a bounded number of untraversed parses. +const SCORE_MAX: u8 = 8; +/// While eager is preferred, one parse in this many still takes the tape, so +/// the evidence keeps being refreshed. +const RESAMPLE_EVERY: u8 = 16; + +// Two byte counters read once per parse: plain `thread_local!`, not the hot-TLS +// macro, and no heap pointer can live in either. +thread_local! { + static SCORE: Cell = const { Cell::new(0) }; + static EAGER_RUN: Cell = const { Cell::new(0) }; +} + +/// A lazy array was created: one point of evidence against traversal. +pub(crate) fn note_lazy_array_created() { + SCORE.with(|s| s.set(s.get().saturating_sub(1))); +} + +/// Called after every cold element read of a lazy array. +/// +/// `flip` is the existing adaptive threshold (cumulative walk or scan streak): +/// materialize, and count it as traversal evidence. `completed_scan` means this +/// read finished an in-order walk of the WHOLE array. That has to count on its +/// own, because the flip deliberately never fires for an array too small for +/// its streak to be proportional evidence (a 120-row array reaches the 64-read +/// streak with over half its elements already cached) -- and a program that +/// walks every one of those small arrays is exactly the one that pays for the +/// tape twice. +/// +/// # Safety +/// +/// `hdr` must be a live `LazyArrayHeader`, as for `force_materialize_lazy`. +pub(crate) unsafe fn after_cold_read( + hdr: *mut crate::json_tape::LazyArrayHeader, + flip: bool, + completed_scan: bool, +) { + if flip || completed_scan { + SCORE.with(|s| s.set(s.get().saturating_add(2).min(SCORE_MAX))); + } + if flip { + crate::json_tape::force_materialize_lazy(hdr); + } +} + +/// Should an otherwise tape-eligible parse go eagerly instead? +pub(crate) fn prefer_eager() -> bool { + if SCORE.with(Cell::get) < PREFER_EAGER_AT { + EAGER_RUN.with(|r| r.set(0)); + return false; + } + EAGER_RUN.with(|r| { + let next = r.get() + 1; + if next >= RESAMPLE_EVERY { + r.set(0); + false + } else { + r.set(next); + true + } + }) +} + +#[cfg(test)] +pub(crate) fn reset_for_tests() { + SCORE.with(|s| s.set(0)); + EAGER_RUN.with(|r| r.set(0)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn created_then(flipped: bool) { + note_lazy_array_created(); + if flipped { + SCORE.with(|s| s.set(s.get().saturating_add(2).min(SCORE_MAX))); + } + } + + #[test] + fn traversal_evidence_switches_to_eager_and_keeps_resampling() { + reset_for_tests(); + // A scan loop: every lazy array is fully traversed. + let mut lazy = 0; + let mut eager = 0; + for _ in 0..200 { + if prefer_eager() { + eager += 1; + } else { + lazy += 1; + created_then(true); + } + } + assert!( + eager > 150, + "a traversing program must mostly parse eagerly" + ); + assert!( + lazy >= 200 / RESAMPLE_EVERY as usize, + "it must keep resampling lazily" + ); + reset_for_tests(); + } + + #[test] + fn untraversed_arrays_never_leave_the_tape() { + reset_for_tests(); + for _ in 0..200 { + assert!(!prefer_eager(), "parse-only programs must stay lazy"); + created_then(false); + } + reset_for_tests(); + } + + #[test] + fn a_program_that_stops_traversing_drifts_back_to_the_tape() { + reset_for_tests(); + for _ in 0..64 { + if !prefer_eager() { + created_then(true); + } + } + assert!(SCORE.with(Cell::get) >= PREFER_EAGER_AT); + let mut went_lazy_for_good = false; + for _ in 0..200 { + if !prefer_eager() { + created_then(false); + if SCORE.with(Cell::get) < PREFER_EAGER_AT { + went_lazy_for_good = true; + break; + } + } + } + assert!( + went_lazy_for_good, + "evidence must be unlearned once traversal stops" + ); + reset_for_tests(); + } +} diff --git a/crates/perry-runtime/src/json_tape.rs b/crates/perry-runtime/src/json_tape.rs index 6f292bd38c..e380a91eff 100644 --- a/crates/perry-runtime/src/json_tape.rs +++ b/crates/perry-runtime/src/json_tape.rs @@ -1229,12 +1229,44 @@ impl LazyArrayHeader { /// `blob_str` reads and stringify emitted NUL bytes. Preserve its generation; /// ownership transfer changes only the pointer-free tape backing allocation. #[inline] -fn alloc_lazy_header_bytes() -> *mut u8 { - crate::arena::arena_alloc_gc_old_born_tenured( - std::mem::size_of::(), - 8, - crate::gc::GC_TYPE_LAZY_ARRAY, - ) +/// Does this lazy array's cluster belong in the old generation? +/// +/// #7539 put the header there because its tape was inline and multi-megabyte; +/// moving the tape out shrank it to ~88 bytes and the old-gen request was kept +/// only because `json_tape_store` keyed a tape by its owner's address and the +/// copying minor's flip ran no finalize hook, so a young header would have +/// orphaned or leaked its tape. `GcMoveHookKind::LazyArrayTape` and +/// `finalize_dead_copied_minor_from_space_lazy_tapes` remove both reasons. +/// +/// Pinning was expensive: a minor never sweeps old-gen, so a DEAD cluster held +/// its whole element graph live through the remembered set until a full +/// collection, which on a parse-and-scan loop never arrives. +/// +/// Decide by size instead, and decide ONCE for the cluster: #7546's invariant +/// is that header, cache and bitmap share a generation, because a nursery cache +/// under an old-gen header is a mixed shape no walker covers. A cache large +/// enough to be born old takes the header with it; anything smaller is +/// nursery-resident and a minor can reclaim the lot. +fn lazy_cluster_is_old(cached_length: u32) -> bool { + let cache_bytes = (cached_length as usize) * std::mem::size_of::(); + // The POINTER-BEARING line, not the flat one. `arena_alloc_gc` keeps the two + // apart for precisely the reason that bites here: tenuring a pointer-bearing + // object does not cost its own bytes, it costs "every object it can reach, + // held live through the remembered set by a container nothing refers to any + // more". The sparse cache is a block of JSValues, so it is that container, + // and a lazy array is the case the distinction was drawn for. 128 KB is + // V8's kMaxRegularHeapObjectSize and sits inside the copier's own ceilings, + // so a cluster admitted by it is always movable. + cache_bytes + crate::gc::GC_HEADER_SIZE + >= crate::gc::LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES +} + +unsafe fn alloc_lazy_cluster_bytes(size: usize, obj_type: u8, old: bool) -> *mut u8 { + if old { + crate::arena::arena_alloc_gc_old_born_tenured(size, 8, obj_type) + } else { + crate::arena::arena_alloc_gc(size, 8, obj_type) + } } pub unsafe fn alloc_lazy_array( @@ -1282,8 +1314,14 @@ unsafe fn alloc_lazy_array_backing( // which can trigger, but the only live thing we hold across it is // `blob_handle`, which is rooted. let (tape_ptr, tape_allocation) = backing.allocate(); - let (raw, blob_str) = - blob_handle.across_const::(alloc_lazy_header_bytes); + let cluster_old = lazy_cluster_is_old(cached_length); + let (raw, blob_str) = blob_handle.across_const::(|| unsafe { + alloc_lazy_cluster_bytes( + std::mem::size_of::(), + crate::gc::GC_TYPE_LAZY_ARRAY, + cluster_old, + ) + }); let hdr = raw as *mut LazyArrayHeader; (*hdr).cached_length = cached_length; (*hdr).magic = LAZY_ARRAY_MAGIC; @@ -1336,11 +1374,8 @@ unsafe fn alloc_lazy_array_backing( // (`parsed[i] === parsed[i]`) across a copying minor. It could not // occur before: a big array's cache was already born old, and a small // array's header was born young along with its cache. - let cache_raw = crate::arena::arena_alloc_gc_old_born_tenured( - cache_bytes, - 8, - crate::gc::GC_TYPE_STRING, - ); + let cache_raw = + alloc_lazy_cluster_bytes(cache_bytes, crate::gc::GC_TYPE_STRING, cluster_old); // arena_alloc_gc can reuse slots from the free list whose // bytes still hold whatever the previous occupant wrote. // Zero explicitly — the cache invariant relies on the @@ -1361,11 +1396,8 @@ unsafe fn alloc_lazy_array_backing( // Same generation as the header and cache — see above. The bitmap // holds no heap edges, but keeping it with its cluster keeps the // page-liveness bookkeeping uniform. - let bitmap_raw = crate::arena::arena_alloc_gc_old_born_tenured( - bitmap_bytes, - 8, - crate::gc::GC_TYPE_STRING, - ); + let bitmap_raw = + alloc_lazy_cluster_bytes(bitmap_bytes, crate::gc::GC_TYPE_STRING, cluster_old); std::ptr::write_bytes(bitmap_raw, 0, bitmap_bytes); let hdr = hdr_handle.get_raw_mut_ptr::(); (*hdr).materialized_bitmap = bitmap_raw as *mut u64; @@ -1670,9 +1702,8 @@ unsafe fn lazy_get_rooted(hdr: *mut LazyArrayHeader, i: u32) -> JSValue { let hdr = hdr_handle.get_raw_mut_ptr::(); let scan_flip = streak >= scan_flip_threshold(cached_length) && lazy_cached_count(hdr) * 2 < cached_length as u64; - if (*hdr).cumulative_walk_steps > (cached_length as u64) * 2 || scan_flip { - force_materialize_lazy(hdr); - } + let flip = (*hdr).cumulative_walk_steps > (cached_length as u64) * 2 || scan_flip; + crate::json::traversal_feedback::after_cold_read(hdr, flip, streak == cached_length); JSValue::from_bits(value_handle.get_nanbox_u64()) } diff --git a/crates/perry-runtime/src/json_tape_store.rs b/crates/perry-runtime/src/json_tape_store.rs index be38e932b5..9ce8285fef 100644 --- a/crates/perry-runtime/src/json_tape_store.rs +++ b/crates/perry-runtime/src/json_tape_store.rs @@ -270,6 +270,62 @@ pub(crate) fn registry_is_empty() -> bool { !TAPE_REGISTRY_NONEMPTY.with(Cell::get) } +/// Rekey a tape whose owner the collector just relocated. +/// +/// `GcMoveHookKind::LazyArrayTape`. The registry is keyed by the owner's +/// address, which was the reason `GC_TYPE_LAZY_ARRAY` had to be immovable and +/// old-gen: a moved header silently orphaned its tape, and the tape then +/// outlived every path that could free it. RegExp solved the same problem the +/// same way (`GcMoveHookKind::RegExpSideTables`), so this is that precedent +/// rather than a new mechanism. +pub(crate) fn owner_moved(old_addr: usize, new_addr: usize) { + if registry_is_empty() || old_addr == new_addr { + return; + } + TAPE_REGISTRY.with(|r| { + let mut registry = r.borrow_mut(); + if let Some(allocation) = registry.remove(&old_addr) { + debug_assert!( + !registry.contains_key(&new_addr), + "a relocated lazy header must not land on a registered address" + ); + registry.insert(new_addr, allocation); + } + }); +} + +/// Release the tapes whose owners just died in a copying minor's from-space. +/// +/// The copying minor's flip runs no per-object finalize hooks, so without this +/// a lazy header that dies young leaks its tape — which is the other half of +/// what kept the type pinned in the old generation. Twin of the sweep-entry +/// [`collect_owners`] pass, mirroring Map/Set/Error/RegExp. +/// +/// Cost: O(registry), i.e. proportional to live-plus-recently-allocated lazy +/// arrays, not to program history. +pub(crate) fn finalize_dead_copied_minor_from_space_lazy_tapes() -> usize { + if registry_is_empty() { + return 0; + } + let dead: Vec = TAPE_REGISTRY.with(|r| { + r.borrow() + .keys() + .copied() + .filter(|&addr| { + crate::gc::owner_is_dead_copied_minor_from_space_of_type( + addr, + crate::gc::GC_TYPE_LAZY_ARRAY, + ) + }) + .collect() + }); + let count = dead.len(); + for addr in dead { + release(addr); + } + count +} + /// Registered owner addresses matching `is_dead`. Split from the release so /// the caller can budget-chunk the frees the way the Map/Set sweep does. pub(crate) fn collect_owners(is_dead: &dyn Fn(usize) -> bool) -> Vec { diff --git a/crates/perry-runtime/src/object/json_construction.rs b/crates/perry-runtime/src/object/json_construction.rs index 38d489789b..9c2ab309ed 100644 --- a/crates/perry-runtime/src/object/json_construction.rs +++ b/crates/perry-runtime/src/object/json_construction.rs @@ -190,6 +190,14 @@ pub(crate) unsafe fn object_from_json_fields_preinstalled( b.try_alloc(size, crate::gc::GC_TYPE_OBJECT) }); let obj = if raw.is_null() { + // #10123: a wide document's property storage crosses the birth + // threshold and is then born tenured, where no minor will ever sweep + // it -- so it holds its whole field set live after the document is + // dead. Keep it young while the copier can still move it. This is the + // site the scope has to cover: the storage is minted HERE, not inside + // `js_json_parse_result`, which is why scoping the parse entry alone + // changed nothing (the allocation saw mask=0). + let _wide = crate::gc::JsonWideBirthScope::objects(); js_object_alloc_class_inline_keys_stamped(0, 0, count as u32, keys, shape_id) } else { let obj = raw.cast::(); diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index bab64b561b..5ba4d64f54 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -863,6 +863,87 @@ pub extern "C" fn js_object_shape_id_for_keys(keys: u64, key_count: u32) -> u32 id } +/// #10123: the inline slot a PLAIN ordinary shape assigns to `key`, or `-1`. +/// +/// The element-shape loop clone's shape-keyed arm asks this once per tracked +/// property, in the preheader, and the answer replaces the compile-time packed +/// field index a class-keyed clone bakes in. `key_bits` is the whole NaN-boxed +/// key as codegen loaded it from the string pool — NOT a masked +/// `StringHeader*`, because a short property name ("id") reaches the pool as an +/// SSO immediate whose masked low bits are packed characters, not an address. +/// +/// **"Plain" is what makes slot k == key position k.** The four conjuncts +/// below are that claim, and dropping any one of them turns this into a wrong +/// offset rather than a missed optimization: +/// +/// * `object_kind == Ordinary` — a class shape's slots are the class's +/// layout, which this function knows nothing about; +/// * `semantic_generation == 0` — a descriptor/prototype mutation minted this +/// layout, so the keys array no longer describes the live slots; +/// * `hole_count == 0` — an O(1) delete tombstones a key IN PLACE, so a later +/// key's position in the keys array is no longer its slot; +/// * `live_inline_slot_count == logical_key_count` — every key is inline; a +/// shape with spilled keys would put later ones outside the inline block. +/// +/// Allocation-free and side-effect-free: it reads the descriptor, walks the +/// keys array, and returns. The walk is bounded by the physically present key +/// slots (`length.min(capacity)`), which is why a corrupted or forwarded keys +/// array costs a short scan and a `-1` rather than a spin. +#[no_mangle] +pub extern "C" fn js_shape_ordinary_inline_slot_for_key(shape_id: u32, key_bits: u64) -> i32 { + let Some(descriptor) = shape_descriptor_by_id(shape_id) else { + return -1; + }; + if descriptor.object_kind != ShapeObjectKind::Ordinary + || descriptor.semantic_generation != 0 + || descriptor.hole_count != 0 + || descriptor.live_inline_slot_count != descriptor.logical_key_count + { + return -1; + } + let mut wanted_buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let mut stored_buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + unsafe { + let Some(wanted) = crate::string::js_string_key_bytes( + crate::JSValue::from_bits(key_bits), + &mut wanted_buf, + ) else { + return -1; + }; + let (slots, slot_len) = + super::keys_array_dense_slots(descriptor.keys as usize as *const ArrayHeader); + if slots.is_null() { + return -1; + } + let bound = slot_len.min(descriptor.logical_key_count as usize); + for index in 0..bound { + let stored = crate::JSValue::from_bits((*slots.add(index)).to_bits()); + // Identical bits is the overwhelmingly common answer for a pooled + // key against a canonical keys array (both interned), and it is + // correct for either representation — an SSO immediate and a heap + // pointer each compare equal to themselves. The byte compare below + // is what makes a MIXED pair (pool immediate vs heap key, or two + // separately allocated heap keys) still match. + if stored.bits() == key_bits { + return index as i32; + } + if crate::string::js_string_key_bytes(stored, &mut stored_buf) == Some(wanted) { + return index as i32; + } + } + } + -1 +} + +/// Keepalive anchor — `js_shape_ordinary_inline_slot_for_key` is a +/// generated-code-only callee (the element-shape loop clone's shape-keyed +/// preheader), so the auto-optimize whole-program build would otherwise +/// dead-strip it (see the FFI-symbol-link-break class). +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_SHAPE_ORDINARY_INLINE_SLOT_FOR_KEY: extern "C" fn(u32, u64) -> i32 = + js_shape_ordinary_inline_slot_for_key; + /// Mint a process-global ShapeId for a codegen-registered typed layout and /// install its structural descriptor in the current agent. Unlike /// [`shape_id_for_keys_ensure`], this deliberately does not canonicalise by diff --git a/crates/perry-runtime/src/object/shapes_tests.rs b/crates/perry-runtime/src/object/shapes_tests.rs index dd06428773..4325b432f9 100644 --- a/crates/perry-runtime/src/object/shapes_tests.rs +++ b/crates/perry-runtime/src/object/shapes_tests.rs @@ -1132,3 +1132,53 @@ fn fresh_shape_creation_does_not_flush_the_lookup_cache() { ); } } + +// --------------------------------------------------------------------------- +// #10123: `js_shape_ordinary_inline_slot_for_key` — the element-shape loop +// clone's "which inline slot holds this key?" preheader query. +// +// The positive cases and the SSO-vs-heap representation case live with the +// invariant they serve (`array/element_shape_tests.rs`). What belongs HERE is +// the conjunct that is a property of the shape TABLE: a shape whose kind is +// `Class` describes a class layout, not "slot k == key position k", and +// answering a slot for one would hand the clone a wrong offset rather than a +// missed optimization. +// --------------------------------------------------------------------------- + +#[test] +fn the_ordinary_slot_query_declines_a_class_kind_shape() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let keys = crate::array::js_array_alloc_with_length(1); + let name = crate::string::js_string_from_bytes(b"id".as_ptr(), 2); + crate::array::js_array_set(keys, 0, crate::JSValue::string_ptr(name)); + let key_bits = crate::JSValue::string_ptr(name).bits(); + let ordinary = shape_id_for_keys_ensure(keys, 1); + assert_eq!( + js_shape_ordinary_inline_slot_for_key(ordinary, key_bits), + 0, + "test premise: the ORDINARY shape answers slot 0 for its only key — \ + otherwise the negative below is vacuous" + ); + + // `transition_object_shape_to_class` keeps the keys array and both + // counts and changes ONLY the kind, so the pair below differs in + // exactly the conjunct under test. + let obj = crate::object::js_object_alloc_class_inline_keys_stamped(0, 0, 1, keys, ordinary); + assert_eq!((*obj).parent_class_id, ordinary, "test premise: stamped"); + let class_kind = transition_object_shape_to_class(obj); + assert_ne!( + ordinary, class_kind, + "test premise: the kind really changed" + ); + assert_eq!( + shape_object_kind_by_id(class_kind), + Some(ShapeObjectKind::Class) + ); + assert_eq!( + js_shape_ordinary_inline_slot_for_key(class_kind, key_bits), + -1, + "a class-kind shape names a class layout, not key positions" + ); + } +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 46d9d4c191..fe58ee1b1a 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -534,6 +534,18 @@ "verdict": "test_only", "why": "#[cfg(test)] counter for repeated JSON output cache hits; absent from production builds and stores only a tally." }, + { + "file": "crates/perry-runtime/src/json/traversal_feedback.rs", + "name": "EAGER_RUN", + "verdict": "not_a_gc_pointer", + "why": "Per-thread count of consecutive eager route decisions since the last lazy resample (json/traversal_feedback.rs). A `Cell` reset to zero or incremented by one in `prefer_eager`; it never stores an address, handle, NaN-boxed value or anything derived from one." + }, + { + "file": "crates/perry-runtime/src/json/traversal_feedback.rs", + "name": "SCORE", + "verdict": "not_a_gc_pointer", + "why": "Per-thread traversal-evidence score for the lazy-vs-eager JSON.parse route (json/traversal_feedback.rs). A `Cell` saturating counter: incremented when a lazy array's cold read flips or completes an in-order scan, decremented when a lazy array is created, compared against a constant in `prefer_eager`. It never stores an address, handle, NaN-boxed value or anything derived from one." + }, { "file": "crates/perry-runtime/src/map.rs", "name": "MAP_COMPACTION_LOG", diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 2fe8a83da7..616fb82258 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -664,6 +664,14 @@ "count": 1, "classification": "metadata-only", "reason": "This indexed scope-stack iteration performs lexical name resolution during HIR construction and does not establish a codegen runtime representation." + }, + { + "path": "crates/perry-codegen/src/stmt/element_shape_loop.rs", + "function": "array_is_untyped", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The declared type is read only to DECLINE the shape-keyed loop clone for a receiver that carries any layout claim (a named class or an anon-shape the resolver found ambiguous); it never selects a layout. A receiver admitted here (no annotation, any, unknown) still goes through the preheader's runtime proof \u2014 js_array_ensure_element_shape_ordinary must return a nonzero ShapeId and js_shape_ordinary_inline_slot_for_key a slot for every tracked key \u2014 and the per-element residual check re-validates that ShapeId on every read, so a stale or wrong hint can only cost the clone, never a wrong offset." } ] } diff --git a/test-files/test_gap_json_record_loop_clone.ts b/test-files/test_gap_json_record_loop_clone.ts new file mode 100644 index 0000000000..0a60a97359 --- /dev/null +++ b/test-files/test_gap_json_record_loop_clone.ts @@ -0,0 +1,272 @@ +// #10123: the SHAPE-keyed element-shape versioned loop clone. +// +// #7480's clone keyed every proof on a compile-time class, so it could never +// fire for the one array shape record-processing code is actually written +// against: `JSON.parse`'d objects, which are class 0 with an ordinary birth +// ShapeId. The runtime now proves the exact ShapeId instead, and the clone's +// preheader resolves each tracked property's inline slot against it. +// +// Three things are new and every one of them is a MISCOMPILE if it is wrong, +// not a slow path, so each gets cases here rather than only a codegen unit +// test: +// +// * the per-element residual no longer requires `GC_OBJ_TYPED_LAYOUT_INTACT` +// (a parsed record never has it), so the loaded word is a NaN-boxed +// JSValue and is tag-tested as a Number per read; +// * `rows[7]` and `const d = i % n; rows[d]` are admitted as indices, each +// with its own preheader bounds obligation; +// * the array head is repaired BEFORE the `GC_TYPE_ARRAY` brand, so a lazy +// JSON array is materialized instead of rejected. + +function buildRecords(count: number, pad: string): string { + const parts: string[] = []; + for (let i = 0; i < count; i++) { + parts.push( + '{"id":' + i + ',"name":"user_' + i + '","active":' + + (i % 2 === 0 ? "false" : "true") + ',"score":' + (i * 1.5) + + ',"note":"' + pad + '"}', + ); + } + return "[" + parts.join(",") + "]"; +} + +// `repeat`: a CONSTANT index. The trip count says nothing about index 7, so +// the preheader owes its own `length > 7`. +function repeatSum(rows: any, count: number): number { + let sum = 0; + for (let i = 0; i < count; i++) sum += rows[7].id; + return sum; +} + +// `sequential`: the derived `i % n` index, lowered to one `srem` in the clone. +function sequentialSum(rows: any, count: number, n: number): number { + let sum = 0; + for (let i = 0; i < count; i++) { + const index = i % n; + sum += rows[index].id; + } + return sum; +} + +// The original counter-indexed shape, now over an untyped receiver. +function scanSum(rows: any, count: number): number { + let sum = 0; + for (let i = 0; i < count; i++) sum += rows[i].id; + return sum; +} + +// `+` on a possibly-non-numeric field: JavaScript switches to string +// concatenation the moment one `id` is a string, which is exactly what the +// clone's per-read Number tag test has to preserve. +function concatIds(rows: any, count: number): string { + let out = ""; + for (let i = 0; i < count; i++) out += String(rows[i].id) + ","; + return out; +} + +// --------------------------------------------------------------------------- +// 1. A LAZY parsed array (a top-level array over 1 KB is returned as a lazy +// header, which the preheader's brand test rejected before the repair was +// moved ahead of it). +// --------------------------------------------------------------------------- +const lazyText = buildRecords(64, "0123456789abcdef0123456789abcdef"); +console.log("lazy-bytes-over-1k:", lazyText.length > 1024); +const lazy: any = JSON.parse(lazyText); +const lazyLength: number = lazy.length; + +console.log("lazy-repeat:", repeatSum(lazy, 50)); +console.log("lazy-repeat-again:", repeatSum(lazy, 50)); +console.log("lazy-sequential:", sequentialSum(lazy, 200, lazyLength)); +console.log("lazy-scan:", scanSum(lazy, lazyLength)); +console.log("lazy-scan-again:", scanSum(lazy, lazyLength)); + +// --------------------------------------------------------------------------- +// 2. An EAGER parsed array (under 1 KB), the same loops. +// --------------------------------------------------------------------------- +const eagerText = buildRecords(6, "x"); +console.log("eager-bytes-under-1k:", eagerText.length < 1024); +const eager: any = JSON.parse(eagerText); +console.log("eager-repeat-index-in-range:", eager.length > 7); +console.log("eager-sequential:", sequentialSum(eager, 20, eager.length)); +console.log("eager-scan:", scanSum(eager, eager.length)); + +// --------------------------------------------------------------------------- +// 3. HETEROGENEOUS key sets. One record with an extra key has a different +// ShapeId, so the array-level proof must decline for the WHOLE array — +// a shape says which slot holds `id`, and the second shape's may differ. +// --------------------------------------------------------------------------- +const heteroText = + '[{"id":1,"name":"a"},{"id":2,"name":"b"},{"extra":9,"id":3,"name":"c"},' + + '{"id":4,"name":"d"}]'; +const hetero: any = JSON.parse(heteroText); +console.log("hetero-scan:", scanSum(hetero, hetero.length)); +console.log("hetero-sequential:", sequentialSum(hetero, 12, hetero.length)); + +// A key set that is the same NAMES in a different ORDER is still a different +// shape, and its `id` sits at a different slot. +const reorderedText = + '[{"id":1,"name":"a"},{"name":"b","id":2},{"id":3,"name":"c"}]'; +const reordered: any = JSON.parse(reorderedText); +console.log("reordered-scan:", scanSum(reordered, reordered.length)); + +// --------------------------------------------------------------------------- +// 4. NON-NUMERIC field values. The array is perfectly homogeneous in SHAPE — +// every record has exactly `{id, name}` — so the array-level proof holds +// and only the per-read Number tag test stands between the clone and +// reading a string pointer's bits as a double. +// --------------------------------------------------------------------------- +const stringIdText = + '[{"id":1,"name":"a"},{"id":2,"name":"b"},{"id":"three","name":"c"},' + + '{"id":4,"name":"d"}]'; +const stringId: any = JSON.parse(stringIdText); +console.log("string-id-sum:", scanSum(stringId, stringId.length)); +console.log("string-id-concat:", concatIds(stringId, stringId.length)); + +const nullIdText = '[{"id":1},{"id":null},{"id":3}]'; +const nullId: any = JSON.parse(nullIdText); +console.log("null-id-sum:", scanSum(nullId, nullId.length)); + +const boolIdText = '[{"id":1},{"id":true},{"id":3}]'; +const boolId: any = JSON.parse(boolIdText); +console.log("bool-id-sum:", scanSum(boolId, boolId.length)); + +const objIdText = '[{"id":1},{"id":{"n":2}},{"id":3}]'; +const objId: any = JSON.parse(objIdText); +console.log("obj-id-concat:", concatIds(objId, objId.length)); + +// Fractional and negative values still read as plain doubles. +const floatText = '[{"id":-1.5},{"id":0.25},{"id":1e21}]'; +const floats: any = JSON.parse(floatText); +console.log("float-ids:", scanSum(floats, floats.length)); +console.log("float-concat:", concatIds(floats, floats.length)); + +// --------------------------------------------------------------------------- +// 5. REVOCATION after the proof was established. Each of these leaves the +// array's length alone, so only the element-store funnel or the per-element +// residual can catch it. +// --------------------------------------------------------------------------- +const mutated: any = JSON.parse(buildRecords(40, "pad")); +console.log("mutated-before:", scanSum(mutated, mutated.length)); +mutated[5] = 123; +console.log("mutated-after-primitive:", scanSum(mutated, mutated.length)); + +const reshaped: any = JSON.parse(buildRecords(40, "pad")); +console.log("reshaped-before:", scanSum(reshaped, reshaped.length)); +delete reshaped[9].name; +console.log("reshaped-after-delete:", scanSum(reshaped, reshaped.length)); + +const downgraded: any = JSON.parse(buildRecords(40, "pad")); +console.log("downgraded-before:", scanSum(downgraded, downgraded.length)); +downgraded[11].id = "eleven"; +console.log("downgraded-after:", concatIds(downgraded, 14)); + +const accessorised: any = JSON.parse(buildRecords(40, "pad")); +Object.defineProperty(accessorised[3], "id", { + get() { + return 99; + }, + configurable: true, +}); +console.log("own-accessor:", scanSum(accessorised, accessorised.length)); + +// Length changes retire the proof through the pinned `verified_len`. +const grown: any = JSON.parse(buildRecords(40, "pad")); +console.log("grown-before:", scanSum(grown, grown.length)); +grown.push({ id: 1000, name: "extra", active: true, score: 0, note: "pad" }); +console.log("grown-after:", scanSum(grown, grown.length)); +grown.pop(); +grown.length = 5; +console.log("grown-truncated:", scanSum(grown, grown.length)); + +// --------------------------------------------------------------------------- +// 6. BOUNDS. Each index form's obligation is discharged once in the preheader; +// a form whose obligation cannot be met must take the slow clone and +// observe ordinary JavaScript semantics. +// --------------------------------------------------------------------------- +const shortArr: any = JSON.parse('[{"id":1},{"id":2},{"id":3}]'); +// `rows[7]` on a 3-element array: `undefined.id` throws, exactly as JS says. +try { + console.log("short-repeat:", repeatSum(shortArr, 4)); +} catch (err) { + console.log("short-repeat-threw:", String(err).slice(0, 9)); +} +// A modulus LARGER than the array: the derived index runs past the end. +try { + console.log("modulus-past-length:", sequentialSum(shortArr, 8, 10)); +} catch (err) { + console.log("modulus-past-length-threw:", String(err).slice(0, 9)); +} +// `i % 0` is NaN in JavaScript, and `rows[NaN]` is `undefined` — the clone +// must never turn this into an `srem` by zero. +try { + console.log("modulus-zero:", sequentialSum(shortArr, 4, 0)); +} catch (err) { + console.log("modulus-zero-threw:", String(err).slice(0, 9)); +} +// A modulus SMALLER than the array is in range and stays specialized. +console.log("modulus-under-length:", sequentialSum(shortArr, 9, 2)); +// A trip count past the array's length with the counter index. +try { + console.log("scan-past-length:", scanSum(shortArr, 5)); +} catch (err) { + console.log("scan-past-length-threw:", String(err).slice(0, 9)); +} +// Empty array: the invariant declines a vacuous proof. +const emptyArr: any = JSON.parse("[]"); +console.log("empty-scan:", scanSum(emptyArr, emptyArr.length)); + +// --------------------------------------------------------------------------- +// 7. RECEIVERS THAT ARE NOT PLAIN PARSED ARRAYS. Each must decline the clone +// and still produce the right answer. +// --------------------------------------------------------------------------- +const literalRows: any = [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 3, name: "c" }, +]; +console.log("object-literal-scan:", scanSum(literalRows, literalRows.length)); + +class RowList extends Array {} +const subclass: any = new RowList(); +subclass.push({ id: 4, name: "d" }); +subclass.push({ id: 5, name: "e" }); +console.log("subclass-scan:", scanSum(subclass, subclass.length)); + +const nested: any = JSON.parse('{"rows":[{"id":1},{"id":2},{"id":3}]}'); +console.log("nested-scan:", scanSum(nested.rows, nested.rows.length)); + +const stringsArr: any = JSON.parse('["a","b","c"]'); +console.log("primitive-elements-concat:", concatIds(stringsArr, 3)); + +const nullElems: any = JSON.parse('[{"id":1},null,{"id":3}]'); +try { + console.log("null-element:", scanSum(nullElems, nullElems.length)); +} catch (err) { + console.log("null-element-threw:", String(err).slice(0, 9)); +} + +// --------------------------------------------------------------------------- +// 8. A MODULE-LEVEL parsed array read from inside a function — the +// module-global arm of the preheader's repaired-head write-back. +// --------------------------------------------------------------------------- +const moduleRows: any = JSON.parse(buildRecords(48, "0123456789abcdef")); +const moduleLength: number = moduleRows.length; + +function sumModuleRows(count: number): number { + let sum = 0; + for (let i = 0; i < count; i++) { + const index = i % moduleLength; + sum += moduleRows[index].score; + } + return sum; +} +console.log("module-global:", sumModuleRows(120)); +console.log("module-global-again:", sumModuleRows(120)); + +// A second field on the same records, so the preheader resolves two slots. +function sumTwoFields(rows: any, count: number): number { + let sum = 0; + for (let i = 0; i < count; i++) sum += rows[i].id + rows[i].score; + return sum; +} +console.log("two-fields:", sumTwoFields(moduleRows, moduleLength));