Skip to content

perf(runtime): symbol probe stops taking the global mutex to say "no" (third latch instance) - #9177

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:perf/symbol-registry-prefilter
Aug 30, 2026
Merged

perf(runtime): symbol probe stops taking the global mutex to say "no" (third latch instance)#9177
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:perf/symbol-registry-prefilter

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Stacked on #9176 — its commit shows here until that merges. Third instance of the same defect.

The pattern, now confirmed three times

A latch that answers "has this ever happened?" is only a fast path for programs where it never happens. The moment it does, the latch is pure overhead and something narrower has to take over.

Every program that touches a well-known symbol creates one, so this latch stops discriminating almost immediately. After that is_registered_symbol_slow takes the process-global mutex for every probe, and the overwhelming majority are asking about pointers that are not symbols at all. The cc --help profile puts it at 0.80%, 1.9% at higher resolution.

I'd note the pattern in review terms: the remaining *_EVER_* flags guarding a global structure are where the fourth instance will be, and it's cheaper to grep for them than to wait for a profile to surface each one.

The change

An address range, widened before the insert — the same ordering, and for the same reason, as the latch arm directly above it, whose comment already says:

Arm before taking the lock, so the entry is never reachable while the latch still reads idle.

A pointer outside the range cannot be in the set, so rejecting is sound; accepting falls through to the lookup that was already there. Atomic rather than thread-local because this registry is global, unlike #9176's.

register_symbol_pointer is the only insertion site — that audit is the load-bearing part of the argument, since a missed insertion would be a silent false negative. Death pruning removes entries without narrowing the range, which is harmless: those pointers reach the lookup, which correctly says no.

Numbers

Quiet Mac mini, medians of 3. A loop doing typeof and Map lookups over pointer-tagged values — string keys fail js_is_symbol's tag check before ever reaching the registry, so a string-keyed benchmark measures nothing here, which cost me a probe to discover:

µs/iter
idle — latch never armed, the floor 4.75
armed, filter on 5.12
armed, filter off 6.31

19% off the armed path, and the arming penalty falls from 33% to 7.8%.

Gates

-D warnings clean; perry-runtime lib 2844/0, including all 15 registry_latch_probes — among them symbol_is_found_after_the_idle_fast_path_ran, unregistered_address_misses_every_probe, and the cross-thread shared_array_buffer_allocated_on_another_thread_is_found_here. Census, address-classification, file-size and raw-handle-debt lints all pass with none raised.

Kill switch

PERRY_SYMBOL_RANGE_FILTER=0 restores the unconditional mutex acquisition.

Summary by CodeRabbit

  • Bug Fixes
    • Improved symbol recognition performance by quickly rejecting pointers outside the known symbol address range.
    • Preserved symbol behavior after garbage collection moves symbols in memory.
    • Fixed symbol-related operations, including typeof, symbol-keyed properties, and iterator dispatch, after memory movement.
    • Added a configuration option to disable address-range filtering when needed.
  • Tests
    • Added regression coverage for symbol recognition after copying garbage collection.

@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 19e22664-94d7-43f6-bc7e-7e4cae1178c3

📥 Commits

Reviewing files that changed from the base of the PR and between ba90b0d and 32ecedb.

📒 Files selected for processing (4)
  • changelog.d/9177-symbol-registry-range-filter.md
  • crates/perry-runtime/src/gc/tests/copying_side_tables.rs
  • crates/perry-runtime/src/symbol.rs
  • crates/perry-runtime/src/symbol/gc_roots.rs

📝 Walkthrough

Walkthrough

The symbol registry now tracks minimum and maximum registered addresses. Lookups can reject out-of-range pointers before locking the registry. All symbol-pointer insertion paths widen the range, including garbage-collector forwarding paths. A copying-minor regression test verifies moved symbols remain discoverable.

Changes

Symbol registry range filter

Layer / File(s) Summary
Range tracking and lookup filtering
crates/perry-runtime/src/symbol.rs
The registry tracks atomic address bounds. Registration widens the bounds before insertion. Lookup uses a cached PERRY_SYMBOL_RANGE_FILTER setting and skips the registry lock for out-of-range pointers when filtering is enabled.
GC pointer rewriting and regression coverage
crates/perry-runtime/src/symbol/gc_roots.rs, crates/perry-runtime/src/gc/tests/copying_side_tables.rs, changelog.d/9177-symbol-registry-range-filter.md
GC pointer rewriting uses the centralized range-widening insertion helper. The copying-minor test verifies that a moved symbol remains registered and discoverable. The changelog documents the behavior and configuration switch.

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

Merge Risk: 🔵 Low · up to ba90b

The PR adds address-range filters before existing registry lookups to reduce mutex overhead without changing accepted registry membership. It is mergeable with owner awareness or follow-up because Symbol classification across some cross-thread handoffs could still depend on range visibility, so a targeted publication test is advisable.

Sequence Diagram(s)

sequenceDiagram
  participant SymbolRegistration
  participant SymbolAddressRange
  participant SymbolRegistry
  participant GarbageCollector
  participant SymbolLookup
  SymbolRegistration->>SymbolAddressRange: widen registered pointer bounds
  SymbolRegistration->>SymbolRegistry: insert symbol pointer
  GarbageCollector->>SymbolAddressRange: widen forwarded pointer bounds
  GarbageCollector->>SymbolRegistry: reinsert forwarded pointer
  SymbolLookup->>SymbolAddressRange: check pointer bounds
  SymbolLookup->>SymbolRegistry: lock and probe in-range pointer
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: avoiding the global mutex for negative symbol probes. It is specific and related to the third latch instance.
Description check ✅ Passed The description is detailed and covers the change, rationale, related issue, test results, benchmarks, and kill switch. It omits the template headings and checklist, but the required technical informa…
Docstring Coverage ✅ Passed Docstring coverage is 91.67% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 2 files.
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.
Full details: Description check

Explanation

The description is detailed and covers the change, rationale, related issue, test results, benchmarks, and kill switch. It omits the template headings and checklist, but the required technical information is mostly present.

✨ 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.

Ralph Küpper added 2 commits August 30, 2026 16:50
Third instance of the same defect, after PerryTS#9176's two. `is_registered_symbol`
has an idle latch, and the latch stops discriminating the moment a program
creates its first symbol — which every program touching a well-known symbol
does. After that `is_registered_symbol_slow` took the PROCESS-GLOBAL MUTEX on
every probe, including the overwhelming majority asking about pointers that
are not symbols at all. The `cc --help` profile puts it at 0.80% (1.9% at
higher resolution).

An address range, widened before the insert — the same ordering, and for the
same reason, as the latch arm directly above it, whose comment already says
"Arm before taking the lock, so the entry is never reachable while the latch
still reads idle". A pointer outside the range cannot be in the set, so
rejecting is sound; accepting falls through to the lookup that was already
there. Atomic rather than thread-local because this registry is global, and
death pruning removing entries without narrowing the range is harmless: those
pointers reach the lookup, which correctly says no.

`register_symbol_pointer` is the only insertion site.

Quiet Mac mini, medians of 3, a loop doing `typeof` and Map lookups over
pointer-tagged values (string keys fail `js_is_symbol`'s tag check before ever
reaching the registry, so they measure nothing here):

  idle — latch never armed, the floor    4.75 us/iter
  armed, filter on                       5.12
  armed, filter off                      6.31

so 19% off the armed path, and the arming penalty falls from 33% to 7.8%.

THE PATTERN, stated because this is the third site: a latch that answers "has
this EVER happened?" is only a fast path for programs where it never happens.
The moment it does, the latch is pure overhead and something narrower has to
take over. The remaining `*_EVER_*` flags guarding a global structure are
where the fourth instance will be.

perry-runtime lib 2844/0, including all 15 `registry_latch_probes` — among
them `symbol_is_found_after_the_idle_fast_path_ran`,
`unregistered_address_misses_every_probe`, and the cross-thread SAB case.

Kill switch: PERRY_SYMBOL_RANGE_FILTER=0.

Claude-Session: https://claude.ai/code/session_01F1dt1jfzK2cheMZyus6y6p
SYMBOL_POINTERS is a GC root registry: the collector re-keys entries when it
moves a symbol. Only the registration path widened the range filter, so a
symbol evacuated past its allocation's bounds was rejected by
is_registered_symbol while still live and registered. Fold widening into the
insert so all three sites share it, and cover it through the public probe
after a real copying minor.
@proggeramlug
proggeramlug force-pushed the perf/symbol-registry-prefilter branch from ba90b0d to 32ecedb Compare August 30, 2026 15:23
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged, rebased onto main (the #9176 commit this was stacked on is already there, so that patch was skipped as a duplicate) with a correctness fix pushed onto the branch.

The bug. This lifts the range-filter pattern from #9176, but SYMBOL_POINTERS differs from the buffer registries in a way that breaks it: it is a GC root registry. The collector re-keys an entry to the symbol's new address every time it moves one — your own doc comment for the death-pruning code says the set was "only forward-renamed on moves". A range filter is sound only while every path that puts a pointer into the set widens the bounds first, and there are three:

  1. register_symbol_pointer — the one you widened
  2. rewrite_symbol_pointer_metadata_if_forwarded — the per-slot forwarding rewrite
  3. scan_symbol_pointer_metadata_roots_mut — the bulk rewrite, and the one a copying minor actually drives

A symbol evacuated past the bounds its own allocation established was therefore a live, registered symbol that is_registered_symbol reported as "not a symbol" — losing typeof, symbol-keyed property lookup and Symbol.iterator dispatch for it. Reachable on any minor that moves a symbol into a region outside the observed range.

Widening and inserting are now one operation (insert_symbol_pointer_in_set), which is what makes the three sites uniform instead of three chances to forget. I also routed the test-only set rebuild in test_clear_symbol_side_table_roots through it.

Worth flagging about the test, because it changed my mind mid-fix: my first patch only fixed site 2, and the new test failed anyway — which is how I found site 3. Had I trusted the fix and shipped, this would have gone in still broken, because the existing test_copying_minor_rewrites_symbol_side_table_roots_and_lookups passes either way: it asserts membership with test_symbol_pointer_root_contains, which reads the set directly and never touches the filter. The entry is in the set; the probe just refuses to look. test_copying_minor_keeps_moved_symbol_visible_to_the_range_filter goes through the public probe after a real copying minor, and resets the range around a single registration first — a wide range left by earlier tests in the same binary would otherwise admit the moved address by luck and make the assertion vacuous.

Validation: perry-runtime 2847 passed / 0 failed at RUST_TEST_THREADS=1; all 60 lint gates green. End-to-end probe (200 symbols, symbol-keyed lookups, Symbol.for/keyFor, description, spread through Symbol.iterator, with 60k-object churn between registration and use) matches node 26.5.1 byte-for-byte under default, PERRY_GC_FORCE_EVACUATE=1, and PERRY_GC_SCHEDULE_SEED=42 PERRY_GC_SCHEDULE_RATE=1. The unit test is the discriminating one, though — that probe can't guarantee a moved address lands outside the range, which is exactly why the unit test controls the range instead of hoping.

Added the changelog.d/ fragment, which the branch was missing. PERRY_SYMBOL_RANGE_FILTER is a runtime knob, so it's correctly outside codegen_env_vars_are_build_cache_inputs.

Worth applying the same question to any further instance of this pattern: is the registry a GC root registry, and if so does the forwarding rewrite widen? That's the difference between #9176 (safe — buffer addresses are pruned on death, never re-keyed) and this one.

@proggeramlug
proggeramlug merged commit cfe5b3b into PerryTS:main Aug 30, 2026
16 of 20 checks passed
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 31, 2026
Hoist PerryTS#9177's symbol address range out of is_registered_symbol_slow into
is_registered_symbol as a RegistryAddrWindow, so the common negative answer
costs no call; add the same window to is_uint8array_buffer. Both rejections
are re-derived from the authoritative tables under debug_assertions.

Not yet measured on cc --help.
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 31, 2026
Hoist PerryTS#9177's symbol address range out of is_registered_symbol_slow into
is_registered_symbol as a RegistryAddrWindow, so the common negative answer
costs no call; add the same window to is_uint8array_buffer. Both rejections
are re-derived from the authoritative tables under debug_assertions.

Not yet measured on cc --help.
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 31, 2026
Hoist PerryTS#9177's symbol address range out of is_registered_symbol_slow into
is_registered_symbol as a RegistryAddrWindow, so the common negative answer
costs no call; add the same window to is_uint8array_buffer. Both rejections
are re-derived from the authoritative tables under debug_assertions.

Not yet measured on cc --help.
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 31, 2026
Hoist PerryTS#9177's symbol address range out of is_registered_symbol_slow into
is_registered_symbol as a RegistryAddrWindow, so the common negative answer
costs no call; add the same window to is_uint8array_buffer. Both rejections
are re-derived from the authoritative tables under debug_assertions.

Not yet measured on cc --help.
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 31, 2026
Hoist PerryTS#9177's symbol address range out of is_registered_symbol_slow into
is_registered_symbol as a RegistryAddrWindow, so the common negative answer
costs no call; add the same window to is_uint8array_buffer. Both rejections
are re-derived from the authoritative tables under debug_assertions.

Not yet measured on cc --help.
proggeramlug added a commit that referenced this pull request Aug 31, 2026
…9225's linear scan gated — cc --help −1.25% instructions, −2.37% cycles (#9291)

* wip(runtime): address windows for the symbol and Uint8Array probes

Hoist #9177's symbol address range out of is_registered_symbol_slow into
is_registered_symbol as a RegistryAddrWindow, so the common negative answer
costs no call; add the same window to is_uint8array_buffer. Both rejections
are re-derived from the authoritative tables under debug_assertions.

Not yet measured on cc --help.

* perf(runtime): a monotone address FILTER in front of the symbol and class-prototype probes

Round 2 (#9272) put an inline [lo, hi] address window in front of the buffer
and typed-array probes. Measured against the four probes it named as follow-up,
a window is the wrong shape for two of them and the right shape for one:

  is_registered_symbol                378,163 calls, window rejects 38.3%
  is_registered_class_prototype_object 26,290 calls, window rejects 54.0%
  is_uint8array_buffer                537,921 calls, window rejects 100%

Symbols and class prototypes are ordinary GC-heap objects, so [lo, hi] grows to
cover most of the heap. RegistryAddrFilter is the same monotone contract over a
1024-bit Bloom filter instead of a range; replaying each probe's real argument
stream from a cc --help run, it rejects 99.58% and 99.05%.

is_uint8array_buffer keeps the cheaper window (100% rejection, 0 true answers).

Every rejection is re-derived from the authoritative table under
debug_assertions, so a registration route added without admitting panics in the
first test that touches it.

* changelog: registry-probe address filter (symbol, class prototype) + Uint8Array window

* test(runtime): keep TEST_SYMBOL_REGISTRY_PROBES meaning 'entry past the latch'

Two sabotage checks in other suites defeat a cheaper upstream screen and require
this counter to move; counting filter admissions instead made them fail.
Filter admissions get their own counter, mirroring
typedarray::TEST_TA_WINDOW_ADMITTED_PROBES.

* test(runtime): the unregistered-scratch probe sweep covers the class-prototype probe too

* docs(runtime): the descriptor-target scan comment's premise is false for every bundle (#9225)

* docs(runtime): name the filter's saturation regime and the knob for it

* fix(runtime): the debug audits use try_lock/try_read, not lock/read

The rejection path never took either lock, so a blocking audit could hang on a
caller the audited code would not have. Sabotage-checked: removing the admit
from either registration funnel fails 1 test (symbol) and 3 tests (class
prototype), so the audits demonstrably run.

* docs(runtime): the symbol side's comments and the funnel's name say 'filter', not 'range'

* docs(runtime): bits accrue per admission (the collector re-keys both tables); record the end-of-run false-positive rate

* revert: unrelated cargo fmt reformat of two perry-codegen files

They are fmt-dirty on pristine main (42d0f45) from #9274/#9279; a stray
`cargo fmt --all` picked them up. Reported separately, not fixed here.

* test: split the #8067 shape-authority tests out of parent_static.rs

parent_static.rs was at 1992 lines on main and this PR adds 52, crossing the
2000-line cap. Extracts the inline shape_authority_tests_8067 module to a
sibling under parent_static/; body unchanged.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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