Repository navigation
repsel: a Ptr<Shape>-proven numeric field still loads NaN-boxed — extend the raw-f64 field load past its scalar-replaced gate #7770
Description
The measurement
On main @ v0.5.1448, a Ptr<Shape>-promoted local — the case that already works — still coerces every numeric field read.
class P { constructor(public x: number, public y: number) {} }
function run(n: number): number {
const a: P[] = [];
for (let i = 0; i < n; i++) a.push(new P(i, i + 1));
let s = 0;
for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; }
return s;
}--opt-report confirms the promotion succeeds: local r -> Ptr<Shape> (in function run). And the by-name machinery is gone from the IR — no js_object_get_field_by_name_f64, no js_typed_feedback_class_field_get_guard, no js_throw_type_error_property_access.
But subtracting a build-only variant to isolate the read loop leaves:
js_array_get_f64 x1 <- separate issue, see "Related"
js_number_coerce x2 <- THIS ISSUE, one per field read
js_gc_loop_safepoint x1 <- by design (#7721)
Two declared-number fields, on a shape that is proven, read through a path with no dynamism left — and each one still pays a coercion, because the slot it loads from holds a NaN-boxed double.
Why this is small
The representation already exists on both sides:
- Runtime: typed shapes carry a
raw_f64_maskbeside the pointer mask (gc/layout.rs), and the GC already understands slots that hold raw f64 rather than NaN-boxed values. - Codegen:
typed_shape.rscomputesraw_f64_mask_wordsand declares it, andexpr/property_get/helpers.rs::lower_raw_f64_class_field_get_for_number_contextalready lowers a numeric field read to a raw load.
That function is gated on .scalar_replaced — objects proven never to escape — via scalar_replaced_field_is_raw_f64 and scalar_replaced_field_raw_f64_store_state.
Ptr<Shape> is a weaker condition that the collector already computes (collectors/ptr_shape.rs: provenance + containment). A Ptr<Shape> local's dynamic class is exactly C, which is precisely what the raw-f64 decision needs. The work is extending an existing gate to an existing proof, not building new machinery.
Note emit_element_shape_field_load (expr/element_shape_guard.rs, "run per access inside the fast clone") is the other place a proven field load happens — check whether the coercion in the measurement above comes from there rather than from the generic path, and fix whichever is live.
Acceptance criteria
- The read loop above emits zero
js_number_coerceforr.x/r.y. --opt-reportstill showslocal r -> Ptr<Shape>; the promotion must not be traded away for the coercion.- The store side agrees with the load side. A slot read as raw f64 must have been written as raw f64. A mismatch is a silent wrong value, not a crash — this is the direction the change can be quietly wrong in, and it needs a test that writes through every path that can reach the field (constructor,
this.x = v,obj.x = vfrom outside,Object.assign, a setter, adeletethen re-add). - The GC still traces correctly.
raw_f64_maskand the pointer mask must stay disjoint — a slot in both, or in neither, is either a missed root or a scanned number. RunPERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800over the reproducer and assert the[gc-zeal]verdict shows non-zerocopying_minorsandmoved_objects— a clean run that collected nothing proves nothing. - A field whose declared type is
numberbut which receives a non-number at runtime (Perry does not enforce declared types — CLAUDE.md, Known Limitations) must still produce Node-identical output. Cover:(p as any).x = "s",= null,= {},= 1n. - Protected floors hold on the pinned quiet mini, interleaved, best-of-N, output byte-verified against Node before timing.
Traps
--opt-reportis the instrument. It names the rule and the promoted local directly; do not infer from emitted calls.- Isolate the read loop by subtraction. Construction is ~12 calls and swamps the ~4 in the read loop. Build a build-only variant and diff the call multisets, as above.
PERRY_NO_AUTO_OPTIMIZE=1changes nothing here — verified identical IR with and without, so it is safe for iteration.- Build
-p perry -p perry-runtime-static -p perry-stdlib-staticand pinPERRY_RUNTIME_DIR, orperry compilelinks a stale.aand both arms of an A/B behave identically. - This is the second of three hops in the same mechanism. It does not depend on repsel: rule-1 provenance does not survive a function boundary — a typed
P[]parameter still reads fields by name (first-party half of #7152/#7170) #7766 — the symptom reproduces on the already-promoted local above — but repsel: rule-1 provenance does not survive a function boundary — a typedP[]parameter still reads fields by name (first-party half of #7152/#7170) #7766 is what makes it fire on code where the array crosses a function boundary.
Related
#7766 (rule-1 provenance, the hop before this one), and the element-fetch issue filed alongside this one (the js_array_get_f64 in the same measurement). Closed context: #7510 (layout side tables), #7149 / #7034 §3 (the element rule), #7480 / #7669 / #7701 (the element-shape guarded clone).
Activity
docs: record the #7770 bench-mini floor results in the changelog frag…
feat(repsel): group-wide numeric-field proof for element-shape groups (…
repsel: discharge the numeric-field proof at element-GROUP scope so p…
repsel: one safety gate and one write walker for both numeric proofs,…
perf(codegen): a reassigned numeric accumulator is a Number by constr…