Skip to content

feat(optimizer): distribute an accessor or application into if branches - #334

Merged
Unisay merged 1 commit into
mainfrom
issue-243/distribute-into-if-branches
Jul 28, 2026
Merged

feat(optimizer): distribute an accessor or application into if branches#334
Unisay merged 1 commit into
mainfrom
issue-243/distribute-into-if-branches

Conversation

@Unisay

@Unisay Unisay commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #243.

Adds pushEliminatorIntoIfBranches to the IR optimizer's rewrite chain: an eliminator applied to a conditional scrutinee — a record field read (ObjectProp), an array read or length (ArrayIndex/ArrayLength), a constructor tag or field read (ReflectCtor/DataArgumentByIndex), or a saturated call (AppN) — distributes into both arms, (if p then a else b).f ==> if p then a.f else b.f and (if p then f else g) x ==> if p then f x else g x. Without the push the conditional sits in expression position, which codegen wraps in an immediately-invoked function (IIFE) allocated and called per evaluation, and the eliminator never reaches the arms where the constructor, projection, and beta folds fire.

The new golden Golden.DistributeIntoIf shows all the shapes. A projection over a conditional of known records used to compile to an IIFE plus a field read off its result:

local Golden_DistributeIntoIf_Test_pickName = function(b)
  return ((function()
    if b then return { name = "big" } else return { name = "small" } end
  end)()).name
end

and now distributes, folds each arm's read, and lands in statement position:

local Golden_DistributeIntoIf_Test_pickName = function(b)
  if b then return "big" else return "small" end
end

A call over a conditional of lambdas was already handled late, at the Lua level, by foldCallThroughScopeCall (the backstop that pushes an applied scope call into its return sites) — but that runs after all IR folds, so it left un-reduced beta redexes allocating a closure per call:

local Golden_DistributeIntoIf_Test_applyPicked = function(b)
  return function(n)
    if b then
      return (function(v) return v + 1 end)(n)
    else
      return (function(v0) return v0 * 2 end)(n)
    end
  end
end

Distributed at the IR level instead, the pushed call meets betaReduce in the arms:

local Golden_DistributeIntoIf_Test_applyPicked = function(b)
  return function(n) if b then return n + 1 else return n * 2 end end
end

The arms themselves are never duplicated — each receives one copy of the eliminator — so no gate on the arms is needed. The only syntactically duplicated operands are an application's arguments, admitted by isInlinableValue (references, scalar literals, cheap projection chains: pure and bounded to re-emit, and binder-free, so the copies cannot break the unique-binders invariant). Only the arm that runs evaluates its argument copy, and evaluation order — condition, then arm, then arguments — is the same in both forms. A call whose argument does real work is declined, pinned by the golden's applyExpensive (its argument is a call, weigh(n)), which keeps its IIFE verbatim. The unrestricted transformation (arbitrary consumer contexts) needs the join points of #234 to avoid duplicating the consumer; this rewrite is deliberately the duplication-free subset.

The rule subsumes the fold-gated tag-read distribution that reduceKnownConstructor carried since #180 (fire only when every branch folds to a tag string): a tag read now distributes over any conditional and folds in whichever arms turn out to be constructors, so the reflectFoldsThrough guard is deleted. The Eq-against-literal push of #203 keeps its own rule and fold gate unchanged.

Golden fallout, all with eval outputs unchanged: NativeLoopsGuard and TailRecM2Shadow move only in IR — their effect-run applications (if b then act else pure unit)(run) now distribute at the IR level, producing exactly the Lua the late backstop already produced. UncurryEffect genuinely improves: distributing the effect-run application turns each arm into a direct tail call, and loopification (which requires the self call in tail position) now converts countdown into a while true loop:

local Golden_UncurryEffect_Test_countdown_S_w = function(n)
  while true do
    local _ = (function()
      local _ = Effect_Console_log("tick")()
      return Effect_Console_log(Data_Show_showIntImpl(n))()
    end)()
    if n >= 1 and n ~= 1 then
      n = n - 1
    else
      return Effect_Console_log("done")()
    end
  end
end

where before the recursion hid behind the conditional-callee shape and stayed a stack-consuming self call:

local Golden_UncurryEffect_Test_countdown
local Golden_UncurryEffect_Test_countdown_S_w = function(n)
  local _ = (function()
    local _ = Effect_Console_log("tick")()
    return Effect_Console_log(Data_Show_showIntImpl(n))()
  end)()
  if n >= 1 and n ~= 1 then
    return Golden_UncurryEffect_Test_countdown(n - 1)()
  else
    return Effect_Console_log("done")()
  end
end

Verification: ten focused unit specs (written and confirmed red first) cover each eliminator kind, the fold cascades, and the declined non-trivial argument both through the pipeline and as a direct guard pin; the new golden carries a hand-written eval oracle checked against real execution; the full suite is green, the IR Optimizer group was seed-stressed 12 runs without a failure or hang, and bench/ci counters are unchanged against the committed oracles.

…es (#243)

A cheap eliminator applied to an IfThenElse scrutinee — a field, index,
length or tag read, or a call — distributes into both arms, so the
conditional leaves expression position (an IIFE in the generated Lua)
and the pushed operation reaches the arms where the constructor,
projection and beta folds fire. Arms are never duplicated; an
application's arguments are the only duplicated operands, gated by
isInlinableValue (pure, bounded, binder-free to re-emit).

Subsumes the fold-gated tag-read distribution of reduceKnownConstructor
(issue #180): a tag read now distributes over any conditional, folding
in whichever arms turn out to be constructors, so reflectFoldsThrough
is gone.

Golden fallout: NativeLoopsGuard and TailRecM2Shadow move only in IR
(the Lua-level foldCallThroughScopeCall backstop had already produced
the distributed Lua); UncurryEffect improves — distributing the
effect-run application exposes the self tail call in both arms and
loopification now turns countdown into a while-true loop. New golden
DistributeIntoIf pins the projection fold, the opaque-record push, the
beta-redex payoff, and the declined non-trivial argument, with an eval
oracle.
@Unisay Unisay self-assigned this Jul 28, 2026
@Unisay
Unisay marked this pull request as ready for review July 28, 2026 10:57
@Unisay
Unisay merged commit 3df2c22 into main Jul 28, 2026
2 checks passed
@Unisay
Unisay deleted the issue-243/distribute-into-if-branches branch July 28, 2026 11:55
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.

General case-of-case: distribute an accessor/application into the branches it is applied to

1 participant