Repository navigation
perf: populated delete is ~14x node after tombstones — the residual is IC retirement per delete, not the delete itself #9064
Description
Activity
Implementer handoff notes (two Codex sessions are picking this up — this comment carries the campaign context that isn't in the issue body):
State of main since filing: tombstone deletes are DEFAULT-ON (#9038) and two later fixes touched this exact machinery — #9110 (the squeeze must publish the shape BEFORE
set_object_live_slot_count, andpublish_object_shape_holesreads the key count from the ARRAY, not the lineage) and #9116. Read both diffs before touchingdelete_rest.rs/shapes_slot_list.rs; the ordering in #9110 is load-bearing and debug-assert-enforced.Coordination (important): a parallel Claude session ("secret-tests-88", same fork account proggeramlug) is actively landing prototype-LATCH work in the method-dispatch guard machinery (inline
(class_id, shape_id, prototype-latch)probes; PR imminent). Option 1 here (per-key IC validation / stable tokens across deletes) touches adjacent invalidation machinery — check open PRs formethod_override.rs/ latch-related changes and rebase on them rather than colliding. Announce which of the two issues each session takes; #9064 and #9065 share a root cause, so decide early whether one fix covers both (likely) or split option-1 (9064) vs the transition-cache/append work (9065).Measurement discipline (hard-won, violate at your peril):
PERRY_NO_CACHE=1on every A/B compile — the per-module object cache ignores env-var differences and you'll measure the first-compiled variant forever.- After ANY source edit rebuild all three:
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-staticthenrm -rf target/perry-auto-*, or app compiles fail "archive may be stale" at link. - Bench on a quiet host, min-of-N interleaved against a base binary built from the exact main SHA in the same script. One shape per binary, direct calls.
- Check IPC before celebrating instruction cuts: IPC ≥2.9 with ~0% branch misses means the work you removed was free.
- Binary size is a gate: report the
.textdelta (size(1)) on a real build; ~1% fine, big multiples not.
Correctness battery (all must stay byte-identical to node, BOTH tombstone flag states): the four differentials + two holed-JSON fixtures live on perrymaster at
/root/claude-arch(ts_tombstone_enum,ts_read_stub_adv,ts_sso_parity,ts_stale_slot,ts_hj2,ts_hj3, sources alongside); node reference outputs regenerate trivially. The enumeration differential is the specific trap for wrong-absence bugs (duplicate appended keys). Unit pins:tombstone_tests.rs(incl. the 60-cycle churn bound ≤2× live size — do not regress the memory bound for speed).Bench binaries:
bench_popdel.ts/bench_dynamic_property_keys.tson perrymaster; current numbers on the Mac mini scoreboard: popdel perry 332ms vs node 23; delete-heavy 981 vs 30. Target in the issue: ≤3× node, write-path benches (which currently BEAT node) within ±2%.Adjacent finding for whoever takes this lane's prototype-latch work: #9123 — direct method dispatch survives
delete C.prototype.method(node throws TypeError). The latches (PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED[_BY_METHOD]) flip on prototype overrides but not on deletes, so the guarded direct arm keeps calling the compiled body. Pre-existing on main; the probe-first method dispatch change (branchperf/method-probe-first, PR imminent) toucheslower_call/method_override.rsbut does not alter the latch machinery — if you change how invalidation is keyed, please rebase on that PR rather than collide, and #9123 is yours to fold in if it fits.- added a commit that references this issue
on Aug 30, 2026 Measurement hand-off for the implementers: an in-place "Dictionary-kind" receiver is a NEGATIVE result on this bench (2.2× slower) — and why.
Branch
perf/ic-per-key-invalidationon the fork (commit005c01359, kill-switchPERRY_OBJECT_DICTIONARY_MODE, default off; not for merge). Design: a receiver that reaches its first tombstone squeeze flips toShapeObjectKind::Dictionary(bit 29 of the ShapeId, classifiable from the value alone); afterwards deletes bumphole_countand appends update the descriptor in place under the same id — no mint, no retire, no reverse-index churn. ICs and the transition cache refuse the kind fail-closed. Keys/slots layout unchanged.Mac mini,
bench_populated_delete(same source as the issue), min of 3:state ms mode off (main behaviour) 322 mode on 723 node 25 Enumeration/re-add order/
in/JSON/for-in/prototype-fallthrough/freeze differential identical to node in both states; 60 shapes/object/tombstone unit tests pass.Why it loses: removing publish/mint/retire only buys the ~21% those symbols cost (same-host perf below), but a fail-closed Dictionary id means the receiver never gets an IC hit again — every get/set/delete runs the full miss path (
shape_slot_lookup_verdict+ hashing). So a dictionary representation is only viable together with its own per-key slot cache keyed on the stable id (the #8901 warning). That is the same machinery as option 1 here, which is why I am not pursuing it further in this lane.Same-host profile of main
35447e706(perrymaster,--debug-symbols,PERRY_NO_CACHE=1; perry 1104 ms vs node v26 75–83 ms = 14×), flat, top symbol 4%:- shape publish/mint/retire ≈ 21%:
shape_descriptor_ensure_with_holes3.6,shape_slot_lookup_verdict3.5,hash_one<ShapeFacts>3.2,shape_descriptor_by_id3.0,remove_descriptor_id_from_facts_index1.7,publish_object_shape_holes1.1, RawVec growth 1.9, sip/RandomState hashing 1.6 - generic computed-key get/set dispatch ≈ 25%:
js_object_get_field_by_name4.0,set_field_by_name_object_tail2.5,js_put_value_set2.5,get_field_by_name_object_tail2.2,js_put_value_set_dyn_ic_miss1.6,is_closure_ptr1.5,ordinary_set_with_receiver1.5,class_instance_set_may_intercept1.4,get_accessor_descriptor1.3, … — fresh"k"+ikeys are never interned, so both interned-key fast lanes (get_field_by_name.rs@182,put_value.rs@153) miss and every op walks the full receiver-kind probe chain before the keys lookup - key-string materialization ≈ 10%:
from_utf83.2,root_string_ptr2.0,keys_find_slot_by_bytes1.5,keys_array_dense_slots1.2
Also relevant to the issue body's framing: on current main the overwrite-only loop already beats node (popdel without the delete: perry 10–13 ms vs node 18–20;
bench_dynamic_property_keysoverwrite column 20 vs 24), while its delete-heavy column is 2964 vs 49. Delete churn is now the entire gap in this family.- shape publish/mint/retire ≈ 21%:
- added 4 commits that reference this issue
on Sep 17, 2026
Where we are
Tombstone deletes shipped default-on (#9029 → #9038):
bench_populated_deletewent 2030 → ~315 ms on the Linux box (6.5×) and ~2100 → 332 ms on the Mac mini. The remaining gap vs node is ~14× (node 21-24 ms), and it is structural, not constant-factor — this issue records where the time is and the ranked options, so the next campaign doesn't re-derive it.Self-contained repro
Cross-engine, Mac mini, min-of-7 interleaved, self-timed: node 23 · perry 332 · porffor 113 · scriptc 24. The scriptc number is the data point that matters: an AOT competitor does this at node parity, almost certainly via dictionary-style storage — parity is achievable in an AOT setting.
Where the ~315 ms goes (perf, symbolized, flag-on, Linux)
The profile is a flat tail — after the tombstone work no symbol is above ~20%:
shape_slot_lookup_verdict~20% total, split by caller (DWARF callgraph): 8.5% the delete's own slot-find (js_object_delete_field → keys_find_slot_by_key_ptr), 3.8% read-path misses, 3.5% write-path misses. Per call it's TLS +RefCellborrow + two hash lookups (~35-40 ns) — call count × fixed cost, not list length.remove_descriptor_id_from_facts_index2.8%,shape_descriptor_by_id2.8%,shape_descriptor_ensure_with_holes1.7%, plusFastKeyHasher2.8% andfrom_utf82.8% (SSO decode during candidate validation).get_field_by_nametails,js_put_value_set,js_object_delete_field, accessor probes — each 1-3%.The structural cause
Every delete mints a fresh shape token (this is forced: per-site dyn-IC ways live in generated-code globals the runtime cannot reach, so retiring a deleted key's cached
(token, key) → slotentries requires changing the token — see #9029's description). Consequence: after each delete, EVERY inline cache on that receiver misses once — the loop's read and write both take their miss paths every iteration. Node doesn't pay this: V8's dictionary-mode ICs validate against the dictionary, not a shape, so an unrelated delete invalidates nothing.Ranked options
TAG_HOLE, which never content-matches) or check a per-object delete epoch only on the deleted key's hash class. Either kills the miss-per-delete for the 499 untouched keys. Risk surface: every IC hit gains a compare; measure on the write/read benches that currently BEAT node before accepting.(keys_id → *mut ShapeIndex)withBoxedShapeIndexvalues for address stability, invalidated on any indices insert/remove. Saves the borrow + first hash lookup on the 3-4 lookups per op. Bounded win (~40-60 ms of 315).DELETE_TOMBSTONES_DESIGN.md§3/§7).Acceptance
bench_populated_delete≤ 3× node on the mini AND the Linux box, min-of-7 interleaved vs the exact main tip.