Skip to content

perf(gc): stop the globalThis bootstrap from disabling the per-object layout fast path - #7809

Draft
proggeramlug wants to merge 1 commit into
mainfrom
perf/7796-globalthis-bootstrap-layout-latch
Draft

perf(gc): stop the globalThis bootstrap from disabling the per-object layout fast path#7809
proggeramlug wants to merge 1 commit into
mainfrom
perf/7796-globalthis-bootstrap-layout-latch

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What

Touching globalThis — which any plain-object or array property miss
does, via builtin_prototype_valuejs_get_global_this_builtin_value — used
to permanently disable the per-object GC slot-layout fast path for the rest of
the process. Adding a single for (const _x of [1]) {} to main() cost churn
+28% and tree +29%.

Perry's whole benchmark corpus happens never to take that path (no for…of, no
spread, no Symbol, no property miss anywhere in
churn/tree/interp/shapes/asyncpipe/retain), so the cost was
invisible 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 churn with
and 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, so PER_OBJECT_LAYOUTS_NONEMPTY
— the emptiness proof that keeps layout_forget_object off the allocation,
death and relocation paths — could never go false again.

Symbolicated layout_forget_object self time:

bench no bootstrap bootstrap with this PR
churn + for…of 23 ms 321 ms 45 ms

Two changes, both needed

  1. gc::ImmortalLayoutScope around populate_global_this_builtins.
    Objects built inside it declare GC_LAYOUT_UNKNOWN (the tag-checked payload
    scan — 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.

  2. 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:

    churn push_cls tree interp churn+for…of tree+for…of
    base 0.422 0.356 1.627 1.888 0.539 2.151
    filter only (flag dropped) 0.438 0.383 1.673 1.934 0.500 1.857
    flag + filter, 2 slots 0.421 0.368 1.642 1.950 0.506 1.886
    flag + filter, 1 slot 0.422 0.368 1.640 1.922 0.493 1.840

    Dropping 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_cls went past budget). Two separate thread-locals cost a second
    _tlv_get_addr on the workloads that ARE armed. One struct behind the
    existing 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
globalThis bootstrap at 1000× the scale.

Tests

Five tests in gc::tests::layout_trace::per_object_tables, written so none can
pass vacuously:

  • the bootstrap leaves both tables empty — with a subject-live check that
    globalThis.Array actually populated;
  • the same store outside a scope still mints a mask, so the scoped test
    cannot pass by the shape no longer reaching that branch at all;
  • an object built inside a scope still traces its children through the fallback
    scan;
  • a live record survives a filter rebuild and is still found and removed;
  • the filter still proves unrelated addresses absent while the global flag is
    armed
    — the exact condition under which the accelerator silently stopped
    accelerating before, and which no existing test could observe.

PERRY_GC_DIAG=1 now 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)

bench base this PR
churn + for…of 0.539 0.493
tree + for…of 2.151 1.840
churn (floor) 0.422 0.422
tree (floor) 1.627 1.640

Every protected bench stays inside budget (churn 0.422, churn_alloc 0.375,
push_cls 0.368, push_num 0.143, churn_read 0.022, cycles 0.194,
deeplist 0.245, tree 1.640, tree_wide 2.113, retain 0.536,
retain_wide 1.092, fib40 0.393, interp 1.922, shapes 0.226,
asyncpipe 0.716). Outputs byte-identical to node with exit 0; canary
iso_miss prints checksum 437840 misses 0; clean under
PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 and
PERRY_GC_VERIFY_EVACUATION=1 with retired_set counts > 0 on the copying
benches. Gap suite: no new failures — the 8 rows the gate reports reproduce
identically on clean main.

interp 1.888 → 1.922 and iso_miss 2.361 → 2.443 are the honest cost: both
are 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≈6100 under load; ~4.4 ms on a quiet
host) is untouched — it is ~3000 set_builtin_property_attrs calls, each a
String allocation plus a (usize, String) hash insert. Two of the three per
installed method are the identical name/length descriptor on a builtin
closure 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_descriptors on every cycle
(~17 ms on churn). Same shape as the latch — immortal data on a per-cycle
path — and worth its own change.

@proggeramlug
proggeramlug force-pushed the perf/7796-globalthis-bootstrap-layout-latch branch from 276c636 to c97f4a9 Compare August 10, 2026 22:36
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ad8cc3d-c514-4c63-b8d4-0999fbde201a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

… 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
proggeramlug force-pushed the perf/7796-globalthis-bootstrap-layout-latch branch from c97f4a9 to ba19360 Compare August 10, 2026 23:30
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