fix(ir): rename a Let's binders in scope order in freshenBinders - #347
Merged
Conversation
) Two example-based guards for the sequential scoping rule (Note [Sequential scoping of Let bindings]) in the Let case of freshenBinders. The first is the minimal shape: freshening `let x = x in y` must rename the binder and leave the free x in its own right-hand side alone, because a Standalone binding is non-recursive. The second pins the left-to-right order across groupings, which an implementation that hoists every binder but the current one would still get wrong. Both are red on the current traversal, which collects every binder of a Let into one rename map before walking any right-hand side.
freshenBinders collected every binder name of a Let into one rename map up front and walked all the right-hand sides under that single map. That holds only under global uniqueness, and on shadowed input it repoints a free reference: a Standalone binding is non-recursive, so a reference to its own name from its own right-hand side resolves to an outer binder, yet the binder's rename entry was already in the map when that right-hand side was walked. Freshening `let x = x in y` renamed both the binder and the free x beside it. Thread the map through the groupings instead: a Standalone right-hand side is renamed under the incoming map and its binder enters only afterwards, while a RecursiveGroup's members all enter before any of their right-hand sides is walked. This is what alphaEq and countFreeRefUsage already implement, and what the LetValues case of this same traversal already did. The discard binder's exemption moves into bindFresh unchanged. Emitted code is unaffected — uniquifyNames is the pipeline's entry pass, so no shadowed shape reaches freshenBinders in a real compilation — and the golden corpus is byte-identical. The visible cost was the property "IR Optimizer / inlines expressions referenced once" reddening intermittently; over hspec seeds 1..500 of that group it failed on seeds 328 and 379 before this change and on none after. Two comments in CSE.alphaKey that cited freshenBinders' hoisted shape now state the assumption directly.
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 #345.
freshenBindersalpha-renames every binder bound inside an IR expression to a fresh, supply-minted name. The optimizer calls it whenever it pastes a copy of an expression somewhere else — the call-site inliners, the call-pattern specializer, and thesubstituteCopyM/substituteMoveMsubstitution pair all go through it — so that the copy's binders cannot collide with anything at the destination. Renaming a binder means rewriting the references that binder binds; references that are free in the expression belong to the enclosing scope and must survive untouched.The
Letcase did not honour that. It collected every binder name of theLetinto one rename map up front and then walked all the right-hand sides under that single map:The comment states the precondition the shortcut needs, and it is exactly the one that fails here. The IR's scoping rule is sequential, like Scheme's
let*—Note [Sequential scoping of Let bindings]inIR/Types.hs— and its first clause says aStandalonebinding is non-recursive: its own name is not 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 rename entry was already in the map when its own right-hand side was walked, such a free reference was renamed along with the binder and silently repointed at it.The minimal shape is
let x = x in y, where the right-hand-sidexis free. Freshening it should rename the binder alone; instead it renamed both (verbatim from the guard added below,expectedbeing the correct result andbut gotthe pre-fix one):freshenBinderswas the odd one out.alphaEq(structural equality modulo binder names) implements the sequential rule explicitly — it compares aStandaloneright-hand side before binding the name, under the comment-- The RHS of a Standalone binding does not see its own binder:— and both free-reference counters,countFreeRefsandcountFreeRefUsage, thread their bound-name set through the groupings the same way. EvenfreshenBinders' ownLetValuescase already got it right, for the same reason:-- The RHS is renamed under the incoming map — the binders scope over the body only. Only theLetcase hoisted.The fix
The rename map is threaded through the groupings in scope order instead of being hoisted:
The discard binder
_keeps its exemption, now expressed inbindFresh: it is exempt from the uniqueness invariant, so oneLetcan bind it several times (a magic-do-lowered thunk binds a run of statements to_), and a single name-keyed map entry would rename every one of them to the same fresh name — a genuine duplicate the exemption no longer covers. Nothing may reference it, so it takes no rename at all.bindFresh rs name | name == discardName = pure (rs, name) | otherwise = do name' ← freshNameFor name pure (Map.insert name name' rs, name')How it showed up
As an intermittent red in the property
IR Optimizer / inlines expressions referenced once. That test handsoptimizedExpression, the optimizer's single-expression entry point, a generated and deliberately non-uniquified inlinee, lets it inline the used-once binding, and compares the pasted copy against the original up to alpha-equivalence. It fails exactly when the generator happens to draw the self-shadowing shape, which is why it is rare.Fixing hspec's global seed makes it deterministic.
--seed 328on the pre-fix build drawslet j = j in []as the inlinee and fails verbatim:Impact on emitted code: none
uniquifyNamesis the pipeline's entry pass and the only one whose stated precondition is merelywellScoped; all nine passes behind it declarepassRequires = guc, the global-uniqueness condition under which every binder in the program has a distinct name. Real compilation therefore never presents a shadowed shape tofreshenBinders. The whole golden corpus —.ir,.luaand the hand-verifiedeval/golden.txtoracles alike — is byte-identical; the branch touches no generated file:(
CSE.hsis comment-only: two remarks in the common-subexpression-elimination pass citedfreshenBinders' hoisted map as precedent for its own, so they now state the assumption directly instead of pointing at code that no longer works that way.)Verification
Two example-based guards were added first and confirmed red on the unmodified traversal. The first pins the acceptance criterion; the second pins the left-to-right ordering, which a "hoist everything except the current binder" implementation would still get wrong:
Both pass after the change, alongside the existing discard-binder and GUC-alpha-renaming guards:
The flake itself was measured as a differential: the
IR Optimizergroup was run over hspec seeds 1–500 with the pre-fix and post-fix binaries, on the same seeds.The two assertion failures are seeds 328 and 379, both the self-shadowing shape quoted above; 379 buries it one
Letdeeper, insidelet z = 0 (pink.y) in let x = x in '…', and repoints that innerxthe same way:(The surrounding
z$0-vs-zlines in that rendering are a plain binder rename, whichalphaEqtolerates; hedgehog's structural diff shows them regardless.) Neither seed reproduces after the change.cabal test allis green — 1232 examples, 0 failures — from acabal cleanrebuild that emits no compiler warnings, andhlint lib/ exe/ test/reports no hints.One unrelated observation
The three timeouts are the same seeds on both binaries — 109, 152 and 478 — so they are not this bug. Each stalls in the property
optimization keeps expressions well-scoped, immediately afterblanking an unused shadowing binder keeps outer references bound, where a normal run of the whole group takes 0.35 s. That property runs the fulloptimizedUberModulepipeline, whose entry pass isuniquifyNames, so it never sees a shadowed shape and cannot be reached by this fix. It is a second, independent source of intermittent redness in the same group and gets its own issue.