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 p1–p6 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.
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 dynamicjs_put_value_setinline-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 = Xis the ordinary way initialisation code is written, andOpenCode --versionspends 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 withperf stat -e instructions:uon 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)FAST — 1,428,024 (
vC.ts) — identical exceptxis a parameterFAST — 1,418,439 (
vD.ts) — constants written inline instead of through a localFor 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=1on the slow variant: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
oarep1–p6plus theo.p6read 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, thennm <bin> | grep perry_fn_.*buildandobjdump -d --disassemble=<sym>:Slow (
vA) — dynamic store path, cold ICs:Fast (
vC) — shape-stamped allocation, no IC misses at all: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 genericPutValueSetpath rather than a static named-property store.Hypotheses already falsified — please don't repeat these
I32/U32/Str 3 selectedand the fast one0 selected, which makes this the obvious suspect. It is not:PERRY_CANONICAL_I32_LOCALS=0leaves the slow variant at 109,714,076 (unchanged).constvslet.let x = 1is equally slow (109,749,930).const x = 1is still slow (109,740,544). It is the origin of the stored value that matters, not the signature.Where to look
crates/perry-codegen/src/collectors/ptr_shape.rs— rule 2 (containment) is what reports the denialcrates/perry-codegen/src/expr/repsel_gates.rs,expr/slot_rep.rs— the representation-selection gatesPERRY_OPT_REPORT=1names the denial rule, source line and candidate class for any program, which makes this cheap to iterate on without profilingWhy 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.