Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions changelog.d/9885-regex-newborn-barrier-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
**A `RegExp` literal's construction no longer pays the write barrier's parent
classification.** Since #9845 the `RegExpHeader` is a nursery allocation, so
its two string field stores — `pattern_ptr` and `flags_ptr` — cannot owe the
remembered set anything; they were still taking the full barrier and
discovering that fact, twice, at a cost of four page-map classifications, two
dirty-page-cache probes and two child classifications per construction, all
ending at `ParentNotOldSkips`.

The fix is the runtime twin of a gate 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 ⇒ neither the remembered set nor the SATB shading has
anything to record. Both clauses are read live, so a header a collection
promoted between `arena_alloc_gc` and the store, or a
`RegExp.prototype.compile` reassigning a tenured receiver, still takes the
full path.

Why the two clauses and not one: the tenured bit answers the generational
question, and the incremental count is what makes it legal to skip the
insertion shading as well — dropping either is a live child swept, which is
what `gc::tests::inline_generation_gate_contract` already pins for the emitted
gate and now pins for the runtime twin, clause by clause, against the same
codegen predicate. A third test asserts on the header `js_regexp_new` actually
returns, so the skip arm is proven reached rather than merely available.

Measured motivation (segment-loop probe, region B, 60,000 reps, `sample`, main
thread, leaf sum = thread header exactly): the probe constructs 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 handicapped control.

`PERRY_REGEX_DIAG` gains the counters that make the claim checkable rather
than argued: `barrier_taken` / `barrier_gated` (whose sum must equal `new`),
`header_bytes`, `site_verify_bytes` (the site cache's byte-compare volume,
which `pattern_bytes` does not isolate) and `side_table_inserts`. Two
reliability fixes ride along: a diag file the process cannot write now says so
on stderr and falls back there instead of vanishing silently, and the first
snapshot is written at the first tick rather than after a full second, so a
short run can no longer look like a dead instrument.
55 changes: 55 additions & 0 deletions changelog.d/9886-regex-literal-site-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
**A regex literal is now identified by its SOURCE SITE, not by its text**, so
constructing one costs a single word compare instead of a content fingerprint
plus a full byte compare of the pattern.

A regex literal evaluates to a fresh object every time it is reached
(ECMA-262), and TUI code reaches them inside hot functions: `string-width`'s
`emojiRegex()` returns a fresh ~12,807-character `/…/g` on every call, once per
grapheme in claude-code's layout pass. The runtime therefore re-derived "which
pattern is this?" from the text on every construction — `regex::site_cache`
keys on a cheap fingerprint and, because a fingerprint can collide, verifies
every hit with `&*entry.pattern == pattern`. That verify is linear in the
pattern: `PERRY_REGEX_DIAG` measured **2.0 GB of `memcmp` per 400-character
reply**, and a `sample` of the segment loop put `_platform_memcmp` at **39.6 %
of `js_regexp_new`'s own subtree**.

The compiler knew the answer all along; the lowering just had no way to say it.
`Expr::RegExp` now emits an 8-byte private global per literal site and passes
its **address** as a third argument to a new `js_regexp_new_site(pattern,
flags, site_key)`. That address is unique by construction, immortal, and never
moves — which is exactly what a `StringHeader` address is not, and why the
earlier analysis of this problem concluded no sound string identity existed and
left the byte compare in place: string headers are GC-managed, so an address is
freed and reused, and a moving collector relocates them.

A hit verifies with one word plus the site's ≤ 8-byte flags text (two spellings
of one canonical form must not answer for each other) and then reads nothing
about the pattern at all: no fingerprint, no `memcmp`, no validation — validity
is a pure function of `(pattern, flags)` and the site's first construction
established it — and no flag canonicalization, since the seven flag bits are a
property of the site. Once the site's first header has executed, later
constructions are born built.

`site_key = 0` means "no site" and behaves exactly as before, so every dynamic
construction (`new RegExp(s)`, `js_regexp_construct`,
`RegExp.prototype.compile`, the runtime's own callers) keeps the two-argument
entry point and never touches the site table — pinned by a test that asserts
the table is still empty after four dynamic constructions, and non-empty after
one site-keyed one, so the zero is a property of the entry point rather than of
a table that never works.

The named sabotage is a table keyed by anything weaker than the site address:
two literals at two sites, same flags, **same pattern length**, different text.
Under a length- or prefix-keyed table the second site inherits the first's
entry, `.source` reports a pattern the literal never contained and `test`
matches the wrong language. Each site is constructed twice, because a first
construction always misses and would pass under every sabotage.

Kill switch: `PERRY_REGEX_SITE_KEY=0` — the probe misses and nothing is
recorded, so the OFF arm is the content-keyed path exactly rather than a
control still paying the bookkeeping.

The new runtime symbol is declared in `runtime_decls/strings.rs` with a test
asserting its **name and arity**: a missing `declare` is invisible to every
HIR-level test and fails only at the in-process LLVM parse (`use of undefined
value`), and a wrong arity parses and miscompiles.
66 changes: 64 additions & 2 deletions crates/perry-codegen/src/expr/logical_collections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1283,15 +1283,77 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let flags_idx = ctx.strings.intern(flags);
let pattern_global = format!("@{}", ctx.strings.entry(pattern_idx).handle_global);
let flags_global = format!("@{}", ctx.strings.entry(flags_idx).handle_global);
// ★ A literal's SITE IDENTITY, as an immortal address.
//
// A regex literal evaluates to a fresh object every time it is
// reached (ECMA-262), and TUI code reaches them inside hot
// functions — `string-width`'s `emojiRegex()` returns a fresh
// ~12,807-character `/…/g` per call. The runtime therefore
// re-derives "which pattern is this?" per construction from the
// TEXT: a content fingerprint plus, on every hit, a full byte
// compare to verify it (`regex::site_cache::entry_matches`). On
// claude-code that verify is ~2.0 GB of `memcmp` per 400-character
// reply, and it is 39.6 % of `js_regexp_new`'s own profile subtree.
//
// The compiler knows the answer statically: this literal is one
// source site whose pattern and flags can never change. What the
// runtime was missing is the key, because the lowering passed only
// the two string handles. This emits an 8-byte private global per
// literal site and passes its ADDRESS — unique by construction
// (distinct globals have distinct addresses), immortal (it is not
// GC memory, so it can never be freed and reused under a stale
// cache entry, which is why the string handles themselves cannot
// serve), and stable for the process. The runtime's site table
// then verifies a hit by comparing that one word, and never looks
// at the pattern at all.
//
// The slot is zero-initialised so it lands in `__bss` and costs
// nothing until the linker lays it out (#9610's lesson about
// zero-initialised globals applies: `private global i64 0`, not a
// non-zero initialiser). Naming carries the module prefix for the
// same reason `inline_cache_global_name` does — codegen-unit
// splitting can promote a private global for cross-unit use.
//
// The slot must reach the module, so every lowering exit has to
// PUBLISH `typed_parse_rodata` rather than drop it. That was not
// true when this landed: `codegen/method.rs`'s "parent class has
// no callable constructor symbol" bail-out lowered the body and
// then discarded the three artifact collections, so a regex
// literal inside such a constructor would have referenced a
// global that is never defined (#9890, fixed by #9896 — every
// return now goes through `publish_lowered_fn_artifacts`, which
// also restores `llmod.ic_counter` and so closes the duplicate
// site-id half). Kept as a note because the obligation is real
// and unenforced: a future early return that drops the artifacts
// breaks this site, loudly, at the in-process LLVM parse (`use of
// undefined value`) rather than at runtime.
let site_id = ctx.ic_site_counter;
ctx.ic_site_counter += 1;
let slot_name = {
let prefix = ctx.strings.module_prefix();
if prefix.is_empty() {
format!("perry_regexp_site_{site_id}")
} else {
format!("perry_regexp_site_{prefix}__{site_id}")
}
};
ctx.typed_parse_rodata
.push(format!("@{slot_name} = private global i64 0"));
let slot_ref = format!("@{slot_name}");
let blk = ctx.block();
let pattern_box = blk.load(DOUBLE, &pattern_global);
let flags_box = blk.load(DOUBLE, &flags_global);
let pattern_handle = unbox_to_i64(blk, &pattern_box);
let flags_handle = unbox_to_i64(blk, &flags_box);
let site_key = blk.ptrtoint(&slot_ref, I64);
let result = blk.call(
I64,
"js_regexp_new",
&[(I64, &pattern_handle), (I64, &flags_handle)],
"js_regexp_new_site",
&[
(I64, &pattern_handle),
(I64, &flags_handle),
(I64, &site_key),
],
);
Ok(nanbox_pointer_inline(blk, &result))
}
Expand Down
53 changes: 53 additions & 0 deletions crates/perry-codegen/src/runtime_decls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,56 @@ pub fn declare_phase_a_strings(module: &mut LlModule) {
// function once they grow.
declare_phase_b_strings(module);
}

#[cfg(test)]
mod tests {
use super::*;

/// A lowering that introduces a new runtime call needs one test that
/// reaches the DECLARATION, not just the HIR.
///
/// #9859 emitted five `js_segments_view_*` calls whose symbols were never
/// declared in the LLVM module: twelve HIR-level unit tests passed and the
/// first real compile died at the in-process LLVM parse with `use of
/// undefined value`. The arity half matters just as much and fails more
/// quietly — a wrong arity PARSES and miscompiles, handing the runtime a
/// garbage argument.
///
/// `Expr::RegExp` lowers to `js_regexp_new_site(pattern, flags, site_key)`
/// (`expr/logical_collections.rs`), so the declaration must be exactly
/// three `i64` parameters returning `i64`.
#[test]
fn the_literal_site_regexp_entry_is_declared_with_its_exact_arity() {
let mut module = crate::module::LlModule::new("arm64-apple-macosx");
declare_phase_b_strings(&mut module);

let line = module
.declaration_lines()
.find(|(name, _)| *name == "js_regexp_new_site")
.map(|(_, line)| line.to_string())
.expect(
"`Expr::RegExp` emits a call to `js_regexp_new_site`; without a `declare` the \
module fails the in-process LLVM parse with `use of undefined value`, which no \
HIR-level test can see",
);
assert!(
line.starts_with("declare i64 @js_regexp_new_site(i64, i64, i64)"),
"the site-keyed entry takes (pattern handle, flags handle, site key) and returns a \
RegExpHeader handle — a wrong arity parses and miscompiles instead of failing. Got: \
{line}"
);

// The two-argument form stays, because every non-literal construction
// (`new RegExp(str)`, `js_regexp_construct`, the runtime's own
// callers) uses it and must never reach the site table.
let plain = module
.declaration_lines()
.find(|(name, _)| *name == "js_regexp_new")
.map(|(_, line)| line.to_string())
.expect("the dynamic form must remain declared");
assert!(
plain.starts_with("declare i64 @js_regexp_new(i64, i64)"),
"got: {plain}"
);
}
}
7 changes: 7 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
&[DOUBLE, DOUBLE, DOUBLE, I32, DOUBLE],
);
module.declare_function("js_regexp_new", I64, &[I64, I64]);
// The literal-site form (`Expr::RegExp` lowering). A missing `declare`
// here is invisible to every HIR-level test and fails only at the
// in-process LLVM parse with `use of undefined value` — which is exactly
// how #9859's five segment-view externs were caught, after twelve passing
// unit tests. `runtime_decls::tests` asserts the name AND the arity: a
// wrong arity parses and miscompiles.
module.declare_function("js_regexp_new_site", I64, &[I64, I64, I64]);
// Full ECMAScript RegExp constructor: NaN-boxed pattern + flags in, handles
// RegExp/undefined/object patterns and ToString-coerced flags.
module.declare_function("js_regexp_construct", I64, &[DOUBLE, DOUBLE]);
Expand Down
57 changes: 57 additions & 0 deletions crates/perry-runtime/src/gc/barrier_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,60 @@ pub(super) fn barrier_remembering_active() -> bool {
bump_write_barrier_trace_counter(BarrierTraceCounter::UnarmedSkips);
false
}

/// The runtime twin of codegen's `emit_parent_may_need_remembering_check`
/// (#7511): may a store into a **freshly allocated** GC parent be skipped
/// outright?
///
/// # Why this exists on the runtime side too
///
/// Every store emitted by the compiler is already gated this way — the
/// generated code reads the parent's `gc_flags` and, when `GC_FLAG_TENURED`
/// is clear *and* no incremental cycle is live anywhere, jumps over the
/// barrier call entirely. Runtime-Rust construction paths call
/// [`runtime_write_barrier_gc_slot`] unconditionally instead, so a native
/// header born in the nursery pays, per pointer slot: a page-map
/// classification for the malloc-parent probe, `barrier_child_prologue`, the
/// armed check, the dereferenceability test, the dirty-page-cache probe and a
/// second page-map classification in `barrier_parent_needs_remembering` —
/// all of which end at `ParentNotOldSkips` because the parent is young.
///
/// Measured on the segment-loop probe (region B, 60,000 reps, `sample`, main
/// thread): `js_regexp_new` constructs one `RegExp` per grapheme and its
/// barrier subtree — `runtime_write_barrier_gc_slot` /
/// `write_barrier_slot_decoded` / `write_barrier_decoded_parent` /
/// `mark_dirty_external_slot_page` / `remembered_child_needs_tracking` /
/// `classify_heap_generation_uncached` — is 739 of the 14,628 main-thread
/// samples, 32 % of that function's own subtree.
///
/// # Why it is sound
///
/// Exactly the two clauses the emitted gate uses, for exactly the two reasons
/// its doc comment gives:
///
/// * **`GC_FLAG_TENURED` clear** ⇒ the parent is not in the old generation,
/// so no old→young remembered-set entry can be owed. The flag is read
/// LIVE at the store, not claimed statically, because promotion can move
/// an object under any static proof (#7501) — a header that a collection
/// promoted between its allocation and this store reads TENURED here and
/// takes the full barrier.
/// * **`PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT == 0`** ⇒ no thread has
/// an incremental mark barrier installed, which is what makes it legal to
/// skip the SATB/insertion shading as well
/// ([`incremental_mark_barrier_globally_idle`]). Shading is not a
/// generational question and must never be dropped while a cycle is live,
/// so a non-zero count sends the store down the ordinary path.
///
/// # Safety
///
/// Dereferences `parent_addr - GC_HEADER_SIZE`. The caller must pass a live,
/// non-forwarded GC user pointer it has just allocated (or otherwise
/// validated) — the same contract `emit_parent_may_need_remembering_check`
/// places on its caller.
#[inline]
pub(crate) unsafe fn newborn_parent_needs_barrier(parent_addr: usize) -> bool {
if !super::barrier::incremental_mark_barrier_globally_idle() {
return true;
}
(*super::layout::header_from_user_ptr(parent_addr as *const u8)).gc_flags & GC_FLAG_TENURED != 0
}
Loading
Loading