feat(ir): uncurry the shapes that only appear after magicDo and flattening - #264
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR enhances the PureScript→Lua IR optimization pipeline by rerunning the worker/wrapper uncurrying pass late (after flattenDeepBinds) so it can rewrite saturated call shapes that only appear after magicDo and flattening, then performing a final DCE sweep to drop newly-dead wrappers. It also updates effect-run detection so the rewritten n-ary worker calls remain protected as effect statements.
Changes:
- Rerun the Uncurry worker/wrapper split late in the optimizer pipeline and follow it with a single DCE pass.
- Extend
isEffectRunto recognize effect runs by a trailingEffectRunArg(including in n-aryAppNcalls). - Add/refresh tests, goldens, and a micro-benchmark covering the late-uncurry shapes.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/Language/PureScript/Backend/IR/Optimizer.hs | Adds a late uncurry pass (uncurry-late) after flattenDeepBinds, followed by a single DCE cleanup pass. |
| lib/Language/PureScript/Backend/IR/Types.hs | Updates isEffectRun to treat any AppN with trailing EffectRunArg as an effect statement. |
| lib/Language/PureScript/Backend/IR/Uncurry.hs | Makes the pass rerun-safe by recognizing previously-generated wrappers and reusing existing workers; tracks rewrite-only cases. |
| test/Language/PureScript/Backend/IR/Uncurry/Spec.hs | Adds coverage for rerunning the pass over already-split bindings and for taken-worker-name scenarios (top-level and local). |
| test/Language/PureScript/Backend/IR/Optimizer/Spec.hs | Adds a regression test ensuring use-once inlining does not inline an n-ary worker effect run (must remain a statement). |
| test/Language/PureScript/Backend/IR/DCE/Spec.hs | Adds a regression test ensuring DCE preserves an unreferenced n-ary worker effect run statement. |
| test/ps/src/Golden/UncurryEffect/Test.purs | New golden test source exercising late-uncurry behavior around magicDo-produced effect-run spines. |
| test/ps/output/Golden.UncurryEffect.Test/corefn.json | New committed CoreFn JSON for the new golden test module. |
| test/ps/output/Golden.UncurryEffect.Test/golden.ir | New IR golden showing late-stage shapes (including uncurried worker forms with trailing run markers). |
| test/ps/output/Golden.UncurryEffect.Test/golden.lua | New Lua golden showing the emitted Lua shape for the new test. |
| test/ps/output/Golden.UncurryEffect.Test/eval/golden.txt | New eval oracle pinning runtime behavior for the new golden test. |
| test/ps/output/Golden.UncurryEffect.Test/eval/.gitignore | Golden eval directory ignore file for actual output. |
| test/ps/src/Bench/EffectStep.purs | New benchmark module targeting the unary-effect-step shape that benefits from the late uncurry rerun. |
| bench/macro/effect_step.lua | New benchmark macro driver for Bench.EffectStep. |
| bench/goldens/trace_effect_step.txt | Baseline LuaJIT trace/abort report for the new benchmark. |
| bench/goldens/fnew_Bench.EffectStep.txt | Baseline LuaJIT FNEW site counts for the new benchmark. |
| test/ps/output/Golden.LongStateBind.Test/golden.lua | Golden churn reflecting $kont helper saturation now rewritten to worker calls. |
| test/ps/output/Golden.LongMaybeBindModule.Test/golden.lua | Golden churn reflecting $kont helper saturation now rewritten to worker calls. |
| test/ps/output/Golden.LongMaybeBind.Test/golden.lua | Golden churn reflecting $kont helper saturation now rewritten to worker calls. |
| test/ps/output/Golden.LongExceptBind.Test/golden.lua | Golden churn reflecting $kont helper saturation now rewritten to worker calls. |
| changelog.d/20260713_160000_unisay_late_uncurry_rerun.md | Changelog fragment describing the late uncurry rerun and effect-run protection changes. |
Unisay
added a commit
that referenced
this pull request
Jul 13, 2026
- test/ps/src/Golden/UncurryEffect/Test.purs:7 — module haddock now says countdown demonstrates the late split while the small tick inlines at its statement sites (#264 (comment)) - test/ps/src/Golden/UncurryEffect/Test.purs:17 — tick's comment describes the inline-before-split outcome instead of claiming a direct worker call (#264 (comment)) corefn.json regenerated for the shifted source spans; golden.ir/.lua and the eval oracle are unchanged.
The late uncurry run (#200) rewrites a saturated effect-run spine f(a)(b)(EffectRunArg) into the n-ary worker call f$w(a, b, EffectRunArg), burying the marker in the argument list. isEffectRun previously matched only the singleton application, so such a statement would lose its protections: dead-code elimination would drop it with its unreferenced binder, and local inlining would paste it past its statement position. Recognise the run by its trailing marker instead — the marker only ever terminates the spine it runs, so the trailing position is precise.
A second run of the worker/wrapper split meets its own earlier output, and re-splitting a wrapper would mint a duplicate $w binding — a GUC violation for locals and a shadowed binding at the top level. Classify candidates against the earlier run's output: a wrapper delegating to its own worker re-registers its arity, so saturated sites that appeared between the runs are still rewritten to the existing worker; a candidate whose worker name is otherwise taken (call-site inlining can paste a worker body back into its wrapper) is left alone entirely. The WasRewritten signal stays precise for the new no-split site rewrites. Groundwork for the late rerun of #200.
Close the pipeline with a second uncurry run (#200) plus a dce pass. The late run sees the two saturated families that do not exist at the early run's position: the effect-run spines magic-do completes — a unary effect action is below the split threshold until the thunk parameter lifts it to arity 2, and the worker absorbs that parameter, so a fully applied effect statement becomes one n-ary call instead of a curried call plus a thunk allocation and force — and the $kont helpers minted by flattening, saturated by construction. The cleanup is a single dce pass, deliberately not an optimize+dce fixpoint: optimize's use-once inlining would paste the $kont workers (each called exactly once) back into their call sites round by round, re-nesting exactly what flattenDeepBinds just flattened. Golden churn: the four Long*Bind modules pin the $kont workers; the new UncurryEffect golden pins the absorbed thunk parameter, the direct worker statement calls, the dce-protected result statements, and the wrapper surviving for value uses — with an eval oracle for semantics.
A hot ST loop through a unary effect step, always fully applied and immediately run — the family the late uncurry run (#200) splits. The step call is now one n-ary worker call per iteration instead of a curried call returning a freshly allocated thunk plus the call forcing it; the committed counters are the regression baseline. Wall-clock on this micro is flat: the step body's ST-ref reads and writes are curried FFI calls that dominate the saved call and allocation.
- test/ps/src/Golden/UncurryEffect/Test.purs:7 — module haddock now says countdown demonstrates the late split while the small tick inlines at its statement sites (#264 (comment)) - test/ps/src/Golden/UncurryEffect/Test.purs:17 — tick's comment describes the inline-before-split outcome instead of claiming a direct worker call (#264 (comment)) corefn.json regenerated for the shifted source spans; golden.ir/.lua and the eval oracle are unchanged.
Unisay
force-pushed
the
issue-200/late-uncurry-rerun
branch
from
July 13, 2026 14:29
9f530e2 to
0fa419e
Compare
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 #200.
The worker/wrapper uncurrying pass (#24) now runs a second time at the end of the IR pipeline, after
flattenDeepBinds, followed by a single dce pass. The late run catches the saturated call sites that do not exist at the early run's position: the effect-run spines magicDo completes, and the$konthelpers minted by flattening. The helpers are saturated by construction, so their curriedkontN(live)(x)calls becomekontN$w(live, x)and the wrappers die in the closing dce pass.One correction to the issue's premise surfaced while updating goldens. An effect function with two or more real arguments is already split by the early run, because its real-argument spine is saturated before magicDo runs:
bindE (f a b) kcontains the fullf(a)(b). For those functions the late run adds nothing; the thunk stays inside the early worker, and absorbing it there is a possible follow-up. The family the late run actually wins is the unary effect action.f :: A -> Effect Bhas manifest arity 1, below the split threshold, until magicDo appends the thunk parameter, and the late split absorbs that parameter into the worker: a fully applied effect statement compiles to the single callf$w(a)instead off(a)()with a thunk allocated per call. The newGolden/UncurryEffecttest covers this end to end, with an eval oracle for semantics.Three pieces of groundwork make the rerun sound:
isEffectRunnow recognises an effect run by its trailingEffectRunArginstead of only the singleton application. Without this, rewritingf(a)(b)(run)tof$w(a, b, run)would strip the statement's protections and dead code elimination would silently drop the effect. New DCE and inliner tests cover the protections; the Lua codegen already erased trailing markers from n-ary argument lists.$wbinding. It re-registers its arity instead, so saturated sites that appeared between the runs still reach the existing worker. A non-wrapper candidate whose$wname is taken is left alone entirely.$kontworkers (each called exactly once) back into their call sites one fixpoint round at a time, re-nesting exactly whatflattenDeepBindshad just flattened and tripping Lua's parser nesting cap again.Golden churn is confined to the four
Long*Bindmodules (the$kontsplit) plus the new test, and every eval oracle passes unchanged. The full suite is green (899 examples) and the randomized specs hold across five extra seeds. The newbench/macro/effect_stepbench records the improved shape and commits baseline counters. Wall-clock on that micro is flat: the step body's ST-ref FFI calls are still curried and dominate the saved call and thunk allocation, which is itself an argument for the follow-ups below.Two follow-up candidates came out of this work and stayed out of scope: absorbing the thunk parameter into workers the early run already split (the
logShow$w(x, true)()shape visible in the Primops golden), and distributing an effect-run application intoIfThenElsebranches, which an if-terminated effect loop still needs before loopification (#181) can turn it into awhileloop.