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.