Skip to content

fix[next]: lower function definitions to let bindings - #2905

Open
tehrengruber wants to merge 5 commits into
mainfrom
tehrengruber/simplify-inline-fundefs
Open

tehrengruber wants to merge 5 commits into
mainfrom
tehrengruber/simplify-inline-fundefs

Conversation

@tehrengruber

@tehrengruber tehrengruber commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

InlineFundefs replaced every SymRef matching a program-level function definition, regardless of any binder of the same name in between. A lambda parameter named like a field operator was therefore silently replaced by that function, producing an ill-typed program (e.g. an AssertionError in type_synthesizer._canonicalize_nb_fields).

Instead of substituting references, all function definitions are now bound in nested lets wrapping every statement expression, and Program.function_definitions is cleared. Regular scoping rules then apply, i.e. an inner binder shadows the function definition, and unused bindings are removed by dead code elimination, which makes the separate prune_unreferenced_fundefs pass obsolete. The lets are nested in topological order since function definitions may call each other.

Follow-up to #2833 (comment), where tuple-comprehension targets made this easy to hit.

AI disclaimer: This PR has been created with the help of AI tools. The code has been reviewed, but tests were only briefly sighted.

`InlineFundefs` replaced every `SymRef` matching a program-level function
definition, regardless of any binder of the same name in between. A lambda
parameter named like a function definition was therefore silently replaced
by that function, producing an ill-typed program (e.g. an `AssertionError`
in `type_synthesizer._canonicalize_nb_fields`).

Instead of substituting references, bind all function definitions in a
`let` wrapping every statement expression. Regular scoping rules then
apply, i.e. an inner binder shadows the function definition, and unused
bindings are removed by dead code elimination, which makes the separate
`prune_unreferenced_fundefs` pass obsolete.
@tehrengruber

tehrengruber commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Since the PR does not inline every function right away it might lead to some problems in the dace backend. We might want to force inline functions for dace and maybe even gtfn. The approach in this PR generally better since we start with a smaller IR (after the first inline lambda which removes unused functions) that we can optimize before inlining later. We force inline all lambdas anyway right now.

The traced `flux` function definition is polymorphic in its parameter `d`,
it is called with an `I` and a `J` offset. Type inference can not type a
`let` bound function used at several types, which the function definitions
are lowered to now. Dropping the `fundef` decorator makes tracing inline
`flux` as a lambda instead, which keeps the test unchanged otherwise.
@havogt
havogt self-requested a review September 23, 2026 09:00
@havogt

havogt commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

The let lowering breaks a real icon4py program, and the shadowing fix is still incomplete. I'd suggest scope-aware substitution instead, though it has one trade-off (below).

1. Regression: TypeError when a statement doesn't call an operator

@gtx.field_operator
def inner(a: F) -> F:
    return a + 1.0

@gtx.field_operator
def outer(a: F, b: F) -> F:
    return concat_where(I >= 1, inner(a), b)

@gtx.program
def p(a: F, b: F, o1: F, o2: F):
    outer(a, b, out=o1)
    inner(a, out=o2)
TypeError: 'DeferredType' object is not callable

This fails in both apply_common_transforms and apply_fieldview_transforms, and lowers fine on main. icon4py's compute_horizontal_velocity_quantities_and_fluxes fails the same way.

Every statement binds every function definition, so in the second statement the lambda for outer is never called and its body stays untyped. expand_tuple_args runs before dead code elimination, visits that body anyway, and reinfer resolves inner to DeferredType (expand_tuple_args.py:44).

2. False CycleError when a parameter is named like an operator

_sorted_by_dependency collects references from fundef.expr without the definition's parameters in scope:

@gtx.field_operator
def foo(foo: F) -> F:
    return foo + 1.0
# CycleError: ('nodes are in a cycle', ['foo', 'foo'])

def f(g: F) together with def g(f: F) fails the same way. These also failed on main (with RecursionError), so this isn't a regression, but it's the bug class this PR targets. The new test passes because shadow(scale) only goes one way.

3. Other behaviour lost compared to main (iterator IR only)

  • A function definition referenced from SetAt.domain or a Temporary domain fails with Symbols {SymbolRef('upper')} not found.
  • A polymorphic @fundef now raises AssertionError, which is why flux in test_hdiff had to change.

Suggestion: substitute at each reference, but respect binders

@dataclasses.dataclass(frozen=True)
class InlineFundefs(PreserveLocationVisitor, NodeTranslator):
    fundefs: dict[str, itir.FunctionDefinition]

    def visit_SymRef(self, node, *, shadowed):
        if node.id in self.fundefs and node.id not in shadowed:
            fundef = self.fundefs[node.id]
            return self.visit(
                itir.Lambda(params=fundef.params, expr=fundef.expr), shadowed=frozenset()
            )
        return self.generic_visit(node, shadowed=shadowed)

    def visit_Lambda(self, node, *, shadowed):
        return self.generic_visit(node, shadowed=shadowed | {p.id for p in node.params})


def inline_fundefs(program: itir.Program) -> itir.Program:
    visitor = InlineFundefs({str(f.id): f for f in program.function_definitions})
    return itir.Program(
        id=program.id,
        function_definitions=[],
        params=program.params,
        declarations=visitor.visit(program.declarations, shadowed=frozenset()),
        body=visitor.visit(program.body, shadowed=frozenset()),
    )

shadowed resets on entering a function body, because the body's names belong to program scope and not to the call site. Each reference gets its own lambda again, so _sorted_by_dependency, _WrapStatementExpressions and the test_hdiff change are no longer needed.

Tested with this patched into the pass manager:

  • The examples in 1 and 2 and the PR's shadowing case lower in both pipelines.
  • The cases in 3 work, and flux stays a @fundef (test_hdiff roundtrip: 3 passed, 1 xfailed).
  • For the two icon4py programs I compared, including the one from 1, the IR is identical to main.
  • tests/next_tests/unit_tests/iterator_tests plus test_closure_vars.py, without gtfn and dace: 590 passed, 7 skipped, 2 xfailed.
  • An earlier version, which differed only in visiting declarations and copying SymRefs, gave the same counts as the unpatched PR on the wider iterator and ffront suites without gtfn and dace.

I haven't compiled anything with gtfn or dace, and test_inline_fundefs.py would need rewriting because it asserts the let form.

Trade-offs of this approach

It keeps a capture bug from main that this PR fixes. A function body that uses a builtin, inlined under a parameter with that builtin's name, binds to the parameter:

@fundef
def times2(x):
    return multiplies(deref(x), 2.0)

@fundef
def app(multiplies, x):
    return times2(x)

# stencil: app(lambda p, q: minus(p, q), x)
# main and this suggestion: [-1.0, 0.0, 1.0, 2.0]
# this PR, and expected:    [2.0, 4.0, 6.0, 8.0]

That wrong result is silent, but it needs a callable bound to a builtin's name, which I only reached from tracing or hand-built IR. From the frontend, a parameter named plus or deref gives TypeError: 'FieldType' object is not callable, as on main. No icon4py operator has a parameter or local named after a builtin.

It also gives up the smaller IR. For f_i = f_{i-1}(x) + f_{i-1}(x), this PR's pass produces 203 nodes at n=14 against 106,513 with substitution, the same as main. That gain doesn't last, because dead_code_elimination force-inlines the lambdas. Through the full apply_common_transforms, all three reach the same final size (86 nodes at n=9) at similar speed (4.28 s for this PR, 4.81 s for the suggestion, 3.62 s for main). Larger n wasn't compared.

Your comment above says all lambdas are force-inlined anyway, so I don't see what lets buy right now. If you plan to stop force-inlining, or consider the capture unacceptable, the smaller fixes to the current PR are:

  • For 1: run InlineLambdas.apply(ir, opcount_preserving=True) directly after wrapping. Tested on the example and the icon4py program.
  • For 2: collect references from im.lambda_(*fundef.params)(fundef.expr). Tested on both examples.

The behaviour in 3 would then remain, and flux would stay a plain closure.

Comparison on icon4py

I compared the IR from main and this PR through apply_common_transforms on simple_grid(), without compiling anything. The run covered 112 of the 134 stencil programs under model/atmosphere before timing out:

Result Programs
Identical IR 105
New failure (the program from 1) 1
IR differs, confirmed naming or line wrapping only 3
IR differs, not established as equivalent 3

The three unresolved programs are compute_velocity_advection_in_corrector_step, compute_velocity_advection_in_predictor_step and compute_ppm4gpu_integer_flux.

🤖 Written by an agent on behalf of @havogt

Missing information propagates as `DeferredType` throughout the iterator
type inference, but `visit_FunCall` called whatever `visit` returned for
`node.fun`, raising `TypeError: 'DeferredType' object is not callable` for
an undeclared symbol. This is hit when `reinfer` is run on an expression
without its enclosing scope, e.g. by `concat_where.expand_tuple_args` on a
not yet typed function body.
Three fixes to `inline_fundefs`:

- The dependency order was computed from references collected without the
  definition's own parameters in scope, so a parameter named like a function
  definition was taken for a dependency. A parameter named like the function
  itself, or two functions with parameters named like each other, resulted in
  a bogus `graphlib.CycleError`.
- Only the expression of a statement was wrapped, so a function definition
  referenced from a statement or temporary domain was left dangling once the
  function definitions were dropped. All of them are wrapped now.
- To keep these expressions small, every position only binds the function
  definitions it references, directly or transitively. Unreferenced
  definitions are not bound at all, which also keeps domain inference from
  seeing bodies that are never called.
@tehrengruber-ai

Copy link
Copy Markdown

All three reproduce. 1. and 2. are addressed, 3. only partly.

1. Missing information propagates as DeferredType throughout the type inference. visit_FunCall was missing that rule and called the DeferredType it got for node.fun. It now returns a DeferredType.

2. The dependency collection now has the definition's own parameters in scope, so a parameter named like a function definition is no longer taken for a dependency. Added unit tests for a parameter named like its own function, for two functions with parameters named like each other, and for a definition that shadows one function but genuinely calls another.

3. Only the function definition in a domain is addressed. Statement domains and temporary domains are now wrapped like statement expressions. To keep these expressions small, each position only binds the function definitions it actually references, directly or transitively. Unreferenced definitions are not bound at all. As a side effect this also makes the case in 1. unreachable, since the dead binding is never created.

The polymorphic @fundef is a different matter. Polymorphic functions are not a feature we support. That the unconditional substitution made some cases work was incidental, not by design. Supporting them properly means duplicating the definition and specializing each copy, which is a separate change. flux in test_hdiff is now a plain closure.

This branch has not been deployed

No deployments
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.

3 participants