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.

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

    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