Skip to content

Merge train: 10 PRs (#9755, #9801–#9813) + gate follow-ups - #9817

Merged
proggeramlug merged 27 commits into
mainfrom
train126
Sep 5, 2026
Merged

Merge train: 10 PRs (#9755, #9801–#9813) + gate follow-ups#9817
proggeramlug merged 27 commits into
mainfrom
train126

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Merge train: 10 PRs#9755, #9801, #9802, #9803, #9805, #9806, #9809, #9811, #9812, #9813 — plus gate follow-ups.

Gate work

  • PASS1_MARKED's non_moving_snapshot window re-pinned after perf(gc): young-entry logs for the side-table root scanners #9755 restructured gc/cycle.rs — the pin's own owner file, so this needed a real audit rather than a bump. perf(gc): young-entry logs for the side-table root scanners #9755's hunks are all root-scan machinery (RootScanSubphase, RootScanCycleState, the mutable-scanner iteration state), which runs before mark propagation completes, and gc/mod.rs gains only a mod young_log; declaration. The bracketing is unchanged — census_pass1_if_armed inside step_mark_propagation, census_take_if_armed_at_full_sweep_start inside step_sweep — and a synchronous full mark-sweep still moves nothing between them.
  • TRANSITION_CACHE_YOUNG and SHAPE_CACHE_YOUNG classified. Both are YoungLog<u32> remembered sets holding transition-cache slot indices and shape-cache ids — a u32 cannot hold a 48-bit pointer — and the pointer-bearing entries they index are visited by registered scanners.
  • Shape-descriptor callsite baseline updated for perf(gc): young-entry logs for the side-table root scanners #9755's relocation of visit_raw_mut_ptr_slot(&mut entry.keys_array) into the new object/side_table_roots.rs.
  • regex.rs crossed the 2000-line cap by one line, so the replaceAll / matchAll non-global receiver guards moved to regex/global_guards.rs, gated on regex-engine like their siblings. Both feature configurations were checked — a --features regex-engine build passes while the default one fails when a moved item sits behind that gate.
  • fix(gc-ratchet): apply documented counter exclusions after measurement #9813's two flagged write_text/read_text calls in tests/test_gc_ratchet.py now pass encoding="utf-8". These run in windows-build's GC structural audits, the first step of the job, where a UnicodeDecodeError would skip the only Windows run of the perry-runtime unit tests (ci(windows): check_thread_locals.py reads Rust sources as cp1252 — GC structural audits fail before any Windows test runs #7977).

Validation

64/64 lint gates; release build; perry-runtime, perry-codegen, perry-stdlib, perry-hir, perry-transform — all green (RUST_TEST_THREADS=1).

Ralph Küpper added 27 commits September 5, 2026 16:58
A minor-scoped root scan — the copying minor's preflight/mark/rewrite
passes and a budgeted `GcCollectionKind::Minor` trace — can neither move
nor sweep an old-generation object, so a side-table entry whose key and
values are all old is a provable no-op for it. Every registered scanner
still walked its whole table on every such pass: on the compiled
claude-code TUI that is ~35k shape families, ~120k descriptors and ~13k
closure-prop owners per walk, three walks per copying minor, 41 minors
per streamed reply, all reporting `slots=0` — 34–56 ms of scanner time per
minor (`[gc-scanner-profile]`, 2026-09-04), and the same walk again in
every budgeted minor's initial root scan and final remark.

Each of the five tables that dominated that profile — closure dynamic
props/prototypes/deleted keys, string-keyed descriptors, the shape
family + slot-index maps, the transition cache and the shape cache — now
keeps a young-entry log (`gc/young_log.rs`): the keys of entries that may
hold a pointer a minor can act on (nursery, longlived, malloc-GC). Every
writer notes the key BEFORE publishing the entry; a minor-scoped scanner
visits only the logged keys, with the same per-entry body as the full
walk, and re-logs an entry iff it is still relevant afterwards; a full
trace walks everything and rebuilds the log. The copied-minor and
fallback-minor dead-owner prunes of the same tables iterate the log too
(only a young owner can be dead on a minor, and a young owner is always
logged). The visitor carries the scope (`RuntimeRootVisitor::young_scope`,
set for the copying passes and for a minor-only budgeted trace).

Three rules from the design note (perry-young-gc-fixed-cost.md):
1. arm before publish — each note precedes the insert;
2. machine-check the writer set — under `debug_assertions` a minor-scoped
   walk first re-derives the relevant set from the authoritative table and
   panics on any key the log does not name; this caught two sites while
   landing (the migrate-after-delete slot-index insert, and the from-space
   index key that outlives its family's mark-pass move);
3. a skip needs a counter — `[gc-young-log]` prints per table and cycle
   how many keys were logged / visited / kept and the table size, and the
   tests read the rows back.

Also: the post-minor `restore_surviving_dirty_coverage` (#5029), which
re-walked every slot of every object on the pre-cycle dirty pages, now
skips the objects the minor's own dirty scan visited completely (every
slot on a dirty page and inside the body) — for those the scan's
per-slot re-remembering is the same predicate on the same value, so the
walk could only re-insert pages already restored. The budgeted cycle keeps
the full walk: it interleaves with the mutator, and a store into an
already-dirty page leaves no trace. Under `debug_assertions` the skipped
objects are still walked and any page the walk would have added panics;
`[gc-restore-coverage]` prints objects walked/skipped and pages added.

Tests: `gc::tests::young_log_tests` (per table: a young entry reachable
only through the table moves and is re-keyed through the partial walk; an
old entry adds no visit; a dead young owner is pruned from the log), plus
the whole `gc::` suite (1048) with the rule-2 assertions active.

Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
…he size gate

`cargo fmt` output plus the `scripts/check_file_size.sh` splits this change
needs: it took `object/mod.rs` 1998 -> 2208, `object/descriptor_state.rs`
1815 -> 2044, `gc/roots.rs` 1994 -> 2027 and `gc/cycle.rs` 1998 -> 2023, and
three of those four sat within two lines of the 2000-line limit on main.

Each split follows the gate's own recipe (extract a function group into a
sibling, re-export by name) and each is a group that already read as a unit:

* `object/side_table_roots.rs` — the transition-cache and shape-cache root
  scanners and dead-owner prunes, now four full-walk/minor-scoped pairs.
* `object/descriptor_state/young.rs` — the minor-scoped descriptor walk and
  the re-derivation of the relevant set rule 2 checks it against.
* `gc/roots/stack_bottom.rs` — the four `#[cfg]` arms of `get_stack_bottom`.
  The doc comment on the first arm describes a trace-phase mark helper rather
  than `get_stack_bottom`; it was already attached to that item and moves with
  it verbatim rather than being re-pointed at the next item.
* `gc/cycle/registered_root_scan.rs` — the two registered-root scan cursors
  the budgeted root scan resumes through.

`scripts/gc_rekeyed_key_tables.json` follows `scan_transition_cache_slot` to
its new file (the gate reported it as one UNCLASSIFIED site and one STALE
entry, which is the gate working), and the two `#[cfg(test)]` transition-cache
seams are re-exported for `gc::tests::dead_owner_side_tables`.

No behaviour change: every moved item keeps its body, and visibility widens
only to the narrowest scope the new boundary needs.

Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
Sabotage audit of this change's own writer set, prompted by the fact that the
GC gates that would independently catch a missed root (`gc-native-roots`,
`gc-root-dominance`, `gc-ratchet`, `gc-stress`) are red on main and cannot
vouch for it. Deleting the production arm site in `shape_cache_insert` and
running the suite gave **11 passed / 0 failed**: the deletion was invisible.

Three defects, all in the verification rather than the mechanism:

1. **Three `#[cfg(test)]` seeds re-implemented the arming** instead of using
   the writer's, so the tests validated a rule that was not the one shipping,
   and the production arm sites were never exercised. The transition cache's
   two seeds did not even carry the same predicate — production arms on
   `addr_is_minor_relevant(next_keys) || (len_marker == 0 &&
   addr_is_minor_relevant(kid))`, `test_seed_transition_cache_entry` on
   `... || addr_is_minor_relevant(key_ptr)` (classifying a packed length as an
   address whenever a marker was set), and `test_seed_transition_cache_root`
   on `next_keys` alone. Both caches now arm through one helper —
   `arm_shape_cache_young` / `arm_transition_cache_young` — that every writer,
   production and seed, calls; a predicate cannot now be right in one writer
   and wrong in another.

2. **The young-log tests drove the seeds, not the writers.** They now go
   through `test_shape_cache_insert` / `test_transition_cache_insert`, which
   are nothing but calls to `shape_cache_insert` / `transition_cache_insert`
   — a seam with logic of its own is what let a deleted arm stay green.

3. **`debug_assert_logged` (rule 2) is compiled out of `--release`**, so no
   release `cargo test` run has ever enforced rule 1. The new `gcaudit`
   profile is release codegen with debug assertions on, which is what the
   audit below was run under.

Also adds the test for the clause no seed ever exercised: a young interned KEY
under an OLD target, which arms only through the `kid` half of the production
predicate.

Audit result, one build with each arm site suppressed in turn (21 sites):
14 fail a test when removed, and the failure is rule 2's own diagnostic
("young log for <table> does not name <key> ..."). Seven do not, because no
test exercises their path at all — `transfer_descriptor_owner`,
`install_fresh_accessor_property`, `set_builtin_accessor_descriptor`,
`ShapeTableInner::family_push_front`, `shape_slot_lookup_verdict`,
`shape_keys_grown` and `shape_index_migrate_after_delete` (the last four are
the whole of the `shapes.indices` arming). They are recorded in the PR rather
than silently left: rule 2 checks any test that reaches them, so closing them
is a matter of exercising the paths, not of writing per-site assertions.

Full suite under `--profile gcaudit`: 3153 passed, 0 failed, and no rule-2
violation anywhere.

Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
…ncovered

The arm-site audit reported seven production sites whose removal failed no
test. Four of them were the ENTIRE arming of `shapes.indices` — the table
#9756 restructures into 4-byte cells — so that PR was changing a table whose
rule-1 writers nothing exercised. A missed `note` there is a keys array the
minor does not visit and therefore does not keep: a collected live object,
found later as a wrong property read, not as a red test.

Four tests, one per site, each driving the production writer:

* `building_a_slot_index_on_a_young_keys_array_arms_the_log` —
  `shape_slot_lookup_verdict`'s `build` arm, reached through
  `shape_slot_lookup(.., build = true)` on a 40-key young array (above
  `KEYS_INDEX_THRESHOLD`, or no index is built at all).
* `growing_an_indexed_keys_array_arms_the_log_for_the_new_address` —
  `shape_keys_grown`, the owned-array grow migration.
* `migrating_an_index_after_a_delete_arms_the_log_for_the_new_address` —
  `shape_index_migrate_after_delete`, which needs a COMPLETE index
  (`indexed_len >= old_key_count`) or it declines and never arms.
* `installing_an_external_shape_id_arms_the_family_log` —
  `ShapeTableInner::family_push_front`, reached through
  `install_external_shape_id`.

Each asserts the accelerator followed its keys array across a copying minor,
but the load-bearing check is rule 2: the minor-scoped walk re-derives the
relevant set from `indices` and `families` and panics on any key the log does
not name.

Suppression audit, each site removed in turn from one build — every one now
fails, with rule 2's own diagnostic ("young log for shapes.families+indices
does not name <addr> ..."), and each fails exactly the test written for it:

| site | test that catches its removal |
|---|---|
| `family_push_front` | `installing_an_external_shape_id_arms_the_family_log` |
| `shape_slot_lookup_verdict` build arm | `building_a_slot_index_on_a_young_keys_array_arms_the_log` |
| `shape_keys_grown` | `growing_an_indexed_keys_array_arms_the_log_for_the_new_address` |
| `shape_index_migrate_after_delete` | `migrating_an_index_after_a_delete_arms_the_log_for_the_new_address` |

The three remaining uncovered sites — `transfer_descriptor_owner`,
`install_fresh_accessor_property`, `set_builtin_accessor_descriptor` — are on
ground neither PR restructures and are recorded as known-uncovered in the PR
rather than half-covered here.

The test seams added for this (`test_build_slot_index`,
`test_shape_index_migrate_after_delete`, `test_install_external_shape_id`) are
pass-throughs to the production functions, reachable from `gc::tests` because
`shapes_slot_list` and `keys_lookup` are private modules; they carry no logic
of their own, which is the property whose absence caused the original gap.

Whole suite under `--profile gcaudit`: 3153 passed, 0 failed.
…ach cycle

The alloc census on this branch shows the logs paying back part of what the
scanners saved: whole-process allocated volume moves only -1.0 %, while
allocation COUNT rises +29.2 % (30.9 M -> 39.9 M calls per 400-char reply),
concentrated in one size class (8.33 M -> 15.53 M). The logs replaced a small
number of large rehashes with a large number of small allocations.

Part of that is structural in the logs themselves: `take_sorted` used
`std::mem::take`, which leaves a Vec of ZERO capacity behind, so every note
made during the walk — and every note until the next collection — re-grew the
log from empty, and each walk allocated a fresh `kept` Vec that was then
dropped. On the compiled claude-code TUI those are 20k-entry Vecs rebuilt per
table per collection, which is the same allocate-from-scratch shape the logs
were added to remove from the scanners, reintroduced one level down.

`YoungLog` now keeps a `spare` buffer: `take_sorted` swaps it in rather than
leaving nothing behind, `take_spare` hands it to a walk for its `kept` list,
and `extend`/`stash_spare` round both back, keeping whichever has the larger
capacity. The five minor-scoped walks take their `kept` buffer from the log
instead of `Vec::new()`.

No behaviour change: the log's contents, ordering and dedup are what they were
— only the allocations behind them are reused. Whole suite under
`--profile gcaudit` (debug assertions, so rule 2 is live): 3153 passed,
0 failed.

Magnitude is not yet measured on this branch: attributing the remaining count
needs `PERRY_ALLOC_CENSUS` (#9771) built against it, which is the next step.
The mechanism is not in doubt — a zero-capacity Vec regrown to 20k entries per
table per collection — but how much of the +7.2 M this recovers is not claimed
here.
Rebasing onto main brings in #9768's `family_append_fresh`, the append that
skips `IdList`'s membership scan for a freshly allocated id. It is the append
`shape_descriptor_intern` uses, and it did not exist when this branch added
rule-1 arming to `family_push_back` / `family_push_front`, so the rebase merges
clean and silently drops the note for every freshly interned descriptor.

`keys` is the canonical keys array's ADDRESS and the minor-scoped rekey scanner
visits only logged keys, so an unlogged family is invisible to a copying minor:
the keys array moves, the family stays filed under the old address, and the
descriptor is lost. Both intents kept — the membership scan stays gone, the note
comes back.

Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
The three compiled-program caches cap independently and clear wholesale, and
compile_and_cache_regex_checked returns early on a REGEX_CACHE hit, so a
pattern whose real program lives in FANCY_CACHE (its REGEX_CACHE entry being
the never-match placeholder) lost that program permanently once FANCY_CACHE
overflowed while the placeholder survived.

lookup_fancy_regex treats a built header as authoritative and
site_cache::install_programs memoizes the triple against the pattern text, so
the consequence is not one bad header but every later construction of that
literal. Repair the missing program before publishing and before memoizing.
The same shape applies to REPEAT_MATCHER_CACHE, where the wrong answer is the
linear engine's capture assignment instead of ECMA-262's.
… shipped

The comment described the group-clear approach that was built first and
dropped — it closes the route into the incoherent state but cannot repair a
header already in it, which is why the test still failed against it. The fix
that shipped repairs the header in `build_and_install_programs` before
publishing it and before `site_cache::install_programs` memoizes the triple.

Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
changelog.d/README.md asks for `<PR-number>-<slug>.md`; the fragment landed
unnumbered. Rename only — the entry text and the code are unchanged.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
… shape

`PERRY_IC_DIAG` counted misses but could not say WHY a site that primes on
every read keeps missing. Two explanations demand opposite fixes, and the miss
table cannot tell them apart:

  * the receiver's shape really changed between reads (polymorphism — widen or
    re-tier the ways), or
  * the site was re-primed with the shape it already held (the cache holds the
    right answer and nothing consulted it — priming/invalidation or IC layout).

`pic_prime_get` is the one place where both candidate answers are still live:
`prev_tok`, `token`, the four ways and `PIC_WAY_STATE` are all in registers
immediately before the write that destroys them. So the split is recorded
there, three ways:

  * `same_token` — re-primed the MRU shape,
  * `new_token` + `in_ways` — the token was ALREADY in one of the four ways,
    so the polymorphic cache held it and the read reached the handler anyway,
  * `new_token`, not in a way — a shape neither the MRU entry nor the ways had.

plus a way-state census at prime time (`fresh` / `armed` / `megamorphic`), so a
site latched off by `PIC_MEGAMORPHIC_EVICTIONS` is distinguishable from one
still trying. Reported globally and per site.

Miss rows are now keyed by the RESOLVED cache rather than by the slot that
points at it, because a prime only ever sees the resolved cache; without that
the two halves of one site would never merge into one row. A site that has
never primed keeps its slot as the key (it has no prime rows to merge with).

Diagnostic only: every probe sits behind `ic_on()`, and the values it reads are
ones the caller already has.
…ever had

#5391 path 3 replaces the inline generic-get diamond with a single
`js_object_get_field_ic` call in oversized modules, and the diamond's
monomorphic fast-load went with it. Nothing replaced it: the helper observed
typed feedback and then called `js_object_get_field_ic_miss`
UNCONDITIONALLY, on every read of every heap receiver. The per-site cache was
written by every read and consulted by nobody.

The threshold that turns full-outlining on (4,000 callables) is met by the
whole MODULE, so on a minified bundle every generic property read in the
program takes that path. `nm -u` on the compiled claude-code object is the
proof: it references `js_object_get_field_ic` and does not reference
`js_object_get_field_ic_miss` at all — there is no inline diamond anywhere in
the binary, so nothing was ever positioned to hit.

Measured with `PERRY_IC_DIAG`'s prime split, one 400-character reply:
2,663,424 entries to the miss handler over 12,326 sites; 2,122,626 of them
primed; and **95.2 % of those primes wrote the token the site's MRU entry
already held**. The four hottest sites (`.done`, `.ambiguousAsWide`, `.value`,
`.segment`, ~195k reads each) each recorded exactly ONE new-token prime and
~195k same-token primes with `PIC_WAY_STATE` still 0 — perfectly monomorphic
sites, a cache holding the right answer, and the whole miss ladder walked every
time. So these were never misses: they are every property read in the program.

`pic_outlined_mru_hit` reads the cache the same path already writes. Its guards
are `lower_generic_property_get`'s, one for one and in the same order — real
heap pointer, `GC_TYPE_OBJECT`, `OBJ_FLAG_HAS_DESCRIPTORS` clear, non-zero
shape stamp equal to the cached token, no `IC_SLOT_OVERFLOW_BIT`, no `TAG_HOLE`
— and the raw header loads are the ones it emits, licensed by the same
already-established pointer tag. Anything it declines still reaches the
handler, so this only ever removes work.

Word 2 (the Array-subclass named-prefix token) and the polymorphic ways are
deliberately left to the handler: 2.5 % of primes between them, and each needs
its own proof. `PERRY_IC_OUTLINE_FASTPATH=0` restores the old behaviour for a
same-binary A/B.
…gs` job rejects

Three checks were failing on PR #9802, none of them for a reason in its own
diff:

* `self-test-checkers` — `check_thread_locals.py` rejected the two raw
  `thread_local!` blocks in `hot_diag.rs`. They are not this PR's: main fixed
  them in 5112112, and the branch was based on 1d63fa9. Rebasing onto main
  is the fix; nothing here touches them.
* `lint` — the fragment must be named `changelog.d/<PR-number>-<slug>.md` per
  `changelog.d/README.md`, so `outlined-ic-monomorphic-hit.md` did not count as
  a fragment at all. Renamed.
* `warnings` — `cargo check -p perry --bins` builds `perry-runtime` WITHOUT
  `regex-engine`, and every use of `HashMap` in `regex.rs` sits inside a
  `#[cfg(feature = "regex-engine")]` block (the four caches and
  `evict_regex_cache_if_full`), so the unconditional import is an
  unused-import error under `-D warnings`. The import now carries the same
  cfg as its uses.

The last one is a pre-existing defect on main — `regex.rs` is byte-identical
at 1d63fa9 and at c7361c8 — surfaced by this PR only because it is one of
the PRs whose `warnings` job ran to completion. It is a one-line attribute in
another lane's file, kept minimal for that reason.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
This was referenced Sep 5, 2026
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