Problem
Two individually sound inliner rules compose badly: the unconditional inlining of bare-Ref bindings and the Linker's auto-Always annotation on foreign accessors. Walking one value, intAdd, from the Linker to Lua shows the interaction.
- Stage 0 — Linker emits. For every foreign name,
foreignNamesBindings (lib/Language/PureScript/Backend/IR/Linker.hs) creates an accessor binding whose body is one projection out of the foreign module's table, annotated Inline.Always: Data.Semiring.intAdd = ObjectProp @Always (Ref Data.Semiring.foreign) "intAdd". The cost model: the body is one cheap field access, so copying it into each use site beats a top-level indirection, and DCE can then prune unused foreign names.
- Stage 1 — a bare-ref alias appears. User code
add = (+) becomes a dictionary projection that reduceObjectProp folds to a bare reference Main.add = Ref Data.Semiring.intAdd, used in two exports. At this point everything is optimal: one projection, two cheap references.
- Stage 2 — the inliner dissolves the alias.
isInlinableExpr (lib/Language/PureScript/Backend/IR/Optimizer.hs) treats any bare Ref as inlinable, so the alias is substituted into both sites and dropped. In isolation this is free (a reference replaced a reference), but the number of sites referencing the Always target went from one to two.
- Stage 3 —
Always fires at every site. When the inliner reaches intAdd, the annotation substitutes its body unconditionally into all sites and drops the binding, yielding N copies with two lookups per site instead of one shared alias with one lookup per site.
Neither rule is wrong alone: dissolving a bare ref is free until its target is Always, and Always duplication is cheap until there is more than one application point. The composition breaks both assumptions, and the alias — the better materialization point on both size and speed — is destroyed.
Approach
In the top-level inliner, decline the bare-Ref branch of isInlinableExpr when the target binding of the reference carries Always, so the alias stays the single materialization point (withBinding has the module's bindings in scope for that lookup). The heavier alternative is to stop annotating Linker accessors with Always altogether and let the ordinary heuristics decide.
Prerequisites / Relations
Optimizer bug, independent in the dependency graph. Related to #169 — both concern the Linker's auto-@inline always annotation — but distinct: #169 is an annotation leak in reduceObjectProp, this one is the bare-Ref alias interaction and fires with perfectly fresh reference counts (so it is also distinct from #142/#143). Scope note: for a user-written @inline always the N copies are the pragma's contract and are by design; the defect is confined to the interaction with the Linker's auto-annotation.
Verification / Measurement
A bare-Ref alias to an Always-annotated foreign accessor, used at several sites, stays a single shared binding rather than duplicating the projection into every use site. Reproducible independently of reduceObjectProp: build Main.add = Ref (Imported Data.Semiring.intAdd), export two uses, and the alias survives as one shared entry instead of intAdd's projection appearing at both exports.
Problem
Two individually sound inliner rules compose badly: the unconditional inlining of bare-
Refbindings and the Linker's auto-Alwaysannotation on foreign accessors. Walking one value,intAdd, from the Linker to Lua shows the interaction.foreignNamesBindings(lib/Language/PureScript/Backend/IR/Linker.hs) creates an accessor binding whose body is one projection out of the foreign module's table, annotatedInline.Always:Data.Semiring.intAdd = ObjectProp @Always (Ref Data.Semiring.foreign) "intAdd". The cost model: the body is one cheap field access, so copying it into each use site beats a top-level indirection, and DCE can then prune unused foreign names.add = (+)becomes a dictionary projection thatreduceObjectPropfolds to a bare referenceMain.add = Ref Data.Semiring.intAdd, used in two exports. At this point everything is optimal: one projection, two cheap references.isInlinableExpr(lib/Language/PureScript/Backend/IR/Optimizer.hs) treats any bareRefas inlinable, so the alias is substituted into both sites and dropped. In isolation this is free (a reference replaced a reference), but the number of sites referencing theAlwaystarget went from one to two.Alwaysfires at every site. When the inliner reachesintAdd, the annotation substitutes its body unconditionally into all sites and drops the binding, yielding N copies with two lookups per site instead of one shared alias with one lookup per site.Neither rule is wrong alone: dissolving a bare ref is free until its target is
Always, andAlwaysduplication is cheap until there is more than one application point. The composition breaks both assumptions, and the alias — the better materialization point on both size and speed — is destroyed.Approach
In the top-level inliner, decline the bare-
Refbranch ofisInlinableExprwhen the target binding of the reference carriesAlways, so the alias stays the single materialization point (withBindinghas the module's bindings in scope for that lookup). The heavier alternative is to stop annotating Linker accessors withAlwaysaltogether and let the ordinary heuristics decide.Prerequisites / Relations
Optimizer bug, independent in the dependency graph. Related to #169 — both concern the Linker's auto-
@inline alwaysannotation — but distinct: #169 is an annotation leak inreduceObjectProp, this one is the bare-Refalias interaction and fires with perfectly fresh reference counts (so it is also distinct from #142/#143). Scope note: for a user-written@inline alwaysthe N copies are the pragma's contract and are by design; the defect is confined to the interaction with the Linker's auto-annotation.Verification / Measurement
A bare-
Refalias to anAlways-annotated foreign accessor, used at several sites, stays a single shared binding rather than duplicating the projection into every use site. Reproducible independently ofreduceObjectProp: buildMain.add = Ref (Imported Data.Semiring.intAdd), export two uses, and the alias survives as one shared entry instead ofintAdd's projection appearing at both exports.