FloatIn: an IR-level replacement for pushDeclarationsDownTheInnerScope - #151
Merged
Conversation
Adds the IR-level float-in pass for issue #136, replacing the disabled pushDeclarationsDownTheInnerScope Lua-AST rule: sinks a Let-bound value into the single IfThenElse branch that uses it, never across a lambda. Not yet wired into the optimizer pipeline, so this commit is a pure no-op on existing goldens; wiring and golden churn come next.
Runs once between the post-merge optimize/dce fixpoint and magicDo. None of the existing golden fixtures happen to exercise the shape this pass targets (a multi-use, non-inlinable Let binding confined to one IfThenElse branch), so this adds a small new golden with a runnable eval oracle demonstrating the sink: the generated Lua now declares the shared local inside the `if` branch instead of above it.
The IR-level float-in pass now covers this rule's intended purpose without its sharing bug, so the disabled Lua-AST rule (PR #137) and its pinning test can go.
prop (parametrized by maxSuccess) moves to Test.Hspec.Hedgehog.Extended; the wrap-as-main-export pass runner and the empty UberModule fixture move to Language.PureScript.Backend.IR.SpecUtils. The DCE and FlattenDeepBinds specs use them now; the FloatIn spec follows in the next commits instead of adding a third copy. Claude-Session: https://claude.ai/code/session_01Bfeu6sp2zVhvuQTnEzYpNG
The fixture was observationally inert: with a pure right-hand side the eval output could not distinguish a correct sink from a missed or unsound one, so the oracle never actually guarded the reordered-evaluation policy the pass relies on. The new tick foreign prints a line every time it is evaluated, and pickShared routes all uses of the shared binding through a lambda called twice inside one branch. The pinned output now proves the binding runs exactly once inside the taken branch — the sink stops above the lambda, preserving sharing, where the #136 bug shape would print twice — and never on the branch not taken. Claude-Session: https://claude.ai/code/session_01Bfeu6sp2zVhvuQTnEzYpNG
The top-down driver decided an outer Let before its children were rewritten: an inner sink that confined a name's remaining uses to a single branch unblocked the enclosing binding only on the next run of the pass, so floatIn . floatIn kept finding work floatIn left behind (~6 of 20000 generated expressions) and the "is idempotent" property was a live CI flake. Rewritten bottom-up (transformOf subexpressions), a Let decides its groupings' fate only after all its subexpressions are final, which makes one pass complete; this also closes the Recurse-escape gap (the #149 class) structurally, so the refloatLet workaround is gone. Also driven by the same review: - usesName, an early-exit occurrence scan (exact under the GUC), replaces building the full countFreeRefs map on every query; - the condition-use and RecursiveGroup negative tests are de-vacuized: the guard each one pins is now the only thing blocking the sink; - new pins: wrap-above-Abs placement inside a branch (the #136 bug shape), the uberModuleBindings rewrite path, and inner-sink-unblocks-outer with an explicit double-application check; - the pipeline comment no longer claims float-in cannot re-open an optimize/dce opportunity (a sink can leave both branches of an IfThenElse alpha-equivalent for removeIfWithEqualBranches); the module header documents the bottom-up design, the corrected magicDo non-interference argument, and the parser-nesting vector backstopped by NestingCheck. Claude-Session: https://claude.ai/code/session_01Bfeu6sp2zVhvuQTnEzYpNG
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes the sharing/evaluation regression caused by the (now removed) Lua-AST optimizer rule pushDeclarationsDownTheInnerScope by replacing it with an IR-level “float-in” optimization. The new pass sinks Let bindings into the single IfThenElse branch that uses them while explicitly never sinking across lambdas, preserving sharing and evaluation timing.
Changes:
- Add
Language.PureScript.Backend.IR.FloatIn, an IR pass that sinks branch-confinedLetbindings without crossingAbsnodes. - Wire FloatIn into the IR optimizer pipeline after the post-merge optimize/DCE fixpoint and before
magicDo. - Add substantial test coverage (unit + property tests) and a new runnable golden fixture (
Golden.FloatIn) with an eval oracle to pin evaluation/sharing behavior; remove the superseded Lua optimizer rule and its test.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
lib/Language/PureScript/Backend/IR/FloatIn.hs |
Implements the new IR FloatIn pass and documents its semantics/termination and pipeline placement. |
lib/Language/PureScript/Backend/IR/Optimizer.hs |
Inserts FloatIn into the IR pipeline between optimize/DCE and magicDo. |
lib/Language/PureScript/Backend/Lua/Optimizer.hs |
Removes the obsolete Lua-AST rewrite rule that caused the regression. |
test/Language/PureScript/Backend/IR/FloatIn/Spec.hs |
Adds unit and property tests for FloatIn (GUC preservation, free refs, idempotence, bound names). |
test/Language/PureScript/Backend/IR/SpecUtils.hs |
Introduces shared helpers for running whole-module passes over single expressions in specs. |
test/Test/Hspec/Hedgehog/Extended.hs |
Adds prop helper to run Hedgehog properties with configurable maxSuccess. |
test/Language/PureScript/Backend/IR/DCE/Spec.hs |
Refactors to reuse SpecUtils and the shared prop helper. |
test/Language/PureScript/Backend/IR/FlattenDeepBinds/Spec.hs |
Refactors to use the shared prop helper (removes local duplicate). |
test/Main.hs |
Registers the new FloatIn spec in the test runner. |
test/Language/PureScript/Backend/Lua/Optimizer/Spec.hs |
Removes the pinning test for the deleted Lua optimizer rule. |
test/ps/src/Golden/FloatIn/Test.purs |
New PureScript golden designed to exercise FloatIn’s evaluation/sharing behavior. |
test/ps/src/Golden/FloatIn/Test.lua |
FFI tick helper used by the eval oracle to observe evaluation frequency. |
test/ps/output/Golden.FloatIn.Test/corefn.json |
Generated CoreFn fixture for the new golden module. |
test/ps/output/Golden.FloatIn.Test/golden.ir |
IR golden output for the new golden module. |
test/ps/output/Golden.FloatIn.Test/golden.lua |
Lua golden output for the new golden module. |
test/ps/output/Golden.FloatIn.Test/eval/golden.txt |
Hand-verified eval oracle output pinning the intended evaluation policy. |
test/ps/output/Golden.FloatIn.Test/eval/.gitignore |
Ignores runtime-produced actual.txt for the eval oracle harness. |
pslua.cabal |
Exposes the new library module and includes the new specs/helpers in the test suite. |
changelog.d/20260703_190000_unisay_float_in.md |
Changelog fragment describing the behavior change and its motivation. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Yura <1009751+Unisay@users.noreply.github.com>
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 #136.
Summary
pushDeclarationsDownTheInnerScope(a Lua-AST rule, disabled in #137) sank alet-bound declaration into the body of the returned lambda. That moved evaluation from partial application into every call of the returned function, losing sharing and delaying errors until the function was actually invoked.This PR replaces it with an IR-level pass,
Language.PureScript.Backend.IR.FloatIn, that sinks aLet-bound value into the singleIfThenElsebranch that uses it. Because it operates on the IR, lambdas are visible as distinctAbsnodes rather than opaque closures, so the pass can rule out exactly the shape that broke sharing: a binding is never sunk across a lambda.Three correctness issues surfaced during design and review, all covered by regression tests:
Let-to-Lettransit is not itself progress. Without this rule,let y = a in let x = b in x + ywould swap the two bindings on every run. A transit only counts once the descent past it crosses anIfThenElseboundary.Letbefore its children are rewritten, so an inner sink that confines a name's uses to one branch unblocks the enclosing binding only on a second run (a live flake in the idempotence property, ~6% of generated expressions), and a fully-collapsingLetexposes its body to no further rewriting (the "Recurse-escape" class fixed for DCE in First-class passes for the IR pipeline: Pass values, invariant checks, deterministic name supply #149). The pass therefore rewrites bottom-up (transformOf subexpressions): one pass is complete and idempotent, checked by a property and by unit regressions.Abs, never inside it. Pinned by a unit test and by the eval oracle below.None of the existing golden fixtures happen to exercise the shape this pass targets (a multi-use, non-inlinable
Letbinding confined to oneIfThenElsebranch, since the ordinary inliner already handles the single-use case). This PR adds a small new golden (Golden.FloatIn) with a runnable eval oracle that observes the evaluation policy, not just the computed value: atickforeign function prints a line every time it is evaluated, and the fixture routes all uses of the shared binding through a lambda called twice inside one branch. The pinned output proves the binding runs once inside the taken branch (sharing preserved, so the sink stopped above the lambda), and never on the branch not taken.Changes
lib/Language/PureScript/Backend/IR/FloatIn.hs(new): the pass. Free-reference tests use an early-exit occurrence scan (usesName, exact under the GUC) instead of building fullcountFreeRefsmaps per query.lib/Language/PureScript/Backend/IR/Optimizer.hs: wires it in between the post-merge optimize/dce fixpoint andmagicDo.test/Language/PureScript/Backend/IR/FloatIn/Spec.hs(new): 20 unit cases plus 4 property tests (GUC preservation, free-reference preservation, idempotence, bound-name preservation) against generated expressions. The idempotence property was additionally hammered offline on 20k generated expressions.test/Language/PureScript/Backend/IR/SpecUtils.hs(new) and a parametrizedpropinTest.Hspec.Hedgehog.Extended: spec helpers shared with the DCE and FlattenDeepBinds specs instead of a third copy.test/ps/src/Golden/FloatIn/Test.purs(new, with.luaFFI) and its golden/eval fixtures.lib/Language/PureScript/Backend/Lua/Optimizer.hs: deletes the now-supersededpushDeclarationsDownTheInnerScoperule and its pinning test.Test plan
cabal test all --test-show-details=directpasses (365 examples, 0 failures)fourmolu -i lib/ exe/ test/andhlint lib/ exe/ test/cleanGolden.FloatIneval oracle passes, pinning both the computed values and the singletickevaluation inside the taken branch