Summary
A declaration in a for initializer registers its local as Type::Any — the annotation is discarded and the initializer is not inferred from. Everything computed from that variable poisons to Any, and for an object literal the consequence is not merely a missed optimisation: the shape is declared to the GC as pointer slots.
{ v: base + j, w: j } inside for (let j = 0; …) mints an __AnonShape_… class whose fields are (v, Any), (w, Any). Any is pointer-bearing (typed_shape::type_is_pointer_bearing), so:
Moving the identical annotation out of the for-initializer fixes all three.
Isolation
gc-handoff/bench/churn_alloc.ts and four one-line variants of it, same compiler (82be85459), best-of-9 interleaved user CPU. declare= is whether #7532's declare-at-allocation gate fired, i.e. whether the anon shape's fields came out Number (checked with nm on a PERRY_DEBUG_SYMBOLS=1 build — a default build is stripped and the check is vacuous).
| variant |
loop variable |
literal operands |
declare |
user |
vs base |
churn_alloc (base) |
for (let j = 0; …) |
inline base + j, j |
0 |
1.586 s |
1.000× |
ca_var_a |
for (let j: number = 0; …) |
inline |
0 |
1.589 s |
0.998× |
ca_var_b |
for (let j = 0; …) |
hoisted const a = base + j |
0 |
1.531 s |
1.036× |
ca_var_d |
let j: number = 0; outside the for |
inline |
1 |
1.339 s |
1.184× |
churn_alloc_typedvar |
annotated, outside |
annotated const a: number |
1 |
1.326 s |
1.196× |
ca_var_a vs ca_var_d is the whole finding: the same annotation on the same variable, moved out of the for-initializer, is worth 1.18×. ca_var_b shows hoisting alone does not help — an unannotated const a = base + j does not recover the type either, because j is already Any by then.
A literal built directly from two typed parameters (no loop, no locals) does get Number fields, so the anon-shape typing path itself is fine — it is only starved of operand types.
Where
ctx.define_local(name.clone(), Type::Any) at the for-initializer declaration sites, e.g. crates/perry-hir/src/lower/stmt.rs:1479, :1518, :1559 and crates/perry-hir/src/lower/lower_module_fn.rs:745, :782, :845. A plain statement-level let/const takes a different path and keeps its type, which is why variant D works.
The fix is to register the declared annotation when present, else infer_type_from_expr of the initializer — the same thing the non-for path already does.
Why this is filed separately from #7510
#7510 is the GC layout side tables. Items 1 and 2 shipped (#7532, #7525) and item 3 is already covered (see that ticket). What is left on churn_alloc is not layout bookkeeping — it is that the literal never had types to begin with. This is HIR type inference and it lands in a different subsystem.
Care required
This is a "more type visibility" change, and #6377 is the standing lesson for that class: registering Number where Any was assumed un-gates latent fast paths that have never run on this shape before. Expect the blast radius to be wider than the diff. In particular the literal would move from SIDE_MASK to POINTER_FREE, which changes what the collector scans — worth a PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 sweep and the gc_ratchet probes, not just a benchmark.
Acceptance
for (let j = 0; …) registers j: number; {v: base + j, w: j} mints an anon shape with raw_f64_mask = {0,1} and an empty pointer mask.
churn_alloc.ts improves ≥1.15× and its object literals report GC_LAYOUT_POINTER_FREE.
- No regression under GC zeal + from-space protection, and the
gc_ratchet probes hold.
Summary
A declaration in a
forinitializer registers its local asType::Any— the annotation is discarded and the initializer is not inferred from. Everything computed from that variable poisons toAny, and for an object literal the consequence is not merely a missed optimisation: the shape is declared to the GC as pointer slots.{ v: base + j, w: j }insidefor (let j = 0; …)mints an__AnonShape_…class whose fields are(v, Any), (w, Any).Anyis pointer-bearing (typed_shape::type_is_pointer_bearing), so:{v: number, w: number}literal isGC_LAYOUT_SIDE_MASKwith two traced slots, notPOINTER_FREE;js_gc_declare_typed_shape_layout(perf(codegen,gc): declare a class's typed-shape layout at allocation, not after its constructor (#7510 item 1, #7512) #7532) refuses the shape, because its gate requires an empty pointer mask.Moving the identical annotation out of the for-initializer fixes all three.
Isolation
gc-handoff/bench/churn_alloc.tsand four one-line variants of it, same compiler (82be85459), best-of-9 interleaved user CPU.declare=is whether #7532's declare-at-allocation gate fired, i.e. whether the anon shape's fields came outNumber(checked withnmon aPERRY_DEBUG_SYMBOLS=1build — a default build is stripped and the check is vacuous).churn_alloc(base)for (let j = 0; …)base + j,jca_var_afor (let j: number = 0; …)ca_var_bfor (let j = 0; …)const a = base + jca_var_dlet j: number = 0;outside theforchurn_alloc_typedvarconst a: numberca_var_avsca_var_dis the whole finding: the same annotation on the same variable, moved out of the for-initializer, is worth 1.18×.ca_var_bshows hoisting alone does not help — an unannotatedconst a = base + jdoes not recover the type either, becausejis alreadyAnyby then.A literal built directly from two typed parameters (no loop, no locals) does get
Numberfields, so the anon-shape typing path itself is fine — it is only starved of operand types.Where
ctx.define_local(name.clone(), Type::Any)at the for-initializer declaration sites, e.g.crates/perry-hir/src/lower/stmt.rs:1479,:1518,:1559andcrates/perry-hir/src/lower/lower_module_fn.rs:745,:782,:845. A plain statement-levellet/consttakes a different path and keeps its type, which is why variant D works.The fix is to register the declared annotation when present, else
infer_type_from_exprof the initializer — the same thing the non-for path already does.Why this is filed separately from #7510
#7510 is the GC layout side tables. Items 1 and 2 shipped (#7532, #7525) and item 3 is already covered (see that ticket). What is left on
churn_allocis not layout bookkeeping — it is that the literal never had types to begin with. This is HIR type inference and it lands in a different subsystem.Care required
This is a "more type visibility" change, and #6377 is the standing lesson for that class: registering
NumberwhereAnywas assumed un-gates latent fast paths that have never run on this shape before. Expect the blast radius to be wider than the diff. In particular the literal would move fromSIDE_MASKtoPOINTER_FREE, which changes what the collector scans — worth aPERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1sweep and thegc_ratchetprobes, not just a benchmark.Acceptance
for (let j = 0; …)registersj: number;{v: base + j, w: j}mints an anon shape withraw_f64_mask = {0,1}and an empty pointer mask.churn_alloc.tsimproves ≥1.15× and its object literals reportGC_LAYOUT_POINTER_FREE.gc_ratchetprobes hold.