Extend loopification to mutual recursion and let-bound join points - #300
Merged
Conversation
…ints Two new runnable golden modules pin what issue #234 improves, before the change: a mutual group compiles to forward-declared locals making real cross-calls per transition, and a let-bound tail-only helper keeps its function shell plus an entry call. Their eval oracles are hand-written and hold across the upcoming flip; only golden.lua discriminates.
Extends #181's loopification to the two shapes it left as calls. A recursive group's mutual tail-call cycles (the strongly-connected components of the spine tail-call graph, size two or more) lower to one while-true dispatcher over a branch selector plus shared argument slots; each transition is one simultaneous assignment. The member bindings survive as entry wrappers delegating to the dispatcher, so every other reference — non-tail uses, the uncurrying wrappers left in the group, external callers — stays valid without a use analysis, and the uniform-tail-call test is profitability, not soundness. Branches rebind their member's parameters as per-iteration locals, so no capture veto is needed. A chunk-local helper only ever tail-called from the chunk's spine (a join point) loses its function shell: the parameters hoist as chunk locals, each entry call becomes an assignment falling through into the helper's body at the chunk's end. A self-recursive helper arrives already loopified, so the entry assignments fall straight into its loop; fusion iterates, flattening join-point chains. Conservative vetoes keep it sound and composable: every use accounted for, no falling off the chunk's end, and no burying of spine tail calls that the enclosing binding's own loopification may want. Structural goldens move accordingly; the hand-written eval oracles are untouched and pass, pinning behaviour across the flip. The isEven/isOdd pair in the new golden pins a known gap as a canary: boolean-literal branches fold to and/or chains in the IR, hiding the sibling call from tail position. Closes #234
The same hot two-argument accumulator loop, split across two workers tail-calling each other. Dispatched, the transition loop trace-compiles (JLOOP, zero blacklists) and LuaJIT reaches parity with the hand-written ideal — 0.0008s vs 0.0172s for the undispatched cross-calls at n=2e6, a ~21x win. PUC 5.1 pays for the selector test and the per-branch parameter rebinds against its already-cheap tail calls (0.0393s vs 0.0330s, ~19% — the pure-transition microbench is the worst case); substituting slot names for parameters instead of rebinding would remove that cost and is the natural follow-up. The curried_step and state_step counter goldens move because join-point fusion (same change series) removes their workers' function shells: one fewer FNEW site and one fewer abort each; curried_step's function-body FNEW count reaches zero.
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.
Problem
Since #181 the code generator turns a self-recursive tail call into a
while true doloop: instead of calling itself, the function reassigns its parameters and repeats. Two recursion shapes were left out and still compile to a real Lua call per step. Closes #234.The first is mutual recursion — functions whose bodies end by calling each other. This PureScript pair
compiled to two closures making a real cross-call per transition (
$w, rendered_S_w, marks an uncurried worker — the n-ary function the worker/wrapper split produces):Each transition pays Lua's call/return machinery, and LuaJIT's trace compiler never finds a loop to anchor a trace on, so the hot path stays interpreted.
The second is join points — a local helper whose every use is a tail call from the enclosing body. Even when the helper's own recursion is already loopified, it survives as a function object plus an entry call:
The transforms
Both live in the code generator's loopification module, beside #181's self-recursion rewrite; the IR keeps needing no loop constructor.
Mutual recursion → one dispatcher. Group members that tail-call each other — precisely, a cycle in the "whose body ends by calling whom" graph — merge into one function that loops over a numeric branch selector plus shared argument slots, one slot per parameter of the widest member. Every tail call between members becomes a single simultaneous assignment of the selector and the slots. The original bindings survive as one-line entry wrappers, which is what makes the cycle test purely a profitability question: a call from outside, a use as a value, or the curried wrappers that uncurrying leaves in the same recursive group all keep referring to the original names with no use analysis needed.
ticktocknow compiles to:(
$sel/$a/$loopare freshly minted names user code cannot collide with; the argument-balancing rules from #181 carry over, so a transition into a narrower member pads its unused slots withnil.) Each branch rebinds its member's parameters from the slots as loop-body locals, re-created every iteration, so a closure created inside a branch captures that iteration's values exactly as a fresh call activation would — the capture veto #181 needs does not apply here. The entry call at the bottom has no wrapper hop left because the join-point transform below fused it.Join points → fall-through. A helper only ever entered by tail calls from the block's exit positions loses its function shell: its parameters become plain locals, each entry call becomes an assignment that falls through into the helper's body, now placed at the end of the block.
sumTrianglesbecomes entry assignment plus bare loop:Non-recursive helpers fuse the same way — a continuation shared by two branches,
classify n = let finish r = (r * 10 + r) * 2 - r in if n > 0 then finish (n + 1) else finish (0 - n), loses both calls:Fusion iterates until nothing fuses: a helper entered only from another fused helper's body fuses on the next round, so chains flatten completely.
Safety conditions
The join transform commits only when it can account for every use of the helper: after rewriting the entry calls, no read of the helper's name may remain anywhere in the block — a reference from inside a nested closure, an argument-position call, or a call from a non-exit position each leaves the function untouched. Both transforms also require that control provably cannot fall off the end of a body they relocate (the value-less native-loop blocks of #233 can), since falling through a dispatcher branch would iterate instead of returning nil. Finally, the join transform refuses to bury a block-final
return f(x)mid-block, because the enclosing binding's own loopification (or a mutual cycle it belongs to) may still claim that tail call.Verification
Red-first: the first commit pins the pre-change shapes of two new runnable golden modules with hand-written output oracles, and the implementation commit flips their generated-Lua goldens while every execution oracle stays byte-identical — the diffs above are quoted from exactly that flip. Coverage: the mutual pair, differing arities, a three-state machine, mixed self+sibling calls, a non-tail pair that must stay calls, dispatch composing with join fusion, two entries into one loop, an effect-land continuation, and an escaping helper that must keep its shell. One pinned gap doubles as a canary: a
Boolean-returning pair likeisEven/isOddfolds toand/orchains in the IR —return n == 0 or Golden_MutualLoopification_Test_isOdd(n - 1)— which hides the sibling call inside an operand, so the pair stays call-shaped; if a later pass learns to distribute that fold back into branches, the golden flips and proves it. Existing golden churn (Golden.Uncurry,Golden.Loopification,Golden.RecursiveBindings,Golden.SpecConstr) was reviewed by hand; IR-level goldens are untouched (the change is codegen-only), 15 new unit specs pin the shapes and the vetoes, 1090 examples green.Measurements
New
mutual_stepmacrobenchmark — the mutual twin ofcurried_step: the same hot two-argument accumulator loop, split across two workers tail-calling each other. Medians at n=2e6 against the pre-change compiler:Under LuaJIT the dispatcher trace-compiles (
JLOOP, zero blacklists) to parity with the hand-written loop, ~21x over the cross-calling shape, which never traced. Under PUC the selector test and the per-branch parameter rebinds cost ~19% on this worst-case pure-transition loop against tail calls PUC already runs cheaply; substituting slot names directly for parameters instead of rebinding (at the price of a capture veto) would remove that cost and is the natural follow-up. Join-point fusion also registers on the existing benchmarks:curried_step's per-iteration closure allocations (FNEW) drop to zero andstate_steploses an allocation site and a trace abort, with all compiled loop traces retained.