Skip to content

perf(runtime): a Bloom filter for the heap-allocated registries, and #9225's linear scan gated — cc --help −1.25% instructions, −2.37% cycles - #9291

Merged
proggeramlug merged 12 commits into
PerryTS:mainfrom
proggeramlug:perf/registry-probes-r3
Aug 31, 2026

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Follow-up to #9272 (merged), covering the four probes it named as the largest remaining members of the family. Also closes the perf half of #9225.

21 interleaved reps, two independent runs instructions (median) cycles (median) IPC
run 1 −1.25% −2.37% 2.0062 → 2.0293
run 2 −1.27% −2.40% 2.0069 → 2.0301

Cycles fall by roughly twice instructions and IPC rises. --help output byte-identical to node (9,175 bytes, rc=0) in all three arms. Binary +24,856 bytes (+0.007%).

Measurement cancelled half the premise

Exact uprobe/uretprobe counts on one cc --help run:

probe calls "yes" share
is_registered_symbol_slow 378,163 622 (1 in 608) 0.65%
is_uint8array_buffer_slow 537,921 0 0.22%
is_registered_class_prototype_object 26,290 122 (1 in 216) 0.46%
is_registered_box_ptr 211,148 205,640 — 97.4% YES 0.22%

is_registered_box_ptr is not a member of this family and is deliberately untouched. Its four callers (js_closure_set_box_capture_ptr, js_box_get_bits, js_box_set_bits, js_box_capture_cell_ptr — 100% between them) ask it about actual box pointers on the async-locals path. Any rejection filter could remove at most 2.6% of its 0.22%. If it is attacked later the target is the cost of the hit — a positive cache — not a filter.

And #9272's address window is the wrong shape for two of the others. Symbols and class prototypes are ordinary gc_malloc'd heap objects, so [lo, hi] spans ~280 MB of heap. Replaying each probe's real argument stream against the window its own registrations would build:

probe a window rejects a 1024-bit/k=3 Bloom filter rejects
symbol 38.3% 99.58%
class prototype 54.0% 99.05%
uint8array 100%

That is also why #9177's symbol range only ever removed 38% and the rest still paid the process-global mutex.

The change

RegistryAddrFilter beside #9272's window, same monotone contract (admit before publish, bits only ever set, false = definitively absent), 1,024 bits, k=3, AcqRel fetch_or with no pre-check — for the reason #9272 documents: a thread that skips the RMW performs no acquire.

Attribution, 100% of calls accounted

  • symbol: 50 sites, broad — proxy::reflect_value_is_object 52.8%, js_is_symbol 25.6%, dispatch_primitive 9.5%; top 12 = 99.4%. No caller-side fix exists.
  • class prototype: one caller, 100%descriptor_state::disable_inline_guards_for_descriptor_target, which genuinely needs the answer, so the fix belongs below it.
  • uint8array: 8 sites, typed_array_addr_from_value 77.7%.

Safety: machine-checked, then sabotaged

Every rejection is re-derived from the authoritative table under debug_assertions. That is not left as an assertion — it is proved able to fail: deleting the admit from the symbol funnel fails 1 test, from the class-prototype funnel fails 3, and forcing may_contain to true fails 5.

Audits use try_lock/try_read: the rejection path never took those locks, so a blocking audit would introduce a deadlock the audited code cannot have. Verified compiled out of release — grep for the panic strings in the shipped binaries returns 0 hits.

Answer census re-run on the shipped binary: symbol 378,163 → 1,492 calls with all 622 genuine "yes" preserved (870 false positives, 0.23%); uint8array 537,921 → 0; class prototype 122 yes / 26,168 no, identical to baseline.

2,893 runtime tests pass, plus 14 targeted integration tests (decl-prototype reverse lookup, class-prototype assignment, user Symbol.iterator, setPrototypeOf chain). global_sink_isolation.py, check_thread_locals.py, check_gc_scanner_latches.py OK.

One property to keep in view

The filter's bits accrue per admission, not per live entry — the collector re-keys both tables, so an evacuated symbol is admitted again at its new address. Saturation degrades to today's behaviour, never to a wrong answer, and WORDS is the knob. The number to watch is the end-of-run false-positive rate (0.23% here), not the registration count.

Unrelated, noted

cargo fmt --all -- --check fails on pristine main (42d0f45685) — 6 hunks in perry-codegen/src/stmt/loops.rs and stable_packed_accumulator.rs from #9274/#9279, reproduced on a second machine with the pinned nightly. This branch does not touch those files.

Ralph Küpper added 12 commits August 31, 2026 17:21
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.
…lass-prototype probes

Round 2 (PerryTS#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.
…he 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.
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.
…tables); record the end-of-run false-positive rate
They are fmt-dirty on pristine main (42d0f45) from PerryTS#9274/PerryTS#9279; a stray
`cargo fmt --all` picked them up. Reported separately, not fixed here.
…ic.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.
@proggeramlug
proggeramlug force-pushed the perf/registry-probes-r3 branch from 0b9c66d to 4654565 Compare August 31, 2026 15:21
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 32 seconds.

View limit details

Limit details: You’ve used all 8 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c8c139a-ba02-4fdc-ad48-ae360564830b

📥 Commits

Reviewing files that changed from the base of the PR and between 7a6d5a1 and 4654565.

📒 Files selected for processing (16)
  • changelog.d/registry-probe-address-filter.md
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry-codegen/src/stmt/stable_packed_accumulator.rs
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/buffer/mod.rs
  • crates/perry-runtime/src/gc/tests/copying_side_tables.rs
  • crates/perry-runtime/src/object/class_gc_roots.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/class_registry/gc_roots.rs
  • crates/perry-runtime/src/object/class_registry/parent_static.rs
  • crates/perry-runtime/src/object/class_registry/parent_static/shape_authority_tests_8067.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • crates/perry-runtime/src/object/descriptor_state.rs
  • crates/perry-runtime/src/registry_latch.rs
  • crates/perry-runtime/src/registry_latch_probes.rs
  • crates/perry-runtime/src/symbol.rs

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

Merged, with one file split pushed onto the branch.

This is the best-handled PR in this family, and the reason is specific. It is the fifth registry-probe change; I found real defects in the first two, and both were the same shape — a "the writer set is complete" claim that wasn't. #9176 armed its latch on one of two insert paths into the external-Uint8Array registry; #9177 didn't widen its range when the collector re-keys a moved symbol. This PR closes that class three separate ways:

  1. It admits on both GC re-key paths. note_class_prototype_object_registered now runs in the bulk scanner and the per-slot step, with a comment saying the per-slot one "carries the same obligation as the bulk scanner." That is exactly the omission that broke perf(runtime): symbol probe stops taking the global mutex to say "no" (third latch instance) #9177.
  2. It carries perf(runtime): an inline address window in front of the registry probes — cc --help −5.16% instructions, −6.30% cycles #9272's machine-check forward, so the enumeration is no longer load-bearing — an inserter added without admitting panics in the first test that touches it.
  3. It improves the regression test rather than just keeping it passing. My perf(runtime): symbol probe stops taking the global mutex to say "no" (third latch instance) #9177 test asserted a single-point range so the move had to land outside it. A Bloom filter can accept an address by coincidence, which would have made that assertion silently vacuous — so you snapshot the pre-move filter and assert the post-move address is not already accepted by it. That is the anti-vacuity property done properly, and it is a strictly better test than the one it replaces.

Verified by sabotage rather than by reading, and the first attempt is worth reporting because it was worthless: I removed the admit_symbol_pointer call at symbol.rs:575 and all four tests passed — because insert_symbol_pointer_in_set admits too (my own #9177 fix folded it into the insert), so that call is redundant. Only sabotaging the one inside the insert reproduces the defect:

a symbol evacuated to 0x4bf62390008, which the filter its allocation
established rejects, is still live and registered — the forwarding
rewrite must have admitted the new address

So the filter's coverage of the re-key path is verified, not asserted. A sabotage that doesn't fail proves nothing, and I nearly logged the first one as a pass.

On the measurement, the part I'd single out is that it cancelled half its own premise: the uretprobe counts show is_uint8array_buffer_slow answering "yes" zero times in 537,921 calls, and is_registered_symbol_slow 622 times in 378,163. Publishing the counts that undercut the original framing, rather than the ones that support it, is what makes the −2.37% cycles credible — and cycles again falling faster than instructions with IPC rising is the check that separates real removal from bookkeeping.

What I fixed: parent_static.rs was at 1992 lines on main and this PR adds 52, so check_file_size.sh went red. I extracted the inline shape_authority_tests_8067 module to a sibling under parent_static/ — body unchanged, 1872 now.

Validation: perry-runtime 2894 passed / 0 failed at RUST_TEST_THREADS=1; perry-codegen 31 suites / 0 failures; all 60 lint gates plus the TLS and root-holder checkers. Five differential probes byte-identical to node 26.5.1, and the shape/class-registry probe identical again under PERRY_GC_FORCE_EVACUATE=1 and PERRY_GC_PROTECT_FROMSPACE=1 with a seeded aggressive schedule.

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