Repository navigation
perf: generalize guarded object-write fast paths beyond the #6811 two-field loop #6812
Description
Objective
Generalize the guarded object-write optimizations landed by #6811 beyond the
single #6759 acceptance-loop shape, without weakening JavaScript semantics or
GC safety.
#6811 completed #6809 and beat Node on the canonical write micro, but its
largest win is intentionally narrow. This issue tracks the remaining coverage
work: identify which real object writes still miss the static write PIC or the
whole-loop clone, rank those misses with measurements, and extend the sound
fast paths in small reviewable slices.
Start from current origin/main. The starting point must contain merge commit
20f19758f370f7616530824218d87f46a67cd3f0 ("perf: beat Node on object-write
micro (#6809) (#6811)"). Do not resume from a pre-#6811 worktree.
What #6811 established
Measured on the same macOS/ARM64 machine using 15 alternating runs:
| implementation | median for 9.6M existing-field writes |
|---|---|
| Node.js | 7 ms |
| Perry before #6809 | about 16 s |
| Perry after runtime + per-site PIC work, before loop versioning | 39–40 ms |
| Perry after #6811 | 5 ms |
The result is real, but it is not a blanket optimization for every
object[key] = value:
-
Broad runtime improvements
- Header-first receiver classification.
- Existing-own-data overwrite routing.
- Builtin descriptor installs no longer unnecessarily poison process-wide
user-descriptor gates. - Correct prototype/descriptor/array invalidation handling.
-
Guarded static-key write PIC
crates/perry-codegen/src/expr/proxy_reflect.rs
(lower_put_value_static_write_ic)- Runtime miss/priming:
crates/perry-runtime/src/proxy/put_value.rs
(js_put_value_set_ic_miss) - Currently requires:
- a literal string key;
- target and receiver to be the same expression;
- a safepoint-free/call-free RHS;
- an eligible regular existing-own-data receiver;
- one matching shape token (monomorphic cache);
- inline, in-bounds storage and all mutable descriptor/frozen/typed-layout
guards to pass.
-
Whole-loop numeric object-array clone
- Matcher/emitter:
crates/perry-codegen/src/stmt/loops.rs
(match_object_array_write2_loop,
lower_object_array_write2_versioned_for) - Runtime preflight:
crates/perry-runtime/src/proxy/put_value.rs
(js_object_array_numeric_write2_guard) - Currently recognizes only:
- a constant counted outer
forloop containing exactly one constant
counted innerforloop; - inner start at zero and a finite constant dense prefix;
- one immutable
const o = objects[i]alias; - exactly two static existing-field writes;
- numeric expressions composed only from loop counters, numeric
constants,+, and-; - no labels, calls, allocations, safepoints, or other body statements.
- a constant counted outer
- The runtime scans the full receiver prefix once and proves regular
objects, dense elements, one shared keys array, writable own slots,
descriptor/frozen/sealed/no-extend state, and typed raw-f64 layout where
applicable. Failure enters an untouched semantic clone before any store.
- Matcher/emitter:
Canonical acceptance micro
Keep this exact source for historical comparability:
// #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);Measurement protocol
-
Use a release build from a clean worktree based on current
origin/main.
Use an isolated Cargo target directory for that worktree: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:
PERRY_RUNTIME_DIR="$PERRY_OBJECT_WRITE_TARGET/release" \ "$PERRY_OBJECT_WRITE_TARGET/release/perry" write.ts -o bin_write
Do not set
PERRY_NO_AUTO_OPTIMIZE. Do not override release
codegen-units; the repository's release/LTO profile is part of the
measurement. -
Alternate Node and Perry runs on the same idle machine. Record versions,
host/architecture, all raw samples, and the median of at least 15 runs.
Keep the checksum identical. For new matrix cells, also provide a scaled
version lasting at least about 100 ms to reduceDate.now()quantization. -
Never report a gain from compile-only IR evidence. Measure the produced
executable and verify output parity first.
Required first step: a coverage/rejection matrix
Before extending code, benchmark and classify at least these cells. Record
whether each hits the generic runtime, the static PIC, or a loop clone, and the
reason an expected fast path rejects.
| Dimension | Required cells |
|---|---|
| Key | o.x, o["x"], stable o[k], alternating dynamic keys |
| RHS | numeric scalar, pointer-capable value, allocating literal, function call |
| Receiver shapes | monomorphic, 2-shape, 4-shape, shape transition before loop |
| Fields per iteration | 1, 2, 4, 8 |
| Loop form | single counted loop, current nested loop, stable local bounds, non-zero start |
| Storage | inline existing slot, wide/overflow object |
| Receiver kind | anonymous object, class instance, class-id-zero plain object |
Add lightweight deterministic observability for compile-time rejection reasons
if needed. It must be disabled by default and must not add work to production
hit paths. A test-only classifier or compiler artifact is preferable to
runtime logging in the hot loop.
Post the completed matrix and flame/sample evidence to this issue before
choosing the first generalization. Optimize the highest-volume sound miss, not
the most convenient matcher case.
Candidate implementation slices
These are ordered by likely leverage, but measurements may reorder them.
A. Root the evaluated PutValue reference across safepointing RHS expressions
The static PIC currently rejects allocating/calling RHS expressions because
JavaScript evaluates the target/key reference before the RHS, and GC could
move an unrooted target held only in SSA. Introduce an explicit, correctly
scoped root/reference temporary so target and key survive RHS evaluation
without being re-evaluated. Then extend the PIC only where evaluation order,
exceptions, and write result semantics remain identical.
Required proof:
- target, key, and RHS are evaluated exactly once and in spec order;
- thrown RHS leaves no store;
- target movement under forced moving GC is handled;
- pointer-capable values take the layout-note/string-alias/write-barrier path;
- no root leaks across loop backedges or function exits.
B. Generalize the numeric whole-loop proof
Replace the hard-coded "exactly two fields" representation with a bounded
descriptor for N static numeric own slots. Candidate initial bound: 1–4
fields, justified by code size and benchmark data. Extend one loop-shape
dimension at a time (for example, a single counted loop before variable
bounds).
The successful clone must remain finite and completely call/GC/safepoint-free
while it holds raw array/object pointers. If a proposed expression can call,
allocate, throw, invoke coercion, or observe user code, it does not belong in
this clone without a different replay/rooting design.
Avoid allocating a descriptor or interning keys per iteration. The preflight
may do bounded work once; the fast body should contain only receiver loads,
numeric operations, and direct stores.
C. Stable dynamic-key PIC
If the matrix shows o[k] is the dominant remaining gap, add a cache that
also validates key identity. Start with an already-interned, loop-invariant
string key. Do not put the global intern-table mutex or UTF-8/key conversion
back on the per-hit path.
Any cache design must distinguish (shape token, key, slot), preserve symbol
and non-string PropertyKey semantics via fallback, and remain correct across
shape transitions and GC movement.
D. Small polymorphic write PIC
If 2–4 stable shapes dominate real misses, consider a bounded polymorphic
cache. Keep code size bounded and measure branch/I-cache costs against the
monomorphic PIC. Do not replace the never-reused/discriminated ShapeId token
with a raw address token susceptible to reuse/ABA.
Load-bearing correctness invariants
Do not weaken these to make a benchmark hit:
- Preserve full strict/sloppy
[[Set]]behavior and the original fallback. - Preserve target → key → RHS evaluation order, side effects, exceptions, and
assignment result. - Recheck mutable receiver state on every PIC hit: frozen, sealed,
non-extensible, descriptors, typed-layout intact, forwarded state, receiver
kind, and slot bounds. - Accessors, inherited setters, proxies, handles, native modules, class
objects, arrays/typed arrays, primitive receivers, and target/receiver
mismatches must retain their semantic paths unless separately proven. - Use the read/write PIC's discriminated, never-reused ShapeId token rules.
Shared keys pointers are valid only under their existing lifetime proof. INLINE_SLOT_FLOORis 4, not 8. Never over-read small object
allocations.- Pointer-capable direct stores must continue through
emit_jsvalue_slot_store_scalar_aware_on_blockor an equivalently complete
layout-note/string-alias/write-barrier implementation. - Raw numeric stores may omit barriers only when the compiler and runtime
jointly prove pointer-free storage/value semantics. - A whole-loop clone may retain raw pointers only while it contains no call,
allocation, safepoint, barrier helper, or side exit. - Persistent shadow-root barrier activity is an atomic runtime global. Keep
the sequentially-consistent LLVM atomic load introduced by perf: beat Node on object-write micro (#6809) #6811; volatile
is not a substitute for atomic access. - Every new raw generated heap/global store needs a justified
GC_STORE_AUDIT(...)marker and must pass
scripts/gc_store_site_inventory.py. - Typed-feedback/verification/diagnostic modes and explicit inline-fast-path
disable gates must force the appropriate semantic path.
Correctness matrix
Every optimized slice needs Node/Perry output parity and focused regression
coverage for:
- own writable data fields;
- readonly/non-writable own fields in strict and sloppy code;
- own and inherited accessors/setters;
- frozen, sealed, and non-extensible receivers;
- delete/re-add and shape transition before/during the loop;
- mixed shapes, holes, short arrays, duplicate target keys, and bounds edges;
- proxies, handles, native modules, class objects, and receiver != target;
- pointer values, young-to-old edges, incremental marking, and forced moving-GC
rewrite/evacuation; - typed-layout intact and downgraded objects;
- array-index/prototype descriptor invalidation;
- allocating/throwing RHS and observable target/key/RHS evaluation order;
- fast-path disable, typed-feedback, and verification environment modes.
Keep the semantic fallback in the generated-IR tests. A test that only proves
the fast block exists is insufficient.
Acceptance criteria
- Baseline matrix and rejection reasons are posted before implementation.
- The canonical perf: beat Node on object-write micro (#6809) #6811 micro remains correct and shows no material
regression (target: within 10% on the same machine/protocol). - Each newly supported matrix cell has:
- a paired eligible and forced-fallback test;
- generated-IR evidence for the intended guard/hit/miss structure;
- executable Node/Perry parity;
- before/after alternating-run measurements. - Call-free monomorphic existing-field loop cells target Node parity or
better. Other generalized cells must show a meaningful measured win
without slowing established paths; do not claim "across the board"
without matrix evidence. - Numeric whole-loop hit bodies contain no calls, allocations, barriers,
coercions, or side exits. - Pointer-capable PIC hits retain complete GC/layout/string-alias handling.
- No new untriaged gap-suite failure, compile failure, or crash.
- Fast-path code-size growth is measured and bounded.
Validation gates
At minimum for every implementation PR:
cargo fmt --all -- --check
git diff --check
python3 scripts/gc_store_site_inventory.py --self-test
python3 scripts/gc_store_site_inventory.py
cargo test -p perry-codegen --test native_proof_regressions
cargo test -p perry-codegen --test shadow_slot_hygiene
cargo test -p perry-runtime --lib -- --test-threads=1
./scripts/run_gap_tests.shAlso compile and execute the benchmark/adversarial corpus with the release
compiler/runtime/static archives. CI must pass clippy, compiler-output
regression, Windows, and the conformance shards.
Existing tests to extend
crates/perry-codegen/tests/native_proof_regressions.rsstatic_put_value_uses_write_pic_for_call_free_rhsstatic_put_value_rejects_write_pic_when_rhs_can_allocatenested_same_shape_object_writes_version_the_whole_loop
crates/perry-codegen/tests/shadow_slot_hygiene.rsimmutable_index_alias_binds_once_but_keeps_incremental_root_barrier
crates/perry-runtime/src/proxy.rsobject_array_numeric_write2_guard_requires_complete_uniform_proof
crates/perry-runtime/src/gc/tests/barrier.rs- incremental-mark active-count coverage
Session-start checklist
- Read this issue, Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759, Object-op hot paths: TLS fetch density (~50% of write samples) + hot-path mutex — Phase A acceptance completion (#6759) #6809, and merged PR perf: beat Node on object-write micro (#6809) #6811.
- Create a fresh isolated worktree/branch from current
origin/main; confirm
it contains20f19758f. - Use an isolated Cargo target directory. Check that no old build/benchmark
process is still running. - Reproduce the canonical 5 ms Perry / 7 ms Node result before changing code.
If it does not reproduce, diagnose the baseline first. - Build and post the coverage/rejection matrix.
- Choose one measured slice, keep its PR narrow, and include a changelog
fragment plus benchmark/test evidence.
Related work
- Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 — object-model architecture and original acceptance measurements
- Object-op hot paths: TLS fetch density (~50% of write samples) + hot-path mutex — Phase A acceptance completion (#6759) #6809 — TLS/mutex floor and object-write campaign (closed by perf: beat Node on object-write micro (#6809) #6811)
- perf: beat Node on object-write micro (#6809) #6811 — runtime fast paths, write PIC, narrow whole-loop versioning, tests,
and the atomic shadow-root guard fix - perf(GC): make per-object layout O(1)-loadable — kill per-operation thread-local layout tracking (umbrella: method_calls/array-downgrade/object-property) #5094 — per-object layout and write-barrier/layout-tracking costs
This issue is complete when the supported object-write coverage is documented
by the matrix, the highest-value safe gaps have landed, and the remaining
fallback categories are explicitly justified by semantics or measured lack of
benefit.
Activity
proggeramlug commentedon Jul 23, 2026
#6812 pre-implementation coverage baseline
Collected on 2026-07-23 before changing any compiler or runtime source.
Provenance and protocol
origin/maincommit20f19758f370f7616530824218d87f46a67cd3f0, which is the required perf: beat Node on object-write micro (#6809) #6811merge.
perf/6812-object-write-generalization./private/tmp/perry-6812-target.qyHg3D.the repository release/LTO profile.
PERRY_NO_AUTO_OPTIMIZEnor a codegen-unit override was set.Node-then-Perry pairs. The runner compared
(writes, checksum)after everypair and retained all samples without outlier filtering.
definitive capture began. Sporadic OS/UI interruptions remain visible in
the raw data instead of being discarded.
The exact historical canonical source produced:
write_mssamples[7, 8, 7, 7, 8, 7, 8, 8, 7, 7, 8, 8, 8, 8, 7][5, 6, 6, 5, 6, 6, 6, 5, 6, 6, 6, 6, 5, 6, 6]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
o.xo["x"]o[k]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, andjs_object_array_numeric_write2_guard. The timed loop was also inspectedseparately when its function contains setup writes.
generic,PIC,clone)o.xo["x"]o[k]Expr::String.class_id != 0eligibility guard.The key measured discontinuity is the bounded static numeric nest:
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 doesnot 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 reachedtry_existing_own_data_overwrite, and 108 were underruntime_store_jsvalue_slot, with further frames in layout-note and writebarrier work. The addresses were resolved against these linker-map ranges:
For the four-field static cell, 441 of 448 post-startup runnable samples
(98.4%) were directly in the inlined
mainbody, 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
maintext contributionAll raw matrix samples and checksums
key_dot122876400[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_literal122876400[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_dynamic122876400[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_dynamic31194000[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_numeric122876400[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_pointer40800[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_allocating22076400[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_call110876400[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_monomorphic122876400[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_two98876400[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_four62876400[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_loop122876400[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_one122876400[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_two239995200[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_four383990400[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_eight383980800[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_counted287997118800[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_nested239995200[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_bounds191995200[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_start191995200[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_inline122876400[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_overflow122876400[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_anonymous122876400[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_instance122876400[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_zero122876400[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]proggeramlug commentedon Jul 24, 2026
#6812 bounded object-write generalization: final evidence
This implements measured slice B from the pre-implementation matrix: generalize the #6811 whole-nest proof from exactly two stores to a bounded one-through-four static numeric field descriptor.
Implementation and safety boundary
INLINE_SLOT_FLOOR = 4bounds, and intact typed descriptors before returning four packed 16-bit slot lanes.INTERNEDmade otherwise identicalx/yfields miss depending on unrelated interning.js_object_array_numeric_write2_guardexport and its 32-bit lane ABI remain available for cached generated objects; new code uses the generalized guard.PERRY_TRACE_OBJECT_ARRAY_WRITE_GUARD=1prints a deterministic cold rejection reason. Successful guards do no environment lookup or logging.The generated fast stores retain
GC_STORE_AUDIT(POINTER_FREE), and function-local IR inspection found zero calls in all 16 generated fast inner blocks.15-pair executable measurements
Same clean worktree/host/isolated release target/default auto-optimization as the baseline. One warmup, then 15 strict Node-then-Perry pairs per cell. Every pair matched write count and checksum.
Raw alternating samples:
fields_one122876400[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_two239995200[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_four383990400[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_eight383980800[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 retained its baseline median:
write_mssamples[8, 7, 8, 8, 8, 8, 9, 9, 7, 8, 7, 7, 7, 8, 8][6, 5, 6, 6, 6, 6, 5, 7, 6, 6, 5, 6, 5, 6, 6]The new one- and four-field cells beat Node under the required protocol. The established two-field/canonical paths show no material regression, while the eight-field forced fallback remains unchanged.
IR and code size
Definitive uncached matrix IR:
fieldsOne: one generalized guard / one fallback PICfieldsTwo: one generalized guard / two fallback PICsfieldsFour: one generalized guard / four fallback PICsfieldsEight: no generalized guard / eight fallback PICsmaintextA final one-pair sweep of all 25 matrix cells had 25/25 Node/Perry checksum matches. Dynamic/safepointing, polymorphic, wide-storage, variable-bound, eight-field, and class-id-zero cells stayed on their intended prior paths.
Validation
cargo fmt --all -- --checkgit diff --checkpython3 scripts/gc_store_site_inventory.py --self-testcargo test -p perry-codegen --test native_proof_regressions: 248 passedcargo test -p perry-codegen --test shadow_slot_hygiene: 10 passedcargo test -p perry-runtime --lib -- --test-threads=1: 1,460 passed, 3 ignored--all-targets -D warningsrun is currently blocked by existing lints in untouched build/dependency/test files.PERRY_DISABLE_CLASS_FIELD_INLINE=1,PERRY_TYPED_FEEDBACK=1, andPERRY_VERIFY_TYPED_INTACT=1Gap gate OKThe worktree contains the reusable benchmark runner, immutable baseline, final raw evidence, focused Rust/IR regressions, adversarial gap corpus, and changelog fragment.
proggeramlug commentedon Jul 24, 2026
Coverage/rejection matrix (issue step 5) — measured triage pass on
main@8bda0351e, node v26.3.0/arm64, medians of 3 alternated runs, identical checksums on all 18 cells. Full table + code-gate provenance:docs/object-write-matrix.md(committed onperf/6812-write-matrix); raw samples preserved. Highlights:Beating node already (5/18): the whole-loop-clone family (canonical 0.67×, one-write 0.71×, three-writes 0.78×, stable-dynkey 0.75×) and class instances via the PIC (0.50× — best row).
Top gaps, ranked by breadth:
{}+ N assigns): 186× —class_id==0blocks the PIC and every assign is a generic transition. The single most ubiquitous real-world write shape.Near-node, deprioritized: megamorphic 8-shape (1.4×), allocating RHS (1.4× — much better than the discarded prototype implied).
Proceeding per issue step 6 with the top slice: the builder pattern (fresh-object transition writes). 15-run idle-protocol evidence will accompany each PR.
proggeramlug commentedon Jul 25, 2026
First matrix slice is up: #6829 — builder-sequence literal fold. Worst row (builders, 186×) → 25.4× vs node (15-run medians on the PR); zero matrix regressions, gap gate OK, all suites green. Next slice per the ranking: the clone-eligibility family (mul-RHS / dynamic bound / while / mutable alias — 4 rows, one matcher generalization) targeting the 0.7× class their siblings occupy.
proggeramlug commentedon Jul 25, 2026
Slice 2 up: #6830 — clone-eligibility widening (Mul RHS / let alias / while form). Three rows move to 0.62–0.75× (beating node); 18-cell sweep regression-free, gap gate OK. 8 matrix rows now beat node. Next: dynamic inner bounds (w4, 6.25×) — needs the loop-invariant runtime bound extension to the versioned emitter; then the overflow-slot row (153×).
perf(codegen,runtime): #6812 — dynamic array-length inner bounds for …
perf(codegen): #6812 — widen whole-loop write-clone eligibility (Mul …
fix(hir): #6812 — fold values must be provably non-executing (CodeRab…
perf(hir): #6812 — fold straight-line builder sequences into object l…
test(codegen): repair two stale e2e assertions (pre-existing on main)
perf(object): #6812 w16 — per-site builder identity, learned width, a…
perf(codegen): #6812 w8/w13 — clone inlined-helper bodies, integer st…
perf(runtime): #6812 — object-owned overflow storage (spill buffer) r…
13 remaining items
proggeramlug commentedon Aug 14, 2026
Status audit against
origin/main@514ff7c9a— what landed, what is left, and what in this issue is now false1. The required first step was completed, and the matrix is a maintained artifact
docs/object-write-matrix.mdwas created on main by #6829 and has been updatedby every slice since; last touched
1bfd41171(#8026, 2026-08-13).2. Twelve commits of coverage work landed under this issue
Both items the last comment named as "next" landed. Dynamic inner bounds
(w4) is easy to miss: PR #6830's merge commit
08dab1e0dis not an ancestorof main — #6829's squash swallowed it (
git log -S 'dyn_len_source'→9b15dc1e2). Overflow-slot (w16) landed across #6833 / #6849 / #6856,173x → 0.26.
Twelve un-folded
changelog.d/6812-*fragments are on main.3. What the two implementation sites accept today
Both functions named in the issue body were renamed
(
…write2…→…write…); the oldjs_object_array_numeric_write2_guardsurvivesonly as an ABI shim (
proxy/put_value.rs:1146).Whole-loop clone — matcher
stmt/loops.rs:2369, emitter:2879.Bounds:
MAX_OBJECT_ARRAY_WRITE_FIELDS = 4(:2043),MAX_OBJECT_ARRAY_WRITE_GROUPS = 2(:2118),MAX_OBJECT_ARRAY_WRITE_TEMPS = 8,MAX_…_NUMBER_NODES = 64.Now accepted beyond the issue's list: dynamic
xs.lengthinner bound(
:2415-2444),whileinner form (:2460-2491),letaliases (:2518),two groups,
Mul/Modin the RHS grammar,const k = "c"and constant-integerkeys (
:2722,:2729), the w12 key-table lane (:2643-2712), and afirst-iteration peel (
:2893-2937).Still rejected: non-zero inner start (
:2495), the single non-nested countedloop (
:2382-2491), a dynamic outer bound, >4 fields, >2 groups, division,non-finite intermediates.
Static write PIC —
expr/proxy_reflect.rs:426, missproxy/put_value.rs:277.Now 4 inline + 4 outlined ways (
STATIC_PIC_TAIL_WAYS = 4) with anon-evicting tail; 9th shape falls to full
[[Set]]. New sibling: a 3-waydynamic-key IC (
js_put_value_set_dyn_ic,:474).Still requires
class_id != 0andidx < max(field_count, INLINE_SLOT_FLOOR)—overflow/spill slots remain PIC-ineligible (
:392,:555); only the loopclone learned spill lanes.
4. ⚠ A load-bearing invariant in this issue's body is now FALSE
It is 2 since
f110261a4(#7928, 2026-08-12, "right-size small objects —INLINE_SLOT_FLOOR 4 → 2"):
perry-codegen/src/target_layout.rs:71,perry-runtime/src/object/mod.rs:49.The PIC's
alloc_limit(put_value.rs:390) and the loop guard'sfirst_limit/limit(:927,:984) both derive from it, so a 3rd/4th slot ona small object now falls outside inline storage and depends on the spill path.
No matrix cell was re-measured after #7928. A stale comment also survives at
perry-runtime/src/object/tests.rs:625.5. What is genuinely still open — and nothing else owns it
I checked all 69 open issues and all 12 open PRs: no other issue or PR covers
overflow-slot writes, dynamic loop bounds, integer keys, the builder pattern,
w15's residual, w10's post-#8026 gap, slice A, non-zero inner start, or the
single-counted-loop form. This issue is the sole owner of all of it, and every
acceptance checkbox is still unchecked.
w15_append_build— the builder residual. perf(hir): #6812 — fold straight-line builder sequences into object literals #6829 took it 186x → 25x andthe row says "residual tracked below", but nothing tracks it. There is an
abandoned branch on origin,
perf/6812-w15-anon-init@98fb60d17(2026-07-26, PR never opened), whose commit message records the profile:
"w15 profile: guard+contract 27, hashmap 17, layout notes 20 of 176
samples" — i.e. the residual is allocation-side (class-field guard
revalidation + per-slot layout notes in the synthesized anon-shape
constructor), not write-side. That is perf(GC): make per-object layout O(1)-loadable — kill per-operation thread-local layout tracking (umbrella: method_calls/array-downgrade/object-property) #5094/perf(gc): layout side tables are 34% of object construction — the construction/death half of #5094 (allocation is 7.7%) #7510 territory, and most of it
has been re-worked since (perf(gc): skip the per-object layout side tables while they are provably empty (#7510) #7525/perf(codegen,gc): declare a class's typed-shape layout at allocation, not after its constructor (#7510 item 1, #7512) #7532/perf(gc): make a repeat typed-shape construction a header bit-set (#7510) #7535/perf(runtime): reach the hot-TLS cache without _tlv_get_addr (#7469 structural half) #7565/perf(gc): stop re-deriving compile-time mask facts on every construction (#7578) #7586/perf(codegen): stamp a pointer-free shape's typed layout into the allocation header #7834), so the 25x
number is very likely stale.
w10_poly8≈ 3.4x. From perf: settle eight-shape static write PICs #8026's own PR body, on its new 60M-writeshape_eightcell: Node 226 ms vs Perry 768 ms (down from 2,359 ms).The matrix's "1.4" is a pre-tail small-cell number that understates it.
landed.
expr/proxy_reflect.rs:441-448still carries the originalrejection verbatim — "Until PutValue reference temporaries have dedicated
GC roots…" — and
proxy/put_value.rs:465records the safepointing-RHSapproach as tried and abandoned. Worth revisiting now that the Layer-1
rooting-by-construction API exists (Layer 1 campaign: migrate perry-codegen's lowerings onto the rooting-by-construction API (88 modules, 262 hazard sites) #7615). This is w17 (1.4x) plus the
baseline matrix's
rhs_callandrhs_allocatingcells.rejected: non-zero inner start (
loops.rs:2495; 7.3x at baseline) and thesingle non-nested counted loop (
loops.rs:2382; 13.8x at baseline).6. The matrix numbers are stale as evidence
Measured on
8bda0351e(2026-07-24) against Node 26.3.0 — the repo now pins26.5.1 — with 3-run medians at ambient load ~7. The raw CSV the header cites
(
matrix_raw_2026-07-25.csv) is not committed, and the w-cell harness wasnever committed:
benchmarks/object-write-6812/matrix.tsis the older 26-celldimension matrix and has no w15/w16 cells. The doc's prose is also internally
stale — "Two catastrophic rows (~180x) … the builder pattern and wide-object
overflow-slot writes" sits directly above a table where w16 reads 0.26.
7. Fresh 26-cell matrix on current main (v0.5.1510, Node 26.5.1)
Using the committed harness,
benchmarks/object-write-6812/{matrix.ts,run_matrix.py},--samples 3 --warmups 1, against a compiler and both static archives builtfrom a clean worktree at
514ff7c9awithPERRY_RUNTIME_DIRpinned and thedefault auto-optimizing pipeline. The runner verifies
(writes, sink)on everyNode/Perry pair and aborts on mismatch; it ran to completion, so all 26 cells
are output-identical. The host is contended, so read ratios, not absolutes.
Against the pre-implementation baseline posted on this issue:
rhs_allocatingshape_twostorage_overflowshape_fourrhs_callloop_single_countedfields_eightrhs_pointerloop_nonzero_startkey_alternating_dynamicreceiver_class_id_zero8. ★ The biggest remaining gap is
JSON.parsereceivers, and it is a 55xintra-matrix difference at an identical write count and checksum
receiverClassIdZero(matrix.ts:501-515) differs fromkeyDotin exactly oneway: its 2400 receivers come from
JSON.parse('{"x":0}')rather than an objectliteral. Same 120,000,000 writes, same
sink:9560 ms vs 173 ms. A
JSON.parse-produced object hasclass_id == 0, whichdisqualifies it from the whole-loop clone (the guard's per-element validation)
and from the static write PIC (
proxy/put_value.rs:337-341), so every one ofthe 120M writes takes the generic runtime path. Every API response, config blob
and ORM row in real code enters a program this way. This is squarely #8067's
subject ("make ShapeId authoritative for keys, live slots, and class-object
kind") and should be sequenced behind it rather than raced.
Worth separating from the doc's "builder pattern" framing, which conflates two
different things: the HIR builder fold (#6829) works — a straight-line
{}+ 6 static assigns over 2M escaping objects measures at node parity(node 820/791/874 ms vs Perry 859/827/687 ms,
sink 38000100identical) — whileclass_id == 0receivers still cost 55x, andJSON.parserather than{}+ assigns is how you get one today.9. Recommendation
The closing condition in the body is "complete when the supported object-write
coverage is documented by the matrix, the highest-value safe gaps have landed,
and the remaining fallback categories are explicitly justified." The first two
are met — the matrix is a maintained in-repo artifact and twelve commits landed
against it, taking four cells from 27–94x to 1.4–4.6x. The third is not: the
twelve non-beating cells above are unfinished, not justified.
Given the repo's preference for granular tickets over umbrellas, I'd suggest
closing this one and carrying the residue as focused issues with the numbers
above:
class_id == 0receivers (JSON.parse, 78x) — sequence behind feat(object/shape): make ShapeId authoritative for keys, live slots, and class-object kind before #8047 #8067.rhs_call18.3x,
rhs_pointer9.2x,rhs_allocating4.6x. The rejection comment atexpr/proxy_reflect.rs:441-448predates the Layer-1 rooting-by-constructionAPI (Layer 1 campaign: migrate perry-codegen's lowerings onto the rooting-by-construction API (88 modules, 262 hazard sites) #7615), so its stated blocker may no longer hold.
loop_single_counted13.2x andloop_nonzero_start7.0x, both one matcher rule each(
stmt/loops.rs:2382,:2495), both flat since the baseline.fields_eight11.8x — the bounded 1..4-field descriptor rejects at 5+.shape_two4.5x /shape_four3.9x /shape_eight3.5x after perf: settle eight-shape static write PICs #8026.
Two housekeeping items for whoever picks this up:
INLINE_SLOT_FLOORinvariant in the body is false (see §4); no cellwas re-measured after perf(runtime): right-size small objects — INLINE_SLOT_FLOOR 4 -> 2 (#7916, #7714) #7928 halved it.
docs/object-write-matrix.mdneeds a refresh. Its numbers are from8bda0351eagainst Node 26.3.0 at ambient load ~7; its prose still callsoverflow-slot writes catastrophic directly above a table where they read 0.26;
its raw CSV is not committed; and the w-cell harness it describes was never
committed, so only the 26-cell dimension matrix above is reproducible.
proggeramlug commentedon Aug 14, 2026
Filed the largest of the remaining rows as #8098 —
JSON.parsereceivers carryclass_id == 0, which rejects them from both the whole-loop clone and the static write PIC (55x at an identical write count and checksum). It is sequenced behind #8067 rather than racing it, since the fix is to give parsed objects a real ShapeId, not to relax theclass_id != 0guards.proggeramlug commentedon Aug 14, 2026
Second successor filed: #8108 — slice A (root the PutValue reference across a safepointing RHS), which covers
rhs_call18.3x,rhs_pointer9.2x andrhs_allocating4.6x in one mechanism. Its stated blocker ("until PutValue reference temporaries have dedicated GC roots") predates the Layer-1 rooting-by-construction migration (#7615), so the first step is to re-test the premise rather than design around it.I am deliberately leaving this issue open. The measurement is decisive that it is not finished: 12 of 26 cells still lose to Node, and four of them (
loop_single_counted13.2x,fields_eight11.8x,loop_nonzero_start7.0x,key_alternating_dynamic3.5x) are statistically flat against the 2026-07-23 baseline. Those are unfinished, not justified, which is the third clause of this issue's own closing condition. With #8098 and #8108 carrying the two biggest families, what remains here is the clone's loop-shape and field-count coverage plus polymorphic receivers (shape_two4.5x /shape_four3.9x /shape_eight3.5x) — a reasonable scope for this ticket to be narrowed to, if you'd rather re-title it than close it.perf(object/json): admit JSON.parse receivers to the object-write fas…
perf(object/json): admit JSON.parse receivers to the object-write fas…
perf(codegen): put the static write PIC's GC bookkeeping behind one l…
proggeramlug commentedon Aug 18, 2026
Closing this umbrella after the object-write implementation series and the final #6821 review follow-up merged. The two largest residual families were split into focused successors and are complete: #8098 (JSON.parse receivers, landed via #8118) and #8108 (safepointing RHS, landed via #8183). The maintained matrix and historical measurements remain in-repo; any further loop-shape, field-count, or polymorphism work should use narrowly scoped follow-up issues.
fix(hir): fold a builder whose stores are separated from its `{}` (Pe…
fix(hir): fold a builder whose stores are separated from its `{}` (#1…
refactor(hir): split builder_fold.rs width-hint scan into its own file
feat(runtime): object dictionary mode — a receiver can carry its own …
feat(runtime): object dictionary mode — a receiver can carry its own …
feat(runtime): object dictionary mode — a receiver can carry its own …
feat(runtime): object dictionary mode — a receiver can carry its own …