Skip to content

feat(optimizer): admit saturated n-ary worker calls to the call-site inliner - #337

Merged
Unisay merged 2 commits into
mainfrom
issue-245/nary-call-site-inlining
Jul 28, 2026
Merged

feat(optimizer): admit saturated n-ary worker calls to the call-site inliner#337
Unisay merged 2 commits into
mainfrom
issue-245/nary-call-site-inlining

Conversation

@Unisay

@Unisay Unisay commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #245.

The uncurrying pass splits a curried top-level function into an n-ary worker plus a curried wrapper and rewrites saturated call sites into direct worker calls:

add = λx. λy. x + y             -- curried source binding; after the split:
add$w = λ(x, y). x + y          -- n-ary worker
add   = λx. λy. add$w(x, y)     -- curried wrapper delegating to it
add n1 n2  ⟶  add$w(n1, n2)    -- saturated sites call the worker directly

The budgeted call-site inliner (inlineSaturatedCall, issue #180) matches sites by peeling the curried unary spine, so these n-ary nodes were invisible to it:

App (App (Ref add) a) b   -- curried spine: peels to (Ref add, [a, b]) — matched
AppN (Ref add$w) [a, b]   -- n-ary node: not a spine link, nothing peels — never matched

#282 added an AppN match, widened slightly by #211, but its body gate admitted only what is free to re-evaluate — primop trees over parameters and write-once field reads; every other worker kept paying for the shared call:

add3$w      = λ(x, y, z). (x + y) + z       -- primops over parameters: admitted
addFields$w = λ(x, y). x.foo + y.bar        -- write-once field reads: admitted
callAdd$w   = λ(x, y). g (x + y)            -- an application may hide work: declined
pickOr$w    = λ(x, y). if x then y else 0   -- a branch: declined

This PR relaxes that gate to the guards the curried tier already uses. Verbatim, the whole match:

AppN ann (Ref _ fname) args
  | HeuristicPastes  pastes
  , _ : _ : _  toList args
  , Nothing  directedArity fname
  , Just rhs@(AbsN _ params _)  Map.lookup fname env
  , length args == length params
  , expSize rhs <= inlineSizeBudget
  , -- A multi-value body is never pasted (see 'containsMultiValue').
    not (containsMultiValue rhs)
  , countFreeRef fname rhs == 0 
      (\rhs'  Just (AppN ann rhs' args)) <$> freshenBinders rhs

Its three ingredients, in order. A manifest AbsN of exactly matching arity — workers are saturated by construction, so a mismatched argument count marks a shape the pipeline did not produce and the site is left shared:

AppN (Ref pick3$w) [a, b]         -- under-applied 3-ary worker: left alone
AppN (Ref pick3$w) [a, b, c, d]   -- over-applied: left alone
AppN (Ref pick3$w) [a, b, c]      -- saturated: pasted

expSize within inlineSizeBudget, the same per-paste ceiling the curried tier prices every paste with (GHC's unfolding-use-threshold analogue, sized in IR nodes):

inlineSizeBudget  Natural
inlineSizeBudget = 64

And no multi-value body — the mark of a CPR worker (issue #206), which returns its constructor's fields as Lua multiple values. A Values node is legal only in a multi-value slot, so the named call must stay: pasted and reduced, the tail would sit loose in a single-value slot, where Lua's explist rules silently keep only its first value:

pair$w = λ(x, y). Values [x, y]   -- result-split worker
AppN (Ref pair$w) [a, b]          -- the call is fine: it sits behind a LetValues
  ⟶ Values [a, b]                 -- pasted + reduced: loose in a single-value slot — declined

The paste lands under the original AppN node, so the exact-arity beta reduction consumes it in the same pass:

AppN (Ref add$w) [a, b]
  ⟶ AppN (λ[x, y]. x + y) [a, b]   -- paste under the original AppN node
  ⟶ a + b                          -- exact-arity betaReduce, same pass

Small workers — dictionary-method residues, constructor workers, tiny helpers — now dissolve into their call sites, where the pasted bodies meet the constructor and primop folds and often reduce to constants. Verbatim from Golden.Primops.Test/golden.lua, where a logShow worker call over a known Show dictionary previously survived whole:

-- before
local _ = Effect_Console_logShow_S_w({
  show = function(v_S_1)
    local _S_cse1 = v_S_1[1]
    if "Data.Ordering∷Ordering.LT" == _S_cse1 then
      return "LT"
    elseif "Data.Ordering∷Ordering.GT" == _S_cse1 then
      return "GT"
    else
      return "EQ"
    end
  end
}, { "Data.Ordering∷Ordering.LT" })()
-- after
local _ = Effect_Console_log("LT")()

Constructor workers take the same path: an arity-≥2 constructor's worker is λ(p₁,…,pₙ). Ctor [p₁,…,pₙ], so its saturated calls beta-reduce to in-place table builds, extending to n-ary constructors what the curried tier already did for arity-1 ones. Verbatim from the linked Bench.CtorBuild.lua (the only bench artifact that changes at all — every other one is byte-identical):

-- before
local Bench_CtorBuild_V_S_w = function(value0, value1, value2)
  return { value0, value1, value2 }
end
...
      return Bench_CtorBuild_V_S_w(i_S_0, i_S_0 + 1, i_S_0 + 2)
-- after
      return { i_S_0, i_S_0 + 1, i_S_0 + 2 }

The growth-veto ladder

The first cut of this change regressed Golden.NativeLoopsGuard: its loop guard, previously folded through the Ord Int dictionary down to v_S_0 >= 0 and v_S_0 ~= 0, came back as a materialized dictionary call, "Data.Ordering∷Ordering.GT" == (Data_Ord_compare(Data_Ord_ordInt)(v_S_0)(0))[1]. The cause is the growth veto (the per-expression speculate-measure-revert of issue #221): the new worker pastes pushed the sweep past the expression's growth allowance, and the all-or-nothing revert redid the sweep with every heuristic tier disarmed — throwing away the collapsing dictionary-method pastes along with the non-collapsing worker pastes that caused the overrun.

The veto's fallback is therefore now a ladder. A vetoed all-tiers sweep is first redone with only the n-ary worker tier disarmed (CurriedPastesOnly — exactly the arming this compiler had before this change, so no expression can come out worse than it did before), and only if that sweep also overruns does it fall to DirectedPastesOnly, where nothing heuristic pastes. The cost is one extra sweep per doubly-vetoed expression; NativeLoopsGuard folds again.

Measurements

Bench corpus (./bench/link): 13164 → 13023 bytes (−1.1%), all of it Bench.CtorBuild (2055 → 1914, −6.9%). Its wall-clock medians (run_macro, pinned): 0.1592s → 0.1520s (−4.5%) under PUC Lua 5.1, 0.0753s → 0.0729s (−3.2%) under LuaJIT. Every other macro artifact is byte-identical, and the timing deltas measured for them sit inside rerun noise. The deterministic LuaJIT counters (./bench/ci) move only for Bench.CtorBuild: one prototype and one load-time FNEW fewer (the worker binding is gone), one more static function-body TNEW site (the table build now sits at the second call site instead of behind the shared call — per-call allocations are unchanged), no new trace aborts and no new blacklists.

Structural goldens: 26 modules move. Excluding the two adversarial stress goldens the golden.lua corpus shrinks 0.7% in bytes. Golden.LongStackBind grows +55% and Golden.LongStateBind +14%: their transformer chains never fold, so each pasted worker body survives whole — e.g. every put(add$w(x, 1)) step becomes the closure put allocated per call anyway, one call fewer per step at the price of printed size. That trade is the growth veto's documented blind spot (its dial measures IR nodes per sweep, and these pastes stay under it while inflating printed lines); Note [Bounded call-site inlining growth] already names measurement at fixpoint convergence as the future fix. Eval goldens are unchanged throughout — the semantic oracle never moved.

Verification

The new IR Optimizer spec block (dissolves budgeted n-ary workers into saturated call sites (#245)) was written first and confirmed red against the old gate: a worker whose body applies a function and a worker whose body branches both dissolve into two saturated sites (previously pinned as kept by the #211 cheap-worker tests, which these replace). Guard pins confirm an under- or over-applied n-ary site is left alone (the rule pastes at exact arity only), a worker past inlineSizeBudget stays shared even in a host large enough that the growth veto alone would admit it, and a multi-value worker is never pasted. The #24 composition test keeps its worker past the budget so it still pins what it was written for (the wrapper dies, the worker stays shared). Full suite green; the optimizer and adjacent IR spec groups were re-run 10×/5× with fresh seeds, no failures and no hangs.

…inliner (#245)

The budgeted call-site inliner matched an n-ary worker call — the direct AppN(f$w, args) call the uncurry split mints — only when the worker body was a bare tree of primops over trivial operands. The gate is now the same one the curried tier uses: a manifest AbsN of exactly matching arity within inlineSizeBudget, minus multi-value bodies (a result-split worker's Values tail pasted into expression position would truncate in a single-value slot). Small workers — dictionary-method residues, constructors, tiny helpers — dissolve into their call sites, where the pasted bodies meet the constructor and primop folds and frequently reduce further, down to constants.

The growth veto's fallback becomes a ladder: a sweep whose pastes overrun the expression's growth allowance is redone with the n-ary worker tier disarmed (CurriedPastesOnly, exactly the pre-change arming) before every heuristic tier is disarmed (DirectedPastesOnly). Without the middle rung, non-collapsing worker pastes drag the collapsing dictionary-method cascade down with them in the all-or-nothing revert: Golden.NativeLoopsGuard's loop guard regressed from the folded 'v >= 0 and v ~= 0' back to a materialized Ord dictionary call until the rung restored it.

Measured on the bench corpus, only Bench.CtorBuild moves: the V$w constructor worker dissolves into in-place table builds (-6.9% bytes, one prototype and one load-time FNEW fewer, -4.5%/-3.2% median wall clock under PUC Lua/LuaJIT); every other artifact is byte-identical. The golden corpus shrinks 0.7% in bytes outside the two adversarial stress goldens; Golden.LongStackBind (+55%) and Golden.LongStateBind (+14%) trade printed size for one call fewer per site by pasting the closures the workers allocated per call anyway — the growth veto's documented per-sweep line-blindness. Eval goldens are unchanged.
@Unisay Unisay self-assigned this Jul 28, 2026
@Unisay
Unisay marked this pull request as ready for review July 28, 2026 14:18
…nCtorThroughLet

The doc said 'inlineSaturatedCall' leaves the Data.Either.Right worker in place because its Ctor RHS is not a lambda. Constructor bindings are lambdas over saturated Ctor bodies (Note [Constructor applications are saturated]) and the call-site inliner does paste them at saturated sites — the curried arity-1 binding and, since the n-ary worker tier, the arity-≥2 worker alike. The enduring reason for the through-a-reference resolution is different: heuristic pastes are disarmed in the growth veto's fallback sweeps while the env-reading folds keep firing there, and resolving only arity and tag introduces no Ctor node, so non-folding chains are not pessimised. State that instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Call-site inliner: admit saturated n-ary worker calls

1 participant