Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/10249-json-clone-traversal-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### perf(json): count a compiled loop's whole-array materialization as traversal evidence

Traversal feedback (#10150) switches a program's `JSON.parse` calls to eager parsing once its lazy arrays keep being fully traversed, but it only counted element reads in `lazy_get_rooted`. The element-shape loop clone (#10171) materializes a lazy array whole in its preheader through `js_array_refresh_local_head`, so scan loops served by the clone never produced evidence and paid for both the tape and the eager materialization on every parse. `js_array_refresh_local_head` now counts the first materialization of each lazy array as evidence. Measured on the bench mini: `records_array_16k:scan` 149.6 → 109.7 ms, `records_array_1m:scan` 180.4 → 132.6 ms, `records_array_8m:scan` 171.5 → 122.6 ms, other JSON rows unchanged.
7 changes: 7 additions & 0 deletions crates/perry-runtime/src/array/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1363,10 +1363,17 @@ pub extern "C" fn js_array_refresh_local_head(value: f64) -> f64 {
if !crate::value::addr_class::is_plausible_heap_addr(raw) {
return value;
}
// Cold arms only (loop-clone preheader, guarded read repair): see
// `traversal_feedback::note_compiled_materialization`.
let materializes_lazy =
unsafe { crate::json::traversal_feedback::lazy_array_unmaterialized(raw) };
let cleaned = clean_arr_ptr(raw as *const ArrayHeader);
if cleaned.is_null() || cleaned as usize == raw {
return value;
}
if materializes_lazy {
crate::json::traversal_feedback::note_compiled_materialization();
}
f64::from_bits(crate::value::POINTER_TAG | (cleaned as u64 & crate::value::POINTER_MASK))
}

Expand Down
2 changes: 2 additions & 0 deletions crates/perry-runtime/src/array/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ mod index_get_exit_tests;
#[cfg(test)]
mod push_pop_tests;
#[cfg(test)]
mod refresh_traversal_evidence_tests;
#[cfg(test)]
mod spread_dense_tests;
#[cfg(test)]
mod strict_store_tests;
Expand Down
55 changes: 55 additions & 0 deletions crates/perry-runtime/src/array/refresh_traversal_evidence_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! A lazy JSON array materialized whole by compiled code refreshing its local
//! head is traversal evidence, exactly once per array, and a plain array is
//! never evidence. Without this the element-shape loop clone (#10171) served
//! every scan loop through its preheader and traversal feedback never learned
//! that the tape was being wasted.

fn lazy_array_box(bytes: &[u8], len: u32) -> f64 {
let tape = crate::json_tape::build_tape(bytes).unwrap();
let text = crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32);
let lazy = unsafe { crate::json_tape::alloc_lazy_array(&tape.entries, 0, len, text) };
let header = unsafe { crate::value::addr_class::try_read_gc_header(lazy as usize) }.unwrap();
assert_eq!(
header.obj_type,
crate::gc::GC_TYPE_LAZY_ARRAY,
"fixture must be a real lazy array or the assertions below are vacuous"
);
crate::value::js_nanbox_pointer(lazy as i64)
}

#[test]
fn compiled_materialization_of_a_lazy_array_counts_once_and_a_plain_array_never() {
crate::json::traversal_feedback::reset_for_tests();
let scope = crate::gc::RuntimeHandleScope::new();

let mut plain = crate::array::js_array_alloc(2);
plain = crate::array::js_array_push_f64(plain, 1.0);
let plain_box = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(plain as i64));
let _ = crate::array::header::js_array_refresh_local_head(plain_box.get_nanbox_f64());
assert_eq!(
crate::json::traversal_feedback::score_for_tests(),
0,
"a plain array is not traversal evidence"
);

let lazy = scope.root_nanbox_f64(lazy_array_box(br#"[{"id":1},{"id":2},{"id":3}]"#, 3));
let first = crate::array::header::js_array_refresh_local_head(lazy.get_nanbox_f64());
assert_ne!(
first.to_bits(),
lazy.get_nanbox_f64().to_bits(),
"the head was refreshed"
);
assert_eq!(
crate::json::traversal_feedback::score_for_tests(),
2,
"materializing a lazy array whole is one flip's worth of evidence"
);

let _ = crate::array::header::js_array_refresh_local_head(lazy.get_nanbox_f64());
assert_eq!(
crate::json::traversal_feedback::score_for_tests(),
2,
"an already materialized lazy array is not evidence again"
);
crate::json::traversal_feedback::reset_for_tests();
}
48 changes: 44 additions & 4 deletions crates/perry-runtime/src/json/traversal_feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@
//! 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.
//! Two kinds of event count as traversal evidence: element-by-element reads in
//! `lazy_get_rooted` (the flip, or an in-order read of the last element), and a
//! compiled loop or indexed read materializing the whole array through
//! `js_array_refresh_local_head` — the element-shape loop clone's preheader
//! (#10171) and the guarded indexed-read repair both do that before a single
//! element is read lazily, so without it a program whose scan loop is served by
//! the clone never produced evidence and paid for the tape AND the eager
//! materialization on every parse. 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;

Expand Down Expand Up @@ -78,6 +84,35 @@ pub(crate) unsafe fn after_cold_read(
}
}

/// A lazy array was materialized whole by compiled code refreshing its local
/// head (`js_array_refresh_local_head`) — the element-shape loop clone's
/// preheader or a guarded indexed read's repair. Every element is now eagerly
/// built, so the tape this array was parsed onto was wasted: the same evidence
/// as a flip.
pub(crate) fn note_compiled_materialization() {
SCORE.with(|s| s.set(s.get().saturating_add(2).min(SCORE_MAX)));
}

/// Is `addr` a lazy JSON array whose elements have not been built yet? Read by
/// `js_array_refresh_local_head` before it materializes, so evidence is noted
/// once per array. Its emitters are cold arms that run about once per
/// receiver, so the tracked-header probe costs nothing measurable.
///
/// # Safety
///
/// `addr` must be a plausible heap address; the tracked-header read validates
/// ownership before anything is dereferenced.
pub(crate) unsafe fn lazy_array_unmaterialized(addr: usize) -> bool {
let Some(header) = crate::value::addr_class::try_read_tracked_gc_header(addr) else {
return false;
};
if (*header.as_ptr()).obj_type != crate::gc::GC_TYPE_LAZY_ARRAY {
return false;
}
let lazy = addr as *const crate::json_tape::LazyArrayHeader;
(*lazy).magic == crate::json_tape::LAZY_ARRAY_MAGIC && (*lazy).materialized.is_null()
}

/// Should an otherwise tape-eligible parse go eagerly instead?
pub(crate) fn prefer_eager() -> bool {
if SCORE.with(Cell::get) < PREFER_EAGER_AT {
Expand All @@ -96,6 +131,11 @@ pub(crate) fn prefer_eager() -> bool {
})
}

#[cfg(test)]
pub(crate) fn score_for_tests() -> u8 {
SCORE.with(Cell::get)
}

#[cfg(test)]
pub(crate) fn reset_for_tests() {
SCORE.with(|s| s.set(0));
Expand Down
Loading