Skip to content

feat(ir): push case-of-case over the IfThenElse decision tree - #277

Merged
Unisay merged 3 commits into
mainfrom
issue-203/push-if-into-branches
Jul 14, 2026
Merged

feat(ir): push case-of-case over the IfThenElse decision tree#277
Unisay merged 3 commits into
mainfrom
issue-203/push-if-into-branches

Conversation

@Unisay

@Unisay Unisay commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Closes #203.

Problem

The IR compiles pattern matches to decision trees of nested IfThenElse. When such a tree is consumed in a strict position it sits in expression position, and codegen wraps it in an IIFE that is allocated and called on every evaluation. For the recursive functions in Golden.LongCallbackChain and Golden.TailRecM2Shadow that means on every loop iteration.

The issue predates #180, when the tree sat directly in the condition of another if. Today the dominant shape is different: the tag-read distribution rewrites ReflectCtor over an Ordering tree into an if-tree of tag strings, so the condition holds Eq "…LT" (IfThenElse …). Seven goldens carried this shape.

Approach

Three rewrites of the same case-of-case family, all in the shared rewrite chain of optimizedExpressionM.

pushEqIntoIfBranches distributes a scalar literal compared against an if-tree into its branches: Eq lit (If c t e) becomes If c (Eq lit t) (Eq lit e), in both orientations. The guard, eqFoldsThrough, mirrors reflectFoldsThrough from the tag-read distribution: the rule fires only when every leaf of the tree constant-folds against the literal. No residual comparison can survive, and the only duplicated operand is a scalar literal. The folded leaves then meet constantFolding and the boolean-if rules, which collapse the whole condition to a flat comparison.

pushIfCondIntoBranches is the transformation as stated in the issue: IfThenElse (IfThenElse c a b) x y becomes IfThenElse c (IfThenElse a x y) (IfThenElse b x y), with the issue's guard. Either the inner branches are boolean literals, in which case the residual ifs fold right away and each outer branch survives in exactly one copy, or the outer branches are trivial by isInlinableValue. Trivial values are binder-free, so the copies cannot violate GUC. Evaluation order is preserved exactly: c, then a or b, then x or y.

reduceBooleanIf gains the four half-literal cases next to its existing two-literal collapses: if p then True else b becomes p or b, if p then False else b becomes not p and b, and the mirrored shapes likewise. The non-literal branch is a Bool too, pinned by the literal (Note [IR is assumed well-typed]), and Lua's and/or evaluate exactly what the branches evaluated, in the same order, but survive in condition position without an IIFE. This is what a pushed GT comparison collapses to when two leaves fold to False: if n < 0 then false else n ~= 0, which now flattens to not (n < 0) and n ~= 0.

Two supporting changes ride along. The scalar-literal Eq cases of constantFolding are factored into foldEqLiterals and shared with the eqFoldsThrough guard, so the guard and the fold cannot drift apart. And foldPrimBinOp gains the identity folds a and truea and a or falsea: an early and/or fold can catch a branch whose literal only appears after a later inlining round, and without the identities that ceremony survives to the output. The annihilator duals (a and false, a or true) stay unfolded, since collapsing them would skip evaluating a.

Verification

Unit tests pin all three rewrites. The LongCallbackChain microcosm folds to a flat comparison end to end (both orientations) and the GT microcosm to a flat boolean expression, a non-foldable leaf declines, trivial outer branches push while non-trivial ones stay put, each half-literal shape folds to its operator while an if with no literal branch stays, and the literal-branches arm of the condition push is exercised by direct rule application. That arm is unreachable through optimizedExpression, where reduceBooleanIf collapses two-literal inner ifs bottom-up before the outer node is inspected.

Ten goldens change, .ir and .lua only. The condition becomes a flat comparison in CharLiterals, FieldCaching, LongCallbackChain and TailRecM2Shadow: if n < 0 then instead of if "…LT" == (function() … end)() then. The GT comparisons in Loopification, Primops and UncurryEffect flatten to not (n < 0) and n ~= 0. The generic-equality chains in BugListGenericEq, GenericEqTwoTypes and StringCodePoints collapse their boolean elseif ladders into and/or chains.

Eval goldens are byte-identical. The full suite passes clean and across four extra Hedgehog seeds.

The golden luacheck run now ignores W581, which suggests rewriting not (x < y) to x >= y. That flip is the NaN-unsafe inverse codegen deliberately never emits (the printer negates == to ~= because it is exact, and keeps not over < for the same reason), so the warning has no actionable fix.

Unisay added 2 commits July 14, 2026 13:30
A boolean-returning decision tree consumed in a strict position sits in
expression position, where codegen wraps it in an IIFE allocated and
called per evaluation. Two guarded pushes dissolve the dominant shapes:

* pushEqIntoIfBranches distributes a scalar literal compared against an
  if-tree into the branches — the Eq sibling of reduceKnownConstructor's
  tag-read distribution, guarded by eqFoldsThrough (every leaf must
  constant-fold) so no residual comparison survives. The leaf folds then
  meet constantFolding and the boolean-if rules, collapsing the tree to
  a flat condition: the shape an inlined Ord comparison leaves once the
  tag read distributes over its Ordering tree (#180).

* pushIfCondIntoBranches pushes an IfThenElse out of the condition of
  another into its branches, restricted to the shapes where pushing
  cannot duplicate work: the inner branches are boolean literals (the
  residual ifs fold right away), or the outer branches are trivial by
  isInlinableValue — binder-free, so the copies cannot break GUC.

The scalar-literal Eq folds of constantFolding are factored into
foldEqLiterals, shared with the eqFoldsThrough guard. Seven goldens
lose the IIFE from their hot loops (flat comparison in CharLiterals,
FieldCaching, LongCallbackChain, TailRecM2Shadow; a reduced residual
tree in Loopification, Primops, UncurryEffect); eval outputs are
byte-identical.
An if with exactly one boolean-literal branch is a short-circuiting
operator in disguise: if p then True else b is p or b, if p then False
else b is not p and b, and the mirrored shapes likewise. The other
branch is a Bool too, pinned by the literal (Note [IR is assumed
well-typed]), and Lua's and/or evaluate exactly what the branches
evaluated, in the same order — but unlike a branch, an operator
survives in condition position without an IIFE. reduceBooleanIf gains
the four cases next to its existing two-literal collapses.

This is what a pushed comparison (pushEqIntoIfBranches) leaves behind
when more than one leaf folds the same way: the Ordering trees compared
against GT collapse to if n < 0 then false else n ~= 0, which now
flattens to not (n < 0) and n ~= 0 — Loopification, Primops and
UncurryEffect lose their last residual IIFE.

Two identity folds join foldPrimBinOp (a and true → a, a or false → a):
an early and/or fold can catch a branch whose literal only appears
after a later inlining round, and without the identities the ceremony
survives to the output. The annihilator duals (a and false, a or true)
stay unfolded, since collapsing them would skip evaluating a.

The golden luacheck run now ignores W581, which suggests rewriting
not (x < y) to x >= y — the NaN-unsafe inverse codegen deliberately
never emits.

Part of #203.
@Unisay
Unisay requested a review from Copilot July 14, 2026 12:33
@Unisay Unisay self-assigned this Jul 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the IR optimizer’s handling of case-of-case patterns over IfThenElse decision trees so boolean decision trees no longer remain in expression position (which forces Lua codegen to wrap them in an IIFE). This directly targets the recurring “Ordering tag-tree compared against a literal” shape and “if-in-condition-of-if” shape described in #203, reducing per-iteration allocation/call overhead in tight recursive loops while preserving evaluation order.

Changes:

  • Add two new optimizer rewrites: distributing Eq over IfThenElse when every leaf comparison constant-folds, and pushing a condition IfThenElse into outer branches when safe.
  • Extend boolean-if reduction to handle half-literal cases by collapsing them into short-circuiting and/or primops, and add identity folds for a and true / a or false.
  • Update IR optimizer unit tests, golden .ir/.lua outputs, and adjust golden luacheck to ignore W581 (NaN-unsafe rewrite suggestion).

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated no comments.

Show a summary per file
File Description
lib/Language/PureScript/Backend/IR/Optimizer.hs Implements the new decision-tree rewrites and supporting folds in the shared optimizer pipeline.
test/Language/PureScript/Backend/IR/Optimizer/Spec.hs Adds unit tests to pin the new rewrites and the new primop identity folds.
test/Language/PureScript/Backend/Lua/Golden/Spec.hs Ignores luacheck W581 for generated Lua goldens to avoid NaN-unsafe rewrite suggestions.
test/ps/output/Golden.BugListGenericEq.Test/golden.ir Golden IR updated to reflect decision-tree collapse into and/or chains.
test/ps/output/Golden.BugListGenericEq.Test/golden.lua Golden Lua updated to reflect decision-tree collapse into and/or chains.
test/ps/output/Golden.CharLiterals.Test/golden.ir Golden IR updated to reflect Eq-over-tree folding into a flat comparison.
test/ps/output/Golden.CharLiterals.Test/golden.lua Golden Lua updated to reflect Eq-over-tree folding into a flat comparison.
test/ps/output/Golden.FieldCaching.Test/golden.ir Golden IR updated to reflect Eq-over-tree folding into a flat comparison.
test/ps/output/Golden.FieldCaching.Test/golden.lua Golden Lua updated to reflect Eq-over-tree folding into a flat comparison.
test/ps/output/Golden.GenericEqTwoTypes.Test/golden.ir Golden IR updated to reflect decision-tree collapse into and chains.
test/ps/output/Golden.GenericEqTwoTypes.Test/golden.lua Golden Lua updated to reflect decision-tree collapse into and chains.
test/ps/output/Golden.LongCallbackChain.Test/golden.ir Golden IR updated so the condition becomes a flat comparison (removing IIFE shape).
test/ps/output/Golden.LongCallbackChain.Test/golden.lua Golden Lua updated so the loop condition becomes a flat comparison (removing IIFE shape).
test/ps/output/Golden.Loopification.Test/golden.ir Golden IR updated to reflect GT-comparison collapsing to not (n < k) and n ~= k.
test/ps/output/Golden.Loopification.Test/golden.lua Golden Lua updated to reflect GT-comparison collapsing to not (n < k) and n ~= k.
test/ps/output/Golden.Primops.Test/golden.ir Golden IR updated to reflect GT-comparison collapsing to a flat boolean expression.
test/ps/output/Golden.Primops.Test/golden.lua Golden Lua updated to reflect GT-comparison collapsing to a flat boolean expression.
test/ps/output/Golden.StringCodePoints.Test/golden.ir Golden IR updated to reflect boolean-if collapse into or/and with error on mismatch.
test/ps/output/Golden.StringCodePoints.Test/golden.lua Golden Lua updated to reflect boolean-if collapse into or/and with error on mismatch.
test/ps/output/Golden.TailRecM2Shadow.Test/golden.ir Golden IR updated so the LT check is a direct < condition (removing IIFE shape).
test/ps/output/Golden.TailRecM2Shadow.Test/golden.lua Golden Lua updated so the LT check is a direct < condition (removing IIFE shape).
test/ps/output/Golden.UncurryEffect.Test/golden.ir Golden IR updated to reflect GT-comparison collapsing to not (n < k) and n ~= k.
test/ps/output/Golden.UncurryEffect.Test/golden.lua Golden Lua updated to reflect GT-comparison collapsing to not (n < k) and n ~= k.
changelog.d/20260714_120000_unisay_case_of_case_if.md Adds a changelog fragment describing the optimizer improvements and their motivation.

The case-of-case pushes strip the IIFE the Ordering comparison in
Bench.EffectStep compiled to, so the linked artifact allocates one
fewer closure family: function-body FNEW 13 → 12, and the trace report
drops the aborts and the compiled trace that closure caused
(aborts 8 → 6, compiled 7 → 6). Deterministic counters only; a
codegen improvement, accepted via ./bench/ci --accept.
@Unisay
Unisay merged commit fef7351 into main Jul 14, 2026
2 checks passed
@Unisay
Unisay deleted the issue-203/push-if-into-branches branch July 14, 2026 15:43
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.

Push an if in condition position into its branches (case-of-case over the IfThenElse decision tree)

2 participants