fix(ir): key a Let's binders in scope order in alphaKey (#349) - #353
Merged
Conversation
A Standalone binding is non-recursive, so a right-hand-side reference to the binder's own name resolves to an outer binder (Note [Sequential scoping of Let bindings]). Two Lets differing only in the binder name around such a reference are therefore alpha-equivalent, and CSE must recognise them as one group; alphaKey hoists the binders into its rename map up front and keys them differently, so the hoist is declined. The recursive-group case is pinned alongside as the contrast: a RecursiveGroup member's right-hand side does see its own binder, so the self-reference is bound in both copies.
alphaKey collected every binder name of a Let into one rename map before canonicalizing any right-hand side, so a free reference sharing a binder's name was renamed to that binder's positional $key name. The map is now threaded through the groupings: a Standalone right-hand side is canonicalized under the incoming map and its binder enters afterwards, a RecursiveGroup's members all enter before any of their right-hand sides is walked. Same shape as freshenBinders and as the LetValues case here. The bug could only ever cost a hoist — a shadowed right-hand side keys to a shape correct canonicalization cannot emit — and never fired, since CSE runs behind the global-uniqueness condition uniquifyNames establishes. The goldens are byte-identical; what the fix buys is that alphaKey no longer relies on that condition to resolve names.
Unisay
force-pushed
the
issue-349/alphakey-sequential-scoping
branch
from
July 29, 2026 11:41
d73af9a to
ba93054
Compare
Unisay
marked this pull request as ready for review
July 29, 2026 12:47
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 #349.
The bug
Common-subexpression elimination (
Language.PureScript.Backend.IR.CSE) finds repeated pure subexpressions in one function body and hoists them into a shared local binding. It decides what counts as a repeat by canonicalizing each candidate to a key:alphaKeydrops annotations and renames every binder bound within the expression to a positional$key<n>name, so — in the words of its own documentation — "two expressions have equal keys iff they are alpha-equivalent up to annotations". References that are free in the expression keep their own names, because they belong to the enclosing scope and two occurrences only mean the same thing if they name the same outer binder.Its
Letcase collected every binder name of theLetinto one rename map up front, then canonicalized all the right-hand sides under that single map:The IR's scoping rule is where that shortcut fails.
Note [Sequential scoping of Let bindings]fixesLetscoping as sequential, like Scheme'slet*: aStandalonebinding (the IR's non-recursive grouping, as opposed to aRecursiveGroup) does not have its own name in scope in its right-hand side, so a reference to that name from its own right-hand side resolves to an outer binder. Because the binder's map entry was already present when its own right-hand side was walked, a free reference sharing that name got canonicalized as if it were bound.The stated iff then breaks in the "misses an equality" direction. Take two expressions in a scope that already binds
x:They are alpha-equivalent — they differ only in the name chosen for the binder, and the right-hand-side
xis a free occurrence of the same outer binder in both.alphaEq, the IR's alpha-equivalence relation, agrees.alphaKeydid not (this block and the one at the end of the next section are verbatim GHCi output, before and after the fix):In
keyAthe freexhad been swallowed into the positional namespace as$key0; inkeyBthe identical free reference correctly survived asx.The fix
The map is now threaded through the groupings instead of hoisted, which is the shape
freshenBinders(the traversal that alpha-renames an expression's binders before a copy of it is pasted elsewhere) and theLetValuescase ofalphaKeyitself already use:keyGroupingsplits on the grouping kind, so each right-hand side is canonicalized under exactly the map that is in scope for it:The discard binder
_keeps its exemption, now inbindMinted: it is exempt from the IR's binder-uniqueness invariant, so oneLetcan bind it several times (magic-do's discard statements), and nothing may reference it, so it takes no positional name:The two expressions now key equally, matching
alphaEq:Test
Red first, at the level the bug manifests: two lambdas that differ only in a
Letbinder name are one alpha-equivalence class, so CSE must hoist them into one shared$cse0binding. Wrapping each in a lambda is what makes it a candidate — lambda literals are one of the pass's effect-free candidate classes, a bareLetis not.Verbatim failure on the parent commit — the pass returned the input unchanged, declining the hoist:
A second test pins the contrasting branch, which the fix must not disturb: a
RecursiveGroupmember's right-hand side does see its own binder, so two self-referencing groups differing only in the member name are alpha-equivalent and must still be shared. It is green on both sides of the fix — it guards the branch the rewrite introduces.The existing property
CSE / keys alpha-equivalent expressions equallycannot catch any of this: it draws generated expressions and uniquifies them, so it never presents a shadowed shape.Impact on emitted code: none
The golden corpus is byte-identical — no
golden.irorgolden.luamoved — which is the check that no CSE decision depended on the hoisted behaviour. Two independent reasons:The direction of the bug was safe. A shadowed right-hand side keyed to a shape correct canonicalization can never emit for a
Standalonebinding, namely a reference to the binder's own positional name inside the binder's own right-hand side. So the bug could only make two alpha-equivalent expressions key differently — CSE declining a hoist it could have made — and never merge two occurrences that mean different things.And it did not fire at all.
uniquifyNamesis the pipeline's entry pass, and every pass behind it, CSE included, declarespassRequires = guc— the global-uniqueness condition, under which every binder in the program has a distinct name and no free reference can collide with one.What the fix buys is that
alphaKeyno longer depends on that condition for correct name resolution. It was the last name-resolving traversal in the IR that did not implement the sequential rule;freshenBinderswas the same bug (#345, fixed in #347). A pass reordering that puts CSE ahead of uniquification, or a property fed deliberately non-uniquified input the wayIR Optimizer / inlines expressions referenced onceis, would turn a latent divergence into a visible one. The module's== GUCsection andalphaKey's documentation are updated to say what GUC is still load-bearing for (the exactness of the hoist-point scope guard) and what it no longer is.Verification
cabal test allgreen — 1236 examples, 0 failures — with no golden churn.fourmoluandhlintclean; the two touched modules recompile with no warnings. The Hedgehog-driven groups were stressed with fresh seeds:--match "CSE"and--match "IR Optimizer", 12 runs each, all green.