Skip to content

perf: generalize guarded object-write fast paths beyond the #6811 two-field loop #6812

Description

@proggeramlug

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:

  1. 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.
  2. 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.
  3. 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 for loop containing exactly one constant
        counted inner for loop;
      • 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.
    • 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.

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

  1. 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
  2. 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.

  3. 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 reduce Date.now() quantization.

  4. 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_FLOOR is 4, not 8. Never over-read small object
    allocations.
  • Pointer-capable direct stores must continue through
    emit_jsvalue_slot_store_scalar_aware_on_block or 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.sh

Also 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.rs
    • static_put_value_uses_write_pic_for_call_free_rhs
    • static_put_value_rejects_write_pic_when_rhs_can_allocate
    • nested_same_shape_object_writes_version_the_whole_loop
  • crates/perry-codegen/tests/shadow_slot_hygiene.rs
    • immutable_index_alias_binds_once_but_keeps_incremental_root_barrier
  • crates/perry-runtime/src/proxy.rs
    • object_array_numeric_write2_guard_requires_complete_uniform_proof
  • crates/perry-runtime/src/gc/tests/barrier.rs
    • incremental-mark active-count coverage

Session-start checklist

  1. 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.
  2. Create a fresh isolated worktree/branch from current origin/main; confirm
    it contains 20f19758f.
  3. Use an isolated Cargo target directory. Check that no old build/benchmark
    process is still running.
  4. Reproduce the canonical 5 ms Perry / 7 ms Node result before changing code.
    If it does not reproduce, diagnose the baseline first.
  5. Build and post the coverage/rejection matrix.
  6. Choose one measured slice, keep its PR narrow, and include a changelog
    fragment plus benchmark/test evidence.

Related work

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime or compile-time performance

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions