fix(ir): bound the bottom-up rewrite driver's re-application loop (#348) - #350
Merged
Conversation
Ω = (λx. x x) (λy. y y) is the smallest untyped term whose every beta step reproduces it modulo binder names. The bottom-up rewrite driver re-applies its rules at a node until none fires, with no bound, so optimizing an export bound to Ω never returns: the group hangs at 99% of one core with a flat resident set instead of failing. Both cases are red on this commit and hang: the pipeline never comes back to be asked whether the result is well-scoped, and the invariant-checking pipeline never comes back to name the fixpoint that keeps firing.
rewriteExpBottomUpM rewrote a node's children, then re-applied the rule to its own result until it stopped firing — an unbounded loop, which beta reduction turns into a livelock on any term with no normal form. The loop now stops after maxNestedRewrites (100) nested rewrites along one path and returns the term it has reached. That is the treatment the pass-level fixpoint already gives its own iteration cap: every rewrite preserves semantics and the IR invariants on its own, so abandoning a redex mid-reduction costs optimization and nothing else, and the driver's Rewritten flag stays precise. Under the invariant-checking runner the residual redex surfaces as FixpointDivergence "optimize+dce" rather than as silence. The bound cannot reach legitimate work: measured over the whole suite (1232 examples, 344 of them golden compilations of real PureScript, the ~300-deep constant chains included) the deepest converging rule chain takes five nested rewrites. The golden corpus is byte-identical and `cabal test all` is green; seeds 109, 152 and 478 now finish the IR Optimizer group in about five seconds each.
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 #348.
The symptom
psluaoptimizes its intermediate representation (IR) by rewriting expression trees, and the rewrite rules are exercised by Hedgehog properties that generate random IR terms. Three of the first 500 hspec seeds — 109, 152 and 478 — made theIR Optimizerspec group stop emitting output and pin one core indefinitely. A complete run of that group costs 0.35 s; these were still spinning when killed at 120 s, with a flat 50 MiB resident set, so nothing was growing — the optimizer was looping.The last line printed is the test declared immediately before
optimization keeps expressions well-scoped, the property that feeds generated terms through the whole optimizer pipeline — so the loop was inside that property, and it hung rather than reddening, which is the easiest kind of failure to misread as flakiness.What was looping
The IR rewrite driver
rewriteExpBottomUpMwalks an expression bottom-up: it rewrites a node's children first, then applies the rule set to the node, and re-applies it to its own result until the rule set stops firing. That re-application loop had no bound.Among the rules that loop drives is
betaReduce, which reduces a redex(λx. M) NtoM[x := N]. Beta reduction has no terminating strategy on an arbitrary untyped term, and the generator draws untyped terms: it drew Ω =(λx. x x) (λy. y y), the smallest term with no normal form, whose every reduction step reproduces it modulo binder names. Instrumenting the loop to abort after 400 nested rewrites and print the node it was chewing on shows the cycle exactly — the two abstractions swap places and every binder index advances by one, because pasting a copy of the argument freshens its binders:All three seeds hit the same term. So this is not the non-converging rule pair the issue guessed at, and it is not a guard that
betaReduceis missing: no local test on a redex can decide whether reducing it terminates. The pass-level pipeline was never the problem either — its fixpoint combinator has carried a 1000-iteration backstop for a while. The unbounded loop was one level below it, inside a single pass, at a single node.The fix
The loop now carries a budget, spelled
maxNestedRewrites, and returns the term it has reached when the budget runs out:This is the treatment the pass-level fixpoint already gives its own iteration cap: stopping early costs optimization and nothing else, because each rewrite preserves semantics and the IR invariants on its own, so a term the driver abandons mid-reduction is exactly as valid as a fully reduced one. The driver's
WasRewrittenflag — which the enclosing fixpoint trusts to decide whether to iterate again — also stays precise: it reportsRewritteniff some rule fired, whether or not the loop ran to exhaustion.The budget is 100, and it cannot reach legitimate work. Instrumenting the driver to record the deepest nesting it ever reaches and running the whole suite gives 5 — 1232 examples, 344 of them golden compilations of real PureScript including the ~300-deep constant chains that stress the pipeline hardest. That is the population that matters, since the IR is assumed well-typed (
Note [IR is assumed well-typed]) and PureScript's type system rejects every term without a normal form; only the untyped terms the generators draw reach the bound at all.Diagnosability
Nothing is silent about a term that exhausts the budget.
optimizedUberModule(the production pipeline) returns a correct, under-optimized module, whileoptimizedUberModuleChecked(the same pipeline with every pass's contract linted, used by the test suite always and by the CLI behind--lint-ir) reports which fixpoint kept firing:Each round of that fixpoint genuinely changes the module — it advances Ω by another 100 beta steps — so the fixpoint runs its full cap and then names itself, which is the third acceptance criterion of the issue.
Tests
Two example-based tests replace the generator draw, so the fix is pinned by something that does not depend on a seed. Both hang on the parent commit; the test commit is deliberately red.
The first asserts the pipeline returns at all and that the abandoned term still satisfies the two IR invariants —
lintWellScoped(every local reference resolves to an enclosing binder) andlintUniqueBinders(no local binder name bound twice within a top-level site). The second pins theFixpointDivergencereport above.Verification
cabal test allis green (1234 examples),fourmoluandhlintare clean, and the golden corpus is byte-identical — nogolden.irorgolden.luamoved, which follows from the bound being 20× clear of the deepest chain any real compilation produces.The check the issue asks for is the sweep that found the stall, so here it is over the same range, with a timeout generous against the group's cost:
Every seed passes; the worst is seed 109 at 6 s. Two costs are worth naming rather than hiding. The group now takes about 3 s at every seed, up from 0.35 s, because the new example-based test deliberately drives the budget to exhaustion twice — the enclosing
optimize+dcefixpoint keeps spending its 1000 rounds advancing a term that will never converge, since each round does change the module. On top of that, the three seeds that draw Ω themselves add ~2 s (109 → 6 s, 152 and 478 → 5 s). Full-suite wall clock is unchanged within noise at 88 s. Making an exhausted budget stop the enclosing fixpoint at once would remove both, but it means wideningPass.passRun's result and its three runners, so it belongs in its own change.