Repository navigation
Every dynamic method call pays 7 side-registry probes to exclude kinds the program never creates — is_registered_symbol alone takes a mutex + SipHash (6.5% of pipeline) #7850
Description
Summary
object::native_call_method::gc_pointer_and_type_from_value — on the path of every dynamic method call — runs a chain of seven side-registry probes purely to exclude object kinds the program may never construct. One of them, symbol::is_registered_symbol, takes a process-global pthread_mutex plus a SipHash over a HashSet<usize> once any Symbol has ever been minted.
On gc-handoff/apps/pipeline.ts that single probe is 6.5% of the program's samples (pthread_mutex_lock/unlock ~2.6% and RandomState::hash_one ~2.3% of it), and the enclosing gc_pointer_and_type_from_value is 8.0%.
crates/perry-runtime/src/object/native_call_method.rs:890:
unsafe fn gc_pointer_and_type_from_value(value: f64) -> Option<(*const u8, u8)> {
…
if crate::buffer::is_any_array_buffer(addr) { return Some((ptr, GC_TYPE_BUFFER)); }
if crate::buffer::is_uint8array_buffer(addr) { return Some((ptr, GC_TYPE_BUFFER)); }
if crate::typedarray::lookup_typed_array_kind(addr).is_some() { return Some((ptr, GC_TYPE_TYPED_ARRAY)); }
if !is_valid_obj_ptr(ptr) { return None; }
if crate::set::is_registered_set(addr)
|| crate::map::is_registered_map(addr)
|| crate::regex::is_regex_pointer(ptr)
|| crate::symbol::is_registered_symbol(addr) // <-- mutex + SipHash
{ return None; }
let gc_header = ptr.sub(GC_HEADER_SIZE) as *const GcHeader;
Some((ptr, (*gc_header).obj_type)) // the answer was in the header
}The latch is not the missing piece — it already exists and is armed
symbol::is_registered_symbol (symbol.rs:390) is already gated by a RegistryLatch:
pub fn is_registered_symbol(ptr: usize) -> bool {
if SYMBOL_EVER_REGISTERED.is_idle() { return false; }
is_registered_symbol_slow(ptr)
}So the 6.5% means the latch is armed on pipeline — some part of the program or the bootstrap mints at least one Symbol — after which every dynamic dispatch pays the full mutex + hash. A latch that a realistic program arms early is not protection; it only moves the cost behind a branch that is always taken.
This reconciles two measurements that looked contradictory:
- A peer session A/B'd the latch this morning on a property-read workload with no dynamic dispatch and measured 0.0221 s vs 0.0221 s — no cost at all.
- This profile is 53.8% dynamic dispatch, and the probe sits on
gc_pointer_and_type_from_value, i.e. on every dynamic call.
Both are true. The honest statement is that the probe is free on monomorphic property access and costs ~6.5% on dispatch-heavy code, so the earlier "measured null" is workload-specific rather than general.
Why this is the same family as five already-fixed bugs
is_registered_set, is_registered_map, is_regex_pointer and is_arguments_object have all been observed on hot paths in programs that construct none of the corresponding objects (see gc-handoff/PROFILE-interp-round3.md, which measured ~1.8–4.6% of interp spent proving the absence of features the program does not use). Fixes of this exact shape have already landed as #7765, #7769 and #7474, and PER_OBJECT_LAYOUTS_NONEMPTY / EXTERNAL_BUFFERS_NONEMPTY (buffer/header.rs:33) are the idiom.
Three independent sightings today — this one, a prototype-map latch taking a mutex plus SipHash per traced object, and the interp set/map/arguments probes — suggest the family is worth attacking structurally rather than one probe at a time.
The structural fix worth considering
The GcHeader sitting 8 bytes before the pointer already records obj_type, and the function ends by reading it. The probe chain exists to override that answer for kinds whose identity lives in a side table. Reading the header first and consulting a side registry only when obj_type leaves the kind ambiguous would remove all seven probes from the common GC_TYPE_OBJECT case.
That requires establishing which of Set / Map / regex / symbol / buffer / typed-array instances can also carry obj_type == GC_TYPE_OBJECT. I have not established that, so I am filing rather than fixing.
Sizing caveat
The 6.5% is measured on pipeline only, and pipeline's dispatch load is itself a bug (#7848 — a generic-class specialization miss that forces every method call onto the dynamic path). Once #7848 lands, pipeline's share of this cost largely disappears with the dynamic calls that carried it. This should be sized across the corpus on genuinely polymorphic code before anyone optimizes for it, not from this program's number.
Evidence
gc-handoff/bench/pipeline_big.ts, PERRY_DEBUG_SYMBOLS=1, quiet M1 mini under the bench lock, two 3 s sample runs at 1 ms agreeing within 1.5 pp (2353 / 2204 stack samples):
js_native_call_method 53.0%
└─ class_vtable_fast_guard 19.6%
└─ gc_pointer_and_type_from_value 8.0%
├─ is_registered_symbol_slow 6.5%
│ ├─ RandomState::hash_one 2.3%
│ ├─ pthread_mutex_lock 1.7%
│ └─ pthread_mutex_unlock 0.9%
├─ is_registered_map 0.3%
├─ is_regex_pointer 0.3%
├─ is_registered_set 0.2%
└─ is_shared_sab / is_array_buffer 0.2%
Activity
perf(runtime): let the GC header pick the side-registry probe on dyna…
perf(runtime): let the GC header pick the side-registry probe on dyna…
perf(runtime): let the GC header pick the side-registry probe on dyna…