perf(gc): stop the globalThis bootstrap from disabling the per-object layout fast path - #7809
Draft
proggeramlug wants to merge 1 commit into
Draft
perf(gc): stop the globalThis bootstrap from disabling the per-object layout fast path#7809proggeramlug wants to merge 1 commit into
proggeramlug wants to merge 1 commit into
Conversation
proggeramlug
force-pushed
the
perf/7796-globalthis-bootstrap-layout-latch
branch
from
August 10, 2026 22:36
276c636 to
c97f4a9
Compare
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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 |
… layout fast path Any plain-object or array property MISS forces the lazy `globalThis` bootstrap, and the bootstrap left 1113 immortal entries in the per-object GC slot-layout side tables. `PER_OBJECT_LAYOUTS_NONEMPTY` — the emptiness proof that keeps `layout_forget_object` off the allocation, death and relocation paths — could then never go false again, so every real TypeScript program ran the full two-map probe on every allocation. Measured: `churn` +28%, `tree` +29% from one `for…of` in `main()`, with `layout_forget_object` self time 112 -> 916 ms and 194 -> 740 ms. It is not the ~1.15 MB the bootstrap allocates and it is not GC pacing: 105 minors on `churn` either way, ~616 KB more copied over the whole run. Two changes, both load-bearing: * `gc::ImmortalLayoutScope` around `populate_global_this_builtins` — objects built inside it declare `GC_LAYOUT_UNKNOWN` (the tag-checked scan the code already falls back to for the same case) instead of minting a mask nothing will ever remove. Residue 1113 -> 0. NOT applied to typed-shape layouts, whose raw-f64 slots a conservative scan would misread as pointers and, under the copying collector, rewrite. * An 8192-bit thread-local address filter replacing the global flag as the hot guard. The scope alone moved nothing measurable: ordinary runtime init still leaves one or two immortal records, and for a single global bit two entries are exactly as bad as 1113. The filter answers "can THIS ADDRESS have an entry" instead. It replaces rather than joins the flag because testing both cost a second thread-local resolution on legitimately-armed workloads (+3.4% interp, +4.6% iso_miss). Five tests, none able to pass vacuously: the bootstrap leaves the tables empty (with a subject-live check on `globalThis.Array`); the same store outside a scope still mints a mask; a scoped object still traces its children; a live record survives a filter rebuild; and the filter still proves unrelated addresses absent while the global flag is armed. Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
proggeramlug
force-pushed
the
perf/7796-globalthis-bootstrap-layout-latch
branch
from
August 10, 2026 23:30
c97f4a9 to
ba19360
Compare
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.
What
Touching
globalThis— which any plain-object or array property missdoes, via
builtin_prototype_value→js_get_global_this_builtin_value— usedto permanently disable the per-object GC slot-layout fast path for the rest of
the process. Adding a single
for (const _x of [1]) {}tomain()costchurn+28% and
tree+29%.Perry's whole benchmark corpus happens never to take that path (no
for…of, nospread, no
Symbol, no property miss anywhere inchurn/tree/interp/shapes/asyncpipe/retain), so the cost wasinvisible to the perf campaign while real TypeScript programs paid it before
their first line of work.
The mechanism (not what it looked like)
It is not the ~1.15 MB the bootstrap allocates, and it is not GC pacing.
GC behaviour is effectively identical either way — 105 minors on
churnwithand without, ~616 KB more copied across the entire run.
It is a global latch. The bootstrap builds hundreds of permanently-rooted
plain objects; each one's first pointer field minted an entry in
LAYOUT_SLOT_MASKS. Those entries are immortal, soPER_OBJECT_LAYOUTS_NONEMPTY— the emptiness proof that keeps
layout_forget_objectoff the allocation,death and relocation paths — could never go
falseagain.Symbolicated
layout_forget_objectself time:churn+for…ofTwo changes, both needed
gc::ImmortalLayoutScopearoundpopulate_global_this_builtins.Objects built inside it declare
GC_LAYOUT_UNKNOWN(the tag-checked payloadscan — the code's own fallback for the same situation, and the universally
safe state) instead of minting a mask nothing will ever remove.
Residue: 1113 entries → 0.
Deliberately not applied to typed-shape layouts: those describe raw-f64
slots, whose bit patterns can alias a heap pointer, and a conservative scan
would trace — and under the copying collector rewrite — a slot holding a
number.
An address filter, co-located with the flag in one thread-local.
Change 1 alone moved nothing measurable, which is the important finding:
ordinary runtime init still leaves one or two long-lived records, and for a
single global bit two entries are exactly as bad as 1113. A 4096-bit filter
turns "is either table empty?" into "can this address have an entry?", so
a nursery address the tables have never seen is proved absent in one
multiply and one load even while immortal records exist elsewhere.
All three arrangements were measured on the quiet mini:
churnpush_clstreeinterpchurn+for…oftree+for…ofDropping the flag loses: almost every workload is disarmed, and for those
the flag is one load where the filter is a multiply, a shift, a load and a
test (
push_clswent past budget). Two separate thread-locals cost a second_tlv_get_addron the workloads that ARE armed. One struct behind theexisting named hot slot gives both.
This is #7510's lesson repeating ("one immortal entry nullifies an is-empty
accelerator") — there it was a single interned keys array, here it is the
globalThisbootstrap at 1000× the scale.Tests
Five tests in
gc::tests::layout_trace::per_object_tables, written so none canpass vacuously:
globalThis.Arrayactually populated;cannot pass by the shape no longer reaching that branch at all;
scan;
armed — the exact condition under which the accelerator silently stopped
accelerating before, and which no existing test could observe.
PERRY_GC_DIAG=1now prints[gc-globalthis-bootstrap] elapsed_us=… per_object_slot_masks=… per_object_typed_layouts=…once per thread, so the residue is observable rather than inferred.
Measured (quiet mini, base
b9415d780, both arms built locally, interleaved best-of-5, exit-checked)churn+for…oftree+for…ofchurn(floor)tree(floor)Every protected bench stays inside budget (
churn0.422,churn_alloc0.375,push_cls0.368,push_num0.143,churn_read0.022,cycles0.194,deeplist0.245,tree1.640,tree_wide2.113,retain0.536,retain_wide1.092,fib400.393,interp1.922,shapes0.226,asyncpipe0.716). Outputs byte-identical to node with exit 0; canaryiso_missprintschecksum 437840 misses 0; clean underPERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800andPERRY_GC_VERIFY_EVACUATION=1withretired_setcounts > 0 on the copyingbenches. Gap suite: no new failures — the 8 rows the gate reports reproduce
identically on clean
main.interp1.888 → 1.922 andiso_miss2.361 → 2.443 are the honest cost: bothare legitimately armed, so the filter never proves absence for them and they
pay the test without the benefit.
Not fixed here
The bootstrap's own fixed cost (
elapsed_us≈6100under load; ~4.4 ms on a quiethost) is untouched — it is ~3000
set_builtin_property_attrscalls, each aStringallocation plus a(usize, String)hash insert. Two of the three perinstalled method are the identical
name/lengthdescriptor on a builtinclosure that already self-identifies as one, which is the obvious next lever.
A second residue is per-collection: the bootstrap's ~1100 closures and ~3000
descriptors are immortal but are re-scanned by
scan_closure_dynamic_props_roots_mut/
scan_descriptor_roots_mut/prune_dead_descriptorson every cycle(~17 ms on
churn). Same shape as the latch — immortal data on a per-cyclepath — and worth its own change.