diff --git a/CLAUDE.md b/CLAUDE.md index 01f00e3a4b..908ff1d056 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.1301 +**Current Version:** 0.5.1302 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 8fda8abac7..d93fbbf7d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1301" +version = "0.5.1302" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1301" +version = "0.5.1302" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1301" +version = "0.5.1302" [[package]] name = "perry-ui-tvos" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1301" +version = "0.5.1302" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index ac8f0975ff..e7e1784958 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1301" +version = "0.5.1302" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7537-early-batch-flip.md b/changelog.d/7537-early-batch-flip.md new file mode 100644 index 0000000000..83592df1af --- /dev/null +++ b/changelog.d/7537-early-batch-flip.md @@ -0,0 +1,74 @@ +### JSON lazy tape: sequential scans hand off to the batch parser (#7478) + +A full scan of a `JSON.parse`'d top-level array was invisible to the lazy +tape's only adaptive signal. `lazy_get`'s `cumulative_walk_steps` counter +trips at `2n`, but a sequential walk costs exactly one tape step per element +and so accumulates only `n` — it can never fire on a scan, by construction. +Every element was therefore materialized individually, at roughly 1.8× the +batch parser's per-element rate, and #7499's batch reparse (gated on the +sparse cache still being mostly empty) was only ever consulted *after* the +scan had filled the bitmap, where it is a deliberate no-op. + +`LazyArrayHeader` gains `sequential_streak`, a run-length of consecutive +ascending cold reads, tripped by `scan_flip_threshold` — `n/64`, floored at +64. The floor is what keeps a glance at the first few records from dragging +in a parse of the whole document; the proportional part keeps the evidence +scaled to the array. The trigger also carries `force_materialize_lazy`'s own +`cached_count * 2 < cached_length` test, so it never asks for a batch +producer the callee would decline (which would otherwise fire on a 64–128 +element array, where the streak can only complete near the end). + +Measured on the pinned quiet host (M1 mini, 15 runs, `taskpolicy -t 0 -l 0`, +both arms compiled with `PERRY_NO_AUTO_OPTIMIZE=1` and run back to back): + +| workload | before | after | +|---|--:|--:| +| `json_polyglot` roundtrip | 201 ms · σ1.3 · 87 MB | 201 ms · σ1.6 · 87 MB | +| `json_polyglot` field_access | 2938 ms · σ135 · 222 MB | 2043 ms · σ146 · 195 MB | +| parse + full scan, no stringify | 2729 ms · σ215 | 1339 ms · σ26 | + +Checksums identical to node on both arms. The roundtrip arm's +unmutated-blob memcpy path is untouched by construction — a workload that +never indexes never opens a streak. On a pure scan the tape now beats its +own tape-off arm (1339 vs 1545 ms), so it has stopped being a net negative +on that shape. + +**field_access does not reach the `idiomatic` floor** (2043 ms against +1478 ms with the tape off and mark-sweep), and that is structural rather +than a tuning miss: the tape build is purely additive whenever the whole +tree ends up materialized anyway, and the flip hands off to a producer that +re-tokenizes the blob from scratch. Turning the tape off for this shape is +still ~500 ms better than this change. + +Decomposing the four `PERRY_JSON_TAPE` × `PERRY_GEN_GC` combinations (the +public baseline's `idiomatic` row flips both at once, so "tape off" alone +had never been measured) also relocates the remaining cost. The run-to-run +variance and the RSS are a **generational-GC × tape interaction**, not the +materializer: with the identical tape, switching to mark-sweep moves scan +σ from 214.9 to 8.8 and RSS from 208 MB to 66 MB, while tape-off under +gen-GC is σ17.6. Each parse allocates header+tape as a single ~2.4 MB block +(200,002 tape entries for the 10k-record fixture) that rounds up to a +dedicated oversized arena block and is then retained. After this change +`tape + mark-sweep` field_access is 1759 ms at σ2.1 and 76 MB, so the +element-wise materializer is no longer the bottleneck — releasing the tape +and blob once `materialized` is set is. + +Tests moved to `crates/perry-runtime/src/json_tape_tests.rs` (`#[path]` +sibling) to keep `json_tape.rs` under the 2,000-line cap. The six new cases +assert the producer that ran, not just the values: `reparse_materializations()` +tells the batch reparse apart from the element-wise merge walk, which +produces identical output, so a test that only checked the tree would pass +against the old never-flips behaviour. `LazyArrayHeader::cached_length`'s +offset-0 codegen contract — Perry inlines `.length` as a raw u32 load there +rather than calling `js_array_length` — is now enforced by a `const` +assertion rather than a doc comment, since this change adds a field to that +struct. + +Surfaced two pre-existing defects, both filed rather than fixed here: the +element-wise materializer can lose an object's key pointers under tape + +generational GC, so `JSON.stringify` emits `field0`/`field1` instead of the +real names (#7538 — this change reduces the exposure by retiring that path +for scan shapes, but does not fix it), and +`PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_ZEAL=1` faults in +`test_json_tape_eager_materialization_handles_survive_copied_minor_gc`, +A/B-confirmed identical on the merge base. diff --git a/crates/perry-runtime/src/json/parse_api.rs b/crates/perry-runtime/src/json/parse_api.rs index f4378c696a..7dc3617cf0 100644 --- a/crates/perry-runtime/src/json/parse_api.rs +++ b/crates/perry-runtime/src/json/parse_api.rs @@ -225,14 +225,23 @@ pub unsafe extern "C" fn js_json_parse(text_ptr: *const StringHeader) -> JSValue // Lazy parse is a win on workloads that touch only a subset of // a parsed top-level array — the tape build cost is ~one-shot // O(n) but each unread element saves the full subtree - // materialization. For workloads that iterate the whole array - // (the canonical "filter all records, stringify the result" - // shape — `benchmarks/honest_bench/workloads/1_json_pipeline` - // for example), lazy is strictly slower than direct: tape walk - // + sparse-cache management on every access plus a forced - // materialize at the end is more work than the direct parser's - // single tree build. The cumulative walk-steps trigger in - // `lazy_get` only catches *random* access, not sequential. + // materialization. Workloads that iterate the WHOLE array (the + // canonical "filter all records, stringify the result" shape — + // `benchmarks/honest_bench/workloads/1_json_pipeline`, and + // `benchmarks/json_polyglot`'s `field_access`) used to be strictly + // slower than direct, because the only adaptive signal was + // `lazy_get`'s cumulative walk-steps counter and that counter + // provably cannot see a scan: a sequential walk costs one tape + // step per element, so it accumulates `n` against a `2n` + // threshold and never trips. Every element was materialized one + // at a time at ~1.8× the batch parser's cost for the same tree, then + // merged. #7478 adds the missing signal — a consecutive-ascending + // streak (`LazyArrayHeader::sequential_streak`) that trips + // `json_tape::scan_flip_threshold` EARLY, while the sparse cache + // is still nearly empty, so `force_materialize_lazy` takes + // #7499's batch reparse for the whole remainder instead of + // finishing element-wise. The tape build itself is still additive + // on this shape; see #7478 for the measured decomposition. // // The auto-mode size window: lazy fires at 1 KB and above (tiny // parses don't pay the tape build) and below 16 MB (very large diff --git a/crates/perry-runtime/src/json_tape.rs b/crates/perry-runtime/src/json_tape.rs index 50c2e3d8b2..f13f205a33 100644 --- a/crates/perry-runtime/src/json_tape.rs +++ b/crates/perry-runtime/src/json_tape.rs @@ -908,261 +908,8 @@ fn parse_string_bytes_slow(bytes: &[u8], start_pos: usize, start: usize) -> Opti } #[cfg(test)] -mod tests { - use super::*; - - /// Tape structure invariants on a simple object — exercises the - /// OBJ_START → KEY → scalar → OBJ_END chain and the backfilled - /// `link` for skip-over. - #[test] - fn tape_simple_object() { - let input = br#"{"a":1,"b":"x"}"#; - let tape = build_tape(input).unwrap(); - let kinds: Vec = tape.entries.iter().map(|e| e.kind).collect(); - assert_eq!( - kinds, - vec![ - KIND_OBJ_START, - KIND_KEY, - KIND_NUMBER, - KIND_KEY, - KIND_STRING, - KIND_OBJ_END - ] - ); - // OBJ_START.link points at the matching OBJ_END (last entry). - assert_eq!(tape.entries[0].link as usize, tape.entries.len() - 1); - // OBJ_END.link points back at OBJ_START. - assert_eq!( - *tape.entries.last().unwrap(), - TapeEntry { - offset: tape.entries.last().unwrap().offset, - kind: KIND_OBJ_END, - link: 0 - } - ); - } - - /// Nested structure — an array of objects. Each inner OBJ_START - /// must have its link pointing at its OWN OBJ_END, not the outer - /// ARR_END. This is the invariant Phase 3 (lazy indexed access) - /// relies on to skip past unwanted elements. - #[test] - fn tape_nested_array_of_objects() { - let input = br#"[{"a":1},{"b":2},{"c":3}]"#; - let tape = build_tape(input).unwrap(); - // ARR_START ... ARR_END outer - assert_eq!(tape.entries[0].kind, KIND_ARR_START); - assert_eq!(tape.entries.last().unwrap().kind, KIND_ARR_END); - // Three object children — count OBJ_START entries. - let n_objs = tape - .entries - .iter() - .filter(|e| e.kind == KIND_OBJ_START) - .count(); - assert_eq!(n_objs, 3); - // Each OBJ_START's link points at an OBJ_END strictly before ARR_END. - for (i, e) in tape.entries.iter().enumerate() { - if e.kind == KIND_OBJ_START { - let end = e.link as usize; - assert!(end > i, "OBJ_START.link must point forward"); - assert!( - end < tape.entries.len() - 1, - "OBJ_END must precede outer ARR_END" - ); - assert_eq!(tape.entries[end].kind, KIND_OBJ_END); - assert_eq!( - tape.entries[end].link as usize, i, - "OBJ_END.link must point back" - ); - } - } - } - - /// Escaped string in a key and value — tape should still emit - /// one KEY and one STRING entry; string decoding is deferred to - /// materialization and doesn't perturb the tape shape. - #[test] - fn tape_escaped_strings() { - let input = br#"{"a\"b":"x\\y"}"#; - let tape = build_tape(input).unwrap(); - assert_eq!( - tape.entries.iter().map(|e| e.kind).collect::>(), - vec![KIND_OBJ_START, KIND_KEY, KIND_STRING, KIND_OBJ_END] - ); - } - - /// Malformed inputs must return None (caller falls back to - /// direct parser with richer error messages). - #[test] - fn tape_malformed_returns_none() { - assert!(build_tape(b"{").is_none(), "unclosed object"); - assert!(build_tape(b"[").is_none(), "unclosed array"); - assert!(build_tape(b"{a:1}").is_none(), "unquoted key"); - assert!(build_tape(b"{\"a\"}").is_none(), "missing colon"); - assert!(build_tape(b"").is_none(), "empty input"); - } - - /// Top-level scalar (allowed by JSON spec). - #[test] - fn tape_top_level_scalars() { - assert_eq!(build_tape(b"42").unwrap().entries.len(), 1); - assert_eq!(build_tape(b"true").unwrap().entries.len(), 1); - assert_eq!(build_tape(br#""hi""#).unwrap().entries.len(), 1); - assert_eq!(build_tape(b"null").unwrap().entries.len(), 1); - } - - /// `TapeEntry` is 12 bytes (u32 + u8 + padding + u32). Keeping - /// this compact matters for tape-size parity with parse output: - /// a 1 MB JSON blob with ~20k tokens should build a ~240 KB tape, - /// not a megabyte. - #[test] - fn tape_entry_layout() { - assert!( - std::mem::size_of::() <= 12, - "TapeEntry grew beyond 12 bytes — check padding" - ); - } - - #[test] - fn force_materialize_numeric_lazy_array_preserves_raw_payload() { - let input = br#"[1,2.5,3]"#; - let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); - let lazy = with_built_tape(input, |tape| unsafe { - alloc_lazy_array(tape, 0, count_array_length(tape, 0), text) - }) - .expect("valid JSON should build a tape"); - - let before = reparse_materializations(); - let arr = unsafe { force_materialize_lazy(lazy) }; - assert_eq!( - reparse_materializations(), - before + 1, - "an uncached lazy array must batch-materialize via the #7478 reparse" - ); - - assert_eq!(crate::array::js_array_is_numeric_f64_layout(arr), 1); - assert_eq!(crate::array::js_array_numeric_get_f64_unboxed(arr, 0), 1.0); - assert_eq!(crate::array::js_array_numeric_get_f64_unboxed(arr, 1), 2.5); - assert_eq!(crate::array::js_array_numeric_get_f64_unboxed(arr, 2), 3.0); - assert_eq!( - crate::gc::test_layout_pointer_slot_count(arr as usize, 3), - Some(0) - ); - } - - #[test] - fn force_materialize_lazy_array_cache_downgrades_for_pointer_values() { - let input = br#"[1,2,3]"#; - let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); - let lazy = with_built_tape(input, |tape| unsafe { - alloc_lazy_array(tape, 0, count_array_length(tape, 0), text) - }) - .expect("valid JSON should build a tape"); - - unsafe { - let cached = crate::string::js_string_from_bytes(b"cached".as_ptr(), 6); - *(*lazy).materialized_elements.add(1) = - JSValue::string_ptr(cached as *mut crate::StringHeader); - *(*lazy).materialized_bitmap |= 1u64 << 1; - } - - let before = reparse_materializations(); - let arr = unsafe { force_materialize_lazy(lazy) }; - assert_eq!( - reparse_materializations(), - before + 1, - "1-of-3 cached is below the crossover, so this must reparse" - ); - - // The reparse produces a RawF64-layout array for `[1,2,3]`; patching - // a STRING into slot 1 has to downgrade it, or the tracer would skip - // a live pointer in an array flagged pointer-free. - assert_eq!(crate::array::js_array_is_numeric_f64_layout(arr), 0); - assert_eq!( - crate::gc::test_layout_pointer_slot_count(arr as usize, 3), - Some(1) - ); - } - - /// #7478 crossover. Once MOST elements are already in the sparse cache - /// the element-wise merge is the cheap producer — it copies the cached - /// JSValues and materializes only the remainder — so a reparse would - /// rebuild subtrees it is about to throw away. This pins the decision, - /// not just the values: without the counter assertion the test passes - /// either way and the crossover could silently invert. - #[test] - fn force_materialize_majority_cached_uses_the_merge_walk_not_a_reparse() { - let input = br#"[10,20,30,40]"#; - let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); - let lazy = with_built_tape(input, |tape| unsafe { - alloc_lazy_array(tape, 0, count_array_length(tape, 0), text) - }) - .expect("valid JSON should build a tape"); - - unsafe { - *(*lazy).materialized_elements.add(0) = JSValue::number(1.5); - *(*lazy).materialized_elements.add(1) = JSValue::number(2.5); - *(*lazy).materialized_bitmap |= 0b11; - } - - let before = reparse_materializations(); - let arr = unsafe { force_materialize_lazy(lazy) }; - assert_eq!( - reparse_materializations(), - before, - "2-of-4 cached is at the crossover — the walk, not a reparse" - ); - - assert_eq!( - crate::array::js_array_get(arr, 0).bits(), - JSValue::number(1.5).bits() - ); - assert_eq!( - crate::array::js_array_get(arr, 1).bits(), - JSValue::number(2.5).bits() - ); - assert_eq!( - crate::array::js_array_get(arr, 2).bits(), - JSValue::number(30.0).bits() - ); - assert_eq!( - crate::array::js_array_get(arr, 3).bits(), - JSValue::number(40.0).bits() - ); - } - - /// A lazy header whose tape root is not the blob's first value cannot - /// have its blob re-parsed (the blob is not that array's source), so the - /// reparse must decline and the tape walk must still produce the array. - #[test] - fn force_materialize_declines_reparse_when_the_tape_root_is_not_the_blob_root() { - let input = br#"[[7,8],[9]]"#; - let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); - // root_idx 1 = the inner `[7,8]`, whose source is NOT the whole blob. - let lazy = with_built_tape(input, |tape| unsafe { - alloc_lazy_array(tape, 1, count_array_length(tape, 1), text) - }) - .expect("valid JSON should build a tape"); - - let before = reparse_materializations(); - let arr = unsafe { force_materialize_lazy(lazy) }; - assert_eq!( - reparse_materializations(), - before, - "a non-root tape index must decline the reparse" - ); - assert_eq!(unsafe { (*arr).length }, 2); - assert_eq!( - crate::array::js_array_get(arr, 0).bits(), - JSValue::number(7.0).bits() - ); - assert_eq!( - crate::array::js_array_get(arr, 1).bits(), - JSValue::number(8.0).bits() - ); - } -} +#[path = "json_tape_tests.rs"] +mod tests; impl PartialEq for TapeEntry { fn eq(&self, other: &Self) -> bool { @@ -1264,9 +1011,89 @@ pub struct LazyArrayHeader { /// ~4 accesses on a 10k-element array, flipping to O(1) access /// and saving 50-100× on the rest of the workload. pub cumulative_walk_steps: u64, + /// #7478: length of the current run of *consecutive ascending* cold + /// reads (`parsed[k]`, `parsed[k+1]`, …). Reset to zero by any read + /// that is not one past the previous one. + /// + /// `cumulative_walk_steps` provably cannot see a scan: a sequential + /// walk costs exactly one tape step per element, so it accumulates + /// `n` against a threshold of `2n` and never trips. A scan is + /// therefore invisible to the only adaptive signal the header had, + /// which is why `field_access` pays 10 000 element-wise + /// materializations at ~1.8× the batch parser's cost for the same + /// tree (#7478's quiet-host decomposition: 2540 ms of element-wise + /// materialization against 1412 ms for a whole `DirectParser` parse, + /// tokenization included). This counter is the missing signal; + /// `scan_flip_threshold` is where it trips. + pub sequential_streak: u32, // Followed by `tape_len` `TapeEntry` elements inline. } +// `cached_length` at offset 0 is a CODEGEN contract, not a layout preference: +// Perry inlines `.length` as a raw u32 load at offset 0 rather than calling +// `js_array_length`, so an unmaterialized lazy array only reports the right +// length because this field sits first. Nothing else in the tree enforced +// that — the guarantee lived in a doc comment — so a field reordered into +// the front would have produced silently wrong `.length` values with every +// test still green. Adding a field to this struct is the moment that can +// happen, so pin it here. +const _: () = assert!( + std::mem::offset_of!(LazyArrayHeader, cached_length) == 0, + "LazyArrayHeader::cached_length must stay at offset 0 — codegen inlines \ + `.length` as a raw u32 load there" +); + +/// #7478: how long a run of consecutive ascending cold reads has to get +/// before we stop materializing element-by-element and hand the whole +/// array to the batch parser. +/// +/// The reparse rebuilds the WHOLE array, so with the element-wise +/// producer costing `r`× the batch one, flipping after a fraction `f` of +/// the array has been walked pays exactly when `f < 1 - 1/r`. At the +/// measured `r ≈ 1.8` that is `f < 44%`, which is why the caller pairs +/// this with `force_materialize_lazy`'s own `cached_count * 2 < +/// cached_length` (`f < 50%`) gate rather than relying on the streak +/// alone. +/// +/// A streak is the evidence that `f` will keep growing, and the threshold +/// scales with the array so that evidence stays proportional: 1/64th of +/// the elements, floored at 64 so small arrays are not flipped on a +/// glance. The floor protects the "peek at a handful of records" shape — +/// `parsed[0]`..`parsed[9]` on a 10k array must NOT drag in a full parse. +/// The waste is bounded on the other side too: if the scan stops right +/// after the flip we have done one batch parse, which is still cheaper +/// than the element-wise walk of the same array it replaced. +#[inline] +pub(crate) fn scan_flip_threshold(cached_length: u32) -> u32 { + core::cmp::max(64, cached_length / 64) +} + +/// How many elements are currently in the sparse cache. +/// +/// `lazy_get`'s scan-flip trigger and `force_materialize_lazy`'s choice of +/// producer have to agree on this number — the trigger exists to ask for the +/// batch reparse, and asking when the callee will decline just materializes +/// the array early through the slow path. They therefore read it from one +/// place. The trigger used to approximate it as `i + 1`, which is only the +/// true count for a scan that starts at zero and touches nothing else. +/// +/// # Safety +/// +/// `hdr` must be a live `LazyArrayHeader`. +#[inline] +unsafe fn lazy_cached_count(hdr: *const LazyArrayHeader) -> u64 { + let bitmap = (*hdr).materialized_bitmap; + let cached_length = (*hdr).cached_length; + if bitmap.is_null() || (*hdr).materialized_elements.is_null() || cached_length == 0 { + return 0; + } + let mut count: u64 = 0; + for w in 0..(cached_length as usize).div_ceil(64) { + count += (*bitmap.add(w)).count_ones() as u64; + } + count +} + impl LazyArrayHeader { /// Slice view over the inline tape bytes. Caller must keep the /// header alive for the slice's lifetime. @@ -1314,6 +1141,7 @@ pub unsafe fn alloc_lazy_array( (*hdr).walk_idx = u32::MAX; (*hdr).walk_tape_pos = 0; (*hdr).cumulative_walk_steps = 0; + (*hdr).sequential_streak = 0; let hdr_handle = scope.root_raw_mut_ptr(hdr); json_tape_safepoint(JsonTapeSafepoint::LazyArrayRooted, hdr as usize); let hdr = hdr_handle.get_raw_mut_ptr::(); @@ -1511,6 +1339,22 @@ pub unsafe fn lazy_get(hdr: *mut LazyArrayHeader, i: u32) -> JSValue { // regardless of subtree size, so this bound matches the actual // work done. let step_cost = (i - start_count) as u64; + // #7478: extend the consecutive-ascending run, or start a new one. A + // cold read of index 0 with no prior walk opens a streak; any read + // that is not exactly one past the previous COLD read ends it. Cache + // hits never reach here, so a re-scan of an already-materialized + // prefix does not inflate the count. + let streak = if prev_walk != u32::MAX && i == prev_walk + 1 { + (*hdr).sequential_streak.saturating_add(1) + } else { + // A cold read that does not continue the previous run still IS a run + // — of length one. Recording zero here made the run lag by one, so a + // scan that began anywhere but index 0 needed 65 reads to trip a + // threshold of 64, and a lone read reported a shorter run than the + // very next read that continued it. + 1 + }; + (*hdr).sequential_streak = streak; (*hdr).walk_idx = i; (*hdr).walk_tape_pos = idx as u32; (*hdr).cumulative_walk_steps = (*hdr).cumulative_walk_steps.saturating_add(step_cost); @@ -1533,15 +1377,43 @@ pub unsafe fn lazy_get(hdr: *mut LazyArrayHeader, i: u32) -> JSValue { *bitmap.add(word_idx) |= 1u64 << bit_idx; } - // Adaptive threshold: if cumulative walk steps exceed 2× the - // array length, future per-element walks cost more than a - // single full-materialize — trigger it now. Sequential access - // (1 step per element) never trips; random access (n/2 per - // step) trips after ~4 accesses on a 10k array. Post-trip, - // every subsequent `lazy_get` hits the fast path at the top of - // the function (materialized != null → direct ArrayHeader read). + // Adaptive thresholds. Either signal means future per-element walks + // cost more than one full materialize, so trigger it now; afterwards + // every `lazy_get` hits fast path 1 at the top of the function + // (materialized != null → direct ArrayHeader read). + // + // 1. Cumulative walk steps past 2× the array length. Random access + // averages n/2 steps per read, so this trips after ~4 reads on a + // 10k array. Sequential access costs 1 step per element and + // provably never trips it — which is the #7478 hole. + // 2. A consecutive-ascending streak past `scan_flip_threshold`. This + // is the scan-shaped signal the first one cannot see. It fires + // while the sparse cache is still nearly empty, which is what + // makes `force_materialize_lazy` take #7499's batch reparse + // (gated on `cached_count * 2 < cached_length`) instead of the + // element-wise merge walk. Firing it late — after the scan has + // already filled the bitmap — is a no-op, which is exactly why + // #7499 alone did not move this benchmark. + // + // The second signal carries the SAME "is the batch producer even going + // to be picked?" test that `force_materialize_lazy` applies, read from + // the same `lazy_cached_count` helper. It used to approximate the cache + // count as `i + 1`, which is only true for a scan that starts at zero + // and touches nothing else: any earlier cold read outside the prefix + // made the trigger UNDERcount, so it could fire on an array the callee + // then declined to reparse — materializing the whole thing early + // through the element-wise merge walk, the exact path this is meant to + // avoid. The popcount is O(n/64) and only runs once the streak has + // already reached the threshold, which for a scan happens once. + // + // It is also what stops the flip firing on an array whose streak can + // only complete near the end — a 64- to 128-element one, where the + // flip would land on the last read and reparse a tree the merge walk + // was already holding. let hdr = hdr_handle.get_raw_mut_ptr::(); - if (*hdr).cumulative_walk_steps > (cached_length as u64) * 2 { + 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); } @@ -1574,10 +1446,14 @@ pub(crate) fn reparse_materializations() -> u64 { /// with `str::parse::` (what `materialize_number` uses) and with node. /// That divergence (#7477) is what blocked this change the first time. /// -/// The batch parser builds the same tree ~2.3× faster than the per-element -/// materializer (#7478's decomposition: 24 ms/iter vs 56 ms/iter on the 10k- -/// record fixture), because it makes one linear pass instead of re-entering -/// the walk, the sparse cache and a fresh handle scope per element. +/// The batch parser builds the same tree ~1.8× cheaper than the per-element +/// materializer (#7478's quiet-host decomposition on the 10k-record fixture: +/// 1412 ms for 50 whole `DirectParser` parses, tokenization included, against +/// 2540 ms of element-wise materialization for the same trees), because it +/// makes one linear pass instead of re-entering the walk, the sparse cache +/// and a fresh handle scope per element — and because it pre-sizes each +/// object from a known field count behind an inline hot-shape cache instead +/// of growing it a field at a time. /// /// GC contract — this is the part the first attempt got wrong, and it /// SIGSEGV'd intermittently for it. `DirectParser` is only sound inside a @@ -1713,26 +1589,20 @@ pub unsafe fn force_materialize_lazy(hdr: *mut LazyArrayHeader) -> *mut crate::a let cached_length = (*hdr).cached_length; let bitmap = (*hdr).materialized_bitmap; let cache = (*hdr).materialized_elements; - let cached_count = if !bitmap.is_null() && !cache.is_null() && cached_length > 0 { - let words = (cached_length as usize).div_ceil(64); - let mut count: u64 = 0; - for w in 0..words { - count += (*bitmap.add(w)).count_ones() as u64; - } - count - } else { - 0 - }; + // Same helper `lazy_get`'s scan-flip trigger consults, so the trigger + // can never ask for a producer this function then declines. + let cached_count = lazy_cached_count(hdr); let has_cache_hits = cached_count > 0; // #7478: when most of the array still has to be built, re-parse the // retained blob with the batch DirectParser instead of walking the - // tape element-by-element — same tree, ~2.3× the rate. When MOST + // tape element-by-element — same tree, ~1.8× cheaper. When MOST // elements are already cached the walk is the cheap producer (it // copies cached JSValues and materializes only the remainder), and a - // reparse would rebuild subtrees it is about to throw away. The - // measured crossover is at ~43% uncached; `cached_count * 2 < - // cached_length` sits on the conservative side of it. + // reparse would rebuild subtrees it is about to throw away. Since the + // reparse rebuilds the whole array, it pays exactly while the cached + // fraction is below `1 - 1/1.8 ≈ 44%`; `cached_count * 2 < + // cached_length` is that crossover rounded to a shift. if cached_count * 2 < cached_length as u64 { let (reparsed, refreshed) = reparse_materialize(&scope, &hdr_handle, hdr, cached_length); if let Some(arr) = reparsed { diff --git a/crates/perry-runtime/src/json_tape_tests.rs b/crates/perry-runtime/src/json_tape_tests.rs new file mode 100644 index 0000000000..1abc95c704 --- /dev/null +++ b/crates/perry-runtime/src/json_tape_tests.rs @@ -0,0 +1,480 @@ +//! Unit tests for the JSON tape builder, its materializer, and the +//! lazy-array header's access/flip policy. +//! +//! Split out of `json_tape.rs` so that file stays under the 2,000-line +//! CI cap (`scripts/check_file_size.sh`). Declared from there with +//! `#[cfg(test)] #[path = "json_tape_tests.rs"] mod tests;`, so +//! `use super::*` still names the tape module's private items. + +use super::*; + +/// Tape structure invariants on a simple object — exercises the +/// OBJ_START → KEY → scalar → OBJ_END chain and the backfilled +/// `link` for skip-over. +#[test] +fn tape_simple_object() { + let input = br#"{"a":1,"b":"x"}"#; + let tape = build_tape(input).unwrap(); + let kinds: Vec = tape.entries.iter().map(|e| e.kind).collect(); + assert_eq!( + kinds, + vec![ + KIND_OBJ_START, + KIND_KEY, + KIND_NUMBER, + KIND_KEY, + KIND_STRING, + KIND_OBJ_END + ] + ); + // OBJ_START.link points at the matching OBJ_END (last entry). + assert_eq!(tape.entries[0].link as usize, tape.entries.len() - 1); + // OBJ_END.link points back at OBJ_START. + assert_eq!( + *tape.entries.last().unwrap(), + TapeEntry { + offset: tape.entries.last().unwrap().offset, + kind: KIND_OBJ_END, + link: 0 + } + ); +} + +/// Nested structure — an array of objects. Each inner OBJ_START +/// must have its link pointing at its OWN OBJ_END, not the outer +/// ARR_END. This is the invariant Phase 3 (lazy indexed access) +/// relies on to skip past unwanted elements. +#[test] +fn tape_nested_array_of_objects() { + let input = br#"[{"a":1},{"b":2},{"c":3}]"#; + let tape = build_tape(input).unwrap(); + // ARR_START ... ARR_END outer + assert_eq!(tape.entries[0].kind, KIND_ARR_START); + assert_eq!(tape.entries.last().unwrap().kind, KIND_ARR_END); + // Three object children — count OBJ_START entries. + let n_objs = tape + .entries + .iter() + .filter(|e| e.kind == KIND_OBJ_START) + .count(); + assert_eq!(n_objs, 3); + // Each OBJ_START's link points at an OBJ_END strictly before ARR_END. + for (i, e) in tape.entries.iter().enumerate() { + if e.kind == KIND_OBJ_START { + let end = e.link as usize; + assert!(end > i, "OBJ_START.link must point forward"); + assert!( + end < tape.entries.len() - 1, + "OBJ_END must precede outer ARR_END" + ); + assert_eq!(tape.entries[end].kind, KIND_OBJ_END); + assert_eq!( + tape.entries[end].link as usize, i, + "OBJ_END.link must point back" + ); + } + } +} + +/// Escaped string in a key and value — tape should still emit +/// one KEY and one STRING entry; string decoding is deferred to +/// materialization and doesn't perturb the tape shape. +#[test] +fn tape_escaped_strings() { + let input = br#"{"a\"b":"x\\y"}"#; + let tape = build_tape(input).unwrap(); + assert_eq!( + tape.entries.iter().map(|e| e.kind).collect::>(), + vec![KIND_OBJ_START, KIND_KEY, KIND_STRING, KIND_OBJ_END] + ); +} + +/// Malformed inputs must return None (caller falls back to +/// direct parser with richer error messages). +#[test] +fn tape_malformed_returns_none() { + assert!(build_tape(b"{").is_none(), "unclosed object"); + assert!(build_tape(b"[").is_none(), "unclosed array"); + assert!(build_tape(b"{a:1}").is_none(), "unquoted key"); + assert!(build_tape(b"{\"a\"}").is_none(), "missing colon"); + assert!(build_tape(b"").is_none(), "empty input"); +} + +/// Top-level scalar (allowed by JSON spec). +#[test] +fn tape_top_level_scalars() { + assert_eq!(build_tape(b"42").unwrap().entries.len(), 1); + assert_eq!(build_tape(b"true").unwrap().entries.len(), 1); + assert_eq!(build_tape(br#""hi""#).unwrap().entries.len(), 1); + assert_eq!(build_tape(b"null").unwrap().entries.len(), 1); +} + +/// `TapeEntry` is 12 bytes (u32 + u8 + padding + u32). Keeping +/// this compact matters for tape-size parity with parse output: +/// a 1 MB JSON blob with ~20k tokens should build a ~240 KB tape, +/// not a megabyte. +#[test] +fn tape_entry_layout() { + assert!( + std::mem::size_of::() <= 12, + "TapeEntry grew beyond 12 bytes — check padding" + ); +} + +#[test] +fn force_materialize_numeric_lazy_array_preserves_raw_payload() { + let input = br#"[1,2.5,3]"#; + let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); + let lazy = with_built_tape(input, |tape| unsafe { + alloc_lazy_array(tape, 0, count_array_length(tape, 0), text) + }) + .expect("valid JSON should build a tape"); + + let before = reparse_materializations(); + let arr = unsafe { force_materialize_lazy(lazy) }; + assert_eq!( + reparse_materializations(), + before + 1, + "an uncached lazy array must batch-materialize via the #7478 reparse" + ); + + assert_eq!(crate::array::js_array_is_numeric_f64_layout(arr), 1); + assert_eq!(crate::array::js_array_numeric_get_f64_unboxed(arr, 0), 1.0); + assert_eq!(crate::array::js_array_numeric_get_f64_unboxed(arr, 1), 2.5); + assert_eq!(crate::array::js_array_numeric_get_f64_unboxed(arr, 2), 3.0); + assert_eq!( + crate::gc::test_layout_pointer_slot_count(arr as usize, 3), + Some(0) + ); +} + +#[test] +fn force_materialize_lazy_array_cache_downgrades_for_pointer_values() { + let input = br#"[1,2,3]"#; + let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); + let lazy = with_built_tape(input, |tape| unsafe { + alloc_lazy_array(tape, 0, count_array_length(tape, 0), text) + }) + .expect("valid JSON should build a tape"); + + unsafe { + let cached = crate::string::js_string_from_bytes(b"cached".as_ptr(), 6); + *(*lazy).materialized_elements.add(1) = + JSValue::string_ptr(cached as *mut crate::StringHeader); + *(*lazy).materialized_bitmap |= 1u64 << 1; + } + + let before = reparse_materializations(); + let arr = unsafe { force_materialize_lazy(lazy) }; + assert_eq!( + reparse_materializations(), + before + 1, + "1-of-3 cached is below the crossover, so this must reparse" + ); + + // The reparse produces a RawF64-layout array for `[1,2,3]`; patching + // a STRING into slot 1 has to downgrade it, or the tracer would skip + // a live pointer in an array flagged pointer-free. + assert_eq!(crate::array::js_array_is_numeric_f64_layout(arr), 0); + assert_eq!( + crate::gc::test_layout_pointer_slot_count(arr as usize, 3), + Some(1) + ); +} + +/// #7478 crossover. Once MOST elements are already in the sparse cache +/// the element-wise merge is the cheap producer — it copies the cached +/// JSValues and materializes only the remainder — so a reparse would +/// rebuild subtrees it is about to throw away. This pins the decision, +/// not just the values: without the counter assertion the test passes +/// either way and the crossover could silently invert. +#[test] +fn force_materialize_majority_cached_uses_the_merge_walk_not_a_reparse() { + let input = br#"[10,20,30,40]"#; + let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); + let lazy = with_built_tape(input, |tape| unsafe { + alloc_lazy_array(tape, 0, count_array_length(tape, 0), text) + }) + .expect("valid JSON should build a tape"); + + unsafe { + *(*lazy).materialized_elements.add(0) = JSValue::number(1.5); + *(*lazy).materialized_elements.add(1) = JSValue::number(2.5); + *(*lazy).materialized_bitmap |= 0b11; + } + + let before = reparse_materializations(); + let arr = unsafe { force_materialize_lazy(lazy) }; + assert_eq!( + reparse_materializations(), + before, + "2-of-4 cached is at the crossover — the walk, not a reparse" + ); + + assert_eq!( + crate::array::js_array_get(arr, 0).bits(), + JSValue::number(1.5).bits() + ); + assert_eq!( + crate::array::js_array_get(arr, 1).bits(), + JSValue::number(2.5).bits() + ); + assert_eq!( + crate::array::js_array_get(arr, 2).bits(), + JSValue::number(30.0).bits() + ); + assert_eq!( + crate::array::js_array_get(arr, 3).bits(), + JSValue::number(40.0).bits() + ); +} + +/// Build a lazy array over `[{"x":0},{"x":1},…]` with `n` records. +/// Returns the header plus the retained blob text so the caller can +/// keep both alive for the duration of a test. +unsafe fn lazy_object_array(n: usize) -> *mut LazyArrayHeader { + let mut json = String::from("["); + for i in 0..n { + if i > 0 { + json.push(','); + } + json.push_str(&format!("{{\"x\":{i}}}")); + } + json.push(']'); + let input = json.as_bytes(); + let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); + with_built_tape(input, |tape| { + alloc_lazy_array(tape, 0, count_array_length(tape, 0), text) + }) + .expect("valid JSON should build a tape") +} + +/// #7478 core acceptance. A sequential scan must hand off to the batch +/// producer PART WAY THROUGH, not at the end. +/// +/// This asserts the subject ran, not merely that the values are right: +/// `reparse_materializations()` distinguishes the batch parser from the +/// element-wise merge walk, and the two produce identical values. Without +/// that counter the test would pass just as happily against the old +/// never-flips behavior. +#[test] +fn a_sequential_scan_flips_to_the_batch_parser_part_way_through() { + let n = 200usize; + let lazy = unsafe { lazy_object_array(n) }; + let threshold = scan_flip_threshold(n as u32) as usize; + assert_eq!(threshold, 64, "n=200 sits on the floor arm of the rule"); + + let before = reparse_materializations(); + let mut flipped_at = None; + for i in 0..n { + unsafe { lazy_get(lazy, i as u32) }; + if flipped_at.is_none() && unsafe { !(*lazy).materialized.is_null() } { + flipped_at = Some(i); + } + } + let flipped_at = flipped_at.expect("a full sequential scan must flip"); + assert_eq!( + flipped_at, + threshold - 1, + "the flip must land on the read that completes the streak" + ); + assert!( + flipped_at < n / 2, + "flipping must happen while most of the array is still unbuilt" + ); + assert_eq!( + reparse_materializations(), + before + 1, + "the flip must reach #7499's BATCH reparse — an element-wise \ + merge walk here would leave this counter untouched" + ); +} + +/// An array too small for the streak to complete before the scan is nearly +/// over must not flip at all: the merge walk is already holding the tree by +/// then, so a reparse would rebuild what it is about to return. This pins +/// the "would the callee even pick the batch producer?" half of the trigger. +#[test] +fn an_array_whose_streak_can_only_complete_late_does_not_flip() { + // n = 100: threshold is the floor (64), and 64 reads in the streak is + // already past the half-way point, so the batch producer is not eligible. + let n = 100usize; + let lazy = unsafe { lazy_object_array(n) }; + assert_eq!(scan_flip_threshold(n as u32), 64); + let before = reparse_materializations(); + for i in 0..n { + unsafe { lazy_get(lazy, i as u32) }; + } + assert!( + unsafe { (*lazy).materialized.is_null() }, + "a late-completing streak must not trigger a redundant batch parse" + ); + assert_eq!(reparse_materializations(), before); +} + +/// The floor of `scan_flip_threshold` is what keeps a glance at the first +/// few records from dragging in a parse of the whole document. Without it +/// the proportional rule would trip at 1/64th of the array, which on a +/// 10k-record blob is 156 reads — but on a small one is 2. +#[test] +fn a_short_prefix_read_does_not_flip() { + let n = 200usize; + let lazy = unsafe { lazy_object_array(n) }; + let before = reparse_materializations(); + for i in 0..10 { + unsafe { lazy_get(lazy, i) }; + } + assert!( + unsafe { (*lazy).materialized.is_null() }, + "10 reads out of 200 must stay lazy" + ); + assert_eq!( + reparse_materializations(), + before, + "no batch parse may be triggered by a short prefix read" + ); +} + +/// The streak counts CONSECUTIVE ascending reads. A strided walk is not a +/// scan, and must not trip the new rule — it is left to the pre-existing +/// `cumulative_walk_steps` rule, which this access pattern also stays +/// under (61 reads × 2 steps = 122, against a 2n threshold of 400). +#[test] +fn a_strided_walk_does_not_trip_the_scan_flip() { + let n = 200usize; + let lazy = unsafe { lazy_object_array(n) }; + let before = reparse_materializations(); + let mut reads = 0; + for i in (0..122).step_by(2) { + unsafe { lazy_get(lazy, i) }; + reads += 1; + } + assert_eq!(reads, 61); + assert_eq!( + unsafe { (*lazy).sequential_streak }, + 1, + "a stride of 2 must never EXTEND a run — each read starts its own run \ + of length one, and 61 of them must not add up to a streak" + ); + assert!( + unsafe { (*lazy).materialized.is_null() }, + "a strided walk must not flip" + ); + assert_eq!(reparse_materializations(), before); +} + +/// `parsed[i] === parsed[i]` has to survive the flip. The elements handed +/// out before it came from the tape walk and live in the sparse cache; +/// the reparse builds fresh ones and then patches the cached values back +/// over its slots. If that patch loop were dropped, this test sees two +/// different pointers for the same index. +#[test] +fn element_identity_survives_the_scan_flip() { + let n = 200usize; + let lazy = unsafe { lazy_object_array(n) }; + // Read index 5 before the flip and remember the exact JSValue. It has to + // be ROOTED, not just copied into a local: the scan below allocates 200 + // records and then a whole reparsed tree, and a raw copy of a pointer + // JSValue held across that is exactly the stale-local shape the identity + // assertion is supposed to be testing for. Unrooted, a moving collection + // would fail this test for the wrong reason. + let scope = crate::gc::RuntimeHandleScope::new(); + let early = scope.root_nanbox_u64(unsafe { lazy_get(lazy, 5) }.bits()); + assert!( + unsafe { (*lazy).materialized.is_null() }, + "one read must not flip" + ); + for i in 0..n { + unsafe { lazy_get(lazy, i as u32) }; + } + assert!( + unsafe { !(*lazy).materialized.is_null() }, + "the scan must have flipped" + ); + let late = unsafe { lazy_get(lazy, 5) }; + assert_eq!( + early.get_nanbox_u64(), + late.bits(), + "the pre-flip element must stay identical across the flip" + ); + // And the batch-produced elements must carry the right values. + let arr = unsafe { (*lazy).materialized }; + assert_eq!(unsafe { (*arr).length }, n as u32); +} + +/// The flip's eligibility test must count what is ACTUALLY in the sparse +/// cache, not assume the cache is the scanned prefix. With a read outside the +/// prefix already cached, approximating the count as `i + 1` undercounts, and +/// the trigger can fire on an array `force_materialize_lazy` then declines to +/// reparse — which does not merely waste the trigger, it materializes the +/// whole array early through the element-wise merge walk, the exact path the +/// flip exists to avoid. +/// +/// 131 elements with index 130 read first: the streak completes at index 63, +/// where the true cache count is 65 (0..=63 plus 130). `65 * 2 = 130 < 131` +/// still admits the reparse by one, so the flip is correct here — and the +/// test pins that it is decided on 65 and not on the 64 the old arithmetic +/// would have used. +#[test] +fn the_flip_counts_the_whole_cache_not_just_the_scanned_prefix() { + let n = 131usize; + let lazy = unsafe { lazy_object_array(n) }; + assert_eq!(scan_flip_threshold(n as u32), 64); + + // A cold read well outside the prefix we are about to scan. + unsafe { lazy_get(lazy, 130) }; + assert_eq!( + unsafe { (*lazy).sequential_streak }, + 1, + "a lone cold read is a run of length one" + ); + + let before = reparse_materializations(); + for i in 0..64u32 { + unsafe { lazy_get(lazy, i) }; + } + assert!( + unsafe { !(*lazy).materialized.is_null() }, + "the streak completes at index 63 and the cache is still under half" + ); + assert_eq!( + reparse_materializations(), + before + 1, + "the flip must have reached the batch reparse, not the merge walk" + ); + // Every element still has to read back correctly, including the one that + // was cached before the flip and patched back over the reparsed slot. + let arr = unsafe { (*lazy).materialized }; + assert_eq!(unsafe { (*arr).length }, n as u32); +} + +/// A lazy header whose tape root is not the blob's first value cannot +/// have its blob re-parsed (the blob is not that array's source), so the +/// reparse must decline and the tape walk must still produce the array. +#[test] +fn force_materialize_declines_reparse_when_the_tape_root_is_not_the_blob_root() { + let input = br#"[[7,8],[9]]"#; + let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); + // root_idx 1 = the inner `[7,8]`, whose source is NOT the whole blob. + let lazy = with_built_tape(input, |tape| unsafe { + alloc_lazy_array(tape, 1, count_array_length(tape, 1), text) + }) + .expect("valid JSON should build a tape"); + + let before = reparse_materializations(); + let arr = unsafe { force_materialize_lazy(lazy) }; + assert_eq!( + reparse_materializations(), + before, + "a non-root tape index must decline the reparse" + ); + assert_eq!(unsafe { (*arr).length }, 2); + assert_eq!( + crate::array::js_array_get(arr, 0).bits(), + JSValue::number(7.0).bits() + ); + assert_eq!( + crate::array::js_array_get(arr, 1).bits(), + JSValue::number(8.0).bits() + ); +}