Skip to content

feat[next]: Tracer support part 1: tree_map#2586

Open
SF-N wants to merge 47 commits into
GridTools:mainfrom
SF-N:tracer_support_tree_map
Open

feat[next]: Tracer support part 1: tree_map#2586
SF-N wants to merge 47 commits into
GridTools:mainfrom
SF-N:tracer_support_tree_map

Conversation

@SF-N

@SF-N SF-N commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

As a first step towards tracer support (enabling vector operations), this PR introduces a new tree_map operator for mapping functions over tuples (including nesting). tree_map is unrolled to make_tuple calls in UnrollTreeMap.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an iterator-level tree_map builtin as an IR operator to support mapping functions over (nested) tuples, as a first step towards tracer support and future vector operations. It includes type synthesis for tree_map and a transform (UnrollTreeMap) that lowers tree_map(f)(...) into explicit make_tuple / tuple_get IR.

Changes:

  • Add tree_map builtin plumbing (builtin dispatch + IR maker helper) and update tuple-where lowering to emit tree_map.
  • Add tree_map type synthesizer and a new UnrollTreeMap transform, wired into the iterator pass pipeline.
  • Add unit tests for the _unroll helper and adjust existing frontend lowering expectations.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_unroll_tree_map.py Adds unit tests for _unroll tuple expansion behavior.
tests/next_tests/unit_tests/ffront_tests/test_foast_to_gtir.py Updates tuple where reference IR to use tree_map.
src/gt4py/next/iterator/type_system/type_synthesizer.py Registers and implements type synthesis for tree_map.
src/gt4py/next/iterator/transforms/unroll_tree_map.py New transform to unroll tree_map into tuple primitives.
src/gt4py/next/iterator/transforms/pass_manager.py Runs UnrollTreeMap and tuple-collapsing before domain inference.
src/gt4py/next/iterator/ir_utils/ir_makers.py Adds im.tree_map(...) helper for constructing IR.
src/gt4py/next/iterator/builtins.py Adds tree_map to builtin dispatch and builtin name set.
src/gt4py/next/ffront/foast_to_gtir.py Lowers tuple where via tree_map instead of explicit tuple construction.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/gt4py/next/iterator/transforms/unroll_tree_map.py Outdated
Comment thread src/gt4py/next/iterator/transforms/pass_manager.py Outdated
Comment thread src/gt4py/next/iterator/transforms/pass_manager.py Outdated
Comment thread src/gt4py/next/iterator/type_system/type_synthesizer.py Outdated
Comment thread src/gt4py/next/iterator/type_system/type_synthesizer.py Outdated
Comment on lines +182 to +199
# After UnrollTreeMap, collapse `tuple_get(i, let(...)(make_tuple(...)))` patterns so that
# domain inference does not encounter `as_fieldop` nodes inside dead tuple elements
# (which would receive NEVER domain). Do multiple iterations for nested `let`s.
for _ in range(10):
collapsed = ir
ir = CollapseTuple.apply(
ir,
enabled_transformations=(
CollapseTuple.Transformation.PROPAGATE_TUPLE_GET
| CollapseTuple.Transformation.COLLAPSE_TUPLE_GET_MAKE_TUPLE
),
uids=uids,
offset_provider_type=offset_provider_type,
) # type: ignore[assignment] # always an itir.Program
if ir == collapsed:
break
else:
raise RuntimeError("'CollapseTuple' did not converge after `UnrollTreeMap`.")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this test_reduction_expression_with_where_and_tuples fails with ValueError: 'target_domain' cannot be 'NEVER' unless "allow_uninferred=True".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: probably this is also the test case where the loop is required. I'll take a look if another configuration of the pass helps to avoid the loop.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/gt4py/next/iterator/transforms/unroll_tree_map.py Outdated
Comment thread src/gt4py/next/iterator/transforms/pass_manager.py Outdated
Comment thread src/gt4py/next/iterator/type_system/type_synthesizer.py Outdated
Comment thread src/gt4py/next/iterator/transforms/unroll_tree_map.py Outdated
f"Call to object of type '{type(node.func.type).__name__}' not understood."
)

def _visit_astype(self, node: foast.Call, **kwargs: Any) -> itir.Expr:

@SF-N SF-N Apr 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think _visit_astype cannot use tree_map because it needs type-dependent lowering per leaf: fields use _map(cast) while scalars use cast directly. Let's discuss if you have something else in mind.


return im.let(cond_symref_name, cond_)(result)

def _visit_concat_where(self, node: foast.Call, **kwargs: Any) -> itir.FunCall:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I see, _visit_concat_where already has its own expand_tuple_args pass for handling nested tuples, and each branch can have a different domain. Attempting to wrap it in tree_map caused type inference failures. Let's discuss if you have something else in mind.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other reasons: A single concat_where is better to digest for optimizations. The lowering would actually be complicated if we would emit tree_map in foast_to_gtir.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _visit_concat_where(self, node: foast.Call, **kwargs: Any) -> itir.FunCall:
# TODO(tehrengruber): Use `tree_map_tuple` when the domain inference is able to handle
# lambda functions (with the results domain depending on the caller / args)

Comment thread src/gt4py/next/iterator/transforms/unroll_tree_map.py Outdated
Comment thread src/gt4py/next/iterator/type_system/type_synthesizer.py Outdated
Comment thread src/gt4py/next/iterator/type_system/type_synthesizer.py Outdated
@SF-N SF-N requested a review from tehrengruber April 29, 2026 13:52
Comment thread src/gt4py/next/iterator/transforms/pass_manager.py Outdated
Comment thread src/gt4py/next/iterator/transforms/pass_manager.py Outdated
Comment thread src/gt4py/next/iterator/transforms/unroll_tuple_maps.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated 4 comments.

Comment on lines +84 to +103
uids: utils.IDGeneratorPool

@classmethod
def apply(
cls,
node: ProgramOrExpr,
*,
uids: utils.IDGeneratorPool | None = None,
offset_provider_type: common.OffsetProviderType | None = None,
) -> ProgramOrExpr:
if node.type is None:
node = itir_inference.infer(
node,
offset_provider_type=offset_provider_type or {},
allow_undeclared_symbols=not isinstance(node, itir.Program),
)
if uids is None:
uids = utils.IDGeneratorPool()
return cls(uids=uids).visit(node)


tup_types: list[ts.TupleType] = []
for tup in tup_args:
itir_inference.reinfer(tup)
body = _UNROLLERS[builtin_name](f, substituted_exprs, tup_types)

result = im.let(*let_bindings)(body) if let_bindings else body
itir_inference.reinfer(result)
Comment on lines +173 to +177
# `UnrollTupleMaps` requires fully-inferred tuple types (relies on `reinfer` to see
# nested `TupleType` chains), so the offset_provider is passed for on-demand inference.
ir = unroll_tuple_maps.UnrollTupleMaps.apply(
ir, uids=uids, offset_provider_type=offset_provider_type
)

@tehrengruber tehrengruber left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial review only.

(node.args[0].type, *arg_types),
result = im.tree_map_tuple(
im.lambda_("__a", "__b")(
im.op_as_fieldop("if_")(im.ref(cond_symref_name), im.ref("__a"), im.ref("__b"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contrary to the tuple comprehension case where we can not type the el_expr in el_expr for ... in ... and hence have to disallow cases with mixed element types, this is not the case here. The process_elements approach works for the case above. Let's add a test (if not already done) and revert to the previous strategy. Additionally a comment that we would like to use tree_map_tuple and why this is not possible (or a reference) would be good. If you want to be thorough create an ADR that describes the strategy for all the cases and reference that. https://hackmd.io/PxqTkA4rSGCgCmUAPq15SA could serve as a starting point.


return im.let(cond_symref_name, cond_)(result)

def _visit_concat_where(self, node: foast.Call, **kwargs: Any) -> itir.FunCall:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _visit_concat_where(self, node: foast.Call, **kwargs: Any) -> itir.FunCall:
# TODO(tehrengruber): Use `tree_map_tuple` when the domain inference is able to handle
# lambda functions (with the results domain depending on the caller / args)

Comment thread src/gt4py/next/iterator/transforms/pass_manager.py Outdated
Comment thread src/gt4py/next/iterator/transforms/pass_manager.py Outdated
Comment thread src/gt4py/next/iterator/transforms/unroll_tuple_maps.py Outdated
Comment thread src/gt4py/next/iterator/transforms/unroll_tuple_maps.py Outdated
Comment thread src/gt4py/next/iterator/transforms/unroll_tuple_maps.py Outdated
Comment thread src/gt4py/next/iterator/type_system/type_synthesizer.py
return mapper(*tup_types)


def _map_tuple_body(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should make map_tuple support multiple args like tree_map_tuple.

Open question: Does using multiple args in the lowering improve what collapse tuple can do before the tree_map_tuple can do? Then let's use that path even for the ... for tuple in ... in the frontend.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tuple[0]+tuple[1] for tuple in nested_tuple
or
let(tuple, {a, b})(tuple[0]+tuple[1]) for a, b in {nested_tuple[0], nested_tuple[1]}
Another dimension: a+b for a, b in nested_tuple
The interesting parts are what is the advantage / disadvantage in the lowering & unroll pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All examples single-arg map_tuple(op)(n) over eg.g n : ((Field[[IDim], float64], Field[[IDim], float64]),(Field[[IDim], float64],Field[[IDim], float64])), and they collapse to the identical result:

  1. t[0] + t[1] for t in n:
    lowered:
    map_tuple(λ(t) → t[0] + t[1])(n)
    before collapse:
    (λ(_utm_0) → {(λ(t) → t[0] + t[1])(_utm_0[0]),(λ(t) → t[0] + t[1])(_utm_0[1])})(n)
    after collapse:
    {n[0][0] + n[0][1], n[1][0] + n[1][1]}

  2. let(tup, {a, b})(tup[0] + tup[1]) for a, b in {n[0], n[1]}:
    lowered:
    map_tuple(λ(t) → (λ(a) → (λ(b) → (λ(tup) → tup[0] + tup[1])({a, b}))(t[1]))(t[0]))({n[0], n[1]})
    before collapse:
    (λ(_utm_0) → {(λ(t) → (λ(a) → (λ(b) → (λ(tup) → tup[0] + tup[1])({a, b}))(t[1]))(t[0]))(_utm_0[0]), (λ(t) → (λ(a) → (λ(b) → (λ(tup) → tup[0] + tup[1])({a, b}))(t[1]))(t[0]))(_utm_0[1])})({n[0], n[1]})
    after collapse:
    {n[0][0] + n[0][1], n[1][0] + n[1][1]}

  3. a + b for a, b in n:
    lowered:
    map_tuple(λ(t) → (λ(a) → (λ(b) → a + b)(t[1]))(t[0]))(n)
    before collapse:
    (λ(_utm_0) → {(λ(t) → (λ(a) → (λ(b) → a + b)(t[1]))(t[0]))(_utm_0[0]), (λ(t) → (λ(a) → (λ(b) → a + b)(t[1]))(t[0]))(_utm_0[1])})(n)
    after collapse:
    {n[0][0] + n[0][1], n[1][0] + n[1][1]}

All three collapse to the same result, so the choice only affects the intermediate size that the unroll+collapse passes must remove.

There is no use-case for multi-arg map_tuple so far and also not in #PR2487 , whereas for tree_map_tuple the multi-arg use-case is where(cond, a, b).

Nevertheless, I tried implementing multi-arg map_tuple and no changes in the lowering were necessary, only small changes in the type_synthesizer, ir_makers and in the unroll_tuple_maps replacing

def _map_tuple_body(
    f: itir.Expr, tup_exprs: list[itir.Expr], tup_types: list[ts.TupleType]
) -> itir.Expr:
    """Unroll `map_tuple(f)(t)` over top-level elements only (no recursion)."""
    (tup_expr,) = tup_exprs
    (tup_type,) = tup_types
    return im.make_tuple(
        *(im.call(f)(im.tuple_get(i, tup_expr)) for i in range(len(tup_type.types)))
    )

by

def _map_tuple_body(
    f: itir.Expr, tup_exprs: list[itir.Expr], tup_types: list[ts.TupleType]
) -> itir.Expr:
    """Unroll `map_tuple(f)(t1, ..., tN)` over top-level elements only (no recursion)."""
    return im.make_tuple(
        *(
            im.call(f)(*(im.tuple_get(i, tup_expr) for tup_expr in tup_exprs))
            for i in range(len(tup_types[0].types))
        )
    )

The only benefit would be in the case
map_tuple(λa,b → a+b)(g(), h()) vs map_tuple(λt → t[0]+t[1])(zip(g(),h())) where g()/h() would be evaluated 2× in the former and 4x in the latter. But usually a comprehension has a single source, so there is nothing independent to combine.

So, I reverted the changes again and would keep map_tuple single-arg.

@SF-N SF-N requested a review from tehrengruber July 6, 2026 13:23
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.

4 participants