Repository navigation
ShapeId exhaustion aborts the process after ~475 TypeScript transpiles (~2.26M ids minted per transpileModule, ~45 per AST node) #10868
Description
Summary
Perry mints ~2,257,000 ShapeIds per ts.transpileModule() — roughly 45 per AST node. The ShapeId band is 2^30 ids. At that rate a process aborts after about 475 transpiles:
ShapeId band (0x8000_0000 .. 0xC000_0000): 1,073,741,824
minted per transpileModule (typescript 5.8.2): 2,257,000
transpiles before exhaustion: 475
Exhaustion is not a graceful degradation. alloc_shape_id_from parks at the end and returns Err(ShapeIdExhausted); shape_descriptor_error_abort turns that into:
fn shape_id_exhausted_abort() -> ! {
eprintln!("Perry ShapeId space exhausted; refusing to publish an untracked object shape");
std::process::abort()
}The refusal is correct — publishing an untracked shape would be a silent wrong value, and never wrapping is right because a wrapped id could alias a live shape. The problem is upstream of it: the consumption rate makes a bounded, non-recycled 2^30 space a hard runtime limit on process lifetime.
Why this is a real-world limit, not a synthetic one
475 transpiles is not a stress test. It is a few minutes of ordinary work for:
- a watch-mode compiler re-transpiling on every save
- a language server, which transpiles continuously as you type
- a dev server with on-the-fly TS transform (vite, tsx, ts-node)
- a test runner transpiling each file in a large suite
- any long-lived build daemon
All of these are the intended workloads for a TypeScript-to-native compiler's own runtime. A process that aborts after a few hundred compilations cannot host them.
Shapes are minted but never reclaimed
Two independent facts combine into the limit:
- Consumption is per-operation, not per-distinct-layout. ~45 ids per AST node means the vast majority of these ids are not describing distinct object layouts a program will ever see again — they are churn from construction, mutation and transition, on objects that die immediately.
- Ids are never reused. That is deliberate: reuse could alias a live shape and a stale inline cache would then match the wrong layout. Non-reuse is what makes a shape compare sound, and it should not be given up.
So the fix is not "recycle ids" — it is to stop minting one per operation. Directions worth evaluating, none of them scoped here:
- De-duplicate more aggressively.
shape_descriptor_ensure_with_holesalready de-duplicates byfacts_key; a 45-per-node rate suggests many mints are semantically identical shapes that miss the memo. The 2026-09-15 work ondeterministic_semantic_generation(perf: one Object.defineProperty sends every later store on that object to the slow path (zod v4 schemas 29× bun; top OpenCode startup cost) #10287) found exactly this shape of defect — a memo table clearing at 8192 entries re-minted ~57k generations and forked every dependent receiver. A census of why each id is minted, by call site, would say whether this is the same class of problem. - Reclaim ids for shapes with no live objects and no cached references. Expensive and needs a safe point, but the space is otherwise permanently lost to dead layouts.
- Widen the band. A larger id would cost a wider shape word and touch the emitted guard, the
+4layout rule (fix(runtime): close rule 3 — no non-object cell can hold a live ShapeId at payload +4 #10828) and every range test; it buys a constant factor, not a fix.
Measurement
typescript 5.8.2, one ts.transpileModule(), output byte-identical to node. Counted via a default-off diagnostic added while designing field representation in shapes; a zod 3.23.8 parse loop shows a much lower slope (1.32 ids/iteration), so the rate is workload-dependent and the compiler case is the severe one.
Relationship to work in flight
Found while measuring the shape-count cost of adding field representation to shapes (design step 5 of the object-model rework). That design would add ~12% to the mint rate, which is a rounding error against this: the budget is already two orders of magnitude short of a real tsc run. A parallel change assigning some ShapeIds at link time proposes splitting this band in two, which would halve the runtime half to ~237 transpiles.
Both of those are downstream of this. Whatever consumes 45 ids per AST node should be understood before anything else is designed against the remaining budget.
Activity
proggeramlug commentedon Sep 21, 2026
Census: why those 2.26 M ids are minted
Instrument:
PERRY_SHAPE_MINT_DIAG=<path|1>(object/shape_mint_census.rs, default-off, one relaxed atomic load when unset, dumped atatexit). It labels every mint by which ofshape_descriptor_ensure_with_holes' six identity facts moved against the family already indexed under the same keys-array address, and separately records the ordered key-name list's content hash — so "a new layout" and "the same layout at a new array address" are different rows.The headline
ts.transpileModuleof a 1,201-line generated source, typescript 5.8.2 compiled from source by perry, output byte-identical to node:ensure()callsidentical(afacts_keymemo failure)Slope 2,256,422 ids per transpile, and the number of distinct layouts is constant at 10,867 while mints grow linearly.
That reframes this from a budget problem into a defect.
What it is not
facts_keymemo failure.identicalis 0 on every workload measured.fresh_keys_new_listis 0.1 % of mints, and the layout count does not grow with the work.poolcontrol below, where an identical creation pattern costs zero ids per object.What it is: a direct-mapped cache thrashing, at two ids per miss
TRANSITION_CACHE_SIZE = 16384, direct-mapped (transition_cache_slot(prev_shape_id, key_id) & 16383, one edge per slot):miss_COLLIDE(a different live edge holds the slot)miss_empty(cold)87 % of all lookups collide with a different live edge; only 4 % are cold. The edges are not new — they are evicted and re-learned.
Every transition-cache miss costs exactly TWO ShapeIds, and the arithmetic closes:
2 × 1,137,572 = 2,275,144against 2,287,869 mints, the remainder being the 2.4 % of other causes. The two are the top two call sites, one line each:object/field_set_by_name/tail.rs:1022— 1,077,000 mints, allfresh_keys_known_list. This is the copy-on-write clone: a shared keys array is cloned before the add, andset_object_keys_array(obj, cloned)publishes a shape for the clone whose ordered key list is identical to its predecessor's. An intermediate state with no consumer.object/field_set_by_name/tail.rs:1102— 1,073,301 mints, allkey_count. The append that follows.Cause split overall:
fresh_keys_known_list49.6 % +key_count47.8 % = 97.4 %;gen_unique2.2 %,gen_deterministic0.2 %,fresh_keys_new_list0.1 %. Retirement confirms the waste: 2,237,158 retirements against 2,287,869 mints, 1,094,525 of them within 2 mints of birth.The controls — the sharing machinery works; tsc's working set does not fit
Six fixtures, each output byte-identical to node, same instrument:
samepooldeldelete+ add per objectaccdObject.defineProperty, data descriptoraccObject.defineProperty, accessordiffmiss_COLLIDE8,624 → 199,949, evictions 31.6 % → 82.0 %poolis the existence proof: the same creation pattern as an AST node factory — same field names, dynamic key, unfoldable — costs zero ids per object once warm. The machinery is capable. tsc's working set simply does not fit in 16,384 direct-mapped slots.A second, independent defect: the descriptor key-add path never publishes an edge
On
acc/accdthe transition cache reports 1,000 lookups, 0 hits, 0 inserts. The descriptor key-add path consults the cache and never populates it, so no object can ever reuse another object's edge. That is whyObject.definePropertycosts a flat 3–4 ids per object forever, and it reads like an oversight rather than a trade.The fork cascade — why this is geometric rather than merely wasteful
One fork is not one wasted id.
deterministic_semantic_generation(#10287) is a pure function of (predecessor ShapeId, key, attrs). Once an object has been forked onto a private keys array by the copy-on-write clone, its predecessor ShapeId is private to it — so the deterministic generation is now deterministic in a value no other object shares, and every subsequent descriptor transition on that object mints too. This is visible onacc: of its 4 ids per object, 2 aregen_deterministic, i.e. #10287's shared-generation path minting per object because the fork upstream already made sharing impossible.One fork makes every later transition on that object private.
Two levers, neither proposed here
tail.rs:1022— half of every miss's cost, publishing a layout identical to the one it replaces.Each needs its own before/after on tsc and on all six controls, with the layout count (10,867) held as an invariant: if that number moves, shape identity has been altered rather than mint volume.
Reproduction
Workloads and fixtures on perrymaster:
/root/fr/work/tscwork.ts(typescript 5.8.2 from source),/root/fr/mx/{same,pool,del,accd,acc,diff}.ts. Compile withPERRY_NO_CACHE=1 PERRY_NO_AUTO_OPTIMIZE=1 PERRY_RUNTIME_DIR=<worktree>/target/release, then run withPERRY_SHAPE_MINT_DIAG=1.Also measured, and worth recording because it removes a false urgency: zod does not burn the band at all. 23,370 mints at 200, 1,000 and 2,000 iterations — identical, a one-time warm-up cost.
proggeramlug commentedon Sep 21, 2026
Correction to the census: the descriptor key-add path DOES publish and replay edges
The "second, independent defect" above is withdrawn. The
acc/accdreceiver is{ a: i, b: i + 1 }— two fields — andINLINE_SLOT_FLOOR = 2, so the literal allocates exactly two inline slots andObject.defineProperty(o, "c", …)landscat index 2: an overflow target.ensure_key_in_keys_arraydeclines overflow targets on both sides by design (slot_idx < alloc_limiton the probe,new_index < inline_capacityon the teach — the comment there says why: a keys-only install writes no value, so the overflow entry an overflow edge implies would never be created). The 1,000 lookups / 0 hits / 0 inserts were that exclusion firing, not a path that never publishes.Same two fixtures with a one-field receiver
{ a: i }, soclands inside the floor. Same instrument, same binary, output identical to node:acc1(accessor)acc1accd1(data descriptor)accd1Constant in N. #10287's descriptor edge works, including its deterministic generation (
memo hits1,999 at N = 1,000 onacc1— two per object, both served). What the original rows measured is the overflow-target exclusion, a deliberate trade, and it is not material to this issue: the whole descriptor family is ~0.6 % of tsc's mints (keys_array.rs:2635,367 +descriptor_state.rs:664/6707,401 of 2,287,869). No PR for it. Whether overflow-target edges should be taught the way the[[Set]]tail already teaches them is a separate question with its own risk (the value / keys-only asymmetry that comment names), and nothing here turns on it.The fork cascade stands, with the example read correctly: on
acc(two fields) the twogen_deterministicmints per object are per object because the copy-on-write fork gave that receiver a private predecessor id; onacc1the replayed edge keeps the predecessor shared and the same two generations are memo hits. The cascade is a consequence of the fork, and a fork happens on every transition-cache miss. Lever (ii) halves what a fork costs; it does not prevent one.proggeramlug commentedon Sep 21, 2026
Lever map and who is on what
Three parallel sessions have now touched this issue, so here is the split, in the one place every session reads. A marker file inside a worktree cannot defend a worktree — this can at least make the race visible.
PERRY_SHAPE_MINT_DIAG— the per-mint cause/key-list/call-site/cache-outcome census the numbers above come fromtail.rs:1022, 1,077,000 mints on one transpile, every onefresh_keys_known_list— a shape published for a clone whose ordered key list is identical to its predecessor'sfeat/cow-single-publishacc/accd: 1,000 / 10,000 cache lookups, 0 hits, 0 inserts; 3–4 ids per object, linear and unboundedAlso mine, and unrelated to the mint rate:
feat/field-representation(object-model design step 5). Its build is suspended pending this issue; only its measurement commits exist.(ii) and (iii) are genuinely independent, which is why they can run in parallel. (ii) is about a path that does use the transition cache and pays two ids per miss; (iii) is about a path that consults the cache and never populates it, so no object can ever reuse another object's edge and no change to the cache's size, associativity or publication discipline can help it. A path that never publishes an edge reads like an oversight rather than a trade.
Why any of these is worth more than its own line count — the fork cascade.
deterministic_semantic_generation(#10287) is a pure function of (predecessor ShapeId, key, attrs). Once an object has been forked onto a private keys array, its predecessor ShapeId is private to it, so the deterministic generation is now deterministic in a value no other object shares and every subsequent descriptor transition on that object mints too. Onacc, 2 of the 4 ids per object aregen_deterministic— #10287's shared-generation path minting per object because a fork upstream had already made sharing impossible. One fork makes every later transition on that object private, which is why the rate is geometric in practice rather than merely wasteful.Invariant for every fix here: the distinct key-name list count must not move. It is 10,867 on one
transpileModuleand 10,867 on two. If a change moves it, that change altered shape identity, not mint volume, and the mint delta means nothing.And the acceptance set is the six controls plus tsc, all on the same instrument:
same(1 mint),pool(constant — the existence proof that the machinery works),del(1/object),accd(3/object),acc(4/object),diff(18.45/object). A change that helps tsc and regressespoolordiffis not a fix.perf(runtime): a default-off census that says WHY each ShapeId was mi…
proggeramlug commentedon Sep 21, 2026
Lever (iii) root-caused: one predicate, and a footprint dial that governs shape publishing
Measured rather than read: I instrumented every decline reason in
define_append_transition_eligibleand at the edge-publication site. Onaccd(a data-descriptor install per object), N = 1,000, output byte-identical to node: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 — and the finding that outlives the fix
INLINE_SLOT_FLOOR = 2(object/mod.rs:49). #7916 moved it 8 → 4 → 2, and its own doc comment is explicit about what it is for:So a two-field literal
{a, b}allocates exactly two slots and has zero growth headroom. The first key added after birth lands atnew_index == inline_capacity == 2, spills to overflow, and the transition edge is never published. Every receiver forks onto a private keys array, and from there the fork cascade does the rest.A dial tuned purely for footprint — documented as having nothing to do with shapes — silently governs whether transition edges get published at all. Neither subsystem's author could have seen that from their own side: #7916 was reasoning about bytes per object and correctly proved
alloc_limitis a fixed point of the allocation; the transition cache was reasoning about edges and reusedalloc_limitas a proxy for "can this key be shared". The coupling is invisible from both ends. Worth more to the next reader than the fix, and worth writing down before the next person tunes a footprint dial.Do not raise
INLINE_SLOT_FLOORIt is the obvious fix — the refusal is
new_index < inline_capacity, so 4 or 8 makes the symptom vanish — and it is wrong three times over:field_set_by_name/tail.rs:1078— the[[Set]]tail's overflow arm — publishes the edge with no inline-capacity gate at all, and its comment gives the reason in the words the fix needs: "so the next object with this exact predecessor ShapeId that adds the same key hits the fast path". One path handles this situation; the other refuses it. That is missing wiring, not a missing capability.It also explains the
poolcontrol:poolgrows by[[Set]], so its overflow adds publish edges and its mints stay constant at 4,534 however much work it does. Same object shape, same overflow, opposite outcome — decided only by which path did the add.It is two changes, and it must be predicted as two
The adopter gate refuses overflow-located edges as well (
keys_array.rs:~200and the first-key arm at~66):That reason is correct for accessors and over-broad for data. An accessor install claims a keys slot and writes no value, so adopting an edge whose slot lives in overflow would imply an overflow entry that never gets created. A data descriptor does write the value, so the same objection does not apply to it.
So lever (iii) is: publish the edge when the install writes a value (mirroring
tail.rs:1078), and let the adopter take an overflow-located edge in that same case. Publishing alone changes nothing, because nobody can adopt — it would measure as a wash and read as "the idea didn't work", when half the mechanism simply wasn't built.cached_target_fitsstays either way: 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 acceptance criterion is differential, not directional
accd(data descriptor, 3 ids/object, linear) must collapse to constant.acc(accessor, 4 ids/object) must NOT improve. The accessor keeps the private path by design.A change that improves both has broken the keys-only-install argument rather than fixed the data one, and that is the result to watch for. Plus the usual: all six controls and tsc on the same instrument, and the distinct key-name list count held at 10,867.
proggeramlug commentedon Sep 21, 2026
Step 2.5 — canonical shape identity: what it requires
Charter home:
OBJECT_MODEL_SINGLE_PATH_DESIGN_2026-09-20.md§L8.1. (The section numbers cited below — §4, §7 — are the original doc's and are stable.)10,867 distinct key-name lists. 1,116,873 distinct keys-array addresses. One
transpileModule.That is #10868 in one line, and it is the whole of it.
What is actually broken
There is an authoritative store and it never fails.
ShapeTableInner::by_factsis an unboundedHashMap<facts_key, IdList>, consulted on everyshape_descriptor_ensure_with_holes. The census'sidenticalrow fires exactly when a mint happens despite an exact-facts sibling already existing; it reads 0 on zod, on tsc, and on all six controls. Same facts ⇒ same ShapeId already holds, always.So the defect is not a missing store. It is that the authoritative table dedups on a key that a lossy cache produces:
facts_keyincludes the keys-array address, and two objects share a ShapeId only if they share an address.Three facts from source, all of which belong in the charter:
ShapeRecordhas no transitions field.keys, semantic_generation, logical_key_count, live_inline_slot_count, hole_count, flags, _pad— 32 bytes, const-asserted.ShapeTableInnerisindices | by_facts | families. The design doc §4's "transitions out of this shape (add key / delete key / descriptor change → next shape)" was never built; the 16,384-entry direct-mapped cache is the only transition structure that exists.Exactly three sites produce a shared keys array, and they are not equally safe:
object/mod.rs:724) — objects born with their key list. A direct-mapped inline cache with an unboundedshape_cache_overflowHashMap behind it. Two-tier, lossless. Evidence:samemints 1 id;poolmints a constant 4,534 at any N.object/mod.rs:1113, reachable only fromtransition_cache_lookup/_insert) — objects grown by name. 16,384 direct-mapped entries, nothing behind them. Evidence: 96.1 % evicting inserts, 87 % of lookups colliding with a different live edge.for_in_stable.rs:212— a for-in stability path, not a general producer.So born-with-keys identity is canonical by construction; grown-by-name identity is cache-determined. The two-tier pattern the growth path needs already exists in this codebase, on the birth path.
deterministic_semantic_generationinherits forks, it does not cause them. It is a pure function of (prev ShapeId, key, attrs) with no capacity — but keyed on the predecessor, so once the predecessor is private the generation is deterministic in a value no other object shares. That is the cascade, and it is why an accessor install costs 4 ids and not 1.The two options against the exit sentence
The criterion is "same layout + same facts ⇒ same ShapeId, however the object was built". The last clause is the One Path part: a shape compare replaces per-site proofs only if two objects that are the same layout compare the same — born from literal site A, literal site B, or grown by name.
Option 1 — an overflow store behind the growth cache. Stops eviction forks. It does not merge a born
{a,b}with a grown{a,b}, nor two literal sites whose static keys arrays differ but whose key lists are equal: identity stays address-keyed, just losslessly. So it meets the criterion only if address-distinct-but-layout-equal arrays are rare in practice — which is a measurement, below.Option 2 — a content-canonical key. Meets the criterion by construction. It is also what the design doc's own table rule asks for — "no table keyed by an object's address" — and there is a second, independent argument for it that has nothing to do with identity:
Every GC that moves a keys array must remove each id from
by_factsunder the old facts key and re-insert it under the new one, andscan_shape_table_rekey_mutwalks the families to do it. A content key is GC-invariant, so option 2 deletes that entire rekey pass.facts_keyon an address is precisely the derived-structure-under-a-moving-collector hazard the rule exists for.The two design points, confirmed from source
deterministic_semantic_generationis already a SplitMix64 fold of (prev shape id, key bytes, attrs); the census'skey_list_content_hashis already a position-sensitive FNV fold. Making the layout key a rolling function of (predecessor's layout key, added key) is a re-association, not new machinery. Then a transition-cache miss costs oneby_factsprobe with an O(1)-computed key — not a mint and not a key-list walk — which demotes the 16,384-entry cache to a true accelerator whose evictions fork nothing.GC_FLAG_SHAPE_SHAREDdegenerates: every keys array a shape record owns is shared, so clone-before-mutate becomes unconditional and the flag stops being a distinction. Simpler, not harder.retire_owned_shape_siblingsbecomes wrong, not merely unnecessary: today an owned array's predecessors are retired on every same-address publish; under a canonical array the predecessors are other objects' layouts and must be retained.facts_key, so the canonical key must be over the ordered list including tombstone positions — otherwise a delete could collide two different arrangements. Key order is observable in JS, so the key is over the ordered list:{a,b}and{b,a}must stay distinct. The census hash is already position-sensitive (it multiplies after each fold), and that property needs a test that fails without it.How option 1 gets its number — and the trap in getting it
The question is: of the 1,116,873 addresses, how many survive under lossless edges?
The naive measurement is wrong and would flatter option 1's opposite. Counting today's distinct
(prev_shape, key)edges gives a number keyed on predecessors that have already forked — 1.1 M of them — so it reports the fork, not the fix, and would say "option 1 leaves ~1.1 M" as an artifact. The error is recursive: fewer forks upstream means fewer distinct predecessors means fewer distinct edges.The honest instrument is a shadow simulation: run the real program, and alongside it maintain a parallel id space under each option's rules — birth arrays as roots,
lossless_edge[(shadow_prev, key)] → shadow_successorfor option 1, andintern(content_key, counts, generation, kind, holes)for option 2 with the generation recomputed over shadow predecessors. Count the distinct shadow ids. That is a faithful simulation of each option and it costs no correctness risk, because nothing branches on it.Predictions, committed before building
For option 2, on one
ts.transpileModule:facts_keyalso carries hole counts, kinds, live-slot bounds and descriptor generations, and a layout can legitimately appear with more than one of those; below 15,000 because the census already showsgen_deterministicat 0.2 % andgen_uniqueat 2.2 % of mints, and both collapse when their predecessors merge.For option 1: I will not guess. The simulation decides it, and the decision rule is stated in advance — if option 1's shadow count is within ~2× of option 2's, the options converge and cost decides; if it is materially more, option 1 is a lever and not the step.
Before any of it: the semantics that must not move
Key order is observable. Differential tests against node for
Object.keys,for-inandJSON.stringifyorder across born-vs-grown objects of the same layout go in before the change and must be sabotage-proven — a canonicalisation that merged two orders would be a silent wrong answer in every program, not a slow one.proggeramlug commentedon Sep 21, 2026
Step 2.5 recommendation: option 2 (content-canonical key)
Charter: §L8.2. Facts and measurement trap: §L8.1 / comment 5763294802.
Ralph's constraint — avoid side tables — decides the architecture ahead of the counts, because the two options move in opposite directions against it. The design doc's own §4 rule says the same thing: no table keyed by an object's address; what remains is keyed by shape id or code site.
The shadow simulation still runs — the 10,867–15,000 prediction needs confirming, and option 1's number tells us how much of #10868 was eviction versus address-keying, which is worth knowing whichever way it lands. But the recommendation does not hang on it.
What option 2 deletes
Every item here exists because identity is keyed on a moving address.
by_factsrekey pass.scan_shape_table_rekey_mutwalks the families on every moving collection; for each idmove_shape_familydoesfacts_remove(facts_key_with_keys(old))+facts_push_back(facts_key_with_keys(new)). Its own comment states the premise — "the other five facts never change under the collector" — so a content key makes the whole pass a no-op. Deleted.next_keys(a heap address) andkey_ptr, so it needsscan_transition_cache_roots_mut, theTRANSITION_CACHE_YOUNGlog,arm_transition_cache_young,transition_entry_is_minor_relevant, and the young-log rule-1 ordering discipline. Under a content key the entry is (prev layout id, key id) → successor layout id — u32s, no addresses — and the successor's array is read from its own shape record. Root scanner, young log and arming discipline all deleted; the cache becomes a pure accelerator whose loss costs a hash probe and forks nothing.GC_FLAG_SHAPE_SHAREDand its clone-decision machinery. Three stamp sites,transition_cache_stamp_shape_shared, and thekeys_sharedbranch that every growth path opens with (tail.rs,keys_array.rs,delete_rest.rs). With one canonical array per layout, a shape-owned array is always shared and clone-before-mutate is unconditional. The flag stops being a distinction. Degenerates.retire_owned_shape_siblings' role. It exists to stop an owned array's per-append prefix descriptors accumulating. There are no owned arrays under option 2; predecessors are other objects' layouts and must be retained. Deleted (and keeping it would be wrong, not merely unnecessary).families(keys addr → ids) collapses to ~one id per array and can be a back-pointer in the record;indices(keys addr → key→slot accelerator) can key on the layout id. Neither needs the address any more.Net: fewer tables than today, and none of the survivors keyed by an address.
The one real cost, and where dictionary mode's storage lives
An owned array appends in place, O(1). Under a canonical array, growth resolves the successor layout and a genuinely new layout costs a copy of length k. That is once per layout — the win — but an object whose key list is unique to it makes every append a new layout: O(k²) over k appends. That is the pathological case, and it is what makes §7's "dictionary mode is required, not optional" load-bearing rather than defensive.
Dictionary mode must respect the same rule. A dictionary-mode object's keys live in the object, behind the ext pointer —
ObjectMeta, which already exists and already carriesprototypeandspill(#6812) — not in a global map keyed by the object's address. That is §4's storage model as written, and it means dictionary mode adds no table: it moves a per-object key list from a shared canonical array into the per-object record it already has.The entry condition wants to be a measured latch, not a guess, and it must be proved to fire (§17: a check that cannot FIRE). The census already distinguishes the two populations —
fresh_keys_new_listis 0.1 % of mints today, so genuinely-unique key lists are rare, which is the same evidence that says the canonical path will hold for almost everything.What still has to be true before any of it
Key order is observable. The canonical key is over the ordered list including tombstone positions,
{a,b}and{b,a}stay distinct, and the differentialObject.keys/for-in/JSON.stringifyorder tests across born-vs-grown objects of the same layout go in before the change, sabotage-proven. A canonicalisation that merged two orders is a silent wrong answer in every program, not a slow one.Predictions, unchanged and still committed
Option 2, one
ts.transpileModule: distinct shadow layout ids 10,867–15,000; distinct keys-array addresses ≈ the same; mints ≈ distinct layouts, against 2,287,800 today. Option 1: no guess, and the decision rule stays — within ~2× of option 2 the counts converge and cost decides; materially more and it is a lever, not the step. The side-table criterion now says that even convergence would not make option 1 the recommendation.proggeramlug commentedon Sep 21, 2026
Shadow simulation: option 2 confirmed, option 1 is ~0, and a fourth fork source
One
ts.transpileModule, output identical to node, instrumentPERRY_SHAPE_MINT_DIAGwith the option-1 edge replay done at dump time (noper-object state, no address keying). Charter: §L8.1–L8.3.
Edges replayed 1,138,062. Births 1,150,711.
Option 1 saves 0.2 %, and the reason is the births
Over half of all minted shapes are births — shapes that are never the target
of a transition edge — and option 1 cannot merge a birth. They are almost
entirely the copy-on-write clone intermediates:
tail.rs:1022alone accountsfor 1,077,458 mints, every one
fresh_keys_known_list, and a clone publish isnot an edge, so a lossless edge store never sees it.
One caveat I cannot measure away: option 1's value is entangled with lever
(ii). With the clone intermediate removed, those 1.15 M births largely
disappear and option 1's number would change. The replay reproduces today's
code faithfully and cannot separate the two. So the honest claim is "option 1
is worth 0.2 % on the tree as it stands", not "option 1 is worthless in
principle". It does not change the recommendation, which was decided by the
side-table rule rather than by counts.
Option 2: my predicted band was right about the part I predicted, and I missed a component
12,846 against a predicted 10,867–15,000 — inside the band, and ×1.18 of the
distinct-layout count, which meets the exit criterion on the structural
axis.
But that is the structural number, and the total is outside my band, for a
reason worth naming rather than smoothing over.
transition_object_shape_semanticsallocates its generation from the monotonic
SHAPE_SEMANTIC_NEXTcounter,not from a pure function — so option 2 cannot canonicalise those shapes at
all. #10287 made the data-descriptor path deterministic and left this one.
On tsc that is 50,083 mints (2.2 %), of which 48,197 come from a single site:
prototype_chain.rs:473, theprototype_diverged → transition_object_shape_semantics(obj)call. Adding them back:
layouts — does not meet the exit criterion.
So this is a fourth fork source, it is one call site, and it is the only
thing standing between option 2 and mints ≈ distinct layouts. The fix has a
precedent to copy exactly: make the generation a pure function of
(predecessor layout, the prototype identity, the link kind) the way
deterministic_semantic_generationalready is for descriptor installs. Tworeceivers that diverge their prototype the same way over the same predecessor
should land on the same successor.
Adding it to the lever map as (iv), claimed by me, sequenced inside step 2.5
rather than before it — it is only worth doing once identity is canonical,
because today its predecessors have already forked and merging its generations
would merge nothing.
Why the "upper" row is an artifact and not a bound to plan against
1,193,019 keeps today's generation values, which are keyed on predecessors
that have already forked — the same recursion I flagged before running this.
It is reported for completeness and it is not the number to design against; the
structural row plus a counted
gen_uniqueresidue is.perf(runtime): a default-off census that says WHY each ShapeId was mi…
perf(runtime): publish a transition edge when a descriptor writes a v…
proggeramlug commentedon Sep 21, 2026
Landed in merge train 253 (v0.5.1633): the instrument and lever (iii)
Verified by content on
origin/main@0fa3915293— both PRs show closed because the merge train rewrites SHAs, so the check is the code itself:shape_mint_census::classify_mint,ensure_key_in_keys_array_for_value, and theshape-mint-diagfeature are all present.PERRY_SHAPE_MINT_DIAG, every call site behind the non-defaultshape-mint-diagfeature, so the shipped runtime is unchanged (off-cost measured at 0.00–0.03 % on all six controls, i.e. within the method's resolution). This is now the instrument every later slice of step 2.5 is measured with.The record, one
ts.transpileModule, typescript 5.8.2 from source, output identical to nodetsc is flat, as predicted on the record before measuring — its mints are 97.4 %
fresh_keys_known_list+key_countfrom the[[Set]]copy-on-write path, a different fork source. The requirement for lever (iii) was must not regress, and the layout-count invariant held: identity did not move, only mint volume.Where lever (iii) does move things, it moves them to constant:
accd(a data-descriptor install per object) 3,002 / 30,002 → 5 / 5 at N = 1 k / 10 k, with −22.5 % instructions.acc(accessor) unchanged to the id, which is the row that makes it sound — an accessor is a keys-only claim whose implied overflow entry is never created, so it must keep the private path.What this does not do
Neither change makes identity canonical. #10868's exit criterion — mints ≈ distinct layouts; same layout + same facts ⇒ same ShapeId, however built — is step 2.5's job. Its plan is §L8.3; Stage 0 (the order/identity differential tests) is #10919, ready for review off
main; the shadow simulation puts the content key at 12,846 shapes against today's 2,288,773 (×1.18 of the 10,867 layouts), with lever (iv) as the one remaining fork source standing between it and the criterion.proggeramlug commentedon Sep 21, 2026
Birth sizing coverage — what fraction of objects can be born the right size statically?
For lane 8's
L8.3.7precondition (every object of a layout has the same inline capacity).Diagnosis only. Source on
upstream/main; counts from uprobes onjs_*allocation entrypoints of
PERRY_KEEP_SYMBOLS=1builds of/root/fr/work/{tscwork,zodwork}.ts.1. By site — the premise is mostly already true, from source
The compiler already sizes every construction whose key set it can see:
{a, b}new __AnonShape_…→object_alloc_class_inline_keys_impljs_object_alloc(0, N)—expr/object_literal.rs:561, N = the literal's key countmax(N, 2)new C(class)max(field_count, INLINE_SLOT_FLOOR)—lower_call/new_alloc.rs:436this.x =stores are unconditional (#10503's conditional add is the exception)new F()(function ctor)js_object_alloc(cid, learned_inline_field_count(cid))—class_registry/construct.rs:1111Object.create(P)js_object_alloc(class_id, 0)—object_ops/prototype.rsSo "can the compiler know the final key set?" is the wrong question for literals and classes —
it already knows and already passes it. The real gap is growth after birth, and that is what
breaks the invariant: a 5-key literal (capacity 5) and a
{}grown to the same 5 keys (capacity2) share a layout and not a capacity.
Slack tracking already exists — with two holes
Every spill records the class's true width —
spill.rscallsnote_learned_inline_fields(obj, class_id, field_index + 1)onoverflow_setandspill_set_slow, commented "Learn the class'strue width so FUTURE instances allocate it inline" — and
learned_inline_field_countfeeds itback at allocation. Two things stop it covering the growth population:
learned_inline_field_countreturns 0 whenclass_id == 0(spill.rs:462). Objectliterals are allocated with class id 0, so a literal that grows after birth never learns —
every instance spills, forever.
Object.createcallsalloc_synthetic_class_id()on every call (object_ops/prototype.rs),so each object gets a fresh class id. It records learning against an id no other object
will ever use, and allocates with field count
0rather than consuming the learned value. Sothe obvious one-line fix — pass
learned_inline_field_count(class_id)at that call — wouldnot help: the id has to be cached per prototype first. (A fresh class id per
Object.createalso means every such object is a distinct class for shape identity, which is ShapeId exhaustion aborts the process after ~475 TypeScript transpiles (~2.26M ids minted per transpileModule, ~45 per AST node) #10868's problem
too.)
2. By object at run time
Uprobe call counts per allocation entry point.
clsinandstampedare the same births(
…_inline_keys_stampedcalls…_inline_keys_impl; the counts match exactly). With LTO somejs_object_alloccalls are inlined into runtime callers, solitcounts only calls made fromcompiled user code — i.e. literal sites — which is the population wanted here.
zod, per iteration (marginal, 40 vs 10 iterations — module init cancels):
object_alloc_class_inline_keys_impl(anon-shape literals, class instances)js_object_allocfrom compiled code (object literals)js_object_alloc_with_parent(generic allocator)js_arguments_object_allocjs_new_function_constructjs_object_createoverflow_set(a value stored to a spilled slot)ensure_key_in_keys_array(descriptor key claims)tsc, one iteration (
transpileModuleof the 400-declaration source; the second point isstill running under uprobes, so this includes module init):
js_new_function_construct(new F()— TypeScript's AST node constructors)js_object_alloc_with_parent(generic allocator)object_alloc_class_inline_keys_impljs_object_allocfrom compiled codejs_object_createoverflow_setensure_key_in_keys_array3. Concentrated or diffuse?
By allocation position (
--opt-report's unbound-site table): two positions dominate —call argument + return are 257 of 322 sites on zod (80%) and 1,689 of 2,698 on tsc (63%).
Growth is small against births on zod: 22 spilled stores per iteration against ~130 births,
and descriptor key claims happen entirely at module init. That is slack tracking's easy case.
What this means for
L8.3.7Object.createis absent from both real workloads. perf: an Object.create() receiver costs 3-4x a literal or class receiver on every operation, including OWN reads and writes (322 vs 112 read, 288 vs 69 write) — a receiver-kind cost, not a prototype-chain cost #10905 is a real defect but fixing itssizing buys nothing on tsc or zod.
new F(), which is already learned-sized — and that is thesharpest risk to the precondition, not the solution to it.
learned_inline_field_countis amonotonic high-water mark, so instances born before the width was learned have a smaller
inline capacity than instances of the same layout born after. Learned sizing, as built,
produces exactly the same-layout / different-capacity split canonical identity has to rule out.
V8 closes the same gap by finishing slack tracking after N instances and then fixing the map's
size; perry's learning never finishes.
so they only diverge by growing, and when they grow they cannot learn.
Posted on #10868 and in
secret-tests/matrix/FINDINGS-2026-09-21.md.proggeramlug commentedon Sep 21, 2026
Follow-up: tsc's per-iteration marginal is in — and it confirms the learning-phase split
The tsc table above was a single-iteration snapshot including module init. The second point
(3 iterations) has landed, so here is the true per-iteration marginal,
(3 − 1) / 2:js_new_function_construct(new F(), AST nodes)js_object_alloc_with_parent(generic allocator)object_alloc_class_inline_keys_impljs_object_allocfrom compiled codejs_object_createoverflow_set(a value stored to a spilled slot)ensure_key_in_keys_array97% of tsc's spills happen in the first iteration and then stop. 8,519 spilled stores in
iteration 1, 239 per iteration after that, while function-constructor births hold constant at
~67,700 per iteration. That is slack tracking working —
new F()learns its width from the earlyinstances that spill, and every later instance is born wide enough not to.
It is also the concrete form of the risk raised above. The early instances were born narrow and
spilled; the later ones are born wide. Same layout, two inline capacities, and the population
of narrow ones is exactly the ~8,300 objects allocated before the high-water mark settled. For
L8.3.7that pins the size of the problem on this workload: not "every object", but a boundedlearning-phase cohort per constructor — which is precisely the cohort V8 handles by finishing
slack tracking after N instances and then fixing the map's size. perry's learning has no finish
point, so the cohort is never reconciled with the steady-state layout.
One correction to the zod reading: its spill rate (22 per iteration against ~130 births) is also
steady-state, since zod's marginal already excluded init. zod has no function constructors, so its
residual spills come from class-id-0 growth — the hole that cannot learn at all.
proggeramlug commentedon Sep 21, 2026
Lever (iv) measured, and the capacity collision resolved
Charter: §L8.3.10b and §L8.3.11. One
transpileModule, output identical to node.Lever (iv) pays now — and the argument that put it later was wrong
The predecessors are shared, not forked. I had argued lever (iv) belonged after the content key because "its predecessors have already forked, so earlier merges nothing". I committed a two-branch prediction before measuring precisely because I was not sure — and the measurement picked the other branch. A generation keyed on (predecessor, prototype identity, link kind) collapses this site's 48,197 mints to ≤ 97: about −48,100 on tsc, standalone, now, and the one fork source between option 2 and the exit criterion.
Prototype identity is a serial in the prototype's own
ObjectMeta, not a unique prototype ShapeId: a prototype's ShapeId changes when it gains a method (that is why #10842's validity hook lives in the stamp funnel), so keying on it would fork receivers that diverge before vs after the mutation. The serial's cost is now measured, not assumed: 112,058 of 112,426 meta records are prototype marks, so it is ~0.9 MB, and "every meta" vs "only prototypes" is the same population on tsc.Capacity is a shape fact — lane 13's collision resolved as (b)
Lane 13 found 97 % of tsc's spills come from objects built before the size was learned: same key list, smaller capacity. From source, the learned size lives in
LEARNED_INLINE_FIELDS(object/spill.rs), a 1,024-entry direct-mapped per-thread table keyed by class id that:if class_id == 0 … return— object literals never learn;Object.create's fresh synthetic class ids.The choice is (b), and it is principled, not a concession: §3 requires the shape to determine key → location, capacity determines location, so capacity is a shape fact. Two objects with the same key list and different capacities are different facts, and same layout + same facts ⇒ same ShapeId still holds.
It is already priced. The option-2 simulation keyed on
live_inline_slot_count, so 12,846 already includes capacity in the key — it is option (b)'s number. Against 10,867 key lists, capacity diversity plus kind and hole variation costs at most 1,979 shapes (×1.18), which meets the criterion. On tsc: 0 dictionary-mode objects from this cause, RSS neutral, mints ≤ 12,846.(a) makes early spills permanent; (c) is the right goal but needs shrinking to be a new layout, which is (b); (d) would put 8,519 objects in dictionary mode in tsc's first iteration — a cliff for a problem (b) handles at ≤ 1,979 shapes.
The follow-up that cuts both spills and shapes: under option 2 the learned size keys on the layout, not the class id — which closes the literal-0 and
Object.creategaps at once — held as a per-layout fact in the shape record, not in another direct-mapped table that evicts.proggeramlug commentedon Sep 21, 2026
Live-set composition: 82.6% of what every collection traces is keys arrays
Measured with the runtime's own
PERRY_GC_CENSUS, which classifies the heap at the mark-completepoint of a synchronous full collection. Nothing was built for this beyond a copy of
tscwork.tsthat callsgc()from a TypeScriptbeforetransformer — so the census landsmid-transpile, with the AST fully live — and once between transpiles. Output is
typescript 5.8.2 193520, byte-identical tonode --expose-gc.Mid-transpile (AST live) — live heap 130.8 MiB, 870,974 objects
The program's genuine live data — AST nodes, strings, closures — is about 15 MiB. Everything
else the collector traces is object-model bookkeeping, and almost all of it is one kind of object.
Between transpiles — live heap 15.6 MiB
Keys arrays are 10.62 MiB of 15.6 MiB = 67.9%, and 50,687 of them persist across the call.
So this is not only a transient: the steady-state retained heap is mostly keys arrays too.
Shape records are NOT in the traced heap — settled from source
ShapeSlab { pages: Vec<Option<Page>>, len }(object/shapes_store.rs:269) is a RustVecthrough the global allocator, and
ShapeTableInner'sindices/by_facts/familiesarePtrHashMaps. The census reports all of them as side tables, not arena types. So the 2.26 Mminted ShapeIds are an RSS cost, not a GC-tracing cost — which is the answer to "is the slab
inside the collector's heap at all": no.
Their Rust-heap footprint is nevertheless large, and larger than the live GC heap:
shapes.descriptorsshapes.familiesshapes.by_factsgc.layout_slot_masksThe shape table's GC cost is not tracing but the per-collection rekey walk, which I measured in
the tsc profile at 0.95% of the run (
scan_shape_table_rekey_mut5,971 samples,ShapeTableInner::facts_remove1,155,shape_keys_entry_is_minor_relevant660).Why this makes step 2.5 a GC fix
shapes.familiesis keyed by keys-array address, and there are 682,223 families to 682,146shared keys arrays — one family per array. The keys-array population is the distinct-shape
population. Canonical identity that collapsed ~2.26 M mints toward the ~12,846 distinct layouts
#10868 counts would collapse the family count with it, and therefore the arrays — which are
82.6% of everything every one of the 244 full collections traces.
So the two levers multiply, and the composition says which is which:
Note the direction of the surprise: private keys arrays are not the live population here.
682,146 of the 703,549 live arrays carry
GC_FLAG_SHAPE_SHARED; the private ones are minted anddie, leaving ~21,400 arrays (1.93 MiB) at any instant. It is the shared arrays that accumulate.
Scope and confidence
classification of its own marked set, not a sample.
not the same instant as the 244
OldReclaim-triggered collections, and it covers the whole heap(nursery included) while
old_reclaimableis old-gen only. The live totals therefore differ fromthe trigger telemetry; the composition ratio is the finding, and it is corroborated at two
very different moments (82.6% and 67.9%).
shape_keys_arrayscounts arrays flaggedGC_FLAG_SHAPE_SHARED. Private keys arrays are countedas ordinary arrays, so the true keys-array share is at least what is quoted.
Artefacts on perrymaster:
/root/matrix/census.jsonl(4 censuses),/root/fr/work/tscensus.ts.64 remaining items
fix(runtime): the dictionary latch STATE is per_test_global! (#10868 …
fix(runtime): scan and prune CLASS_KEYS_BY_ID -- the side table the c…
test(runtime): delete in_place_owned_append_leaves_one_descriptor_per…
fix(runtime): longlived ONLY for the shape cache entry -- the invaria…
test(runtime): a re-add after a tombstone publishes a successor shape (…
feat(runtime): canonical keys arrays -- one array per ordered key lis…
fix(runtime): canonical keys arrays must be LONGLIVED -- the shape ca…
feat(runtime): LiveObject -- a receiver that a collecting call has to…
feat(runtime): SharedLayout -- interning requires PROOF the receiver …
fix(runtime): the latch test helpers must RESTORE, not assume the def…
fix(runtime): the dictionary latch STATE is per_test_global! (#10868 …
fix(runtime): scan and prune CLASS_KEYS_BY_ID -- the side table the c…
test(runtime): delete in_place_owned_append_leaves_one_descriptor_per…
fix(runtime): longlived ONLY for the shape cache entry -- the invaria…
test(runtime): a re-add after a tombstone publishes a successor shape (…