Skip to content

fix[next]: make MergeLet alpha-invariant - #2726

Draft
havogt wants to merge 2 commits into
GridTools:mainfrom
havogt:fix/mergelet-alpha-invariance
Draft

fix[next]: make MergeLet alpha-invariant#2726
havogt wants to merge 2 commits into
GridTools:mainfrom
havogt:fix/mergelet-alpha-invariance

Conversation

@havogt

@havogt havogt commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

MergeLet is 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

from gt4py.next.iterator.ir_utils import ir_makers as im
from gt4py.next.iterator.transforms.merge_let import MergeLet

MergeLet().visit(im.let("a", "i")(im.let("a", 1)("a")))
# before: (λ(a) → (λ(a) → a)(1))(i)      -- unchanged
# after:  (λ(a, a_) → a_)(i, 1)

MergeLet().visit(im.let("a", "i")(im.let("b", 1)("b")))
# before: (λ(a, b) → b)(i, 1)            -- merged
# after:  (λ(a, b) → b)(i, 1)            -- unchanged by this PR

The two inputs are α-equivalent: the inner binder is not referenced from outside the
inner lambda, so renaming it ab is a pure renaming. Before this PR only the
second merged, because the collision guard was a set intersection of parameter names:

# skip if we have a collision
if set(outer_lambda.params) & set(inner_lambda.params):
return node

The second guard (the ref-count over inner_lambda.params) was name-keyed the same
way: (λ(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 MergeLet runs directly after
CommonSubexpressionElimination in pass_manager.py, and CSE invents its binder
names from a UID pool: the names CSE happens to pick decide whether the subsequent
MergeLet fires. It also makes the pass hard to test, and hard to port — we hit this
while 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 RenameSymbols and ir_misc.unique_symbol,
exactly as inline_lambdas already 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 reserved
set, 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/SymRef occurring anywhere in the inner lambda. Including binder Syms
and not just SymRefs is load-bearing: a nested lambda may bind the candidate name
without ever referencing it, and renaming into it would then capture. With a refs-only
reserved set,

(λ(a) → (λ(a) → (⇑(λ(a_) → ·a))())(q))(i)

merges to (λ(a, a_) → (⇑(λ(a_) → ·a_))())(i, q), where ·a_ now derefs the nested
lambda's own parameter. There is a regression test for this.

One change outside merge_let.py

RenameSymbols.visit_Sym dropped Sym.type when renaming, while visit_SymRef right
below it explicitly copies the type over. That asymmetry looks like an oversight; it
became visible here because MergeLet runs on typed IR inside fuse_as_fieldop, so
renaming a parameter would have silently discarded its type. Fixed by passing
type=node.type through. This also affects inline_lambdas, the other RenameSymbols
user — 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:

  • New binder names appear in the output. Unavoidable for α-renaming. The thing we
    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, or
    anything matching on specific symbol names.
  • Guard 2 could be dropped rather than turned into a rename. Arguments are evaluated
    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 be
    correct 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.py is new;
merge_let had no tests. It covers the plain merge, both renaming cases, α-invariance of
the 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 (the
    pipeline path where MergeLet runs 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 gtfn and dace, which we cannot run locally.

Related: we have a second draft PR (fix/mergelet-builtin-names) touching the same
file — 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

havogt added 2 commits July 29, 2026 11:49
`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.
@havogt havogt changed the title test[next]: MergeLet is not alpha-invariant (characterization + design question) fix[next]: make MergeLet alpha-invariant Jul 29, 2026
@tehrengruber

Copy link
Copy Markdown
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 visit_FunCall. My general idea to avoid this is to have some sort of SSA pass and then merge without needing to care about collision. The _CanonicalizeNames pass in this draft might work https://github.com/GridTools/gt4py/pull/2302/changes, but has slightly different requirements.

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.

2 participants