Skip to content
Merged
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
121 changes: 121 additions & 0 deletions changelog.d/registry-probe-address-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
### Performance

- **The symbol and class-prototype probes answer "no" from an inlined Bloom
test instead of a mutex acquisition and a linear scan; the Uint8Array probe
gets #9272's address window.**

#9272 put an inline `[lo, hi]` address window in front of `is_registered_buffer`
and `lookup_typed_array_kind`, and named four probes as the next-largest
members of the same family. Measured on a symbolized `claude --help`
(`is_registered_symbol_slow` 0.65%, `is_registered_class_prototype_object`
0.46%, `is_registered_box_ptr` 0.22%, `is_uint8array_buffer_slow` 0.22%), a
window is the right fix for exactly one of them.

Exact uprobe/uretprobe counts from one run — not inferred from the profile:

| probe | calls | answered "yes" | a window rejects | a filter rejects |
|---|---|---|---|---|
| `is_registered_symbol` | 378,163 | 622 | 38.3% | **99.58%** |
| `is_uint8array_buffer` | 537,921 | **0** | **100%** | — |
| `is_registered_class_prototype_object` | 26,290 | 122 | 54.0% | **99.05%** |
| `is_registered_box_ptr` | 211,148 | **205,640 (97.4%)** | — | — |

The window column is not a guess: it replays each probe's real argument stream
against the window its own registrations would have built, widening it exactly
as `RegistryAddrWindow::admit` does. The filter column does the same against a
bit-for-bit simulation of the filter that ships here.

**Why a window fails on two of them.** Buffers and typed arrays sit in their
own allocations; symbols and class prototypes are ordinary `gc_malloc`'d heap
objects, interleaved with everything else the program allocates, so `[lo, hi]`
grows to span ~280 MB of heap and stops discriminating. `RegistryAddrFilter`
is the same monotone contract — admit before you publish, bits only ever
set, `false` means "definitively absent" — over a 1,024-bit Bloom filter
instead of a range. Both shapes are in the tree on purpose; pick by
measurement.

For symbols this replaces #9177's `[lo, hi]` range, which sat *inside*
`is_registered_symbol_slow`: a rejected probe still paid the out-of-line call
and a `OnceLock` load for an env kill switch before reaching the two bound
loads. `PERRY_SYMBOL_RANGE_FILTER` is gone with it — it guarded an
enumeration of the inserters, and the debug-build audit below is strictly
stronger than an env var nobody sets.

For class prototypes the filter sits in front of a `map.values().any(…)`
**linear scan** (#9225) reached through a thread-local and an `RwLock`, whose
one caller — `descriptor_state::disable_inline_guards_for_descriptor_target`,
100% of the 26,290 calls — runs on every `Object.defineProperty`, and a bundle's
`__export(exports, { … })` init runs thousands of those. This does **not**
close #9225: a false positive still pays the scan, so the table's O(n) slope
survives at ~1% of its strength, and the O(1) inverse index that issue asks
for is still the right structural fix.

- **`is_registered_box_ptr` is not a member of this family, and the measurement
is what says so.** It answers **"yes" 205,640 times out of 211,148** (97.4%)
— the opposite regime from every other probe here, because its four callers
(`js_closure_set_box_capture_ptr`, `js_box_get_bits`, `js_box_set_bits`,
`js_box_capture_cell_ptr`, 100% of calls between them) ask it about *actual
box pointers* on the async-locals read/write path. A filter in front of it
could remove at most 2.6% of its 0.22%. It is left alone deliberately; if it
is attacked again, the target is the cost of the **hit** — the direct-mapped
positive cache in `tls_hot` — not a rejection filter.

The filter is 1,024 bits with three probes per address, sized for the ~160
entries this corpus registers. A much larger bundle can saturate it —
`CLASS_PROTOTYPE_OBJECTS` grows by one entry per ES5-transpiled constructor —
and that is a *win* cliff, not a correctness one: a saturated filter is
exactly the code that ran before it existed. Raising the constant is a
one-line change; it is deliberately not raised on speculation, because 1,024
bits is the size every number above was measured at.

Bits accrue per *admission*, not per live entry: both tables are re-keyed by
the collector, so an evacuated symbol or prototype is admitted again at its
new address and the old address's bits stay set. The number that matters is
therefore the false-positive rate at END of run, and it was measured on the
shipped binary rather than assumed — see the census below.

#### The census on the shipped binary

Re-running the uretprobe answer census on the built `claude --help` binary,
after the change:

| probe | calls | "yes" |
|---|---|---|
| `is_registered_symbol` | 378,163 → **1,492** | 622 → **622** |
| `is_uint8array_buffer` | 537,921 → **0** | 0 → 0 |
| `is_registered_class_prototype_object` | 26,290 → 26,290 | 122 → **122** |

Every genuine "yes" survives. The symbol filter's 1,492 admissions are 622 real
answers plus 870 false positives — 0.23% of the 377,541 negatives, at the end
of a run in which the population was evacuated and re-admitted throughout. The
class-prototype probe is still *entered* the same number of times (the filter
is inside the function, which has three call sites and is not `#[inline]`);
what it no longer does is the scan, which is where its 0.46% lived.

`--help` output stayed byte-identical to node — 9,175 bytes, rc=0 — in every
arm, before and after.

#### Why it cannot misclassify

These probes classify pointers, so a wrong answer is type confusion rather than
a slow path. A Bloom filter has false positives and no false negatives, which
is the asymmetry the probes need: a false positive costs the ordinary lookup
that was already there.

That leaves one obligation — every registration must admit before it publishes
— and it is not left as an enumeration of writers. Enumerations of writer sets
have produced silent wrong answers in this codebase twice recently, so under
`debug_assertions` **every rejection is re-derived from the authoritative
table**: `SYMBOL_POINTERS` for the symbol filter, `CLASS_PROTOTYPE_OBJECTS` for
the prototype filter, `is_uint8array_buffer_slow` for the Uint8Array window. A
registration route added without admitting panics in the first test that
touches it. The class-prototype table is the harder case — the two GC root
scanners and the per-slot GC step all *rewrite* stored addresses through
`visit_usize_slot`, so each admits what the visitor left behind, under the same
write guard that keeps the new address unfindable until it drops.

`admit` performs its bit-set RMWs unconditionally, with no "already set?"
pre-check, for the reason #9272 gives for the window's: a thread that skips the
RMW performs no acquire, so another thread's admission never joins its
happens-before graph and a reader synchronising only with this thread could
miss it.
6 changes: 1 addition & 5 deletions crates/perry-codegen/src/stmt/loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -872,11 +872,7 @@ fn emit_packed_numeric_accumulator_admission(
offset_reads_inlined: bool,
) -> PackedAccumulatorScope {
let accumulators = super::stable_packed_accumulator::collect_numeric_accumulators(
ctx,
body,
array_id,
counter_id,
offset_reads_inlined,
ctx, body, array_id, counter_id, offset_reads_inlined,
);
// Integer (`c++`) accumulators admit independently of the float set —
// a pure count loop has no float accumulator at all.
Expand Down
102 changes: 28 additions & 74 deletions crates/perry-codegen/src/stmt/stable_packed_accumulator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,51 +67,28 @@ fn accumulator_rhs_is_numeric(
// expression lowers to a tag-test diamond over
// `js_dynamic_string_or_number_add` — the same cost #9060 and
// #9091 removed for the bare-counter form.
_ if offset_reads_inlined => crate::expr::packed_f64_loop_index_parts(index)
.is_some_and(|(i, _)| i == counter_id),
_ if offset_reads_inlined => {
crate::expr::packed_f64_loop_index_parts(index)
.is_some_and(|(i, _)| i == counter_id)
}
_ => false,
}
}
Expr::LocalGet(id) => {
candidates.contains(id) || crate::type_analysis::is_numeric_expr(ctx, expr)
}
Expr::Binary { left, right, .. } => {
accumulator_rhs_is_numeric(
ctx,
left,
array_id,
counter_id,
offset_reads_inlined,
candidates,
) && accumulator_rhs_is_numeric(
ctx,
right,
array_id,
counter_id,
offset_reads_inlined,
candidates,
)
accumulator_rhs_is_numeric(ctx, left, array_id, counter_id, offset_reads_inlined, candidates)
&& accumulator_rhs_is_numeric(ctx, right, array_id, counter_id, offset_reads_inlined, candidates)
}
Expr::NumberCoerce(operand) => {
accumulator_rhs_is_numeric(ctx, operand, array_id, counter_id, offset_reads_inlined, candidates)
}
Expr::NumberCoerce(operand) => accumulator_rhs_is_numeric(
ctx,
operand,
array_id,
counter_id,
offset_reads_inlined,
candidates,
),
Expr::Unary { op, operand } => {
matches!(
op,
perry_hir::UnaryOp::Neg | perry_hir::UnaryOp::Pos | perry_hir::UnaryOp::BitNot
) && accumulator_rhs_is_numeric(
ctx,
operand,
array_id,
counter_id,
offset_reads_inlined,
candidates,
)
) && accumulator_rhs_is_numeric(ctx, operand, array_id, counter_id, offset_reads_inlined, candidates)
}
Expr::MathAbs(v)
| Expr::MathSqrt(v)
Expand All @@ -120,41 +97,16 @@ fn accumulator_rhs_is_numeric(
| Expr::MathRound(v)
| Expr::MathTrunc(v)
| Expr::MathSign(v)
| Expr::MathFround(v) => accumulator_rhs_is_numeric(
ctx,
v,
array_id,
counter_id,
offset_reads_inlined,
candidates,
),
| Expr::MathFround(v) => {
accumulator_rhs_is_numeric(ctx, v, array_id, counter_id, offset_reads_inlined, candidates)
}
Expr::MathImul(l, r) | Expr::MathPow(l, r) => {
accumulator_rhs_is_numeric(
ctx,
l,
array_id,
counter_id,
offset_reads_inlined,
candidates,
) && accumulator_rhs_is_numeric(
ctx,
r,
array_id,
counter_id,
offset_reads_inlined,
candidates,
)
accumulator_rhs_is_numeric(ctx, l, array_id, counter_id, offset_reads_inlined, candidates)
&& accumulator_rhs_is_numeric(ctx, r, array_id, counter_id, offset_reads_inlined, candidates)
}
Expr::MathMin(values) | Expr::MathMax(values) => values.iter().all(|v| {
accumulator_rhs_is_numeric(
ctx,
v,
array_id,
counter_id,
offset_reads_inlined,
candidates,
)
}),
Expr::MathMin(values) | Expr::MathMax(values) => values
.iter()
.all(|v| accumulator_rhs_is_numeric(ctx, v, array_id, counter_id, offset_reads_inlined, candidates)),
_ => false,
}
}
Expand Down Expand Up @@ -331,14 +283,16 @@ pub(super) fn collect_numeric_accumulators(
.copied()
.filter(|id| {
!writes[id].iter().all(|write| match write {
Some(rhs) => accumulator_rhs_is_numeric(
ctx,
rhs,
array_id,
counter_id,
offset_reads_inlined,
&candidates,
),
Some(rhs) => {
accumulator_rhs_is_numeric(
ctx,
rhs,
array_id,
counter_id,
offset_reads_inlined,
&candidates,
)
}
// `Update` (++/--): ToNumeric(Number) ± 1 is a Number.
None => true,
})
Expand Down
77 changes: 77 additions & 0 deletions crates/perry-runtime/src/buffer/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,48 @@ static ARRAY_BUFFER_EVER_MARKED: RegistryLatch = RegistryLatch::new();
static SHARED_ARRAY_BUFFER_EVER_MARKED: RegistryLatch = RegistryLatch::new();
static DATA_VIEW_EVER_MARKED: RegistryLatch = RegistryLatch::new();
static UINT8ARRAY_EVER_MARKED: RegistryLatch = RegistryLatch::new();

/// Smallest and largest address ever marked as a `new Uint8Array(...)`
/// backing, process-wide.
///
/// `UINT8ARRAY_EVER_MARKED` stops discriminating at the first Uint8Array, and
/// `typedarray_props::typed_array_owner_kind` asks this question about the
/// receiver of **every untyped element access** — so on `claude-code --help`
/// the latch is armed for essentially the whole run and the probe is a
/// permanent out-of-line call, a `OnceLock` load, a thread-local resolution and
/// a `RefCell` borrow to say "no".
///
/// It covers both tables `is_uint8array_buffer_slow` consults, each of which
/// has exactly one insert funnel:
/// * `UINT8ARRAY_FROM_CTOR` — only [`mark_as_uint8array`] inserts;
/// * the process-global external registry — only
/// [`register_external_uint8array`] inserts, and both of ITS callers reach
/// [`mark_as_uint8array`] with the same address anyway.
///
/// Both widen before they publish, so an address outside the window is in
/// neither table and rejecting it is sound. Removal (the GC's dead-buffer
/// sweep) never narrows the window, which only makes it a weaker filter, never
/// a wrong one. See [`RegistryAddrWindow`] for the ordering rule.
static UINT8ARRAY_ADDR_WINDOW: RegistryAddrWindow = RegistryAddrWindow::new();

#[cfg(test)]
thread_local! {
/// Test-only count of `is_uint8array_buffer` calls that got past the address
/// window and reached the registries — the twin of
/// `TEST_BUFFER_REGISTRY_PROBES`, for the same reason: a fast path nobody can
/// prove ran is not a fast path.
static TEST_UINT8ARRAY_REGISTRY_PROBES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}

#[cfg(test)]
pub(crate) fn test_uint8array_registry_probe_count() -> u64 {
TEST_UINT8ARRAY_REGISTRY_PROBES.with(|c| c.get())
}

#[cfg(test)]
pub(crate) fn test_uint8array_addr_window_bounds() -> Option<(usize, usize)> {
UINT8ARRAY_ADDR_WINDOW.bounds_for_tests()
}
static SECRET_KEY_EVER_MARKED: RegistryLatch = RegistryLatch::new();
static CRYPTO_KEY_EVER_MARKED: RegistryLatch = RegistryLatch::new();
static ASYMMETRIC_KEY_EVER_MARKED: RegistryLatch = RegistryLatch::new();
Expand Down Expand Up @@ -468,6 +510,10 @@ fn is_registered_buffer_slow(addr: usize) -> bool {
/// Mark this buffer as one that came from `new Uint8Array(...)` so it
/// formats as `Uint8Array(N) [ ... ]` rather than `<Buffer ...>`.
pub fn mark_as_uint8array(addr: usize) {
// Widen before arming, and arm before the insert: the probe consults the
// latch and then the window, so both must already cover this address by the
// time it becomes findable. See `crate::registry_latch`.
UINT8ARRAY_ADDR_WINDOW.admit(addr);
UINT8ARRAY_EVER_MARKED.arm();
UINT8ARRAY_ADDR_RANGE.with(|r| {
let (lo, hi) = r.get();
Expand Down Expand Up @@ -510,6 +556,13 @@ pub extern "C" fn js_buffer_mark_as_uint8array_external(addr: usize) {
/// precisely so an address registered on one thread is visible from another,
/// and the thread-local set that would otherwise cover it is not.
fn register_external_uint8array(addr: usize) {
// Both of this function's callers also call `mark_as_uint8array(addr)`,
// which admits the same address — but that is an enumeration of callers,
// and this is the funnel the doc comment above promises is authoritative.
// Admitting here too costs two RMWs on a path that runs a handful of times
// per process and makes the funnel self-sufficient.
UINT8ARRAY_ADDR_WINDOW.admit(addr);
UINT8ARRAY_EVER_MARKED.arm();
EXTERNAL_UINT8ARRAYS_NONEMPTY.store(true, std::sync::atomic::Ordering::Release);
if let Ok(mut r) = external_uint8arrays().lock() {
r.insert(addr);
Expand Down Expand Up @@ -659,6 +712,30 @@ pub fn is_uint8array_buffer(addr: usize) -> bool {
if UINT8ARRAY_EVER_MARKED.is_idle() {
return false;
}
// An address outside the marked window is in neither table the slow path
// consults, so reject it inline — no call, no `OnceLock`, no thread-local
// resolution, no `RefCell` borrow. See `UINT8ARRAY_ADDR_WINDOW`.
if !UINT8ARRAY_ADDR_WINDOW.may_contain(addr) {
// Completeness audit, machine-checked rather than enumerated — see the
// twin in `is_registered_buffer` for why. `is_uint8array_buffer_slow`
// is the authoritative reader of both tables and mutates nothing, so
// calling it here changes no state the next probe would observe.
// Compiled out entirely in release.
#[cfg(debug_assertions)]
{
assert!(
!is_uint8array_buffer_slow(addr),
"UINT8ARRAY_ADDR_WINDOW rejected {addr:#x}, but it IS a marked \
Uint8Array backing. Some route reached UINT8ARRAY_FROM_CTOR or \
the external-Uint8Array registry without calling \
`UINT8ARRAY_ADDR_WINDOW.admit()` (via `mark_as_uint8array` or \
`register_external_uint8array`) first."
);
}
return false;
}
#[cfg(test)]
TEST_UINT8ARRAY_REGISTRY_PROBES.with(|c| c.set(c.get().wrapping_add(1)));
is_uint8array_buffer_slow(addr)
}

Expand Down
3 changes: 2 additions & 1 deletion crates/perry-runtime/src/buffer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ pub(crate) use header::{
#[cfg(test)]
pub(crate) use header::{
test_buffer_addr_window_bounds, test_buffer_registry_probe_count, test_data_view_registry_len,
test_shared_array_buffer_registry_len,
test_shared_array_buffer_registry_len, test_uint8array_addr_window_bounds,
test_uint8array_registry_probe_count,
};

// ---- Re-exports: ArrayBuffer detach / transfer (ES2024) ----
Expand Down
Loading
Loading