Problem
pslua represents an Effect/ST action as a nullary Lua thunk, and the absorbEffectThunk pass (Language.PureScript.Backend.IR.AbsorbEffectThunk, #265) removes an allocation of that thunk per executed statement. It applies to a worker — the n-ary Lua function the uncurrying split extracts from a curried binding — whose body is such a thunk, by moving the thunk's parameter onto the worker's own parameter list, so a statement site w(a, b)() becomes the single call w(a, b, run).
Widening a worker's arity is only safe if every reference to it is rewritten too, so the pass first accounts for all of them and declines the binding unless each is either a statement site it rewrites or the delegate call of the binding's curried wrapper, which it grows by a matching parameter:
accounted ∷ Qualified Name → Int → Bool
accounted q _arity =
count forced q > 0
&& count refs q == count forced q + count wrappers q
The veto is therefore total: one reference of any other shape and the whole binding keeps its thunk, at every site. The shape that trips it most often in real code is a recursive effect action whose self-call is an argument rather than a statement, because an argument position is not forced:
walk :: Int -> Int -> Effect Unit
walk lo hi = do
logShow lo
when (lo < hi) (walk (lo + 1) hi)
Here when's second argument is the action walk (lo + 1) hi, which by the time this pass runs is the saturated worker call walk$w(lo + 1, hi) with no run applied to it. Every genuine statement site of walk is forced and would benefit, but that one argument reference vetoes all of them.
The reason for the veto is real but narrower than the veto itself. Rewriting an unforced saturated call means eta-expanding it — walk$w(lo + 1, hi) becomes \x -> walk$w(lo + 1, hi, x), which denotes the same action — and that moves when the arguments are read: before, they are read once where the thunk is built; after, on each call of the closure. For a wrapper's delegate call the arguments are the wrapper's own parameters, bound afresh per call and never reassigned, so the pass allows exactly that shape and nothing else. For an arbitrary call the arguments could in principle change between construction and run, which is why the boundary was drawn conservatively.
Approach
Admit an unforced saturated call whose arguments provably cannot change between the eta-expanded closure's construction and its call, and eta-expand it as the wrapper case already is. Arguments that are literals, or references to binders nothing ever reassigns, are the useful population; the delegate call the pass already accepts is the special case where this holds by construction.
The claim that needs establishing rather than assuming is what "nothing ever reassigns" means across the pipeline, because the property is not decided at the IR level where this pass runs. IR locals are immutable, but the Lua-level loop lowering (Language.PureScript.Backend.Lua.Loopify / NativeLoop) turns a self tail call into a while loop that reassigns the function's parameters — visible in the artifact this pass itself produces, where a driver's parameters are rebound per iteration:
local Bench_EffectStep_go_S_w = function(i, ref)
while true do
local _ = Bench_EffectStep_step_S_w(ref)
if i >= 1 and i ~= 1 then
i, ref = i - 1, ref
else
return Control_Monad_ST_Internal_read(ref)()
end
end
end
So an eta-expanded closure capturing i inside such a body, stored and run after an iteration, would read the later i. Whether that combination is reachable — the closure must escape its iteration for it to matter — is the question to settle first. Two routes: prove the escape cannot happen for the shapes admitted, or restrict admission to arguments the loop lowering demonstrably never rebinds. Either way the answer belongs in a Note [...] at the site, because it is a cross-level invariant that a future change to the loop lowering could break silently.
Verification / Measurement
Golden.EffectWorkerThunk already pins the veto from the outside: its deferred binding keeps the call-then-force shape because its worker call is bound as an action value and run later. That case must stay vetoed — the action is stored, so re-reading its arguments at run time is exactly the hazard — while a walk-shaped module with the self-call under when should move from vetoed to absorbed, both with their eval/golden.txt oracles unchanged. A golden pairing an admitted eta-expansion with a loopified enclosing worker is what would pin the cross-level claim, and is worth writing even if the analysis concludes the combination is unreachable.
For a number, the Bench.EffectStep2 macrobenchmark is the closest existing driver, but its step is already absorbed; a when-guarded recursive variant would be the spec that isolates this case.
Prerequisites / Relations
Builds on #265, which added the pass and drew the current boundary. Independent of #342 and of the code generator's own thunk shed.
Problem
psluarepresents anEffect/STaction as a nullary Lua thunk, and theabsorbEffectThunkpass (Language.PureScript.Backend.IR.AbsorbEffectThunk, #265) removes an allocation of that thunk per executed statement. It applies to a worker — the n-ary Lua function the uncurrying split extracts from a curried binding — whose body is such a thunk, by moving the thunk's parameter onto the worker's own parameter list, so a statement sitew(a, b)()becomes the single callw(a, b, run).Widening a worker's arity is only safe if every reference to it is rewritten too, so the pass first accounts for all of them and declines the binding unless each is either a statement site it rewrites or the delegate call of the binding's curried wrapper, which it grows by a matching parameter:
The veto is therefore total: one reference of any other shape and the whole binding keeps its thunk, at every site. The shape that trips it most often in real code is a recursive effect action whose self-call is an argument rather than a statement, because an argument position is not forced:
Here
when's second argument is the actionwalk (lo + 1) hi, which by the time this pass runs is the saturated worker callwalk$w(lo + 1, hi)with no run applied to it. Every genuine statement site ofwalkis forced and would benefit, but that one argument reference vetoes all of them.The reason for the veto is real but narrower than the veto itself. Rewriting an unforced saturated call means eta-expanding it —
walk$w(lo + 1, hi)becomes\x -> walk$w(lo + 1, hi, x), which denotes the same action — and that moves when the arguments are read: before, they are read once where the thunk is built; after, on each call of the closure. For a wrapper's delegate call the arguments are the wrapper's own parameters, bound afresh per call and never reassigned, so the pass allows exactly that shape and nothing else. For an arbitrary call the arguments could in principle change between construction and run, which is why the boundary was drawn conservatively.Approach
Admit an unforced saturated call whose arguments provably cannot change between the eta-expanded closure's construction and its call, and eta-expand it as the wrapper case already is. Arguments that are literals, or references to binders nothing ever reassigns, are the useful population; the delegate call the pass already accepts is the special case where this holds by construction.
The claim that needs establishing rather than assuming is what "nothing ever reassigns" means across the pipeline, because the property is not decided at the IR level where this pass runs. IR locals are immutable, but the Lua-level loop lowering (
Language.PureScript.Backend.Lua.Loopify/NativeLoop) turns a self tail call into awhileloop that reassigns the function's parameters — visible in the artifact this pass itself produces, where a driver's parameters are rebound per iteration:So an eta-expanded closure capturing
iinside such a body, stored and run after an iteration, would read the lateri. Whether that combination is reachable — the closure must escape its iteration for it to matter — is the question to settle first. Two routes: prove the escape cannot happen for the shapes admitted, or restrict admission to arguments the loop lowering demonstrably never rebinds. Either way the answer belongs in aNote [...]at the site, because it is a cross-level invariant that a future change to the loop lowering could break silently.Verification / Measurement
Golden.EffectWorkerThunkalready pins the veto from the outside: itsdeferredbinding keeps the call-then-force shape because its worker call is bound as an action value and run later. That case must stay vetoed — the action is stored, so re-reading its arguments at run time is exactly the hazard — while awalk-shaped module with the self-call underwhenshould move from vetoed to absorbed, both with theireval/golden.txtoracles unchanged. A golden pairing an admitted eta-expansion with a loopified enclosing worker is what would pin the cross-level claim, and is worth writing even if the analysis concludes the combination is unreachable.For a number, the
Bench.EffectStep2macrobenchmark is the closest existing driver, but its step is already absorbed; awhen-guarded recursive variant would be the spec that isolates this case.Prerequisites / Relations
Builds on #265, which added the pass and drew the current boundary. Independent of #342 and of the code generator's own thunk shed.