Skip to content

Absorb the magic-do thunk despite an unforced saturated worker call #360

Description

@Unisay

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    OptimisationA Compiler Optimisationarea: irIR / optimizer / DCE / inlinerenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions