Skip to content

Canonical-handle classification makes every heap pointer pay a malloc-registry lookup (43.1 M empty queries per cc command) - land the fix with the wrapper subsystem #10110

Description

@proggeramlug

Summary

On the campaign's measurement stack, the three cheapest address predicates in value::addr_class each embed a native-handle question, so every ordinary heap pointer whose numeric arm cannot decide pays an exact malloc-registry lookup. Measured on one claude-code command: 65,133,719 classifications, 43,150,678 of them reaching the registry (66.25 %), and 9,225 finding a real wrapper (0.021 %) — 43,141,453 registry queries per command with nothing in them.

A fix exists and is measured at −7.63 % minimum command CPU (node gap 2.81× → 2.60×). It cannot be opened against main, because the code it fixes is not on main — hence this issue, so the work is not lost when the subsystem lands.

Why it is not a main issue yet

On current main:

pub fn is_above_handle_band(addr: usize) -> bool {
    addr >= HANDLE_BAND_MAX          // pure numeric test
}

and crates/perry-runtime/src/native_handle/canonical.rs does not exist. The canonical-handle wrapper subsystem — 9fcd96d1c, "wrap timer receivers in managed native handles" — is one of 15 commits on a campaign base that diverged from main 290 commits ago. That commit is what turned the three predicates into:

is_handle_band(addr)       = addr < HANDLE_BAND_MAX || is_canonical_handle_addr(addr)
is_small_handle(addr)      = (1..HANDLE_BAND_MAX).contains(&addr) || is_canonical_handle_addr(addr)
is_above_handle_band(addr) = addr >= HANDLE_BAND_MAX && !is_canonical_handle_addr(addr)

A filter pass then continues canonical_handle_parts_from_addr → handle_from_addr → gc_malloc_header_is_tracked. Whoever lands the wrapper subsystem should land the fix with it, or the subsystem arrives carrying a 7.63 % command-CPU regression that is invisible in review: the check is correct, cheap-looking and inlined into js_object_get_field_by_name 27 times.

The defect

The guard is a 1,024-bit monotone Bloom filter, and the wrapper population does not fit in it: 643–648 live addresses, 712–721 admissions and 69–76 retirements per command. Selectivity collapses inside a single process — 0.021–0.154 % of addresses pass after startup, 57.7–74.6 % after the command, a 430–3,500× loss with no configuration involved. Sizing dominates: clearing retired bits would still leave ~640 live addresses in a filter that cannot hold 60.

The fix that is already measured

WORDS becomes a per-owner parameter, and a RegistryAddrIndex keeps an exact live-address set — mutated only on wrapper creation and retirement, against tens of millions of probes — from which the bit array is recomputed once enough has changed, which is what releases retired addresses. Refresh is O(live) per O(live) changes: amortized O(1), no interval and no workload-specific setting. The hot side is unchanged: the same three bit tests, no lock, no allocation, no pointer chase. The canonical owner takes 256 words (16,384 bits, 2 KiB) and retires at all three sites that end a registration, each after the authoritative table.

Results: command CPU minima 1.18 → 1.09 s (−7.63 %), faster in four of five interleaved pairs and tied in the fifth, paired median −5.08 %; startup minima unchanged; largest per-pair peak-RSS increase +3.78 %, larger than the change's own footprint (2 KiB plus a ~645-entry set) so its cause was not the structure and was not measured. Mechanism, on a counter arm of the same change: registry-bound passes 43,150,678 → 144,246 (−99.67 %) with resolutions identical at 9,225, so the answers do not change.

Three tests encode it and each fails without it: the measured 644-address population at both widths (a 1,024-bit witness of the same population admits 80.4 % of non-members), retirement narrowing the index while keeping every live address, and 20,000 churned addresses against 64 resident ones not walking back to saturation.

What is deliberately not in it

The 232 call sites of the three range predicates are untouched. Separating the numeric range test from semantic receiver classification at each site is the larger architectural change an earlier caller census recommended, and it is independent of this one.

The measured fix is open for review at proggeramlug#1, against the campaign base rather than main for the reason above. Sibling finding: #10109. Sibling fix already on main-track: #10108.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions