fix[next]: make MergeLet alpha-invariant - #2726
Draft
havogt wants to merge 2 commits into
Draft
Conversation
`MergeLet` had no unit tests. Add one covering the documented merge and one characterizing the fact that the pass is not alpha-invariant: its collision guard compares parameter names, so two alpha-equivalent programs get merged differently. Claude-Session: https://claude.ai/code/session_01LUm47QGSPihfM376T7mkpY
MergeLet skipped the merge whenever an inner parameter was spelled like a name bound or referenced by the outer call, so two alpha-equivalent programs were optimised differently. Since MergeLet runs right after CommonSubexpressionElimination, the names CSE happens to invent decided whether the merge fired. Rename the offending inner parameters apart instead, via the existing RenameSymbols and ir_misc.unique_symbol. The reserved set includes binder Syms nested in the inner lambda, not just SymRefs, so a fresh name cannot be captured by a nested binder that is never referenced. The remaining guard is the only genuine one: an inner argument that references an outer parameter cannot be hoisted out of its binder. RenameSymbols dropped Sym.type while its visit_SymRef copies the type over; carry the type through so renaming a parameter of typed IR does not discard it. Also add unit tests for merge_let, which had none.
Contributor
|
This pass was essentially written out of a need for readability and later lower stack depth, but is not well designed as it recollects the used symbols on every |
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.
Summary
MergeLetis not α-invariant: its guards compare binder spellings, so twoα-equivalent programs are optimised differently. This PR makes the pass α-invariant
by renaming colliding inner binders apart instead of skipping the merge, and adds
the unit tests the pass was missing.
An earlier revision of this PR only characterised the behaviour and asked which fix
you wanted. It now implements one of the options that were listed — the α-renaming
one — because the objection raised against it turned out not to apply (see
"No uids pool needed"). Whether you want this at all is still open; see
"What we would like you to decide".
The problem
The two inputs are α-equivalent: the inner binder is not referenced from outside the
inner lambda, so renaming it
a→bis a pure renaming. Before this PR only thesecond merged, because the collision guard was a set intersection of parameter names:
gt4py/src/gt4py/next/iterator/transforms/merge_let.py
Lines 43 to 45 in 1eda83a
The second guard (the ref-count over
inner_lambda.params) was name-keyed the sameway:
(λ(x) → (λ(b) → b)(1))(b)was skipped while the α-equivalent(λ(x) → (λ(c) → c)(1))(b)merged.Nothing miscompiled — each individual output was correct. The defect is that the
result of the rewrite was not a function of the program's α-equivalence class. That
matters here in particular because
MergeLetruns directly afterCommonSubexpressionEliminationinpass_manager.py, and CSE invents its bindernames from a UID pool: the names CSE happens to pick decide whether the subsequent
MergeLetfires. It also makes the pass hard to test, and hard to port — we hit thiswhile differential-testing the iterator transforms against an independent MLIR/xDSL
port of GTIR, where the IR is α-normalised, so the two programs above are literally
the same term and cannot produce two different results.
The change
Guards 1 and 2 are replaced by renaming the offending inner parameters apart. Guard 3
is kept unchanged and is now the only reason to skip: if an argument of the inner call
references a parameter of the outer lambda, hoisting it would move it out of the binder
it refers to. Renaming uses the existing
RenameSymbolsandir_misc.unique_symbol,exactly as
inline_lambdasalready does for the same purpose.Net effect: strictly more merging, and the result now depends only on the input up to
renaming of bound symbols.
No uids pool needed
The obvious objection to α-renaming inside this pass is that it would need a fresh-name
source, changing
MergeLet's signature and all three of its call sites. It does not:ir_misc.unique_symbol(name, reserved)is a pure function of the name and the reservedset, so the pass stays stateless and no call site changes.
The reserved set is the outer parameters, the symbols referenced by the outer arguments,
and every
Sym/SymRefoccurring anywhere in the inner lambda. Including binderSymsand not just
SymRefs is load-bearing: a nested lambda may bind the candidate namewithout ever referencing it, and renaming into it would then capture. With a refs-only
reserved set,
merges to
(λ(a, a_) → (⇑(λ(a_) → ·a_))())(i, q), where·a_now derefs the nestedlambda's own parameter. There is a regression test for this.
One change outside
merge_let.pyRenameSymbols.visit_SymdroppedSym.typewhen renaming, whilevisit_SymRefrightbelow it explicitly copies the type over. That asymmetry looks like an oversight; it
became visible here because
MergeLetruns on typed IR insidefuse_as_fieldop, sorenaming a parameter would have silently discarded its type. Fixed by passing
type=node.typethrough. This also affectsinline_lambdas, the otherRenameSymbolsuser — happy to split it into its own PR if you would rather review it separately.
What we would like you to decide
The alternative is to keep the current behaviour and document the pass as
name-sensitive by design. If you prefer that, this PR should simply be closed and
replaced by a docstring note — we would just like the chosen semantics to be written
down somewhere.
Two things worth a maintainer's eye if you do want this:
cannot check from outside is whether any downstream consumer relies on binder names
surviving
MergeLet— e.g. debugging output, the DaCe lowering's name handling, oranything matching on specific symbol names.
in the enclosing scope, so an inner binder that merely spells the same name as a free
symbol in an outer argument cannot actually capture it;
(λ(x, b) → b)(b, 1)would becorrect as-is. We kept a rename there because it preserves the guard's evident intent
and keeps the merged output free of confusing shadowing, but dropping it entirely is a
defensible simplification if you prefer.
Tests
tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_merge_let.pyis new;merge_lethad no tests. It covers the plain merge, both renaming cases, α-invariance ofthe pair above, both guard-3 skip cases, capture-avoidance against a nested binder, and
type preservation of a renamed parameter.
Local runs (Python 3.14):
tests/next_tests/unit_tests/iterator_tests/— 505 passed, 1 skipped, 2 xfailed.tests/next_tests/integration_tests/feature_tests/ffront_tests/ -k roundtrip(thepipeline path where
MergeLetruns after CSE) — 622 passed, 10 skipped, 70 xfailed.tests/next_tests/unit_tests/— identical pass/fail set to a clean-tree baseline(the failures there are local dace/cupy/sympy environment noise, unrelated).
CI will have to cover
gtfnanddace, which we cannot run locally.Related: we have a second draft PR (
fix/mergelet-builtin-names) touching the samefile — it fixes one concrete instance of name-keyed-guard breakage in guard 3 and also
creates
test_merge_let.py, so the two will need sequencing / a trivial merge.Left as a draft pending your call on the direction.
🤖 Generated with Claude Code