feat(optimizer): admit saturated n-ary worker calls to the call-site inliner - #337
Merged
Conversation
…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
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.
This was referenced Jul 28, 2026
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 #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:
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:#282 added an
AppNmatch, 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:This PR relaxes that gate to the guards the curried tier already uses. Verbatim, the whole match:
Its three ingredients, in order. A manifest
AbsNof 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:expSizewithininlineSizeBudget, the same per-paste ceiling the curried tier prices every paste with (GHC's unfolding-use-threshold analogue, sized in IR nodes):And no multi-value body — the mark of a CPR worker (issue #206), which returns its constructor's fields as Lua multiple values. A
Valuesnode 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:The paste lands under the original
AppNnode, so the exact-arity beta reduction consumes it in the 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 alogShowworker call over a knownShowdictionary previously survived whole: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 linkedBench.CtorBuild.lua(the only bench artifact that changes at all — every other one is byte-identical):The growth-veto ladder
The first cut of this change regressed
Golden.NativeLoopsGuard: its loop guard, previously folded through theOrd Intdictionary down tov_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 toDirectedPastesOnly, where nothing heuristic pastes. The cost is one extra sweep per doubly-vetoed expression;NativeLoopsGuardfolds again.Measurements
Bench corpus (
./bench/link): 13164 → 13023 bytes (−1.1%), all of itBench.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 forBench.CtorBuild: one prototype and one load-timeFNEWfewer (the worker binding is gone), one more static function-bodyTNEWsite (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.luacorpus shrinks 0.7% in bytes.Golden.LongStackBindgrows +55% andGolden.LongStateBind+14%: their transformer chains never fold, so each pasted worker body survives whole — e.g. everyput(add$w(x, 1))step becomes the closureputallocated 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 Optimizerspec 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 pastinlineSizeBudgetstays shared even in a host large enough that the growth veto alone would admit it, and a multi-value worker is never pasted. The#24composition 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.