Skip to content

perf: populated delete is ~14x node after tombstones — the residual is IC retirement per delete, not the delete itself #9064

Description

@proggeramlug

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 + RefCell borrow + two hash lookups (~35-40 ns) — call count × fixed cost, not list length.
  • Publish machinery per delete: remove_descriptor_id_from_facts_index 2.8%, shape_descriptor_by_id 2.8%, shape_descriptor_ensure_with_holes 1.7%, plus FastKeyHasher 2.8% and from_utf8 2.8% (SSO decode during candidate validation).
  • The rest: get_field_by_name tails, 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

  1. 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.
  2. 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).
  3. Verdict-lookup fixed cost (~20%): one-entry TLS memo (keys_id → *mut ShapeIndex) with Boxed ShapeIndex values 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).
  4. 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

  1. proggeramlug commented on Aug 30, 2026

    @proggeramlug
    ContributorAuthor

    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, and publish_object_shape_holes reads the key count from the ARRAY, not the lineage) and #9116. Read both diffs before touching delete_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 for method_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=1 on 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-static then rm -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 .text delta (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.ts on 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%.

  2. proggeramlug commented on Aug 30, 2026

    @proggeramlug
    ContributorAuthor

    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 (branch perf/method-probe-first, PR imminent) touches lower_call/method_override.rs but 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.

  3. proggeramlug commented on Aug 30, 2026

    @proggeramlug
    ContributorAuthor

    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.

  4. proggeramlug commented on Aug 30, 2026

    @proggeramlug
    ContributorAuthor

    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-invalidation on the fork (commit 005c01359, kill-switch PERRY_OBJECT_DICTIONARY_MODE, default off; not for merge). Design: a receiver that reaches its first tombstone squeeze flips to ShapeObjectKind::Dictionary (bit 29 of the ShapeId, classifiable from the value alone); afterwards deletes bump hole_count and 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_holes 3.6, shape_slot_lookup_verdict 3.5, hash_one<ShapeFacts> 3.2, shape_descriptor_by_id 3.0, remove_descriptor_id_from_facts_index 1.7, publish_object_shape_holes 1.1, RawVec growth 1.9, sip/RandomState hashing 1.6
    • generic computed-key get/set dispatch ≈ 25%: js_object_get_field_by_name 4.0, set_field_by_name_object_tail 2.5, js_put_value_set 2.5, get_field_by_name_object_tail 2.2, js_put_value_set_dyn_ic_miss 1.6, is_closure_ptr 1.5, ordinary_set_with_receiver 1.5, class_instance_set_may_intercept 1.4, get_accessor_descriptor 1.3, … — fresh "k"+i keys 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_utf8 3.2, root_string_ptr 2.0, keys_find_slot_by_bytes 1.5, keys_array_dense_slots 1.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_keys overwrite column 20 vs 24), while its delete-heavy column is 2964 vs 49. Delete churn is now the entire gap in this family.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions