Skip to content

repsel row 1: scoping Ptr<Shape> beyond locals — measured, promotion is 0 on the motivating workload; build --opt-report (#6952) first #7034

Description

@proggeramlug

Scoping + design for row 1 of the runtime-helper map: extending Ptr<Shape> beyond locals. Measurement done, unknowns named, nothing landed.

Host for every number below: M-series macOS arm64, perry-dev profile, worktree at c6ed8175d. Load average was 7.57 for the whole session (parallel agents building), so no timing measurement was taken — every number here is either a static count or a binary size, both load-independent. See §8.


0. TL;DR — the brief's framing needs one correction before the plan makes sense

The task is framed as extend a working representation to new positions. Measured, that is not the situation:

corpus files files with ≥1 Ptr<Shape> local promoted locals
benchmarks/suite/*.ts (first 12) 12 3 1 each
batch.ts — the object/property-heavy program from the helper map 1 0 0

PERRY_REPSEL_DEBUG=1 on batch.ts prints exactly one repsel line, and it is a Phase 3a canonical-Str local (reportText), not a shape. Independent confirmation: PERRY_PTR_SHAPE_LOCALS=0 vs default produces byte-identical output (4,294,904 bytes, __text 3,804,424 both) — there is nothing to turn off.

So Ptr<Shape> today promotes on micro-benchmark-shaped code (one object, one contained local) and promotes nothing on the workload that motivates the whole row. The first step is therefore not "add positions" — it is "make the proof survive an escape", because every realistic record escapes. That is the same work the brief asks for (params/returns/fields are the escapes), but it changes which step comes first and what each step should be measured against.


1. The concrete blocker at parameters

It is not the ABI, not the collision guard, and not "nobody wrote it". Two distinct things:

(a) #6925 is not an ABI change, and that is the key insight. collectors/proven_this.rs does not give this a new machine representation. It emits an internal {public}__pshape clone of the method whose this carries the PtrShapeLocal proof, and routes two already-proving call sites to the clone. Its own header states the ABI is unchanged ((double this, double args…)), the shadow-bound tagged-at-rest receiver slot is unchanged, and "net new proof work: zero". The mechanism is monomorphic specialization by cloning, with representation as a compile-time fact attached to a clone.

So there is no ABI work needed for params either. What is missing is proof propagation.

(b) Rule 2 containment kills the object in the caller before the callee could benefit. collectors/ptr_shape.rs §2 lists the disqualifiers explicitly:

every use of the local is a declared-chain field read/write/update or a vetted method call. Any other use — reassignment, closure capture, call argument, array/object element, return, throw, delete, freeze/seal, aliasing — disqualifies.

this.m() survives only because rule 3 walks the callee body under a this-usage discipline — an inlined-style summary, not a cross-function representation contract. There is no mechanism by which a fact about an argument at a call site becomes a fact about a parameter in a callee.

Therefore "params" concretely means: generalise #6925's clone-and-route from the receiver position to argument positions, and relax rule 2's call argument disqualifier to "argument of a direct call to a callee for which a __pshape clone exists and whose other call sites agree". The proof obligation that replaces containment-by-non-escape is agreement across all call sites of the callee.


2. Module-wide barrier — quantified. The hypothesis is wrong for app code and right for dependencies.

Rule 5 kills all promotion in a module on any of Object.define{Property,Properties}, Reflect.defineProperty, Object/Reflect.setPrototypeOf, __proto__ write, Reflect.set, Reflect.deleteProperty, Reflect.preventExtensions, plain delete, or new Proxy — targets not inspected.

Source-level scan (a proxy for the HIR variants in expr_is_shape_barrier; per file, since the kill is per module):

corpus files hit a barrier rate
benchmarks/suite/*.ts 30 0 0%
benchmarks/app-patterns/kernels/*.ts 11 0 0%
test-files/test_gap_*.ts 435 43 9%
test-files/*.ts (all) 1133 70 6%
real dependency JS (scriptc/node_modules, 600-file random sample of 1056) 600 315 52%

Breakdown on the dependency sample: defineProperty 308, delete 9, new Proxy 7, setPrototypeOf 2, Reflect.set 1. The 308 are overwhelmingly the transpiled-CJS interop marker (Object.defineProperty(exports, "__esModule", …)).

Conclusion. The brief's guess that the barrier is "probably the largest silent loss on real code" does not hold for hand-written TypeScript — it never fires on the benchmark corpus. It fires on ~half of compiled npm dependency files, essentially always for one mechanical reason. So barrier-narrowing is worth exactly as much as the volume of dependency code a build compiles (perry.compilePackages), and nothing at all for a pure-TS app.

Second, sharper point: narrowing the barrier would buy zero on batch.ts today, because promotion there is already zero for the unrelated containment reason in §1. Barrier work cannot be validated until containment work lands. That ordering is not optional.

Cheapest useful narrowing, when it is time: exempt the single defineProperty(exports, "__esModule", …) idiom (constant key on the module's own exports, no aliasing) — one pattern, and on this sample it is ~98% of dependency-side barrier hits.


3. Fields — how far the typed heap reaches, and the gap

#6893/#6963 give a real substrate: TypedLayoutDescriptor { slot_count, raw_f64_mask, pointer_mask }, shared per shape in SHAPE_LAYOUTS keyed by keys_array, plus GC_OBJ_TYPED_LAYOUT_INTACT (bit 12 of GcHeader._reserved). Per gc/layout.rs, the payoff is already substantial: with a class_id/keys_array match, the codegen-inlined class-field guard can conclude "slot K is raw-f64" from that single bit — no cross-crate guard call, no thread-local probe — and the bit survives copying/evacuating GC because the collector copies the whole reserved word.

Three things stand between that and a direct offset load/store with no guard:

  1. It is still a guarded fast path. The site tests class-id + keys token + the intact bit. Row 1's contribution is a static provenance proof that removes the test, not a better test.
  2. Eviction is permanent and one-way. layout_note_slotlayout_set_typed_unknown evicts on any representation change; barrier.rs documents that one FFI integer used to cost an object its fast path forever (since mitigated by canonicalising INT32 boxes). A statically-proven, guard-free site must be provably unreachable for objects whose descriptor can be evicted — otherwise the proof outlives its premise. This is the single largest correctness question in the fields work and it is not answered yet.
  3. Field-of-record vs record-in-field are different problems. TYPED_LAYOUTS stores per-class-constant layout masks per OBJECT — O(objects) memory (272MB on churn bench) + hashmap insert on every new #6893 types the slots of an object. Row 1's fields case is a record stored in a field of another object, which additionally needs the outer object's shape proven — i.e. it composes on top of §1's work, not beside it.

4. Returns — cheapest of the three, and the one batch.ts needs most

Least discussed, but the .map(x => ({ key, value })) idiom is the most common record-producing shape in real TS and is exactly what batch.ts does. A narrow version is tractable:

For a function/arrow whose every return is a new C(...) or closed object literal of the same shape, and which is not exported, not reassigned, and not captured as a value, attach a return-shape fact. A caller then treats the call result with the same provenance strength as a new site — which is precisely what rule 1 already accepts.

Worth doing separately: yes, and arguably first among the three positions. It is a single-function local analysis (no cross-call-site agreement, unlike params), it needs no ABI or clone machinery, and it unlocks the array-of-records pipeline that params/fields later build on.


5. Phasing — each step independently landable and measurable

Deliberately starts with visibility and with the escape that needs no cross-function agreement.

phase change independently measurable by
P0 #6952 --opt-report (see §7) it is the measurement instrument; validate by reproducing the §0 table in one command
P1 Promotion census in the compiler-output-regression harness: promoted-local counts per corpus workload, recorded as structural evidence the census numbers themselves; guards against promotion silently going to zero (which is today's state and was invisible)
P2 Return-shape facts (§4): proven-shape-producing functions; relax rule 2's return disqualifier for them promotion count on .map(=>({…})) patterns; batch.ts diamond-block count
P3 Argument positions via generalised clone-and-route (§1b), narrowest first: direct calls to non-exported, non-reassigned callees where all call sites agree on the argument's class promotion count; diamond blocks in the sort-comparator/callback clones
P4 Array-of-records element reads (sorted[0].key) — the Phase 4a Ptr<NumArray> analogue for object elements diamond blocks at element-read sites
P5 Fields — records stored in fields; requires §3.2 eviction question answered first promotion count on nested-record workloads
P6 Barrier narrowing__esModule idiom exemption only; gated on dependency-compilation volume mattering dependency-corpus promotion rate (§2), which is currently unmeasurable because promotion is 0

P2 and P3 are the two that move batch.ts. P0/P1 are cheap and make every later step falsifiable.


6. GC contract for every new site that would hold an object pointer

Standing rule applied throughout: a root buys three things — liveness, a location the collector rewrites on evacuation, and the value the call actually observed. Each site below justifies all three, not just liveness.

#7019 shipped default-on evacuating young-gen scavenge. Objects move in the shipped configuration now, so a raw object pointer held across a safepoint is a live bug, not a latent one.

  • Argument/param clone (P3). Keeps the double ABI and retains js_shadow_slot_bind on the callee-side argument slot, exactly as perf(codegen): repsel Phase 5a — Ptr<Shape> proven this in methods #6925 does for the receiver. Liveness: the slot is a scanned root. Rewrite: the collector marks and rewrites through the bound alloca (gc/roots/shadow_stack.rs). Observed value: every access re-derives the raw pointer from the slot, and because the slot address escapes to the shadow-stack registry, LLVM cannot CSE the reload across a safepoint. Explicitly not copied: TaPtr's callee-side no-bind shortcut. That is sound only because typed-array storage is non-movable; GC_TYPE_OBJECT is movable. fix(gc): typed-array constructor sources are precise roots (#6981) #6990 is required reading here — it found that shortcut's conclusion held while its stated reason named the wrong object (storage vs the view header), so the comments in codegen/function.rs / codegen/spec_abi.rs are the corrected source of truth.
  • Return position (P2). The callee returns a tagged value in a register. The caller must store it into a shadow-bound slot and bind before the next allocation or call, with no safepoint between the return and the bind. Liveness/rewrite/observed all come from the caller's slot; the register copy is dead immediately after the store. A returned raw pointer must never be the at-rest form.
  • Field read (P5) and array element read (P4). The pointer read out of a slot is region-local SSA only, re-derived per access from the outer object's bound slot; it is never kept across a safepoint. Write barriers are retained unchanged for pointer stores into fields/elements — a proven shape says nothing about whether the stored value is a pointer, and the old→young remembered set depends on the barrier.
  • Everywhere. Raw pointers stay region-local SSA; tagged-at-rest is preserved (ptr_shape.rs's existing contract). No new at-rest raw-pointer representation is proposed anywhere in this plan.

7. Recommendation: yes — build #6952 first

Not deference to the suggestion; direct evidence from doing this task. Establishing the single most important fact in this document — promotion is zero on the motivating workload — took six compiler invocations, an IR dump, a symbol-level size A/B, and a PERRY_REPSEL_DEBUG grep whose output format I had to discover. --opt-report answers it in one command.

Three concrete ways it changes this plan rather than merely documenting it:

  1. Every phase above is scored by "did promotion increase, and where did the proof still fail" — that is the report's output, so without it P2–P5 cannot be cheaply evaluated.
  2. It would have caught today's state — a shipped representation promoting nothing on realistic code — at the time it shipped. The brief cites two agents losing hours to the same blindness; this is a third instance, found by accident.
  3. It supplies the hotness ranking that §8 lists as unmeasured, which is the only way to know whether the 208 closure-body diamonds matter for speed or only for size.

Minimum useful version: per value — position (local/param/return/field), the rep it got, the rule that denied it (rule number from ptr_shape.rs), and enclosing-function loop depth as a hotness proxy. Actionability tiering can come later.


8. What I could not measure, plainly

  • Hotness / any time profile. Load average 7.57 the entire session; this campaign has twice recorded bogus levers from measuring under load, so nothing was timed. A sample-based profile of b_def was attempted and timed out. Every "hot" claim in the helper-map table remains unproven, including mine.
  • My own earlier claim needs narrowing. The table's "247 blocks, Highest — majority of emitted code" is a size statement. Static attribution: of 247 PIC/guard blocks in batch.ts, 39 are inside an explicit loop region and 208 are in closure bodies (perry_closure_batch_ts__3/__7/… — the map/sort/reduce callbacks). Those closures have no loop of their own and are invoked per element, so a loop-nesting proxy cannot rank them. The size claim stands; the speed claim is unresolved.
  • Bytes per diamond site. Intended to isolate it with PERRY_PTR_SHAPE_LOCALS=0/1 on batch.ts; the A/B is byte-identical because promotion is already zero. Needs a workload where promotion actually fires (07_object_create.ts, 12_binary_trees.ts).
  • Dependency barrier rate is a proxy. Only 23 dependency .js files exist in this worktree, so §2's 52% comes from scriptc/node_modules (1056 files, 600 sampled). Its dependency mix is not necessarily what a Perry-compiled app pulls in, and the scan is source-level regex, not HIR — it can over-count (a defineProperty inside a dead branch still counts, correctly for the current module-wide rule) and under-count (dynamic Reflect[m] forms).
  • Barrier scan is per file, the rule is per module. For multi-file modules the effective rate is at least the per-file rate; I did not resolve import graphs.
  • Not attempted: whether P3's all-call-sites-agree proof is decidable often enough to matter on real code. That is exactly what P0+P1 exist to answer before P3 is written.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions