Repository navigation
perf(hir): a for initializer registers its local as Any — object literals in loops are declared to the GC as pointer slots #7547
Description
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:
- the raw-f64 mask is empty and the pointer mask is full — a
{v: number, w: number}literal isGC_LAYOUT_SIDE_MASKwith two traced slots, notPOINTER_FREE; - there is no raw-f64 store path to engage, so the perf(method dispatch): method_calls ~290× Node — remaining cost is per-field-access shape-guard calls (plan + standby) #5093 class-field fast path can never fire for the literal;
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.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; …)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.- No regression under GC zeal + from-space protection, and the
gc_ratchetprobes hold.
Activity
docs(changelog): #7547 for-init local types
perf(hir): type a for-initializer declaration instead of registering …