Skip to content

perf(runtime): right-size small objects — INLINE_SLOT_FLOOR 4 -> 2 (#7916, #7714) - #7928

Merged
proggeramlug merged 3 commits into
mainfrom
perf/7916-inline-slot-floor
Aug 12, 2026
Merged

perf(runtime): right-size small objects — INLINE_SLOT_FLOOR 4 -> 2 (#7916, #7714)#7928
proggeramlug merged 3 commits into
mainfrom
perf/7916-inline-slot-floor

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

The accounting first (#7916 asked for this as a deliverable in its own right)

A two-field object literal {a: number, b: number}gc-handoff/bench/retain.ts — occupies
72 bytes to store 16 bytes of payload. Byte for byte, with no guesswork:

region field bytes content
GcHeader obj_type / gc_flags / _reserved / size 1+1+2+4 type tag, arena+age flags, GC layout state, 72
ObjectHeader object_type: u32 4 OBJECT_TYPE_REGULAR — constant for every plain object
class_id: u32 4 0 for every object literal
parent_class_id: u32 4 the runtime ShapeId (birth-stamped, #6804)
field_count: u32 4 2 — a property of the shape
keys_array: *mut ArrayHeader 8 the shape cache's array — the same pointer in every object of this shape
meta: *mut ObjectMeta 8 null for ordinary objects
payload slots 0–1 16 the two doubles — the only real payload
payload slots 2–3 16 undefined, forever — INLINE_SLOT_FLOOR = 4
total 72

Alignment and capacity rounding contribute zero. ObjectHeader is #[repr(C)]
4+4+4+4+8+8 with no interior padding, the slot region is 8-aligned by construction, and
gc_padded_total_size(64, 8) finds 8 + 64 already a multiple of 8. Every one of the 56
non-payload bytes is a deliberate field. 22.2% of the allocation is payload; 22.2% is the
slot floor; 44.4% is ObjectHeader; 11.1% is GcHeader.

Full write-up, including the projection for the header itself: gc-handoff/REPR-NOTES.md.

The change

INLINE_SLOT_FLOOR 4 → 2, in lockstep on both sides of the runtime/codegen boundary.

The floor looks like a corruption-critical safety constant (its doc comment says so, and 55
runtime sites plus 3 codegen sites independently compute max(field_count, FLOOR) as the
inline/overflow boundary). It is not a safety constant — it is a growth-headroom dial, and
the reason is one comment in field_set_by_name/tail.rs: when a new key lands past the
limit the value spills to overflow storage and field_count is deliberately not bumped.
So alloc_limit is a fixed point of the allocation and can never grow past the physical slot
count, for any FLOOR ≥ 0. (#6712 moved it 8 → 4 on exactly this reasoning.)

2 rather than 1 or 0: all three are indistinguishable in footprint for every shape in the
perf corpus — a two-field literal allocates two slots under all of them — so 2 is the value
that keeps the most inline headroom for a dynamically-grown {} at zero byte cost.

Codegen's copy of the constant was two separately-spelled 4s held together by a comment.
This moves both to target_layout::INLINE_SLOT_FLOOR, paired with the runtime by
inline_slot_floor_matches_runtime / inline_slot_floor_matches_codegen — the same
mechanism PIC_CACHE_WORDS already uses. The two consumers fail in opposite directions
(the inline-new allocator under-allocates if codegen is low; the emitted bounds checks
over-read if codegen is high), so equality is required, not conservatism either way.

Footprint result

shape before after
{} / 1-field / 2-field literal 72 56
3-field 72 64
≥4 fields unchanged unchanged

retain now writes 168 MB instead of 216 MB for the same 48 MB of doubles —
amplification 4.5x → 3.5x.

Peak RSS (bit-exact run to run, so these are not estimates):

bench before after Δ
tree 45 203 456 36 782 080 −18.6%
retain 308 002 816 260 947 968 −15.3%
retain1 104 448 000 91 045 888 −12.8%
deeplist 87 932 928 77 971 456 −11.3%
everything else ≤0.4%

retain_wide is untouched, exactly as the accounting predicts: at 8 fields the floor does
not apply and the whole overhead is the two headers.

★ The catch, and it is the more valuable finding

retain1 and deeplist retire 12–14% more instructions. It is not mutator cost — the
mutator is uniformly cheaper. The normalised --trace llvm diff has exactly four changes:
the two alloc-size constants 72→56, the packed GcHeader word, two deleted slot inits, and
the two PIC bound literals 42. Nothing else.

The per-cycle GC trace explains all of it. Every minor in both arms fires at the same byte
mark and processes the same bytes
:

retain1 minor bytes, before → after objects, before → after
1 17 694 064 → 17 694 216 245 752 → 315 969
2 18 742 672 → 18 742 816 260 316 → 334 694
3 34 601 760 → 34 602 008 480 580 → 617 893

The object ratio is 1.286 = 72/56, exactly; deeplist reproduces it to three decimals.
GC pause retain1 39.60 → 50.10 ms, and that +10.5 ms exceeds the program's whole cycle
delta (25.2 M cycles ≈ 7.9 ms) — the mutator got faster and the collector got slower.
Per extra promotion: 10.5 ms / 211 691 objects = 49.6 ns, the unchanged per-object promotion
price. Nothing got more expensive; the same byte budget simply now contains 28.6% more
objects.

The collector's trigger is denominated in bytes; its cost is denominated in objects. That
generalises well past this PR: every future object-shrinking change is taxed back until the
nursery/promotion budgets carry an object-count term. Filed as #7929, alongside #7715 and
#7432 (the adaptive-tenuring valve that reads eden_live_bytes here).

Two things that make it less alarming than the percentages look:

  • Total promotion work is set by the surviving object count (1 M either way), so a program
    that runs to completion pays it once regardless. retain1 promotes 740 896 of 1 M objects
    before, 952 587 after — the change pulls ~211 k promotions forward into the measurement
    window rather than creating them. On an exit-bounded microbenchmark that reads as a
    slowdown.
  • The rest of the corpus moves the other way: churn −1.2%, churn_alloc −1.4%,
    push_cls −1.4%, tree −0.8% instructions (with −18.6% RSS), and −5.7…−9.0% cycles on
    those rows.

Wall clock is deliberately not quoted — measured on the dev box at load 30–200, where it
cannot resolve 5%. RSS is bit-exact and instructions reproduce to 0.07%, so those are the
numbers here; the quiet-mini verdict is the maintainer's call.

Validation

  • Corpus 19/19 byte-exact vs node --experimental-strip-types v26.5.1, exit 0.
  • Same 19/19 under PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1 — a layout
    change is GC-visible, so this is not optional.
  • iso_miss canary: checksum 437840 misses 0.
  • Gap suite, 548 tests: zero regressions attributable to this branch. The runner reports
    11 pass -> parity_fail/crash against test-parity/gap_snapshot.json. All 11 were
    A/B'd against a second compiler built from origin/main @ ebefba51a, and all 11 produce
    byte-identical stdout and identical exit codes on both
    — they are standing failures on
    main (parity has been tag-gated since v0.5.1018). Filed: gap suite red on main: typed array constructed with a fractional length reports .length === undefined (test_gap_3146, test_gap_4103) #7930 (typed array constructed
    with a fractional length reports .length === undefinedtest_gap_3146_spec_throws,
    test_gap_4103_typedarray_view_validation) and gap suite red on main: test_gap_fetch_request_from_node_incoming_message aborts with 'there is no reactor running' (perry-ext-http/server.rs:911) #7932 (six HTTP/net tests abort with
    there is no reactor running at perry-ext-http/src/server/server.rs:911). The remaining
    three (gc_ta_ctor_source_rooting, specabi_reassign, zlib_3285_params) are likewise
    identical on both arms.
  • cargo test --release -p perry-codegen -p perry-runtime --no-fail-fast: all green
    perry-codegen 915 passed / 0 failed, perry-runtime 2192 passed / 0 failed plus the
    sub-suites. Two typed_shape_bake_tests (perf(codegen): stamp a pointer-free shape's typed layout into the allocation header #7834) asserted the packed GcHeader word as a
    hard-coded literal that encodes size = 72; they now derive it from INLINE_SLOT_FLOOR
    so they keep asserting the thing they exist for (whether GC_OBJ_TYPED_LAYOUT_INTACT is
    claimed) instead of the incidental footprint.
  • 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;
    by_name_growth_past_the_floor_reads_back pins that the inline/overflow boundary stays
    invisible to reads; the two inline_slot_floor_matches_* tests pin the cross-crate pair.
  • cargo fmt --all -- --check and scripts/check_file_size.sh clean.

Not closed

retain_wide and every ≥4-field object: their overhead is entirely the 40 bytes of header.
keys_array (8 B, derivable from the ShapeId already in parent_class_id), object_type
(4 B, constant), and field_count (4 B, a shape property) are the 16 bytes that a
hidden-class layout would remove, taking {a, b} to 32 bytes (2.0x) and retain_wide to 80
(1.25x). That needs every codegen offset (0/4/8/12/16 plus
target_layout::object_header_size_bytes) to move together and is a separate project —
sketched in gc-handoff/REPR-NOTES.md §4 rather than rushed in here.

Summary by CodeRabbit

  • Performance

    • Reduced inline object storage from four slots to two, lowering allocation footprints while preserving alignment and runtime behavior.
  • Bug Fixes

    • Improved property access for objects with fields beyond the inline storage limit.
    • Prevented truncation issues when handling objects with overflow properties.
  • Tests

    • Added coverage for allocation sizing, overflow-field access, layout consistency, and shape handling.
  • Documentation

    • Updated technical documentation and regression-test comments to reflect the revised inline storage limit.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 83956e6c-2b4d-4856-8bc7-22e93e211780

📥 Commits

Reviewing files that changed from the base of the PR and between 14e291f and aebfcaa.

📒 Files selected for processing (13)
  • changelog.d/7928-inline-slot-floor.md
  • crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
  • crates/perry-codegen/src/expr/property_get/tests.rs
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/src/lower_call/new_alloc.rs
  • crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs
  • crates/perry-codegen/src/target_layout.rs
  • crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs
  • crates/perry-runtime/src/json/mod.rs
  • crates/perry-runtime/src/json/stringify_shape_template.rs
  • crates/perry-runtime/src/object/alloc.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/tests.rs

📝 Walkthrough

Walkthrough

The inline slot floor changes from 4 to 2. Runtime and codegen constants now share target-layout values. Allocation, property bounds, typed-shape tests, and regression documentation use the synchronized floor.

Changes

Inline slot floor reduction

Layer / File(s) Summary
Shared floor contract and layout checks
crates/perry-codegen/src/target_layout.rs
Defines numeric and IR literal forms of INLINE_SLOT_FLOOR and tests their synchronization and allocation alignment.
Runtime floor and allocation validation
crates/perry-runtime/src/object/*, crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs, crates/perry-runtime/src/json/*, changelog.d/7928-inline-slot-floor.md
Reduces the runtime floor to 2, updates allocation documentation, and adds tests for object footprint, synchronization, and by-name access beyond the inline boundary.
Codegen consumers and shape-layout tests
crates/perry-codegen/src/expr/property_get/*, crates/perry-codegen/src/expr/proxy_reflect.rs, crates/perry-codegen/src/lower_call/*
Replaces hardcoded floor values in allocation, reflection, and cached-slot bounds logic. Typed-shape tests compute header words from target-layout parameters.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • PerryTS/perry#6712: Both changes synchronize runtime and codegen inline-slot floor behavior.
  • PerryTS/perry#7834: Both changes modify allocator and typed-shape allocation test areas.

Suggested labels: performance

Suggested reviewers: thehypnoo

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7916-inline-slot-floor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug marked this pull request as ready for review August 12, 2026 08:32
@proggeramlug
proggeramlug merged commit f110261 into main Aug 12, 2026
0 of 19 checks passed
@proggeramlug
proggeramlug deleted the perf/7916-inline-slot-floor branch August 12, 2026 08:34
proggeramlug added a commit that referenced this pull request Aug 12, 2026
* fix(gc): denominate the nursery constant band in objects (#7929)

The scavenge nursery trigger compares from-space BYTES against a constant
16 MB band, while the copying minor's cost is per OBJECT. Shrinking a
representation therefore silently buys the collector more work per cycle:
#7928 took a two-field object literal 72 B -> 56 B and every minor then
moved 1.286x (= 72/56) as many objects for the same bytes.

Scale the constant band by the mean size of the objects the last copying
minor actually moved, so the band buys a constant OBJECT budget. The mean
comes from the census the collector already produces, so nothing is added
to the allocation fast path.

The scaling is one-sided (clamped at 1.0): a mean above the reference keeps
today's band. That is what neutralises an array-dominated mean, and it
leaves every program at or above the reference bit-identical.

The two tenuring ratios are representation-invariant by cancellation, so
only the constant band is re-denominated.

* docs(changelog): add fragment for #7961

* fix(gc): compute the object-denomination scale in u64 for ILP32 targets

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant