perf(runtime): right-size small objects — INLINE_SLOT_FLOOR 4 -> 2 (#7916, #7714) - #7928
Merged
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (13)
📝 WalkthroughWalkthroughThe 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. ChangesInline slot floor reduction
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested labels: Suggested reviewers: ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
This was referenced Aug 12, 2026
proggeramlug
marked this pull request as ready for review
August 12, 2026 08:32
This was referenced Aug 12, 2026
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>
This was referenced Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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— occupies72 bytes to store 16 bytes of payload. Byte for byte, with no guesswork:
GcHeaderobj_type/gc_flags/_reserved/size72ObjectHeaderobject_type: u32OBJECT_TYPE_REGULAR— constant for every plain objectclass_id: u320for every object literalparent_class_id: u32field_count: u322— a property of the shapekeys_array: *mut ArrayHeadermeta: *mut ObjectMetaundefined, forever —INLINE_SLOT_FLOOR = 4Alignment and capacity rounding contribute zero.
ObjectHeaderis#[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)finds8 + 64already a multiple of 8. Every one of the 56non-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% isGcHeader.Full write-up, including the projection for the header itself:
gc-handoff/REPR-NOTES.md.The change
INLINE_SLOT_FLOOR4 → 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 theinline/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 thelimit the value spills to overflow storage and
field_countis deliberately not bumped.So
alloc_limitis a fixed point of the allocation and can never grow past the physical slotcount, 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 byinline_slot_floor_matches_runtime/inline_slot_floor_matches_codegen— the samemechanism
PIC_CACHE_WORDSalready uses. The two consumers fail in opposite directions(the inline-
newallocator under-allocates if codegen is low; the emitted bounds checksover-read if codegen is high), so equality is required, not conservatism either way.
Footprint result
{}/ 1-field / 2-field literalretainnow 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):
retain_wideis untouched, exactly as the accounting predicts: at 8 fields the floor doesnot apply and the whole overhead is the two headers.
★ The catch, and it is the more valuable finding
retain1anddeeplistretire 12–14% more instructions. It is not mutator cost — themutator is uniformly cheaper. The normalised
--trace llvmdiff has exactly four changes:the two alloc-size constants 72→56, the packed
GcHeaderword, two deleted slot inits, andthe two PIC bound literals
4→2. 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:
retain1minorThe object ratio is 1.286 = 72/56, exactly;
deeplistreproduces it to three decimals.GC pause
retain139.60 → 50.10 ms, and that +10.5 ms exceeds the program's whole cycledelta (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_byteshere).Two things that make it less alarming than the percentages look:
that runs to completion pays it once regardless.
retain1promotes 740 896 of 1 M objectsbefore, 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.
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 onthose 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
node --experimental-strip-typesv26.5.1, exit 0.PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1— a layoutchange is GC-visible, so this is not optional.
iso_misscanary:checksum 437840 misses 0.11
pass -> parity_fail/crashagainsttest-parity/gap_snapshot.json. All 11 wereA/B'd against a second compiler built from
origin/main@ebefba51a, and all 11 producebyte-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 constructedwith a fractional length reports
.length === undefined—test_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 withthere is no reactor runningatperry-ext-http/src/server/server.rs:911). The remainingthree (
gc_ta_ctor_source_rooting,specabi_reassign,zlib_3285_params) are likewiseidentical 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 packedGcHeaderword as ahard-coded literal that encodes
size = 72; they now derive it fromINLINE_SLOT_FLOORso they keep asserting the thing they exist for (whether
GC_OBJ_TYPED_LAYOUT_INTACTisclaimed) instead of the incidental footprint.
two_field_literal_footprint_is_exactly_accountedreads the size theallocator recorded in
GcHeader::sizerather than recomputing the formula, so it failsif any allocation path stops honouring the floor;
by_name_growth_past_the_floor_reads_backpins that the inline/overflow boundary staysinvisible to reads; the two
inline_slot_floor_matches_*tests pin the cross-crate pair.cargo fmt --all -- --checkandscripts/check_file_size.shclean.Not closed
retain_wideand every ≥4-field object: their overhead is entirely the 40 bytes of header.keys_array(8 B, derivable from the ShapeId already inparent_class_id),object_type(4 B, constant), and
field_count(4 B, a shape property) are the 16 bytes that ahidden-class layout would remove, taking
{a, b}to 32 bytes (2.0x) andretain_wideto 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
Bug Fixes
Tests
Documentation