Case-of-known-constructor through a let-bound scrutinee (#214)#216
Merged
Conversation
) reduceKnownConstructor only fires on a constructor in place, and a scrutinee read more than once is never in place: beta reduction Let-binds a non-trivial argument rather than substitute it, and a dictionary method reads its scrutinee several times (a tag test, a payload read, a fallthrough tag test). So an inlined and beta-reduced method lands on `let v = Just (x + 1) in if justTag == ReflectCtor v then ... v.value0 ... else ...` and nothing folds. Add propagateKnownCtorThroughLet: when a Standalone Let binding's RHS is a saturated constructor application and the binder is read only through constructor-eliminating reads, fold each ReflectCtor to the tag string and each field read to a fresh field-binder bound once to its argument (GHC's case-binder to field-binder split, so an argument read at several sites is evaluated once, not duplicated), then drop the binding. Trivial and dead field-binders inline or DCE away, and the folded reads let the surrounding Eq/if collapse to its live arm — the #177 payoff reached through a Let. The rule declines when the binder is read as a whole value, which dropping it would dangle and keeping it would duplicate the arguments, and it guards against a self-referencing RHS on non-GUC input. This is the enabler that makes dictionary-method inlining (#180) pay off; standalone impact is near zero, so no golden moves.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds an IR optimizer rewrite to enable “case-of-known-constructor” simplification when the scrutinee constructor is hidden behind a Let (the common shape produced by beta-reduction + multi-read dictionary methods), so downstream constant-folding and unreachable-branch elimination can collapse decision trees into straight-line code.
Changes:
- Introduces
propagateKnownCtorThroughLetand wires it intooptimizedExpressionMafterreduceKnownConstructor. - Adds optimizer specs (unit + property) covering successful folds and decline conditions, plus an end-to-end checked-pipeline regression.
- Adds a changelog fragment documenting the new optimization.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| lib/Language/PureScript/Backend/IR/Optimizer.hs | Adds the new let-scrutinee constructor propagation rewrite and hooks it into the optimizer fixpoint. |
| test/Language/PureScript/Backend/IR/Optimizer/Spec.hs | Adds tests validating the rewrite’s behavior, refusals, and pipeline integration. |
| changelog.d/20260709_114305_unisay_known_ctor_through_let.md | Documents the new optimizer capability and its intended impact. |
- Optimizer.hs — decline propagateKnownCtorThroughLet on an out-of-range DataArgumentByIndex: guard hasWholeValueRead by the constructor arity so the read counts as whole-value and the rule declines (like reduceKnownConstructor), instead of stranding a fresh field-binder (#216 (comment)) - Optimizer/Spec.hs — compare the field-binder-split test up to alphaEq, not the incidental "$field0" fresh-supply name (#216 (comment)) - Optimizer/Spec.hs — add a property fuzzing arity vs index asserting well-scopedness, so the suite catches the out-of-range dangling binder
- changelog.d/…_known_ctor_through_let.md — reword the closing line to drop the "golden" shorthand and state the observable impact instead (#216 (comment))
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 #214. Builds on #213 (now on main).
What
reduceKnownConstructor(#177) only fires on a constructor that is syntactically in place, and a scrutinee read more than once is never in place. Beta reduction Let-binds a non-trivial argument rather than substitute it into every occurrence (the #167 discipline), and a dictionary method reads its scrutinee several times: a tag test, a payload read, a fallthrough tag test. So an inlined and beta-reduced method lands onand nothing folds. The rules see
ReflectCtor (Ref v)andObjectProp (Ref v) "value0", never the constructor, so the whole match stays opaque behind the Let. That is a near-zero win for exactly the multiply-read-scrutinee methods (bind,apply), which are most of them.How
propagateKnownCtorThroughLetpropagates a known constructor from aStandaloneLet binding into the binder's reads. When the RHS is a saturatedCtorapplication and the binder is read only through constructor-eliminating reads:ReflectCtor vfolds to the tag string (sum types only, as inreduceKnownConstructor);ObjectProp v "valueᵢ",DataArgumentByIndex i v) folds to a fresh field-binder bound once to the iᵗʰ argument, GHC's case-binder to field-binder split, so a non-trivial argument read at several sites is evaluated once rather than copied to each site;reduceKnownConstructorhas for a field read's siblings.Trivial and dead field-binders then inline or DCE away, and with the reads folded the surrounding
Eq/ifmeets constant folding and the unreachable-branch rules, collapsing the decision tree to its live arm. That is the case-of-known-constructor payoff of #177, now reached through the Let.The rule declines when the binder is read as a whole value anywhere (a sibling binding, or a non-eliminating position such as an argument to a function): dropping it would dangle the reference, and keeping it while binding the fields would duplicate the arguments. A product-type
ReflectCtorread has no tag row to fold to, so it too leaves a whole-value read and the rule declines. It also guards against a self-referencing RHS, which cannot arise under the global uniqueness condition but can on the non-GUC inputoptimizedExpressionis exercised on directly.Why now
This is the enabler that makes dictionary-method inlining (#180) pay off. Once #180 inlines a method and beta reduction Let-binds the dictionary value, the payload is read through exactly this Let-bound shape; without this rule #180 would inline the method but leave the match opaque. #180 stacks on this branch.
Verification
New optimizer specs cover the fold and its refusals: the tag fold unlocking the live branch, the payload dropped when only the tag is read, a field read at several sites bound once (proving no duplication), the whole-value and partial-application declines, and an end-to-end collapse to straight-line code run through the checked pipeline so a GUC or scoping violation would fail rather than pass silently. A randomized property stresses the fold across scalar payloads, checking the binder is eliminated and the free-reference multiset never grows.
Full suite green (
cabal test all): 702 examples, 0 failures, and the IR optimizer specs stay green across several seeds. No structural golden moved and no eval oracle moved, matching the near-zero standalone impact the issue predicts, since the shape appears once method inlining lands.fourmoluandhlintclean, warning-free build.