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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions changelog.d/9807-layout-prune-single-pass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
**The per-object layout death-prune walks its tables once instead of three
times, and no longer allocates a `Vec` of every live key** — 50.6 MB of a
compiled claude-code turn's 304 MB in the layout tables (#9792).

`prune_dead_per_object_layout_owners` visited every surviving key three times
per collection: `retain` to drop the dead owners, then
`layout_addr_filter_rebuild` — which first collected all of them into a
`Vec<usize>` — and then `recount_young_layout_records` to re-derive the
nursery-key count. The last two want exactly the survivor set `retain` is
already walking, so both fold into its closure. The `Vec` is gone from the
rebuild's other caller too.

The measurement that prompted it also found the accelerator these tables sit
behind unable to do its job. `layout_addr_filter_may_hold` is a 4,096-bit
one-hash sketch documented for "one or two entries, ~0.05 % false positives";
a new `PERRY_LAYOUT_DIAG` instrument reports **162,258 live keys and 4,096 of
4,096 bits set** on one 400-character claude-code reply. Every probe answers
"may hold", so the early returns in `transfer_per_object_descriptor` and
`transfer_per_object_slot_mask` never fire, and each rebuild was an O(live
keys) walk restoring the all-ones state it started from. Past four times the
bit count — 16,384 keys, where the false-positive rate is already 98.2 % — the
rebuild now sets all ones directly, the same conservative answer reached in
O(1); below that the filter keeps exactly the selectivity it has today. The
instrument says so out loud rather than leaving it to be inferred. Widening the sketch is not available
from the runtime: its geometry and hash are mirrored in `perry-codegen`'s
`emit_gated_forget_object_layout`, and discriminating at 162k keys would take
~190 KB of inline thread-local storage per thread.

`transfer_per_object_descriptor` also gained the emptiness test its shared
flag cannot express: the flag and the filter are common to both per-object
tables, so a full slot-mask table drags every relocation into the typed-layout
map as well — which on cc is permanently empty (typed=0, masks=162,258). One
`len` load replaces two hashes per evacuated object.
11 changes: 11 additions & 0 deletions changelog.d/9816-for-of-array-iter-result.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 79 additions & 16 deletions crates/perry-runtime/src/array/iter_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
Expand Down Expand Up @@ -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):
Expand All @@ -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 /
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-runtime/src/array/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,14 @@ 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 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,
js_array_keys_iter_obj, js_array_values_iter_obj, ARRAY_ITERATOR_CLASS_ID,
};
pub(crate) use self::iter_object::{
dispatch_array_iterator_method_builtin, dispatch_array_iterator_method_emit,
};
pub(crate) use self::iterator::iter_bt_dump;
pub(crate) use self::iterator::{array_from_spread_value, is_builtin_iterator_class_id};
pub use self::iterator::{
Expand Down
Loading
Loading