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",