Skip to content

Merge train: #9891, #9892, #9893, #9904, #9905, #9906, #9909, #9910, #9911, #9913, #9914 - #9922

Merged
proggeramlug merged 19 commits into
mainfrom
train137
Sep 6, 2026
Merged

Merge train: #9891, #9892, #9893, #9904, #9905, #9906, #9909, #9910, #9911, #9913, #9914#9922
proggeramlug merged 19 commits into
mainfrom
train137

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Merge train: #9891, #9892, #9893, #9904, #9905, #9906, #9909, #9910, #9911, #9913, #9914.

All eleven cherry-picked without a single conflict. #9892 is stacked on #9891 and picked in that order.

Two audits worth recording

#9891 skips the write barrier for a nursery RegExpHeader's field stores. The predicate was checked against the one codegen already emits in front of every store (emit_parent_may_need_remembering_check, #7511) and matches it, including the clause that is easy to miss: newborn_parent_needs_barrier short-circuits to true whenever incremental marking is not globally idle, before it ever looks at GC_FLAG_TENURED. Its test asserts its own premise — "a fresh nursery allocation must not be TENURED — otherwise this test exercises nothing".

#9893 records two heap pointers in thread-locals, which is the unrooted-cache shape the static checker structurally cannot see. The doc comments claim they are visited by scan_object_cache_roots_mut; that is true in code, the function is registered via reg_scanner!, and each slot is visited by the method matching its representation — visit_atomic_i64_slot for the bare address, visit_atomic_nanbox_u64_slot for the NaN-boxed closure word.

File cap: which half of regex.rs to split

regex.rs reached 2141 lines and regex/tests.rs 2121.

js_regexp_new_impl is the obvious extraction at 538 lines, and is deliberately not the one taken. It carries the two #7341 raw-handle debt sites, and raw_handle_debt.py's --no-raise-vs arm refuses a ceiling on a file that did not exist at the merge base — its bare arm accepts relocating the ceiling, the --no-raise-vs arm does not. That left two bad options: surgery on the allocation path both #9891 and #9892 are tuning, or a with_const_ptr(|p| p) that satisfies the regex while scoping nothing, which is the ratchet-gaming shape. Neither was taken; the debt-free program-compilation and cache group moved to regex/compile_cache.rs instead.

regex/tests.rs split at a test boundary into tests_part2.rs. All 70 #[test] items are accounted for across the two files, and the sibling carries the same cfg(all(test, feature = "regex-engine")) gate.

Gate fixes

  • shape_descriptor_census: perf(regex): identify a literal by its source site, not by its pattern text #9892 split construction into a thin js_regexp_new / js_regexp_new_site pair over a shared js_regexp_new_impl, so the census was reading a wrapper with no birth site in it. Retargeted to the birth site, and verified the retargeted gate still fails when the kind is blunted inside js_regexp_new_impl.
  • gc_store_site_inventory wanted a marker on REGEXP_PROTOTYPE_PTR.store. This looked like a missed barrier — the sibling closure store uses runtime_store_root_atomic_nanbox_u64 while this one used a plain .store(), and runtime_store_root_atomic_raw_i64 exists as its exact counterpart. It is not: RealmAtomicI64::store routes through that barriered helper itself, and the sibling spells it out only because it uses with_slot and bypasses the wrapper. Marker added recording that.
  • NEVER_MATCH moved with the compile-cache group, so its holder entry is retargeted; perf(intl): take the view mode's canonicality proof and UTF-8 re-validation off the per-grapheme path #9893's four new holders are classified (two covered_elsewhere naming the scanner, two not_a_gc_pointer); fix(stream): wait for both finished sides #9906's new test thread_local! is recorded cold, which is what its 23 siblings in that file already are.

Validation

run_lint_gates: all 64 gates passed; 2 CI-only skipped

suite passed failed
perry-runtime 3262 0
perry-codegen 1940 0
perry-hir 628 0
perry-stdlib 132 0

Ralph Küpper and others added 19 commits September 6, 2026 22:03
…at cannot fail silently

`PERRY_REGEX_DIAG` gains four per-construction work counters — `barrier_taken`
/ `barrier_gated` (whose sum must equal `new`), `header_bytes`,
`site_verify_bytes` and `side_table_inserts` — so what `js_regexp_new` costs
per call is a number rather than a reading of a profile. Writers for the first
group arrive with the change they measure; `site_verify_bytes` is written here.

`site_verify_bytes` is deliberately NOT `pattern_bytes`: the latter counts
every construction's pattern length whether the site-cache probe hit or
missed, while the full byte compare that verifies a fingerprint match is the
part that is linear in the pattern — what makes a 12 KB emoji pattern
expensive and a 60-byte one free. Counted at the construction probe only;
`insert` and `install_programs` verify too and are not counted here.

Two reliability fixes, both of the same shape as the campaign's missing
exit-line trap — an absent output that greps identically to an instrument that
was never built:

* a file sink that cannot write now reports the path and the error on stderr
  once and keeps writing there, instead of swallowing the error;
* the first snapshot is written at the first tick rather than one full
  DUMP_INTERVAL_MS later, so a run shorter than a second produces output.

(cherry picked from commit df202bf)
Since #9845 the `RegExpHeader` is a nursery allocation, so its two string
field stores cannot owe the remembered set anything — and they were still
taking the full barrier twice to discover that: four page-map
classifications, two dirty-page-cache probes and two child classifications
per construction, every one of them ending at `ParentNotOldSkips`.

The gate is the runtime twin of the one the compiler already emits in front
of every one of its own stores (`emit_parent_may_need_remembering_check`,
#7511): `GC_FLAG_TENURED` clear on the parent's LIVE header, and a globally
idle incremental mark barrier. The first clause answers the generational
question; the second is what makes it legal to skip the SATB/insertion
shading as well, and dropping either one is a live child swept. Both are read
live, so a header a collection promoted between `arena_alloc_gc` and the
store, and `RegExp.prototype.compile` reassigning a tenured receiver, still
take the full path.

`gc::tests::inline_generation_gate_contract` already pins those two clauses
for the emitted gate against a stranded-child witness; it now pins the runtime
twin to the same codegen predicate clause by clause, and a third test asserts
on the header `js_regexp_new` actually returns — so the skip arm is proven
REACHED, not merely available.

Measured motivation (segment-loop probe, region B, 60,000 reps, `sample`,
main thread, leaf sum = thread header exactly): one `RegExp` per grapheme from
a literal inside a function body, and the barrier subtree under
`js_regexp_new` is 739 of 14,628 main-thread samples — 32 % of that
function's own subtree.

`PERRY_REGEX_NEWBORN_BARRIER_GATE=0` restores the unconditional pair. With the
gate off nothing else changes, so the OFF arm is the pre-change code path
exactly rather than a control still carrying the bookkeeping.

(cherry picked from commit 3b5f5ef)
…n text

`regex::site_cache` answers "have I seen this pattern TEXT before?" — the right
question for a dynamic `new RegExp(s)`, and the wrong one for a literal, which
is one source site whose pattern and flags are fixed at compile time. Because a
content fingerprint can collide, every hit is verified by a full byte compare
of the pattern, and a literal constructs a fresh object every time it is
reached: on claude-code that verify is ~2.0 GB of `memcmp` per 400-character
reply and 39.6 % of `js_regexp_new`'s own profile subtree.

`Expr::RegExp` now emits an 8-byte private global per literal site and passes
its ADDRESS to a new `js_regexp_new_site(pattern, flags, site_key)`. The
address is unique by construction, immortal and never moves — the three
properties a `StringHeader` address lacks, which is why the earlier analysis
concluded no sound string identity was available and left the compare in place.

A hit compares one word plus the site's <= 8-byte flags text and then reads
nothing about the pattern: no fingerprint, no memcmp, no validation (validity
is a pure function of the pair and the site's first construction established
it), no flag canonicalization (the seven bits are a property of the site), and
the programs the site already compiled are installed eagerly.

`site_key = 0` is "no site": every dynamic construction keeps the two-argument
entry and never touches the table. Kill switch `PERRY_REGEX_SITE_KEY=0`.

Tests: two sites whose patterns have EQUAL LENGTH and different text, each
constructed twice — the sabotage of keying the table by pattern length hands
the second site the first's entry and fails on `.source` and on `test`; four
dynamic constructions leave the table empty while one site-keyed construction
fills it; a second construction at an executed site is born built; and the new
symbol's declaration is asserted by name AND arity, because a missing declare
fails only at the LLVM parse and a wrong arity miscompiles silently.

Measurement is owed on the cc rig, where the 12,807-character pattern lives —
the segment-loop probe's literal is ~60 characters and its memcmp is 0.16 % of
the thread, so the probe cannot show this change.

(cherry picked from commit 7cbadc1)
…d counters moving the dump

Two corrections from the I6 cc arm, both found by reading the instrument back
rather than by argument.

**1. The site table must never be the reason a program stays alive.** Measured
on one 3300-char reply: settled footprint 478/474 MB -> 500/527 MB and idle CPU
2.37 -> 2.68 s against main. 1,024 entries at ~19 KB per compiled program is
that order, and the campaign's directive is both metrics together — a CPU win
bought with resident memory does not land. The entry now holds
`Weak<Regex>` / `Weak<fancy_regex::Regex>` / `Weak<RepeatMatcherRegex>`;
strong references stay where they belong, in the `(pattern, flags)` program
caches and in every live header that installed them with `Arc::into_raw`. An
entry whose programs have expired reports "not built yet" and the next
construction re-picks them up from the content cache — the same path the site's
first construction takes, so the lane self-heals.

The upgrade is ALL-OR-NOTHING. #9801 fixed an incoherent triple — a standard
program memoized beside a missing fancy fallback — which does not error, it
silently never matches; three independent `Arc` lifetimes reintroduce exactly
that shape unless one dead reference invalidates the whole entry. Pinned by a
test that drops ONLY the fancy program and asserts the entry reports unbuilt,
which the natural per-field upgrade fails.

**2. An added counter moved the instrument's own sampling.** `regex_with`
counts every call as an event and dumps every `TICK_EVERY` events after a
second has passed, so a second probe on an already-instrumented path doubles
that path's event rate and moves the snapshot a SIGKILLed process leaves
behind. On the I6 pair that showed up as `new / t` 206 k/s vs 173 k/s between
two arms whose per-call ratios agree to 0.13 %, i.e. the two files describe
different windows of the same workload. `regex_counters` accumulates without
ticking the dump clock, and the three counters that ride along on already
instrumented paths (barrier gate outcome, side-table inserts, site-verify
bytes) now use it.

(cherry picked from commit 78e8b9d)
…is fixed

The comment at the `Expr::RegExp` lowering described the artifact-discarding
bail-out in `codegen/method.rs` in the present tense. #9896 fixed it: every
return there now goes through `publish_lowered_fn_artifacts`, which drains all
three collections and restores `llmod.ic_counter`, closing the duplicate
site-id half as well.

Rewritten as the obligation rather than the bug — every lowering exit must
PUBLISH `typed_parse_rodata`, and a future early return that drops it breaks
this site loudly at the in-process LLVM parse. A comment describing a hazard
that no longer exists is a false lead, which is the thing it was written to
prevent.

(cherry picked from commit 91a7791)
… walk

`js_segments_view_regexp_test` asks "is `RegExp.prototype.test` still the
builtin?" twice per grapheme, and the proof cost more than the match it guards.
Symbolised on the view arm of the string-width probe, the proof was ~13 % of the
thread — `get_field_by_name_object_tail` 3.6, `js_object_get_field_by_name` 3.5,
`get_accessor_descriptor` 2.1, `closure_get_dynamic_prop` 1.75,
`RandomState::hash_one<&str>` 1.4 (it hashed the key string on every call),
`js_object_get_prototype_of` 1.3 — against 0.8 % for `regexp_test_str_bounded`,
the actual matching.

The property belongs to `RegExp.prototype`, not to the call, so it is recorded
once when the prototype's methods are installed: the prototype pointer, the
FIELD INDEX of its own `test`, and the canonical closure value. A call reads
that slot by index and compares — three loads — plus the per-key accessor Bloom
bit off the meta record. Everything it can get wrong, it gets wrong in the
declining direction: a replaced or deleted `test` no longer matches the recorded
closure; a reshaped prototype makes the index hold something else, which also
does not match; `defineProperty(proto,"test",{get})` leaves the data slot alone
and is caught by the accessor bit; a reparented receiver is caught by
`object_static_prototype`, which answers from the object's own meta record or an
atomic "nothing was ever recorded" latch — no mutex, no chain walk.

Deliberately NOT a flag invalidated from the property-set path: that design
makes every property store in the program pay for this one question and adds an
invalidation surface that fails silently. This one hooks no shared write path.

`REGEXP_PROTOTYPE_TEST_WALKS` counts by-name walks. The fast path does none, so
it counts realms rather than calls, and the tests pin the property that matters:
50 accepted calls, plus a second cursor and a second regex, add ZERO walks; and
patching `RegExp.prototype.test` after the site is recorded makes the very next
call decline, so the caller materialises and runs the user's function.

Also removes `report_segview_counters`, which had no caller. The counters now
have one — the test suite — and a comment says to wire a runtime-side printer
when a rig run needs the numbers, not before.

`cargo test -p perry-runtime --release --lib -- --test-threads=1`: 3,239 passed,
0 failed.

(cherry picked from commit 638b832)
Every view entry point re-derives the input `&str` from the cursor's traced slot
per call — that is the §9a rooting contract and it stays — but each derivation
also re-ran `std::str::from_utf8` over the WHOLE input. On the symbolised view
arm of the string-width probe that was the top self symbol at 6.2 %.

`open` already validates: it refuses an input that is not already a string
primitive and runs `from_utf8` on its bytes before allocating the cursor. So the
per-call validation re-establishes something the slot's only writer guaranteed.

The borrow now uses `from_utf8_unchecked`, with the invariant written out where
the `unsafe` is, in four checkable parts: `F_INPUT` is written exactly once, by
`open`, and never reassigned; `open` validated that value; a collection MOVES
the string but never rewrites its bytes, and the traced slot is updated to the
new address; the SSO path decodes the same value into the stack buffer. A
`debug_assert` re-checks it in debug builds, which is where a future second
writer to the slot would be caught.

`cargo test -p perry-runtime --release --lib -- --test-threads=1`: 3,239 passed,
0 failed.

(cherry picked from commit ea54f0a)
The canonicality fast path records the prototype address and the canonical
`test` closure and reads them on every call — and neither was scanned. An
address held across a collection without being visited is stale the first time
the collector moves the object, which is the #9539 / #9445 shape and exactly
what this campaign keeps finding. Nothing had failed yet because a realm
prototype is long-lived and rarely moves; that is luck, not a design.

The packed `(index << 48) | ptr` word is split so each part can be handled
correctly:

* `REGEXP_PROTOTYPE_PTR` — a raw address, visited by
  `scan_object_cache_roots_mut` with `visit_atomic_i64_slot` beside the
  iterator-prototype towers, so a move rewrites it;
* `REGEXP_PROTOTYPE_TEST_CLOSURE` — a NaN-boxed word, visited with
  `visit_atomic_nanbox_u64_slot` and stored through
  `runtime_store_root_atomic_nanbox_u64` with the GC_STORE_AUDIT(ROOT) note the
  other mutable roots carry, so the pointer inside it is rewritten too;
* the field index is not an address and stays an ordinary atomic.

The per-call cost is unchanged — three loads and the accessor Bloom bit — and
the identity compare stays an identity compare across a move, because both
sides are now maintained by the collector.

`cargo test -p perry-runtime --release --lib -- --test-threads=1`: 3,239 passed,
0 failed.

(cherry picked from commit f076656)
`PERRY_SEGVIEW` is not a diagnostic — it changes the emitted code — and it is
in neither the build-cache fingerprint nor any object-cache key. So a cached
build can return a binary compiled with the OTHER setting: compile a source
with the tier on, compile it again with the tier off, and the second can be
served from the first.

That is precisely the shape every A/B in this campaign uses — one compiler
binary, two compiles of one source differing only in this variable — so the
failure mode is not a broken build, it is two arms that are secretly the same
binary and a measured difference of zero, or two arms swapped. Silent, and it
would look like a result.

`PERRY_SEGVIEW_DIAG` was already excluded for the weaker reason that a cached
build prints no report. The switch that changes codegen was not, which is the
worse omission of the two and mine.

Excluded rather than keyed because the tier is experimental and default OFF.
A cache key is the right fix when it ships on by default; exclusion is correct
now and cannot produce a stale entry that becomes the measurement.

(cherry picked from commit a423a1b)
The cursor local is declared in the ENCLOSING statement list, not inside the
loop:

    let __segview_recv   = <receiver>
    let __segview_input  = <input>
    let __segview_cursor = js_segments_view_open(recv, inp)
    let __segview_iter   = cur != 0 ? undefined : GetIterator(...)
    For { ... }            <- last read of the cursor

so without a clear its slot stays a live GC root until the function returns.
The cursor holds the input string in a traced slot, so a cursor promoted during
the loop drags that string into the old generation, and leaving the slot rooted
afterwards keeps a DEAD cursor doing it for the rest of the function.
`string-width` is entered thousands of times per reply.

That is a candidate mechanism for the idle behaviour measured on cc: I4 settles
45-65 MB ABOVE I3 at 3300 and 15-20 MB at 400 after 120 s, despite winning
20-50 MB of PEAK RSS in 12/12 paired runs. Lower peak with a higher floor is
not "less garbage"; it is something being retained.

One unconditional `LocalSet(cursor, undefined)` after the loop covers both
paths: on the declined path the local holds `0.0`, a number, so the clear is a
no-op. `break` reaches it; `return` inside the body pops the frame, which is
equally fine.

WHAT THIS DOES NOT DO, stated so the commit is not read as a cure: it does not
prevent promotion DURING the loop, and nothing in the compiler can, because the
cursor is genuinely live there. It removes only the post-loop rooting of a dead
cursor. If the idle delta comes from cursors promoted mid-loop, this will not
move it. perrymaster's old-gen census after idle, counting class id
0xFFFF_000E on I5-spec / I5-view / I7-view, decides that independently.

The test is structural rather than string-matched: it locates the rewritten
`For`, reads the cursor's LocalId out of the loop's own guard, and requires the
next statement to be `LocalSet(that id, Undefined)`. It fails if the clear is
removed, clears the wrong local, or is emitted before the loop.

17/17 segview tests.

(cherry picked from commit 15ea8eb)
(cherry picked from commit c8663cf)
…he new roots

Two files went over 2000 lines:

- regex.rs -> regex/compile_cache.rs takes the program-compilation and
  cache group (size limit, std/fancy builders, eviction, the checked
  compile-and-cache entry). `js_regexp_new_impl` was the obvious bigger
  extraction and is deliberately NOT the one taken: it carries the two
  #7341 raw-handle debt sites, and raw_handle_debt.py's --no-raise-vs arm
  refuses a ceiling on a file absent at the merge base, so moving it would
  have forced either surgery on the allocation path two perf PRs are
  tuning, or a with_const_ptr(|p| p) that games the ratchet without
  scoping anything.
- regex/tests.rs -> regex/tests_part2.rs, split at a test boundary. All
  70 #[test] items are accounted for across the two files, and part2
  carries the same cfg(all(test, feature)) gate as its sibling.

#9893's new roots are classified: REGEXP_PROTOTYPE_PTR_SLOT and
REGEXP_PROTOTYPE_TEST_CLOSURE_SLOT are covered_elsewhere, naming
scan_object_cache_roots_mut, which really does visit them (as an i64 slot
and a nanbox word respectively) and is registered via reg_scanner!. The
test index and the walk counter are not_a_gc_pointer.

NEVER_MATCH moved with the compile-cache group, so its inventory entry is
retargeted. #9906's new test thread_local is recorded cold — it is a test
file, and the other 23 there are recorded the same way.
… store

- shape_descriptor_census asserted the dedicated GC birth kind inside
  `js_regexp_new`. #9892 split construction into a thin
  js_regexp_new / js_regexp_new_site pair over a shared
  js_regexp_new_impl, which is where the allocation now lives, so the
  census read a wrapper with no birth site. It follows the birth site
  instead. Verified the retargeted gate still fails when the kind is
  blunted inside js_regexp_new_impl.

- gc_store_site_inventory wanted a marker on
  `REGEXP_PROTOTYPE_PTR.store`. The store is already correct — the
  sibling closure store spells out its barrier only because it uses
  with_slot and bypasses the wrapper, while RealmAtomicI64::store routes
  through runtime_store_root_atomic_raw_i64 itself. Marker records that.
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