feat(ir): absorb the magic-do thunk into early-split effect workers - #358
Merged
Conversation
…t statement (#265) An Effect action of two or more real arguments is saturated at its real arity before magic-do runs, so the uncurrying split fires there and the thunk magic-do builds ends up inside an already n-ary worker. Every fully applied statement site therefore compiles to a worker call that allocates the thunk plus the run that forces it. Golden.EffectWorkerThunk pins that shape from four sides: a plain case whose wrapper is dead, one whose wrapper survives as a value, a let-bound local worker, and an action bound to a name and run later — the shape whose arity may not be widened. The eval oracle pins the runtime output.
Bench.EffectStep drives a hot ST loop through a unary effect action, the case the late uncurry run splits. Bench.EffectStep2 is its two-argument sibling: with two real arguments the spine is already saturated when the early uncurry run measures it, so the split fires at the real arity and the thunk sits inside the worker, out of the late run reach. Every iteration pays the thunk allocation plus the call that forces it, which the committed counter oracles record as two NYI: bytecode FNEW trace aborts and an interpreted driver.
…265) An Effect/ST action of two or more real arguments is saturated at that arity before magic-do runs, so the uncurrying worker/wrapper split fires at the real arity and magic-do afterwards rewrites the worker's body into the nullary thunk an Effect value is. Every fully applied statement site then allocates that thunk and immediately forces it, two Lua calls with a closure in between. The late uncurry run cannot repair it: it splits manifest lambda chains, and the thunk is inside a worker that is already n-ary. absorbEffectThunk widens such a worker in place — the thunk's parameter joins its parameter list, its body becomes the worker's body — and rewrites every forced site w(a...)(run) to the single call w(a..., run), still an effect run by its trailing marker. Each wrapper delegating to the worker grows one parameter, which the delegate passes on, so a partial application still evaluates to a closure. The precondition is that every reference is a forced site or such a delegate, with at least one forced site: the Lua backend drops the worker's trailing unused parameter run, so any other reference the wider arity leaves under-applied would become a saturated Lua call and run the effect at construction time. Absorbing the marker also makes a recursive driver's self-call a genuine tail call, so the native-loop lowering turns it into a Lua while loop. Bench.EffectStep2 gains 1.22x under PUC Lua 5.1 and 1.28x under LuaJIT; Bench.EffectStep, whose two-argument driver is the same case, gains 1.10x/1.14x, and both trace reports lose their thunk-allocation aborts with the driver ending as a compiled ILOOP. The pass runs last, after the late uncurry run and the dce that follows it: nothing later moves a call, so the reference census is final, and the wrappers whose sites all went to their workers are already gone.
Unisay
force-pushed
the
issue-265/absorb-magicdo-thunk-into-workers
branch
from
July 29, 2026 18:22
0c1f399 to
c724f89
Compare
Unisay
marked this pull request as ready for review
July 30, 2026 09:07
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 #265.
The problem
psluacompiles PureScript'sEffectandSTthe way every PureScript backend does: an action is a nullary thunk, and running it is calling that thunk. Adoblock is a chain ofbindapplications with lexically nested continuations, and the magic-do pass (Language.PureScript.Backend.IR.MagicDo) flattens such a chain into one thunk holding a flat statement sequence —function() local x = m1(); …; return last() end.An earlier pass in the pipeline, the uncurrying worker/wrapper split (
Language.PureScript.Backend.IR.Uncurry), turns a curried binding into a worker — one n-ary Lua function holding the original body — plus a wrapper that keeps the original name and delegates to it, and rewrites every saturated call site to a direct worker call. That split runs before magic-do. An effect function of two or more real arguments is therefore already saturated at its real arity when the split measures it, so the split fires there and magic-do afterwards rewrites the worker's body into the thunk. Every fully applied statement site ends up as two Lua calls with a closure allocation in between:The worker call allocates the thunk over
tag/n; the trailing()immediately forces it. Nothing else ever sees that closure.The second, late run of the uncurry pass (added for #200) cannot repair this. It splits manifest chains of unary lambdas, and here the thunk sits inside a worker that is already n-ary, so there is no chain left to split. That run only absorbs the thunk parameter of the actions the early run left alone — the unary ones, below its arity floor of two.
The change
A new pass,
Language.PureScript.Backend.IR.AbsorbEffectThunk, widens such a worker in place: the thunk's parameter joins the worker's parameter list and the thunk's body becomes the worker's body.Each forced site loses its outer call —
w(a₁…aₙ)(run)becomesw(a₁…aₙ, run). That is still recognised as an effect run by its trailing marker (isEffectRun, so dead-code elimination keeps a statement whose binder is unreferenced) and it is still one Lua call, because the backend erases the marker from an n-ary argument list. The site above becomes:Each wrapper — the curried delegate the split left under the original name — grows one parameter, which the delegate call passes on, so a partial application still evaluates to a closure (the action) with the new innermost lambda playing the thunk's role:
Both rewrites are local, and the pass keys on the shape rather than on the
$wnaming scheme, so any producer of an n-ary worker whose body is a thunk is covered — including the$konthelpers the deep-bind flattening mints and the copies call-pattern specialization makes.The precondition
The extension fires only for a worker with at least one forced site and whose every reference is either a forced site or a wrapper's delegate call. Any other reference vetoes the whole binding, because the Lua backend drops a trailing unused parameter run: the widened worker still compiles to
function(p₁…pₙ), so a call left under-applied by the wider arity is a saturated Lua call and would run the effect at construction time instead of returning the action. The shape that most often vetoes a binding is an action bound to a name and run later:Golden.EffectWorkerThunkpins that case unchanged next to the three that do fire.The pass runs last in the IR pipeline, after the late uncurry run and the dead-code pass that follows it. Nothing later moves a call, so the reference census the precondition needs is final; and the wrappers whose sites all went to their workers are already gone, so no doomed wrapper is grown.
A tail call falls out
Taking the run marker into the argument list also turns a recursive driver's self-call into a genuine tail call, which the native-loop lowering then turns into a Lua
while.Bench.EffectStep's driver — a two-argument effect action, so exactly this pass's case — went from a forward-declared recursive closureto a flat loop:
Measurements
Bench.EffectStep2is new here: a hotSTloop whose per-iteration step is a two-argument effect action, the sibling of the existing unaryBench.EffectStep. Medians frombench/tools/run_macro.lua,n=100000, identicalresult=1500000on both sides:effect_step2effect_step2effect_stepeffect_stepThe other ten macrobenchmarks are unchanged within noise, and every one returns the same value as before.
The committed LuaJIT counter oracles record the JIT-level reason.
effect_step2loses the two closure-allocation trace aborts and its driver ends compiled rather than interpreted:effect_stepmoves the same way (aborts=5 → 4,blacklisted=6 → 5,IFUNCF → ILOOP). TheTNEW/TDUPtable-allocation censuses are byte-identical everywhere: the pass touches closures, not tables.Tests
Test-first, in three commits: the golden pinning the two-call shape, the benchmark with its oracles at that state, then the pass — so the feature commit carries the whole codegen delta as a reviewable diff.
Golden.EffectWorkerThunk— four shapes in one module: an action whose wrapper is dead (the plain win), one also passed as a value so its wrapper survives and must grow, alet-bound local worker, and an action bound to a name and run later (vetoed).eval/golden.txtis the hand-verified runtime oracle and is unchanged by the pass in either direction.Golden.NativeLoopsGuardis the one pre-existing golden carrying this shape; itswhileE$wloses the closure.IR AbsorbEffectThunk— six focused cases over the pass itself: worker extension, wrapper growth, local extension, and the three veto shapes (no forced site, an unforced saturated call, a bare reference). Against an identity-stubbed pass the three positive cases fail and the three vetoes pass, which is what makes the suite evidence rather than assertion; both structural goldens fail too, while the eval oracle stays green.IR OptimizerHedgehog group and the uncurry/pass/DCE groups were each re-run twelve times with fresh seeds. The pipeline's per-pass linter checks the new pass's output for well-scopedness, globally unique binders, and the n-ary application/abstraction invariants on every golden.Sibling audit
Three passes genuinely mint multi-parameter
AbsNnodes: the uncurry split, the CPR result split, and call-pattern specialization. A CPR worker's every return path builds a constructor lowered to Lua multiple values, never a lambda, so it cannot carry this shape; specialization copies a candidate's body, so a specialized copy of a thunk-returning worker is covered by the same structural recogniser as the original. The otherAbsNsites (linker, DCE, CSE, renumber, uniquify) rebuild an existing node and cannot introduce one. No unhandled instance of the shape remains — which is why the recogniser keys on structure rather than on the$wsuffix.The audit did surface the dual shape, out of scope here: a literal thunk immediately forced,
(function() … end)(), 141 occurrences across the golden corpus. Codegen already collapses it when the thunk's body is a single application; the remaining cases have aLetbody and are deliberately left thunked, because splicing their locals into the parent function threatens Lua 5.1's ~200-local ceiling that magic-do's chunking exists to respect. A budgeted version — splice when the parent's local count allows — needs a new local-count analysis, so it is follow-up work rather than part of this diff.