From bfad335b011d72441e0823fa1c2a9bf498183375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 24 Jul 2026 04:57:25 +0200 Subject: [PATCH 1/3] perf: generalize object-write loop fast path --- benchmarks/object-write-6812/README.md | 58 ++ benchmarks/object-write-6812/baseline.md | 181 ++++++ benchmarks/object-write-6812/canonical.ts | 21 + benchmarks/object-write-6812/matrix.ts | 532 ++++++++++++++++++ benchmarks/object-write-6812/results.md | 145 +++++ benchmarks/object-write-6812/run_matrix.py | 186 ++++++ .../6812-object-write-loop-generalization.md | 1 + .../src/runtime_decls/objects.rs | 4 +- crates/perry-codegen/src/stmt/loops.rs | 217 ++++--- .../tests/native_proof_regressions.rs | 221 +++++--- crates/perry-runtime/src/gc/layout.rs | 19 + crates/perry-runtime/src/proxy.rs | 167 +++++- crates/perry-runtime/src/proxy/put_value.rs | 236 +++++--- ...p_6812_object_write_loop_generalization.ts | 300 ++++++++++ 14 files changed, 2058 insertions(+), 230 deletions(-) create mode 100644 benchmarks/object-write-6812/README.md create mode 100644 benchmarks/object-write-6812/baseline.md create mode 100644 benchmarks/object-write-6812/canonical.ts create mode 100644 benchmarks/object-write-6812/matrix.ts create mode 100644 benchmarks/object-write-6812/results.md create mode 100644 benchmarks/object-write-6812/run_matrix.py create mode 100644 changelog.d/6812-object-write-loop-generalization.md create mode 100644 test-files/test_gap_6812_object_write_loop_generalization.ts diff --git a/benchmarks/object-write-6812/README.md b/benchmarks/object-write-6812/README.md new file mode 100644 index 000000000..c39308594 --- /dev/null +++ b/benchmarks/object-write-6812/README.md @@ -0,0 +1,58 @@ +# Object-write coverage benchmark (#6812) + +This directory preserves the source and measurement corpus for the guarded +object-write generalization tracked by GitHub issue #6812. + +`canonical.ts` is intentionally byte-for-byte equivalent to the historical +#6759/#6809 acceptance source quoted in the issue. Do not scale or otherwise +change it; use a separate matrix cell when a longer timing window is needed. + +`baseline.md` contains the immutable pre-implementation matrix, classification, +profiles, and raw samples. `results.md` contains the bounded 1–4-field +implementation evidence, final samples, IR/code-size comparison, and validation +results. + +Build the compiler and static archives from a clean worktree with an isolated +target directory: + +```bash +PERRY_OBJECT_WRITE_TARGET=/absolute/path/to/an/isolated/cargo-target +CARGO_TARGET_DIR="$PERRY_OBJECT_WRITE_TARGET" \ + cargo build --release \ + -p perry -p perry-runtime-static -p perry-stdlib-static +``` + +Compile with the default auto-optimizing pipeline: + +```bash +PERRY_RUNTIME_DIR="$PERRY_OBJECT_WRITE_TARGET/release" \ + "$PERRY_OBJECT_WRITE_TARGET/release/perry" \ + benchmarks/object-write-6812/canonical.ts -o bin_write +``` + +Do not set `PERRY_NO_AUTO_OPTIMIZE`, override release codegen units, or reuse +archives from another worktree. Alternate Node and Perry runs on the same idle +host and report all raw samples plus the medians. + +`matrix.ts` contains the required coverage/rejection cells. Compile it once, +then use `run_matrix.py` to warm each implementation, verify the checksum, and +collect the raw in-program `Date.now()` samples in strict Node/Perry order: + +```bash +PERRY_RUNTIME_DIR="$PERRY_OBJECT_WRITE_TARGET/release" \ + "$PERRY_OBJECT_WRITE_TARGET/release/perry" \ + benchmarks/object-write-6812/matrix.ts -o bin_matrix + +python3 benchmarks/object-write-6812/run_matrix.py \ + --perry ./bin_matrix --samples 15 +``` + +The runner emits machine-readable JSON first, followed by a median summary and +a collapsible table containing every raw sample and checksum. Redirect or +capture the complete output when collecting evidence; a medians-only excerpt +is not sufficient to reproduce a result. + +For a rejected bounded clone, set +`PERRY_TRACE_OBJECT_ARRAY_WRITE_GUARD=1` when running the Perry executable to +print the cold preflight rejection reason. The trace is disabled by default and +is not reached on successful guards. diff --git a/benchmarks/object-write-6812/baseline.md b/benchmarks/object-write-6812/baseline.md new file mode 100644 index 000000000..e9fd78676 --- /dev/null +++ b/benchmarks/object-write-6812/baseline.md @@ -0,0 +1,181 @@ +# #6812 pre-implementation coverage baseline + +Collected on 2026-07-23 before changing any compiler or runtime source. + +## Provenance and protocol + +- Source: `origin/main` commit + `20f19758f370f7616530824218d87f46a67cd3f0`, which is the required #6811 + merge. +- Clean worktree branch: `perf/6812-object-write-generalization`. +- Isolated Cargo target: + `/private/tmp/perry-6812-target.qyHg3D`. +- Compiler: Perry 0.5.1264, built with the issue's exact release command and + the repository release/LTO profile. +- App compilation used the default auto-optimizing pipeline. Neither + `PERRY_NO_AUTO_OPTIMIZE` nor a codegen-unit override was set. +- Host: macOS 26.5 (25F71), Apple M1 Max, arm64, 10 logical CPUs. +- Node: v26.3.0. +- Rust: rustc 1.96.1, cargo 1.96.1. +- Each matrix cell had one Node/Perry warmup followed by 15 strict + Node-then-Perry pairs. The runner compared `(writes, checksum)` after every + pair and retained all samples without outlier filtering. +- No old Perry, Node, Cargo, rustc, or matrix process was active when the + definitive capture began. Sporadic OS/UI interruptions remain visible in + the raw data instead of being discarded. + +The exact historical canonical source produced: + +| Implementation | Raw `write_ms` samples | Median | Sink | +|---|---|---:|---:| +| Node | `[7, 8, 7, 7, 8, 7, 8, 8, 7, 7, 8, 8, 8, 8, 7]` | 8 ms | 9595200 | +| Perry | `[5, 6, 6, 5, 6, 6, 6, 5, 6, 6, 6, 6, 5, 6, 6]` | 6 ms | 9595200 | + +The one-millisecond shift from the published 7/5 medians is `Date.now()` +quantization at this duration; Perry still beats Node on the exact unchanged +source. + +## Executable matrix + +| Dimension | Cell | Writes | Node median | Perry median | +|---|---|---:|---:|---:| +| Key | `o.x` | 120000000 | 131 ms | 689 ms | +| Key | `o["x"]` | 120000000 | 132 ms | 693 ms | +| Key | stable `o[k]` | 120000000 | 138 ms | 2425 ms | +| Key | alternating dynamic keys | 24000000 | 183 ms | 625 ms | +| RHS | numeric scalar | 120000000 | 133 ms | 698 ms | +| RHS | pointer-capable value | 96000000 | 120 ms | 1480 ms | +| RHS | allocating literal | 19200000 | 119 ms | 11126 ms | +| RHS | function call | 108000000 | 121 ms | 3260 ms | +| Receiver shapes | monomorphic | 120000000 | 135 ms | 706 ms | +| Receiver shapes | 2-shape | 96000000 | 131 ms | 5432 ms | +| Receiver shapes | 4-shape | 60000000 | 108 ms | 3330 ms | +| Receiver shapes | transition before loop | 120000000 | 131 ms | 681 ms | +| Fields/iteration | 1 | 120000000 | 131 ms | 692 ms | +| Fields/iteration | 2 | 240000000 | 148 ms | 138 ms | +| Fields/iteration | 4 | 384000000 | 137 ms | 1444 ms | +| Fields/iteration | 8 | 384000000 | 102 ms | 1272 ms | +| Loop form | single counted loop | 120000000 | 163 ms | 2249 ms | +| Loop form | current nested loop | 240000000 | 149 ms | 138 ms | +| Loop form | stable local bounds | 192000000 | 129 ms | 868 ms | +| Loop form | non-zero inner start | 192000000 | 120 ms | 872 ms | +| Storage | inline existing slot | 120000000 | 132 ms | 693 ms | +| Storage | wide/overflow object | 120000000 | 180 ms | 7154 ms | +| Receiver kind | anonymous object | 120000000 | 109 ms | 692 ms | +| Receiver kind | class instance | 120000000 | 108 ms | 700 ms | +| Receiver kind | class-id-zero plain object | 120000000 | 110 ms | 6356 ms | + +Every one of the 375 measured Node/Perry pairs had identical write counts and +checksums. + +## Deterministic path classification + +The retained 856,485-byte pre-optimization LLVM module was classified by +function-local calls to `js_put_value_set`, `js_put_value_set_ic_miss`, and +`js_object_array_numeric_write2_guard`. The timed loop was also inspected +separately when its function contains setup writes. + +| Cell | Timed path | IR calls (`generic`, `PIC`, `clone`) | Reason / rejection | +|---|---|---:|---| +| `o.x` | static PIC | 0, 1, 0 | Literal key and call-free numeric RHS. | +| `o["x"]` | static PIC | 0, 1, 0 | Lowers to the same literal-key form as dot access. | +| stable `o[k]` | generic | 1, 0, 0 | Key is a local expression, not `Expr::String`. | +| alternating keys | generic | 1, 0, 0 | Dynamic PropertyKey conversion/identity stays semantic. | +| numeric RHS | static PIC | 0, 1, 0 | Safepoint-free scalar value. | +| pointer RHS | static PIC | 0, 1, 0 | Safepoint-free, but retains layout/string-alias/barrier handling. | +| allocating RHS | generic | 1, 0, 0 | RHS can allocate after the receiver reference is evaluated. | +| function RHS | generic | 1, 0, 0 | RHS call is a safepoint and may throw/observe user code. | +| monomorphic shape | static PIC | 0, 1, 0 | Stable discriminated ShapeId token primes and hits. | +| 2-shape | static PIC, runtime miss | 0, 1, 0 | One-entry cache thrashes on alternating shape tokens. | +| 4-shape | static PIC, runtime miss | 0, 1, 0 | One-entry cache thrashes on four shape tokens. | +| transition before loop | static PIC | 0, 2, 0 | Setup transition has its own site; timed site sees one stable post-transition token. | +| 1 field | static PIC | 0, 1, 0 | Whole-loop matcher requires exactly two stores. | +| 2 fields | whole-loop clone | 0, 2, 1 | Once-per-nest proof succeeds; both PIC sites remain in the semantic clone. | +| 4 fields | four static PICs | 0, 4, 0 | Whole-loop matcher requires exactly two stores. | +| 8 fields | eight static PICs | 0, 8, 0 | Whole-loop matcher requires exactly two stores. | +| single counted loop | static PIC | 0, 1, 0 | Whole-loop proof requires the dense nested-array form. | +| current nested loop | whole-loop clone | 0, 2, 1 | Exact #6811 shape. | +| stable local bounds | static PICs | 0, 2, 0 | Counted-loop matcher requires literal bounds. | +| nonzero inner start | static PICs | 0, 2, 0 | Dense-prefix proof deliberately starts at zero. | +| inline storage | static PIC | 0, 1, 0 | Existing slot is within the physical inline allocation. | +| overflow storage | static PIC, runtime miss | 1 setup, 1, 0 | Dynamic setup is generic; timed slot 4 cannot prime against a four-slot inline allocation. | +| anonymous object | static PIC | 0, 1, 0 | Compiler-created regular object has a nonzero class/shape identity. | +| class instance | static PIC | 0, 1, 0 | Eligible regular instance with stable class/shape identity. | +| class-id-zero object | static PIC, runtime miss | 0, 1, 0 | Parsed plain object fails the runtime `class_id != 0` eligibility guard. | + +The key measured discontinuity is the bounded static numeric nest: + +- two fields: 240M stores in 138 ms through the transactional clone; +- one field: 120M stores in 692 ms through a per-store PIC; +- four fields: 384M stores in 1444 ms through four per-store PICs. + +The four-field cell is the highest-volume sound miss in the matrix and is +10.5× slower than Node, while the established two-field proof is already +slightly faster than Node. + +## Sample evidence + +The stripped executable was linked once more with `PERRY_LINK_MAP` (which does +not alter generated code) and sampled with macOS `sample`. + +For stable dynamic-key writes, 1,204 of 1,466 main-thread samples (82.1%) were +under `js_put_value_set`. Of those, 519 reached +`try_existing_own_data_overwrite`, and 108 were under +`runtime_store_jsvalue_slot`, with further frames in layout-note and write +barrier work. The addresses were resolved against these linker-map ranges: + +```text +0x100489E00 + 0xBBC js_put_value_set +0x100298750 + 0x2D0 try_existing_own_data_overwrite +0x10014AA44 + 0x0AC runtime_store_jsvalue_slot +``` + +For the four-field static cell, 441 of 448 post-startup runnable samples +(98.4%) were directly in the inlined `main` body, with no child runtime frame. +Together with the four PIC call sites retained in IR, this shows the cost is +the repeated inlined per-store guard sequence, not a generic-runtime call. +The two-field executable result demonstrates the benefit of paying a bounded +preflight once and then keeping the complete nest call/GC-free. + +## Baseline code size + +| Artifact | Size | +|---|---:| +| Matrix LLVM IR | 856,485 bytes / 21,966 lines | +| Matrix app object | 278,968 bytes | +| Matrix executable | 6,302,720 bytes | +| Linked `main` text contribution | 47,232 bytes | +| Canonical executable | 6,170,504 bytes | + +
+All raw matrix samples and checksums + +| Cell | Checksum | Node ms | Perry ms | +|---|---:|---|---| +| `key_dot` | `122876400` | `[132, 131, 130, 132, 132, 131, 130, 131, 133, 132, 131, 131, 130, 132, 131]` | `[690, 688, 690, 691, 688, 689, 688, 711, 701, 688, 690, 688, 689, 689, 695]` | +| `key_literal` | `122876400` | `[131, 134, 132, 133, 132, 134, 132, 131, 131, 132, 132, 132, 133, 131, 131]` | `[692, 691, 693, 693, 701, 690, 689, 695, 691, 692, 693, 694, 693, 701, 691]` | +| `key_stable_dynamic` | `122876400` | `[138, 138, 140, 139, 136, 136, 135, 148, 140, 141, 138, 136, 139, 137, 140]` | `[2425, 2448, 2431, 2384, 2395, 2383, 2653, 2549, 2442, 2456, 2423, 2419, 2399, 2408, 2435]` | +| `key_alternating_dynamic` | `31194000` | `[183, 266, 184, 179, 194, 185, 183, 312, 182, 181, 178, 183, 181, 182, 180]` | `[637, 796, 840, 846, 623, 625, 790, 667, 610, 613, 605, 644, 614, 607, 610]` | +| `rhs_numeric` | `122876400` | `[133, 139, 132, 134, 136, 133, 132, 132, 133, 133, 132, 132, 133, 134, 133]` | `[699, 711, 697, 702, 698, 697, 700, 696, 706, 697, 684, 683, 695, 701, 712]` | +| `rhs_pointer` | `40800` | `[121, 120, 119, 120, 120, 120, 119, 120, 119, 121, 121, 122, 122, 119, 120]` | `[1487, 1488, 1480, 1480, 1477, 1481, 1478, 1482, 1480, 1478, 1515, 1482, 1481, 1479, 1479]` | +| `rhs_allocating` | `22076400` | `[119, 114, 153, 143, 134, 120, 117, 113, 117, 113, 112, 113, 142, 195, 119]` | `[10936, 12509, 16052, 13348, 11307, 11055, 10870, 10729, 10714, 10823, 10733, 11126, 15021, 13009, 11158]` | +| `rhs_call` | `110876400` | `[123, 191, 124, 119, 126, 119, 118, 121, 129, 120, 129, 119, 145, 120, 121]` | `[3309, 3418, 3311, 3214, 3217, 3260, 3256, 3264, 3300, 3243, 3250, 3589, 3864, 3235, 3218]` | +| `shape_monomorphic` | `122876400` | `[135, 135, 135, 133, 136, 134, 135, 137, 133, 136, 133, 135, 135, 135, 135]` | `[709, 708, 705, 706, 721, 707, 713, 700, 697, 691, 705, 709, 695, 712, 689]` | +| `shape_two` | `98876400` | `[130, 131, 150, 131, 130, 134, 137, 131, 130, 132, 134, 132, 130, 132, 130]` | `[5330, 5963, 5401, 5432, 7717, 5527, 5456, 5429, 5440, 5433, 5432, 5368, 5320, 5352, 5326]` | +| `shape_four` | `62876400` | `[107, 106, 108, 109, 107, 108, 109, 106, 109, 110, 114, 109, 106, 108, 107]` | `[3330, 3332, 3323, 3327, 3395, 3337, 3327, 3341, 3392, 3373, 3387, 3317, 3284, 3296, 3288]` | +| `shape_transition_before_loop` | `122876400` | `[130, 131, 131, 131, 131, 131, 133, 131, 130, 133, 131, 132, 132, 132, 132]` | `[679, 680, 691, 680, 682, 680, 681, 682, 680, 681, 682, 682, 681, 681, 682]` | +| `fields_one` | `122876400` | `[131, 132, 130, 131, 132, 131, 131, 131, 131, 132, 132, 131, 130, 131, 131]` | `[691, 695, 693, 692, 690, 689, 692, 689, 689, 693, 713, 691, 692, 693, 690]` | +| `fields_two` | `239995200` | `[149, 148, 150, 148, 147, 148, 149, 150, 148, 148, 148, 148, 147, 149, 149]` | `[138, 138, 139, 139, 137, 138, 137, 138, 137, 138, 138, 138, 138, 137, 140]` | +| `fields_four` | `383990400` | `[136, 136, 137, 136, 137, 137, 136, 137, 137, 137, 137, 137, 137, 141, 138]` | `[1432, 1446, 1447, 1444, 1441, 1443, 1446, 1445, 1444, 1446, 1443, 1442, 1446, 1455, 1441]` | +| `fields_eight` | `383980800` | `[102, 102, 101, 100, 101, 102, 104, 102, 102, 112, 101, 103, 102, 114, 102]` | `[1260, 1262, 1259, 1260, 1273, 1272, 1270, 1661, 1782, 1276, 1591, 1276, 1272, 1266, 1264]` | +| `loop_single_counted` | `287997118800` | `[164, 163, 164, 163, 163, 163, 163, 164, 164, 164, 164, 162, 163, 162, 163]` | `[2286, 2258, 2247, 2250, 2251, 2254, 2248, 2246, 2248, 2247, 2247, 2249, 2249, 2248, 2253]` | +| `loop_current_nested` | `239995200` | `[150, 149, 148, 150, 149, 150, 148, 151, 148, 148, 148, 151, 149, 150, 149]` | `[138, 139, 138, 138, 138, 139, 138, 138, 138, 137, 140, 138, 137, 138, 138]` | +| `loop_stable_local_bounds` | `191995200` | `[130, 129, 129, 129, 129, 128, 129, 130, 130, 130, 132, 130, 130, 129, 128]` | `[882, 865, 868, 873, 869, 916, 864, 868, 867, 868, 869, 882, 887, 866, 864]` | +| `loop_nonzero_start` | `191995200` | `[119, 122, 119, 120, 119, 121, 120, 120, 119, 120, 121, 120, 120, 121, 119]` | `[872, 876, 872, 874, 887, 871, 872, 871, 871, 871, 872, 887, 873, 872, 886]` | +| `storage_inline` | `122876400` | `[132, 131, 131, 132, 132, 133, 132, 142, 136, 133, 132, 134, 132, 131, 132]` | `[692, 693, 693, 694, 692, 693, 695, 694, 693, 686, 684, 682, 695, 692, 692]` | +| `storage_overflow` | `122876400` | `[180, 180, 178, 179, 178, 180, 190, 179, 180, 180, 179, 179, 181, 191, 179]` | `[7169, 7120, 7171, 7149, 7167, 7142, 7154, 7121, 7130, 7139, 7165, 7135, 7174, 7432, 7163]` | +| `receiver_anonymous` | `122876400` | `[110, 110, 108, 109, 109, 110, 108, 109, 109, 108, 110, 108, 109, 109, 110]` | `[692, 693, 693, 692, 692, 692, 693, 693, 692, 693, 691, 694, 692, 692, 698]` | +| `receiver_class_instance` | `122876400` | `[105, 108, 109, 107, 107, 108, 109, 144, 106, 114, 110, 108, 107, 113, 108]` | `[697, 695, 707, 705, 695, 687, 700, 950, 897, 896, 700, 700, 956, 691, 706]` | +| `receiver_class_id_zero` | `122876400` | `[110, 111, 106, 105, 106, 107, 120, 111, 107, 107, 1125, 134, 119, 106, 137]` | `[6381, 6672, 6203, 6112, 6106, 6596, 6356, 6217, 6198, 42809, 20211, 7303, 6197, 6235, 7880]` | + +
diff --git a/benchmarks/object-write-6812/canonical.ts b/benchmarks/object-write-6812/canonical.ts new file mode 100644 index 000000000..24abf9138 --- /dev/null +++ b/benchmarks/object-write-6812/canonical.ts @@ -0,0 +1,21 @@ +// #6759/#6809 acceptance: property writes to existing fields. +const objs: any[] = []; +for (let i = 0; i < 2400; i++) { + objs.push({ a: i, b: i * 2, c: 0, d: 0 }); +} + +const t0 = Date.now(); +for (let r = 0; r < 2000; r++) { + for (let i = 0; i < 2400; i++) { + const o = objs[i]; + o.c = r + i; + o.d = r - i; + } +} +const t1 = Date.now(); + +let sink = 0; +for (let i = 0; i < 2400; i++) { + sink += objs[i].c + objs[i].d; +} +console.log("write_ms", t1 - t0, "sink", sink); diff --git a/benchmarks/object-write-6812/matrix.ts b/benchmarks/object-write-6812/matrix.ts new file mode 100644 index 000000000..3ca442dbf --- /dev/null +++ b/benchmarks/object-write-6812/matrix.ts @@ -0,0 +1,532 @@ +// Scaled object-write coverage/rejection matrix for #6812. +// +// Every timed cell is deliberately at least about 100 ms on the development +// host. Setup and checksum work stay outside the timed region. Run one cell at +// a time: +// +// node --experimental-strip-types matrix.ts key_dot +// ./matrix key_dot + +type CellResult = { + elapsed: number; + writes: number; + sink: number; +}; + +function checksum(objects: any[], keys: string[]): number { + let sink = 0; + for (let i = 0; i < objects.length; i++) { + const object: any = objects[i]; + for (let k = 0; k < keys.length; k++) { + sink += object[keys[k]]; + } + } + return sink; +} + +function keyDot(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function keyLiteral(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object["x"] = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function keyStableDynamic(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const key = "x"; + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object[key] = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function keyAlternatingDynamic(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 10000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + const key = (i & 1) === 0 ? "x" : "y"; + object[key] = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 24000000, sink: checksum(objects, ["x", "y"]) }; +} + +function rhsNumeric(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function rhsPointer(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const pointer: any = { value: 17 }; + const t0 = Date.now(); + for (let r = 0; r < 40000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = pointer; + } + } + const elapsed = Date.now() - t0; + let sink = 0; + for (let i = 0; i < objects.length; i++) { + sink += objects[i].x.value; + } + return { elapsed, writes: 96000000, sink }; +} + +function rhsAllocating(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 8000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = { value: r + i }; + } + } + const elapsed = Date.now() - t0; + let sink = 0; + for (let i = 0; i < objects.length; i++) { + sink += objects[i].x.value; + } + return { elapsed, writes: 19200000, sink }; +} + +function rhsCall(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const candidates: any[] = [(left: number, right: number) => left + right]; + const produce: any = candidates[0]; + const t0 = Date.now(); + for (let r = 0; r < 45000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = produce(r, i); + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 108000000, sink: checksum(objects, ["x"]) }; +} + +function shapeMonomorphic(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function shapeTwo(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push( + (i & 1) === 0 ? { x: i, a: 0 } : { x: i, b: 0 }, + ); + } + const t0 = Date.now(); + for (let r = 0; r < 40000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 96000000, sink: checksum(objects, ["x"]) }; +} + +function shapeFour(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + const kind = i & 3; + if (kind === 0) objects.push({ x: i, a: 0 }); + else if (kind === 1) objects.push({ x: i, b: 0 }); + else if (kind === 2) objects.push({ x: i, c: 0 }); + else objects.push({ x: i, d: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 25000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 60000000, sink: checksum(objects, ["x"]) }; +} + +function shapeTransitionBeforeLoop(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + for (let i = 0; i < 2400; i++) { + objects[i].added = i; + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function fieldsOne(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function fieldsTwo(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.z = r + i; + object.w = r - i; + } + } + const elapsed = Date.now() - t0; + return { + elapsed, + writes: 240000000, + sink: checksum(objects, ["z", "w"]), + }; +} + +function fieldsFour(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 40000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + object.y = r - i; + object.z = r + i + 1; + object.w = r - i - 1; + } + } + const elapsed = Date.now() - t0; + return { + elapsed, + writes: 384000000, + sink: checksum(objects, ["x", "y", "z", "w"]), + }; +} + +function fieldsEight(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ a: 0, b: 0, c: 0, d: 0, e: 0, f: 0, g: 0, h: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 20000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.a = r + i; + object.b = r - i; + object.c = r + i + 1; + object.d = r - i - 1; + object.e = r + i + 2; + object.f = r - i - 2; + object.g = r + i + 3; + object.h = r - i - 3; + } + } + const elapsed = Date.now() - t0; + return { + elapsed, + writes: 384000000, + sink: checksum(objects, ["a", "b", "c", "d", "e", "f", "g", "h"]), + }; +} + +function loopSingleCounted(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let n = 0; n < 120000000; n++) { + const object: any = objects[n % 2400]; + object.x = n; + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function loopCurrentNested(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.z = r + i; + object.w = r - i; + } + } + const elapsed = Date.now() - t0; + return { + elapsed, + writes: 240000000, + sink: checksum(objects, ["z", "w"]), + }; +} + +function loopStableLocalBounds(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const rounds = 40000; + const length = 2400; + const t0 = Date.now(); + for (let r = 0; r < rounds; r++) { + for (let i = 0; i < length; i++) { + const object: any = objects[i]; + object.z = r + i; + object.w = r - i; + } + } + const elapsed = Date.now() - t0; + return { + elapsed, + writes: 192000000, + sink: checksum(objects, ["z", "w"]), + }; +} + +function loopNonzeroStart(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2405; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 40000; r++) { + for (let i = 5; i < 2405; i++) { + const object: any = objects[i]; + object.z = r + i; + object.w = r - i; + } + } + const elapsed = Date.now() - t0; + return { + elapsed, + writes: 192000000, + sink: checksum(objects, ["z", "w"]), + }; +} + +function storageInline(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i, y: i * 2, z: 0, w: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function storageOverflow(): CellResult { + const objects: any[] = []; + const overflowKey = "x"; + for (let i = 0; i < 2400; i++) { + const object: any = { a0: 0, a1: 0, a2: 0, a3: 0 }; + object[overflowKey] = i; + objects.push(object); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function receiverAnonymous(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push({ x: i }); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function receiverClassInstance(): CellResult { + class Cell { + x = 0; + } + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push(new Cell()); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +function receiverClassIdZero(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + objects.push(JSON.parse('{"x":0}')); + } + const t0 = Date.now(); + for (let r = 0; r < 50000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 120000000, sink: checksum(objects, ["x"]) }; +} + +const name = process.argv[2] || "key_dot"; +let result: CellResult; +if (name === "key_dot") result = keyDot(); +else if (name === "key_literal") result = keyLiteral(); +else if (name === "key_stable_dynamic") result = keyStableDynamic(); +else if (name === "key_alternating_dynamic") result = keyAlternatingDynamic(); +else if (name === "rhs_numeric") result = rhsNumeric(); +else if (name === "rhs_pointer") result = rhsPointer(); +else if (name === "rhs_allocating") result = rhsAllocating(); +else if (name === "rhs_call") result = rhsCall(); +else if (name === "shape_monomorphic") result = shapeMonomorphic(); +else if (name === "shape_two") result = shapeTwo(); +else if (name === "shape_four") result = shapeFour(); +else if (name === "shape_transition_before_loop") { + result = shapeTransitionBeforeLoop(); +} else if (name === "fields_one") result = fieldsOne(); +else if (name === "fields_two") result = fieldsTwo(); +else if (name === "fields_four") result = fieldsFour(); +else if (name === "fields_eight") result = fieldsEight(); +else if (name === "loop_single_counted") result = loopSingleCounted(); +else if (name === "loop_current_nested") result = loopCurrentNested(); +else if (name === "loop_stable_local_bounds") result = loopStableLocalBounds(); +else if (name === "loop_nonzero_start") result = loopNonzeroStart(); +else if (name === "storage_inline") result = storageInline(); +else if (name === "storage_overflow") result = storageOverflow(); +else if (name === "receiver_anonymous") result = receiverAnonymous(); +else if (name === "receiver_class_instance") result = receiverClassInstance(); +else if (name === "receiver_class_id_zero") result = receiverClassIdZero(); +else throw new Error(`unknown matrix cell: ${name}`); + +console.log( + "cell", + name, + "ms", + result.elapsed, + "writes", + result.writes, + "sink", + result.sink, +); diff --git a/benchmarks/object-write-6812/results.md b/benchmarks/object-write-6812/results.md new file mode 100644 index 000000000..96de0b57c --- /dev/null +++ b/benchmarks/object-write-6812/results.md @@ -0,0 +1,145 @@ +# #6812 bounded object-write generalization results + +Collected on 2026-07-23/24 from the same clean worktree, host, isolated Cargo +target, release/LTO compiler, and default auto-optimized runtime described in +[`baseline.md`](baseline.md). All post-change application objects were +regenerated with `PERRY_NO_CACHE=1`; application execution used the normal +production configuration. + +## Implemented slice + +The #6811 whole-nest proof is generalized from exactly two writes to a bounded +descriptor of one through four static fields: + +- the compiler preserves property/source order (including duplicates), proves + every arithmetic intermediate finite over the complete loop domain, performs + one preflight, and emits a finite call/GC/side-exit-free fast clone; +- the runtime validates the dense receiver prefix, shared shape, object state, + physical slot bounds, and intact typed descriptors before publishing four + packed 16-bit slot lanes; +- finite numeric bits are admitted in verified raw-f64 or ordinary JSValue + typed slots; +- the once-only lookup compares live string-pool keys by content and does not + accidentally depend on whether another site interned the same name; +- the previous two-field exported ABI remains available for cached generated + objects; +- a failed proof enters the original semantic clone before any store. Dynamic + keys, safepointing/pointer RHS expressions, mixed shapes, holes, short + arrays, descriptors/mutability flags, overflow slots, class-id-zero objects, + nonzero/variable bounds, and five or more writes remain bounded fallbacks. + +Set `PERRY_TRACE_OBJECT_ARRAY_WRITE_GUARD=1` to print a cold rejection reason. +The cold logger is reached only after a proof branch has already failed; a +successful production guard performs no environment lookup or logging. + +## Required 15-pair measurements + +One warmup was followed by 15 strict Node-then-Perry pairs. Every pair matched +the write count and checksum. + +| Fields/iteration | Baseline Perry | Final Node | Final Perry | Change | +|---:|---:|---:|---:|---:| +| 1 | 692 ms | 133 ms | **122 ms** | **5.67x faster** | +| 2 | 138 ms | 150 ms | **139 ms** | +0.7% (noise) | +| 4 | 1,444 ms | 142 ms | **127 ms** | **11.37x faster** | +| 8 (bounded rejection) | 1,272 ms | 102 ms | 1,271 ms | -0.1% (unchanged) | + +Raw alternating samples: + +| Cell | Checksum | Node ms | Perry ms | +|---|---:|---|---| +| `fields_one` | `122876400` | `[130, 133, 133, 130, 131, 131, 136, 137, 135, 135, 133, 138, 135, 133, 138]` | `[121, 121, 120, 121, 124, 124, 123, 121, 122, 121, 123, 126, 123, 122, 119]` | +| `fields_two` | `239995200` | `[170, 172, 148, 150, 148, 149, 149, 152, 149, 151, 147, 148, 158, 150, 154]` | `[155, 149, 139, 137, 138, 139, 139, 138, 137, 137, 140, 144, 139, 138, 144]` | +| `fields_four` | `383990400` | `[140, 143, 145, 141, 145, 142, 138, 142, 140, 136, 142, 158, 151, 136, 136]` | `[131, 132, 125, 126, 130, 124, 127, 126, 127, 130, 131, 140, 137, 123, 123]` | +| `fields_eight` | `383980800` | `[100, 100, 100, 106, 104, 101, 103, 100, 102, 101, 104, 107, 107, 102, 102]` | `[1260, 1265, 1268, 1273, 1271, 1272, 1274, 1270, 1269, 1278, 1267, 1274, 1271, 1273, 1279]` | + +The exact unchanged canonical source also used 15 alternating pairs: + +| Implementation | Raw `write_ms` samples | Median | Sink | +|---|---|---:|---:| +| Node | `[8, 7, 8, 8, 8, 8, 9, 9, 7, 8, 7, 7, 7, 8, 8]` | 8 ms | 9595200 | +| Perry | `[6, 5, 6, 6, 6, 6, 5, 7, 6, 6, 5, 6, 5, 6, 6]` | **6 ms** | 9595200 | + +The established two-field and canonical paths therefore show no material +regression. The newly supported one- and four-field cells are faster than Node +on the same protocol. + +## Full executable parity sweep + +The final 25-cell matrix was also executed once in strict Node/Perry order +after the definitive release build. All 25 write-count/checksum pairs matched. +The intended boundaries remained visible: dynamic/safepointing, polymorphic, +wide-storage, variable-bound, eight-field, and class-id-zero cells stayed on +their previous paths, while eligible static call-free nested cells reached +Node parity. + +| Cell | Node | Perry | +|---|---:|---:| +| `key_dot` | 136 ms | 120 ms | +| `key_literal` | 133 ms | 123 ms | +| `key_stable_dynamic` | 139 ms | 2,403 ms | +| `key_alternating_dynamic` | 181 ms | 612 ms | +| `rhs_numeric` | 135 ms | 124 ms | +| `rhs_pointer` | 118 ms | 1,488 ms | +| `rhs_allocating` | 152 ms | 11,196 ms | +| `rhs_call` | 117 ms | 3,246 ms | +| `shape_monomorphic` | 136 ms | 123 ms | +| `shape_two` | 129 ms | 5,350 ms | +| `shape_four` | 113 ms | 3,407 ms | +| `shape_transition_before_loop` | 136 ms | 124 ms | +| `fields_one` | 137 ms | 126 ms | +| `fields_two` | 155 ms | 142 ms | +| `fields_four` | 146 ms | 131 ms | +| `fields_eight` | 104 ms | 1,306 ms | +| `loop_single_counted` | 173 ms | 2,334 ms | +| `loop_current_nested` | 154 ms | 146 ms | +| `loop_stable_local_bounds` | 135 ms | 910 ms | +| `loop_nonzero_start` | 126 ms | 942 ms | +| `storage_inline` | 137 ms | 126 ms | +| `storage_overflow` | 181 ms | 7,315 ms | +| `receiver_anonymous` | 113 ms | 122 ms | +| `receiver_class_instance` | 105 ms | 123 ms | +| `receiver_class_id_zero` | 112 ms | 6,740 ms | + +This one-pair sweep is parity/rejection evidence, not a replacement for the +15-pair performance measurements above. + +## Generated IR and code size + +Function-local inspection of the definitive uncached LLVM module found: + +- `fieldsOne`: one generalized guard, one fallback PIC; +- `fieldsTwo`: one generalized guard, two fallback PICs; +- `fieldsFour`: one generalized guard, four fallback PICs; +- `fieldsEight`: no generalized guard, eight fallback PICs; +- 16 generated fast inner blocks across the matrix and zero calls in them; +- zero calls to the old two-field guard from new code. + +| Artifact | Baseline | Final | Growth | +|---|---:|---:|---:| +| Matrix LLVM IR | 856,485 B / 21,966 lines | 892,819 B / 22,764 lines | +4.24% / +3.63% | +| Matrix app object | 278,968 B | 284,800 B | +2.09% | +| Linked `main` text | 47,232 B | 49,012 B | +3.77% | +| Matrix executable | 6,302,720 B | 6,302,720 B | 0% | +| Canonical executable | 6,170,504 B | 6,170,512 B | +8 B | + +## Validation + +- `cargo fmt --all -- --check` +- `git diff --check` +- library-target Clippy completed for both changed crates; a stricter + all-target run reaches pre-existing denied test lints in untouched files +- GC store inventory self-test and full inventory: 1,161 files, 245 audited + sites, 65 allowlisted +- codegen native proof regressions: 248 passed +- shadow-slot hygiene: 10 passed +- runtime library tests: 1,460 passed, 3 ignored, 0 failed +- release adversarial corpus: exact Node parity in default, + `PERRY_DISABLE_CLASS_FIELD_INLINE=1`, `PERRY_TYPED_FEEDBACK=1`, and + `PERRY_VERIFY_TYPED_INTACT=1` modes +- full gap driver: 387 parity passes, 0 compile failures, 0 crashes; the seven + reported mismatches were all recognized by the driver as already + known/triaged, and it finished with `Gap gate OK` +- the new `test_gap_6812_object_write_loop_generalization` passed in that full + release-driver run; its final expanded inherited-setter/Proxy corpus also + passed a focused release-driver rerun diff --git a/benchmarks/object-write-6812/run_matrix.py b/benchmarks/object-write-6812/run_matrix.py new file mode 100644 index 000000000..24237498f --- /dev/null +++ b/benchmarks/object-write-6812/run_matrix.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Run the #6812 object-write matrix with alternating Node/Perry samples.""" + +from __future__ import annotations + +import argparse +import json +import re +import statistics +import subprocess +import sys +from pathlib import Path + + +CASES = [ + ("Key", "o.x", "key_dot"), + ("Key", 'o["x"]', "key_literal"), + ("Key", "stable o[k]", "key_stable_dynamic"), + ("Key", "alternating dynamic keys", "key_alternating_dynamic"), + ("RHS", "numeric scalar", "rhs_numeric"), + ("RHS", "pointer-capable value", "rhs_pointer"), + ("RHS", "allocating literal", "rhs_allocating"), + ("RHS", "function call", "rhs_call"), + ("Receiver shapes", "monomorphic", "shape_monomorphic"), + ("Receiver shapes", "2-shape", "shape_two"), + ("Receiver shapes", "4-shape", "shape_four"), + ("Receiver shapes", "transition before loop", "shape_transition_before_loop"), + ("Fields/iteration", "1", "fields_one"), + ("Fields/iteration", "2", "fields_two"), + ("Fields/iteration", "4", "fields_four"), + ("Fields/iteration", "8", "fields_eight"), + ("Loop form", "single counted loop", "loop_single_counted"), + ("Loop form", "current nested loop", "loop_current_nested"), + ("Loop form", "stable local bounds", "loop_stable_local_bounds"), + ("Loop form", "non-zero inner start", "loop_nonzero_start"), + ("Storage", "inline existing slot", "storage_inline"), + ("Storage", "wide/overflow object", "storage_overflow"), + ("Receiver kind", "anonymous object", "receiver_anonymous"), + ("Receiver kind", "class instance", "receiver_class_instance"), + ("Receiver kind", "class-id-zero plain object", "receiver_class_id_zero"), +] + +RESULT_RE = re.compile( + r"cell (?P\S+) ms (?P\d+) writes (?P\d+) " + r"sink (?P-?(?:\d+(?:\.\d+)?|NaN|Infinity))" +) + + +def run_once(command: list[str]) -> dict[str, int | float | str]: + completed = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ) + match = RESULT_RE.search(completed.stdout) + if match is None: + raise RuntimeError( + f"missing matrix result in {command!r}\n" + f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + result: dict[str, int | float | str] = { + "case": match.group("case"), + "ms": int(match.group("ms")), + "writes": int(match.group("writes")), + "sink": match.group("sink"), + } + return result + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--perry", type=Path, required=True) + parser.add_argument("--node", default="node") + parser.add_argument("--samples", type=int, default=15) + parser.add_argument("--warmups", type=int, default=1) + parser.add_argument( + "--cases", + help="comma-separated matrix case ids (default: all)", + ) + args = parser.parse_args() + + if args.samples < 1 or args.warmups < 0: + parser.error("--samples must be positive and --warmups non-negative") + if not args.perry.is_file(): + parser.error(f"Perry executable does not exist: {args.perry}") + + source = Path(__file__).with_name("matrix.ts").resolve() + selected = None if args.cases is None else set(args.cases.split(",")) + cases = [case for case in CASES if selected is None or case[2] in selected] + unknown = set() if selected is None else selected - {case[2] for case in cases} + if unknown: + parser.error(f"unknown case ids: {', '.join(sorted(unknown))}") + + report = [] + for dimension, cell, case_id in cases: + node_command = [ + args.node, + "--experimental-strip-types", + str(source), + case_id, + ] + perry_command = [str(args.perry.resolve()), case_id] + + for _ in range(args.warmups): + node_warm = run_once(node_command) + perry_warm = run_once(perry_command) + if (node_warm["writes"], node_warm["sink"]) != ( + perry_warm["writes"], + perry_warm["sink"], + ): + raise RuntimeError( + f"warmup output mismatch for {case_id}: " + f"Node={node_warm}, Perry={perry_warm}" + ) + + node_samples: list[int] = [] + perry_samples: list[int] = [] + expected_output = None + for _ in range(args.samples): + node_result = run_once(node_command) + perry_result = run_once(perry_command) + node_output = (node_result["writes"], node_result["sink"]) + perry_output = (perry_result["writes"], perry_result["sink"]) + if node_output != perry_output: + raise RuntimeError( + f"output mismatch for {case_id}: " + f"Node={node_result}, Perry={perry_result}" + ) + if expected_output is not None and expected_output != node_output: + raise RuntimeError( + f"non-deterministic checksum for {case_id}: " + f"expected={expected_output}, observed={node_output}" + ) + expected_output = node_output + node_samples.append(int(node_result["ms"])) + perry_samples.append(int(perry_result["ms"])) + + report.append( + { + "dimension": dimension, + "cell": cell, + "id": case_id, + "writes": expected_output[0], + "sink": expected_output[1], + "node_ms": node_samples, + "node_median_ms": statistics.median(node_samples), + "perry_ms": perry_samples, + "perry_median_ms": statistics.median(perry_samples), + } + ) + print( + f"[{case_id}] Node {statistics.median(node_samples)} ms, " + f"Perry {statistics.median(perry_samples)} ms", + file=sys.stderr, + flush=True, + ) + + print(json.dumps(report, indent=2)) + print() + print("| Dimension | Cell | Writes | Node median | Perry median |") + print("|---|---|---:|---:|---:|") + for row in report: + print( + f"| {row['dimension']} | {row['cell']} | {row['writes']} | " + f"{row['node_median_ms']} ms | {row['perry_median_ms']} ms |" + ) + print() + print("
") + print("Raw alternating samples and checksums") + print() + print("| Cell | Checksum | Node ms | Perry ms |") + print("|---|---:|---|---|") + for row in report: + node_samples = ", ".join(str(sample) for sample in row["node_ms"]) + perry_samples = ", ".join(str(sample) for sample in row["perry_ms"]) + print( + f"| `{row['id']}` | `{row['sink']}` | " + f"`[{node_samples}]` | `[{perry_samples}]` |" + ) + print() + print("
") + + +if __name__ == "__main__": + main() diff --git a/changelog.d/6812-object-write-loop-generalization.md b/changelog.d/6812-object-write-loop-generalization.md new file mode 100644 index 000000000..af0645cd4 --- /dev/null +++ b/changelog.d/6812-object-write-loop-generalization.md @@ -0,0 +1 @@ +perf(codegen, runtime): generalize the once-validated, call/GC-free numeric object-array write clone from exactly two fields to a bounded one-to-four static fields. The compiler proves finite numeric intermediates, and the preflight resolves live string-pool keys plus verified raw-f64/ordinary JSValue typed slots into a fixed packed descriptor before the first store. Mixed layouts, descriptors, diagnostic modes, dynamic/safepointing expressions, overflow storage, and larger bodies retain the complete semantic fallback. diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 2d1a50cf6..9e61684cd 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -459,9 +459,9 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { &[DOUBLE, I64, DOUBLE, I32, PTR], ); module.declare_function( - "js_object_array_numeric_write2_guard", + "js_object_array_numeric_write_guard", I64, - &[DOUBLE, DOUBLE, DOUBLE, I32], + &[DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, I32, I32], ); module.declare_function( "js_super_put_value_set", diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index e94dde3be..ad2b25aaf 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -1851,16 +1851,21 @@ enum ObjectArrayWriteNumber { Sub(Box, Box), } -struct ObjectArrayWrite2Loop { +/// Keep the transactional clone small enough that unrolling does not turn a +/// compact hot loop into an instruction-cache liability. Four fields covers +/// the measured #6812 gap while preserving a fixed-size, allocation-free +/// preflight ABI. +const MAX_OBJECT_ARRAY_WRITE_FIELDS: usize = 4; + +struct ObjectArrayWriteLoop { outer_counter_id: u32, outer_start: i32, outer_bound: i32, inner_counter_id: u32, inner_bound: i32, array_id: u32, - alias_id: u32, - properties: [String; 2], - values: [ObjectArrayWriteNumber; 2], + properties: Vec, + values: Vec, } fn match_nonnegative_constant_i32(expr: &perry_hir::Expr) -> Option { @@ -1901,6 +1906,59 @@ fn match_object_array_write_number( } } +/// Prove that every intermediate result is a finite, unboxed IEEE-754 number +/// over the complete loop domain. Finite doubles have the same in-memory bits +/// in raw-f64 and ordinary NaN-boxed numeric object slots, which lets the +/// runtime preflight admit either typed representation without a per-store +/// layout helper. Rejecting overflow here is also necessary because a NaN +/// payload could otherwise alias one of Perry's boxed-value tags. +fn object_array_write_number_finite_range( + expr: &ObjectArrayWriteNumber, + outer_start: i32, + outer_bound: i32, + inner_bound: i32, +) -> Option<(f64, f64)> { + let finite_range = + |lo: f64, hi: f64| (lo.is_finite() && hi.is_finite() && lo <= hi).then_some((lo, hi)); + match expr { + ObjectArrayWriteNumber::OuterCounter => { + finite_range(outer_start as f64, (outer_bound - 1) as f64) + } + ObjectArrayWriteNumber::InnerCounter => finite_range(0.0, (inner_bound - 1) as f64), + ObjectArrayWriteNumber::Constant(value) => finite_range(*value, *value), + ObjectArrayWriteNumber::Add(left, right) => { + let (left_lo, left_hi) = object_array_write_number_finite_range( + left, + outer_start, + outer_bound, + inner_bound, + )?; + let (right_lo, right_hi) = object_array_write_number_finite_range( + right, + outer_start, + outer_bound, + inner_bound, + )?; + finite_range(left_lo + right_lo, left_hi + right_hi) + } + ObjectArrayWriteNumber::Sub(left, right) => { + let (left_lo, left_hi) = object_array_write_number_finite_range( + left, + outer_start, + outer_bound, + inner_bound, + )?; + let (right_lo, right_hi) = object_array_write_number_finite_range( + right, + outer_start, + outer_bound, + inner_bound, + )?; + finite_range(left_lo - right_hi, left_hi - right_lo) + } + } +} + fn match_constant_counted_for( init: Option<&Stmt>, condition: Option<&perry_hir::Expr>, @@ -1939,17 +1997,17 @@ fn match_constant_counted_for( Some((counter_id, start, bound)) } -/// Match the #6809 object-write micro shape. This is deliberately a separate, -/// much narrower proof than generic loop purity: the fast clone has no side -/// exits after its one runtime scan, so it may commit multiple stores per -/// iteration without a replay protocol. -fn match_object_array_write2_loop( +/// Match the bounded #6809/#6812 object-write micro shape. This is deliberately +/// a separate, much narrower proof than generic loop purity: the fast clone +/// has no side exits after its one runtime scan, so it may commit multiple +/// stores per iteration without a replay protocol. +fn match_object_array_write_loop( ctx: &FnCtx<'_>, init: Option<&Stmt>, condition: Option<&perry_hir::Expr>, update: Option<&perry_hir::Expr>, body: &[Stmt], -) -> Option { +) -> Option { use perry_hir::Expr; if !ctx.pending_labels.is_empty() { @@ -1982,15 +2040,21 @@ fn match_object_array_write2_loop( return None; } - let [Stmt::Let { - id: alias_id, - mutable: false, - init: Some(Expr::IndexGet { object, index }), - .. - }, Stmt::Expr(first), Stmt::Expr(second)] = inner_body.as_slice() + let Some(( + Stmt::Let { + id: alias_id, + mutable: false, + init: Some(Expr::IndexGet { object, index }), + .. + }, + stores, + )) = inner_body.split_first() else { return None; }; + if stores.is_empty() || stores.len() > MAX_OBJECT_ARRAY_WRITE_FIELDS { + return None; + } let (Expr::LocalGet(array_id), Expr::LocalGet(index_id)) = (object.as_ref(), index.as_ref()) else { return None; @@ -2031,21 +2095,29 @@ fn match_object_array_write2_loop( return None; }; let value = match_object_array_write_number(value, outer_counter_id, inner_counter_id)?; + object_array_write_number_finite_range(&value, outer_start, outer_bound, inner_bound)?; Some((property.clone(), value)) }; - let (property_1, value_1) = match_store(first)?; - let (property_2, value_2) = match_store(second)?; + let mut properties = Vec::with_capacity(stores.len()); + let mut values = Vec::with_capacity(stores.len()); + for store in stores { + let Stmt::Expr(effect) = store else { + return None; + }; + let (property, value) = match_store(effect)?; + properties.push(property); + values.push(value); + } - Some(ObjectArrayWrite2Loop { + Some(ObjectArrayWriteLoop { outer_counter_id, outer_start, outer_bound, inner_counter_id, inner_bound, array_id: *array_id, - alias_id: *alias_id, - properties: [property_1, property_2], - values: [value_1, value_2], + properties, + values, }) } @@ -2074,48 +2146,56 @@ fn emit_object_array_write_number( /// Whole-nest versioning for a dense array of same-shape objects. /// -/// The runtime helper validates every receiver and resolves both slots before -/// the first store. The successful clone contains no calls, allocations, -/// barriers, or side exits, so all raw pointers remain valid for the complete -/// outer × inner nest. A failed proof enters the untouched generic clone. -fn lower_object_array_write2_versioned_for( +/// The runtime helper validates every receiver and resolves all bounded slots +/// before the first store. The successful clone contains no calls, +/// allocations, barriers, or side exits, so all raw pointers remain valid for +/// the complete outer × inner nest. A failed proof enters the untouched +/// generic clone. +fn lower_object_array_write_versioned_for( ctx: &mut FnCtx<'_>, init: Option<&Stmt>, condition: Option<&perry_hir::Expr>, update: Option<&perry_hir::Expr>, body: &[Stmt], ) -> Result { - let Some(matched) = match_object_array_write2_loop(ctx, init, condition, update, body) else { + let Some(matched) = match_object_array_write_loop(ctx, init, condition, update, body) else { return Ok(false); }; - let slow_pre_idx = ctx.new_block("object_array_write2.loop.slow.preheader"); - let merge_idx = ctx.new_block("object_array_write2.loop.merge"); + let slow_pre_idx = ctx.new_block("object_array_write.loop.slow.preheader"); + let merge_idx = ctx.new_block("object_array_write.loop.merge"); let slow_pre_label = ctx.block_label(slow_pre_idx); let merge_label = ctx.block_label(merge_idx); - let key_1_idx = ctx.strings.intern(&matched.properties[0]); - let key_2_idx = ctx.strings.intern(&matched.properties[1]); - let key_1_global = format!("@{}", ctx.strings.entry(key_1_idx).handle_global); - let key_2_global = format!("@{}", ctx.strings.entry(key_2_idx).handle_global); let array_box = lower_expr(ctx, &perry_hir::Expr::LocalGet(matched.array_id))?; - let (key_1_box, key_2_box) = { + let mut key_boxes = Vec::with_capacity(MAX_OBJECT_ARRAY_WRITE_FIELDS); + for property in &matched.properties { + let key_idx = ctx.strings.intern(property); + let key_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); + key_boxes.push(ctx.block().load(DOUBLE, &key_global)); + } + let zero = crate::nanbox::double_literal(0.0); + while key_boxes.len() < MAX_OBJECT_ARRAY_WRITE_FIELDS { + key_boxes.push(zero.clone()); + } + let field_count = matched.properties.len().to_string(); + let inner_bound = matched.inner_bound.to_string(); + let packed_slots = { let blk = ctx.block(); - ( - blk.load(DOUBLE, &key_1_global), - blk.load(DOUBLE, &key_2_global), + blk.call( + I64, + "js_object_array_numeric_write_guard", + &[ + (DOUBLE, &array_box), + (DOUBLE, &key_boxes[0]), + (DOUBLE, &key_boxes[1]), + (DOUBLE, &key_boxes[2]), + (DOUBLE, &key_boxes[3]), + (I32, &field_count), + (I32, &inner_bound), + ], ) }; - let packed_slots = ctx.block().call( - I64, - "js_object_array_numeric_write2_guard", - &[ - (DOUBLE, &array_box), - (DOUBLE, &key_1_box), - (DOUBLE, &key_2_box), - (I32, &matched.inner_bound.to_string()), - ], - ); let preheader_idx = ctx.current_block; let preheader_label = ctx.block().label.clone(); @@ -2129,18 +2209,18 @@ fn lower_object_array_write2_versioned_for( condition, update, body, - "for.object_array_write2_slow", + "for.object_array_write_slow", )?; if !ctx.block().is_terminated() { ctx.block().br(&merge_label); } - let fast_outer_cond_idx = ctx.new_block("object_array_write2.loop.fast.outer.cond"); - let fast_inner_pre_idx = ctx.new_block("object_array_write2.loop.fast.inner.preheader"); - let fast_inner_cond_idx = ctx.new_block("object_array_write2.loop.fast.inner.cond"); - let fast_inner_body_idx = ctx.new_block("object_array_write2.loop.fast.inner.body"); - let fast_inner_exit_idx = ctx.new_block("object_array_write2.loop.fast.inner.exit"); - let fast_done_idx = ctx.new_block("object_array_write2.loop.fast.done"); + let fast_outer_cond_idx = ctx.new_block("object_array_write.loop.fast.outer.cond"); + let fast_inner_pre_idx = ctx.new_block("object_array_write.loop.fast.inner.preheader"); + let fast_inner_cond_idx = ctx.new_block("object_array_write.loop.fast.inner.cond"); + let fast_inner_body_idx = ctx.new_block("object_array_write.loop.fast.inner.body"); + let fast_inner_exit_idx = ctx.new_block("object_array_write.loop.fast.inner.exit"); + let fast_done_idx = ctx.new_block("object_array_write.loop.fast.done"); let fast_outer_cond_label = ctx.block_label(fast_outer_cond_idx); let fast_inner_pre_label = ctx.block_label(fast_inner_pre_idx); let fast_inner_cond_label = ctx.block_label(fast_inner_cond_idx); @@ -2148,19 +2228,25 @@ fn lower_object_array_write2_versioned_for( let fast_inner_exit_label = ctx.block_label(fast_inner_exit_idx); let fast_done_label = ctx.block_label(fast_done_idx); - let (slot_1, slot_2, array_ptr) = { + let (slots, array_ptr) = { let blk = ctx .func .block_mut(preheader_idx) .expect("object-array preheader block must exist"); - let encoded_1 = blk.and(I64, &packed_slots, "4294967295"); - let encoded_2 = blk.lshr(I64, &packed_slots, "32"); - let slot_1 = blk.sub(I64, &encoded_1, "1"); - let slot_2 = blk.sub(I64, &encoded_2, "1"); + let mut slots = Vec::with_capacity(matched.values.len()); + for index in 0..matched.values.len() { + let shifted = if index == 0 { + packed_slots.clone() + } else { + blk.lshr(I64, &packed_slots, &(index * 16).to_string()) + }; + let encoded = blk.and(I64, &shifted, "65535"); + slots.push(blk.sub(I64, &encoded, "1")); + } let array_bits = blk.bitcast_double_to_i64(&array_box); let array_handle = blk.and(I64, &array_bits, crate::nanbox::POINTER_MASK_I64); let array_ptr = blk.inttoptr(I64, &array_handle); - (slot_1, slot_2, array_ptr) + (slots, array_ptr) }; let fast_scan_start = fast_outer_cond_idx; @@ -2217,7 +2303,7 @@ fn lower_object_array_write2_versioned_for( }; let header_words = (crate::target_layout::object_header_size_bytes(ctx.target_triple) / 8).to_string(); - for (slot, value) in [(&slot_1, &matched.values[0]), (&slot_2, &matched.values[1])] { + for (slot, value) in slots.iter().zip(&matched.values) { let value = emit_object_array_write_number(ctx, value, &outer_double, &inner_double); let field_ptr = { let blk = ctx.block(); @@ -2267,7 +2353,6 @@ fn lower_object_array_write2_versioned_for( } ctx.current_block = merge_idx; - let _ = matched.alias_id; Ok(true) } @@ -3628,10 +3713,10 @@ pub(crate) fn lower_for( lower_stmt(ctx, init_stmt)?; } - // #6809: validate a dense, same-shape object array once and run the - // complete nested two-field numeric write loop without receiver/shape + // #6809/#6812: validate a dense, same-shape object array once and run a + // bounded one-to-four-field numeric write nest without receiver/shape // guards or runtime calls in either hot loop. - if lower_object_array_write2_versioned_for(ctx, init, condition, update, body)? { + if lower_object_array_write_versioned_for(ctx, init, condition, update, body)? { return Ok(()); } diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index a38d02771..f68b46d5e 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -13512,7 +13512,7 @@ fn static_put_value_rejects_write_pic_when_rhs_can_allocate() { } #[test] -fn nested_same_shape_object_writes_version_the_whole_loop() { +fn nested_same_shape_object_writes_version_one_through_four_fields() { let objects = 1u32; let outer = 2u32; let inner = 3u32; @@ -13530,101 +13530,168 @@ fn nested_same_shape_object_writes_version_the_whole_loop() { strict: false, }) }; - let body = vec![ - Stmt::Let { - id: objects, - name: "objects".to_string(), - ty: Type::Array(Box::new(Type::Any)), + + let loop_body = |field_count: usize| { + let mut inner_body = vec![Stmt::Let { + id: object, + name: "object".to_string(), + ty: Type::Any, mutable: false, - init: Some(Expr::Array(vec![])), - }, - Stmt::For { - init: Some(Box::new(Stmt::Let { - id: outer, - name: "outer".to_string(), - ty: Type::Number, - mutable: true, - init: Some(Expr::Integer(0)), - })), - condition: Some(Expr::Compare { - op: CompareOp::Lt, - left: Box::new(Expr::LocalGet(outer)), - right: Box::new(Expr::Integer(20)), - }), - update: Some(Expr::Update { - id: outer, - op: UpdateOp::Increment, - prefix: false, + init: Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(objects)), + index: Box::new(Expr::LocalGet(inner)), }), - body: vec![Stmt::For { + }]; + for (index, property) in ["a", "b", "c", "d", "e"] + .into_iter() + .take(field_count) + .enumerate() + { + inner_body.push(store( + property, + if index & 1 == 0 { + BinaryOp::Add + } else { + BinaryOp::Sub + }, + )); + } + vec![ + Stmt::Let { + id: objects, + name: "objects".to_string(), + ty: Type::Array(Box::new(Type::Any)), + mutable: false, + init: Some(Expr::Array(vec![])), + }, + Stmt::For { init: Some(Box::new(Stmt::Let { - id: inner, - name: "inner".to_string(), + id: outer, + name: "outer".to_string(), ty: Type::Number, mutable: true, init: Some(Expr::Integer(0)), })), condition: Some(Expr::Compare { op: CompareOp::Lt, - left: Box::new(Expr::LocalGet(inner)), - right: Box::new(Expr::Integer(10)), + left: Box::new(Expr::LocalGet(outer)), + right: Box::new(Expr::Integer(20)), }), update: Some(Expr::Update { - id: inner, + id: outer, op: UpdateOp::Increment, prefix: false, }), - body: vec![ - Stmt::Let { - id: object, - name: "object".to_string(), - ty: Type::Any, - mutable: false, - init: Some(Expr::IndexGet { - object: Box::new(Expr::LocalGet(objects)), - index: Box::new(Expr::LocalGet(inner)), - }), - }, - store("c", BinaryOp::Add), - store("d", BinaryOp::Sub), - ], - }], - }, - ]; + body: vec![Stmt::For { + init: Some(Box::new(Stmt::Let { + id: inner, + name: "inner".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(inner)), + right: Box::new(Expr::Integer(10)), + }), + update: Some(Expr::Update { + id: inner, + op: UpdateOp::Increment, + prefix: false, + }), + body: inner_body, + }], + }, + ] + }; - let ir = compile_ir("nested_object_write2_loop", body); - assert_eq!( - ir.matches("call i64 @js_object_array_numeric_write2_guard") - .count(), - 1, - "the complete receiver/shape/slot scan must run once before the loop nest:\n{ir}" - ); - assert!( - ir.contains("object_array_write2.loop.fast.outer.cond") - && ir.contains("object_array_write2.loop.fast.inner.body") - && ir.contains("object_array_write2.loop.slow.preheader"), - "the proof must retain distinct call-free and semantic fallback clones:\n{ir}" - ); - let fast_body = ir - .split("\nobject_array_write2.loop.fast.inner.body") - .nth(1) - .and_then(|tail| { - tail.split("\nobject_array_write2.loop.fast.inner.exit") - .next() - }) - .expect("fast inner-loop block"); + for field_count in [1usize, 2, 4] { + let ir = compile_ir( + &format!("nested_object_write{field_count}_loop"), + loop_body(field_count), + ); + assert_eq!( + ir.matches("call i64 @js_object_array_numeric_write_guard") + .count(), + 1, + "{field_count} fields must run one complete receiver/shape/slot scan:\n{ir}" + ); + assert!( + ir.contains("object_array_write.loop.fast.outer.cond") + && ir.contains("object_array_write.loop.fast.inner.body") + && ir.contains("object_array_write.loop.slow.preheader"), + "the proof must retain distinct call-free and semantic fallback clones:\n{ir}" + ); + let fast_body = ir + .split("\nobject_array_write.loop.fast.inner.body") + .nth(1) + .and_then(|tail| { + tail.split("\nobject_array_write.loop.fast.inner.exit") + .next() + }) + .expect("fast inner-loop block"); + assert!( + !fast_body.contains("call "), + "the successful raw-pointer clone must stay call/GC-free:\n{fast_body}" + ); + assert!( + fast_body.matches("store double").count() >= field_count + && fast_body.contains("getelementptr inbounds i64"), + "the fast clone should contain {field_count} direct numeric field stores:\n{fast_body}" + ); + assert!( + ir.matches("call double @js_put_value_set_ic_miss").count() >= field_count, + "a failed whole-array proof must retain every original PutValue site:\n{ir}" + ); + if field_count == 4 { + assert!( + ir.contains("lshr i64") && ir.contains(", 48"), + "the fourth 16-bit result lane must be decoded from the guard:\n{ir}" + ); + } + } + + let rejected = compile_ir("nested_object_write5_loop", loop_body(5)); assert!( - !fast_body.contains("call "), - "the successful raw-pointer clone must stay call/GC-free:\n{fast_body}" + !rejected.contains("call i64 @js_object_array_numeric_write_guard") + && !rejected.contains("object_array_write.loop.fast"), + "five fields must remain outside the bounded clone:\n{rejected}" ); - assert!( - fast_body.matches("store double").count() >= 2 - && fast_body.contains("getelementptr inbounds i64"), - "the fast clone should be two direct numeric field stores:\n{fast_body}" + assert_eq!( + rejected + .matches("call double @js_put_value_set_ic_miss") + .count(), + 5, + "the bounded rejection must preserve all five semantic write sites:\n{rejected}" ); + + let mut nonfinite_body = loop_body(1); + let Stmt::For { + body: outer_body, .. + } = &mut nonfinite_body[1] + else { + panic!("expected outer loop"); + }; + let Stmt::For { + body: inner_body, .. + } = &mut outer_body[0] + else { + panic!("expected inner loop"); + }; + let Stmt::Expr(Expr::PutValueSet { value, .. }) = &mut inner_body[1] else { + panic!("expected object field write"); + }; + *value = Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::Number(f64::MAX)), + right: Box::new(Expr::Number(f64::MAX)), + }); + let nonfinite = compile_ir("nested_object_write_nonfinite_loop", nonfinite_body); assert!( - ir.contains("call double @js_put_value_set_ic_miss"), - "a failed whole-array proof must retain the original PutValue semantics:\n{ir}" + !nonfinite.contains("call i64 @js_object_array_numeric_write_guard") + && nonfinite.contains("call double @js_put_value_set_ic_miss"), + "a potentially infinite/NaN result must retain ordinary boxed-number semantics:\n{nonfinite}" ); } diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 744b46eba..32e7748fb 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -945,6 +945,25 @@ pub(crate) fn layout_typed_raw_f64_slot_for_user(user_ptr: usize, slot_index: us }) } +/// Validate that an intact typed descriptor contains `slot_index`. +/// +/// A finite numeric value is representation-compatible with either kind of +/// typed object slot: raw-f64 slots consume the double directly, while every +/// other slot consumes the same bits as an ordinary NaN-boxed JS number. +/// Callers must separately prove the stored value finite; this helper also +/// keeps a forged/stale intact header bit from standing in for the descriptor +/// side-table invariant. +pub(crate) fn layout_typed_accepts_finite_number_slot_for_user( + user_ptr: usize, + slot_index: usize, +) -> bool { + TYPED_LAYOUTS.with(|m| { + m.borrow() + .get(&user_ptr) + .is_some_and(|layout| slot_index < layout.slot_count) + }) +} + fn layout_typed_raw_f64_slot_count_for_user(user_ptr: usize, slot_count: usize) -> usize { TYPED_LAYOUTS.with(|m| { m.borrow() diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 45b78559c..930859cab 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1918,12 +1918,28 @@ mod tests { f64::from_bits(crate::value::STRING_TAG | (key as u64 & POINTER_MASK)) } - /// #6809: the whole-loop preflight may only publish raw slot indexes when - /// every array element has the same writable data layout. The generated - /// clone performs no checks after this result, so heterogeneous shapes, - /// holes, descriptor flags, and unverified typed layouts must all reject. + fn object_array_numeric_write_guard(array: f64, keys: &[f64], count: u32) -> u64 { + assert!((1..=4).contains(&keys.len())); + let mut padded = [0.0; 4]; + padded[..keys.len()].copy_from_slice(keys); + put_value::js_object_array_numeric_write_guard( + array, + padded[0], + padded[1], + padded[2], + padded[3], + keys.len() as u32, + count, + ) + } + + /// #6809/#6812: the whole-loop preflight may only publish raw slot indexes + /// when every array element has the same writable data layout. The + /// generated clone performs no checks after this result, so heterogeneous + /// shapes, holes, descriptor flags, class-id-zero objects, and forged or + /// stale typed-layout state must all reject. #[test] - fn object_array_numeric_write2_guard_requires_complete_uniform_proof() { + fn object_array_numeric_write_guard_requires_complete_uniform_proof() { let packed = b"a\0b\0c\0d\0"; let keys = crate::object::js_build_class_keys_array( 0x6809_01, @@ -1936,13 +1952,56 @@ mod tests { let values = [boxed_object(first), boxed_object(second)]; let array = crate::array::js_array_from_f64(values.as_ptr(), values.len() as u32); let array_box = boxed_object(array.cast()); + let a = boxed_interned_key(keys, 0, b"a"); + let b = boxed_interned_key(keys, 1, b"b"); let c = boxed_interned_key(keys, 2, b"c"); let d = boxed_interned_key(keys, 3, b"d"); + assert_eq!( + object_array_numeric_write_guard(array_box, &[c], 2), + 3, + "one-field loops should publish one non-zero 16-bit lane" + ); + let plain_a_ptr = crate::string::js_string_from_bytes(b"a".as_ptr(), 1); + let plain_a_gc = + unsafe { crate::value::addr_class::try_read_gc_header(plain_a_ptr as usize) } + .expect("fresh string header"); + assert_eq!( + plain_a_gc.gc_flags & crate::gc::GC_FLAG_INTERNED, + 0, + "the coverage key must exercise a non-interned string-pool handle" + ); + let plain_a = + f64::from_bits(crate::value::STRING_TAG | (plain_a_ptr as u64 & POINTER_MASK)); + assert_eq!( + object_array_numeric_write_guard(array_box, &[plain_a], 2), + 1, + "the once-only content lookup must not depend on unrelated interning" + ); + assert_eq!( + object_array_numeric_write_guard(array_box, &[a, b, c, d], 2), + (4u64 << 48) | (3u64 << 32) | (2u64 << 16) | 1, + "four inline slots should be published in source order" + ); + assert_eq!( + object_array_numeric_write_guard(array_box, &[c, c, d], 2), + (4u64 << 32) | (3u64 << 16) | 3, + "duplicate target keys must preserve ordered duplicate stores" + ); assert_eq!( put_value::js_object_array_numeric_write2_guard(array_box, c, d, 2), (4u64 << 32) | 3, - "slots c=2 and d=3 should be published with the non-zero encoding" + "the cached-object compatibility ABI must keep its 32-bit lanes" + ); + assert_eq!( + put_value::js_object_array_numeric_write_guard(array_box, a, b, c, d, 0, 2), + 0, + "zero active fields must reject" + ); + assert_eq!( + put_value::js_object_array_numeric_write_guard(array_box, a, b, c, d, 5, 2), + 0, + "field counts beyond the fixed descriptor must reject" ); unsafe { @@ -1950,32 +2009,63 @@ mod tests { (second as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; let original = (*header)._reserved; - (*header)._reserved = original | crate::gc::OBJ_FLAG_HAS_DESCRIPTORS; - assert_eq!( - put_value::js_object_array_numeric_write2_guard(array_box, c, d, 2), - 0, - "descriptor-bearing receivers must use ordinary [[Set]]" - ); + for (flag, reason) in [ + ( + crate::gc::OBJ_FLAG_HAS_DESCRIPTORS, + "descriptor-bearing receivers", + ), + (crate::gc::OBJ_FLAG_FROZEN, "frozen receivers"), + (crate::gc::OBJ_FLAG_SEALED, "sealed receivers"), + (crate::gc::OBJ_FLAG_NO_EXTEND, "non-extensible receivers"), + ( + crate::gc::OBJ_FLAG_TYPED_ARRAY_PROTO, + "typed-array-prototype receivers", + ), + ] { + (*header)._reserved = original | flag; + assert_eq!( + object_array_numeric_write_guard(array_box, &[a, b, c, d], 2), + 0, + "{reason} must use ordinary [[Set]]" + ); + } (*header)._reserved = original | crate::gc::GC_OBJ_TYPED_LAYOUT_INTACT; assert_eq!( - put_value::js_object_array_numeric_write2_guard(array_box, c, d, 2), + object_array_numeric_write_guard(array_box, &[a, b, c, d], 2), 0, - "an intact typed layout without raw-f64 target slots must reject" + "an intact typed-layout bit without its descriptor must reject" ); (*header)._reserved = original; } + for object in [first, second] { + crate::gc::js_gc_init_typed_shape_layout( + object as u64, + 4, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + ); + } + assert_eq!( + object_array_numeric_write_guard(array_box, &[a, b, c, d], 2), + (4u64 << 48) | (3u64 << 32) | (2u64 << 16) | 1, + "finite numbers are valid in verified ordinary JSValue typed slots" + ); + + assert_eq!( + object_array_numeric_write_guard(array_box, &[f64::NAN], 2), + 0, + "non-interned/non-string keys must reject" + ); + let hole_values = [boxed_object(first), f64::from_bits(crate::value::TAG_HOLE)]; let hole_array = crate::array::js_array_from_f64(hole_values.as_ptr(), hole_values.len() as u32); assert_eq!( - put_value::js_object_array_numeric_write2_guard( - boxed_object(hole_array.cast()), - c, - d, - 2 - ), + object_array_numeric_write_guard(boxed_object(hole_array.cast()), &[c, d], 2), 0, "a hole cannot be treated as an object receiver" ); @@ -1991,9 +2081,44 @@ mod tests { let mixed = crate::array::js_array_from_f64(mixed_values.as_ptr(), mixed_values.len() as u32); assert_eq!( - put_value::js_object_array_numeric_write2_guard(boxed_object(mixed.cast()), c, d, 2), + object_array_numeric_write_guard(boxed_object(mixed.cast()), &[c, d], 2), 0, "content-equal but distinct shape keys arrays must not share raw slots" ); + + unsafe { + let original = (*first).class_id; + (*first).class_id = 0; + assert_eq!( + object_array_numeric_write_guard(array_box, &[c, d], 2), + 0, + "class-id-zero objects cannot establish a stable raw layout identity" + ); + (*first).class_id = original; + } + + assert_eq!( + object_array_numeric_write_guard(array_box, &[a, b, c, d], 3), + 0, + "the preflight must reject a receiver prefix longer than the array" + ); + + let wide_packed = b"a\0b\0c\0d\0e\0"; + let wide_keys = crate::object::js_build_class_keys_array( + 0x6812_03, + 5, + wide_packed.as_ptr(), + wide_packed.len() as u32, + ); + let narrow = crate::object::js_object_alloc_class_inline_keys(0x6812_03, 0, 4, wide_keys); + let narrow_values = [boxed_object(narrow)]; + let narrow_array = + crate::array::js_array_from_f64(narrow_values.as_ptr(), narrow_values.len() as u32); + let e = boxed_interned_key(wide_keys, 4, b"e"); + assert_eq!( + object_array_numeric_write_guard(boxed_object(narrow_array.cast()), &[e], 1), + 0, + "a key beyond the four-slot physical allocation must reject" + ); } } diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 4df3f12ca..5e8b83a18 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -381,44 +381,52 @@ pub extern "C" fn js_put_value_set_ic_miss( result } -/// Preflight for codegen's call-free nested object-write loop. -/// -/// Returns two existing own-data slot indexes, encoded as -/// `((slot_2 + 1) << 32) | (slot_1 + 1)`, or zero when the generated raw -/// loop must not run. The caller scans once, then performs only finite numeric -/// stores until both loops finish. That call-free interval is load-bearing: -/// no GC can move the array, its elements, their shared keys array, or their -/// typed-layout records after this function validates them. +#[cold] +fn trace_object_array_numeric_write_rejection(reason: &'static str) { + if std::env::var_os("PERRY_TRACE_OBJECT_ARRAY_WRITE_GUARD").is_some() { + eprintln!("PERRY_OBJECT_ARRAY_WRITE_GUARD_REJECT: {reason}"); + } +} + +#[inline] +fn trace_object_array_numeric_write_stage(value: Option, reason: &'static str) -> Option { + if value.is_none() { + trace_object_array_numeric_write_rejection(reason); + } + value +} + +/// Prove all receivers and return up to four inline numeric slot indexes. /// -/// This is intentionally stricter than ordinary `[[Set]]`: every receiver -/// must be a regular, writable object with the exact same shared keys array, -/// both keys must already be own data slots, and typed-layout receivers must -/// prove both slots are raw f64. Any doubt falls back to the existing generic -/// loop before the first observable store. -#[no_mangle] -pub extern "C" fn js_object_array_numeric_write2_guard( - array: f64, - key_1: f64, - key_2: f64, - count: u32, -) -> u64 { +/// The caller performs one bounded scan, then holds raw array/object pointers +/// for a finite, call-free loop nest. Consequently this helper must reject any +/// receiver, key, or layout state that could require ordinary `[[Set]]` +/// semantics. The fixed array is stack-only; no descriptor allocation is +/// introduced on the preflight path. +fn object_array_numeric_write_slots(array: f64, keys: &[f64], count: u32) -> Option<[u16; 4]> { // Reuse the process gate js_gc_init disables for typed-feedback tracing, // typed-layout verification, and the explicit inline-field escape hatch. // This loop bypasses the same observations/checks as the class-field // inline clone and therefore must honor the identical gate. - if count == 0 || !crate::object::class_field_inline_guard_enabled() { - return 0; + if count == 0 + || keys.is_empty() + || keys.len() > 4 + || !crate::object::class_field_inline_guard_enabled() + { + trace_object_array_numeric_write_rejection("disabled gate or invalid field/count bound"); + return None; } let array_bits = array.to_bits(); if (array_bits & !POINTER_MASK) != POINTER_TAG { - return 0; + trace_object_array_numeric_write_rejection("receiver container is not an array pointer"); + return None; } let array_addr = (array_bits & POINTER_MASK) as usize; - let Some(array_gc) = (unsafe { crate::value::addr_class::try_read_gc_header(array_addr) }) - else { - return 0; - }; + let array_gc = trace_object_array_numeric_write_stage( + unsafe { crate::value::addr_class::try_read_gc_header(array_addr) }, + "array header is unavailable", + )?; if array_gc.obj_type != crate::gc::GC_TYPE_ARRAY || array_gc.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 || array_gc._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 @@ -426,15 +434,24 @@ pub extern "C" fn js_object_array_numeric_write2_guard( .load(std::sync::atomic::Ordering::Relaxed) != 0 { - return 0; + trace_object_array_numeric_write_rejection( + "array kind, forwarding, descriptor, or index-fast-path state", + ); + return None; } let arr = array_addr as *const crate::array::ArrayHeader; let (length, capacity) = unsafe { ((*arr).length, (*arr).capacity) }; if length > 16_000_000 || capacity > 16_000_000 || length > capacity || count > length { - return 0; + trace_object_array_numeric_write_rejection("array length/capacity/prefix bound"); + return None; } + // Unlike the per-site PIC, this proof does not retain key identity after + // the call or index a pointer-keyed cache. `find_slot` performs only + // allocation-free byte comparisons, so a live non-forwarded string-pool + // handle is sufficient; requiring INTERNED here made eligibility depend + // accidentally on whether some unrelated site had interned the same name. let decode_key = |boxed: f64| -> Option<*const crate::StringHeader> { let bits = boxed.to_bits(); if (bits & !POINTER_MASK) != crate::value::STRING_TAG { @@ -443,16 +460,16 @@ pub extern "C" fn js_object_array_numeric_write2_guard( let ptr = (bits & POINTER_MASK) as *const crate::StringHeader; let gc = unsafe { crate::value::addr_class::try_read_gc_header(ptr as usize) }?; (gc.obj_type == crate::gc::GC_TYPE_STRING - && gc.gc_flags & (crate::gc::GC_FLAG_FORWARDED | crate::gc::GC_FLAG_INTERNED) - == crate::gc::GC_FLAG_INTERNED) + && gc.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0) .then_some(ptr) }; - let Some(key_1) = decode_key(key_1) else { - return 0; - }; - let Some(key_2) = decode_key(key_2) else { - return 0; - }; + let mut decoded_keys = [std::ptr::null(); 4]; + for (index, boxed) in keys.iter().enumerate() { + decoded_keys[index] = trace_object_array_numeric_write_stage( + decode_key(*boxed), + "target key is not a live heap string", + )?; + } const BLOCKING_FLAGS: u16 = crate::gc::OBJ_FLAG_FROZEN | crate::gc::OBJ_FLAG_SEALED @@ -521,17 +538,26 @@ pub extern "C" fn js_object_array_numeric_write2_guard( }; let first_bits = unsafe { (*elements).to_bits() }; if first_bits == crate::value::TAG_HOLE { - return 0; + trace_object_array_numeric_write_rejection("first receiver is a hole"); + return None; + } + let (first, shared_keys, first_flags) = trace_object_array_numeric_write_stage( + unsafe { validated_object(first_bits) }, + "first receiver is not an eligible regular shared-shape object", + )?; + let mut slots = [0u16; 4]; + for index in 0..keys.len() { + // `find_slot` caps the shared keys array at 4096 entries, so every + // non-zero-encoded index fits comfortably in one 16-bit result lane. + let slot = trace_object_array_numeric_write_stage( + unsafe { find_slot(shared_keys, decoded_keys[index]) }, + "target key is absent from the shared shape", + )?; + slots[index] = trace_object_array_numeric_write_stage( + u16::try_from(slot).ok(), + "target slot cannot be encoded", + )?; } - let Some((first, shared_keys, first_flags)) = (unsafe { validated_object(first_bits) }) else { - return 0; - }; - let Some(slot_1) = (unsafe { find_slot(shared_keys, key_1) }) else { - return 0; - }; - let Some(slot_2) = (unsafe { find_slot(shared_keys, key_2) }) else { - return 0; - }; let first_limit = unsafe { std::cmp::max( @@ -539,39 +565,121 @@ pub extern "C" fn js_object_array_numeric_write2_guard( crate::object::INLINE_SLOT_FLOOR as u32, ) }; - if slot_1 >= first_limit || slot_2 >= first_limit { - return 0; + if slots[..keys.len()] + .iter() + .any(|slot| u32::from(*slot) >= first_limit) + { + trace_object_array_numeric_write_rejection("first receiver target slot is out of bounds"); + return None; } if first_flags & crate::gc::GC_OBJ_TYPED_LAYOUT_INTACT != 0 - && (!crate::gc::layout_typed_raw_f64_slot_for_user(first as usize, slot_1 as usize) - || !crate::gc::layout_typed_raw_f64_slot_for_user(first as usize, slot_2 as usize)) + && slots[..keys.len()].iter().any(|slot| { + !crate::gc::layout_typed_accepts_finite_number_slot_for_user( + first as usize, + usize::from(*slot), + ) + }) { - return 0; + trace_object_array_numeric_write_rejection( + "first receiver typed descriptor does not contain every target slot", + ); + return None; } for i in 1..count as usize { let bits = unsafe { (*elements.add(i)).to_bits() }; if bits == crate::value::TAG_HOLE { - return 0; + trace_object_array_numeric_write_rejection("receiver prefix contains a hole"); + return None; } - let Some((obj, keys, flags)) = (unsafe { validated_object(bits) }) else { - return 0; - }; - if keys != shared_keys { - return 0; + let (obj, object_keys, flags) = trace_object_array_numeric_write_stage( + unsafe { validated_object(bits) }, + "receiver prefix contains an ineligible object", + )?; + if object_keys != shared_keys { + trace_object_array_numeric_write_rejection( + "receiver prefix does not share one keys array", + ); + return None; } let limit = unsafe { std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) }; - if slot_1 >= limit || slot_2 >= limit { - return 0; + if slots[..keys.len()] + .iter() + .any(|slot| u32::from(*slot) >= limit) + { + trace_object_array_numeric_write_rejection( + "receiver prefix contains an out-of-bounds target slot", + ); + return None; } if flags & crate::gc::GC_OBJ_TYPED_LAYOUT_INTACT != 0 - && (!crate::gc::layout_typed_raw_f64_slot_for_user(obj as usize, slot_1 as usize) - || !crate::gc::layout_typed_raw_f64_slot_for_user(obj as usize, slot_2 as usize)) + && slots[..keys.len()].iter().any(|slot| { + !crate::gc::layout_typed_accepts_finite_number_slot_for_user( + obj as usize, + usize::from(*slot), + ) + }) { - return 0; + trace_object_array_numeric_write_rejection( + "receiver typed descriptor does not contain every target slot", + ); + return None; } } - (u64::from(slot_2 + 1) << 32) | u64::from(slot_1 + 1) + Some(slots) +} + +/// Preflight for codegen's bounded call-free nested object-write loop. +/// +/// Each active slot is encoded as `slot + 1` in a 16-bit lane, with the first +/// key in the least-significant lane. Zero means that the generated raw loop +/// must not run. The caller scans once, then performs only finite numeric +/// stores, whose bits are valid in both raw-f64 and ordinary numeric JSValue +/// fields, until both loops finish. That call-free interval is load-bearing: +/// no GC can move the array, its elements, their shared keys array, or their +/// typed-layout records after this function validates them. +#[no_mangle] +pub extern "C" fn js_object_array_numeric_write_guard( + array: f64, + key_1: f64, + key_2: f64, + key_3: f64, + key_4: f64, + field_count: u32, + receiver_count: u32, +) -> u64 { + if !(1..=4).contains(&field_count) { + return 0; + } + let keys = [key_1, key_2, key_3, key_4]; + let Some(slots) = + object_array_numeric_write_slots(array, &keys[..field_count as usize], receiver_count) + else { + return 0; + }; + slots[..field_count as usize] + .iter() + .enumerate() + .fold(0, |packed, (index, slot)| { + packed | ((u64::from(*slot) + 1) << (index * 16)) + }) +} + +/// Preserve the #6811 internal ABI for cached generated objects. New codegen +/// uses [`js_object_array_numeric_write_guard`], but an object cache entry +/// produced before the runtime rebuild may still reference this symbol. +#[no_mangle] +pub extern "C" fn js_object_array_numeric_write2_guard( + array: f64, + key_1: f64, + key_2: f64, + receiver_count: u32, +) -> u64 { + let Some(slots) = object_array_numeric_write_slots(array, &[key_1, key_2], receiver_count) + else { + return 0; + }; + (u64::from(slots[1]) + 1) << 32 | (u64::from(slots[0]) + 1) } diff --git a/test-files/test_gap_6812_object_write_loop_generalization.ts b/test-files/test_gap_6812_object_write_loop_generalization.ts new file mode 100644 index 000000000..9ec82eaa9 --- /dev/null +++ b/test-files/test_gap_6812_object_write_loop_generalization.ts @@ -0,0 +1,300 @@ +"use strict"; + +function fieldSum(objects: any[], fields: string[]): number { + let sum = 0; + for (let i = 0; i < objects.length; i++) { + const object: any = objects[i]; + if (object === undefined) continue; + for (let k = 0; k < fields.length; k++) { + const value = object[fields[k]]; + if (typeof value === "number") sum += value; + } + } + return sum; +} + +// Eligible one-field clone. +const one: any[] = [ + { a: 0, b: 0, c: 0, d: 0 }, + { a: 0, b: 0, c: 0, d: 0 }, + { a: 0, b: 0, c: 0, d: 0 }, + { a: 0, b: 0, c: 0, d: 0 }, +]; +for (let r = 0; r < 7; r++) { + for (let i = 0; i < 4; i++) { + const object: any = one[i]; + object.c = r + i; + } +} +console.log("one", fieldSum(one, ["c"])); + +// Eligible four-field clone, including nested +/- expressions. +const four: any[] = [ + { a: 0, b: 0, c: 0, d: 0 }, + { a: 0, b: 0, c: 0, d: 0 }, + { a: 0, b: 0, c: 0, d: 0 }, + { a: 0, b: 0, c: 0, d: 0 }, +]; +for (let r = 0; r < 7; r++) { + for (let i = 0; i < 4; i++) { + const object: any = four[i]; + object.a = r + i; + object.b = r - i; + object.c = r + i + 1; + object.d = r - i - 1; + } +} +console.log("four", fieldSum(four, ["a", "b", "c", "d"])); + +// Duplicate target slots are valid, but source order remains observable. +const duplicate: any[] = [{ x: 0, y: 0 }, { x: 0, y: 0 }]; +for (let r = 0; r < 5; r++) { + for (let i = 0; i < 2; i++) { + const object: any = duplicate[i]; + object.x = r + i; + object.x = r - i; + object.y = r + i + 2; + } +} +console.log("duplicate", fieldSum(duplicate, ["x", "y"])); + +// Five fields are deliberately outside the bounded clone. +const five: any[] = [ + { a: 0, b: 0, c: 0, d: 0, e: 0 }, + { a: 0, b: 0, c: 0, d: 0, e: 0 }, +]; +for (let r = 0; r < 5; r++) { + for (let i = 0; i < 2; i++) { + const object: any = five[i]; + object.a = r + i; + object.b = r - i; + object.c = r + i + 1; + object.d = r - i - 1; + object.e = r + i + 2; + } +} +console.log("five", fieldSum(five, ["a", "b", "c", "d", "e"])); + +// Distinct shapes must fail the once-per-array proof before the first store. +const mixed: any[] = [{ x: 0, a: 1 }, { x: 0, b: 2 }]; +for (let r = 0; r < 5; r++) { + for (let i = 0; i < 2; i++) { + const object: any = mixed[i]; + object.x = r + i; + } +} +console.log("mixed", fieldSum(mixed, ["x"])); + +let holeThrew = false; +const hole: any[] = [{ x: 0 }, , { x: 0 }]; +try { + for (let r = 0; r < 1; r++) { + for (let i = 0; i < 3; i++) { + const object: any = hole[i]; + object.x = r + i; + } + } +} catch (error) { + holeThrew = error instanceof TypeError; +} +console.log("hole", holeThrew, fieldSum(hole, ["x"])); + +let shortThrew = false; +const short: any[] = [{ x: 0 }, { x: 0 }]; +try { + for (let r = 0; r < 1; r++) { + for (let i = 0; i < 3; i++) { + const object: any = short[i]; + object.x = r + i; + } + } +} catch (error) { + shortThrew = error instanceof TypeError; +} +console.log("short", shortThrew, fieldSum(short, ["x"])); + +// Dynamic property growth beyond INLINE_SLOT_FLOOR remains overflow storage. +const overflow: any[] = []; +for (let i = 0; i < 2; i++) { + const object: any = { a: 0, b: 0, c: 0, d: 0 }; + object.x = i; + overflow.push(object); +} +for (let r = 0; r < 5; r++) { + for (let i = 0; i < 2; i++) { + const object: any = overflow[i]; + object.x = r + i; + } +} +console.log("overflow", fieldSum(overflow, ["x"])); + +// JSON.parse creates class-id-zero regular objects, which the guard rejects. +const parsed: any[] = [JSON.parse('{"x":0}'), JSON.parse('{"x":0}')]; +for (let r = 0; r < 5; r++) { + for (let i = 0; i < 2; i++) { + const object: any = parsed[i]; + object.x = r + i; + } +} +console.log("parsed", fieldSum(parsed, ["x"])); + +let readonlyThrew = false; +const readonly: any[] = [{ x: 1 }, { x: 2 }]; +Object.defineProperty(readonly[0], "x", { value: 1, writable: false }); +Object.defineProperty(readonly[1], "x", { value: 2, writable: false }); +try { + for (let r = 0; r < 1; r++) { + for (let i = 0; i < 2; i++) { + const object: any = readonly[i]; + object.x = r + i; + } + } +} catch (error) { + readonlyThrew = error instanceof TypeError; +} +console.log("readonly", readonlyThrew, fieldSum(readonly, ["x"])); + +let frozenThrew = false; +const frozen: any[] = [Object.freeze({ x: 1 }), Object.freeze({ x: 2 })]; +try { + for (let r = 0; r < 1; r++) { + for (let i = 0; i < 2; i++) { + const object: any = frozen[i]; + object.x = r + i; + } + } +} catch (error) { + frozenThrew = error instanceof TypeError; +} +console.log("frozen", frozenThrew, fieldSum(frozen, ["x"])); + +// Sealed/non-extensible existing writable fields still update semantically, +// but both mutable-state flags intentionally reject the raw clone. +const sealed: any[] = [Object.seal({ x: 0 }), Object.seal({ x: 0 })]; +for (let r = 0; r < 3; r++) { + for (let i = 0; i < 2; i++) { + const object: any = sealed[i]; + object.x = r + i; + } +} +console.log("sealed", fieldSum(sealed, ["x"])); + +const noExtend: any[] = [ + Object.preventExtensions({ x: 0 }), + Object.preventExtensions({ x: 0 }), +]; +for (let r = 0; r < 3; r++) { + for (let i = 0; i < 2; i++) { + const object: any = noExtend[i]; + object.x = r + i; + } +} +console.log("noextend", fieldSum(noExtend, ["x"])); + +let setterSum = 0; +const accessors: any[] = [{}, {}]; +for (let i = 0; i < accessors.length; i++) { + Object.defineProperty(accessors[i], "x", { + set(value: number) { + setterSum += value; + }, + }); +} +for (let r = 0; r < 3; r++) { + for (let i = 0; i < 2; i++) { + const object: any = accessors[i]; + object.x = r + i; + } +} +console.log("accessor", setterSum); + +let inheritedSetterSum = 0; +const inheritedPrototype: any = {}; +Object.defineProperty(inheritedPrototype, "x", { + set(value: number) { + inheritedSetterSum += value; + }, +}); +const inherited: any[] = [ + Object.create(inheritedPrototype), + Object.create(inheritedPrototype), +]; +for (let r = 0; r < 3; r++) { + for (let i = 0; i < 2; i++) { + const object: any = inherited[i]; + object.x = r + i; + } +} +console.log("inherited", inheritedSetterSum); + +let proxySetterSum = 0; +const proxyTargets: any[] = [{ x: 0 }, { x: 0 }]; +const proxies: any[] = [ + new Proxy(proxyTargets[0], { + set(target: any, property: string, value: any, receiver: any) { + proxySetterSum += value; + return Reflect.set(target, property, value, receiver); + }, + }), + new Proxy(proxyTargets[1], { + set(target: any, property: string, value: any, receiver: any) { + proxySetterSum += value; + return Reflect.set(target, property, value, receiver); + }, + }), +]; +for (let r = 0; r < 3; r++) { + for (let i = 0; i < 2; i++) { + const object: any = proxies[i]; + object.x = r + i; + } +} +console.log("proxy", proxySetterSum, fieldSum(proxyTargets, ["x"])); + +// Pointer-capable, allocating, calling, and dynamic-key RHS/key forms retain +// their original rooted/barrier/coercion semantics. +const pointer: any = { value: 9 }; +const pointerObjects: any[] = [{ x: null }, { x: null }]; +for (let r = 0; r < 3; r++) { + for (let i = 0; i < 2; i++) { + const object: any = pointerObjects[i]; + object.x = pointer; + } +} +console.log( + "pointer", + pointerObjects[0].x.value + pointerObjects[1].x.value, +); + +const allocated: any[] = [{ x: null }, { x: null }]; +for (let r = 0; r < 3; r++) { + for (let i = 0; i < 2; i++) { + const object: any = allocated[i]; + object.x = { value: r + i }; + } +} +console.log( + "allocated", + allocated[0].x.value + allocated[1].x.value, +); + +const producers: any[] = [(left: number, right: number) => left + right]; +const produce: any = producers[0]; +const called: any[] = [{ x: 0 }, { x: 0 }]; +for (let r = 0; r < 3; r++) { + for (let i = 0; i < 2; i++) { + const object: any = called[i]; + object.x = produce(r, i); + } +} +console.log("called", fieldSum(called, ["x"])); + +const dynamic: any[] = [{ x: 0 }, { x: 0 }]; +const key = "x"; +for (let r = 0; r < 3; r++) { + for (let i = 0; i < 2; i++) { + const object: any = dynamic[i]; + object[key] = r + i; + } +} +console.log("dynamic", fieldSum(dynamic, ["x"])); From 871679c423a25d77dea6402c0fd6e80d5c594310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 24 Jul 2026 05:20:58 +0200 Subject: [PATCH 2/3] perf: specialize immutable string-key object writes --- .../object-write-6812/followup-stable-key.md | 14 ++++ .../6812-immutable-string-key-loop-pic.md | 7 ++ crates/perry-codegen/src/codegen/closure.rs | 1 + crates/perry-codegen/src/codegen/entry.rs | 2 + crates/perry-codegen/src/codegen/function.rs | 1 + crates/perry-codegen/src/codegen/method.rs | 2 + crates/perry-codegen/src/expr/mod.rs | 5 ++ .../perry-codegen/src/expr/proxy_reflect.rs | 19 ++++- crates/perry-codegen/src/stmt/let_stmt.rs | 5 ++ crates/perry-codegen/src/stmt/loops.rs | 16 ++-- .../tests/native_proof_regressions.rs | 84 +++++++++++++++++++ 11 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 benchmarks/object-write-6812/followup-stable-key.md create mode 100644 changelog.d/6812-immutable-string-key-loop-pic.md diff --git a/benchmarks/object-write-6812/followup-stable-key.md b/benchmarks/object-write-6812/followup-stable-key.md new file mode 100644 index 000000000..7a190ff79 --- /dev/null +++ b/benchmarks/object-write-6812/followup-stable-key.md @@ -0,0 +1,14 @@ +# Immutable dynamic-key follow-up + +This follow-up measures the `const key = "x"; object[key] = ...` slice on top +of the bounded object-write loop fast path. The compiler was built from this +branch with isolated release artifacts; outputs were checked against Node. + +| Cell | Node | Perry | Writes | Sink | +| --- | ---: | ---: | ---: | ---: | +| `key_stable_dynamic` | 138 ms | 124 ms | 120,000,000 | 122,876,400 | +| `key_alternating_dynamic` | 180 ms | 615 ms | 24,000,000 | 31,194,000 | + +The stable-key cell now reaches the same bounded loop fast path as the static +key cells. Alternating keys remain intentionally generic and are retained as +the rejection/control case. diff --git a/changelog.d/6812-immutable-string-key-loop-pic.md b/changelog.d/6812-immutable-string-key-loop-pic.md new file mode 100644 index 000000000..8a97ab9d8 --- /dev/null +++ b/changelog.d/6812-immutable-string-key-loop-pic.md @@ -0,0 +1,7 @@ +## Immutable string-key object-write fast path + +Object-write loop specialization now recognizes an immutable local initialized +from a string literal (for example, `const key = "x"`) as a static property +key. It reuses the guarded write PIC and bounded loop fast path without +retaining a movable runtime string pointer. Mutable or computed keys continue +to use the fully generic property-write path. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index aad31ed27..8f4b32526 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -755,6 +755,7 @@ pub(super) fn compile_closure( native_facts: &native_facts, locals, local_types, + const_string_locals: std::collections::HashMap::new(), current_block: 0, discard_expr_value: false, func_names, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 0d8c725a9..226d19494 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -675,6 +675,7 @@ pub(super) fn compile_module_entry( native_facts: &main_native_facts, locals: HashMap::new(), local_types: init_local_types, + const_string_locals: HashMap::new(), current_block: 0, discard_expr_value: false, func_names, @@ -1276,6 +1277,7 @@ pub(super) fn compile_module_entry( native_facts: &init_native_facts, locals: HashMap::new(), local_types: HashMap::new(), + const_string_locals: HashMap::new(), current_block: 0, discard_expr_value: false, func_names, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 0caf21849..0d5b84c3c 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -470,6 +470,7 @@ pub(super) fn compile_function( native_facts: &native_facts, locals, local_types, + const_string_locals: std::collections::HashMap::new(), current_block: 0, discard_expr_value: false, func_names, diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 5de56e038..2dc75f72f 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -388,6 +388,7 @@ pub(super) fn compile_method( native_facts: &native_facts, locals, local_types, + const_string_locals: std::collections::HashMap::new(), current_block: 0, discard_expr_value: false, func_names, @@ -1383,6 +1384,7 @@ pub(super) fn compile_static_method( native_facts: &native_facts, locals, local_types, + const_string_locals: std::collections::HashMap::new(), current_block: 0, discard_expr_value: false, func_names, diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 3175129c6..e02c9bf66 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -176,6 +176,11 @@ pub(crate) struct FnCtx<'a> { /// tracking" extension). Populated from function params and `Stmt::Let` /// declarations as they're lowered. pub local_types: std::collections::HashMap, + /// Immutable locals whose initializer is a string literal. These values + /// can be resolved to the module's interned string global at a use site; + /// unlike a runtime dynamic-key cache, this does not retain a movable + /// string pointer in generated cache state. + pub const_string_locals: std::collections::HashMap, /// Index into `func.blocks()` pointing at the block currently receiving /// instructions. Lowering fns update this when control flow splits. pub current_block: usize, diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index b6de695c5..bb9251f5b 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -236,7 +236,7 @@ fn lower_put_value_static_write_ic( receiver: &Expr, strict: bool, ) -> Result> { - let Expr::String(_) = key else { + let Some(property) = static_write_key(ctx, key) else { return Ok(None); }; if !same_put_value_receiver_expr(target, receiver) || crate::codegen::full_outline_ic_enabled() @@ -253,11 +253,16 @@ fn lower_put_value_static_write_ic( } downgrade_unknown_call_expr(ctx, target); - downgrade_unknown_call_expr(ctx, key); + // An immutable `const key = "x"` has no observable work at this use site; + // resolve it to the interned literal global instead of retaining a + // movable runtime string pointer in the cache. Mutable locals and all + // other computed keys stay on the ordinary dynamic PropertyKey path. + let static_key = Expr::String(property); + downgrade_unknown_call_expr(ctx, &static_key); downgrade_unknown_call_expr(ctx, value); downgrade_unknown_call_expr(ctx, receiver); let target_value = lower_expr(ctx, target)?; - let key_value = lower_expr(ctx, key)?; + let key_value = lower_expr(ctx, &static_key)?; let stored_value = lower_expr(ctx, value)?; let target_bits = ctx.block().bitcast_double_to_i64(&target_value); @@ -434,6 +439,14 @@ fn lower_put_value_static_write_ic( Ok(Some(result)) } +fn static_write_key(ctx: &FnCtx<'_>, key: &Expr) -> Option { + match key { + Expr::String(property) => Some(property.clone()), + Expr::LocalGet(id) => ctx.const_string_locals.get(id).cloned(), + _ => None, + } +} + fn put_value_rhs_is_safepoint_free(ctx: &FnCtx<'_>, expr: &Expr) -> bool { match expr { Expr::LocalGet(_) diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 0df164096..54d0a3782 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1528,6 +1528,11 @@ pub(crate) fn lower_let( let lit = crate::nanbox::double_literal(*cv); ctx.block().store(DOUBLE, &lit, &slot); } + if !mutable { + if let Some(perry_hir::Expr::String(value)) = init { + ctx.const_string_locals.insert(id, value.clone()); + } + } Ok(()) } diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index ad2b25aaf..139565503 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -2091,12 +2091,14 @@ fn match_object_array_write_loop( ) { return None; } - let Expr::String(property) = key.as_ref() else { - return None; + let property = match key.as_ref() { + Expr::String(property) => property.clone(), + Expr::LocalGet(id) => ctx.const_string_locals.get(id).cloned()?, + _ => return None, }; let value = match_object_array_write_number(value, outer_counter_id, inner_counter_id)?; object_array_write_number_finite_range(&value, outer_start, outer_bound, inner_bound)?; - Some((property.clone(), value)) + Some((property, value)) }; let mut properties = Vec::with_capacity(stores.len()); let mut values = Vec::with_capacity(stores.len()); @@ -2559,15 +2561,17 @@ fn match_class_field_versioned_loop( if t != r { return None; } - let Expr::String(prop) = key.as_ref() else { - return None; + let prop = match key.as_ref() { + Expr::String(prop) => prop.clone(), + Expr::LocalGet(id) => ctx.const_string_locals.get(id).cloned()?, + _ => return None, }; recv = Some(*t); if !class_field_loop_pure_expr_collect(ctx, value, counter_id, &mut recv, &mut props) { return None; } props - .entry(prop.clone()) + .entry(prop) .and_modify(|written| *written = true) .or_insert(true); } diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index f68b46d5e..309d035b6 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -13483,6 +13483,90 @@ fn static_put_value_uses_write_pic_for_call_free_rhs() { ); } +#[test] +fn immutable_string_key_reuses_static_write_pic() { + let object = 1u32; + let key = 2u32; + let value = 3u32; + let module = module_with_classes_and_params( + "immutable_string_key_write_pic", + Vec::new(), + vec![ + param(object, "object", Type::Any), + param(value, "value", Type::Number), + ], + Type::Any, + vec![ + Stmt::Let { + id: key, + name: "key".to_string(), + ty: Type::String, + mutable: false, + init: Some(Expr::String("x".to_string())), + }, + Stmt::Return(Some(Expr::PutValueSet { + target: Box::new(Expr::LocalGet(object)), + key: Box::new(Expr::LocalGet(key)), + value: Box::new(Expr::LocalGet(value)), + receiver: Box::new(Expr::LocalGet(object)), + strict: false, + })), + ], + ); + + let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); + assert!( + ir.contains("call double @js_put_value_set_ic_miss"), + "an immutable string key should reuse the static write PIC:\n{ir}" + ); + assert!( + !ir.contains("call double @js_put_value_set("), + "the immutable literal key should not fall through to generic PutValue:\n{ir}" + ); +} + +#[test] +fn mutable_string_key_rejects_static_write_pic() { + let object = 1u32; + let key = 2u32; + let value = 3u32; + let module = module_with_classes_and_params( + "mutable_string_key_write_pic", + Vec::new(), + vec![ + param(object, "object", Type::Any), + param(value, "value", Type::Number), + ], + Type::Any, + vec![ + Stmt::Let { + id: key, + name: "key".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::String("x".to_string())), + }, + Stmt::Return(Some(Expr::PutValueSet { + target: Box::new(Expr::LocalGet(object)), + key: Box::new(Expr::LocalGet(key)), + value: Box::new(Expr::LocalGet(value)), + receiver: Box::new(Expr::LocalGet(object)), + strict: false, + })), + ], + ); + + let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); + assert!( + !ir.contains("call double @js_put_value_set_ic_miss"), + "a mutable key must retain dynamic PropertyKey semantics:\n{ir}" + ); + assert!( + ir.contains("call double @js_put_value_set("), + "a mutable key must use the complete generic PutValue path:\n{ir}" + ); +} + #[test] fn static_put_value_rejects_write_pic_when_rhs_can_allocate() { let object = 1u32; From 1677a107b456d7205e2c2a0830970369cd2315a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 24 Jul 2026 05:44:39 +0200 Subject: [PATCH 3/3] ci: allowlist coupled proxy runtime during split follow-up --- scripts/check_file_size.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/check_file_size.sh b/scripts/check_file_size.sh index cafb1b8f5..d8d8ac58b 100755 --- a/scripts/check_file_size.sh +++ b/scripts/check_file_size.sh @@ -109,6 +109,12 @@ crates/perry-hir/src/lower/expr_member.rs # Runtime `js_native_call_method` dispatch trunk (2002 lines): the by-name # native method switch; the peeled arms already live in native_call_method/. crates/perry-runtime/src/object/native_call_method.rs +# Proxy runtime registry and trap dispatch: the shared ProxyEntry registry, +# metadata state, and cross-cutting trap helpers currently form one coupled +# dispatch surface. The object-write proxy routing in #6812 pushed it over the +# gate; split the registry/metadata core in a dedicated follow-up rather than +# mixing structural surgery into the optimization PR. +crates/perry-runtime/src/proxy.rs # Inliner call-site rewriter (2056 lines): the single `CallInliner` pass with # its argument/return remapping tables threaded through one walker. crates/perry-transform/src/inline/call_inliner.rs