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
3 changes: 3 additions & 0 deletions changelog.d/8976-elements-loop-guard-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Counted loops over a `class X extends Array` instance read element 0 as the object's `meta` word (a bare heap pointer seen as `1.8e-311`): the loop guard admitted such a receiver as kind 1 — "the receiver IS the ArrayHeader" — while handing back the elements store's address, and the generated loop derives its base from the receiver. Elements-backed receivers are now admitted as their own kind, with the payload address published in the descriptor and refreshed by every revalidation, so those loops stay on the fast path and read the right memory.
31 changes: 25 additions & 6 deletions crates/perry-codegen/src/stmt/stable_packed_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -631,15 +631,35 @@ fn descriptor_word(ctx: &mut FnCtx<'_>, descriptor: &str, index: u64) -> String
/// used once in ordinary call-free loops and at every iteration entry for a
/// closure capture, where a nested guard/callback may have moved the receiver
/// since the preceding iteration.
/// The kind test and payload base shared by the two plain-payload kinds.
///
/// Kind 1 means the receiver IS the `ArrayHeader`, so its payload starts at
/// `live_raw + 8`. Kind 3 is an elements-backed Array subclass
/// (`perry-runtime::array::subclass_elements`): the receiver is the object and
/// the payload lives in a separate Array whose address the guard publishes in
/// descriptor word 3 and every revalidation refreshes. Selecting the base from
/// that word keeps both the capture-safe path (which reloads the receiver) and
/// the ordinary path (whose `live_raw` is the receiver) reading the payload.
fn plain_payload_base(ctx: &mut FnCtx<'_>, descriptor: &str, live_raw: &str) -> (String, String) {
let kind = descriptor_word(ctx, descriptor, 0);
let is_array_receiver = ctx.block().icmp_eq(I64, &kind, "1");
let is_elements_store = ctx.block().icmp_eq(I64, &kind, "3");
let is_plain = ctx.block().or(I1, &is_array_receiver, &is_elements_store);
let store = descriptor_word(ctx, descriptor, 3);
let payload = ctx
.block()
.select(I1, &is_elements_store, I64, &store, live_raw);
(is_plain, payload)
}

fn build_numeric_access(
ctx: &mut FnCtx<'_>,
descriptor: &str,
live_raw: &str,
contiguous_u32_prefix: bool,
) -> StablePackedNumericAccess {
let kind = descriptor_word(ctx, descriptor, 0);
let is_plain = ctx.block().icmp_eq(I64, &kind, "1");
let plain_base = ctx.block().add(I64, live_raw, "8");
let (is_plain, payload) = plain_payload_base(ctx, descriptor, live_raw);
let plain_base = ctx.block().add(I64, &payload, "8");

let element_base = descriptor_word(ctx, descriptor, 4);
let packed_bounds = descriptor_word(ctx, descriptor, 5);
Expand Down Expand Up @@ -990,7 +1010,7 @@ pub(crate) fn try_lower_index_get(
fact.u32_component_bound.as_deref(),
));
}
let kind = descriptor_word(ctx, &fact.descriptor, 0);
let (is_plain, payload) = plain_payload_base(ctx, &fact.descriptor, &raw);

let plain_idx = ctx.new_block("stable_packed.load.plain");
let object_idx = ctx.new_block("stable_packed.load.object");
Expand All @@ -1004,13 +1024,12 @@ pub(crate) fn try_lower_index_get(
let object_spill_label = ctx.block_label(object_spill_idx);
let object_spill_ptr_label = ctx.block_label(object_spill_ptr_idx);
let merge_label = ctx.block_label(merge_idx);
let is_plain = ctx.block().icmp_eq(I64, &kind, "1");
ctx.block().cond_br(&is_plain, &plain_label, &object_label);

ctx.current_block = plain_idx;
let byte_offset = ctx.block().shl(I64, &idx_i64, "3");
let with_header = ctx.block().add(I64, &byte_offset, "8");
let element_addr = ctx.block().add(I64, &raw, &with_header);
let element_addr = ctx.block().add(I64, &payload, &with_header);
let element_ptr = ctx.block().inttoptr(I64, &element_addr);
let plain_raw = ctx.block().load(DOUBLE, &element_ptr);
let plain_bits = ctx.block().bitcast_double_to_i64(&plain_raw);
Expand Down
56 changes: 33 additions & 23 deletions crates/perry-runtime/src/array/subclass_elements_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,13 +342,16 @@ fn freeze_deopts_to_the_shape_carried_form() {
);
}

/// The counted-loop guard admits an elements-backed instance as a PLAIN-ARRAY
/// loop over its inner array: kind 1, the inner array's live address, and a
/// revalidation that re-resolves the (possibly re-allocated) store from the
/// receiver — this is what keeps versioned loops off the string-keyed
/// property path.
/// The counted-loop guard admits an elements-backed instance as KIND 3: the
/// live address stays the RECEIVER (the capture-safe caller reloads it) and
/// the payload address is published in descriptor word 3. The generated loop
/// derives its base from the receiver on the ordinary path, so publishing the
/// store as a plain-Array receiver (kind 1) made element 0 read the object's
/// `meta` word instead — `issue_8773_closure_capture_packed_loops`'s dense
/// case. Revalidation refreshes word 3 after a move and side-exits when an
/// append re-allocates the store.
#[test]
fn the_counted_loop_guard_admits_an_elements_backed_receiver_as_its_inner_array() {
fn the_counted_loop_guard_admits_an_elements_backed_receiver_as_kind_three() {
let _representation = ArraySubclassRepresentationGuard::elements();
let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers();
crate::gc::register_runtime_handle_root_scanner_for_tests();
Expand All @@ -371,30 +374,35 @@ fn the_counted_loop_guard_admits_an_elements_backed_receiver_as_its_inner_array(
facts.as_mut_ptr(),
);
assert_ne!(live, 0, "an elements-backed receiver must be admitted");
assert_eq!(facts[0], 1, "admitted as a plain-array loop");
assert_eq!(facts[0], 3, "its own kind, never a plain Array");
assert_eq!(
live as usize,
unsafe { elements_of(live_obj(recv())) } as usize,
"the live address is the inner array"
(recv().to_bits() & 0x0000_FFFF_FFFF_FFFF) as usize,
"the live address is the RECEIVER, not the payload"
);
assert_eq!(facts[6], 8, "the live-length bound is the inner length");
let revalidated = super::subclass::loop_guard::js_packed_arraylike_loop_revalidate_live(
recv(),
-1.0,
0,
facts.as_ptr(),
let store = unsafe { elements_of(live_obj(recv())) };
assert_eq!(facts[3] as usize, store as usize, "word 3 is the payload");
assert_eq!(facts[6], 8, "the live-length bound is the store's length");
assert_eq!(
super::subclass::loop_guard::js_packed_arraylike_loop_revalidate_live(
recv(),
-1.0,
0,
facts.as_ptr(),
),
live,
"revalidation keeps the same receiver"
);
assert_eq!(revalidated, live, "revalidation resolves the same store");
assert_eq!(facts[3] as usize, store as usize);

// An append inside the loop body re-allocates the store. The recorded
// facts describe the OLD array, so revalidation takes the side exit (0),
// exactly as it does for a grown plain Array — and a fresh guard call
// then admits the current store.
// An append inside the loop body re-allocates the store: the recorded
// capacity no longer matches, so revalidation side-exits exactly as it
// does for a grown plain Array, and a fresh guard call re-admits.
for i in 8..64u32 {
assert!(super::subclass::array_subclass_fast_push_one(recv(), f64::from(i)).is_some());
}
let grown = unsafe { elements_of(live_obj(recv())) };
assert_ne!(grown as usize, live as usize, "the appends re-allocated");
assert_ne!(grown as usize, store as usize, "the appends re-allocated");
assert_eq!(
super::subclass::loop_guard::js_packed_arraylike_loop_revalidate_live(
recv(),
Expand All @@ -411,9 +419,11 @@ fn the_counted_loop_guard_admits_an_elements_backed_receiver_as_its_inner_array(
0,
facts.as_mut_ptr(),
);
assert_ne!(live2, 0);
assert_eq!(facts[0], 3);
assert_eq!(
live2 as usize, grown as usize,
"re-admission sees the new store"
facts[3] as usize, grown as usize,
"word 3 tracks the new store"
);
assert_eq!(facts[6], 64);
}
163 changes: 151 additions & 12 deletions crates/perry-runtime/src/array/subclass_loop_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ use super::*;
/// managed pointers, so the generated loop can reload a relocated receiver
/// from its root before each residual check.
///
/// Layout: `(kind, gc_header, receiver_header, length_slot, element_base,
/// dense_prefix|inline_bound<<32, bound)`. Kind 1 is an ArrayHeader and kind 2
/// is an ObjectHeader Array subclass. A zero return leaves every semantic case
/// to the unchanged generic loop.
/// Layout: `(kind, gc_header, receiver_header, length_slot|elements, element_base,
/// dense_prefix|inline_bound<<32, bound)`. Kind 1 is an ArrayHeader, kind 2 is
/// a shape-carried ObjectHeader Array subclass, and kind 3 is an
/// elements-backed one (`super::subclass_elements`) whose payload lives in a
/// separate Array: word 3 carries that store's user address, because the
/// generated loop derives its base from the RECEIVER on the non-capture path
/// and the receiver is the object, not the payload. A zero return leaves every
/// semantic case to the unchanged generic loop.
/// Resolve an elements-backed Array-subclass receiver to its inner array
/// (`None` for every other receiver, including the shape-carried subclass
/// form, which keeps the kind-2 admission below).
Expand All @@ -41,6 +45,57 @@ fn elements_loop_source(
.then_some((elements, elements_header))
}

/// Admit an elements-backed Array-subclass receiver as a kind-3 loop over its
/// store. Every proof is the store's (an ordinary Array): no descriptors, the
/// prototype latch clear, `bound <= length <= capacity`, and — for a numeric
/// mode — the whole-array raw-f64 bit. Mode 2 (the fused ECS entity-id clone)
/// wants the per-prefix payload a shape-carried subclass publishes, so it is
/// declined here exactly as it is for a plain Array.
fn elements_backed_loop_guard(
receiver: *const u8,
elements: *const u8,
elements_header: &'static crate::gc::GcHeader,
requested_bound: Option<u32>,
require_numeric: i32,
out: *mut u64,
) -> Option<(i32, *const u8)> {
if elements_header._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0
|| super::super::PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED.load(Ordering::Relaxed) != 0
|| require_numeric >= 2
{
return None;
}
let array = elements.cast::<ArrayHeader>();
let (length, capacity) = unsafe { ((*array).length, (*array).capacity) };
let bound = requested_bound.unwrap_or(length);
if bound > length || length > capacity || capacity > 16_000_000 {
return None;
}
if require_numeric != 0
&& !unsafe { super::super::header::ensure_array_numeric_raw_f64(array as *mut ArrayHeader) }
{
return None;
}
let gc_word = unsafe { ptr::read_unaligned(elements.sub(8).cast::<u64>()) };
let array_word = (u64::from(capacity) << 32) | u64::from(length);
unsafe {
out.add(0).write(3);
out.add(1).write(gc_word);
out.add(2).write(array_word);
// Word 3 is the payload address the generated loop reads through; the
// revalidation entry refreshes it after every iteration that can move
// or re-allocate the store.
out.add(3).write(elements as u64);
out.add(4).write(0);
out.add(5).write(0);
out.add(6).write(u64::from(bound));
}
// The live address is the RECEIVER: the capture-safe caller reloads the
// receiver and re-derives the payload from word 3, so handing back the
// store here would let a stale capture read a detached payload.
Some((3, receiver))
}

fn packed_arraylike_loop_guard(
receiver: f64,
bound: f64,
Expand Down Expand Up @@ -90,14 +145,24 @@ fn packed_arraylike_loop_guard(
source
};
let header = unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) }?;
// An elements-backed Array-subclass instance (`super::subclass_elements`)
// keeps its elements in a real Array hanging off the meta record, so the
// loop is a PLAIN-ARRAY loop over that inner array: resolve to it and let
// the ordinary kind-1 admission below prove length/capacity/descriptors
// and (for a numeric mode) the raw-f64 bit. The live address the caller
// reads through is the inner array's, and the revalidation entry
// re-resolves it from the receiver on every iteration.
let (raw, header) = elements_loop_source(raw, header).unwrap_or((raw, header));
// An elements-backed Array-subclass instance keeps its payload in a
// separate Array hanging off the meta record. It is admitted as its own
// KIND rather than as a plain Array: kind 1 means "the receiver IS the
// ArrayHeader" and the generated loop computes `receiver + header + i*8`
// itself, so answering kind 1 with the store's address made element 0 read
// the object's `meta` word (`issue_8773_closure_capture_packed_loops`'s
// dense case). Kind 3 publishes the store address in word 3 and every
// proof below is the store's.
if let Some((elements, elements_header)) = elements_loop_source(raw, header) {
return elements_backed_loop_guard(
raw,
elements,
elements_header,
requested_bound,
require_numeric,
out,
);
}

if header.obj_type == crate::gc::GC_TYPE_ARRAY {
if header._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0
Expand Down Expand Up @@ -315,6 +380,9 @@ pub extern "C" fn js_packed_arraylike_loop_revalidate_live(
if unsafe { facts.read() } == 2 {
return revalidate_admitted_subclass_live(receiver, bound, require_numeric, facts);
}
if unsafe { facts.read() } == 3 {
return revalidate_admitted_elements_live(receiver, bound, require_numeric, facts);
}
let live_length_bound = bound == -1.0;
let js = JSValue::from_bits(receiver.to_bits());
if !js.is_pointer() {
Expand Down Expand Up @@ -441,6 +509,77 @@ pub extern "C" fn js_packed_arraylike_loop_revalidate_live(
raw as i64
}

/// Kind-3 revalidation: re-resolve the elements store from the receiver and
/// prove it is the SAME payload the guard admitted (same header word, same
/// length/capacity). A moving GC changes only the address, so word 3 is
/// refreshed and the loop keeps running; a re-allocating append changes the
/// capacity word and takes the side exit, exactly as a grown plain Array does.
#[inline(always)]
fn revalidate_admitted_elements_live(
receiver: f64,
bound: f64,
require_numeric: i32,
facts: *const u64,
) -> i64 {
let js = JSValue::from_bits(receiver.to_bits());
if !js.is_pointer() {
return 0;
}
let raw = js.as_pointer::<u8>();
let Some(header) = (unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) })
else {
return 0;
};
let Some((elements, elements_header)) = elements_loop_source(raw, header) else {
return 0;
};
if elements_header._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0
|| super::super::PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED.load(Ordering::Relaxed) != 0
{
return 0;
}
let (admitted_gc_word, admitted_array_word, admitted_bound) = unsafe {
(
facts.add(1).read(),
facts.add(2).read(),
facts.add(6).read(),
)
};
let array = elements.cast::<ArrayHeader>();
let (length, capacity) = unsafe { ((*array).length, (*array).capacity) };
let array_word = (u64::from(capacity) << 32) | u64::from(length);
let gc_word = unsafe { ptr::read_unaligned(elements.sub(8).cast::<u64>()) };
if array_word != admitted_array_word || gc_word != admitted_gc_word {
return 0;
}
let live_length_bound = bound == -1.0;
if !live_length_bound
&& (!bound.is_finite()
|| bound < 0.0
|| bound.fract() != 0.0
|| bound != admitted_bound as f64)
{
return 0;
}
if admitted_bound > u64::from(length) {
return 0;
}
if require_numeric != 0
&& !unsafe { super::super::header::ensure_array_numeric_raw_f64(array as *mut ArrayHeader) }
{
return 0;
}
// Republish the payload address: the store itself may have been evacuated
// even though its contents and header word are unchanged.
unsafe {
(facts as *mut u64).add(3).write(elements as u64);
(facts as *mut u64)
.add(6)
.write(u64::from(length).min(admitted_bound));
}
raw as i64
}

#[inline(always)]
fn revalidate_admitted_subclass_live(
receiver: f64,
Expand Down
Loading