@@ -7,6 +7,7 @@ import Data.List qualified as List
77import Data.List.NonEmpty qualified as NE
88import Data.Map qualified as Map
99import Data.Set qualified as Set
10+ import Data.Text qualified as Text
1011import Language.PureScript.Backend.IR.DCE (eliminateDeadCode )
1112import Language.PureScript.Backend.IR.FlattenDeepBinds (flattenDeepBindsM )
1213import Language.PureScript.Backend.IR.FloatIn (floatIn )
@@ -35,15 +36,18 @@ import Language.PureScript.Backend.IR.Supply (SupplyM, freshName, runSupply)
3536import Language.PureScript.Backend.IR.Types
3637 ( AlgebraicType (SumType )
3738 , Ann
39+ , Capture (.. )
3840 , Exp
3941 , Grouping (.. )
4042 , Parameter (.. )
4143 , PrimOp (.. )
4244 , RawExp (.. )
4345 , RewriteRuleM
46+ , Usage (.. )
4447 , WasRewritten (.. )
4548 , alphaEq
4649 , countFreeRef
50+ , countFreeRefUsage
4751 , countFreeRefs
4852 , ctorId
4953 , freshenBinders
@@ -465,6 +469,61 @@ inlineSizeBudget = 64
465469expSize ∷ RawExp ann → Natural
466470expSize e = 1 + sum (expSize <$> toListOf subexpressions e)
467471
472+ {- | The largest expression the Deref and KnownSize inlining tiers paste
473+ (Note [Complexity and Capture gate inlining]), sized in IR nodes like
474+ 'inlineSizeBudget' but far below it: these tiers admit duplication at
475+ every use site, so growth scales with the use count.
476+ -}
477+ smallInlineBudget ∷ Natural
478+ smallInlineBudget = 16
479+
480+ {- | How costly an expression is to duplicate, ordered by escalation.
481+ Combines by taking the worse classification.
482+ See Note [Complexity and Capture gate inlining].
483+ -}
484+ data Complexity = Trivial | Deref | KnownSize | NonTrivial
485+ deriving stock (Show , Eq , Ord )
486+
487+ instance Semigroup Complexity where
488+ (<>) = max
489+
490+ instance Monoid Complexity where
491+ mempty = Trivial
492+
493+ {- | Bottom-up cost classification. 'Trivial': a reference or a
494+ scalar/empty literal.
495+ 'Deref': a chain of cheap reads (projection, index, length, tag) over a
496+ Trivial base. 'KnownSize': an abstraction or a non-empty literal — a
497+ bounded allocation. Everything that computes is 'NonTrivial', and
498+ unlisted constructors deliberately land there, so a new node kind is
499+ conservative by default. A string literal above 128 characters counts
500+ as an allocation rather than a scalar: 'expSize' sees one node, but
501+ duplicating the payload is not free.
502+ -}
503+ complexityOf ∷ RawExp ann → Complexity
504+ complexityOf = \ case
505+ Ref {} → Trivial
506+ LiteralInt {} → Trivial
507+ LiteralFloat {} → Trivial
508+ LiteralChar {} → Trivial
509+ LiteralBool {} → Trivial
510+ LiteralString _ann s
511+ | Text. length s > 128 → KnownSize
512+ | otherwise → Trivial
513+ LiteralArray _ann exprs
514+ | null exprs → Trivial
515+ | otherwise → KnownSize <> foldMap complexityOf exprs
516+ LiteralObject _ann props
517+ | null props → Trivial
518+ | otherwise → KnownSize <> foldMap (complexityOf . snd ) props
519+ ObjectProp _ann base _prop → Deref <> complexityOf base
520+ ArrayIndex _ann base _idx → Deref <> complexityOf base
521+ ArrayLength _ann base → Deref <> complexityOf base
522+ ReflectCtor _ann base → Deref <> complexityOf base
523+ DataArgumentByIndex _ann _idx base → Deref <> complexityOf base
524+ AbsN _ann _params body → KnownSize <> complexityOf body
525+ _ → NonTrivial
526+
468527{- | Pure wrapper for tests and standalone use: runs the rewrite with
469528its own supply and no inlining environment. Production code uses
470529'optimizedExpressionM' so all passes share one supply.
@@ -988,9 +1047,12 @@ inlineSaturatedCall env expr = case unwindApp expr of
9881047~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
9891048'betaReduce' and 'inlineLocalBinding' decide whether to paste an expression
9901049into its use sites by the same test: paste only when re-evaluating it cannot
991- multiply work — the expression is trivial ('isInlinableExpr': a Ref, a
992- literal, or an @inline-always node) or it is used at most once. Otherwise the
993- expression stays behind a single 'Let' binding.
1050+ multiply work — the expression is cheap to re-evaluate ('isInlinableExpr': a
1051+ Ref, a literal, an @inline always@ node, or the Deref tier of
1052+ Note [Complexity and Capture gate inlining]), it is used at most once, or it
1053+ is a small closed abstraction whose uses all sit outside branches and
1054+ closures ('isDuplicatableClosedAbs', the KnownSize tier of the same Note).
1055+ Otherwise the expression stays behind a single 'Let' binding.
9941056
9951057The two rules must agree, because they hand work to each other. When
9961058'betaReduce' declines to substitute a redex it rewrites it to
@@ -1006,8 +1068,9 @@ fixpoint in one bottom-up pass instead of oscillating.
10061068Fires only at exact arity: any other argument count on a literal lambda
10071069head is ill-formed ('WellApplied', Note [n-ary abstraction]). The unary
10081070redex is the singleton case. Each pair reduces by the shared guard: the
1009- argument is substituted when trivial or used at most once, and bound by
1010- a 'Let' otherwise; an argument at a 'ParamUnused' position is dropped
1071+ argument is substituted when cheap, used at most once, or a small
1072+ duplicatable closed abstraction, and bound by a 'Let' otherwise; an
1073+ argument at a 'ParamUnused' position is dropped
10111074with its evaluation — the same call DCE makes when it drops an unused
10121075binding unconditionally. See Note [IR is assumed well-typed].
10131076-}
@@ -1044,7 +1107,10 @@ betaReduce = \case
10441107 ParamUnused _ann → pure (body, letBinds)
10451108 ParamNamed paramAnn name
10461109 -- See Note [Beta reduction and local inlining share an inlining guard]
1047- | isInlinableExpr arg || countFreeRef (Local name) body <= 1 →
1110+ | usage ← countFreeRefUsage (Local name) body
1111+ , isInlinableExpr arg
1112+ || usageTotal usage <= 1
1113+ || isDuplicatableClosedAbs arg usage →
10481114 -- The λ is consumed by the rewrite, so the first inserted
10491115 -- occurrence of the argument may keep its binder names
10501116 -- ('substituteMoveM').
@@ -1229,19 +1295,72 @@ inlineLocalBinding rhsRefCounts grouping (body, inlined) =
12291295 -- names it (the count map records no zero entries, and the binding's
12301296 -- own RHS is excluded by the self-reference guard above).
12311297 isInlinableExpr inlinee
1232- || (occurrences == 1 && name `Map.notMember` rhsRefCounts) →
1298+ || ( (occurrences == 1 || isDuplicatableClosedAbs inlinee usage)
1299+ && name `Map.notMember` rhsRefCounts
1300+ ) →
12331301 -- The binding survives until DCE drops it, so the inserted copy
12341302 -- must not reuse its binder names ('substituteCopyM').
12351303 (,Rewritten ) <$> substituteCopyM name inlinee body
12361304 | otherwise → pure (body, inlined)
12371305 where
1306+ usage ∷ Usage
1307+ usage = countFreeRefUsage name body
12381308 occurrences ∷ Natural
1239- occurrences = countFreeRef name body
1309+ occurrences = usageTotal usage
1310+
1311+ {- Note [Complexity and Capture gate inlining]
1312+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1313+ Two small lattices refine the inlining heuristics beyond exact use
1314+ counts and flat size ceilings (issue #231):
1315+
1316+ Complexity = Trivial < Deref < KnownSize < NonTrivial ('complexityOf')
1317+ Capture = CaptureNone < CaptureBranch < CaptureClosure
1318+ ('countFreeRefUsage')
1319+
1320+ 'Complexity' prices duplicating an expression; 'Capture' locates a
1321+ binding's use sites relative to it. Together they admit two tiers past
1322+ the use-count rule:
1323+
1324+ * Deref tier ('isInlinableExpr'): an expression of complexity at most
1325+ 'Deref' and size under 'smallInlineBudget' pastes at any use count.
1326+ Re-reading a projection chain is semantics-preserving because
1327+ PureScript records and module tables are write-once; the
1328+ re-evaluation cost is a field read. The tier reaches all three
1329+ guard sites — 'withBinding', 'betaReduce', 'inlineLocalBinding' —
1330+ through the shared predicate.
1331+
1332+ * KnownSize tier ('isDuplicatableClosedAbs', the two local sites
1333+ only): a closed 'AbsN' under 'smallInlineBudget' whose bound name's
1334+ uses all sit at 'CaptureNone' is substituted even when used many
1335+ times, so a locally-bound combinator beta-reduces at every site.
1336+ The 'CaptureNone' condition refuses to move the lambda literal into
1337+ a branch or closure: paying a closure allocation per call of the
1338+ surrounding function is the LuaJIT trace-abort pathology of issue
1339+ #204, so closedness and small size alone do not admit duplication.
1340+
1341+ A 'NonTrivial' body is admitted by neither tier, so it is never
1342+ duplicated — under a branch, a closure, or anywhere else. It still
1343+ inlines when used at most once: that is relocation, not duplication,
1344+ and the FloatIn pass moves work into single branches deliberately.
1345+
1346+ 'withBinding' gets no KnownSize tier on purpose: an uber-module
1347+ binding's uses sit inside other top-level functions — 'CaptureClosure'
1348+ by construction — and saturated call sites of top-level lambdas are
1349+ already served by the call-site inliner ('inlineSaturatedCall').
1350+
1351+ Left for the follow-up analyses that need them: per-use-kind counters
1352+ (call/access/case) and an admission for a projection used only in call
1353+ position.
1354+ -}
12401355
12411356-- See Note [Inline annotations and inlining heuristics]
1357+ -- and Note [Complexity and Capture gate inlining]
12421358isInlinableExpr ∷ Exp → Bool
12431359isInlinableExpr expr =
1244- hasInlineAnnotation expr || isRef expr || isNonRecursiveLiteral expr
1360+ hasInlineAnnotation expr
1361+ || isRef expr
1362+ || isNonRecursiveLiteral expr
1363+ || isCheapProjection expr
12451364 where
12461365 isRef ∷ RawExp a → Bool
12471366 isRef = \ case
@@ -1254,3 +1373,39 @@ isInlinableExpr expr =
12541373 Just Always → True
12551374 Just Never → False
12561375 Nothing → False
1376+
1377+ -- The Deref tier. The explicit disjuncts above are subsumed for the
1378+ -- shapes they share (a Ref, a short scalar), but kept: they also admit
1379+ -- what the tier prices differently (a long string literal).
1380+ isCheapProjection ∷ Exp → Bool
1381+ isCheapProjection e =
1382+ complexityOf e <= Deref && expSize e < smallInlineBudget
1383+
1384+ {- | The KnownSize tier of Note [Complexity and Capture gate inlining]:
1385+ a small closed abstraction whose bound name is only used outside
1386+ branches and closures may be pasted at every use site.
1387+ -}
1388+ isDuplicatableClosedAbs ∷ Exp → Usage → Bool
1389+ isDuplicatableClosedAbs rhs Usage {usageCapture} =
1390+ isAbs rhs
1391+ && usageCapture == CaptureNone
1392+ && expSize rhs < smallInlineBudget
1393+ && isClosedExp rhs
1394+ where
1395+ isAbs ∷ RawExp a → Bool
1396+ isAbs = \ case
1397+ AbsN {} → True
1398+ _ → False
1399+
1400+ {- | No free local references. Imported references do not count: they
1401+ are valid at any position in the module, so they survive being
1402+ pasted anywhere.
1403+ -}
1404+ isClosedExp ∷ RawExp ann → Bool
1405+ isClosedExp =
1406+ not . any isLocalName . Map. keys . countFreeRefs
1407+ where
1408+ isLocalName ∷ Qualified Name → Bool
1409+ isLocalName = \ case
1410+ Local _ → True
1411+ _ → False
0 commit comments