chore: merge train 246 (v0.5.1625) - #10850
Merged
Merged
Conversation
A read whose key lives on the prototype chain missed every cache in the
runtime and re-walked the chain from scratch, every time. Callgrind on
`const P={a:1}; const O=Object.create(P); O.a`, 200 k reads, v0.5.1619:
~1300 instructions per read, against 130 for the same read of an OWN key
and 16 in node, which charges nothing for the inheritance.
Where the 1300 went, per read:
native_get::try_data_get_bytes self 344
class_prototype_object 192 (118 of it SipHash)
keys_find_slot_by_bytes_resolved x2 180
ic_miss::get_field_ic_miss_impl self 153
has_property::closure_dynamic_prop_by_key 90
shapes::shape_descriptor_by_id 70
is_anon_shape_class_id 54
class_decl_prototype_object 41
core::str::from_utf8 38
is_arguments_object 28
Not one instruction of it is the `OBJECT_PROTOTYPES` mutex or a probe of
it: #6759 phase B already took every `GC_TYPE_OBJECT` off that table, and
the prototype of an `Object.create` receiver comes from the class
registry. The 2026-09-20 design note guessed otherwise; the measurement
is recorded in `prototype_chain.rs`'s header, together with the kinds
that still have nowhere else to store a prototype, so the next reader
does not re-plan on the guess.
This adds `object::inherited_read_cache`: a per-thread, direct-mapped
512-entry table from (receiver class id, receiver ShapeId, interned key)
to (chain, holder, inline slot). A hit re-proves the claim with the
semantic property epoch, the receiver's identity word, its recorded
prototype bits, and one ShapeId compare per hop.
The two invalidation mechanisms are both load-bearing and neither
subsumes the other:
* `prop_plan_semantic_epoch()` -- Perry's existing enforced funnel for
"what a lookup would ANSWER" changes: descriptor installs and clears,
`delete`, prototype recording, class-prototype registration. It is
not bumped by GC, which keeps this cache off the #7910 cliff where
keying on the full epoch degrades a cache into a recompute.
* a per-hop ShapeId compare -- a plain `proto.b = 1` is not a
descriptor install and does not bump the epoch, but it is a key-add
transition on that hop. Disabling this compare fails exactly one test
(`adding_a_key_to_the_prototype_invalidates_through_the_hop_shape`),
which is how it is known to be load-bearing rather than decorative.
GC contract. A hit LOADS `holder + slot`, so unlike the transition cache
(#6759 phase 3) this table cannot hold its addresses weakly: a slot the
death prune has not yet reached would be a read of recycled memory
returning a wrong value, not a miss. The root scan therefore MARKS and
rewrites every key and hop, which bounds retention at 512 keys and
512 x 4 prototypes and makes a stale address unrepresentable; the death
prune is registered in `DEAD_KEY_PRUNES` as the backstop that registry
exists to enforce. An earlier revision instead refused to prime a
nursery hop: it is correct and it is useless, because a read-only loop
allocates nothing, so nothing is ever promoted, so every prototype stays
in the nursery for the life of the process. Measured cost of that
refusal, on this fixture: +264 instructions per inherited read for a
chain walk performed and then thrown away.
Refusals are recorded too, and that is not a detail: most reads that
reach this cache are ones it cannot serve. Recording only the hits made
an accessor on the prototype 424 instructions per read SLOWER than no
cache at all, because the chain walk ran and was discarded on every
read. A declining walk therefore writes a NEGATIVE entry and the lookup
answers `Declined`, which tells the miss handler not to walk. A negative
entry can never return a wrong value, only keep a read on the path it is
already on, so it reuses the hit's identity, epoch and per-hop compares
unchanged. The one refusal NOT recorded is one caused by a VALUE (an
`undefined`, `null` or hole in the holder's slot): a plain store can
replace it while transitioning no shape and bumping no epoch, so
remembering it would decline the pair for the life of the process.
Hook points are three one-call sites -- the hit at the top of
`js_object_get_field_by_name` and inside `get_field_ic_miss_impl`, and
the prime at the one place in the runtime that already knows the key is
not an own property. `js_inherited_read_cache_hit_f64` is the same hit
as an emitted-code entry point for lane 4. `PERRY_INHERITED_IC=0` turns
the cache off so one binary can be measured both ways.
A cache that primes and then declines every lookup returns exactly the
values the chain walk would and is invisible in a program's output, so
the hit COUNT is reported two ways: `js_inherited_read_cache_stats` for
a harness, and an `inherited: hits=/primes=/declines=` row added to the
existing `[ic-diag]` report, on the existing `PERRY_IC_DIAG` arming
rather than an instrument of its own.
21 runtime tests cover the invalidation cases (shadowing own key added
and deleted, key added/deleted/redefined-as-accessor on the prototype,
`setPrototypeOf` on the receiver and on an interior prototype, null
prototype, a Proxy in the chain, accessor on the prototype, undefined
holder slot, a nursery prototype, death of a holder and of a key, and
the three properties of a negative entry) plus two GC tests for the root
scan's rewrite and its registration, and a differential fixture against
node.
That fixture found one divergence it does not assert: perry answers
`o.a` after `Object.setPrototypeOf(o, null)` from the prototype `o` was
BORN with, whenever `o` came from `Object.create(p)` or `new C()`. It is
pre-existing -- `PERRY_INHERITED_IC=0` prints the same wrong value, this
cache declines the case -- and is filed as #10827, with the reason the
case is absent written at the point in the fixture where it belongs.
#10834 adds one reg_scanner! line to gc/mod.rs, which is a pinned source for the census snapshot's window contract, so the holders gate refused the tree. Both of the PR's collector-side additions sit outside the mark-complete -> sweep-entry window, on opposite sides of it: the scanner runs in the RootScan phase, before census_pass1_if_armed at the end of step_mark_propagation; the new DEAD_KEY_PRUNES entry runs from with_dead_collection_finalize at cycle.rs:1548, after census_take_if_armed_at_full_sweep_start at cycle.rs:1505 has taken the snapshot out of the thread-local. The other four pinned sources were re-hashed and asserted unchanged before advancing this one, so the re-audit cannot be silently covering a second change.
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (16)
✨ 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 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.
Merge train 246 — one PR and the GC-pin re-audit it forced. Released as v0.5.1625.
Contents
perf(runtime): give an INHERITED property read an inline-cache hit (1364 → 278 instructions)ci(gc): re-audit and advance thePASS1_MARKEDwindow pin (below)The change
A read whose key lives on the prototype chain missed every cache in the runtime and re-walked the chain from scratch, every time. On tsc compiled by perry (7.9 s run) 523 k such reads were 7.2 % of the run.
Per loop iteration,
perf stat -e instructions:u, min of 3, fitted 200 k → 5 M, one binary with the cache on and off, output identical to node on every row:Object.createdataC.prototype.a = 1Marginal cost of the inheritance — each fixture minus its own-read twin — goes from 1364 to 278 on one hop, 2322 → 296 on three.
The accessor row is the honest cost and is recorded because it is the one that regresses: +51 instructions per read, since such a read still reaches the miss handler and pays a lookup that answers "declined".
Two invalidation mechanisms, neither subsuming the other:
prop_plan_semantic_epoch()for anything that changes what a lookup would answer, and a per-hop ShapeId compare for a plainproto.b = 1, which is a key-add transition but bumps no epoch. Disabling the second fails exactly one test — which is how it is known to be load-bearing rather than decorative.The pin the train had to clear
gc_runtime_root_holders.pyrefused the tree:PASS1_MARKEDis the census's mark-phase snapshot — aVec<usize>of real GC header addresses, deliberately untraced so the diagnostic does not keep its own subjects alive. It is populated bycensus_pass1_if_armedat the end of mark propagation and consumed bycensus_take_if_armed_at_full_sweep_startat sweep entry, inside onerun_to_completion. The contract is that nothing in between allocates a GC object, relocates, collects, or runs a JS callback, and the gate pins the sha256 of the five files that could break it.The pin was advanced, not re-baselined. #10834 touches two relevant places and both sit outside the window, on opposite sides of it:
gc/mod.rsgains exactly one line — areg_scanner!for the cache's root scanner. Mutable-root scanners are consumed insideRootScanCycleState::step_current_subphase, entirely within the RootScan phase:step_root_scanonly advances toGcCyclePhase::MarkPropagationonce that loop reports done (gc/cycle.rs:958-961), andcensus_pass1_if_armed()fires at the end ofstep_mark_propagation(gc/cycle.rs:982). The scanner runs strictly before the window opens. Its body is a bounded walk of a fixed 512-entry thread-local callingvisit_tagged_usize_slot/visit_usize_slot— no allocation, no relocation, no JS. Same shape already cleared for perf(intl): one shared shape for Intl.Segmenter records — removes the program's largest allocation category #9769, perf(gc): prune the per-object layout tables from a young-entry log (replay of #9895) + a residue histogram — 4 ms per steady minor on cc #9976/diag(regex): regex tables in the SIGUSR2 heap census — reconciled rows for pointers, caches, literal sites and the site table (0.64 MB on cc; the RX2 +6 MB was descriptor-map capacity on a superseded head) #9977, perf(string): trim scans and copies the entire interior for constant-size edge whitespace #10054, perf(string): non-ASCII charCodeAt and bracket scans become quadratic #10055 and require.main === module is true in EVERY compiled CommonJS module, so any package with a CLI entry guard runs its CLI branch when merely imported #10735.gc/dead_owner.rs(not a pinned source) gains anINHERITED_READ_CACHEentry inDEAD_KEY_PRUNES. That registry is consumed byIncrementalSweepState::with_dead_collection_finalizeatgc/cycle.rs:1548— aftercensus_take_if_armed_at_full_sweep_startatgc/cycle.rs:1505has alreadytake()n the snapshot out of the thread-local. Same argument that cleared perf(regex): allocate the RegExp header in the nursery, not the malloc arm #9845'scollect_dead_registered_regexps_post_trace.Before advancing the one digest, the other four pinned sources were re-hashed and asserted unchanged — so this re-audit cannot be silently covering a second change. The note is appended to the entry's existing audit trail rather than replacing it, and the edit is two lines: the file was not reformatted, which matters because a reformat would have buried the real change in a 104-line diff.
What the gate did NOT refuse, and why that is the gate working
INHERITED_READ_CACHEis itself a thread-local holding raw heap addresses — exactly the "runtime-side cache of a raw heap pointer is a GC root, and the static checker cannot see it" hazard. It required no inventory verdict, because thereg_scanner!registration puts it inside the call-graph walkgc_runtime_root_holders.pyperforms. The holder is reached by a registered scanner, so the gate classifies it automatically.The scanner MARKS rather than merely rewriting, deliberately: a hit LOADS
holder + slot, so a weak slot the death prune has not yet reached is a read of recycled memory returning a wrong value, not a miss. Retention is bounded by the table at 512 keys and 512 × 4 prototypes.Validation
Assembled on
91cc563573; source head asserted fresh; cherry-picked clean onto amainthat already carries train 244's work in the sameic_miss.rs; no attribution trailers. Ten cheap gates green,cargo check --workspace --all-targetsunder-D warnings, all five pinned artifacts byte-identical before and after the gap sweep.lintcomplete at 6-of-6 compile commands,lint_unexpected_failures=[]cor_native-region-proof/cor_native-abi-proofbothfailed_workloads=[]repsel_census rc=0 wasted_promotion=Falsesecurity_audit: the single tracked RUSTSEC-2026-0285 (security: cargo audit fails on RUSTSEC-2026-0285 (rustls 0.23.44) — the fix is unblocked by the soak window on 2026-09-21 #10791)Gap sweep at
PERRY_RUN_TIMEOUT=30, seven areas weighted to the prototype/property surface this change is about. 268 fixtures, every area asserted live, zero unexplained regressions: