Fold constant chains within a sweep instead of one fixpoint round per layer - #361
Merged
Conversation
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.
#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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #328.
The problem
The IR optimizer runs several of its passes as fixpoints: a group of passes is applied to the whole linked module over and over until a round changes nothing. One of them,
specialize+dce, is where a monadic bind chain collapses — call-site inlining pastes thebindmethod bodies in, and the case-of-known-constructor folds then reduce the resulting matches to straight-line code.That fixpoint took 301 rounds on
Golden.LongBindFlipped, a ~300-deep chain of=<<overMaybe, and 3–4 rounds on everything else in the corpus. One round per layer, measured by tracing the round count of every fixpoint in the pipeline:Each round is a sweep of the whole module, so compile time was O(depth × module size). #327 raised the fixpoint's iteration backstop (
maxFixpointIterations, the cap that stops a genuinely looping pass from spinning forever) from 100 to 1000 to keep the invariant-checking test runner from reporting these legitimate rounds as divergence — which also meant a real loop bug had to burn 1000 whole-module sweeps before being reported.Why one layer per round
Not the growth veto and not the nested-rewrite bound — instrumenting both showed no sweep was ever vetoed, and the chain shrank by ~10 nodes per round, exactly one layer. Dumping the expression before and after one sweep of round 2 shows what actually blocks:
The fold worked — the match collapsed, the payload was inlined,
2 + 1folded to3. But it left$field4527behind:propagateKnownCtorThroughLet, the rule that pushes a known constructor into a let-bound scrutinee's reads, binds each read field to a fresh$fieldbinder (so an argument read at several sites is evaluated once, not copied). When the reads then fold away, that binder is unreferenced — and nothing in the rewrite rule set dropped an unreferencedLetbinding. Only the separatedcepass did, one pass later in the round. That matches the per-pass trace:specializeanddceeach reportedRewrittenon 300 consecutive rounds, then both went quiet.While the residue stands, the enclosing layer's right-hand side is a
Letrather than theJust 3it wraps, sopropagateKnownCtorThroughLetdeclines there — it needs a saturated constructor. The chain advances one layer,dceclears the residue, and the next round advances one more.The change
Dropping an unread binding becomes a rewrite as well, so a cascade completes inside the sweep that starts it:
The licence to discard an unread binding's right-hand side is the one
eliminateDeadCodealready takes: an effect run stays (dropping it would silently discard the effect), anything else is evaluated for a value nothing wants.Bindings are decided right to left and only a kept grouping contributes its references, so a binding kept alive solely by a binding that is itself dropped goes with it. The direction matters because a
Let's bindings scope sequentially — a binder is in scope for the groupings after it and the body, never the ones before (Note [Sequential scoping of Let bindings]). Recursive groups are left todce, which sees a dead group as a whole through module-level reachability.maxFixpointIterationsreturns to 100. With convergence no longer depth-scaled the bound can sit close to real work, so a pass that over-reports changes is caught in 100 sweeps instead of 1000.Verification
Round count, measured the same way as above. The three ~300-deep stress modules now converge in the same number of rounds as a three-deep chain, and the deepest fixpoint anywhere in the 348-example golden corpus takes 4:
optimize+dcespecialize+dceGolden.MaybeChainGolden.LongBindFlippedGolden.LongApplyChainGolden.LongWriterBindThe new cap is itself the regression guard: the stress goldens are ~300 layers deep, so any return to per-layer folding exceeds 100 rounds and the checked runner used by the test suite fails with
FixpointDivergencerather than merely getting slower.Compile time, best of three runs each,
spec --match <module>:Golden.LongApplyChainGolden.LongBindFlippedUnit-level pin (red before the fix, in its own commit). Eight layers nested one in another must fold to their final constant in a single
optimizedExpressionsweep. Verbatim failure without the fix — one layer folded, the other seven untouched:Goldens. 346 of 348 byte-identical.
Golden.LongWriterBindmoves, in the direction the collapse intends: its 200-deepWriterchain used to compile to a nest of closures overtell/discardplus theData.Identitydictionary tables, and now compiles to straight-line code — 847 lines of Lua down to 234:The accumulated result is now the literal
42, computed at compile time. The module'seval/golden.txtoracle (42) is unchanged, so the collapse is semantics-preserving where execution checks it.Four existing expectations in the optimizer spec moved with the rule. Each asserted the pre-
dceresidue —let1 name inlinee inlineewhere the paste had just consumed the body's only reference — and now expects the bareinlinee. They still pin the paste: a result that kept theLetwould mean the inlining never happened. One of them becomes identical to its beta-reduced sibling two tests down, which previously already expected the bareliteralInt 5.cabal test allis green (1247 examples). The optimizer's Hedgehog groups were re-run under 14 fresh seeds, all clean with no hangs. A forced full recompile of the library and test suite emits no warnings;hlintreports no hints.Sibling audit
The shape here is "a rewrite leaves residue only a later pass clears, so a cascade stalls at one layer per round". Searched the rest of the rule set for the same shape:
LetValues(the multi-value binding CPR introduces) has the same asymmetry:eliminateDeadCodecollapses one whose every binder is dead, and no rule in the sweep does. No instance shows up in the corpus — thespecialize+dce-post-cprfixpoint peaks at 2 rounds across all 348 goldens — and this PR's rule deliberately does not cover it, since the red would have to be a shape I invent rather than one the corpus produces. Recorded as a follow-up rather than fixed blind.blankDeadSuffix, also DCE-only) does not gate any fold: the rules match on node structure, not on binder names.withBindingfolds all bindings in one pass, substituting into later ones, so a chain of top-level bindings does not need a round each. No depth scaling observed.