Skip to content

Inline small pure function bindings regardless of use count #211

Description

@Unisay

Problem

A tiny curried function used at two or more saturated sites (\x y -> x - y, \x y -> x + y, \x y -> x == y) comes out as a shared uncurried worker instead of an inline operator. Since #178 lifts the prelude arithmetic / comparison / equality foreigns to primops, this now shows up directly: Golden.Fibonacci.Test emits sub$w(v, 1) / sub$w(v, 2) where v - 1 / v - 2 would do. The cost is one Lua call per operation. PUC pays it (fib(32) is roughly 7M extra calls); LuaJIT traces the tiny leaf function and mostly erases it.

Why the current pipeline leaves it

Two independent factors combine, so the lifted Just Always annotation does not save the day:

  1. reduceObjectProp deliberately strips Just Always when it projects a dictionary field (setAnn takes the projection's annotation, not the field's), so a large foreign accessor like foldlArray does not become inline-always everywhere through a projection. The lifted intSub lambda loses its Always as it resolves through ringInt.sub.
  2. purs already shares sub ringInt as a top-level binding (Golden.Fibonacci.Test.sub) because - occurs more than once in fib.

The result is sub = \x y -> x - y with the annotation gone, used twice, so the optimizer does not inline it (use-count > 1, not Always), it survives to the uncurry pass, and uncurry splits the curried binding into a worker.

Underneath, isInlinableExpr is overly conservative for lambdas: it whitelists refs, literals, and Always nodes, and gates everything else on use-once. But a binding whose RHS is a lambda (a value) is work-free to duplicate. The body runs once per application no matter how many textual copies exist, so pasting it at N sites never multiplies runtime work. Only code size is at stake.

Approach

Extend isInlinableExpr in IR.Optimizer with a size-capped small-value predicate. The guard is shared by withBinding, betaReduce, and inlineLocalBinding (Note [Beta reduction and local inlining share an inlining guard]), so this is a one-place change.

Minimal-correct form: the RHS is a manifest lambda and its node count is at most K. Conservative first cut (recommended), which also bounds a lambda body to shapes that are cheap to duplicate:

  • allowed in the body: LiteralInt/Float/String/Char/Bool, Ref, PrimBinOp, PrimNot, Eq, IfThenElse, the projections (ObjectProp / DataArgumentByIndex / ReflectCtor), and nested AbsN;
  • disallowed: AppN (may carry an effect or an expensive call), Let, LiteralArray / LiteralObject, ObjectUpdate (allocation), ForeignImport;
  • cap the node count (around 8 to 16), and ideally add a use-count × size growth budget in the GHC size/discount spirit.

The growth budget matters: a tiny function used at thirty sites is thirty copies, which pushes on the 200-locals-per-function limit (see the M-table history, #174). So this wants tuning against #172 measurements, not a fixed constant pulled out of the air.

Correctness is straightforward: inlining a lambda is not eta reduction (the lambda stays, so Note [Eta reduction is unsound] does not apply), recursive-group members are already excluded from inlining, and substituteCopyM freshens binders so GUC holds.

Prerequisites / Relations

Complementary to #180 (budgeted call-site inlining of dictionary methods), with little overlap. #180 collapses the Data_Ord_compare(dict) method dispatch; this cleans up residual shared small bindings. #180 narrows the scope (a dict method inlined at its call site can collapse the arithmetic there, so no shared binding forms), but does not subsume it: Golden.Fibonacci.Test.sub is shared by purs before our IR ever sees it, and #180's own output can leave fresh small bindings this pass would pick up. Land #180 first, re-measure, and pursue this only if the residual still matters.

Scope and churn

This is a general inliner change, not primop-specific: it inlines any small pure function used more than once, so its blast radius is wider than #178's additions. The conservative predicate (pure primop / Eq bodies only) shifts roughly 13 goldens carrying sub$w / add$w / eq$w (Fibonacci, FieldCaching, Loopification, the Long*Bind family, TailRecM2Shadow, LongCallbackChain, Uncurry). It leaves greaterThanOrEq$w / lessThan$w (bodies apply compare), logShow$w (applies log), and the monad workers alone, since their bodies contain applications.

Priority

Low. #178 already captures the large win (foreign dispatch to primop), LuaJIT erases most of the remaining per-call gap, and #180 will shrink the residual further.

Metadata

Metadata

Assignees

Labels

OptimisationA Compiler Optimisationarea: irIR / optimizer / DCE / inliner

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions