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
60 changes: 60 additions & 0 deletions changelog.d/7928-inline-slot-floor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
### perf(runtime): right-size small objects — `INLINE_SLOT_FLOOR` 4 → 2

Closes the front half of #7916 and all of #7714.

**The accounting.** A two-field object literal `{a: number, b: number}` occupied **72 bytes
to store 16 bytes of payload**: 8 `GcHeader` + 32 `ObjectHeader` (`object_type` 4,
`class_id` 4, `parent_class_id` 4, `field_count` 4, `keys_array` 8, `meta` 8) + 4 × 8 slot
bytes, of which only two slots are reachable. Alignment and capacity rounding contribute
**zero** — `ObjectHeader` is `#[repr(C)]` with no interior padding and
`gc_padded_total_size(64, 8)` finds `8 + 64` already 8-aligned. 22.2% of the allocation was
payload, 22.2% was the slot floor. `gc-handoff/bench/retain.ts` wrote 216 MB to store 48 MB
of doubles.

**Why the floor is a dial, not a safety constant.** Its doc comment called it
corruption-critical and 55 runtime sites plus 3 codegen sites independently compute
`max(field_count, FLOOR)` as the inline/overflow boundary — but the by-name append path
(`field_set_by_name/tail.rs`) only bumps `field_count` for a slot it placed *inline*, and
spills anything at or past `alloc_limit` to overflow storage. `alloc_limit` is therefore a
fixed point of the allocation and can never grow past the physical slot count, at any
FLOOR ≥ 0. (#6712 moved it 8 → 4 on the same reasoning.) 2 rather than 1 or 0 because all
three are indistinguishable in footprint for every shape in the perf corpus, so 2 keeps the
most inline headroom for a dynamically-grown `{}` at zero byte cost.

**Result.** `{}` / 1-field / 2-field literals 72 → **56 bytes**; 3-field 72 → **64**; ≥4
fields unchanged (their overhead is entirely the two headers). `retain` writes 168 MB
instead of 216 MB — write amplification **4.5x → 3.5x**. Peak RSS (bit-exact run to run):
`tree` −18.6%, `retain` −15.3%, `retain1` −12.8%, `deeplist` −11.3%, everything else ≤0.4%.

**The interaction worth knowing about.** `retain1` and `deeplist` retire 12–14% *more*
instructions, and none of it is mutator cost. Every minor GC in both arms fires at the same
byte mark and processes the same bytes — but 1.286 = 72/56 times as many *objects*
(`retain1` minor 1: 245 752 → 315 969 objects at 17 694 064 → 17 694 216 bytes). GC pause
39.60 → 50.10 ms, which exceeds the program's entire cycle delta: the mutator got faster and
the collector got slower, at an unchanged ~50 ns per promoted object. **The collector's
trigger is denominated in bytes; its cost is denominated in objects**, so every future
object-shrinking change is taxed back until the nursery/promotion budgets carry an
object-count term. Total promotion work is set by the surviving object count (unchanged), so
these microbenchmarks are seeing work pulled *forward* into their measurement window, not
created. The rest of the corpus moves the other way: `churn` −1.2%, `churn_alloc` −1.4%,
`push_cls` −1.4%, `tree` −0.8% instructions.

**Codegen pairing.** perry-codegen carried two separately-spelled `4`s held together by a
comment, used for opposite purposes: sizing the inline-`new` bump allocation (too small →
writes past the allocation) and emitting the property bounds checks (too large → reads past
it). Both now read `target_layout::INLINE_SLOT_FLOOR`, paired with the runtime by
`inline_slot_floor_matches_runtime` / `inline_slot_floor_matches_codegen`, the mechanism
`PIC_CACHE_WORDS` already uses.

**Validation.** 19/19 corpus programs byte-exact vs `node --experimental-strip-types`
26.5.1 with exit 0, and again under `PERRY_GC_PROTECT_FROMSPACE=1
PERRY_GC_VERIFY_EVACUATION=1` (a layout change is GC-visible); `iso_miss` canary
`checksum 437840 misses 0`; gap suite; `cargo test --release -p perry-codegen
-p perry-runtime`. New tests: `two_field_literal_footprint_is_exactly_accounted` reads the
size the *allocator recorded* in `GcHeader::size` rather than recomputing the formula, so it
fails if any allocation path stops honouring the floor, and
`by_name_growth_past_the_floor_reads_back` pins that the inline/overflow boundary stays
invisible to reads.

Full byte-level write-up and the projection for shrinking `ObjectHeader` itself:
`gc-handoff/REPR-NOTES.md`.
23 changes: 13 additions & 10 deletions crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ use crate::types::{DOUBLE, I1, I32, I64, I8, PTR};
/// **Must equal `perry_runtime::object::field_get_set::PIC_CACHE_WORDS`** —
/// the runtime writes this memory through a `*mut [i64; PIC_CACHE_WORDS]`, so a
/// smaller global here is an out-of-bounds store. perry-codegen does not depend
/// on perry-runtime (the same reason `INLINE_SLOT_FLOOR` is spelled `4` inline
/// below), so the pairing is held by `pic_cache_layout_matches_runtime` here and
/// `pic_cache_words_match_codegen` in the runtime: change one and both fail.
/// on perry-runtime (the same reason `INLINE_SLOT_FLOOR` is duplicated in
/// `target_layout`), so the pairing is held by `pic_cache_layout_matches_runtime`
/// here and `pic_cache_words_match_codegen` in the runtime: change one and both
/// fail.
pub(crate) const PIC_CACHE_WORDS: usize = 12;
/// First word of the polymorphic way array (words 0..2 are the MRU entry and
/// word 3 is the gate). Mirrors the runtime's `PIC_WAY_BASE`.
Expand All @@ -40,13 +41,15 @@ pub(crate) const PIC_WAY_STATE: usize = 3;
/// Spelled as the equivalent disjunction `slot < FLOOR || slot < field_count`
/// rather than as a `max` followed by one compare. The predicate is identical
/// for every input (`x < max(a, b)` ⟺ `x < a ∨ x < b`), but the `max` had to be
/// materialised — `mov w, #4` / `cmp` / `csel` — and that `csel` was the single
/// hottest instruction in `interp.ts` (4.65% of `evalNode`, #7907), because it
/// sits on the dependency chain out of the `field_count` load. The disjunction
/// has no such node: LLVM folds the pair into `cmp` + `ccmp`, and the
/// `slot < 4` half does not depend on the load at all.
/// materialised — `mov w, #FLOOR` / `cmp` / `csel` — and that `csel` was the
/// single hottest instruction in `interp.ts` (4.65% of `evalNode`, #7907),
/// because it sits on the dependency chain out of the `field_count` load. The
/// disjunction has no such node: LLVM folds the pair into `cmp` + `ccmp`, and
/// the `slot < FLOOR` half does not depend on the load at all.
fn emit_slot_in_bounds(ctx: &mut FnCtx<'_>, slot: &str, field_count: &str) -> String {
let below_floor = ctx.block().icmp_ult(I64, slot, "4"); // INLINE_SLOT_FLOOR
let below_floor = ctx
.block()
.icmp_ult(I64, slot, crate::target_layout::INLINE_SLOT_FLOOR_LIT);
let below_count = ctx.block().icmp_ult(I64, slot, field_count);
ctx.block().or(I1, &below_floor, &below_count)
}
Expand Down Expand Up @@ -535,7 +538,7 @@ pub(crate) fn lower_generic_property_get(
// slots live in its OVERFLOW map) — a slot primed from a
// larger-capacity sibling must not drive a raw load past this
// receiver's field region. `alloc_limit = max(field_count,
// INLINE_SLOT_FLOOR=4)` mirrors the miss handler's cacheability
// INLINE_SLOT_FLOOR)` mirrors the miss handler's cacheability
// rule; an out-of-bounds slot falls to the miss path, which reads
// the overflow map correctly (and records the guard failure —
// `record_guard_pass` only fires after the bounds check passes).
Expand Down
12 changes: 7 additions & 5 deletions crates/perry-codegen/src/expr/property_get/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,16 +332,18 @@ fn pic_miss_reuses_the_token_blocks_values_instead_of_re_deriving_them() {
/// notices.
#[test]
fn cached_slot_bound_is_a_disjunction_not_a_materialised_max() {
let floor = crate::target_layout::INLINE_SLOT_FLOOR_LIT;
let ir = emit(false, None);
assert!(
ir.contains("icmp ult i64 ") && ir.contains(", 4"),
ir.lines()
.any(|l| l.contains("icmp ult i64 ") && l.ends_with(&format!(", {floor}"))),
"test premise: the emitted bound compares a slot against \
INLINE_SLOT_FLOOR:\n{ir}"
INLINE_SLOT_FLOOR ({floor}):\n{ir}"
);
assert!(
!ir.contains(", i64 4, i64 %"),
"a `select …, i64 4, i64 %fc` is the materialised max this deliberately \
does not emit:\n{ir}"
!ir.contains(&format!(", i64 {floor}, i64 %")),
"a `select …, i64 {floor}, i64 %fc` is the materialised max this \
deliberately does not emit:\n{ir}"
);
}

Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/expr/proxy_reflect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -961,7 +961,7 @@ fn lower_put_value_dyn_ic_inline(
/// perry-runtime `object::INLINE_SLOT_FLOOR` (the runtime pads every object
/// to at least this many physical slots; a codegen value larger than the
/// runtime's would widen inline stores into unallocated memory).
const INLINE_SLOT_FLOOR_LIT: &str = "4";
const INLINE_SLOT_FLOOR_LIT: &str = crate::target_layout::INLINE_SLOT_FLOOR_LIT;

fn static_write_key(ctx: &FnCtx<'_>, key: &Expr) -> Option<String> {
match key {
Expand Down
6 changes: 4 additions & 2 deletions crates/perry-codegen/src/lower_call/new_alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,10 @@ fn emit_instance_alloc_inner(
// Inline-slot floor — MUST match perry-runtime `object::INLINE_SLOT_FLOOR`
// (they independently pad `new` objects to the same minimum; a mismatch
// where codegen allocs fewer slots than the runtime's get/set bound-check
// assumes is heap corruption). Lowered 8->4 to shrink small-object footprint.
const MIN_FIELD_SLOTS: u64 = 4;
// assumes is heap corruption). Single source of truth, paired with the
// runtime by `target_layout::tests::inline_slot_floor_matches_runtime`.
// Lowered 8->4 (#6712) then 4->2 (#7916) to shrink small-object footprint.
const MIN_FIELD_SLOTS: u64 = crate::target_layout::INLINE_SLOT_FLOOR;
const GC_TYPE_OBJECT: u64 = 2;
const GC_FLAG_ARENA: u64 = 0x02;
// PR #1146: pointer-free hint for inline-allocated regular
Expand Down
51 changes: 40 additions & 11 deletions crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,47 @@ const ANY_ATOMIC_LOAD: &str =
"load atomic i32, ptr @PERRY_PER_OBJECT_LAYOUTS_ANY monotonic, align 4";

/// The packed `GcHeader` word the inline bump writes for a two-`number`-field
/// class, WITH the baked layout:
/// class:
///
/// ```text
/// obj_type GC_TYPE_OBJECT = 0x02 bits 0..7
/// gc_flags GC_FLAG_ARENA = 0x02 bits 8..15
/// _reserved GC_LAYOUT_POINTER_FREE | INTACT = 0x5000 bits 16..31
/// size 8 + 32 + max(2,4)*8 = 72 bits 32..63
/// obj_type GC_TYPE_OBJECT = 0x02 bits 0..7
/// gc_flags GC_FLAG_ARENA = 0x02 bits 8..15
/// _reserved GC_LAYOUT_POINTER_FREE [| INTACT] = 0x4000 [| 0x1000] bits 16..31
/// size 8 + 32 + max(2, INLINE_SLOT_FLOOR)*8 bits 32..63
/// ```
const BAKED_HEADER_WORD: &str = "store i64 310579823106,";
/// The same word WITHOUT `GC_OBJ_TYPED_LAYOUT_INTACT` (0x1000 << 16 less) —
/// what the pointer-bearing class still writes.
const UNBAKED_HEADER_WORD: &str = "store i64 310311387650,";
///
/// Computed from `INLINE_SLOT_FLOOR` rather than spelled as a literal: #7916
/// moved the floor 4 → 2, which changes `size` 72 → 56 and therefore both
/// words. A hard-coded constant here fails the moment the footprint changes
/// and says nothing about what this test is actually for (whether
/// `GC_OBJ_TYPED_LAYOUT_INTACT` is claimed), so derive the part that is
/// incidental and keep asserting the part that is not.
fn header_word(intact: bool) -> String {
const GC_TYPE_OBJECT: u64 = 0x02;
const GC_FLAG_ARENA: u64 = 0x02;
const GC_LAYOUT_POINTER_FREE: u64 = 0x4000;
const GC_OBJ_TYPED_LAYOUT_INTACT: u64 = 0x1000;
let slots = std::cmp::max(2, crate::target_layout::INLINE_SLOT_FLOOR);
let size =
8 + crate::target_layout::object_header_size_bytes("aarch64-apple-darwin") + 8 * slots;
let reserved = GC_LAYOUT_POINTER_FREE
| if intact {
GC_OBJ_TYPED_LAYOUT_INTACT
} else {
0
};
let word = (size << 32) | (reserved << 16) | (GC_FLAG_ARENA << 8) | GC_TYPE_OBJECT;
format!("store i64 {word},")
}

/// The packed word WITH the baked `GC_OBJ_TYPED_LAYOUT_INTACT`.
fn baked_header_word() -> String {
header_word(true)
}
/// The same word WITHOUT it — what the pointer-bearing class still writes.
fn unbaked_header_word() -> String {
header_word(false)
}

fn ir_opts() -> CompileOptions {
CompileOptions {
Expand Down Expand Up @@ -318,7 +347,7 @@ pub(super) fn emit(m: &Module) -> String {
fn a_pointer_free_shape_bakes_its_layout_into_the_header_constant() {
let ir = emit(&loop_new_module("Pair", Type::Number, Expr::Integer(2)));
assert!(
ir.contains(BAKED_HEADER_WORD),
ir.contains(&baked_header_word()),
"the inline-bump header constant does not carry \
GC_OBJ_TYPED_LAYOUT_INTACT, so the bake did not fire and every \
construction still pays the runtime declare:\n{ir}"
Expand Down Expand Up @@ -354,7 +383,7 @@ fn a_pointer_bearing_shape_keeps_the_runtime_declare() {
lookup reads:\n{ir}"
);
assert!(
ir.contains(UNBAKED_HEADER_WORD) && !ir.contains(BAKED_HEADER_WORD),
ir.contains(&unbaked_header_word()) && !ir.contains(&baked_header_word()),
"the header constant must NOT claim GC_OBJ_TYPED_LAYOUT_INTACT for a \
shape whose descriptor is installed at runtime:\n{ir}"
);
Expand Down
52 changes: 52 additions & 0 deletions crates/perry-codegen/src/target_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,62 @@ pub fn object_header_size_bytes(target_triple: &str) -> u64 {
}
}

/// Minimum number of inline field slots `perry-runtime` allocates for EVERY
/// object, mirroring `perry_runtime::object::INLINE_SLOT_FLOOR`.
///
/// perry-codegen deliberately does not depend on perry-runtime (the same reason
/// `PIC_CACHE_WORDS` is duplicated), so the pairing is held by
/// `inline_slot_floor_matches_runtime` here and
/// `inline_slot_floor_matches_codegen` in `perry-runtime/src/object/tests.rs`:
/// change one and both fail.
///
/// Two independent consumers, with OPPOSITE failure modes — which is why they
/// must share one constant rather than two spellings of the same digit:
///
/// - **`lower_call/new_alloc.rs`** sizes the inline-`new` bump allocation as
/// `max(field_count, INLINE_SLOT_FLOOR)` slots. A value SMALLER than the
/// runtime's makes the runtime's bound checks admit slots the emitted
/// allocation never reserved → writes into the neighbouring arena object.
/// - **the emitted property bounds checks** (`expr/property_get`,
/// `expr/proxy_reflect`) gate a raw inline slot load/store on
/// `slot < max(field_count, INLINE_SLOT_FLOOR)`. A value LARGER than the
/// runtime's widens those raw accesses past the allocation.
///
/// So codegen must be exactly equal, not conservatively either way.
pub const INLINE_SLOT_FLOOR: u64 = 2;

/// `INLINE_SLOT_FLOOR` as the string literal the IR emitters splice in.
pub const INLINE_SLOT_FLOOR_LIT: &str = "2";

#[cfg(test)]
mod tests {
use super::*;

/// Paired with `inline_slot_floor_matches_codegen` in
/// `perry-runtime/src/object/tests.rs` (#7916).
#[test]
fn inline_slot_floor_matches_runtime() {
assert_eq!(
INLINE_SLOT_FLOOR, 2,
"perry-runtime's object::INLINE_SLOT_FLOOR is 2; update both sides together"
);
assert_eq!(
INLINE_SLOT_FLOOR_LIT,
INLINE_SLOT_FLOOR.to_string(),
"the spliced literal must be the constant"
);
// The inline-`new` allocation is `GcHeader + ObjectHeader + 8 * slots`
// and the bump allocator's offset invariant requires a multiple of 8.
for triple in ["aarch64-apple-darwin", "arm64_32-apple-watchos"] {
let total = 8 + object_header_size_bytes(triple) + 8 * INLINE_SLOT_FLOOR;
assert_eq!(
total % 8,
0,
"{triple}: floor-sized allocation must be 8-aligned"
);
}
}

#[test]
fn object_header_size_matches_pointer_width() {
// 64-bit targets: 4×u32 + two 8-byte-aligned pointers (keys_array +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,10 @@ fn test_fresh_closure_capture_slots_are_initialized_7154() {
/// `object/field_set_by_name/tail.rs`'s two "#7154 publication order" sites.
///
/// `perry_ffi::alloc_object()` calls `js_object_alloc(0, 0)`: `field_count =
/// 0` with `INLINE_SLOT_FLOOR` (4) physical slots undefined-initialized.
/// 0` with `INLINE_SLOT_FLOOR` physical slots undefined-initialized.
/// `js_object_set_field(obj, 0, pointer_value)` passes the bounds check
/// (`0 < max(field_count, 4)`) and stores the pointer, but — unlike
/// (`0 < max(field_count, INLINE_SLOT_FLOOR)`) and stores the pointer, but —
/// unlike
/// `tail.rs`'s by-name writer — never bumps `field_count`. The collector's
/// view of the payload (`object::gc_field_slot_range`, and downstream
/// `heap_payload_slot_selection`'s `payload.is_empty()` short-circuit) is
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1375,7 +1375,7 @@ mod tests {
// The array fast path built its shape template with
// `min(keys_len, field_count)`. `field_count` is PHYSICAL — it never
// exceeds the object's inline slot allocation, so an object grown by
// name past `INLINE_SLOT_FLOOR` reports the floor (4) while the
// name past `INLINE_SLOT_FLOOR` reports the floor while the
// remaining values live in overflow storage. `JSON.parse`'s tape
// materializer produces exactly that shape (`js_object_alloc(0, 0)` +
// `js_object_set_field_by_name` per key), so `JSON.stringify` of a
Expand Down
10 changes: 6 additions & 4 deletions crates/perry-runtime/src/json/stringify_shape_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,12 @@ pub(crate) unsafe fn build_shape_prefix_template(first_elem_bits: u64) -> Option
// every property by name, so a 6-key record reports `field_count == 4`.
//
// The old `min(keys_len, field_count)` therefore truncated EVERY element of
// a homogeneous array to the first 4 properties with no diagnostic — silent
// data loss in `JSON.stringify(JSON.parse(x))` (#7264). Latent since the
// template landed (v0.5.65); exposed for ordinary 5–8-field records when
// #6712 lowered `INLINE_SLOT_FLOOR` from 8 to 4.
// a homogeneous array to the first `INLINE_SLOT_FLOOR` properties with no
// diagnostic — silent data loss in `JSON.stringify(JSON.parse(x))` (#7264).
// Latent since the template landed (v0.5.65); exposed for ordinary
// 5–8-field records when #6712 lowered `INLINE_SLOT_FLOOR` from 8 to 4
// (#7916 then lowered it again to 2, which is why this must never go back
// to reading `field_count`).
//
// `min` was never needed for the opposite skew either: a pre-sized object
// (`js_object_alloc(0, 8)` holding 2 real keys) has `field_count > keys_len`,
Expand Down
10 changes: 6 additions & 4 deletions crates/perry-runtime/src/object/alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,12 @@ pub extern "C" fn js_object_alloc_with_parent(
}

let header_size = std::mem::size_of::<ObjectHeader>();
// Allocate at least 8 field slots to match js_object_set_field_by_name's alloc_limit
// assumption (max(field_count, 8)). Without this, empty objects ({}) with field_count=0
// would have 0 field slots but js_object_set_field_by_name writes up to 8 fields inline,
// causing heap buffer overflow into adjacent arena objects.
// Allocate at least INLINE_SLOT_FLOOR field slots to match
// js_object_set_field_by_name's alloc_limit assumption
// (max(field_count, INLINE_SLOT_FLOOR)). Without this, empty objects ({})
// with field_count=0 would have 0 field slots but
// js_object_set_field_by_name writes up to the floor inline, causing a heap
// buffer overflow into adjacent arena objects.
let alloc_field_count = std::cmp::max(field_count as usize, crate::object::INLINE_SLOT_FLOOR);
let fields_size = alloc_field_count * std::mem::size_of::<JSValue>();
let total_size = header_size + fields_size;
Expand Down
Loading
Loading