From aceaa9a99e50e19ee4b5da71da13c42e85419f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 08:22:51 +0200 Subject: [PATCH 01/22] perf(json): bound nesting inside the direct parser instead of pre-scanning The direct JSON parser re-read every byte of its input before parsing, to decide whether the recursive descent could exceed the 1000-level native-stack bound. The "already validated" shortcut lived on the string-token reuse cache, which only exists for a source under 2 MB holding one large string value, so no record document ever hit it and every direct parse paid a whole-document scan on top of the parse. DirectParser now counts open containers as it descends, on every recursive entry including the shaped-record path and the typed top-level array, and aborts with `depth_exceeded` when the bound would be crossed. Valid documents never scan. A failed direct parse goes 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 its error kinds. The only remaining pre-scan is the forced tape above the lazy size ceiling, where an over-budget document must fail before its native tape is reserved. (cherry picked from commit 44d28cf34eb8412baffdc35d44c7bde1322d5bf2) --- crates/perry-runtime/src/json/mod.rs | 5 +- crates/perry-runtime/src/json/parse_api.rs | 170 ++++++++++++++----- crates/perry-runtime/src/json/parse_reuse.rs | 28 --- crates/perry-runtime/src/json/parser.rs | 82 ++++++++- 4 files changed, 209 insertions(+), 76 deletions(-) diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index 0951812113..1098de6356 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -87,9 +87,8 @@ pub(crate) unsafe fn test_json_stringify_record_output(bits: u64) -> Option ! { 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,72 @@ 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. +#[test] +fn direct_parser_bounds_nesting_inside_the_descent() { + 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 +309,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 +339,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 +348,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")); } @@ -397,13 +463,10 @@ unsafe fn parse_slow(text_ptr: *const StringHeader, len: usize) -> JSValue { } let use_tape = tape_route_eligible(len, bytes); // 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 +527,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 +581,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 +594,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 +607,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 +629,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)] @@ -725,12 +813,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 +846,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..f22fa618f5 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 { From f46c20a8f23b72c7053adae1073122765ce5c587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 08:49:15 +0200 Subject: [PATCH 02/22] docs(changelog): fragment for #10168 (cherry picked from commit 0ff443dbdbfe09f2e7b960d0844fe0901ec5f91e) --- changelog.d/10168-json-parse-depth-in-descent.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/10168-json-parse-depth-in-descent.md 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. From a205a5e0060c7b1095d0e3663c10749eeb3d64b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 09:38:44 +0200 Subject: [PATCH 03/22] test(json): run the depth-bound cases on a large-stack thread A debug test build's parser frames are several times larger than the release runtime's, so 1001 nested objects on the harness's default 2 MB thread overflowed the stack in CI (SIGSEGV after the iterator_helpers tests). The bound under test is the release runtime's; the check itself runs on a 256 MB worker like the 300 000-level test does. (cherry picked from commit f5f651d9b4b3de0fa37fd5e296374b07e6e12f1e) --- crates/perry-runtime/src/json/parse_api.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/perry-runtime/src/json/parse_api.rs b/crates/perry-runtime/src/json/parse_api.rs index b7e96ace39..997416a85c 100644 --- a/crates/perry-runtime/src/json/parse_api.rs +++ b/crates/perry-runtime/src/json/parse_api.rs @@ -150,8 +150,23 @@ fn direct_parse_depth_exceeded(input: &[u8], shape_keys: Option<&[u8]>) -> bool /// 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)); From 91ddce49aa3d2e43184afc2495e9fd27968738a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 08:31:25 +0200 Subject: [PATCH 04/22] feat(runtime): prove the element shape of class-0 record arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-array homogeneous element-shape invariant (#7480) keyed every proof on a class id, and `element_identity_of_bits` refused `class_id == 0` outright. Every `JSON.parse`'d record is class 0 with an ordinary birth ShapeId (`object/json_construction.rs`), so no parsed record array could ever carry a proof — the one array shape the invariant's consumer most wants to reason about was structurally excluded. Admit class 0, keyed on the EXACT ordinary ShapeId the descriptor probe already validated. That identity is strictly narrower than the class-level one it replaces, and it has to be: "same class" is vacuous when the class is 0, so `element_matches_record` now declines the two class-level fallbacks for a class-0 record rather than relying on both of them failing closed by coincidence. Two new generated-code-facing entry points: * `js_array_ensure_element_shape_ordinary` — establish-or-confirm, returning the proven ordinary ShapeId for a class-0 proof and 0 for a class-keyed one. The two proofs are deliberately 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. * `js_shape_ordinary_inline_slot_for_key` — the inline slot a PLAIN ordinary shape assigns to a key, or -1. Four conjuncts make "slot k == key position k" true (ordinary kind, generation 0, no holes, every key inline); dropping any one would produce a wrong offset rather than a missed optimization, so each is asserted separately. The key arrives as the whole NaN-box rather than a masked pointer, because a short property name reaches the string pool as an SSO immediate whose masked low bits are packed characters and not an address. `js_array_ensure_element_shape` still returns the class id, so every existing consumer reads a class-0 proof exactly as "no proof". (cherry picked from commit 84ee63ff10d51f86c322bda46f4583539e3fbf4f) --- .../perry-runtime/src/array/element_shape.rs | 80 +++++- .../src/array/element_shape_tests.rs | 262 ++++++++++++++++++ crates/perry-runtime/src/object/shapes.rs | 81 ++++++ .../perry-runtime/src/object/shapes_tests.rs | 50 ++++ 4 files changed, 462 insertions(+), 11 deletions(-) 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/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" + ); + } +} From ef978c188f4c5d4f8c3a07c157ffec03e87fad8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 08:48:17 +0200 Subject: [PATCH 05/22] feat(codegen): key the element-shape loop clone on a runtime shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 a `JSON.parse`'d record array now run in the #7480 call-free element-shape fast clone. They previously ran the full element-read + field-read diamond pair. The clone keyed every proof on a compile-time class, which is exactly what a parsed record array does not have. Four things kept it dark, each independently sufficient: * the matcher needed a resolvable element class, and `rows: any` has none; * the preheader's `GC_TYPE_ARRAY` brand ran BEFORE the growth-forwarding repair, so the `GC_TYPE_LAZY_ARRAY` header `JSON.parse` returns for a top-level array in [1 KB, 16 MB] was rejected before the repair that would have materialized it; * the residual per-element check required `GC_OBJ_TYPED_LAYOUT_INTACT`, which a parsed record never has — the clone would have been emitted, entered, and then side-exited on the first element of every loop, with every IR-census assertion still passing; * the index had to be exactly the counter, so neither `rows[7]` nor `const d = i % n; rows[d]` was admitted. 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 `js_shape_ordinary_inline_slot_for_key` for each tracked property's inline slot in that shape. Both are loop-invariant, so the read stays one bare offset load. The repair now precedes the brand (the refresh is safe on an unbranded value by construction: it resolves through `clean_arr_ptr`, which returns null for every tracked non-array). The residual mask drops the typed-layout conjunct and the loaded word is tag-tested as a Number instead, side-exiting to the slow clone when it is not one — the same "this slot holds a raw double" claim, established from the value rather than from a layout declaration. `rows[k]` and `const d = i % m; rows[d]` each carry their 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`, because the generic `%` lowering is a runtime call and a call inside this clone deletes it rather than slowing it (#7690). The matcher admits exactly one index form per loop and the fact lookup re-checks the spelling, so a fact can never serve a read whose obligation was not discharged. A constant-index loop deliberately does not require the counter's canonical i32 slot: `stmt/let_stmt.rs` mints one only for an index-used or i32-bounded local, and the `repeat` shape's counter is neither. The matcher, the fact lookup and the field lowering all ask the same `needs_counter_i32_slot()` question. The revocation argument is unchanged — it never mentioned classes, and call-free remains the whole admission test. `fast_clone_slice` was widened to own the new `element_shape.number` blocks, or every negative assertion against it would have been partly vacuous. (cherry picked from commit a02e11ff7bfe96c73385ad140942ef99a912d7ae) --- changelog.d/10123-json-record-loop-clone.md | 63 ++ .../src/expr/element_shape_guard.rs | 397 ++++++-- crates/perry-codegen/src/expr/mod.rs | 128 ++- .../src/expr/property_get/helpers.rs | 33 +- crates/perry-codegen/src/expr/shadow_slot.rs | 13 +- .../perry-codegen/src/runtime_decls/arrays.rs | 4 + .../src/runtime_decls/strings.rs | 4 + .../src/stmt/element_shape_loop.rs | 870 +++++++++++++----- .../src/stmt/element_shape_loop_tests.rs | 448 ++++++++- crates/perry-codegen/src/stmt/let_stmt.rs | 40 + .../src/type_analysis/numeric.rs | 17 +- test-files/test_gap_json_record_loop_clone.ts | 272 ++++++ 12 files changed, 1938 insertions(+), 351 deletions(-) create mode 100644 changelog.d/10123-json-record-loop-clone.md create mode 100644 test-files/test_gap_json_record_loop_clone.ts diff --git a/changelog.d/10123-json-record-loop-clone.md b/changelog.d/10123-json-record-loop-clone.md new file mode 100644 index 0000000000..0b04945888 --- /dev/null +++ b/changelog.d/10123-json-record-loop-clone.md @@ -0,0 +1,63 @@ +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, best of five, ns per iteration: + +| cell | before | after | node 26.5.1 | bun 1.3.14 | +|---|---|---|---|---| +| 16k repeat | 5.73 | BEFORE_AFTER | 3.06 | 3.63 | +| 16k sequential | 12.27 | BEFORE_AFTER | 4.39 | 4.29 | +| 1m repeat | 5.74 | BEFORE_AFTER | 3.49 | 4.66 | +| 1m sequential | 15.80 | BEFORE_AFTER | 9.51 | 6.76 | +| 20m repeat | 5.24 | BEFORE_AFTER | 3.59 | 5.17 | +| 20m sequential | 17.65 | BEFORE_AFTER | 7.22 | 8.37 | diff --git a/crates/perry-codegen/src/expr/element_shape_guard.rs b/crates/perry-codegen/src/expr/element_shape_guard.rs index 5db287d909..5d22777dbf 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) 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); } - // (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. + // (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))?; @@ -306,6 +466,17 @@ pub(crate) fn emit_element_shape_loop_preheader_check( } }; + // #10123: the index obligation. `FromTripCount` adds nothing — the trip + // count already covers every read — and the other two are compared + // UNSIGNED for the same reason `Bound` is: `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. let base_addr = blk.array_elements_addr(&handle1); let elements_base = blk.inttoptr(I64, &base_addr); @@ -314,40 +485,63 @@ 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(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 +558,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 +582,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 +636,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/mod.rs b/crates/perry-codegen/src/expr/mod.rs index bcc1eb6f32..aa6f68fb4e 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -2096,6 +2096,70 @@ 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. +#[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 +2175,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 +2218,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 +2269,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 +2317,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..3f9f9a8d9b 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,46 @@ fn anon_shape_field_type_is_compatible( } } -/// Match `for (let j = k0; j < B; j++) acc = `. +/// 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 +642,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 +733,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 +821,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 +899,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 +909,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; } - let class = ctx.classes.get(&class_name)?; + + 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)?; if !class.computed_members.is_empty() { return None; } @@ -752,59 +984,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 +1094,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 +1120,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 +1152,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 +1266,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 +1285,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 +1332,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 +1361,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..1ca8524a06 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,436 @@ fn element_binding_form_through_a_parameter_gets_the_clone() { let ir = emit(&m); assert_clone_fires_call_free(&ir, "parameter binding form"); } + +// --------------------------------------------------------------------------- +// #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}" + ); + 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}" + ); +} + +#[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}" + ); +} + +#[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..b9fe5ba0e1 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -48,6 +48,46 @@ pub(crate) fn lower_let( { return Ok(()); } + // #10123: the derived-index twin. Inside a shape-keyed element-shape fast + // clone, `const d = j % m` is not lowered generically — `%` on two + // possibly-untyped operands is a runtime call, and a call inside the clone + // DELETES it (#7690) rather than slowing it. 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 `srem`. + // + // Sound for the same four reasons the element binding is: nothing reads + // `d` 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 real slot before + // any use. The fact is popped before the slow clone lowers, so this arm + // cannot fire there. + if 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(), + )) + }) + { + 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); + return Ok(()); + } // `let C = SomeClass` aliases the local `C` to the class // `SomeClass` for `new C()` site rerouting. The HIR lowers // class identifiers referenced as values to `Expr::ClassRef`, 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/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)); From 9f7e5a8fa099d5bb24096a75f7270b0c08158e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 09:10:37 +0200 Subject: [PATCH 06/22] fix(codegen): drop the trip-count obligation for a non-counter index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shape-keyed clone (#10123) was entered by a `cond_br` the IR census could see and never once at run time on its own benchmark. The preheader still emitted the counter arm's `length >= bound`, so `for (i = 0; i < 1000000; i++) sum += rows[7].id` over a 7,600-element array asked it to prove `length >= 1000000` — false — and a million iterations ran the slow clone. Measured: 5.73 ns/iter before, 5.43 after, with the whole optimization inert. The counter is not an index in the `Constant` and `DerivedMod` forms, so the verified prefix has nothing to say about the trip count; each form already carries its own obligation (`length > k`, `m <= length`). The `arr.length` trip-count arm keeps its i32-fits check for every index form, because the emitted trip test is signed whatever the index is. Both shape-keyed index tests now assert the ABSENCE of the counter arm's comparison, which is the assertion that would have caught this: the derived case pins the count at exactly one `icmp uge`, and the counter case asserts the obligation is still there, so neither can drift into the other. `element_shape_loop_tests.rs` crosses the 2000-line cap with those cases, so the shape-keyed half moves to `element_shape_shape_keyed_tests.rs` — a CHILD module, because every helper it uses is private to the parent and duplicating an IR census is how two of them drift apart. `let_stmt.rs` crossed the cap too; its two virtual-binding arms (#7771's element binding, #10123's derived index) are now one call into `element_shape_loop::lower_virtual_clone_binding`, which is where their soundness arguments belong anyway. (cherry picked from commit f603e0dba2313ee013b4202891b32b168ee29a59) --- .../src/expr/element_shape_guard.rs | 61 ++- .../src/stmt/element_shape_loop.rs | 66 +++ .../src/stmt/element_shape_loop_tests.rs | 440 +--------------- .../stmt/element_shape_shape_keyed_tests.rs | 491 ++++++++++++++++++ crates/perry-codegen/src/stmt/let_stmt.rs | 64 +-- 5 files changed, 610 insertions(+), 512 deletions(-) create mode 100644 crates/perry-codegen/src/stmt/element_shape_shape_keyed_tests.rs diff --git a/crates/perry-codegen/src/expr/element_shape_guard.rs b/crates/perry-codegen/src/expr/element_shape_guard.rs index 5d22777dbf..d2ec6ce903 100644 --- a/crates/perry-codegen/src/expr/element_shape_guard.rs +++ b/crates/perry-codegen/src/expr/element_shape_guard.rs @@ -443,34 +443,51 @@ 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. `FromTripCount` adds nothing — the trip - // count already covers every read — and the other two are compared - // UNSIGNED for the same reason `Bound` is: `length` is a `u32` read into - // an i32, so a hypothetical 2^31-element array must not read as negative - // and pass. + // #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())), @@ -503,7 +520,9 @@ pub(crate) fn emit_element_shape_loop_preheader_check( 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); } diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs index 3f9f9a8d9b..1ce4f0cd8d 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -590,6 +590,72 @@ fn anon_shape_field_type_is_compatible( } } +/// 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[]` 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 1ca8524a06..c78cfc72e5 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs @@ -1617,435 +1617,11 @@ fn element_binding_form_through_a_parameter_gets_the_clone() { assert_clone_fires_call_free(&ir, "parameter binding form"); } -// --------------------------------------------------------------------------- -// #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}" - ); - 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}" - ); -} - -#[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}" - ); -} - -#[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" - ); -} +/// #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 b9fe5ba0e1..28ec377674 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -27,65 +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)) - { - return Ok(()); - } - // #10123: the derived-index twin. Inside a shape-keyed element-shape fast - // clone, `const d = j % m` is not lowered generically — `%` on two - // possibly-untyped operands is a runtime call, and a call inside the clone - // DELETES it (#7690) rather than slowing it. 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 `srem`. - // - // Sound for the same four reasons the element binding is: nothing reads - // `d` 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 real slot before - // any use. The fact is popped before the slow clone lowers, so this arm - // cannot fire there. - if 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(), - )) - }) - { - 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); + // #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 From d674528e031ec83f87de1c5a5344b7b331f42849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 09:13:19 +0200 Subject: [PATCH 07/22] docs(changelog): record the measured json-record loop clone numbers (cherry picked from commit c865ad05dff8953fc58787c979a2306ce8c6cb92) --- changelog.d/10123-json-record-loop-clone.md | 27 +++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/changelog.d/10123-json-record-loop-clone.md b/changelog.d/10123-json-record-loop-clone.md index 0b04945888..399aafa09d 100644 --- a/changelog.d/10123-json-record-loop-clone.md +++ b/changelog.d/10123-json-record-loop-clone.md @@ -51,13 +51,26 @@ 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, best of five, ns per iteration: +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.73 | BEFORE_AFTER | 3.06 | 3.63 | -| 16k sequential | 12.27 | BEFORE_AFTER | 4.39 | 4.29 | -| 1m repeat | 5.74 | BEFORE_AFTER | 3.49 | 4.66 | -| 1m sequential | 15.80 | BEFORE_AFTER | 9.51 | 6.76 | -| 20m repeat | 5.24 | BEFORE_AFTER | 3.59 | 5.17 | -| 20m sequential | 17.65 | BEFORE_AFTER | 7.22 | 8.37 | +| 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. From b4bd34acd6c8aafdaed25254d618432420323897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 09:14:52 +0200 Subject: [PATCH 08/22] docs(codegen): note why the trip-count obligation is index-form-specific (cherry picked from commit 5d685a2e210012d6c174c19ead9a6ff0ff610602) --- crates/perry-codegen/src/expr/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index aa6f68fb4e..a0df121336 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -2104,6 +2104,12 @@ pub(crate) struct ClassFieldLoopFact { /// 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. From 31f016aeca93bc3706929991f3caaab7aeeb37fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 09:36:54 +0200 Subject: [PATCH 09/22] docs(changelog): key the fragment on its PR number (cherry picked from commit 540fc36f55209d451f8f4c9f19216daf3376a272) --- ...-json-record-loop-clone.md => 10171-json-record-loop-clone.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10123-json-record-loop-clone.md => 10171-json-record-loop-clone.md} (100%) diff --git a/changelog.d/10123-json-record-loop-clone.md b/changelog.d/10171-json-record-loop-clone.md similarity index 100% rename from changelog.d/10123-json-record-loop-clone.md rename to changelog.d/10171-json-record-loop-clone.md From 24a3f0e1e49b367a927ce7341770d7f92c169923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 10:25:04 +0200 Subject: [PATCH 10/22] lint(codegen): classify the shape-keyed clone's untyped-receiver hint read The local binding type-proof audit requires every local_type_hint read to carry a classification. array_is_untyped reads the declared type only to decline the shape-keyed element-shape loop clone for a receiver with any layout claim; admission still goes through the preheader's runtime proof and the per-element residual check. (cherry picked from commit 2b77e7d4fee0373e018fa9714f443111298d73cd) --- scripts/local_binding_type_allowlist.json | 8 ++++++++ 1 file changed, 8 insertions(+) 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." } ] } From 6fa997ede08ba3914d010da62c3cb96cda586941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 00:22:52 +0200 Subject: [PATCH 11/22] perf(gc): batch dead old-object page unregistration per sweep step A full collection unregistered each dead old object from the page index on its own: two `Vec` allocations, a deferral-buffer flush, a promoted-run materialization, both table borrows, and a linear `position` over the page's object list before `swap_remove`. Freeing every object on a page that way is quadratic in objects per page, and a full frees whole pages of small objects. On records_array_8m:scan (~720k dead records per full) `invalidate_dead_old_arena_header` was 15.8% of ALL samples -- the single largest cost of a full collection. The registration side already batches for exactly this reason (`flush_deferred_old_page_registrations_batch`, #7624). This is its mirror: `ArenaSweepObjectsState` still invalidates each dead header's fields immediately, so no walker can read it as live, but queues the page-index removal and flushes once per sweep step (and every 4096 headers, so an unbudgeted sweep never stages an unbounded buffer). The batch does one flush, one run materialization per touched page, one `retain` per page against that page's sorted dead headers, and one page-meta update per page -- allocated bytes and object counts only fall here, so applying a page's decrements together and resetting/refreshing once is the same state the per-object path reaches. Nothing inside a sweep step reads page-index membership, and the queue is empty at every step boundary. Four fulls on records_array_8m:scan: 241 ms -> 187 ms (-22%). The batched flush is 6.3% of samples where the per-object remover was 15.8%. `every_page_object_reader_expands_promoted_runs` covers the new remover (it touches OLD_GEN_PAGE_OBJECTS and expands first). A new test drives both removers over the same population in one arena -- page-spanning objects, a fully emptied page, partial pages -- and requires identical page membership and metadata; it was sabotage-tested (dropping the object-count decrement fails it with "page metadata diverged"). (cherry picked from commit 7f55a7d7423a47481749dd595f1f0dab2690e060) --- crates/perry-runtime/src/arena/mod.rs | 6 +- .../perry-runtime/src/arena/page_meta/mod.rs | 109 ++++++++++++++++ crates/perry-runtime/src/arena/tests.rs | 2 +- .../src/arena/tests_batch_unregister.rs | 123 ++++++++++++++++++ crates/perry-runtime/src/gc/oldgen.rs | 16 ++- .../src/gc/oldgen/sweep_batch.rs | 46 +++++++ 6 files changed, 295 insertions(+), 7 deletions(-) create mode 100644 crates/perry-runtime/src/arena/tests_batch_unregister.rs create mode 100644 crates/perry-runtime/src/gc/oldgen/sweep_batch.rs 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/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(); + } +} From ecda010f0d6a63e874abab608cfc50e401624981 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 12/22] 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. (cherry picked from commit 549a7c4414a2459c88198aa3d442802657439f4b) --- 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 1098de6356..43f643948f 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 997416a85c..e294c9f190 100644 --- a/crates/perry-runtime/src/json/parse_api.rs +++ b/crates/perry-runtime/src/json/parse_api.rs @@ -476,7 +476,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, 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 @@ -747,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( 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 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", From 0b9908a5f61f8a6d3f2bddb8d73f21bfade5b342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 10:24:40 +0200 Subject: [PATCH 13/22] perf(codegen): decide the typed-array brand test on the tag alone The brand test is the first thing every indexed read on an unknown receiver executes, and it computed the entire guard set before finding out the receiver was not a typed array: the element kind load, its range test, both index range checks and three ANDs. A JSON.parse array -- and any ordinary Array behind an erased receiver -- is not a typed array, so it paid all of that on every element read, forever, to reach a branch it was always going to take. Decide on the tag alone and leave. The kind and index guards only mean anything once the tag says typed array, so they move behind it into tav.get.kind_guard; the typed-array fast path reaches the same guard set by the same AND-reduction and is unchanged. Retired instructions per read, measured against the pre-#10114 compiler on the JSON access fixtures: -2.4% to -3.5% on all twelve rows. That also erases #10114's one disclosed cost -- the 20 MiB rows, which are ordinary Arrays above the lazy admission bound, go from +0.7..+1.8% against that reference to -0.4..-2.6%, i.e. below it. (cherry picked from commit 2adaac2e2a01525a285ce38f6db99833b46737c8) --- .../expr/index_get/inline_dyn_typed_array.rs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) 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); From 2bde940c5ec929409b45ac36296f2491b47693ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 10:57:34 +0200 Subject: [PATCH 14/22] gc(json): make lazy JSON arrays movable and nursery-resident (#10098) A lazy cluster born old is never swept by a minor, so a DEAD one holds its whole element graph live through the remembered set until a full collection. On records_array_16k:scan that full collection never arrives -- the arena rebaselines its own trigger 134M->268M->536M->1073M while old_in_use climbs past 48 MB -- and every minor reports survival_permille=996, copied_objects=0, freed_bytes=0. That is 205 MiB peak RSS against Node's 62 MiB. GC_TYPE_LAZY_ARRAY was pinned for two reasons, both now removed the way GC_TYPE_REGEXP removed its own: - json_tape_store keys a tape by its owner's address, so a moved header orphaned it. Added json_tape_store::owner_moved plus GcMoveHookKind::LazyArrayTape, mirroring GcMoveHookKind::RegExpSideTables. - the copying minor's flip runs no per-object finalize hook, so a header dying young leaked its tape. Added finalize_dead_copied_minor_from_space_lazy_tapes, the twin of the sweep-entry collect_owners pass, wired into the flip beside map/set/errors/regex and reported in the diag line. With both present the type is movable and the cluster's generation is decided by cache size -- decided ONCE, so #7546's rule that header, cache and bitmap share a generation still holds. Large clusters stay old exactly as before. Four tests that asserted immovability were retargeted: the large-cluster one still pins old-gen residency on size, and the handle tests now assert the stronger property -- that the rooted handle resolves to wherever the collector left the header, and that alloc_lazy_array returns the refreshed address rather than the stale one. One test was itself holding a raw header across a forced evacuation and faulting on the 0xDEADBEEFBAADF0DE poison fill; it is rooted now. Dead-owner tape release for a NURSERY header is not handled here -- a dead nursery header can carry a stale GC_FLAG_MARKED from an earlier cycle, so the full trace's dead-owner predicate, which assumed old-gen residency, never reports it dead. That is mark-bit semantics rather than movability, so it is the next commit rather than this one. (cherry picked from commit e19963524d7044f0d36b74d6d6ef454a7111b565) --- crates/perry-runtime/src/gc/copying_phase.rs | 10 +++- crates/perry-runtime/src/gc/tests/alloc.rs | 14 +++-- .../src/gc/tests/lazy_tape_side_alloc.rs | 40 +++++++------ .../tests/runtime_roots/callback_scanners.rs | 55 +++++++++++------ .../gc/tests/runtime_roots/json_tape_owned.rs | 7 +++ crates/perry-runtime/src/gc/types.rs | 40 ++++++++++--- crates/perry-runtime/src/json_tape.rs | 59 +++++++++++++------ crates/perry-runtime/src/json_tape_store.rs | 56 ++++++++++++++++++ 8 files changed, 212 insertions(+), 69 deletions(-) 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/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/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..f328bd3109 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"); @@ -492,13 +505,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_tape_owned.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/json_tape_owned.rs index aa0811f4ba..a9238f2242 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 @@ -345,6 +345,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,6 +370,7 @@ fn json_lazy_assignment_roots_heap_key_and_value_during_materialization() { string_bits(stored as usize), "stored string must move" ); + let lazy = lazy_handle.get_raw_mut_ptr::(); let array = unsafe { (*lazy).materialized }; assert!(!array.is_null()); let actual = crate::array::js_array_get(array, 1); diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 7eadbecc8f..f6ee0db73a 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -237,6 +237,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 +472,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 +497,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 +833,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 +859,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_tape.rs b/crates/perry-runtime/src/json_tape.rs index b446d2ba67..d363f90a28 100644 --- a/crates/perry-runtime/src/json_tape.rs +++ b/crates/perry-runtime/src/json_tape.rs @@ -1229,12 +1229,35 @@ 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::(); + cache_bytes + crate::gc::GC_HEADER_SIZE >= crate::gc::LARGE_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 +1305,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 +1365,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 +1387,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; 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 { From ff73bbf7301b7122e975adf9e18fd8eaacd4cd91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 11:32:55 +0200 Subject: [PATCH 15/22] gc(json): reclaim a dead nursery lazy owner on the pass that owns it Completes the movability change. Two tape-release tests asserted that a full mark-sweep reclaims a dead owner, which was true only while every owner was old-gen: the old-gen sweep finalizes an unmarked payload directly. A nursery-resident owner is reclaimed by a MINOR instead, through the new finalize_dead_copied_minor_from_space_lazy_tapes, exactly as Map/Set/Error/ RegExp reclaim theirs. A full sweep leaves nursery mark bits to the minor, so asserting on it was asserting against the wrong pass -- measured directly: six consecutive full sweeps released nothing, and the first minor released exactly the dead owner's bytes. The same probe confirmed GcMoveHookKind::LazyArrayTape works: across that minor the surviving owner relocated and the registry followed it to the new address. Both tests also held raw headers across collections, which only became visible once headers could move. They re-read through the roots they already had -- the shadow slots, which the collector rewrites -- rather than keeping the address owned_small returned. A RuntimeHandleScope is NOT a root in this file unless register_runtime_handle_root_scanner_for_tests ran, which these two do not call, so the shadow slot is the correct root to read back from. 3611 runtime tests pass, 0 fail. (cherry picked from commit ac9aa771107518d383a30f4cc1f25dd84c00df0b) --- .../gc/tests/runtime_roots/json_tape_owned.rs | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) 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 a9238f2242..0fd0b646d4 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); } From 2785eb0b43436b6eeea09ccd7dcd9dbb47fb273c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 11:51:45 +0200 Subject: [PATCH 16/22] gc(json): decide the lazy cluster against the pointer-bearing threshold arena_alloc_gc keeps two large-object lines apart, and its comment says why: 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 exactly that container, and a lazy array is the case the distinction was drawn for. Use the 128 KB line rather than the flat 16 KB one -- V8's kMaxRegularHeapObjectSize, inside the copier's own ceilings, so a cluster admitted by it is always movable. Peak RSS on records_array_16k:scan, against main: 205 MiB -> 51 MiB, where Node is 62 MiB and Bun 77 MiB. test_json_tape_lazy_get_records_its_cache_store_as_an_external_edge needed a bigger fixture to keep its premise: it wants a born-old cluster, which used to be free because every lazy header was born old unconditionally. At 4096 elements its cache is 32 KB and would now be nursery-resident, so the test would still pass its later assertions while exercising none of the containment branch it exists for. 20 000 elements is ~156 KB, over the line. 3611 runtime tests pass, 0 fail. (cherry picked from commit 1da6aec0c3d4bc1749b3960849f9598cd96d8df6) --- .../gc/tests/runtime_roots/callback_scanners.rs | 14 ++++++++++---- crates/perry-runtime/src/json_tape.rs | 11 ++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) 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 f328bd3109..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 @@ -388,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 { diff --git a/crates/perry-runtime/src/json_tape.rs b/crates/perry-runtime/src/json_tape.rs index d363f90a28..e380a91eff 100644 --- a/crates/perry-runtime/src/json_tape.rs +++ b/crates/perry-runtime/src/json_tape.rs @@ -1249,7 +1249,16 @@ impl LazyArrayHeader { /// 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::(); - cache_bytes + crate::gc::GC_HEADER_SIZE >= crate::gc::LARGE_OBJECT_THRESHOLD_BYTES + // 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 { From f2e336849260890d11bc0e5a67b9663d7dc96c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 16:07:52 +0200 Subject: [PATCH 17/22] docs(changelog): record lazy JSON array movability and the brand short-circuit (cherry picked from commit 628f7ce87b3a69041ea6aa58cb7a5aa32c09c41a) --- ...10098-json-lazy-array-movable-and-brand.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 changelog.d/10098-json-lazy-array-movable-and-brand.md 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. From 023f5434635389d2b0340232a26d620d7a0fdb86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 22:48:57 +0200 Subject: [PATCH 18/22] test(gc): read the lazy header's backing through its handle scope `json_owned_tape_*`'s post-collection check dereferenced a raw `get_raw_mut_ptr` across the collection it had just forced, which is one new raw-handle debt site in a module with no ceiling (#7341) and fails the per-module ratchet. Read `materialized` inside `with_mut_ptr` instead: the header may have moved, and the scoped read is the protocol that says so. This fix was made and verified on this branch before it was first pushed, then lost to an uncommitted-tree reset while checking an unrelated gate, so the pushed head still carried the raw site. Ratchet: 944 (baseline 944), exit 0. (cherry picked from commit 718239be8dac9053cf5f45a2792a5eebc14b9063) --- .../src/gc/tests/runtime_roots/json_tape_owned.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 0fd0b646d4..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 @@ -398,8 +398,12 @@ fn json_lazy_assignment_roots_heap_key_and_value_during_materialization() { string_bits(stored as usize), "stored string must move" ); - let lazy = lazy_handle.get_raw_mut_ptr::(); - 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()); From 23ae8bb1a59e294486dab50ddeb4f5e464bbe57e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 20:29:36 +0200 Subject: [PATCH 19/22] perf(gc): keep wide JSON document storage in the nursery (#10123) Repeated `JSON.parse` of a 50,000-field document held 220 MiB peak RSS against a live set of ~0. Node holds 136 MiB on the same workload, Bun 71. `arena/allocators.rs` already names 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 the 128 KB pointer-bearing threshold, is born tenured, and then holds its whole field or key set live long after the document is dead. Measured to the byte with PERRY_GC_CENSUS at 64 parses -- 16,300 fields: 0 retained, 29 MiB; 16,500: 30 retained, 98 MiB; 50,000: 15 retained, 34.3 MB live, 177 MiB. The step lands exactly on the constant, and the census names the retainer: 15 shape-keys arrays holding 750,000 live strings. The same binary under PERRY_GEN_GC=0 reports 336 bytes live, which is the truth about the workload. Admit that storage into the nursery past the threshold, for as long as the copier can still move it (512 KB -- half a nursery block, inside copying::MAX_YOUNG_MOVE_BYTES). The scope is read 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, 0.20s -> 0.15s. Below both engines on RSS and faster than before on CPU. SCOPED AND TYPE-MASKED ON PURPOSE. Raising the constant globally reaches the same 40 MiB but also moves ordinary ARRAY element storage into the nursery, which other rows neither need nor can afford: records_array_8m:scan 643 -> 710 MiB, 0.91s -> 1.20s. Only a document's own object storage and its keys array are admitted; array element storage keeps the flat threshold. Measured in a SINGLE binary (one build, three arms by env) the scoped change is byte-identical to baseline on records_array_8m:scan, records_array_20m:parse, records_array_1m:scan, records_array_16k:scan and heterogeneous_1m:parse. Three tests hardcoded a field count and then asserted pointer_in_old_gen, so they silently depended on the threshold being 128 KB and failed on their PREMISE rather than their subject. They now derive their width from the governing ceiling and keep covering the old-gen path at any value. perry-runtime: 3627 passed, 0 failed (three consecutive runs). (cherry picked from commit bb698ff9abd6fdad6d5628e4ebfbc61f1cf05924) --- ...10123-json-wide-object-birth-generation.md | 48 +++++++++ crates/perry-runtime/src/arena/allocators.rs | 6 +- .../src/gc/tests/helper_stores.rs | 8 +- .../tests/runtime_roots/json_construction.rs | 9 +- .../tests/runtime_roots/json_key_lifetime.rs | 28 ++++- crates/perry-runtime/src/gc/types.rs | 101 ++++++++++++++++++ crates/perry-runtime/src/json/mod.rs | 4 + .../src/object/json_construction.rs | 8 ++ 8 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 changelog.d/10123-json-wide-object-birth-generation.md 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..457d8ad87b --- /dev/null +++ b/changelog.d/10123-json-wide-object-birth-generation.md @@ -0,0 +1,48 @@ +### 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. 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/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/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/types.rs b/crates/perry-runtime/src/gc/types.rs index f6ee0db73a..23bc76214b 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -119,6 +119,87 @@ 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 an ordinary object's property storage (#10123). +/// +/// Half a nursery block: it clears a 65,536-field object, and the worst-case +/// block fragmentation it can cause is 1/2 of one block against the flat +/// threshold's 1/8. Past it the storage is born old as before, which is also +/// where the copier would stop being able to move it. +pub const LARGE_OBJECT_STORAGE_YOUNG_BIRTH_CEILING_BYTES: usize = 512 * 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))) + } +} + +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 +218,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, diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index 43f643948f..a050ed3dfd 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -539,6 +539,10 @@ unsafe fn allocate_parse_shape_keys_array(keys: &[*const StringHeader]) -> *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/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::(); From 02a44705955ff6edc250ec6ec48071627d583ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 12 Sep 2026 22:49:26 +0200 Subject: [PATCH 20/22] perf(json): allocate large record arrays once, at their estimated size (#10123) The direct parser pre-sizes `[{...}]` arrays from `remaining_bytes / 96`, but clamped that estimate at 16,384 slots: 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 first allocation and then doubled twice more in old-gen (131 -> 262 -> 524 KB for a 59,000-row document). 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 zero fulls. Use the estimate as-is. One allocation, admitted into the nursery through `JsonWideBirthScope::arrays()` when it fits the JSON young-birth ceiling, and a single old allocation past it rather than a chain of four. The ceiling rises 512 KB -> 768 KB (three quarters of a nursery block, still inside `arena::BLOCK_SIZE` and `copying::MAX_YOUNG_MOVE_BYTES`) because a 7.1 MB document's estimate is 593 KB. No fixture has object storage between the two values, so the wide-object path from the previous commit is unchanged. An earlier attempt admitted every JSON array young, including the doubling chain's intermediates. It regressed eight cells (records_array_20m:* CPU +45%) because each abandoned young intermediate still cost a copy. Sizing once is what removes that cost; this version was measured against the old clamp in a single binary across all 50 matrix cells: records_object_8m:parse 187 -> 118 MiB (1.68x -> 1.05x best) records_array_20m:parse/scan/sparse 256 -> 240 MiB records_object_20m:parse 256 -> 240 MiB records_object_8m:parse CPU 167 -> 206 ms, still 0.88x the better engine. No other cell moved outside noise. A unit test pins both outcomes: an under-ceiling estimate is one young allocation that does not regrow for the rows it was sized for, and a past-ceiling estimate keeps its old-gen birth. (cherry picked from commit 0c2876b1931b682dd2a0ab923a86aff7f60e9c49) --- ...10123-json-wide-object-birth-generation.md | 24 ++++++ crates/perry-runtime/src/gc/types.rs | 22 ++++-- .../src/json/construction_array.rs | 77 +++++++++++++++++++ crates/perry-runtime/src/json/parser.rs | 4 +- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/changelog.d/10123-json-wide-object-birth-generation.md b/changelog.d/10123-json-wide-object-birth-generation.md index 457d8ad87b..a8a5adf933 100644 --- a/changelog.d/10123-json-wide-object-birth-generation.md +++ b/changelog.d/10123-json-wide-object-birth-generation.md @@ -46,3 +46,27 @@ 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/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 23bc76214b..14cbdd4cff 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -119,13 +119,17 @@ 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 an ordinary object's property storage (#10123). +/// Birth-generation ceiling for JSON-constructed storage (#10123). /// -/// Half a nursery block: it clears a 65,536-field object, and the worst-case -/// block fragmentation it can cause is 1/2 of one block against the flat -/// threshold's 1/8. Past it the storage is born old as before, which is also -/// where the copier would stop being able to move it. -pub const LARGE_OBJECT_STORAGE_YOUNG_BIRTH_CEILING_BYTES: usize = 512 * 1024; +/// 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 { @@ -176,6 +180,12 @@ impl JsonWideBirthScope { 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 { 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/parser.rs b/crates/perry-runtime/src/json/parser.rs index f22fa618f5..209f76917b 100644 --- a/crates/perry-runtime/src/json/parser.rs +++ b/crates/perry-runtime/src/json/parser.rs @@ -1252,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) } From 1784db5b8ce623a64c7f17596eaeadfb778dce5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 12:22:06 +0200 Subject: [PATCH 21/22] docs(changelog): fragments for #10147 and #10150 --- changelog.d/10147-batch-old-page-unregistration.md | 3 +++ changelog.d/10150-json-traversal-feedback.md | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 changelog.d/10147-batch-old-page-unregistration.md create mode 100644 changelog.d/10150-json-traversal-feedback.md 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. From 17182509a971de38b78b6dd1fa58b064548c1cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 13:13:50 +0200 Subject: [PATCH 22/22] chore: bump workspace version to 0.5.1549 --- CLAUDE.md | 2 +- Cargo.lock | 162 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 83 insertions(+), 83 deletions(-) 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"