Skip to content

codegen: a property store from a constant-valued local loses Ptr<Shape> promotion (75x) #10353

Description

@proggeramlug

Summary

A property store whose value comes from a local initialised to a compile-time constant silently loses Ptr<Shape> promotion for the object being built, and falls back to the dynamic js_put_value_set inline-cache path. The identical program with the value supplied as a parameter, or with the constants written inline, is promoted and runs 75× faster.

This is not a micro-benchmark curiosity: const X = …; obj.field = X is the ordinary way initialisation code is written, and OpenCode --version spends 23.5% of its instructions in object/property ops against 1.25% in its own compiled code.

Reproduction

Five variants, all computing the same thing, --no-auto-optimize, measured with perf stat -e instructions:u on Linux x86_64. A one-module baseline loop is 1,087,632 instructions; subtract it to get the build cost.

SLOW — 109,730,754 (vA.ts)

function build() {
  const o: any = {};
  const x = 1;
  o.p1 = x; o.p2 = x; o.p3 = x; o.p4 = x; o.p5 = x; o.p6 = x;
  return o;
}
let n = 0;
for (let i = 0; i < 2400; i++) { const o = build(); n += o.p6; }
console.log(n);

FAST — 1,428,024 (vC.ts) — identical except x is a parameter

function build(x: number) {
  const o: any = {};
  o.p1 = x; o.p2 = x; o.p3 = x; o.p4 = x; o.p5 = x; o.p6 = x;
  return o;
}
let n = 0;
for (let i = 0; i < 2400; i++) { const o = build(i); n += o.p6; }
console.log(n);

FAST — 1,418,439 (vD.ts) — constants written inline instead of through a local

function build() {
  const o: any = {};
  o.p1 = 1; o.p2 = 2; o.p3 = 3; o.p4 = 4; o.p5 = 5; o.p6 = 6;
  return o;
}

For scale, the same object built as a literal (lit2.ts) is 1,216,326, and built by assignment from a parameter (asg2.ts) is 1,478,364 — so assignment-from-a-parameter is fine. Only the constant-valued local is pathological.

What the compiler says

PERRY_OPT_REPORT=1 on the slow variant:

  Ptr<Shape>          0 selected /    2 denied   (0% of 2 candidates)
*** Ptr<Shape> promoted 0 of 2 candidates in this build. ***

  vA.ts :: local `o` [loop depth 1]
      in module-init module_init -> Boxed
      ptr-shape rule 2 (containment)
      a property that is not a declared field of the class chain is read or
      written on it, which would transition its shape.
      candidate class: __AnonShape_b038f64005f7db44
      --> vA.ts:2:9

On the fast variant the same report reads Ptr<Shape> 2 selected / 0 denied.

The claim in the denial is what needs explaining: the only properties read or written on o are p1p6 plus the o.p6 read in the loop, and the candidate class is built from exactly those. Nothing appears to be outside the declared field set, yet containment reports otherwise — and it only does so when the stored value is a constant-valued local.

What the generated code does

Built with PERRY_KEEP_SYMBOLS=1, then nm <bin> | grep perry_fn_.*build and objdump -d --disassemble=<sym>:

Slow (vA) — dynamic store path, cold ICs:

11 <js_put_value_set_ic_miss>
 6 <js_put_value_set_ic_poly_tail>
 6 <js_put_value_set_ic_overflow_store>

Fast (vC) — shape-stamped allocation, no IC misses at all:

 2 <vC_ts____AnonShape_9be20692a8c3f7f0_constructor>
 2 <js_object_alloc_class_inline_keys_stamped>
 2 <js_gc_declare_typed_shape_layout>

A profile of the slow variant shows the store coercing the key to a string on every execution (js_to_primitive, js_string_coerce, core::str::converts::from_utf8), which is the generic PutValueSet path rather than a static named-property store.

Hypotheses already falsified — please don't repeat these

  • Canonical-i32 promotion of the local. The slow variant reports I32/U32/Str 3 selected and the fast one 0 selected, which makes this the obvious suspect. It is not: PERRY_CANONICAL_I32_LOCALS=0 leaves the slow variant at 109,714,076 (unchanged).
  • const vs let. let x = 1 is equally slow (109,749,930).
  • Absence of a parameter. Adding an unused parameter and keeping const x = 1 is still slow (109,740,544). It is the origin of the stored value that matters, not the signature.
  • Module-level vs function-local scope. Both are slow; a variant with the object and stores at module top level and one inside a function called once differ by under 1%.

Where to look

  • crates/perry-codegen/src/collectors/ptr_shape.rs — rule 2 (containment) is what reports the denial
  • crates/perry-codegen/src/expr/repsel_gates.rs, expr/slot_rep.rs — the representation-selection gates
  • PERRY_OPT_REPORT=1 names the denial rule, source line and candidate class for any program, which makes this cheap to iterate on without profiling

Why it matters

Perry's object-literal path is excellent — a 6-property object costs ~54 instructions built as a literal. The assignment path costs ~30k per store at a cold (once-executed) site and ~27 warm. Module-initialisation code is overwhelmingly "create an object, assign fields", and every such site in a module graph runs exactly once, so the cold number is the one that applies. Closing this would pay across module init, schema construction and ordinary user code simultaneously.

Related: #7034 §4 (return-shape facts) denies the neighbouring return { … } / module.exports = { … } idiom for a different reason — an allocation in expression position has no local to anchor the proof to.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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