Skip to content

Merge train: #9881, #9894, #9896 - #9901

Merged
proggeramlug merged 7 commits into
mainfrom
train135
Sep 6, 2026
Merged

Merge train: #9881, #9894, #9896#9901
proggeramlug merged 7 commits into
mainfrom
train135

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Merge train: #9881, #9894, #9896.

Audit note on #9881

IdList::remove becomes a swap-remove, which reorders. The PR's own full-suite run failed exactly one test — owned_key_count_versions_are_retired_behind_the_current_one, which asserted the family's ORDER after a retirement — and the PR converts it to a membership assertion.

That conversion is the kind of change that can launder a real regression, so the claim was checked rather than taken on trust. Two of the enumerated readers, scan_shape_table_rekey_mut and scan_shape_keys_address, are described as "first carrier else any member", which reads as order-sensitive.

It holds up, and the code is written so that it does. scan_shape_keys_address scans the family and breaks on the first carrier, so the chosen descriptor is a carrier iff any member is one — an existential property, not a positional one. That choice feeds exactly one decision, strong (visit_usize_slot) versus weak (visit_metadata_usize_slot) root visiting, which therefore does not depend on which member was picked. Every subsequent write applies to all ids in the family, and the rewritten address derives from the family key rather than from the chosen member. The order really was free.

Gate fixes

#9881's new ID_LIST_OP_STATS tripped two gates:

  • holder inventory — three plain u64 counts in a Cell, no address and no NaN-boxed value, so not_a_gc_pointer.
  • thread-local policy — it was a raw thread_local!. The gate offers two remedies, and recording it as cold debt would have been the shorter one, but it would also have been false: note_scan/note_removal are not diagnostic-gated and run on every IdList scan and removal, which is the path this PR measures at ~3.7M removals per turn. Converted to crate::perry_thread_local! instead, so the PR that removes hot-path cost does not add an unconditional _tlv_get_addr to it.

Worth a maintainer's eye, not changed here: those counters are unconditional overhead on the hot path, cheap next to the memmove being removed but never switched off.

Validation

run_lint_gates: all 64 gates passed; 2 CI-only skipped

suite passed failed
perry-runtime 3248 0
perry-codegen 1938 0
perry-hir 626 0
perry-stdlib 132 0

Summary by CodeRabbit

  • Bug Fixes

    • Performance API methods now correctly validate that they are called with the performance object.
    • Invalid histogram constructors now throw the expected error.
    • Performance methods accessed through the prototype retain correct receiver behavior.
    • Constructor-related metadata and declarations are preserved in fallback scenarios.
  • Performance

    • Improved garbage-collection shape-list removal performance, especially for very large lists.
    • Added diagnostics to help measure shape-list operations and memory-management behavior.

Ralph Küpper added 7 commits September 6, 2026 17:56
(cherry picked from commit 77ddaee)
(cherry picked from commit 49a4f99)
IdList::remove was Vec::remove(pos), which shifts every element past the
removed position. The removals that matter come from the dead-owner prune
(prune_dead_owner_side_tables_post_trace -> remove_descriptor_indexed_under)
against a `families` list -- the descriptor ids indexed under one keys-array
address.

Measured on the compiled claude-code TUI, single 3300-char replies, two
hosts, fourteen draws:

  * the removals sit at position ~0.31 of the list -- essentially always
    the FRONT, which is the worst case for a tail shift;
  * one owned-keys array per process grows its family to 404k-514k while
    every other list stays small;
  * so the same ~3.7M removals memmove up to 848 GB in a single turn;
  * 100.000% of those bytes are in `families`. `by_facts` moves ZERO: its
    longest list is 1 in every draw, exactly as its doc claims.
  * retire_owned_shape_siblings is NOT involved -- it never sees a family
    longer than 16.

THE ORDER QUESTION, because a swap-remove is only available if order is not
load-bearing, and the answer differs per index:

  * `by_facts` IS ordered -- facts_push_front installs a process-global id
    as the canonical answer ahead of an equivalent local one, read
    first-wins. It keeps remove_ordered, and that costs nothing: length 1.
  * `families` is NOT. Its only order-touching reader is the "one descriptor
    stands for the family" choice in the two rekey walks, which breaks on
    the first carrier and otherwise takes any present member -- and the
    chosen descriptor feeds exactly one expression, old_carrier ||
    cache_carrier, whose value is the same for every carrier and the same
    for every non-carrier. The outcome is a function of the SET.

So removal comes in two flavours and THE CALLER DECLARES THE CONTRACT,
because the caller is the one that knows whether its order matters; a single
remove() that guessed would be the bug.

A spilled list gains an id -> index map, built once it passes
SPILL_INDEX_MIN (32) and empty below it, where a scan of a few entries is
one cache line and a hash probe is not. The index makes
position/contains/remove O(1) on the lists that get long, which also removes
the linear membership scan in family_push_back -- recorded at this file's
own append_unchecked as 6.2% of main-thread leaf samples, 95% of it under
that one caller.

Three tests. The guard asserts its bound as a MULTIPLE of N, so it is about
the complexity class rather than about one N: 2,000 front removals may move
at most 4N elements, where the ordered path moves N(N-1)/2 = 1,999,000.
Sabotage: point remove_unordered at remove_ordered (250x the bound); raising
SPILL_INDEX_MIN above N fails the scan half of the same test. The second
checks the index against a plain Vec oracle after front, middle and back
removals, because an index that drifts is a WRONG ANSWER -- a descriptor
that cannot be found -- not a slow one; its sabotage is dropping the fixup
for the element the swap relocated. The third pins that a short spilled list
builds no index at all.

[gc-idlist] under PERRY_GC_DIAG=1 reports removals, elements moved and
positions scanned.

NOT CLAIMED: that the memmove explains the bimodal turn CPU. On the
pre-fix binary one draw moved 335 GB and was as fast as one that moved
16 GB, so bytes moved is necessary but not sufficient for the slow mode.
What this removes is unambiguously wasted work.

NOT ADDRESSED: why one family reaches half a million descriptors. That is a
separate defect, still being measured, and will be a separate change.

Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp
(cherry picked from commit 01313fc)
perrymaster's run of the PR tree: 3192 tests, 1 failed --
owned_key_count_versions_are_retired_behind_the_current_one, at the
post-retirement assert_eq!(test_shape_ids_for_keys(keys), vec![cached,
current]); swap-removing stale_a/stale_b moved `current` in front of
`cached`.

THE TEST OVER-SPECIFIES, and the evidence is an enumeration of every
production reader of `families` on this base, done with this test's subject
in mind:

  shapes.rs:1408  retire_owned_shape_siblings  filter all-but-keep    SET
  shapes.rs:1906  prune_dead_shape_keys_young  snapshot, remove all   SET
  shapes.rs:1946  scan_shape_table_rekey_mut   first-carrier-else-any SET
  shapes.rs:2067  move_shape_family            wholesale remove/insert  n/a
  shapes.rs:2092  relevant_shape_keys          KEYS, sort+dedup       normalised
  shapes.rs:2182  scan_shape_keys_address      as 1946                SET
  shapes.rs:2337  census heap_bytes sum                               aggregate
  shapes.rs:2390  census len/max                                      aggregate
  slot_list:402   retire all but `current`     filter                 SET
  slot_list:597   ids.replace(old, new)        position-PRESERVING    n/a
  slot_list:686   retire all but `id`          filter                 SET

The two rekey walks are the only order-TOUCHING readers, and what they take
from the list is a single choice fed to exactly one expression, old_carrier
|| cache_carrier, whose value is the same for every carrier and the same for
every non-carrier -- so the outcome is a function of the set. Nothing else
reads a position.

And the helper the assertion uses, test_shape_ids_for_keys, is #[cfg(test)]:
it renders families.as_slice().to_vec() for tests only. Its .first() sibling,
test_shape_id_for_keys, is also #[cfg(test)] and is only ever called on
families the caller has seeded with one descriptor. #9706's contract is about
WHICH ids survive a same-address retirement, not the order they survive in;
the assertion compared against a Vec because the helper returns one.

So the post-retirement assertion becomes a sorted comparison, with the
contract and the reader enumeration written at it. The pre-retirement
assertion is left as-is -- no removal has happened there and it legitimately
documents that adds append -- with a comment saying that is a property of the
add path and not a contract.

Corroborating: the suite ran 3192 with exactly ONE failure, so no other test
in the tree asserts a family's order across a removal.

(cherry picked from commit f9eb62c)
ID_LIST_OP_STATS is three plain u64 counts in a Cell — no address, no
NaN-boxed value — so it is not_a_gc_pointer for the holder inventory.

It was also a raw thread_local!, and its increments are NOT diagnostic-
gated: note_scan/note_removal run on every IdList scan and removal, which
is the path #9881 measures at ~3.7M removals per turn. That is the hot
case perry_thread_local! exists for, so it is converted rather than
recorded as cold debt.
@proggeramlug
proggeramlug merged commit 9869101 into main Sep 6, 2026
15 of 17 checks passed
@proggeramlug
proggeramlug deleted the train135 branch September 6, 2026 16:35
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 7e2d90f7-ab43-4461-97c3-26ed322f6a4d

📥 Commits

Reviewing files that changed from the base of the PR and between 0866940 and 490ccde.

📒 Files selected for processing (17)
  • changelog.d/9879-gc-family-list-swap-remove.md
  • changelog.d/9894-perf-hooks-validation.md
  • changelog.d/9896-constructor-lowering-artifacts.md
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/object/class_registry/construct.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/object/native_module/constructor_exports.rs
  • crates/perry-runtime/src/object/native_module/perf_instance_bind.rs
  • crates/perry-runtime/src/object/shapes.rs
  • crates/perry-runtime/src/object/shapes_store.rs
  • crates/perry-runtime/src/object/shapes_tests.rs
  • crates/perry-runtime/src/perf_hooks.rs
  • crates/perry-runtime/src/perf_hooks/prototypes.rs
  • crates/perry-runtime/src/perf_hooks/prototypes/performance_methods.rs
  • scripts/gc_runtime_root_holders.json
  • test-parity/node-suite/perf_hooks/shapes/receiver-branding.ts

📝 Walkthrough

Walkthrough

The change optimizes descriptor-family removal, adds receiver validation for Performance methods, rejects illegal histogram construction, and centralizes LLVM artifact publication during method lowering. It also adds diagnostics, tests, changelog entries, and GC root metadata.

Changes

Descriptor family list optimization

Layer / File(s) Summary
Indexed ID-list storage and removal
crates/perry-runtime/src/object/shapes_store.rs
Spilled lists now use an optional id -> index map. Family removal uses swap-remove, while ordered removal remains available for fact lists. Tests cover complexity, index consistency, and short-list behavior.
Family removal wiring and diagnostics
crates/perry-runtime/src/object/shapes.rs, crates/perry-runtime/src/gc/copying.rs, crates/perry-runtime/src/object/shapes_tests.rs, scripts/gc_runtime_root_holders.json, changelog.d/9879-gc-family-list-swap-remove.md
Family and fact callers select explicit removal semantics. ID-list counters are reported during minor collection. Family tests compare membership instead of order. GC root metadata and changelog text describe the counters and optimization.

Performance method receiver validation

Layer / File(s) Summary
Receiver-aware Performance methods
crates/perry-runtime/src/perf_hooks/*, crates/perry-runtime/src/object/native_module.rs, crates/perry-runtime/src/object/native_module/perf_instance_bind.rs, test-parity/node-suite/perf_hooks/shapes/receiver-branding.ts, changelog.d/9894-perf-hooks-validation.md
Performance.prototype methods now use receiver-checking thunks. Native-module lookups resolve methods on the canonical performance namespace. Receiver-branding tests cover direct calls.
Illegal histogram constructor handling
crates/perry-runtime/src/object/class_registry/construct.rs, crates/perry-runtime/src/object/native_module/constructor_exports.rs
RecordableHistogram and ELDHistogram are classified as constructor-shaped exports but dispatch to the illegal-constructor error when invoked with new.

Constructor-lowering artifact publication

Layer / File(s) Summary
Lowered-function artifact publication
crates/perry-codegen/src/codegen/method.rs, changelog.d/9896-constructor-lowering-artifacts.md
Method lowering collects counters, declarations, native records, inline-cache globals, raw globals, and typed parse data before releasing the function borrow. Instance methods, static methods, and the constructor fallback use the shared publisher. Tests verify publication.

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

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant NativeModule
  participant PerformancePrototype
  participant PerformanceThunk
  Caller->>NativeModule: read performance method
  NativeModule->>PerformancePrototype: resolve prototype method
  PerformancePrototype-->>Caller: return receiver-aware thunk
  Caller->>PerformanceThunk: invoke method
  PerformanceThunk->>PerformanceThunk: validate Performance receiver
Loading
✨ 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 train135

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.

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