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
10 changes: 10 additions & 0 deletions changelog.d/6919-repsel-p4b-store-elision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**Representation-selection Phase 4b (narrow) — class-field store note/addref elision + INT32 layout-poison fix (#6919)**

Phase 4b of the RFC (§5.7), scoped down after recon: **full field-unboxing was assessed and rejected.** `number` fields are already bit-unboxed (NaN-boxing reserves only `0x7FF9..=0x7FFF`, so a number slot already holds raw IEEE bits; `raw_f64_mask` is a *proof bit*, not a storage change, and Phase 3b already deleted the read-side guard). Raw string handles at rest would break SSO for nothing, and raw `i1`/`i32` slots would need a third GC mask **plus** a layout probe at ~25 direct slot-read sites — `JSON.stringify`, `util.inspect`, `v8` IPC serde, descriptor reads — all hot, not the "rare, already-slow" surfaces the RFC's observation-equivalence bullet assumes. §5.7 records the decision.

- **4b.1** — both `property_set.rs` class-field store sites emitted `js_gc_note_slot_layout` + `js_string_addref_if_heap_string` unconditionally. On a `Ptr<Shape>`-proven receiver each is now retired when provably dead, gated **independently** because they die under different conditions: the note when the value is a non-pointer by construction, the addref when the value cannot carry `STRING_TAG` (strictly weaker — an object/array literal or closure retires the addref while keeping the note). The generational write barrier is untouched. The note elision holds in every layout state: `UNKNOWN` and `POINTER_FREE` short-circuit inside the note, an intact descriptor falls through the `pointer_mask` arm untouched, and under `SIDE_MASK` the note would only ever *clear* the bit — skipping that leaves a stale set bit over a non-pointer, which costs one extra visit and nothing else, because `mark_field_into_worklist` re-validates every slot word and the evacuation rewrite path routes through it.
- Both gates are keyed on the **value expression, never the declared field type**: Perry does not validate declared types at runtime, so a `boolean`-declared field legitimately receives a string through an `any`, and a wrong addref elision there silently corrupts it on the next in-place append. `Expr::New` is excluded from the "cannot be a heap string" set because a constructor return override makes the answer a runtime question.
- **Deliberately not elided:** a pointer-valued store into a pointer-masked slot — the larger win originally scoped — because the receiver is not guaranteed to have a descriptor. `lower_new_impl` has an exit (the `force_ctor_call` branch where `call_local_constructor_symbol` yields `None`) that returns a fresh instance *without* emitting `js_gc_init_typed_shape_layout`; such an object sits at `POINTER_FREE`, where the note is the only thing that sets the pointer-mask bit the collector reads. Closing that exit is the prerequisite. The guarded (non-`Ptr<Shape>`) store keeps both calls for the same reason.
- **4b.2** — `layout_note_slot` evicts an object's `TypedLayoutDescriptor` permanently and one-way when a non-raw-f64 bit pattern lands in a raw-f64 slot. INT32 boxes genuinely reach object fields from FFI/native modules (sqlite rows, `v8` deserialization) and `runtime_store_jsvalue_slot` wrote them verbatim, so one FFI integer cost that object its fast path forever. It now applies the array precedent `canonicalize_array_numeric_store_bits` (INT32 → raw f64) for raw-f64-masked slots under an intact descriptor — no observable change (an INT32 box and its f64 are `===`), gated tag-first so the hot non-INT32 store never pays the thread-local probe, with `value_bits_to_number` supplying the `ClassRef` exclusion.

Structural proof (`--trace llvm`, A/B with the predicates forced back to `true`), per changed function: notes 4/6/2 → 3/4/2, addrefs 4/6/2 → 2/2/2, barriers 3/4/2 unchanged; `compoundSnapshots` correctly elides nothing (both stores are `tag ||= <uniquely-owned heap string>`). Post-`opt -O3`, whole module: notes 41 → 37, addrefs 56 → 48. A new 13-section gap file covers each elision and non-elision case — string-typed field still addrefs under snapshot-then-grow, union-with-string keeps the demote, declared types are not enforced, INT32-into-typed-field reads back byte-exact — byte-exact vs the pinned Node 26.5.0 oracle under `PERRY_PTR_SHAPE_LOCALS` on/off, `PERRY_GC_FORCE_EVACUATE=1`, `PERRY_GC_VERIFY_EVACUATION=1`, `PERRY_GEN_GC=0` and `PERRY_WRITE_BARRIERS=0`, plus four runtime regression tests for 4b.2 including the negative controls. No benchmark numbers: the machine never went quiet, so the perf claim is deferred rather than published load-corrupted.
79 changes: 79 additions & 0 deletions crates/perry-codegen/src/expr/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,85 @@ pub(crate) fn array_store_needs_write_barrier(ctx: &FnCtx<'_>, value: &Expr) ->
!expr_produces_non_pointer_bits_by_construction(ctx, value)
}

/// Object twin of [`array_store_needs_layout_note`] — Phase 4b.1.
///
/// `layout_note_slot` is a provable no-op for a class-field store whose value is
/// a **non-pointer by construction**: in that case the note can only ever
/// *clear* mask state, never set it, so it is never the difference between a
/// slot being scanned and a live child being stranded. That holds in every
/// layout state the receiver can be in:
///
/// - `GC_LAYOUT_UNKNOWN` — the note returns at its own state check.
/// - intact typed descriptor — a non-pointer value falls straight through the
/// `pointer_mask` arm without touching the descriptor. (The `raw_f64_mask`
/// arm is unreachable from the caller: it only uses this predicate when
/// `requires_raw_f64` is false, and that is the very same
/// `type_is_raw_f64_candidate` predicate the mask is built from.)
/// - `GC_LAYOUT_POINTER_FREE` — the note's `!pointer && POINTER_FREE` early
/// return.
/// - `GC_LAYOUT_SIDE_MASK` — the note would only *clear* this slot's bit.
/// Skipping that leaves a stale set bit over a non-pointer, which costs an
/// extra visit and nothing else: `mark_field_into_worklist` (`gc/trace.rs`)
/// re-validates every slot word — f64 bit patterns fall outside the 48-bit
/// user-address range and are rejected — and the evacuation rewrite path
/// routes through the same function.
///
/// **Deliberately NOT elided: a pointer-valued store into a pointer-masked
/// slot.** That would be a no-op under an intact descriptor, but the receiver
/// is not guaranteed to have one — `lower_new_impl` has an exit
/// (`lower_call/new.rs`, the standalone-ctor-symbol branch where
/// `call_local_constructor_symbol` yields `None`) that returns a freshly
/// allocated instance *without* emitting `js_gc_init_typed_shape_layout`. Such
/// an object sits at `GC_LAYOUT_POINTER_FREE`, where the note is the only thing
/// that ever sets the pointer-mask bit the collector reads. Closing that exit
/// (#6921) is the prerequisite for the stronger elision.
pub(crate) fn class_field_store_needs_layout_note(ctx: &FnCtx<'_>, value: &Expr) -> bool {
!expr_produces_non_pointer_bits_by_construction(ctx, value)
}

/// `js_string_addref_if_heap_string` demotes a uniquely-owned (refcount==1)
/// heap string to shared when it becomes aliased from a heap slot, and is a
/// no-op for every non-`STRING_TAG` value (`string/alloc.rs`). So it is dead
/// exactly when the stored value provably cannot be a heap string — a strictly
/// weaker condition than "cannot be a pointer", which is why this is gated
/// separately from the layout note above.
///
/// **This is keyed on the value expression, never on the declared field type.**
/// Perry does not validate declared types at runtime (see CLAUDE.md, "No
/// runtime type *validation*"): a field declared `boolean` legitimately
/// receives a string that arrived through an `any`, and skipping the demote
/// there would leave a refcount==1 string aliased from the heap for a later
/// in-place `+=` to rewrite underneath the stored slot — silent corruption
/// with no crash to trace it back from.
pub(crate) fn class_field_store_needs_string_addref(ctx: &FnCtx<'_>, value: &Expr) -> bool {
!expr_cannot_produce_heap_string(ctx, value)
}

/// The stored value provably does not carry `STRING_TAG`.
///
/// Beyond the non-pointer set, the three *literal* constructor forms qualify:
/// each evaluates to a freshly allocated `POINTER_TAG` value with no path to a
/// primitive result. `Expr::New` is deliberately excluded — a constructor
/// return override (`js_ctor_return_override`) makes "what `new C()` evaluates
/// to" a runtime question, and this predicate must not depend on the answer.
fn expr_cannot_produce_heap_string(ctx: &FnCtx<'_>, expr: &Expr) -> bool {
match expr {
Expr::Object(_) | Expr::Array(_) | Expr::Closure { .. } => true,
Expr::Conditional {
then_expr,
else_expr,
..
} => {
expr_cannot_produce_heap_string(ctx, then_expr)
&& expr_cannot_produce_heap_string(ctx, else_expr)
}
Expr::Sequence(exprs) => exprs
.last()
.is_some_and(|last| expr_cannot_produce_heap_string(ctx, last)),
_ => expr_produces_non_pointer_bits_by_construction(ctx, expr),
}
}

/// `lower_expr` variant that hands an expected-type hint down to the
/// object-literal lowerer (so it can pick raw f64 slots when the
/// destination has a typed shape). All other expression kinds ignore
Expand Down
3 changes: 2 additions & 1 deletion crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ pub(crate) use channel::{
};
pub(crate) use helpers::{
array_store_needs_layout_note, array_store_needs_write_barrier, buffer_alias_metadata_suffix,
class_field_store_needs_layout_note, class_field_store_needs_string_addref,
expr_has_numeric_pointer_free_array_layout, expr_produces_non_pointer_bits_by_construction,
is_global_this_builtin_function_name, is_global_this_builtin_name,
lower_expr_with_expected_type, lower_js_args_array, proxy_build_args_array, unbox_str_handle,
Expand Down Expand Up @@ -117,7 +118,7 @@ pub(crate) use v8_interop::{
};
pub(crate) use write_barrier::{
emit_array_numeric_write_note_on_block, emit_jsvalue_slot_store_on_block,
emit_jsvalue_slot_store_scalar_aware_on_block,
emit_jsvalue_slot_store_scalar_aware_on_block, emit_jsvalue_slot_store_with_flags_on_block,
emit_jsvalue_slot_store_with_value_bits_on_block, emit_root_heap_word_store_on_block,
emit_root_nanbox_store_on_block, emit_write_barrier, emit_write_barrier_slot_on_block,
lower_array_super_init, lower_event_emitter_subclass_init, lower_node_stream_super_init,
Expand Down
45 changes: 39 additions & 6 deletions crates/perry-codegen/src/expr/property_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ use crate::type_analysis::{
use crate::types::{DOUBLE, I1, I32, I64, I8, PTR};

use super::{
emit_jsvalue_slot_store_on_block, emit_typed_feedback_register_site,
expr_produces_non_pointer_bits_by_construction, lower_expr, lower_expr_native,
raw_f64_layout_fact, try_lower_pod_field_set, unbox_to_i64, FnCtx, TypedFeedbackContract,
TypedFeedbackKind,
class_field_store_needs_layout_note, class_field_store_needs_string_addref,
emit_jsvalue_slot_store_on_block, emit_jsvalue_slot_store_with_flags_on_block,
emit_typed_feedback_register_site, expr_produces_non_pointer_bits_by_construction, lower_expr,
lower_expr_native, raw_f64_layout_fact, try_lower_pod_field_set, unbox_to_i64, FnCtx,
TypedFeedbackContract, TypedFeedbackKind,
};

fn canonicalize_raw_f64_numeric_store_value(
Expand Down Expand Up @@ -596,15 +597,47 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
}
ctx.current_block = merge_idx;
} else {
// Repsel Phase 4b.1: retire the two bookkeeping
// calls that are provably dead here.
//
// The receiver being `Ptr<Shape>`-proven is
// what licenses the layout-note elision. Three
// facts close it:
//
// Both are decided from the VALUE expression,
// and gated independently because they are dead
// under different conditions: the note needs
// "not a pointer", the addref only "not a heap
// string". Neither is keyed on the declared
// field type — Perry does not enforce declared
// types at runtime, so a `boolean` field can
// legitimately receive a string through an
// `any`, and a wrong addref elision there
// silently corrupts it on the next in-place
// append.
//
// `requires_raw_f64` is false on this arm, so
// the raw-f64-mask arm of `layout_note_slot` —
// the one that *must* downgrade — is
// unreachable from here. The full per-layout-
// state argument, including why a pointer store
// into a pointer-masked slot is deliberately
// NOT elided, is on
// `class_field_store_needs_layout_note`.
let layout_note_needed =
class_field_store_needs_layout_note(ctx, value);
let string_addref_needed =
class_field_store_needs_string_addref(ctx, value);
let blk = ctx.block();
let field_addr = blk.ptrtoint(&field_ptr, I64);
emit_jsvalue_slot_store_on_block(
emit_jsvalue_slot_store_with_flags_on_block(
blk,
&field_ptr,
&val_double,
&obj_handle,
&field_idx_str,
true,
string_addref_needed,
layout_note_needed,
&obj_bits,
&field_addr,
field_set_barrier_needed,
Expand Down
52 changes: 49 additions & 3 deletions crates/perry-codegen/src/expr/write_barrier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,46 @@ pub(crate) fn emit_jsvalue_slot_store_on_block(
layout_parent_bits,
slot_index,
layout_note_needed,
layout_note_needed,
barrier_parent_bits,
slot_addr,
write_barrier_needed,
false,
None,
)
}

/// As [`emit_jsvalue_slot_store_on_block`], but with the string-addref demote
/// and the GC layout note gated **independently** (Phase 4b.1).
///
/// The two calls answer different questions and are provably dead under
/// different conditions: the addref is a no-op unless the value is a heap
/// `STRING_TAG` string, while the note is a no-op when the slot's mask class is
/// already fixed. Class-field stores on a shape-proven receiver can retire one
/// without the other — e.g. `Foo`-typed field ← `LocalGet` elides the note
/// (pointer-masked slot) but must keep the addref (the local could hold a
/// uniquely-owned string that a later `+=` would rewrite in place).
#[allow(clippy::too_many_arguments)]
pub(crate) fn emit_jsvalue_slot_store_with_flags_on_block(
blk: &mut LlBlock,
slot_ptr: &str,
value_double: &str,
layout_parent_bits: &str,
slot_index: &str,
string_addref_needed: bool,
layout_note_needed: bool,
barrier_parent_bits: &str,
slot_addr: &str,
write_barrier_needed: bool,
) -> Option<String> {
emit_jsvalue_slot_store_on_block_inner(
blk,
slot_ptr,
value_double,
layout_parent_bits,
slot_index,
string_addref_needed,
layout_note_needed,
barrier_parent_bits,
slot_addr,
write_barrier_needed,
Expand Down Expand Up @@ -164,6 +204,7 @@ pub(crate) fn emit_jsvalue_slot_store_with_value_bits_on_block(
layout_parent_bits,
slot_index,
layout_note_needed,
layout_note_needed,
barrier_parent_bits,
slot_addr,
write_barrier_needed,
Expand Down Expand Up @@ -200,6 +241,7 @@ pub(crate) fn emit_jsvalue_slot_store_scalar_aware_on_block(
layout_parent_bits,
slot_index,
layout_note_needed,
layout_note_needed,
barrier_parent_bits,
slot_addr,
write_barrier_needed,
Expand All @@ -215,6 +257,7 @@ fn emit_jsvalue_slot_store_on_block_inner(
value_double: &str,
layout_parent_bits: &str,
slot_index: &str,
string_addref_needed: bool,
layout_note_needed: bool,
barrier_parent_bits: &str,
slot_addr: &str,
Expand All @@ -239,10 +282,13 @@ fn emit_jsvalue_slot_store_on_block_inner(
// allocates fresh instead of mutating the stored element. This is the inline
// codegen choke point the runtime store functions (`js_array_push_f64`, …)
// are bypassed for on the fast paths. Tag-checked at runtime (a no-op for
// SSO / non-string), and only emitted when the value can be a heap pointer
// (`layout_note_needed`), so numeric stores pay nothing. Mirrors the
// SSO / non-string), and only emitted when the value can be a heap string
// (`string_addref_needed`), so numeric stores pay nothing. Most callers tie
// that to `layout_note_needed`; Phase 4b.1's class-field store gates it
// separately, because a pointer-masked slot can retire the note while the
// stored value can still be a uniquely-owned string. Mirrors the
// object-field demote in `runtime_store_jsvalue_slot` (#5533).
if layout_note_needed {
if string_addref_needed {
blk.call_void("js_string_addref_if_heap_string", &[(DOUBLE, value_double)]);
}
if !layout_note_needed && !write_barrier_needed {
Expand Down
Loading
Loading