Skip to content

perf(runtime): publish a transition edge when a descriptor writes a value (#10868) - #10901

Closed
proggeramlug wants to merge 1 commit into
feat/shape-mint-censusfrom
feat/descriptor-transition-edge
Closed

proggeramlug wants to merge 1 commit into
feat/shape-mint-censusfrom
feat/descriptor-transition-edge

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Lever (iii) of #10868. Stacked on #10885 (the census), because every number here is taken with that instrument.

Object.defineProperty mints 3 ShapeIds per object for a data descriptor and 4 for an accessor, linearly and without bound, while the transition cache reports lookups and zero inserts. This makes the data case constant.

The predicate, measured rather than read

I instrumented every decline reason rather than reasoning about the source. On accd (one data-descriptor install per object), N = 1,000:

defineProperty key-add, edge outcomes:
  no publish: new_index >= inline_capacity      1000
  eligible                                      1000

The receiver is eligible and the cache lookup does run. Exactly one predicate refuses, at object_ops/keys_array.rs:306: new_index < inline_capacity.

Why it refuses every time — a footprint dial governing shape publication

INLINE_SLOT_FLOOR is 2, and #7916's own doc comment says what it is for:

the floor is purely a growth-headroom dial for objects that gain properties by name after birth.

A two-field literal {a, b} therefore allocates exactly two slots and has zero growth headroom. The first key added after birth lands at new_index == inline_capacity == 2, spills to overflow, and the edge is never published. Every receiver forks onto a private keys array, and from there the fork cascade (#10868) does the rest: once the predecessor ShapeId is private, even #10287's deterministic generation is deterministic in a value no other object shares, so every later transition on that object mints too. That is why an accessor install costs 4 ids and not 1.

A dial tuned purely for footprint silently decided whether transition edges get published. Neither side could see it: #7916 was reasoning about bytes and correctly proved alloc_limit is a fixed point of the allocation; the transition cache reused alloc_limit as a proxy for "can this key be shared". Raising the floor is not the fix — it trades footprint against a standing RSS budget, and it only moves the cliff to four-field literals.

The correct behaviour already existed next door

field_set_by_name/tail.rs's overflow arm publishes the edge with no inline-capacity gate at all, and its comment gives the reason in the words this fix needs. One path handles the situation; the other refused it. That is missing wiring, not a missing capability — and it is why the pool control (the same objects grown by [[Set]] instead) already mints a constant 4,534 ids however much work it does.

Two changes, because publishing alone would have measured as a wash

The adopter gate refused overflow-located edges too, with its own stated reason:

An overflow target stays on the private path below: a keys-only install (an accessor claiming its slot) writes no value, so the overflow entry such an edge implies would never be created.

That reason is correct for accessors and over-broad for data. A data descriptor does write the value, so the objection does not apply to it. So: publish the edge when the install will write a value, and let the adopter take one in that case. ensure_key_in_keys_array_for_value is the opt-in and define_property_force_store_value — the data-descriptor path — is its only caller. Every keys-only claim (accessor install, built-in getter, attribute-only descriptor) keeps today's behaviour by construction.

cached_target_fits is untouched: it guards a different hazard (a target whose live inline bound exceeds this receiver's allocation, which would have the collector trace past the end of the object). The live-bound bump is now guarded in both adopt arms — it is unreachable in the first-key arm today, since that arm's precondition is a null keys array and its slot_idx is always 0, but an unguarded bump beside a gate that now admits overflow slots is a trap for whoever widens that precondition.

Measured: the acc row is the load-bearing one

Six controls, two arms built from their own trees, cmp-distinct binaries, output byte-identical to node on every row. Mints via PERRY_SHAPE_MINT_DIAG; instructions fitted N = 2,000 → 20,000, min of 3.

fixture mints @1k / @10k before after instr/iter before → after
same 1 / 1 1 / 1 93.58 → 93.58
pool 4,534 / 4,536 4,534 / 4,536 15596.14 → 15595.14
del 5,516 / 14,516 5,516 / 14,516 21365.89 → 21387.20
accd (data descriptor) 3,002 / 30,002 5 / 5 16188.99 → 12539.77 (−22.5 %)
acc (accessor) 4,002 / 40,002 4,002 / 40,002 19244.19 → 19315.15
diff 22,516 / 184,516 22,516 / 184,516 220937.30 → 220936.49

accd goes constant — 5 ids whether it runs 1,000 iterations or 10,000 — and 22.5 % fewer instructions, so this is a speedup and not merely a smaller counter.

acc being unchanged to the id is not a missed opportunity; it is the result that makes the change sound. The accessor is a keys-only claim whose implied overflow entry is never created, so it must keep the private path. A change that improved both rows would have broken the keys-only-install argument rather than fixed the data one. The reviewer instinct that more green rows are better is exactly inverted here.

tsc: flat, as predicted before measuring

ts.transpileModule, typescript 5.8.2 compiled from source, output identical to node:

before after
MINTS 2,288,352 2,287,800
distinct key-NAME lists 10,867 10,867
cache hits 8.4 % 8.4 %
miss_COLLIDE 1,221,556 1,221,578
evicting inserts 96.1 % 96.1 %

The prediction was on the record before the run: tsc's mints are 97.4 % fresh_keys_known_list + key_count from the [[Set]] CoW path, a different fork source, so lever (iii) should not move it. The requirement was must not regress; a flat row confirms the attribution rather than disappointing it.

The layout-count invariant held — 10,867 before and after. Shape identity did not move; only mint volume did.

Verification

  • cargo test -p perry-runtime on both arms back to back under the same host state, skipping the one test that aborts the process on main (bun_compat::plugin::tests::calls_setup_for_objects_and_functions_without_running_hooks, a non-unwinding panic, pre-existing): 4,204 passed / 0 failed on both.
  • cargo check clean in both feature configurations.
  • Both arms' archives asserted to contain the census before measuring (strings | grep), because perry-build-slot.sh's stale-check repair relinks with default features and had silently stripped the instrument out of an arm once.
  • Fixtures /root/fr/mx/{same,pool,del,accd,acc,diff}.ts on perrymaster.

Scope

This is one fork source among several, not a fix for #10868. That issue's exit criterion is mints ≈ distinct layouts on tsc, no abort, and same layout + same facts ⇒ same ShapeId always. Reaching it needs canonical shape identity: the authoritative dedup (by_facts) never fails, but it keys on the keys-array address, and that address is produced by a lossy 16,384-entry direct-mapped cache with no store behind it. Lever (iii) removes one way an address forks; it does not make the key canonical.

Stacked on #10885 — every number here is taken with that instrument.

…alue (#10868)

`Object.defineProperty` minted 3 ShapeIds per object for a data descriptor and
4 for an accessor, linearly and without bound, while the transition cache
reported lookups and ZERO inserts. This makes the data case constant.

Measured rather than read. Instrumenting every decline reason showed exactly
one predicate refusing, 1000 times out of 1000, at `object_ops/keys_array.rs`:

    defineProperty key-add, edge outcomes:
      no publish: new_index >= inline_capacity      1000
      eligible                                      1000

The receiver IS eligible and the lookup DOES run.

Why it refuses every time: `INLINE_SLOT_FLOOR` is 2, and #7916 documents it as
a pure FOOTPRINT dial -- "purely a growth-headroom dial for objects that gain
properties by name after birth". A two-field literal therefore allocates
exactly two slots and has zero growth headroom, so the first key added after
birth lands at `new_index == inline_capacity == 2`, spills to overflow, and the
edge is never published. Every receiver forks onto a private keys array, and
from there the fork cascade does the rest: once the predecessor ShapeId is
private, even #10287's DETERMINISTIC generation is deterministic in a value no
other object shares, which is why an accessor costs 4 ids and not 1. A dial
tuned for bytes silently decided whether transition edges get published at all;
neither subsystem could see that from its own side.

Raising the floor is not the fix: it trades footprint against a standing RSS
budget and only moves the cliff to four-field literals. The correct behaviour
already exists next door -- `field_set_by_name/tail.rs`'s overflow arm
publishes the edge with no inline-capacity gate at all, which is why the `pool`
control (the same objects grown by [[Set]]) already mints a constant 4,534 ids
however much work it does. This is missing wiring, not a missing capability.

Two changes, because publishing alone would have measured as a wash: the
adopter gate refused overflow-located edges too, on the stated grounds that "a
keys-only install (an accessor claiming its slot) writes no value, so the
overflow entry such an edge implies would never be created". That reason is
correct for accessors and over-broad for data. So publish the edge when the
install will write a value, AND let the adopter take one in that case.
`ensure_key_in_keys_array_for_value` is the opt-in and
`define_property_force_store_value` is its only caller; every keys-only claim
keeps today's behaviour by construction. `cached_target_fits` is untouched (it
guards a different hazard), and the live-bound bump is now guarded in BOTH
adopt arms -- unreachable in the first-key arm today, since its precondition is
a null keys array, but an unguarded bump beside a gate that now admits overflow
slots is a trap for whoever widens that precondition.

Measured with #10885's census. Six controls, two arms built from their own
trees, `cmp`-distinct binaries, output byte-identical to node on every row;
mints via `PERRY_SHAPE_MINT_DIAG`, instructions fitted N=2,000 -> 20,000,
min of 3:

  fixture  mints @1k/@10k before -> after     instr/iter before -> after
  same     1 / 1          -> 1 / 1            93.58 -> 93.58
  pool     4,534 / 4,536  -> 4,534 / 4,536    15596.14 -> 15595.14
  del      5,516 / 14,516 -> 5,516 / 14,516   21365.89 -> 21387.20
  accd     3,002 / 30,002 -> 5 / 5            16188.99 -> 12539.77  (-22.5%)
  acc      4,002 / 40,002 -> 4,002 / 40,002   19244.19 -> 19315.15
  diff     22,516/184,516 -> 22,516/184,516   220937.30 -> 220936.49

`accd` goes CONSTANT -- 5 ids whether it runs 1,000 iterations or 10,000 --
with 22.5% fewer instructions, so this is a speedup and not merely a smaller
counter.

`acc` being unchanged TO THE ID is not a missed opportunity; it is the result
that makes the change sound. The accessor is a keys-only claim whose implied
overflow entry is never created, so it must keep the private path. A change
that improved both rows would have broken the keys-only-install argument rather
than fixed the data one.

tsc (`ts.transpileModule`, typescript 5.8.2 from source, output identical to
node): MINTS 2,288,352 -> 2,287,800 and distinct key-NAME lists 10,867 ->
10,867. Flat, as predicted before measuring: tsc's mints are 97.4%
`fresh_keys_known_list` + `key_count` from the [[Set]] CoW path, which is a
different fork source. The requirement here was must-not-regress, and a flat
tsc row confirms the attribution. The layout-count invariant held, so shape
IDENTITY did not move -- only mint volume.

`cargo test -p perry-runtime` on both arms back to back, same host state,
skipping the one test that aborts the process on main
(`bun_compat::plugin::tests::calls_setup_for_objects_and_functions_without_
running_hooks`, a non-unwinding panic, pre-existing): 4,204 passed / 0 failed
on both. `cargo check` clean in both feature configurations.

This is ONE fork source among several, not a fix for #10868. The exit criterion
there is: mints ~= distinct layouts on tsc, no abort, and same layout + same
facts => same ShapeId always. Reaching it needs canonical shape identity,
because the authoritative dedup (`by_facts`) keys on the keys-array ADDRESS and
that address is produced by a lossy 16,384-entry direct-mapped cache.
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 752f07b1-dd02-4311-a8d2-9734be31d56d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Acceptance-matrix verdict: no cell moves — the lever does not touch the access path

The ONE PATH acceptance matrix (OBJECT_MODEL_SINGLE_PATH_DESIGN_2026-09-20.md §C1.4), run on
this PR's stack: base feat/shape-mint-census @ edef43475 vs head
feat/descriptor-transition-edge @ 89f157b1c. 234 cells, field type num, marginal
instructions:u, min of 3, fitted 500k→5M, every cell output-identical to node, every cell
disassembled to confirm the access is inside its loop. Declared expectation: EXPECT=addkey.

Binaries asserted by content, not by status: base e675122753abc20c…, head
bb0f521030d21fb1… — distinct; no ^error in either build log; no .rs newer than either
binary. Link mode pinned (PERRY_NO_AUTO_OPTIMIZE=1) on both arms: fixture fingerprints
size=19837816 nsym=17045 vs size=19837824 nsym=17045 — same mode, the 8 bytes are this
PR's runtime change.

operation max/min median/node verdict Δ median vs base
read1 4.72× 9.28× FAIL +0.00
read4 7.18× 17.88× FAIL +0.00
overwrite 5.00× 5.41× FAIL +0.00
addkey 1.26× 23.67× FAIL +0.00
inherited 1.23× 36.04× FAIL +0.00
method 55.88× 358.52× FAIL +0.00
read1_hoisted 3.45× 4.87× FAIL +0.00
read4_hoisted 9.51× 15.28× FAIL +0.00
inherited_hoisted 1.27× 20.43× FAIL +0.00

Per-cell diff: 0 regressed, 0 voided, 0 diverged, 0 improved, 0 expected. Every one of the
234 cells is bit-identical to the base, including all 21 addkey cells. The coordinator's
prediction — addkey does not move — is confirmed, and so is the stronger statement that
nothing moves.

Why nothing can move — from source

The new behaviour is gated on writes_value, which is set only by
ensure_key_in_keys_array_for_value, and that function has exactly one caller:
define_property_force_store_value (object_ops/descriptor_helpers.rs:800), the
data-descriptor path. The matrix's addkey cells add a key by plain assignment
(o.z = k), which never enters ensure_key_in_keys_array at all — it goes through
field_set_by_name/tail.rs's overflow arm, which, as this PR's own doc comment notes, already
published an overflow-located edge. So the matrix has no cell on the path this PR changes, by
construction. That makes this run a no-regression gate for the PR, not a verification of the
lever
— the lever's own evidence is the mint census in the body.

Does the lever touch the access path at all? — not on its own path either

Source-derived, not measured (the two arms differ only in runtime, and verifying it would need
a defineProperty cell built against the base runtime, which I have not rebuilt): on the
data-descriptor path the change decides whether an overflow-located transition edge is
published and adopted — i.e. whether a new ShapeId is minted. It does not change where
the value lives. The diff is explicit about it: "Only an INLINE slot advances the live bound",
so a key that lands at slot_idx >= alloc_limit is still stored through the overflow path. The
mint count goes from linear to constant; the ~219-instruction spilled store and ~100
spilled read measured on #10905 are untouched on the descriptor path just as on the assignment
path.

That is the useful reading for lane 8: lever (iii) fixes shape identity (step 2.5's mint
problem), not storage location.
The access cost of a key added after birth is a birth-sizing /
growth-policy defect (#10905, L8.3) and needs a separate change.

One thing this run caught in the harness

The first diff refused to compare the two arms: the link-mode fingerprint compared binary
size exactly, and this PR's runtime change legitimately moves the fixture binary by 8 bytes. A
fingerprint that refuses every runtime-changing PR fails in the safe direction but is still
wrong. It now refuses on a >2% symbol-count or >10% size swing — the real
auto-optimized/prebuilt split it exists to catch was 12,249 vs 17,037 symbols, a 28% swing — and
it still refuses that case. The guard's first live PR run is what found it.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train 253 (#10918) as v0.5.1633 — merge commit 0fa3915293.

Expedited at the owner's request. Carried with nine other PRs; the stacked ones (#10899/#10900 on #10886, #10901 on #10885) had only their unique commits taken.

Evidence on the assembled tree: perry-runtime full suite 4233 passed / 0 failed / 0 SIGABRT, perry-hir 471 / 0, cargo check --workspace --all-targets under -D warnings clean, cargo fmt --check clean, and all eight ratchets rc=0. The gap sweep and compiler-output suites were not run.

Closing here rather than merging — a train lands the commits directly.

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