Skip to content

perf(array): answer "is this receiver a Map/Set?" from the object's own GC tag (#7765) - #7765

Open
proggeramlug wants to merge 5 commits into
mainfrom
perf/mapset-header-dispatch
Open

perf(array): answer "is this receiver a Map/Set?" from the object's own GC tag (#7765)#7765
proggeramlug wants to merge 5 commits into
mainfrom
perf/mapset-header-dispatch

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The finding

gc-handoff/apps/asyncpipe.ts — an async service pipeline, and at 13x node the
worst gap in the corpus — spent 13.5% of its run in
set::is_registered_set + map::is_registered_map
. Not on Set or Map work:
on js_array_get_f64 and js_array_length asking both collection registries
whether an ordinary array was secretly a collection, on every element read.
Caller attribution on a symbolicated sample of asyncpipe_big.ts:

probe share of its own samples from
is_registered_set (7.48%) js_array_get_f64 76%, js_array_length 11%
is_registered_map (6.03%) js_array_get_f64 82%, js_array_length 16%

#7755 made unused-feature registry probes free with monotone latches and named
Map/Set as the deliberate residual — asyncpipe uses both, so the #7474 latch is
correctly armed and each probe is real work — and named why the typed-array
trick does not transfer: an address-keyed negative memo is an ABA hazard for
Map/Set, whose headers are recyclable arena objects.

The mechanism

The object already knows. js_map_alloc / js_set_alloc allocate their
headers through arena_alloc_gc(_, _, GC_TYPE_MAP | GC_TYPE_SET), and each is
the single registration site for its registry — so a registered collection's
address is its GC header, and obj_type answers in one byte. Both hot call
sites gate their probes on it; the registry stays authoritative for the positive
answer, so nothing about when an entry is added or swept moves.

ABA-proof by construction rather than by bookkeeping: the tag lives INSIDE
the candidate bytes, so whatever allocation owns them next stamps its own
obj_type before the pointer is handed out. A recycled address answers for its
new owner with no invalidation step to get wrong.

Correct for a header-less receiver too — buffers and typed arrays are
std::alloc-backed, so the bytes below them are allocator bookkeeping. Both are
already routed by the (latched, free) probes above, and either way those bytes
read the outcome is unchanged: a byte that happens to read as
GC_TYPE_SET/GC_TYPE_MAP still falls through to the authoritative registry,
and any other value skips a probe that would have answered false anyway.
Neither call site gains a dereference — both already read this header.

The second commit takes the adjacent cluster the same way: the object field-get
funnel proves keys is a live GC_TYPE_ARRAY and caps the index below its
capacity, then called js_array_get per key, which re-establishes both facts
from scratch. keys_array_slot serves the dense case from the array's own words
and delegates everything else.

Tests

crates/perry-runtime/src/array/collection_tag_tests.rs asserts THE SUBJECT,
not just the answer — the registry is a correct fallback, so a test that only
compared values would still pass with the gates deleted (CLAUDE.md, "four ways a
gate can be unable to fail", case 4). Both sabotage checks were run:

remove test that goes red
either receiver-tag gate plain_array_element_reads_never_probe_the_collection_registries — 64 passes over a 4-element array moved the probe counters from 0 to 320
the header confirmation in is_registered_map a_stale_registry_entry_over_recycled_bytes_does_not_read_as_a_map — the stale entry then reports true and the read serves MapHeader::entries

Numbers — absolute seconds

Quiet pinned M1 mini (perry@perry-macos.local), static arm64 binaries shipped
over, both arms timed back to back in the same session (the published floors
were set on a different host, so only a paired A/B means anything). Min of 7
runs. All outputs verified byte-identical to node --experimental-strip-types
BEFORE timing. These figures were re-measured from scratch after an unrelated
machine reboot and reproduce the pre-reboot run to within 0.001 s.

bench before after delta floor
asyncpipe 0.9044 0.7133 -21.1% <= 0.75
interp 2.2864 2.2783 -0.4% <= 2.30
shapes 0.2870 0.2741 -4.5% <= 0.29
churn 0.4208 0.4177 -0.7% <= 0.43
churn_alloc 0.3728 0.3772 +1.2% <= 0.38
churn_read 0.0226 0.0224 -0.9% <= 0.03
push_cls 0.3551 0.3549 -0.1% <= 0.37
push_num 0.1367 0.1365 -0.1% <= 0.14
cycles 0.1923 0.1922 -0.1% <= 0.20
deeplist 0.2451 0.2445 -0.2% <= 0.25
tree 1.6333 1.6343 +0.1% <= 1.66
tree_wide 2.1096 2.1081 -0.1% <= 2.15
retain 0.5415 0.5414 0.0% <= 0.55
retain_wide 1.0973 1.0923 -0.5% <= 1.10
fib40 0.3937 0.3933 -0.1% <= 0.40

churn_alloc is the one number I would not call clean. Re-measured
interleaved, 3 passes of best-of-11: base 0.3740 / 0.3728 / 0.3749, after
0.3802 / 0.3772 / 0.3798 — a reproducible +1.2 to +1.4% that sits ON its
0.38 floor rather than comfortably under it. churn_alloc.ts is
keep.push({v, w}) x 1000 x 20000 with one keep.length per chunk: it executes
none of the paths this PR changes (no array element reads, no object field
reads, no Map or Set, so the #7474 latch is idle and both probes were already a
single atomic load each). The first commit measured identical to base on it
(0.3737 vs 0.3737); the drift appears with the second. That points at code
layout in libperry_runtime.a rather than an executed path, but I did not
chase it down and cannot prove it, so it is reported as a real +1.2%.

Profile of asyncpipe_big.ts (symbolicated sample, two runs, in agreement):

entry before after
is_registered_set + is_registered_map 13.51% 1.18% / 1.41%
array_object_flags 3.06% out of the top 20
js_array_get_f64 6.27% out of the top 14 (replaced by keys_array_slot, 2.6-3.1%)

classify_heap_space_in_range (4.4% before) was left alone: caller attribution
shows it is 100% GC-internalCopyingNurseryCollector::mark_addr 51%,
rewrite_value_bits 37% — and #7742 already introduced the (space, base)
form specifically to collapse the double lookup for addresses 8 bytes apart,
which mark_addr uses. Its share rises after this change only because the
mutator side shrank.

GC canaries. gc-handoff/apps/iso_miss.ts prints checksum 437840 misses 0
plain, under PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800, and under
PERRY_GC_VERIFY_EVACUATION=1. asyncpipe still exits 138 under
PROTECT_FROMSPACE with a from-space fault on an obj_type=5 (Promise)
object — that reproduces identically on the merge base, same signature, same
object type, correct output printed either way, so it is untouched here.

Gap suite (full 517, PERRY_SKIP_BUILD=1 against this build): 492 parity
pass, 95.1%. Every divergence from test-parity/gap_snapshot.json is
pre-existing and none is array/Map/Set/property-shaped — six http/fetch/net
crashes, specabi_reassign, zlib_3285_params, ten node_fail -> parity_fail
status changes that are oracle-side (missing node_modules,
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX; a runtime change cannot flip node's exit
code), and the two gc_*_argument_rooting tests, whose entire diff is the
[gc-zeal] forced_collections=... verdict line #7684 writes to stderr and the
harness merges into stdout. test_gap_iterator_helpers_2874 goes
parity_fail -> pass.

Two comments claiming Map/Set headers are alloc()-backed with no GcHeader
the stated reason the registries are consulted before any header read — are
corrected in place. They predate the move into the managed arena, and are how
the answer stayed hidden.

Summary by CodeRabbit

  • Performance
    • Improved array element and length access by avoiding unnecessary collection checks for ordinary arrays.
    • Added a faster path for dense internal key arrays while preserving correct handling of sparse, forwarded, and out-of-range entries.
  • Bug Fixes
    • Prevented stale collection metadata from being mistaken for recycled array memory.
    • Preserved correct behavior for specialized collection and descriptor-backed receivers.
  • Tests
    • Added comprehensive coverage for collection detection, array growth, sparse arrays, invalid pointers, and fallback behavior.

Ralph Küpper added 2 commits August 10, 2026 14:07
…C tag (#7768)

`js_array_get_f64` and `js_array_length` asked both collection registries
whether an ordinary array was secretly a Set or a Map on every element read.
On `gc-handoff/apps/asyncpipe.ts` those two probes were 13.5% of the run: the
program uses Map and Set, so the #7474 latch is armed and each probe really
resolved a thread-local and hashed.

Every registered Map/Set IS its `arena_alloc_gc(_, _, GC_TYPE_MAP|GC_TYPE_SET)`
header, so the object's own `obj_type` answers in one byte. ABA-proof by
construction — the tag lives inside the candidate bytes, so recycling the
address rewrites it — which is what an address-keyed negative memo could not
offer (#7755). The registry stays authoritative for the positive answer.

The same header read now also supplies the descriptor flags that
`array_object_flags` re-derived through a second `clean_arr_ptr`.
…7768)

The object field-get funnel already proves `keys` is a live `GC_TYPE_ARRAY` and
caps the index below its capacity, then called `js_array_get` per key — which
re-establishes both facts from scratch: a `clean_arr_ptr` forwarding walk, a
lazy-header probe, the exotic-receiver classifications and a descriptor-flag
read. That funnel was 78% of all `js_array_get_f64` samples on
`gc-handoff/apps/asyncpipe_big.ts`. `keys_array_len_capped_to_capacity` paid the
same toll through `js_array_length` once per property read.

`keys_array_slot` serves the dense, descriptor-free, non-forwarded case from the
array's own words and delegates everything else — a hole, an out-of-range index,
a forwarded or descriptor-carrying array, a null pointer — so no general
semantics move. A test-only per-thread fallback counter pins both directions, so
a fast path that stopped applying and one that started swallowing a shape it
should have delegated are equally red.

Also switches the #7768 receiver-tag read to `addr_class::try_read_gc_header`:
this file's usual `>= GC_HEADER_SIZE + 0x1000` floor sits BELOW the handle band,
and `js_array_length` reaches it before proxy/handle receivers are routed. Keeps
the addr-class ratchet green too.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Array access now reads GC receiver tags once to gate Map and Set registry probes. Dense key arrays use direct slot reads with fallback handling. Tests cover registry suppression, recycled memory, collection tags, allocation growth, and key-array fallback behavior.

Changes

Array receiver gating

Layer / File(s) Summary
Receiver tag gating
crates/perry-runtime/src/array/header.rs, crates/perry-runtime/src/array/indexing.rs, crates/perry-runtime/src/array/mod.rs, crates/perry-runtime/src/map.rs, crates/perry-runtime/src/set.rs
Array operations classify the receiver once, derive descriptor flags from the tag, and perform Map or Set registry checks only for matching GC types.
Dense key-array access
crates/perry-runtime/src/array/indexing.rs, crates/perry-runtime/src/array/mod.rs, crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs, crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs
Dense key-array slots use direct reads. Unsupported shapes use the general getter. Field lookup paths use the new accessor.
Validation and runtime support
crates/perry-runtime/src/array/collection_tag_tests.rs, crates/perry-runtime/src/object/field_get_set/field_ops.rs, scripts/addr_class_allowlist.txt, changelog.d/7765-array-receiver-tag-gates-collection-probes.md
Tests validate probe counts, recycled-address handling, key-slot fallbacks, and collection tags after growth. Supporting comments, the allowlist, and the changelog describe the updated behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ArrayAccess
  participant ReceiverTag
  participant MapRegistry
  participant SetRegistry
  ArrayAccess->>ReceiverTag: Read receiver GC type
  alt Map receiver
    ArrayAccess->>MapRegistry: Validate Map registration
  else Set receiver
    ArrayAccess->>SetRegistry: Validate Set registration
  else Ordinary array
    ArrayAccess-->>ArrayAccess: Skip collection probes
  end
Loading

Possibly related PRs

Suggested labels: performance

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Title check ✅ Passed The title clearly identifies the array performance optimization and GC-tag-based Map/Set detection.
Description check ✅ Passed The description thoroughly explains the problem, implementation, tests, benchmarks, regressions, and related issue references.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/mapset-header-dispatch

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/array/collection_tag_tests.rs`:
- Around line 182-191: Replace the unsafe recycling block in the collection tag
test with a genuine ArrayHeader allocation sized for two f64 elements, rather
than reinterpreting the MapHeader allocation at addr. Add a test-only helper to
place the new array address into the registry’s stale-entry state, and update
the later cleanup around the existing registry test flow so the MapHeader
entries pointer remains untouched and both allocations are safely released.
- Around line 47-60: The collection tests currently retain raw MapHeader and
SetHeader pointers across allocating calls, so they can become stale after GC
relocation. In arm_both_registries, root map and set with RuntimeHandleScope
before allocating the other collection, then reload their pointers before
registry checks and return/use. In
crates/perry-runtime/src/array/collection_tag_tests.rs:287-300, store rooted
handles rather than raw pointers while the allocation loop runs, and reload
pointers during each verification loop; apply the same change at both sites.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 38cf13e6-22f6-416f-868b-8748fbc4e66a

📥 Commits

Reviewing files that changed from the base of the PR and between 423bb44 and c6b63e8.

📒 Files selected for processing (11)
  • changelog.d/7768-array-receiver-tag-gates-collection-probes.md
  • crates/perry-runtime/src/array/collection_tag_tests.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/indexing.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/object/field_get_set/field_ops.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs
  • crates/perry-runtime/src/set.rs
  • scripts/addr_class_allowlist.txt

Comment on lines +47 to +60
fn arm_both_registries() -> (*mut MapHeader, *mut SetHeader) {
let map = js_map_alloc(4);
js_map_set(map, 1.0, 10.0);
let set = js_set_alloc(4);
js_set_add(set, 5.0);
assert!(
crate::map::is_registered_map(map as usize),
"the map registry must be armed for these tests to mean anything"
);
assert!(
crate::set::is_registered_set(set as usize),
"the set registry must be armed for these tests to mean anything"
);
(map, set)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Root collection pointers across allocations.

js_set_alloc, js_map_alloc, and later collection allocations can evacuate existing GC objects. Raw MapHeader and SetHeader pointers do not update after relocation.

  • crates/perry-runtime/src/array/collection_tag_tests.rs#L47-L60: Keep map and set in a RuntimeHandleScope before allocating the other collection. Reload their pointers before use.
  • crates/perry-runtime/src/array/collection_tag_tests.rs#L287-L300: Store rooted handles, not raw pointers, while the loop continues to allocate collections. Reload pointers during the verification loops.

Based on learnings, raw Rust pointer locals are neither GC roots nor reliable pins across allocating operations.

📍 Affects 1 file
  • crates/perry-runtime/src/array/collection_tag_tests.rs#L47-L60 (this comment)
  • crates/perry-runtime/src/array/collection_tag_tests.rs#L287-L300
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/array/collection_tag_tests.rs` around lines 47 - 60,
The collection tests currently retain raw MapHeader and SetHeader pointers
across allocating calls, so they can become stale after GC relocation. In
arm_both_registries, root map and set with RuntimeHandleScope before allocating
the other collection, then reload their pointers before registry checks and
return/use. In crates/perry-runtime/src/array/collection_tag_tests.rs:287-300,
store rooted handles rather than raw pointers while the allocation loop runs,
and reload pointers during each verification loop; apply the same change at both
sites.

Source: Learnings

Comment on lines +182 to +191
// Recycle the bytes into a two-element dense array, registry untouched.
unsafe {
set_gc_obj_type(addr, crate::gc::GC_TYPE_ARRAY);
let recycled = addr as *mut ArrayHeader;
(*recycled).length = 2;
(*recycled).capacity = 2;
let elements = (addr as *mut u8).add(std::mem::size_of::<ArrayHeader>()) as *mut f64;
std::ptr::write(elements, 111.0);
std::ptr::write(elements.add(1), 222.0);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Do not write an array layout into a MapHeader allocation.

Line 188 points at the MapHeader::entries field. Line 189 overwrites the Map side-buffer pointer. Line 190 writes beyond the Map header allocation. Lines 209-216 do not restore entries, so later teardown can dereference or release corrupted memory.

Create a real array allocation and add a test-only helper that injects its address into the registry stale-entry state. Do not reuse the Map allocation as array storage.

Also applies to: 209-216

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/array/collection_tag_tests.rs` around lines 182 -
191, Replace the unsafe recycling block in the collection tag test with a
genuine ArrayHeader allocation sized for two f64 elements, rather than
reinterpreting the MapHeader allocation at addr. Add a test-only helper to place
the new array address into the registry’s stale-entry state, and update the
later cleanup around the existing registry test flow so the MapHeader entries
pointer remains untouched and both allocations are safely released.

@proggeramlug proggeramlug changed the title perf(array): answer "is this receiver a Map/Set?" from the object's own GC tag (#7768) perf(array): answer "is this receiver a Map/Set?" from the object's own GC tag (#7765) Aug 10, 2026
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