Skip to content

perf(hir): a for initializer registers its local as Any — object literals in loops are declared to the GC as pointer slots #7547

Description

@proggeramlug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions