Menu

Monomorphization — non-reified generics for the erased-array boundary

Status: SHIPPED — the Monomorphize pass covers both the array dimension and the callable dimension. This doc is the original engine design and rationale; three details below no longer match the implementation:

  1. Mangling. Specialised names are <name>$mono$<key> (Monomorphize.php), not the array_map$Lvec_int_R_Cint scheme sketched here. Examples: head$mono$p0_vec_int, head$mono$p0_vec_str.
  2. Pipeline position. Monomorphize does NOT run right after LowerFromAst. It runs after ConstFold, DeadStore, InferTypes, a pre-mono NarrowReturns, InferTypes again, InlineClosures and InferTypes a third time — precisely because it needs settled call-argument types. See src/Compile/README.md for the real order.
  3. The callee$cell fallback is NOT built. Monomorphize.php calls it "future, Phase 3". The invariant stated below — that every monomorphized function keeps exactly one name-addressable $cell entry — is aspirational, not upheld. Phases 4-5 likewise did not land.

Prereqs (landed): uniform closure ABI, cell/union type system (type-system-v2.md), float literal precision.

Why this is THE next milestone#

Three independently-investigated gaps all converge on ONE root — bare-array element-type erasure:

Fixing erasure unblocks all three plus general generics. The USER DESIGN CALL (design decision): monomorphization (non-reified) is the generics path; cell is the dynamic fallback; reflection is orthogonal.

The cell question (answered)#

"If it's a multi-type array_map, will they be cells?"

No. Each call-group gets its OWN specialized copy with the CONCRETE element type — no boxing, no cell:

array_map(fn($x)=>$x*2,   [1,2,3])      → array_map$Lvec_int_R_Cint   (over vec[int])
array_map(fn($x)=>$x."!", ["a","b"])    → array_map$Lvec_str_R_Cstr   (over vec[string])

The specialized copy has vec[int] / vec[string] as the param, the loop reads a typed element, the closure arg is boxed/unboxed per its REAL type (the closure ABI is already correct), and the result is vec[int] / vec[string]. Cells appear ONLY in the dynamic fallback (below) — when the element type is genuinely unknown at every call site (e.g. an array built from heterogeneous runtime sources, or a recursion-bounded type explosion). Monomorphization exists precisely to AVOID the slow/lossy cell path.

Scope: what gets monomorphized#

A function is a monomorphization candidate when it has a parameter whose type is erased / polymorphic: a bare array (element unknown), a bare callable, mixed, or an unannotated param, AND its behaviour depends on that param's concrete shape (element type, callee signature). The dominant cases:

Closures are already specialized by capture; a monomorphized callee that invokes a closure passes a CONCRETE element type, so the closure's arg box/unbox is exact.

Architecture#

A new MIR pass, Monomorphize, running AFTER LowerFromAst + the class/function registry is built, and BEFORE (or interleaved with) InferTypes. Reason: it must see call sites with enough type info to compute the specialization key, but produce concrete FunctionDefs that InferTypes then types precisely.

Pipeline insertion:

… → LowerFromAst → Monomorphize → InferTypes → InferAllocKind → … → EmitLlvm

Algorithm (worklist / fixpoint):

  1. Seed. Type the call graph enough to know each call site's argument types (a lightweight pre-inference, or reuse the call-site element inference already in InferTypes — see scanParamElements). For a polymorphic callee, compute a specialization key from the concrete arg types at the call site.
  2. Specialize. For each (callee, key) not yet emitted, clone the callee's FunctionDef, substitute the concrete types into its params (bare array → vec[T] / assoc[K,V], callable → the concrete closure class), name-mangle callee$key, and enqueue any NESTED polymorphic calls it makes (e.g. array_map calling the closure, or a helper calling another helper) — those get their own keys derived from the now-concrete types. This is the worklist fixpoint.
  3. Rewrite call sites. Repoint each call to its specialized callee$key.
  4. Bound + fallback. Cap the number of specializations per function (e.g. 8). On overflow, OR when a call site's arg type is genuinely cell/unknown (no concrete type), route that call to a single callee$cell copy whose erased param is cell and whose elements are boxed cells (today's behaviour, but now EXPLICIT and isolated — the slow path, used only when needed). This guarantees termination and a correct (if slower) result for genuinely dynamic code.

Specialization key & mangling#

Key = a canonical string over the monomorphizable params' concrete types, e.g. vec[int], vec[string], assoc[string,int], closure class id. Mangle into an LLVM-safe suffix (reuse the existing mangle() + a type→token encoder; mirror the generator/closure __closure_N style). Two call sites with the SAME key share one specialization (dedupe via a specialized: map<callee#key, FunctionDef>).

Interaction with the prelude#

Prelude fns (array_map etc.) are injected as source today and compiled with the user program. Monomorphize treats them like any other function — their multiple call sites now yield multiple specializations instead of one all-agree (or erased) copy. The injection/gating stays; only the post-injection specialization is new. Drop the prelude comment's "one element type per call-group" limit once this lands.

Interaction with InferTypes#

After Monomorphize, every specialized fn has concrete param types, so InferTypes types its body precisely (no erased element, no cell unless the $cell fallback). This REMOVES the need for several existing erasure band-aids (scanParamElements all-agree, the bare-array element refinements) — but KEEP them initially; retire only after the new path is proven (they become no-ops when types are already concrete).

Foundation for explicit generics (frame the engine deliberately)#

This IS the core of future generics — monomorphization is the canonical codegen strategy for generics (Rust, C++ templates, .NET reified). What we build here is the ENGINE; full generics layer a frontend + binding on top of the SAME engine:

Net: doing monomorphization now lays the whole performance-critical foundation; explicit generics later = a docblock/parser frontend + type-variable binding feeding the same specialization engine. Keep the key as bindings to avoid a rewrite then.

Reflection interaction (does NOT conflict — keep the layering)#

Monomorphization is a LOWER-layer codegen optimization (multiple machine copies of a function); runtime reflection is an UPPER layer keyed on SOURCE identities. They do not interfere provided the boundary holds:

Net: as long as (a) object class_ids are preserved in values, (b) one canonical $cell entry per function stays reachable by name, and (c) reflection metadata is keyed on source names + declared types (never specialization keys), monomorphization and runtime reflection coexist cleanly — they live at different layers.

Why this also unblocks NaN-boxing & array_sum#

Phased plan (each phase fully gated)#

2026-10: the Zend cold seed is gone — the compiler bootstraps from the pinned release (AGENTS.md, "Bootstrap: the pinned release"). The seed-based recovery/validation advice below is historical.

Gate EVERY phase: tests/aot/run.sh (full) + tools/difftest.sh + tools/selfhost_fixpoint.sh (fixpoint byte-identical + self-host suite + stability 5×2) + bin/build --seed (the Zend cold-seed enforced PHP param hints the native rebuild ignores; now bootstrap-from-pin plays the independent-generation role). Revert-on-regression discipline (cf. NaN-boxing).

Risks & lessons (carry forward)#

Open work#

Phase 0 + Phase 1: wire the pass (pass-through, prove fixpoint), then specialize a single user helper over two element types with a focused test. Do NOT touch the prelude or closures until Phase 1 is green through the full gate.

Edit this page on GitHub