From 549a7c4414a2459c88198aa3d442802657439f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 01:43:32 +0200 Subject: [PATCH] perf(json): parse eagerly when this thread's lazy arrays keep being 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` 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.11x the better engine. The same inputs parsed eagerly run at parity. No size threshold can choose 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. Raising the lazy window's lower bound would have passed the matrix cells and regressed small heterogeneous parses ~4x, so the decision follows behaviour instead. `json::traversal_feedback` keeps a per-thread score: every lazy array created costs a point, every traversal earns two. Once traversal is the norm, eligible parses go eagerly, and one in 16 still takes the tape so a program that stops traversing drifts back. Parse-only and sparse programs never earn evidence, so they never leave the tape. The gate applies only in `PERRY_JSON_TAPE`'s auto mode. Two traversal signals, both from `lazy_get_rooted`'s cold read and folded into one call so json_tape.rs stays under its line cap: - the existing adaptive flip (cumulative walk or scan streak), and - an in-order read of the LAST element. 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 of it cached -- so without this, records_array_16k:scan never produced any evidence. Stringify, revivers, array methods and mutation also force materialization, but none of them is evidence that a scan would have been cheaper eagerly. Measured across all 50 matrix cells in one binary against the tape-only route: records_array_16k:scan 1.23x -> 0.78x (RSS 51 -> 34 MiB) records_array_1m:scan 1.11x -> 0.98x records_array_8m:scan CPU 0.93x -> 0.77x, RSS 190 -> 163 MiB No other cell moved outside noise. --- crates/perry-runtime/src/json/mod.rs | 1 + crates/perry-runtime/src/json/parse_api.rs | 7 +- .../src/json/traversal_feedback.rs | 177 ++++++++++++++++++ crates/perry-runtime/src/json_tape.rs | 5 +- scripts/gc_runtime_root_holders.json | 12 ++ 5 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 crates/perry-runtime/src/json/traversal_feedback.rs diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index 0951812113..9fc37897a9 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; diff --git a/crates/perry-runtime/src/json/parse_api.rs b/crates/perry-runtime/src/json/parse_api.rs index fe465e0f8b..ad763e8a2c 100644 --- a/crates/perry-runtime/src/json/parse_api.rs +++ b/crates/perry-runtime/src/json/parse_api.rs @@ -395,7 +395,11 @@ 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. @@ -644,6 +648,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( 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..b446d2ba67 100644 --- a/crates/perry-runtime/src/json_tape.rs +++ b/crates/perry-runtime/src/json_tape.rs @@ -1670,9 +1670,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/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 93387d5fa6..f362d111de 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",