From b67b4d74129365e48e887939b8868830afd6edc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 16:49:53 +0200 Subject: [PATCH] perf(runtime): recycle the for-of result object for array iterators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fused `for…of` advance (`js_for_of_next`, the runtime entry the compiler's desugar emits) already recycled ONE `{ value, done }` object per ITERATOR for builtin Map/Set iterators: the result local is a compiler temporary the loop body cannot name, and the driver reads `done`/`value` out of it before the next advance, so mutating one cached object is unobservable. Array iterators fell through to the generic arm and minted a fresh 40-byte object per element. The allocation-site census of the compiled claude-code TUI attributes 100 % of its iterator-result bytes — 14.4 MB of a 3300-character reply, 15.5 % of all attributed arena bytes and the third-largest category — to exactly that: `array::iter_object` under `js_native_call_method`, one object per element of every generic `for…of`. A minified bundle reaches it whenever the iterated value is not a statically proven array, which is nearly always. So the array iterator takes the same fused arm, and the recycling routine moves to `iter_result::emit_iter_result_cached` so the two families share ONE implementation instead of a second copy — the drift #7564 removed from the five result constructors this module replaced. Three things the change had to keep intact: * The override probe still runs first, so a patched own `next` wins on the fused path exactly as it does on the manual one. * `node:sqlite`'s `{ done, value }` key order is observable, so the cache is built with that iterator's own order. * Field 5 now holds the cache, so `reserved_slot_floor_for_class_id` rises from 5 to 6 for the array iterator — without that, the first user property added to an iterator (`it.foo = 1`) would land on the cache field. Manual `.next()`, spread, `Array.from`, `yield*` and `for await` are unchanged and keep allocating fresh results. Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m --- changelog.d/9816-for-of-array-iter-result.md | 11 ++ crates/perry-runtime/src/array/iter_object.rs | 95 +++++++-- crates/perry-runtime/src/array/mod.rs | 4 +- .../src/collection_iter_object.rs | 183 ++++++++++++------ crates/perry-runtime/src/iter_result.rs | 65 +++++++ .../src/object/reserved_floor.rs | 8 +- 6 files changed, 289 insertions(+), 77 deletions(-) create mode 100644 changelog.d/9816-for-of-array-iter-result.md diff --git a/changelog.d/9816-for-of-array-iter-result.md b/changelog.d/9816-for-of-array-iter-result.md new file mode 100644 index 0000000000..bbf827b5f0 --- /dev/null +++ b/changelog.d/9816-for-of-array-iter-result.md @@ -0,0 +1,11 @@ +### Runtime + +- perf(runtime): a `for…of` over an array no longer allocates a `{ value, done }` + object per element. The fused `for…of` advance (`js_for_of_next`) already + recycled ONE result object per iterator for builtin Map/Set iterators; array + iterators fell through to the generic arm and minted a fresh 40-byte object + for every element. They now take the same fused arm, and the recycling routine + is one shared implementation rather than a second copy. Manual `.next()`, + spread, `Array.from`, `yield*` and `for await` are unchanged and keep + returning fresh results, so a caller that retains one still sees spec + behaviour; the recycled object is only ever the compiler's own loop temporary. diff --git a/crates/perry-runtime/src/array/iter_object.rs b/crates/perry-runtime/src/array/iter_object.rs index 94b3214552..3276f932b8 100644 --- a/crates/perry-runtime/src/array/iter_object.rs +++ b/crates/perry-runtime/src/array/iter_object.rs @@ -33,6 +33,12 @@ use crate::value::{js_nanbox_get_pointer, js_nanbox_pointer, JSValue, TAG_UNDEFI /// runtime-defined classes. pub const ARRAY_ITERATOR_CLASS_ID: u32 = 0xFFFF_0006; +/// Field holding the recycled `{value, done}` the fused `for…of` driver +/// mutates in place — one result object per ITERATOR instead of one per +/// element. Same index and same contract as the Map/Set iterator's, and the +/// same routine emits both (`iter_result::emit_iter_result_cached`). +const ITER_RESULT_CACHE_FIELD: u32 = 5; + /// Iterator kind tags — matches the i32 stored in field 2. const KIND_VALUES: i32 = 0; const KIND_KEYS: i32 = 1; @@ -66,7 +72,12 @@ unsafe fn alloc_iterator_backing(backing: f64, kind: i32) -> f64 { // The iterator allocation and the lazy prototype bootstrap can both // collect. Keep the incoming backing and the new iterator relocatable. let backing_h = scope.root_nanbox_f64(backing); - let obj_h = scope.root_raw_mut_ptr(js_object_alloc(ARRAY_ITERATOR_CLASS_ID, 3)); + // Six fields, not three: 3 and 4 are the `node:sqlite` epoch pair and 5 is + // the recycled `{value, done}` the fused `for…of` driver mutates in place + // (see `ITER_RESULT_CACHE_FIELD`). Reserving them at construction keeps the + // cache out of the per-iterator shape transition that growing into field 5 + // would otherwise cost, and matches the Map/Set iterator's layout. + let obj_h = scope.root_raw_mut_ptr(js_object_alloc(ARRAY_ITERATOR_CLASS_ID, 6)); // Field 0: backing array (NaN-boxed pointer so the GC scanner keeps it). obj_h.with_mut_ptr(|obj| { js_object_set_field( @@ -79,6 +90,14 @@ unsafe fn alloc_iterator_backing(backing: f64, kind: i32) -> f64 { obj_h.with_mut_ptr(|obj| js_object_set_field(obj, 1, JSValue::number(0.0))); // Field 2: iterator kind. obj_h.with_mut_ptr(|obj| js_object_set_field(obj, 2, JSValue::number(kind as f64))); + // Fields 3/4: the `node:sqlite` epoch pair, unused by every other kind. + obj_h.with_mut_ptr(|obj| js_object_set_field(obj, 3, JSValue::undefined())); + obj_h.with_mut_ptr(|obj| js_object_set_field(obj, 4, JSValue::undefined())); + // Field 5: the recycled fused-driver result. Manual `.next()` never reads + // or writes it, so a caller that retains a result still sees fresh objects. + obj_h.with_mut_ptr(|obj| { + js_object_set_field(obj, ITER_RESULT_CACHE_FIELD, JSValue::undefined()) + }); // Link `[[Prototype]]` to the shared `%ArrayIteratorPrototype%` singleton so // `Object.getPrototypeOf(it)` and the inherited `.next` read resolve. obj_h @@ -114,7 +133,7 @@ pub fn array_values_iter_null_done( if arr_ptr.is_null() { return f64::from_bits(TAG_UNDEFINED); } - let obj = js_object_alloc(ARRAY_ITERATOR_CLASS_ID, 5); + let obj = js_object_alloc(ARRAY_ITERATOR_CLASS_ID, 6); js_object_set_field( obj, 0, @@ -128,6 +147,7 @@ pub fn array_values_iter_null_done( JSValue::pointer(iteration_epoch as *const _ as *const u8), ); js_object_set_field(obj, 4, JSValue::number(epoch as f64)); + js_object_set_field(obj, ITER_RESULT_CACHE_FIELD, JSValue::undefined()); crate::object::attach_iterator_prototype(obj, ARRAY_ITERATOR_CLASS_ID); js_nanbox_pointer(obj as i64) } @@ -620,7 +640,22 @@ pub unsafe fn dispatch_array_iterator_method( iter_obj: *mut ObjectHeader, method_name: &str, ) -> f64 { - dispatch_array_iterator_method_inner(iter_obj, method_name, true) + dispatch_array_iterator_method_inner(iter_obj, method_name, true, false) +} + +/// The FUSED `for…of` advance (`js_for_of_next`): same algorithm, but the +/// `{value, done}` is the iterator's own recycled one rather than a fresh +/// allocation per element. Only the compiler's `for…of` desugar reaches this, +/// and its result local is a temporary the loop body cannot name — see +/// [`crate::iter_result::emit_iter_result_cached`]. The override probe still +/// runs first, so a patched own `next` wins exactly as on the manual path. +pub(crate) unsafe fn dispatch_array_iterator_method_emit( + iter_obj: *mut ObjectHeader, + method_name: &str, + emit_cached: bool, + honor_override: bool, +) -> f64 { + dispatch_array_iterator_method_inner(iter_obj, method_name, honor_override, emit_cached) } /// Builtin advance only — the canonical prototype thunk's entry (#9019): @@ -632,13 +667,14 @@ pub(crate) unsafe fn dispatch_array_iterator_method_builtin( iter_obj: *mut ObjectHeader, method_name: &str, ) -> f64 { - dispatch_array_iterator_method_inner(iter_obj, method_name, false) + dispatch_array_iterator_method_inner(iter_obj, method_name, false, false) } unsafe fn dispatch_array_iterator_method_inner( iter_obj: *mut ObjectHeader, method_name: &str, honor_override: bool, + emit_cached: bool, ) -> f64 { // #7475: the raw `iter_obj` parameter is not a GC root, and this function // allocates in several places — `js_object_set_field` (shape transition / @@ -662,6 +698,15 @@ unsafe fn dispatch_array_iterator_method_inner( JSValue::undefined() } }; + // `node:sqlite`'s iterator yields `{ done, value }`; every other kind + // yields `{ value, done }`. The key order is observable through + // `Object.keys`/`JSON.stringify`, so it picks the shared keys array (and + // therefore the shape) the result is built with. + let result_order = if kind == KIND_VALUES_NULL_DONE { + crate::iter_result::IterResultOrder::DoneValue + } else { + crate::iter_result::IterResultOrder::ValueDone + }; match method_name { "next" => { if honor_override { @@ -692,10 +737,15 @@ unsafe fn dispatch_array_iterator_method_inner( // Array iterators clear their backing array at exhaustion. SQLite's // statement iterator restarts a completed execution on the next call. if JSValue::from_bits(backing_f64.to_bits()).is_undefined() { - if kind == KIND_VALUES_NULL_DONE { - return make_sqlite_iter_result(done_value(), true); - } - return make_iter_result(done_value(), true); + return crate::iter_result::emit_iter_result_cached( + &scope, + &iter_h, + ITER_RESULT_CACHE_FIELD, + emit_cached, + result_order, + done_value(), + true, + ); } let backing_ptr = js_nanbox_get_pointer(backing_f64); // Field 1: current index. @@ -714,11 +764,20 @@ unsafe fn dispatch_array_iterator_method_inner( if idx >= len { if kind == KIND_VALUES_NULL_DONE { + // SQLite's statement iterator restarts on the next call. js_object_set_field(iter_obj(), 1, JSValue::number(0.0)); - return make_sqlite_iter_result(done_value(), true); + } else { + js_object_set_field(iter_obj(), 0, JSValue::undefined()); } - js_object_set_field(iter_obj(), 0, JSValue::undefined()); - return make_iter_result(done_value(), true); + return crate::iter_result::emit_iter_result_cached( + &scope, + &iter_h, + ITER_RESULT_CACHE_FIELD, + emit_cached, + result_order, + done_value(), + true, + ); } // Advance the stored cursor before computing the value so a @@ -759,11 +818,15 @@ unsafe fn dispatch_array_iterator_method_inner( _ => JSValue::undefined(), }; let value_h = scope.root_nanbox_u64(value.bits()); - if kind == KIND_VALUES_NULL_DONE { - make_sqlite_iter_result(JSValue::from_bits(value_h.get_nanbox_u64()), false) - } else { - make_iter_result(JSValue::from_bits(value_h.get_nanbox_u64()), false) - } + crate::iter_result::emit_iter_result_cached( + &scope, + &iter_h, + ITER_RESULT_CACHE_FIELD, + emit_cached, + result_order, + JSValue::from_bits(value_h.get_nanbox_u64()), + false, + ) } // Iterators are themselves iterable — `[Symbol.iterator]()` on one // returns the same iterator (matches Node, and lets `js_get_iterator` diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 99359c9d92..5cbedce095 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -173,7 +173,9 @@ pub use self::iter_methods::{ js_array_map_discard, js_array_reduce, js_array_some, js_array_some_captureless, js_array_to_locale_string, js_validate_array_callback, js_validate_array_map_callback, }; -pub(crate) use self::iter_object::dispatch_array_iterator_method_builtin; +pub(crate) use self::iter_object::{ + dispatch_array_iterator_method_builtin, dispatch_array_iterator_method_emit, +}; pub use self::iter_object::{ arguments_values_iter, array_entries_iter, array_keys_iter, array_values_iter, array_values_iter_null_done, dispatch_array_iterator_method, js_array_entries_iter_obj, diff --git a/crates/perry-runtime/src/collection_iter_object.rs b/crates/perry-runtime/src/collection_iter_object.rs index 4a91eef8ec..62864230e7 100644 --- a/crates/perry-runtime/src/collection_iter_object.rs +++ b/crates/perry-runtime/src/collection_iter_object.rs @@ -196,13 +196,11 @@ static KEEP_SET_KEYS_ITER: extern "C" fn(*const SetHeader) -> i64 = js_set_keys_ #[used] static KEEP_SET_ENTRIES_ITER: extern "C" fn(*const SetHeader) -> i64 = js_set_entries_iter_obj; -/// Build the `{ value, done }` iterator-result object. Mirrors -/// `array/iter_object.rs::make_iter_result`. -// #7564: this was a local five-allocation copy with every intermediate in a -// bare Rust local — see `crate::iter_result` for what that cost and why it was -// a stale-from-space hazard. `use` rather than a wrapper so the call sites -// below read unchanged. -use crate::iter_result::make_iter_result; +// #7564: the `{ value, done }` constructor was a local five-allocation copy +// with every intermediate in a bare Rust local — see `crate::iter_result` for +// what that cost and why it was a stale-from-space hazard. Since the fused +// driver's recycling moved there too, this module reaches results only through +// `iter_result::emit_iter_result_cached` and imports no constructor of its own. /// `[key, value]` pair array for Map entries / Set entries (`[v, v]`). unsafe fn make_pair_array(a: f64, b: f64) -> f64 { @@ -376,19 +374,14 @@ unsafe fn dispatch_set_iterator_method_emit( } } -/// Emit a `{value, done}` iterator result. -/// -/// `emit_cached == false` (every manual `.next()` and both public -/// dispatchers) allocates a fresh object per call, exactly as before — -/// results a caller retains behave per spec. -/// -/// `emit_cached == true` is reserved for [`js_for_of_next`], whose only -/// caller is the compiler's `for…of` desugar. There the result local is a -/// compiler temporary the loop body cannot name, read for `done`/`value` -/// before the next advance — so mutating one cached object per ITERATOR is -/// unobservable, and it deletes the per-element allocation that dominated -/// generic iteration. The cache lives in the iterator object's field 5, so -/// the GC traces and rewrites it like any other field. +/// Field of a Map/Set iterator object that holds the recycled `{value, done}` +/// the fused `for…of` driver mutates in place. `alloc_iterator` reserves it. +const ITER_RESULT_CACHE_FIELD: u32 = 5; + +/// Emit a `{value, done}` iterator result — see +/// [`crate::iter_result::emit_iter_result_cached`] for the caching contract. +/// The array iterator uses the same routine through the same helper, so the +/// two cannot drift. unsafe fn emit_iter_result( scope: &crate::gc::RuntimeHandleScope, iter_h: &crate::gc::RuntimeHandle, @@ -396,42 +389,34 @@ unsafe fn emit_iter_result( value: JSValue, done: bool, ) -> f64 { - let iter_obj = || js_nanbox_get_pointer(iter_h.get_nanbox_f64()) as *mut ObjectHeader; - if !emit_cached { - return make_iter_result(value, done); - } - let cached = js_object_get_field(iter_obj(), 5); - if JSValue::from_bits(cached.bits()).is_pointer() { - let res = js_nanbox_get_pointer(f64::from_bits(cached.bits())) as *mut ObjectHeader; - // Barriered field stores: the iterator (and its cached result) may be - // tenured while `value` is young. - js_object_set_field(res, 0, value); - js_object_set_field(res, 1, JSValue::bool(done)); - return js_nanbox_pointer(res as i64); - } - // First fused advance on this iterator: build the result once and cache - // it. `make_iter_result` allocates, so root `value` across it. - let value_h = scope.root_nanbox_u64(value.bits()); - let res = make_iter_result(JSValue::from_bits(value_h.get_nanbox_u64()), done); - let res_h = scope.root_nanbox_f64(res); - js_object_set_field( - iter_obj(), - 5, - JSValue::from_bits(res_h.get_nanbox_f64().to_bits()), - ); - res_h.get_nanbox_f64() + crate::iter_result::emit_iter_result_cached( + scope, + iter_h, + ITER_RESULT_CACHE_FIELD, + emit_cached, + crate::iter_result::IterResultOrder::ValueDone, + value, + done, + ) } /// One fused `IteratorNext` for the `for…of` desugar: advance + result in a /// single runtime call. /// -/// A builtin Map/Set iterator advances in place and reuses its cached result -/// object (see [`emit_iter_result`]); the override probe inside the -/// dispatcher still runs first, so a patched `next` wins exactly as it does -/// on the manual path. Every other receiver — array iterators, generators, -/// user iterators — takes the arm at the bottom, which is byte-for-byte the -/// two-call desugar this entry replaces: the dynamic `.next()` dispatch -/// followed by spec IteratorNext result validation. +/// A builtin Map/Set/Array iterator advances in place and reuses its cached +/// result object (see [`crate::iter_result::emit_iter_result_cached`]); the +/// override probe inside each dispatcher still runs first, so a patched `next` +/// wins exactly as it does on the manual path. Every other receiver — +/// generators, user iterators, string and typed-array iterators — takes the arm +/// at the bottom, which is byte-for-byte the two-call desugar this entry +/// replaces: the dynamic `.next()` dispatch followed by spec IteratorNext +/// result validation. +/// +/// The array arm is the one that matters on a real program: the allocation-site +/// census of the compiled claude-code TUI attributed **100 %** of its iterator +/// -result bytes to `array::iter_object` — one 40-byte object per element of +/// every generic `for…of`, which is what a minified bundle emits whenever the +/// iterated value is not a statically proven array. #[no_mangle] pub unsafe extern "C-unwind" fn js_for_of_next(iter: f64) -> f64 { let jv = JSValue::from_bits(iter.to_bits()); @@ -457,6 +442,13 @@ pub unsafe extern "C-unwind" fn js_for_of_next(iter: f64) -> f64 { dispatch_set_iterator_method_emit(obj, "next", true, true), ); } + if class_id == crate::array::ARRAY_ITERATOR_CLASS_ID { + return crate::symbol::js_iterator_result_validate( + crate::array::dispatch_array_iterator_method_emit( + obj, "next", true, true, + ), + ); + } } } } @@ -547,18 +539,97 @@ mod fused_for_of_tests { } } - /// A non-collection receiver takes the generic arm: dynamic `.next()` - /// dispatch plus validation — here, an array VALUES iterator object. + /// The ARRAY arm — the one the allocation census says carries 100 % of a + /// real program's iterator-result bytes. Same contract as the Set arm: + /// correct walk, terminates, and ONE recycled result installed in the + /// iterator's cache field, while the manual dispatcher keeps allocating + /// fresh ones. #[test] - fn fused_next_routes_other_iterators_through_the_generic_arm() { + fn fused_next_walks_an_array_and_recycles_its_result() { unsafe { let arr = crate::array::js_array_alloc(2); crate::array::js_array_push_f64(arr, 7.0); crate::array::js_array_push_f64(arr, 8.0); let iter = crate::array::array_values_iter(js_nanbox_pointer(arr as i64)); - assert_eq!(value_of(js_for_of_next(iter)), 7.0); - assert_eq!(value_of(js_for_of_next(iter)), 8.0); + + // Read the cache field immediately after each advance rather than + // comparing two nan-boxed pointers taken across a possible + // collection: a copying minor between the two calls would move the + // result and make a raw bit comparison fail for the wrong reason. + let cache_now = || { + let iter_obj = js_nanbox_get_pointer(iter) as *mut ObjectHeader; + js_object_get_field(iter_obj, 5).bits() + }; + let r1 = js_for_of_next(iter); + assert_eq!(value_of(r1), 7.0); + assert!( + JSValue::from_bits(cache_now()).is_pointer(), + "the first fused advance must install the recycled result" + ); + assert_eq!( + r1.to_bits(), + cache_now(), + "the first result IS the cached object" + ); + let r2 = js_for_of_next(iter); + assert_eq!(value_of(r2), 8.0); + assert_eq!( + r2.to_bits(), + cache_now(), + "the fused driver must hand back the cached object, or nothing is saved" + ); assert!(done_of(js_for_of_next(iter))); + assert!(done_of(js_for_of_next(iter)), "stays exhausted"); + + // The manual path still returns fresh, independent results — a + // caller that retains one must not see it mutate underneath. + let manual_iter = crate::array::array_values_iter(js_nanbox_pointer(arr as i64)); + let m1 = crate::array::dispatch_array_iterator_method( + js_nanbox_get_pointer(manual_iter) as *mut ObjectHeader, + "next", + ); + let m2 = crate::array::dispatch_array_iterator_method( + js_nanbox_get_pointer(manual_iter) as *mut ObjectHeader, + "next", + ); + assert_eq!(value_of(m1), 7.0); + assert_eq!(value_of(m2), 8.0); + assert_ne!( + m1.to_bits(), + m2.to_bits(), + "manual .next() must keep allocating fresh results" + ); + assert_eq!( + value_of(m1), + 7.0, + "the first manual result must still read 7 after the second call" + ); + } + } + + /// A receiver outside the three fused families still takes the generic + /// arm: dynamic `.next()` dispatch plus spec IteratorNext validation. + /// Without this the fused branch could be "always taken" and the test + /// above would still pass. + #[test] + fn fused_next_routes_other_iterators_through_the_generic_arm() { + unsafe { + let s = crate::string::js_string_from_bytes(b"ab".as_ptr(), 2); + let iter = crate::string::string_values_iter(s); + let iter_obj = js_nanbox_get_pointer(iter) as *mut ObjectHeader; + let r1 = js_for_of_next(iter); + let r2 = js_for_of_next(iter); + assert_ne!( + r1.to_bits(), + r2.to_bits(), + "the generic arm must not recycle — it has no cache field" + ); + assert!(done_of(js_for_of_next(iter))); + assert_eq!( + (*iter_obj).class_id, + crate::string::STRING_ITERATOR_CLASS_ID, + "receiver really is outside the fused families" + ); } } } diff --git a/crates/perry-runtime/src/iter_result.rs b/crates/perry-runtime/src/iter_result.rs index 832e567d81..f0125407b3 100644 --- a/crates/perry-runtime/src/iter_result.rs +++ b/crates/perry-runtime/src/iter_result.rs @@ -189,6 +189,71 @@ pub(crate) unsafe fn make_sqlite_iter_result(value: JSValue, done: bool) -> f64 build_iter_result_ordered(JSValue::bool(done), value, IterResultOrder::DoneValue) } +/// Emit a `{ value, done }` for the FUSED `for…of` driver, recycling ONE +/// result object per ITERATOR instead of allocating one per element. +/// +/// `emit_cached == false` — every manual `.next()` and both public +/// dispatchers — allocates a fresh object, exactly as before, so a result the +/// caller retains behaves per spec. +/// +/// `emit_cached == true` is reserved for +/// [`crate::collection_iter_object::js_for_of_next`], whose only caller is the +/// compiler's `for…of` desugar. There the result local is a compiler temporary +/// the loop body cannot name, and the driver reads `done` and `value` out of it +/// before the next advance — so mutating one cached object per ITERATOR is +/// unobservable. The cache lives in the iterator object's `cache_field`, so the +/// GC traces and rewrites it like any other field. +/// +/// The Map/Set iterators have worked this way since the fused driver landed; +/// this is the same routine, lifted here so the array iterator uses the ONE +/// implementation rather than a second copy — which is the drift `#7564` +/// removed from the five result constructors this module replaced. +pub(crate) unsafe fn emit_iter_result_cached( + scope: &crate::gc::RuntimeHandleScope, + iter_h: &crate::gc::RuntimeHandle<'_>, + cache_field: u32, + emit_cached: bool, + order: IterResultOrder, + value: JSValue, + done: bool, +) -> f64 { + let (first, second) = match order { + IterResultOrder::ValueDone => (value, JSValue::bool(done)), + IterResultOrder::DoneValue => (JSValue::bool(done), value), + }; + if !emit_cached { + return build_iter_result_ordered(first, second, order); + } + let iter_obj = || crate::js_nanbox_get_pointer(iter_h.get_nanbox_f64()) as *mut ObjectHeader; + let cached = crate::object::js_object_get_field(iter_obj(), cache_field); + if JSValue::from_bits(cached.bits()).is_pointer() { + let res = crate::js_nanbox_get_pointer(f64::from_bits(cached.bits())) as *mut ObjectHeader; + // Barriered field stores: the iterator (and its cached result) may be + // tenured while `value` is young. + crate::object::js_object_set_field(res, 0, first); + crate::object::js_object_set_field(res, 1, second); + return crate::js_nanbox_pointer(res as i64); + } + // First fused advance on this iterator: build the result once and cache it. + // `build_iter_result_ordered` allocates, so root both field values across + // it, and re-read the iterator and the result through storage the collector + // rewrites afterwards. + let first_h = scope.root_nanbox_u64(first.bits()); + let second_h = scope.root_nanbox_u64(second.bits()); + let res = build_iter_result_ordered( + JSValue::from_bits(first_h.get_nanbox_u64()), + JSValue::from_bits(second_h.get_nanbox_u64()), + order, + ); + let res_h = scope.root_nanbox_f64(res); + crate::object::js_object_set_field( + iter_obj(), + cache_field, + JSValue::from_bits(res_h.get_nanbox_f64().to_bits()), + ); + res_h.get_nanbox_f64() +} + /// GC root scanner for the shared keys arrays. /// /// MARKING: nothing else in the heap references these arrays — the result diff --git a/crates/perry-runtime/src/object/reserved_floor.rs b/crates/perry-runtime/src/object/reserved_floor.rs index ffd744d278..ea8cfebdba 100644 --- a/crates/perry-runtime/src/object/reserved_floor.rs +++ b/crates/perry-runtime/src/object/reserved_floor.rs @@ -29,8 +29,8 @@ use crate::array::ArrayHeader; /// `0` for every class id without a reserved raw-field layout. Keep each /// entry in lock-step with the family's allocator/dispatcher: /// -/// * array: `array/iter_object.rs` (fields 0..4: backing, cursor, kind, -/// snapshot len, epoch) +/// * array: `array/iter_object.rs` (fields 0..5: backing, cursor, kind, +/// `node:sqlite` epoch pointer, epoch value, cached fused result) /// * map/set: `collection_iter_object.rs` (fields 0..5: backing, cursor, /// kind, size-at-last-next, last key, cached fused result) /// * string: `string/iter_object.rs` (fields 0..1) @@ -39,7 +39,7 @@ use crate::array::ArrayHeader; /// * iterator helpers: `iterator_helpers.rs` (fields 0..3) pub(crate) fn reserved_slot_floor_for_class_id(class_id: u32) -> u32 { match class_id { - crate::array::ARRAY_ITERATOR_CLASS_ID => 5, + crate::array::ARRAY_ITERATOR_CLASS_ID => 6, crate::collection_iter_object::MAP_ITERATOR_CLASS_ID | crate::collection_iter_object::SET_ITERATOR_CLASS_ID => 6, crate::string::STRING_ITERATOR_CLASS_ID => 2, @@ -242,7 +242,7 @@ mod tests { fn floors_cover_every_reserved_family_and_nothing_else() { assert_eq!( reserved_slot_floor_for_class_id(crate::array::ARRAY_ITERATOR_CLASS_ID), - 5 + 6 ); assert_eq!( reserved_slot_floor_for_class_id(crate::collection_iter_object::MAP_ITERATOR_CLASS_ID),