Repository navigation
perf: populated delete is ~14x node after tombstones — the residual is IC retirement per delete, not the delete itself #9064
Description
Where we are
Tombstone deletes shipped default-on (#9029 → #9038): bench_populated_delete went 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
// bench_populated_delete.ts — 500 keys stay resident, one key churns.
const o: Record<string, number> = {};
for (let i = 0; i < 500; i++) o["k" + i] = i;
let s = 0;
const N = 200000;
const t = Date.now();
for (let i = 0; i < N; i++) {
const k = "k" + (i % 500);
delete o[k];
o[k] = i;
s += o[k];
}
console.log("popdel_ms=" + (Date.now() - t) + " chk=" + (s % 7));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.- Publish machinery per delete:
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). - The rest:
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) → slot entries 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
- Per-key invalidation instead of whole-token retirement (the real fix, larger design). Keep the shape token STABLE across tombstone deletes; make IC hits validate the cached slot's key content (one load+compare — the slot still holds the key unless tombstoned, and a tombstoned slot holds
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. - Cheapen the per-delete publish: the mint+sweep+facts-index dance is ~8-10% combined. With (1) in place most of it disappears entirely (no successor id needed per delete — only the hole write + epoch bump).
- Verdict-lookup fixed cost (~20%): one-entry TLS memo
(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). - Full dictionary mode — superseded as first move by (1), but it's what scriptc's parity suggests; the tombstone walkers already built the hole-skipping groundwork (feat(runtime): O(1) object deletes via tombstones (flag-gated; populated delete 6.5x on, -11% off) #9029's audit,
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.- No regression (±2%) on the benches that now beat node: plain write loop (21 vs 33), combined overwrite (34 vs 38), dynamic-prop overwrite (13 vs 19).
- The feat(runtime): O(1) object deletes via tombstones (flag-gated; populated delete 6.5x on, -11% off) #9029 differential battery (enumeration / adversarial / SSO / stale-slot + the two holed-JSON-array fixtures) byte-identical to node — option (1) changes IC validation, which is exactly what the stale-slot differential exists to catch.
Activity
proggeramlug commentedon Aug 30, 2026
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.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..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%.proggeramlug commentedon Aug 30, 2026
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.proggeramlug commentedon Aug 30, 2026
Coordination note: I am taking the #9064 stable-delete-token/per-key IC-validation lane on
fix/9064-stable-delete-ic. I am not taking #9065 or #9123 unless the #9064 correctness fix necessarily spans them; I will avoid/rebase around the prototype-latch method-dispatch PR.runtime(shapes): Dictionary-kind receivers for churned objects (Perry…
proggeramlug commentedon 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: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_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.6js_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 lookupfrom_utf83.2,root_string_ptr2.0,keys_find_slot_by_bytes1.5,keys_array_dense_slots1.2Also 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.perf(codegen): give a constant-key `in` a presence inline cache
perf(runtime): make `delete` a shape transition, not a stable tombstone
fix(runtime): read_stub primes through the probe's guard; stable tomb…
fix(runtime): read_stub primes through the probe's guard; stable tomb…