From 5c31d0ed77dbf103627d7360c37ac1b20d56ccb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 00:10:18 +0200 Subject: [PATCH] =?UTF-8?q?bench:=20dynamic=20property=20keys=20=E2=80=94?= =?UTF-8?q?=20the=20measurement=20that=20refutes=20dictionary=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phase-4 plan was a formal dictionary representation for objects that defeat shapes, to stop delete-heavy code minting shapes the table carries and rekeys forever. This benchmark runs two loops with identical property writes and reads, differing only in a delete, so the ratio isolates shape churn from raw access cost. Idle host, N=300_000: engine delete_heavy overwrite_only delete penalty node 36 ms 21 ms 1.7x perry 1487 ms 1321 ms 1.1x Perry's delete penalty is LOWER than node's. Delete-driven shape churn is not disproportionately expensive here, so a dictionary representation — a new object representation with its own storage, PIC handling, enumeration and GC integration — would be a large investment aimed at a tail perry does not have. Deliberately not implemented. The real gap is the second column: ~60x on plain dynamic overwrite. Profiling attributes it to IC misses and shape-table probes, not deletion: js_array_get_f64 (324), try_read_tracked_gc_header (307), shape_descriptor_by_id (105) + shape_descriptor_ensure_with_generation (103), js_put_value_set_dyn_ic_miss (72). The two shape probes are ~13% of main-thread samples — a hash lookup per property access on a densely-allocated ShapeId space that could be an array index. Left as a bounded follow-up rather than done here: ids come from a process-global counter while the tables are per-thread, so a dense Vec could be sparse under threads, and that needs measuring. --- benchmarks/bench_dynamic_property_keys.ts | 73 +++++++++++++++++++ .../8901-dynamic-property-benchmark.md | 45 ++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 benchmarks/bench_dynamic_property_keys.ts create mode 100644 changelog.d/8901-dynamic-property-benchmark.md diff --git a/benchmarks/bench_dynamic_property_keys.ts b/benchmarks/bench_dynamic_property_keys.ts new file mode 100644 index 0000000000..ee27ed626b --- /dev/null +++ b/benchmarks/bench_dynamic_property_keys.ts @@ -0,0 +1,73 @@ +// Benchmark: dynamic string-keyed property access, with and without `delete`. +// +// Two loops do the SAME number of property writes and reads; only the `delete` +// differs. Comparing them isolates shape churn from raw property-access cost, +// which is what makes this benchmark worth keeping: +// +// * `deleteHeavy / overwriteOnly` is the *delete penalty* — how much a +// delete-driven shape walk costs relative to a stable shape. +// * `overwriteOnly` on its own is *baseline dynamic property throughput*. +// +// Measured 2026-08-28 (idle host, perry 0.5.1519, N = 300_000): +// +// engine delete_heavy overwrite_only delete penalty +// node 36 ms 21 ms 1.7x +// perry 1487 ms 1321 ms 1.1x +// +// The delete penalty is the number the "objects that defeat shapes need a +// dictionary mode" argument rests on — and perry's is LOWER than node's. Adding +// a dictionary representation would therefore be a large investment aimed at a +// tail perry does not have. +// +// The second column is the real gap: ~60x on plain overwrite. Profiling this +// binary puts the time in `js_array_get_f64`, `try_read_tracked_gc_header`, +// `shape_descriptor_by_id` + `shape_descriptor_ensure_with_generation` (two +// hash lookups per access on the hot path), and `js_put_value_set_dyn_ic_miss` +// — i.e. inline-cache misses and shape-table probes, not deletion. +// +// Keep both columns when changing this file: the ratio is what refutes the +// dictionary-mode premise, and the absolute is what tracks the real gap. + +function deleteHeavy(n: number): number { + const o: Record = {}; + let s = 0; + for (let i = 0; i < n; i++) { + const k = "k" + (i % 500); + o[k] = i; + s += o[k]; + delete o[k]; // walks the object back to a previous key set + } + return s; +} + +function overwriteOnly(n: number): number { + const o: Record = {}; + let s = 0; + for (let i = 0; i < n; i++) { + const k = "k" + (i % 500); + o[k] = i; + s += o[k]; // same writes; the shape stabilises after 500 keys + } + return s; +} + +const N = 300000; + +let t = Date.now(); +const a = deleteHeavy(N); +const deleteMs = Date.now() - t; + +t = Date.now(); +const b = overwriteOnly(N); +const overwriteMs = Date.now() - t; + +console.log( + "delete_heavy_ms=" + + deleteMs + + " overwrite_ms=" + + overwriteMs + + " delete_penalty=" + + (deleteMs / Math.max(overwriteMs, 1)).toFixed(1) + + "x checksum=" + + ((a + b) % 7), +); diff --git a/changelog.d/8901-dynamic-property-benchmark.md b/changelog.d/8901-dynamic-property-benchmark.md new file mode 100644 index 0000000000..66326e6872 --- /dev/null +++ b/changelog.d/8901-dynamic-property-benchmark.md @@ -0,0 +1,45 @@ +Added `benchmarks/bench_dynamic_property_keys.ts`, and with it the measurement +that **refutes the premise for a dictionary mode**. + +The phase-4 plan was a formal dictionary representation for objects that defeat +shapes — heavy `delete` use, thousands of unique keys — to stop pathological +objects minting shapes the table must then carry and rekey forever. + +The benchmark runs two loops with the same number of property writes and reads, +differing only in a `delete`, so the ratio isolates shape churn from raw +property-access cost. Measured on an idle host, N = 300 000: + +| engine | delete-heavy | overwrite-only | delete penalty | +|---|---:|---:|---:| +| node | 36 ms | 21 ms | **1.7×** | +| perry | 1487 ms | 1321 ms | **1.1×** | + +**Perry's delete penalty is lower than node's.** Delete-driven shape churn is +not disproportionately expensive here, so a dictionary representation — a new +object representation, with its own property storage, PIC handling, enumeration +and GC integration — would be a large investment aimed at a tail perry does not +have. It is not implemented, and on this evidence should not be until a workload +shows the penalty that motivates it. + +The second column is the finding worth acting on: **~60× on plain dynamic +property overwrite**. Profiling that binary attributes it to inline-cache misses +and shape-table probes, not deletion: + +| samples | symbol | +|---:|---| +| 324 | `js_array_get_f64` | +| 307 | `value::addr_class::try_read_tracked_gc_header` | +| 105 | `shapes::shape_descriptor_by_id` | +| 103 | `shapes::shape_descriptor_ensure_with_generation` | +| 72 | `js_put_value_set_dyn_ic_miss` | + +The two shape-table probes are ~13% of main-thread samples on their own — a +hash lookup per property access, on a key space (`ShapeId`) that is allocated +densely by a single atomic counter and could be an array index instead. That is +a concrete, bounded follow-up; it is not done here because the ids are minted +from a process-global counter while the tables are per-thread, so a dense `Vec` +could be sparse on a multi-threaded program, and that trade needs measuring +rather than assuming. + +Both columns matter when changing this benchmark: the ratio is what refutes the +dictionary-mode premise, the absolute is what tracks the real gap.