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:
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.
- 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.
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.Testemitssub$w(v, 1)/sub$w(v, 2)wherev - 1/v - 2would 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 Alwaysannotation does not save the day:reduceObjectPropdeliberately stripsJust Alwayswhen it projects a dictionary field (setAnntakes the projection's annotation, not the field's), so a large foreign accessor likefoldlArraydoes not become inline-always everywhere through a projection. The liftedintSublambda loses itsAlwaysas it resolves throughringInt.sub.sub ringIntas a top-level binding (Golden.Fibonacci.Test.sub) because-occurs more than once infib.The result is
sub = \x y -> x - ywith the annotation gone, used twice, so the optimizer does not inline it (use-count > 1, notAlways), it survives to the uncurry pass, and uncurry splits the curried binding into a worker.Underneath,
isInlinableExpris overly conservative for lambdas: it whitelists refs, literals, andAlwaysnodes, 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
isInlinableExprinIR.Optimizerwith a size-capped small-value predicate. The guard is shared bywithBinding,betaReduce, andinlineLocalBinding(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:
LiteralInt/Float/String/Char/Bool,Ref,PrimBinOp,PrimNot,Eq,IfThenElse, the projections (ObjectProp/DataArgumentByIndex/ReflectCtor), and nestedAbsN;AppN(may carry an effect or an expensive call),Let,LiteralArray/LiteralObject,ObjectUpdate(allocation),ForeignImport;use-count × sizegrowth 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
substituteCopyMfreshens 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.subis 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 /
Eqbodies only) shifts roughly 13 goldens carryingsub$w/add$w/eq$w(Fibonacci, FieldCaching, Loopification, the Long*Bind family, TailRecM2Shadow, LongCallbackChain, Uncurry). It leavesgreaterThanOrEq$w/lessThan$w(bodies applycompare),logShow$w(applieslog), 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.