Skip to content

Commit e96fa34

Browse files
authored
Fold constant chains within a sweep instead of one fixpoint round per layer (#361)
* test(ir): pin the constant-chain fold against per-round layering (#328) A chain of let-bound constructors nested one in another -- the shape a folded monadic bind chain reaches -- collapses one layer per bottom-up sweep, so the whole chain needs as many optimize+dce rounds as it has layers. This pins the intended behaviour instead: eight nested layers fold to their final constant in a single optimizedExpression sweep. Red at this commit: the sweep folds the innermost layer only, leaving Let ($field0 = 0) (Just 1) where the enclosing layer's fold wants a bare constructor. * feat(optimizer): drop unread let bindings in the sweep that folds them (#328) Every fold that eliminates a let-bound constructor leaves the payload behind in a spent field-binder Let that nothing reads: propagating the constructor binds each read field to a fresh $field binder, the trivial ones then paste into their reads, and the binding is left unreferenced. Only the separate dce pass dropped that residue, one pass later in the round -- and until it did, the enclosing layer's right-hand side was a Let rather than the constructor it wraps, so the fold there declined and a chain advanced by exactly one layer per whole-module round. Dropping an unread binding is now a rewrite too (removeUnusedLetBindings), on the licence dce already took: an effect run stays because dropping it would discard the effect, anything else is evaluated for a value nothing wants. Bindings are decided right to left, so one kept alive only by a binding itself dropped goes with it, and sequential scoping puts a binder in scope for the groupings after it and the body, never the ones before. Recursive groups are left to dce, which can see a dead group as a whole. Convergence no longer scales with chain depth: Golden.LongBindFlipped's specialize+dce fixpoint goes from 301 rounds to 3, the same count the three-deep Golden.MaybeChain takes, and the deepest fixpoint over all 348 goldens takes 4. maxFixpointIterations therefore returns to 100, close enough to real work that a pass which over-reports changes is caught in 100 sweeps rather than 1000. Compiling Golden.LongApplyChain drops from 42.6s to 1.2s, Golden.LongBindFlipped from 8.2s to 1.2s, and the golden suite from 81.9s to 30.2s. 346 of the 348 goldens are byte-identical. Golden.LongWriterBind moves, and in the direction the collapse intends: its 200-deep Writer chain compiled to a nest of closures over tell/discard plus the Data.Identity dictionary tables, and now compiles to straight-line code -- the result's first component is the literal 42, the log a flat concatArray spine, the dictionaries gone, 847 lines of Lua down to 234. Its eval oracle (42) is unchanged, so the collapse is semantics-preserving where it is checked by execution.
1 parent 734e65c commit e96fa34

7 files changed

Lines changed: 2072 additions & 4615 deletions

File tree

changelog.d/20260728_120000_unisay_default_directive_pack.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,3 @@
1717
- Directive targets may now contain `_` and `'`, matching PureScript
1818
identifier syntax (previously `Effect.Ref.modify_` or a primed name could
1919
not be named by any pragma or directives file).
20-
- The optimizer fixpoint iteration backstop is raised from 100 to 1000
21-
rounds: directive-driven inlining folds a constant chain one layer per
22-
round, so legitimate iteration counts scale with the deepest such chain
23-
in the module (the ~300-deep golden stress chains need several hundred).
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
### Changed
2+
3+
- The IR optimizer no longer needs one whole-module sweep per layer of a
4+
constant chain, so compile time stops scaling with the depth of the deepest
5+
such chain (#328). Every fold that eliminates a let-bound constructor leaves
6+
the payload behind in a spent field-binder — a `Let` nothing reads any more:
7+
8+
```
9+
let v = Just 2 in -> let $field = 2 in
10+
if justTag == reflectCtor v Just 3
11+
then Just (arg0 v + 1) else Nothing
12+
```
13+
14+
Only the separate dead-code-elimination pass dropped that residue, one pass
15+
later in the round. Until it did, the enclosing layer's right-hand side was a
16+
`Let` rather than the `Just 3` it wraps, so the fold there declined and the
17+
chain advanced by exactly one layer per round. Dropping an unread binding is
18+
now also a rewrite (`removeUnusedLetBindings`), on the licence dead-code
19+
elimination already used — an effect run stays, anything else is evaluated
20+
for a value nothing wants — so the whole cascade completes inside one sweep.
21+
22+
A ~300-deep chain now converges in as many rounds as a three-deep one:
23+
`Golden.LongBindFlipped`'s `specialize+dce` fixpoint goes from 301 rounds to
24+
3, and the deepest fixpoint over the whole golden corpus takes 4. Compiling
25+
`Golden.LongApplyChain` drops from 42.6s to 1.2s, `Golden.LongBindFlipped`
26+
from 8.2s to 1.2s, and the golden suite as a whole from 81.9s to 30.2s.
27+
28+
- The optimizer fixpoint iteration backstop (`maxFixpointIterations`) is 100
29+
rounds. Legitimate convergence no longer scales with chain depth, so the
30+
bound can sit close to real work (4 rounds at the corpus maximum) and a pass
31+
that over-reports changes or genuinely loops is caught after 100 sweeps
32+
rather than 1000.
33+
34+
- Collapsing the chains within the sweep unblocks a fold the growth veto used
35+
to stall: `Golden.LongWriterBind` compiled a 200-deep `Writer` chain into a
36+
nest of closures over `tell`/`discard` plus the `Data.Identity` dictionary
37+
tables, and now compiles to straight-line code — the result's first component
38+
is the literal `42`, the log a flat `concatArray` spine, and the dictionaries
39+
are gone (847 lines of Lua down to 234). The module's execution output is
40+
unchanged.

lib/Language/PureScript/Backend/IR/Optimizer.hs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,6 +1457,7 @@ optimizedExpressionWithPastes ctorTags canon pastes policy env =
14571457
`thenRewrite` pushIfCondIntoBranches
14581458
`thenRewrite` pushEliminatorIntoIfBranches
14591459
`thenRewrite` inlineLocalBindings
1460+
`thenRewrite` removeUnusedLetBindings
14601461
)
14611462

14621463
{- | Tier 2 of Note [Canonical Effect/ST heads]: rewrite an Effect/ST
@@ -3424,6 +3425,64 @@ inlineLocalBinding rhsRefCounts grouping (body, inlined) =
34243425
occurrences Natural
34253426
occurrences = usageTotal usage
34263427

3428+
{- | Drop the 'Standalone' bindings of a Let that nothing reads any more.
3429+
3430+
Dead local bindings are the residue of the folds around them: the
3431+
constructor propagation of 'propagateKnownCtorThroughLet' binds each
3432+
read field to a fresh field-binder, 'inlineLocalBindings' then pastes a
3433+
trivial one into its reads, and a pruned branch takes its reads with it
3434+
— each leaving a binding no reference names. Dropping them is dead-code
3435+
elimination, which the @dce@ pass also does; doing it here as a rewrite
3436+
is what lets a /cascade/ complete inside one sweep. A layer of a
3437+
constant chain folds to its result wrapped in the spent field-binder
3438+
Let, and the enclosing layer's right-hand side is then a Let rather
3439+
than the constructor it wraps, so the fold there declines: while the
3440+
residue survives to the end of the sweep, one whole-module round
3441+
advances the chain by exactly one layer, and rounds scale with chain
3442+
depth (issue #328).
3443+
3444+
The licence to discard an unread binding's right-hand side is the one
3445+
dead-code elimination already takes
3446+
('Language.PureScript.Backend.IR.DCE.eliminateDeadCode'): an effect run
3447+
stays, because dropping it would silently discard the effect
3448+
('isEffectRun'), and anything else is evaluated for a value nothing
3449+
wants. Bindings are decided right to left, so one that is kept alive
3450+
only by a binding itself dropped goes with it; sequential scoping puts a
3451+
binder in scope for the groupings after it and the body, never the ones
3452+
before (Note [Sequential scoping of Let bindings]).
3453+
3454+
Recursive groups are left alone. Their members reference each other, so
3455+
a dead group is only recognisable as a whole, and the shapes that block
3456+
a cascade bind values rather than recursive closures — the @dce@ pass
3457+
collects a group nothing calls at the end of the round.
3458+
-}
3459+
removeUnusedLetBindings Applicative m RewriteRuleM m Ann
3460+
removeUnusedLetBindings =
3461+
pure . \case
3462+
Let ann groupings body
3463+
| let kept = snd (foldr keep (countFreeRefs body, []) groupings)
3464+
, length kept < length groupings
3465+
Just case nonEmpty kept of
3466+
Nothing body
3467+
Just keptNE Let ann keptNE body
3468+
_ Nothing
3469+
where
3470+
keep
3471+
Grouping (Ann, Name, Exp)
3472+
(Map (Qualified Name) Natural, [Grouping (Ann, Name, Exp)])
3473+
(Map (Qualified Name) Natural, [Grouping (Ann, Name, Exp)])
3474+
keep grouping (refs, kept) = case grouping of
3475+
Standalone (_ann, name, rhs)
3476+
| Local name `Map.notMember` refs
3477+
, not (isEffectRun rhs)
3478+
(refs, kept)
3479+
_
3480+
( Map.unionsWith
3481+
(+)
3482+
(refs : [countFreeRefs rhs | (_ann, _n, rhs) listGrouping grouping])
3483+
, grouping : kept
3484+
)
3485+
34273486
{- Note [Complexity and Capture gate inlining]
34283487
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
34293488
Two small lattices refine the inlining heuristics beyond exact use

lib/Language/PureScript/Backend/IR/Pass.hs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,17 +162,23 @@ renderPassCheckFailure = \case
162162
["Optimized module contains dangling imported references:"]
163163
<> (show <$> toList violations)
164164

165-
{- | Iteration backstop for 'RunFixpoint'. Convergence normally takes a
166-
handful of rounds, but directive-driven inlining folds a constant chain
167-
one layer per round, so legitimate rounds scale with the deepest such
168-
chain in the module (the ~300-deep golden stress chains need several
169-
hundred). Hitting the backstop anyway means a pass over-reports changes
170-
or genuinely loops — a bug, which the checked runner turns into a
165+
{- | Iteration backstop for 'RunFixpoint'. Legitimate rounds do not
166+
scale with the size or shape of the module: a rewrite cascade completes
167+
inside one sweep, so the deepest golden stress chains (~300 constant
168+
folds nested one in another) converge in as many rounds as a three-deep
169+
one. The whole golden corpus peaks at four, so this sits far clear of
170+
real work, and hitting it means a pass over-reports changes or genuinely
171+
loops — a bug, which the checked runner turns into a
171172
'FixpointDivergence' while the production runner accepts the (correct,
172173
possibly under-optimized) module reached.
174+
175+
The bound is what keeps that bug cheap to find: each round is a sweep of
176+
the whole module, so a backstop generous enough to hide depth-scaled
177+
convergence would also make a loop burn hundreds of sweeps before
178+
reporting.
173179
-}
174180
maxFixpointIterations Natural
175-
maxFixpointIterations = 1000
181+
maxFixpointIterations = 100
176182

177183
--------------------------------------------------------------------------------
178184
-- Runners ---------------------------------------------------------------------

test/Language/PureScript/Backend/IR/Optimizer/Spec.hs

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,30 @@ spec = describe "IR Optimizer" do
861861
)
862862
optimizedExpression original `shouldSatisfy` alphaEq expected
863863

864+
it "collapses a chain of nested let-bound constructors in one sweep" do
865+
-- Folding one layer leaves the payload behind in a spent field-binder
866+
-- Let: the binder's reads are folded and then inlined, so nothing
867+
-- reads it any more, yet the Let node stands. The enclosing layer's
868+
-- right-hand side is then that Let rather than the constructor it
869+
-- wraps, so the fold declines there — and a chain of depth N advances
870+
-- one layer per whole-module sweep instead of collapsing in this one.
871+
let layer Int Exp Exp
872+
layer i inner =
873+
let v = Name ("v" <> show i)
874+
in let1 v inner $
875+
ifThenElse
876+
(eq (literalString justTag) (reflectCtor (refLocal v)))
877+
( just
878+
( primBinOp
879+
PrimAdd
880+
(dataArgumentByIndex SumType 0 (refLocal v))
881+
(literalInt 1)
882+
)
883+
)
884+
nothingCtor
885+
optimizedExpression (foldr layer (just (literalInt 0)) [1 .. 8])
886+
`shouldBe` just (literalInt 8)
887+
864888
it "declines when the binder is read as a whole value" do
865889
-- v flows into a function as a whole value, so it cannot be dropped
866890
-- (nor is it inlinable, so the binding survives untouched).
@@ -2123,22 +2147,22 @@ spec = describe "IR Optimizer" do
21232147
runIdentity (pushEliminatorIntoIfBranches original) `shouldBe` Nothing
21242148

21252149
describe "inlines expressions" do
2150+
-- The Let itself is gone from each result below: pasting the body's
2151+
-- only reference leaves the binding unread, and an unread binding is
2152+
-- dropped in the same sweep ('removeUnusedLetBindings'). A result
2153+
-- still carrying the Let would mean the paste never happened.
21262154
test "inlines literals" do
21272155
name forAll Gen.name
21282156
inlinee forAll Gen.scalarExp
2129-
let original = let1 name inlinee (refLocal name)
2130-
expected = let1 name inlinee inlinee
2131-
optimizedExpression original === expected
2157+
optimizedExpression (let1 name inlinee (refLocal name)) === inlinee
21322158

21332159
test "inlines references" do
21342160
name forAll Gen.name
21352161
-- A reference to the binding's own name is the one reference that
21362162
-- must NOT be inlined (see the self-inlining test below), so the
21372163
-- inlinee is drawn from the other names.
21382164
inlinee refLocal <$> forAll (mfilter (/= name) Gen.name)
2139-
let original = let1 name inlinee (refLocal name)
2140-
expected = let1 name inlinee inlinee
2141-
optimizedExpression original === expected
2165+
optimizedExpression (let1 name inlinee (refLocal name)) === inlinee
21422166

21432167
-- Regression: substituting @x := x@ is a textual no-op, so the
21442168
-- occurrence count of @x@ never reaches zero and an unguarded rule
@@ -2164,13 +2188,11 @@ spec = describe "IR Optimizer" do
21642188
&& countFreeRef (Local name) e == 0
21652189
)
21662190
Gen.exp
2167-
let body = refLocal name
2168-
original = let1 name inlinee body
2169-
expected = let1 name inlinee inlinee
2170-
annotateShow body
2191+
let original = let1 name inlinee (refLocal name)
2192+
annotateShow original
21712193
-- The inserted copy gets fresh binder names ('substituteCopyM'
21722194
-- freshens every insertion), so compare up to alpha-equivalence.
2173-
diff (optimizedExpression original) alphaEq expected
2195+
diff (optimizedExpression original) alphaEq inlinee
21742196

21752197
test "doesn't inline expressions referenced more than once" do
21762198
name forAll Gen.name
@@ -2383,8 +2405,9 @@ spec = describe "IR Optimizer" do
23832405
PrimAdd
23842406
(application (refLocal incName) (literalInt 1))
23852407
(application (refLocal incName) (literalInt 2))
2386-
optimizedExpression original
2387-
`shouldSatisfy` alphaEq (let1 incName incExpr (literalInt 5))
2408+
-- Both call sites fold, so nothing reads the binding and it goes
2409+
-- with them — the same result the beta-reduced spelling below has.
2410+
optimizedExpression original `shouldSatisfy` alphaEq (literalInt 5)
23882411

23892412
it "beta-reduces a small closed lambda argument used twice" do
23902413
let body =

0 commit comments

Comments
 (0)