Skip to content

perf(object): let a RegExp answer the descriptor-summary probe - #9862

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/regex-lastindex-meta
Closed

perf(object): let a RegExp answer the descriptor-summary probe#9862
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/regex-lastindex-meta

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

What this is

set_last_index_throwing asks get_property_attrs(re, "lastIndex") on every
global or sticky test()/exec(), because a user can make lastIndex
non-writable and the spec's Set(R, "lastIndex", n, true) must then throw
(test262 prototype/{exec,test}/y-fail-lastindex-no-write). That question is
meant to be answered by #6759 phase C2's per-object meta summary without
touching the descriptor tables
— but may_have_descriptor_entry reached the
summary through prototype_chain::meta_capable_object, which answers only for
GC_TYPE_OBJECT. A RegExp is its own cell type, so the filter returned the
conservative "maybe" for every RegExp receiver and the slow probe ran every
time: key.to_string() — a String allocation — plus a SipHash of
(usize, String) and a map lookup that was always going to miss.

The capability was already there and simply unwired. #6759 phase 1 unified
the metadata edge behind object::cell_meta_slot, which answers for Object,
Error, Map, Set, RegExp, Promise and Date; RegExpHeader::meta is traced by
GcLayoutSlotKind::RegExpFields and moves with its header. So this adds no
state and no new invariant. It asks the narrower question the descriptor
summary actually needs (descriptor_summary_meta) instead of the
ObjectHeader-shaped one meta_capable_object's other callers need.

The general rule, which is the point

This is not a RegExp patch. All five descriptor-summary sites
(get_property_attrs, get_accessor_descriptor, own_descriptor_may_cover_key,
owner_may_have_descriptor_entries, note_meta_descriptor_key*) now share one
predicate over cell_meta_slot, so every cell type that owns a meta edge can
answer a descriptor probe from its own header
— Error, Map, Set, Promise and
Date as well as RegExp. RegExp is merely the type claude-code drives hardest.

The three-way answer is the contract

None        the cell type has no meta edge -> caller must stay conservative
Some(null)  the edge exists, no record was ever installed -> PROVES absence
Some(meta)  read the summary words

Collapsing the first two into one None would turn a conservative maybe into
a false no for the cell types that still lack an edge (Temporal, the
typed-array views). That is why descriptor_summary_meta returns
Option<*mut ObjectMeta> rather than a bool.

Counters — measured, pre-registered, and one prediction missed

One 400-char streamed reply through the offline rig (stream_scale.py, chunk
100, sandbox rx, PERRY_REGEX_DIAG armed), on a binary relinked from this
branch's runtime source. One binary serves both arms: before this change
desc_regexp_meta_negative is 0 by construction, because the filter answered
"maybe" for every RegExp receiver — so the negative count measured here is
exactly the work this change removes.

[regex-diag] t=10.1s new=89455 site_hit=88380 compiles std=208 fancy=88 repeat=32
  test=170201 test_global=84398 desc_regexp_probes=424035 desc_regexp_meta_negative=424035
quantity pre-registered measured verdict
desc_regexp_probes test_global (~96,500); ~0 would mean inert 424,035 missed — 5.02× the prediction, not inert
desc_regexp_meta_negative probes 424,035 = 100.000 % of probes as registered
compiles std / fancy / repeat unchanged vs the rx A baseline (211 / 88 / 33) 208 / 88 / 32 −3 / 0 / −1
new, site_hit unchanged (~101.9k, ~100.9k) 89,455 / 88,380 −12.2 % / −12.4 %

The missed prediction, stated as a miss. The pre-registration derived the
probe count from one call site — set_last_index_throwing, at ~1 probe per
global test(). The counter, deliberately, sits at the shared predicate, so
it also counts the other descriptor-summary consumer that meets a RegExp
receiver: js_native_call_method's get_accessor_descriptor on the receiver of
every native method call, which for re.test(s) is the same RegExp — plus the
Set(obj, "lastIndex", 0, true) that RegExpInitialize step 12 runs on every
construction, and this loop constructs a fresh RegExp per call (84,387 news
against 84,398 global tests). Measured rate: 5.02 probes per global test().
The split between those sites is not separately counted and is not claimed
here; the total is. The direction of the registration holds and the magnitude is
five times larger than predicted, which strengthens rather than rescues the
change.

new / site_hit "unchanged" also failed, and it was the wrong statement to
register.
The two captures are different runs with different diag windows
(46.2 s vs 10.1 s). All four per-call counters moved together by 12.2–12.5 %
while every ratio among them is preserved: site_hit/new 0.9895 → 0.9880,
test_global/new 0.9468 → 0.9435, test/test_global 2.0154 → 2.0167. The
compile counts, which are a property of the program rather than of how many
renders the turn did, moved by at most one pattern each (the distinct-pattern
table went 1,056 → 1,061). So the workload's shape is identical and its
absolute level is not — an invariant that should have been registered as a
ratio. Nothing here indicates a semantic change, and no regex is compiled
differently.

Absolute counts are a property of the sandbox project — the same interaction
reads test_global 96,511 in rx and 180,802 in int — so 5.02 probes per
global test() is the portable number
, and the removed work is one String
allocation plus one SipHash plus one always-missing map lookup, each.

Correctness

  • cargo test --profile gcaudit -p perry-runtime -- --test-threads=1:
    3,171 passed / 0 failed / 4 ignored, first attempt.
    This is the gate that
    matters here: the meta record is GC-allocated and moves with its owner.
  • Both new tests ran and passed in that run.
  • No new warnings; the -D warnings gate is clean.

Install and probe move together — that is the safety argument. Every insert
into property_descriptors / accessor_descriptors routes through
set_property_attrs / set_accessor_descriptor and therefore through
note_meta_descriptor_key, which now uses the installing twin of the same
predicate (descriptor_summary_meta_ensure). A probe widened without its
install would answer "proven absent" for an owner that really has a descriptor.
js_regexp_new writes meta = null on every construction, so a fresh header at
a recycled address cannot inherit a dead tenant's bits. The touches outside this
module are all removals, which can only make a probe more conservative.

Sabotage

  • a_fresh_regexp_proves_lastindex_absent_without_probing_the_tables asserts on
    the filter's answer via a #[cfg(test)] view of
    may_have_descriptor_entry, not on the value it filters to. Asserting only
    get_property_attrs(...).is_none() would pass against a change that did
    nothing — that is equally true when the fast negative never fires. The test
    also asserts its own premise (obj_type == GC_TYPE_REGEXP), so it cannot
    quietly become a test about a shaped object.
  • a_regexp_with_a_non_writable_lastindex_is_still_found_by_the_probe is the
    half that makes the fast negative safe: after
    Object.defineProperty(re, "lastIndex", {writable:false}) the probe must send
    the caller to the table and the descriptor must read back non-writable. A
    probe widened without its install fails here, on that named assertion — and
    set_last_index_throwing would have silently stopped throwing.
  • The same test asserts that a different key on the same owner
    ("source") still takes the fast negative: the summary is per key, not per
    owner, so widening it must not blunt it.
  • Reverting descriptor_summary_meta to the Object-only
    meta_capable_object fails both new tests on their own named assertions.

What is NOT claimed

No CPU number. This is a counter result only. In the leaf profile of a
400-char reply the two symbols this change acts on rank at
get_accessor_descriptor 1.1 % and get_property_attrs 0.3–0.5 % of active
samples, and this change removes only the RegExp-receiver share of each — under
10 % of the turn by count, below what a shared 10-core box can resolve. It
therefore gets no dedicated clean-host arm
; it rides the next combined
measurement, labelled. It is offered on the mechanism, the counter, and
gcaudit, not on a stopwatch.

Independent of #9845 (RegExp header in the nursery) — based on main, no
shared files with that stack.

https://claude.ai/code/session_014knX724SYDogwzsXybCGxp

Summary by CodeRabbit

  • Performance

    • Improved RegExp property checks by efficiently confirming when descriptor metadata is absent, reducing unnecessary lookups.
  • Diagnostics

    • Added diagnostic counters for RegExp descriptor probes and cases resolved without descriptor-table searches.
  • Compatibility

    • Preserved existing lastIndex behavior, including errors when writing to non-writable descriptors.
  • Tests

    • Added coverage for default RegExp objects and custom non-writable lastIndex descriptors.

Ralph Küpper added 2 commits September 6, 2026 10:18
`set_last_index_throwing` asks `get_property_attrs(re, "lastIndex")` on every
global or sticky `test()`/`exec()`, because a user may make `lastIndex`
non-writable and the spec's `Set(R, "lastIndex", n, true)` must then throw.
That question is meant to be answered by PerryTS#6759 phase C2's per-object meta
summary without touching the tables — but `may_have_descriptor_entry` reached
the summary through `meta_capable_object`, which answers only for
`GC_TYPE_OBJECT`. A `RegExp` is its own cell type, so the filter returned the
conservative "maybe" for every RegExp receiver and the probe ran:
`key.to_string()` — a `String` allocation — plus a SipHash of `(usize, String)`,
on roughly 96,500 global `test()` calls per 400-character claude-code reply.

The capability was already there and simply unwired. PerryTS#6759 phase 1 unified the
metadata edge behind `cell_meta_slot`, which answers for Object, Error, Map,
Set, RegExp, Promise and Date; `RegExpHeader::meta` is traced by
`GcLayoutSlotKind::RegExpFields` and moves with its header. So this adds no
state and no new invariant: it asks the narrower question the summary actually
needs (`descriptor_summary_meta`) instead of the `ObjectHeader`-shaped one the
other callers of `meta_capable_object` need, and every cell type with a meta
edge benefits, not only RegExp.

The three-way answer is the contract. `None` means the cell type has no meta
edge and the caller must stay conservative; `Some(null)` means the edge exists
and no record was ever installed, which PROVES absence; `Some(meta)` means read
the summary words. Collapsing the first two would turn a conservative "maybe"
into a false "no" for the types that still lack an edge.

Install and probe move together, which is the safety argument: all five
descriptor-summary sites now share one predicate, so an owner whose install set
the key bit is always found. Every insert into `property_descriptors` /
`accessor_descriptors` routes through `set_property_attrs` /
`set_accessor_descriptor` and therefore through `note_meta_descriptor_key`; the
touches outside this module are all removals, which can only make a probe more
conservative. `js_regexp_new` writes `meta = null` on every construction, so a
fresh header at a recycled address cannot inherit a dead tenant's bits.

Counters, diagnostic only and armed with `PERRY_REGEX_DIAG`:
`desc_regexp_probes` (RegExp receivers this filter sees) and
`desc_regexp_meta_negative` (those it now proves absent). The second was 0 by
construction before this change.

Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp
@proggeramlug proggeramlug added the run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke label Sep 6, 2026
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: d58915a7-4ed1-46b6-9a0b-75466cd80830

📥 Commits

Reviewing files that changed from the base of the PR and between f96a6c3 and 0d6eaf7.

📒 Files selected for processing (5)
  • changelog.d/9862-regexp-descriptor-summary-probe.md
  • crates/perry-runtime/src/hot_diag.rs
  • crates/perry-runtime/src/object/descriptor_state.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/regex/tests.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The descriptor-summary path now reads metadata from RegExp cells. RegExp probes can prove absent keys without table lookups, preserve descriptor detection, and report diagnostic counters. Tests cover fresh RegExp objects and non-writable lastIndex descriptors.

Changes

RegExp descriptor summary

Layer / File(s) Summary
Generalized descriptor summary path
crates/perry-runtime/src/object/descriptor_state.rs
Metadata helpers now support cells with meta edges, including RegExp. Probe and metadata-install paths use the generalized helpers.
RegExp probe diagnostics
crates/perry-runtime/src/object/descriptor_state.rs, crates/perry-runtime/src/hot_diag.rs
RegExp descriptor probes increment counters for total probes and metadata-proven negative results. The diagnostic renderer outputs both counters.
RegExp probe validation
crates/perry-runtime/src/object/mod.rs, crates/perry-runtime/src/regex/tests.rs, changelog.d/9862-regexp-descriptor-summary-probe.md
Test-only access exposes the probe helper. Tests cover absent lastIndex, non-writable lastIndex, and unrelated keys. The changelog records the metadata states and diagnostics.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 0d6ea

RegExp descriptor probes now avoid unnecessary table lookups while preserving detection of installed non-writable lastIndex descriptors. The covered behavior and passing runtime tests indicate no remaining merge-blocking risk.

Sequence Diagram(s)

sequenceDiagram
  participant RegExp
  participant may_have_descriptor_entry
  participant descriptor_summary_meta
  participant DescriptorTables
  participant RegexDiag
  RegExp->>may_have_descriptor_entry: probe descriptor key
  may_have_descriptor_entry->>descriptor_summary_meta: read RegExp metadata
  descriptor_summary_meta-->>may_have_descriptor_entry: absent or possibly present
  may_have_descriptor_entry->>RegexDiag: record RegExp probe
  alt metadata proves key absent
    may_have_descriptor_entry-->>RegExp: return absent
  else metadata permits a descriptor
    may_have_descriptor_entry->>DescriptorTables: check descriptor tables
    DescriptorTables-->>may_have_descriptor_entry: return descriptor result
    may_have_descriptor_entry-->>RegExp: return result
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: enabling RegExp objects to answer descriptor-summary probes. It is concise and specific.
Description check ✅ Passed The description is detailed and covers the change summary, implementation details, related issues, validation commands and results, correctness risks, and test coverage. It uses custom headings instea…
Docstring Coverage ✅ Passed Docstring coverage is 92.86% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 4 files. (1 skipped: 1 …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train #9883. Validated as a tree: 64/64 lint gates, and perry-runtime/codegen/hir/stdlib all green (5,910 tests, 0 failures). Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant