Skip to content

feat(optimizer): bound call-site inlining growth per expression - #286

Merged
Unisay merged 2 commits into
mainfrom
issue-221/bound-inlining-growth
Jul 23, 2026
Merged

feat(optimizer): bound call-site inlining growth per expression#286
Unisay merged 2 commits into
mainfrom
issue-221/bound-inlining-growth

Conversation

@Unisay

@Unisay Unisay commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Closes #221.

Problem

Every call-site paste is a bet. It pays off when the pasted method body meets a known-constructor fold and collapses; it loses when nothing folds. A product-type monad is the losing case by construction: State threads a single-constructor Tuple, no tag test exists, and the pasted bind/pure bodies survive whole at every step of the chain. The residual growth measured on the corpus was LongStateBind +275 and LongStackBind +285 lines against their pre-#180 baselines. A check at the paste site cannot decide the bet, as the issue lays out: the fold that would collapse a paste often needs several other pastes and reductions first, so the outcome only exists after the sweep has run.

Approach

The sweep itself becomes the speculation. optimizeExp runs the rewrite with all paste tiers armed, then compares expSize before and after against inlineGrowthBudget: a quarter of the input size, floored at smallInlineBudget. A sweep that stayed within the allowance is kept, since either the collapse happened or the growth is bounded. A sweep that grew past it is growth without collapse: its result and its change flag are both discarded, and the sweep is redone with the heuristic paste tiers disarmed (DirectedPastesOnly, threaded to inlineSaturatedCall and resolveDictionaryProp). Explicit @inline directives keep firing in the fallback sweep (a directive is the user overriding the heuristics), and so do the env-reading constructor folds, which only shrink.

The veto lives inside optimizeExp, so it covers every path that pastes: the per-binding sweeps, recursive-group members, exports (where the whole program concentrates in application mode), and the final re-sweep. The discarded sweep never reports Rewritten, so a converged module still reports Unmodified, the specialize fixpoint terminates, and the checked runner's contract holds. The baseline resets each round, which keeps the veto non-sticky: an expression vetoed in round N is re-attempted in round N+1 against the then-current environment.

Calibration

The dial is pinned from both sides of the golden corpus. It must reject the chains where every paste survives: the ExceptT-over-State stack of LongStackBind grows past a third of its host. And it must stay above the transient growth of a collapse that spans fixpoint rounds: the Either chain of LongEitherBind first grows when its binds paste and only folds a round later, once the environment carries its settled neighbours. Probing the dial showed that chain freezing at its unfolded worst (59 → 698 lines) anywhere at or below 16.7%, with its measured transient growth sitting between 16.7% and 20%: a quarter clears it with margin and still catches the transformer stack. Note [Bounded call-site inlining growth] records the mechanism, the dial rationale, and the contracts.

Results

Twenty-one goldens move, .ir and .lua only; every eval/golden.txt oracle is untouched and green. LongStackBind sheds the unrolled transformer machinery: 1187 → 888 lines, back at its pre-inliner 902-line baseline. TailRecM2Shadow drops 162 → 139, and StringCodePoints, NumberIsNaN, UncurryCtor, the two GenericEq goldens and six more lose paste residue. Four goldens grow by a few lines where a vetoed expression now keeps its workers shared instead of pasted (CprResult +9, LongApplyChain +11, CSE and DerivedFunctor +3 each). The zero-line movers carry only binder renumbering: a discarded speculative sweep still consumes name-supply freshness, which shifts the numbering downstream.

The plain State chain of LongStateBind stays as it is. Its growth is ~10% in IR nodes (many tiny pastes diluted in one huge host expression), which sits below any dial that keeps the Either chain collapsing: a 6.25% dial does shrink it to 747 lines, below the pre-inliner baseline, but freezes LongEitherBind. Catching it needs the measurement at fixpoint convergence, where transient growth is invisible; that is filed as #287.

Verification

Three unit specs written red-first pin the behavior: a 16-site product-monad dictionary chain keeps its dictionary shared (red before the change: the dictionary was pasted away and collected), a collapsing Maybe chain still dissolves end to end (green throughout), and an @inline always field still pastes inside a growth-vetoed expression (red before: no fallback sweep existed). The full suite is green (990 examples, 0 failures), including luacheck and the Lua differential; goldens were accepted with PSLUA_GOLDEN_ACCEPT=1 and re-verified with a clean run.

Unisay added 2 commits July 20, 2026 15:55
A call-site paste pays off when it meets a known-constructor fold and
collapses; a product-type monad (State over Tuple) has no tag to test,
so its pasted bind/pure bodies survive whole and a long chain grows
linearly with no payoff. Whether a paste collapses is unknowable at the
paste site — the fold may need other pastes and reductions that happen
later in the same sweep — so the sweep itself becomes the speculation:

* optimizeExp measures each call-site-inlining sweep against
  inlineGrowthBudget (a quarter of the input size, floored at
  smallInlineBudget). A sweep that grew past the allowance — growth
  without collapse — is discarded, result and change flag both, and
  redone with the heuristic paste tiers disarmed (DirectedPastesOnly).
  Explicit @inline directives and the env-reading constructor folds
  keep firing in the fallback sweep.

* The discarded sweep never reports Rewritten, so a converged module
  still reports Unmodified and the specialize fixpoint terminates; the
  veto is re-attempted each round against the then-current environment,
  so a collapse unlocked by a neighbour's settled form still lands.

* The dial is calibrated from both sides of the golden corpus: it must
  reject the transformer stack of LongStackBind (grows past a third of
  its host when nothing folds) and stay above the transient growth of a
  multi-round collapse (the Either chain grows just under a fifth
  before folding a round later; a lower dial freezes it unfolded).
  See Note [Bounded call-site inlining growth].

The plain State chain of LongStateBind (~10% node growth diluted in a
huge host expression) sits below any dial that keeps the Either chain
collapsing; catching it needs the measurement at fixpoint convergence
rather than per sweep — a follow-up.
Twenty-one goldens move, .ir and .lua only; every eval/golden.txt
oracle is untouched and green. LongStackBind sheds the unrolled
ExceptT-over-State machinery (1187 → 888 lines, back at its
pre-inliner size); TailRecM2Shadow, StringCodePoints, NumberIsNaN,
UncurryCtor, the GenericEq pair and six more lose paste residue. Four
goldens grow by a few lines where a vetoed expression now keeps its
workers shared instead of pasted (CprResult +9, LongApplyChain +11,
CSE and DerivedFunctor +3). Zero-line movers carry only the binder
renumbering that the discarded speculative sweeps shift.
@Unisay Unisay self-assigned this Jul 23, 2026
@Unisay
Unisay marked this pull request as ready for review July 23, 2026 07:27
@Unisay
Unisay merged commit bbe573f into main Jul 23, 2026
2 checks passed
@Unisay
Unisay deleted the issue-221/bound-inlining-growth branch July 23, 2026 07:39
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.

Bound call-site inlining growth on non-collapsing monad chains

1 participant