Repository: GridTools/gt4py
Pull request #1651 (2024-09-19 11:52:03)

feat[next][dace]: GTIR-to-SDFG lowering of tuples
Add support for tuples in lowering to SDFG. This includes:
- Tuples as program arguments
- Tuples in IR-expressions like let-statements and scalar-if (aka `cond`) statements

The design relies on recursive visit of tuples: wherever possible the utility functions `tree_map` and `flatten_nested_tuple` were used.
Another design aspect was to represent tuple elements of type `ts.Scalar` as scalar data containers in the SDFG. All other program arguments of type `ts.Scalar`, not part of a tuple, are resented as dace symbols (legacy behavior).


Patch:
diff --git a/src/gt4py/next/iterator/ir_utils/domain_utils.py b/src/gt4py/next/iterator/ir_utils/domain_utils.py
index ed04eb11..8eec4051 100644
--- a/src/gt4py/next/iterator/ir_utils/domain_utils.py
+++ b/src/gt4py/next/iterator/ir_utils/domain_utils.py
@@ -16,6 +16,7 @@ import gt4py.next as gtx
 from gt4py.next import common
 from gt4py.next.iterator import ir as itir
 from gt4py.next.iterator.ir_utils import ir_makers as im
+from gt4py.next.iterator.transforms import trace_shifts
 
 
 def _max_domain_sizes_by_location_type(offset_provider: Mapping[str, Any]) -> dict[str, int]:
@@ -86,7 +87,11 @@ class SymbolicDomain:
 
     def translate(
         self: SymbolicDomain,
-        shift: tuple[itir.OffsetLiteral, ...],
+        shift: tuple[
+            itir.OffsetLiteral
+            | Literal[trace_shifts.Sentinel.VALUE, trace_shifts.Sentinel.ALL_NEIGHBORS],
+            ...,
+        ],
         offset_provider: common.OffsetProvider,
     ) -> SymbolicDomain:
         dims = list(self.ranges.keys())
@@ -95,9 +100,12 @@ class SymbolicDomain:
             return self
         if len(shift) == 2:
             off, val = shift
-            assert isinstance(off.value, str) and isinstance(val.value, int)
+            assert isinstance(off, itir.OffsetLiteral) and isinstance(off.value, str)
             nbt_provider = offset_provider[off.value]
             if isinstance(nbt_provider, common.Dimension):
+                if val is trace_shifts.Sentinel.VALUE:
+                    raise NotImplementedError("Dynamic offsets not supported.")
+                assert isinstance(val, itir.OffsetLiteral) and isinstance(val.value, int)
                 current_dim = nbt_provider
                 # cartesian offset
                 new_ranges[current_dim] = SymbolicRange.translate(
@@ -105,6 +113,12 @@ class SymbolicDomain:
                 )
             elif isinstance(nbt_provider, common.Connectivity):
                 # unstructured shift
+                assert (
+                    isinstance(val, itir.OffsetLiteral) and isinstance(val.value, int)
+                ) or val in [
+                    trace_shifts.Sentinel.ALL_NEIGHBORS,
+                    trace_shifts.Sentinel.VALUE,
+                ]
                 # note: ugly but cheap re-computation, but should disappear
                 horizontal_sizes = _max_domain_sizes_by_location_type(offset_provider)
 
diff --git a/src/gt4py/next/program_processors/runners/dace_common/dace_backend.py b/src/gt4py/next/program_processors/runners/dace_common/dace_backend.py
index 6625ce57..6039c82f 100644
--- a/src/gt4py/next/program_processors/runners/dace_common/dace_backend.py
+++ b/src/gt4py/next/program_processors/runners/dace_common/dace_backend.py
@@ -7,12 +7,12 @@
 # SPDX-License-Identifier: BSD-3-Clause
 import warnings
 from collections.abc import Mapping, Sequence
-from typing import Any
+from typing import Any, Iterable
 
 import dace
 import numpy as np
 
-from gt4py.next import common as gtx_common
+from gt4py.next import common as gtx_common, utils as gtx_utils
 
 from . import utility as dace_utils
 
@@ -54,9 +54,10 @@ def _get_args(
     sdfg: dace.SDFG, args: Sequence[Any], use_field_canonical_representation: bool
 ) -> dict[str, Any]:
     sdfg_params: Sequence[str] = sdfg.arg_names
+    flat_args: Iterable[Any] = gtx_utils.flatten_nested_tuple(tuple(args))
     return {
         sdfg_param: _convert_arg(arg, sdfg_param, use_field_canonical_representation)
-        for sdfg_param, arg in zip(sdfg_params, args, strict=True)
+        for sdfg_param, arg in zip(sdfg_params, flat_args, strict=True)
     }
 
 
@@ -112,7 +113,7 @@ def _get_stride_args(
 
 def get_sdfg_conn_args(
     sdfg: dace.SDFG,
-    offset_provider: dict[str, Any],
+    offset_provider: gtx_common.OffsetProvider,
     on_gpu: bool,
 ) -> dict[str, np.typing.NDArray]:
     """
diff --git a/src/gt4py/next/program_processors/runners/dace_common/utility.py b/src/gt4py/next/program_processors/runners/dace_common/utility.py
index bf160388..dec34ecb 100644
--- a/src/gt4py/next/program_processors/runners/dace_common/utility.py
+++ b/src/gt4py/next/program_processors/runners/dace_common/utility.py
@@ -9,7 +9,7 @@
 from __future__ import annotations
 
 import re
-from typing import Any, Final, Mapping, Optional, Sequence
+from typing import Final, Optional, Sequence
 
 import dace
 
@@ -78,7 +78,9 @@ def debug_info(
     return default
 
 
-def filter_connectivities(offset_provider: Mapping[str, Any]) -> dict[str, gtx_common.Connectivity]:
+def filter_connectivities(
+    offset_provider: gtx_common.OffsetProvider,
+) -> dict[str, gtx_common.Connectivity]:
     """
     Filter offset providers of type `Connectivity`.
 
diff --git a/src/gt4py/next/program_processors/runners/dace_common/workflow.py b/src/gt4py/next/program_processors/runners/dace_common/workflow.py
index f7809e3c..a9f26d54 100644
--- a/src/gt4py/next/program_processors/runners/dace_common/workflow.py
+++ b/src/gt4py/next/program_processors/runners/dace_common/workflow.py
@@ -102,7 +102,7 @@ def convert_args(
 
     def decorated_program(
         *args: Any,
-        offset_provider: dict[str, common.Connectivity | common.Dimension],
+        offset_provider: common.OffsetProvider,
         out: Any = None,
     ) -> Any:
         if out is not None:
diff --git a/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_builtin_translators.py b/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_builtin_translators.py
index 5c835095..10696ef8 100644
--- a/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_builtin_translators.py
+++ b/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_builtin_translators.py
@@ -9,12 +9,13 @@
 from __future__ import annotations
 
 import abc
-from typing import TYPE_CHECKING, Optional, Protocol, TypeAlias
+import dataclasses
+from typing import TYPE_CHECKING, Iterable, Optional, Protocol, TypeAlias
 
 import dace
 import dace.subsets as sbs
 
-from gt4py.next import common as gtx_common
+from gt4py.next import common as gtx_common, utils as gtx_utils
 from gt4py.next.iterator import ir as gtir
 from gt4py.next.iterator.ir_utils import common_pattern_matcher as cpm
 from gt4py.next.iterator.type_system import type_specifications as gtir_ts
@@ -32,7 +33,15 @@ if TYPE_CHECKING:
 
 
 IteratorIndexDType: TypeAlias = dace.int32  # type of iterator indexes
-TemporaryData: TypeAlias = tuple[dace.nodes.Node, ts.FieldType | ts.ScalarType]
+
+
+@dataclasses.dataclass(frozen=True)
+class Field:
+    data_node: dace.nodes.AccessNode
+    data_type: ts.FieldType | ts.ScalarType
+
+
+FieldopResult: TypeAlias = Field | tuple[Field | tuple, ...]
 
 
 class PrimitiveTranslator(Protocol):
@@ -44,7 +53,7 @@ class PrimitiveTranslator(Protocol):
         state: dace.SDFGState,
         sdfg_builder: gtir_to_sdfg.SDFGBuilder,
         reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
-    ) -> list[TemporaryData]:
+    ) -> FieldopResult:
         """Creates the dataflow subgraph representing a GTIR primitive function.
 
         This method is used by derived classes to build a specialized subgraph
@@ -67,7 +76,7 @@ class PrimitiveTranslator(Protocol):
         """
 
 
-def _parse_arg_expr(
+def _parse_fieldop_arg(
     node: gtir.Expr,
     sdfg: dace.SDFG,
     state: dace.SDFGState,
@@ -77,22 +86,20 @@ def _parse_arg_expr(
     ],
     reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
 ) -> gtir_to_tasklet.IteratorExpr | gtir_to_tasklet.MemletExpr:
-    fields: list[TemporaryData] = sdfg_builder.visit(
+    arg = sdfg_builder.visit(
         node,
         sdfg=sdfg,
         head_state=state,
         reduce_identity=reduce_identity,
     )
 
-    assert len(fields) == 1
-    data_node, arg_type = fields[0]
-    # require all argument nodes to be data access nodes (no symbols)
-    assert isinstance(data_node, dace.nodes.AccessNode)
+    # arguments passed to field operator should be plain fields, not tuples of fields
+    if not isinstance(arg, Field):
+        raise ValueError(f"Received {node} as argument to field operator, expected a field.")
 
-    if isinstance(arg_type, ts.ScalarType):
-        return gtir_to_tasklet.MemletExpr(data_node, sbs.Indices([0]))
-    else:
-        assert isinstance(arg_type, ts.FieldType)
+    if isinstance(arg.data_type, ts.ScalarType):
+        return gtir_to_tasklet.MemletExpr(arg.data_node, sbs.Indices([0]))
+    elif isinstance(arg.data_type, ts.FieldType):
         indices: dict[gtx_common.Dimension, gtir_to_tasklet.IteratorIndexExpr] = {
             dim: gtir_to_tasklet.SymbolExpr(
                 dace_gtir_utils.get_map_variable(dim),
@@ -100,12 +107,14 @@ def _parse_arg_expr(
             )
             for dim, _, _ in domain
         }
-        dims = arg_type.dims + (
+        dims = arg.data_type.dims + (
             # we add an extra anonymous dimension in the iterator definition to enable
             # dereferencing elements in `ListType`
-            [gtx_common.Dimension("")] if isinstance(arg_type.dtype, gtir_ts.ListType) else []
+            [gtx_common.Dimension("")] if isinstance(arg.data_type.dtype, gtir_ts.ListType) else []
         )
-        return gtir_to_tasklet.IteratorExpr(data_node, dims, indices)
+        return gtir_to_tasklet.IteratorExpr(arg.data_node, dims, indices)
+    else:
+        raise NotImplementedError(f"Node type {type(arg.data_type)} not supported.")
 
 
 def _create_temporary_field(
@@ -116,7 +125,7 @@ def _create_temporary_field(
     ],
     node_type: ts.FieldType,
     output_desc: dace.data.Data,
-) -> tuple[dace.nodes.AccessNode, ts.FieldType]:
+) -> Field:
     domain_dims, _, domain_ubs = zip(*domain)
     field_dims = list(domain_dims)
     # It should be enough to allocate an array with shape (upper_bound - lower_bound)
@@ -131,20 +140,21 @@ def _create_temporary_field(
 
     if isinstance(output_desc, dace.data.Array):
         assert isinstance(node_type.dtype, gtir_ts.ListType)
+        assert isinstance(node_type.dtype.element_type, ts.ScalarType)
         field_dtype = node_type.dtype.element_type
-        # extend the result arrays with the local dimensions added by the field operator (e.g. `neighbors`)
+        # extend the array with the local dimensions added by the field operator (e.g. `neighbors`)
         field_shape.extend(output_desc.shape)
-    else:
-        assert isinstance(output_desc, dace.data.Scalar)
+    elif isinstance(output_desc, dace.data.Scalar):
         field_dtype = node_type.dtype
+    else:
+        raise ValueError(f"Cannot create field for dace type {output_desc}.")
 
-    # allocate local temporary storage for the result field
-    assert isinstance(field_dtype, ts.ScalarType)
+    # allocate local temporary storage
     temp_name, _ = sdfg.add_temp_transient(field_shape, dace_utils.as_dace_type(field_dtype))
     field_node = state.add_access(temp_name)
     field_type = ts.FieldType(field_dims, node_type.dtype)
 
-    return field_node, field_type
+    return Field(field_node, field_type)
 
 
 def translate_as_field_op(
@@ -153,7 +163,7 @@ def translate_as_field_op(
     state: dace.SDFGState,
     sdfg_builder: gtir_to_sdfg.SDFGBuilder,
     reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
-) -> list[TemporaryData]:
+) -> FieldopResult:
     """Generates the dataflow subgraph for the `as_fieldop` builtin function."""
     assert isinstance(node, gtir.FunCall)
     assert cpm.is_call_to(node.fun, "as_fieldop")
@@ -180,7 +190,7 @@ def translate_as_field_op(
 
     # first visit the list of arguments and build a symbol map
     stencil_args = [
-        _parse_arg_expr(arg, sdfg, state, sdfg_builder, domain, reduce_identity)
+        _parse_fieldop_arg(arg, sdfg, state, sdfg_builder, domain, reduce_identity)
         for arg in node.args
     ]
 
@@ -201,7 +211,7 @@ def translate_as_field_op(
         last_node_connector = None
 
     # allocate local temporary storage for the result field
-    field_node, field_type = _create_temporary_field(sdfg, state, domain, node.type, output_desc)
+    result_field = _create_temporary_field(sdfg, state, domain, node.type, output_desc)
 
     # assume tasklet with single output
     output_subset = [dace_gtir_utils.get_map_variable(dim) for dim, _, _ in domain]
@@ -230,12 +240,12 @@ def translate_as_field_op(
     state.add_memlet_path(
         last_node,
         mx,
-        field_node,
+        result_field.data_node,
         src_conn=last_node_connector,
-        memlet=dace.Memlet(data=field_node.data, subset=",".join(output_subset)),
+        memlet=dace.Memlet(data=result_field.data_node.data, subset=",".join(output_subset)),
     )
 
-    return [(field_node, field_type)]
+    return result_field
 
 
 def translate_if(
@@ -244,20 +254,20 @@ def translate_if(
     state: dace.SDFGState,
     sdfg_builder: gtir_to_sdfg.SDFGBuilder,
     reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
-) -> list[TemporaryData]:
-    """Generates the dataflow subgraph for the `if_` builtin function (outside of `as_fieldop`)."""
+) -> FieldopResult:
+    """Generates the dataflow subgraph for the `if_` builtin function."""
     assert cpm.is_call_to(node, "if_")
     assert len(node.args) == 3
     cond_expr, true_expr, false_expr = node.args
 
     # expect condition as first argument
-    cond = gtir_python_codegen.get_source(cond_expr)
+    if_stmt = gtir_python_codegen.get_source(cond_expr)
 
     # use current head state to terminate the dataflow, and add a entry state
     # to connect the true/false branch states as follows:
     #
     #               ------------
-    #           === |  cond  | ===
+    #           === |   cond   | ===
     #          ||   ------------   ||
     #          \/                  \/
     #     ------------       -------------
@@ -273,12 +283,12 @@ def translate_if(
 
     # expect true branch as second argument
     true_state = sdfg.add_state(state.label + "_true_branch")
-    sdfg.add_edge(cond_state, true_state, dace.InterstateEdge(condition=f"bool({cond})"))
+    sdfg.add_edge(cond_state, true_state, dace.InterstateEdge(condition=f"bool({if_stmt})"))
     sdfg.add_edge(true_state, state, dace.InterstateEdge())
 
     # and false branch as third argument
     false_state = sdfg.add_state(state.label + "_false_branch")
-    sdfg.add_edge(cond_state, false_state, dace.InterstateEdge(condition=(f"not bool({cond})")))
+    sdfg.add_edge(cond_state, false_state, dace.InterstateEdge(condition=(f"not bool({if_stmt})")))
     sdfg.add_edge(false_state, state, dace.InterstateEdge())
 
     true_br_args = sdfg_builder.visit(
@@ -294,32 +304,72 @@ def translate_if(
         reduce_identity=reduce_identity,
     )
 
-    output_nodes = []
-    for true_br, false_br in zip(true_br_args, false_br_args, strict=True):
-        true_br_node, true_br_type = true_br
-        assert isinstance(true_br_node, dace.nodes.AccessNode)
-        false_br_node, _ = false_br
-        assert isinstance(false_br_node, dace.nodes.AccessNode)
-        desc = true_br_node.desc(sdfg)
-        assert false_br_node.desc(sdfg) == desc
+    def make_temps(x: Field) -> Field:
+        desc = x.data_node.desc(sdfg)
         data_name, _ = sdfg.add_temp_transient_like(desc)
-        output_nodes.append((state.add_access(data_name), true_br_type))
+        data_node = state.add_access(data_name)
+
+        return Field(data_node, x.data_type)
+
+    result_temps = gtx_utils.tree_map(make_temps)(true_br_args)
+
+    fields: Iterable[tuple[Field, Field, Field]] = zip(
+        gtx_utils.flatten_nested_tuple((true_br_args,)),
+        gtx_utils.flatten_nested_tuple((false_br_args,)),
+        gtx_utils.flatten_nested_tuple((result_temps,)),
+        strict=True,
+    )
 
-        true_br_output_node = true_state.add_access(data_name)
+    for true_br, false_br, temp in fields:
+        assert true_br.data_type == false_br.data_type
+        true_br_node = true_br.data_node
+        false_br_node = false_br.data_node
+
+        temp_name = temp.data_node.data
+        true_br_output_node = true_state.add_access(temp_name)
         true_state.add_nedge(
             true_br_node,
             true_br_output_node,
-            dace.Memlet.from_array(data_name, desc),
+            sdfg.make_array_memlet(temp_name),
         )
 
-        false_br_output_node = false_state.add_access(data_name)
+        false_br_output_node = false_state.add_access(temp_name)
         false_state.add_nedge(
             false_br_node,
             false_br_output_node,
-            dace.Memlet.from_array(data_name, desc),
+            sdfg.make_array_memlet(temp_name),
         )
 
-    return output_nodes
+    return result_temps
+
+
+def _get_data_nodes(
+    sdfg: dace.SDFG,
+    state: dace.SDFGState,
+    sdfg_builder: gtir_to_sdfg.SDFGBuilder,
+    sym_name: str,
+    sym_type: ts.DataType,
+) -> FieldopResult:
+    if isinstance(sym_type, ts.FieldType):
+        sym_node = state.add_access(sym_name)
+        return Field(sym_node, sym_type)
+    elif isinstance(sym_type, ts.ScalarType):
+        if sym_name in sdfg.arrays:
+            # access the existing scalar container
+            sym_node = state.add_access(sym_name)
+        else:
+            sym_node = _get_symbolic_value(
+                sdfg, state, sdfg_builder, sym_name, sym_type, temp_name=f"__{sym_name}"
+            )
+        return Field(sym_node, sym_type)
+    elif isinstance(sym_type, ts.TupleType):
+        tuple_fields = dace_gtir_utils.get_tuple_fields(sym_name, sym_type)
+        return tuple(
+            _get_data_nodes(sdfg, state, sdfg_builder, fname, ftype)
+            for fname, ftype in tuple_fields
+        )
+    else:
+        raise NotImplementedError(f"Symbol type {type(sym_type)} not supported.")
 
 
 def _get_symbolic_value(
@@ -360,14 +410,65 @@ def translate_literal(
     state: dace.SDFGState,
     sdfg_builder: gtir_to_sdfg.SDFGBuilder,
     reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
-) -> list[TemporaryData]:
+) -> FieldopResult:
     """Generates the dataflow subgraph for a `ir.Literal` node."""
     assert isinstance(node, gtir.Literal)
 
     data_type = node.type
     data_node = _get_symbolic_value(sdfg, state, sdfg_builder, node.value, data_type)
 
-    return [(data_node, data_type)]
+    return Field(data_node, data_type)
+
+
+def translate_make_tuple(
+    node: gtir.Node,
+    sdfg: dace.SDFG,
+    state: dace.SDFGState,
+    sdfg_builder: gtir_to_sdfg.SDFGBuilder,
+    reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
+) -> FieldopResult:
+    assert cpm.is_call_to(node, "make_tuple")
+    return tuple(
+        sdfg_builder.visit(
+            arg,
+            sdfg=sdfg,
+            head_state=state,
+            reduce_identity=reduce_identity,
+        )
+        for arg in node.args
+    )
+
+
+def translate_tuple_get(
+    node: gtir.Node,
+    sdfg: dace.SDFG,
+    state: dace.SDFGState,
+    sdfg_builder: gtir_to_sdfg.SDFGBuilder,
+    reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
+) -> FieldopResult:
+    assert cpm.is_call_to(node, "tuple_get")
+    assert len(node.args) == 2
+
+    if not isinstance(node.args[0], gtir.Literal):
+        raise ValueError("Tuple can only be subscripted with compile-time constants.")
+    assert node.args[0].type == dace_utils.as_scalar_type(gtir.INTEGER_INDEX_BUILTIN)
+    index = int(node.args[0].value)
+
+    data_nodes = sdfg_builder.visit(
+        node.args[1],
+        sdfg=sdfg,
+        head_state=state,
+        reduce_identity=reduce_identity,
+    )
+    if isinstance(data_nodes, Field):
+        raise ValueError(f"Invalid tuple expression {node}")
+    unused_arg_nodes: Iterable[Field] = gtx_utils.flatten_nested_tuple(
+        tuple(arg for i, arg in enumerate(data_nodes) if i != index)
+    )
+    state.remove_nodes_from(
+        [arg.data_node for arg in unused_arg_nodes if state.degree(arg.data_node) == 0]
+    )
+    return data_nodes[index]
 
 
 def translate_scalar_expr(
@@ -376,7 +477,7 @@ def translate_scalar_expr(
     state: dace.SDFGState,
     sdfg_builder: gtir_to_sdfg.SDFGBuilder,
     reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
-) -> list[TemporaryData]:
+) -> FieldopResult:
     assert isinstance(node, gtir.FunCall)
     assert isinstance(node.type, ts.ScalarType)
 
@@ -400,19 +501,16 @@ def translate_scalar_expr(
         if visit_expr:
             # we visit the argument expression and obtain the access node to
             # a scalar data container, which will be connected to the tasklet
-            arg_node, arg_type = sdfg_builder.visit(
+            arg = sdfg_builder.visit(
                 arg_expr,
                 sdfg=sdfg,
                 head_state=state,
                 reduce_identity=reduce_identity,
-            )[0]
-            if not (
-                isinstance(arg_type, ts.ScalarType)
-                and isinstance(arg_node.desc(sdfg), dace.data.Scalar)
-            ):
+            )
+            if not (isinstance(arg, Field) and isinstance(arg.data_type, ts.ScalarType)):
                 raise ValueError(f"Invalid argument to scalar expression {arg_expr}.")
-            param = f"__in_{arg_node.data}"
-            args.append(arg_node)
+            param = f"__in_{arg.data_node.data}"
+            args.append(arg.data_node)
             connectors.append(param)
             scalar_expr_args.append(gtir.SymRef(id=param))
         else:
@@ -454,7 +552,7 @@ def translate_scalar_expr(
         dace.Memlet(data=temp_name, subset="0"),
     )
 
-    return [(temp_node, node.type)]
+    return Field(temp_node, node.type)
 
 
 def translate_symbol_ref(
@@ -463,7 +561,7 @@ def translate_symbol_ref(
     state: dace.SDFGState,
     sdfg_builder: gtir_to_sdfg.SDFGBuilder,
     reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
-) -> list[TemporaryData]:
+) -> FieldopResult:
     """Generates the dataflow subgraph for a `ir.SymRef` node."""
     assert isinstance(node, gtir.SymRef)
 
@@ -474,17 +572,7 @@ def translate_symbol_ref(
     # Create new access node in current state. It is possible that multiple
     # access nodes are created in one state for the same data container.
     # We rely on the dace simplify pass to remove duplicated access nodes.
-    if isinstance(gt_symbol_type, ts.FieldType):
-        sym_node = state.add_access(symbol_name)
-    elif symbol_name in sdfg.arrays:
-        # access the existing scalar container
-        sym_node = state.add_access(symbol_name)
-    else:
-        sym_node = _get_symbolic_value(
-            sdfg, state, sdfg_builder, symbol_name, gt_symbol_type, temp_name=f"__{symbol_name}"
-        )
-
-    return [(sym_node, gt_symbol_type)]
+    return _get_data_nodes(sdfg, state, sdfg_builder, symbol_name, gt_symbol_type)
 
 
 if TYPE_CHECKING:
@@ -493,6 +581,8 @@ if TYPE_CHECKING:
         translate_as_field_op,
         translate_if,
         translate_literal,
+        translate_make_tuple,
+        translate_tuple_get,
         translate_scalar_expr,
         translate_symbol_ref,
     ]
diff --git a/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_to_sdfg.py b/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_to_sdfg.py
index 8617dc7c..3fc4918b 100644
--- a/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_to_sdfg.py
+++ b/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_to_sdfg.py
@@ -16,16 +16,16 @@ from __future__ import annotations
 
 import abc
 import dataclasses
-from typing import Any, Dict, List, Optional, Protocol, Sequence, Set, Tuple, Union
+import itertools
+from typing import Any, Dict, Iterable, List, Optional, Protocol, Sequence, Set, Tuple, Union
 
 import dace
 
 from gt4py import eve
 from gt4py.eve import concepts
-from gt4py.next import common as gtx_common
+from gt4py.next import common as gtx_common, utils as gtx_utils
 from gt4py.next.iterator import ir as gtir
 from gt4py.next.iterator.ir_utils import common_pattern_matcher as cpm
-from gt4py.next.iterator.transforms import symbol_ref_utils
 from gt4py.next.iterator.type_system import inference as gtir_type_inference
 from gt4py.next.program_processors.runners.dace_common import utility as dace_utils
 from gt4py.next.program_processors.runners.dace_fieldview import (
@@ -41,7 +41,7 @@ class DataflowBuilder(Protocol):
     """Visitor interface to build a dataflow subgraph."""
 
     @abc.abstractmethod
-    def get_offset_provider(self, offset: str) -> gtx_common.Connectivity | gtx_common.Dimension:
+    def get_offset_provider(self, offset: str) -> gtx_common.OffsetProviderElem:
         pass
 
     @abc.abstractmethod
@@ -84,7 +84,7 @@ class SDFGBuilder(DataflowBuilder, Protocol):
     """Visitor interface available to GTIR-primitive translators."""
 
     @abc.abstractmethod
-    def get_symbol_type(self, symbol_name: str) -> ts.FieldType | ts.ScalarType:
+    def get_symbol_type(self, symbol_name: str) -> ts.DataType:
         """Retrieve the GT4Py type of a symbol used in the program."""
         pass
 
@@ -108,10 +108,8 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
     from where to continue building the SDFG.
     """
 
-    offset_provider: dict[str, gtx_common.Connectivity | gtx_common.Dimension]
-    global_symbols: dict[str, ts.FieldType | ts.ScalarType] = dataclasses.field(
-        default_factory=lambda: {}
-    )
+    offset_provider: gtx_common.OffsetProvider
+    global_symbols: dict[str, ts.DataType] = dataclasses.field(default_factory=lambda: {})
     map_uids: eve.utils.UIDGenerator = dataclasses.field(
         init=False, repr=False, default_factory=lambda: eve.utils.UIDGenerator(prefix="map")
     )
@@ -119,10 +117,10 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         init=False, repr=False, default_factory=lambda: eve.utils.UIDGenerator(prefix="tlet")
     )
 
-    def get_offset_provider(self, offset: str) -> gtx_common.Connectivity | gtx_common.Dimension:
+    def get_offset_provider(self, offset: str) -> gtx_common.OffsetProviderElem:
         return self.offset_provider[offset]
 
-    def get_symbol_type(self, symbol_name: str) -> ts.FieldType | ts.ScalarType:
+    def get_symbol_type(self, symbol_name: str) -> ts.DataType:
         return self.global_symbols[symbol_name]
 
     def unique_map_name(self, name: str) -> str:
@@ -160,8 +158,13 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         return shape, strides
 
     def _add_storage(
-        self, sdfg: dace.SDFG, name: str, symbol_type: ts.DataType, transient: bool = True
-    ) -> None:
+        self,
+        sdfg: dace.SDFG,
+        name: str,
+        symbol_type: ts.DataType,
+        transient: bool = True,
+        is_tuple_member: bool = False,
+    ) -> list[tuple[str, ts.DataType]]:
         """
         Add storage for data containers used in the SDFG. For fields, it allocates dace arrays,
         while scalars are stored as SDFG symbols.
@@ -169,26 +172,48 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         The fields used as temporary arrays, when `transient = True`, are allocated and exist
         only within the SDFG; when `transient = False`, the fields have to be allocated outside
         and have to be passed as array arguments to the SDFG.
+
+        Returns:
+            List of data containers or symbols allocated as storage. This is a list, not a single value,
+            because in case of tuples we flat the tuple fields (eventually nested) and allocate storage
+            for each tuple element.
         """
-        if isinstance(symbol_type, ts.FieldType):
+        if isinstance(symbol_type, ts.TupleType):
+            tuple_fields = []
+            for tname, tsymbol_type in dace_gtir_utils.get_tuple_fields(
+                name, symbol_type, flatten=True
+            ):
+                tuple_fields.extend(
+                    self._add_storage(sdfg, tname, tsymbol_type, transient, is_tuple_member=True)
+                )
+            return tuple_fields
+
+        elif isinstance(symbol_type, ts.FieldType):
             dtype = dace_utils.as_dace_type(symbol_type.dtype)
             # use symbolic shape, which allows to invoke the program with fields of different size;
             # and symbolic strides, which enables decoupling the memory layout from generated code.
             sym_shape, sym_strides = self._make_array_shape_and_strides(name, symbol_type.dims)
             sdfg.add_array(name, sym_shape, dtype, strides=sym_strides, transient=transient)
+
+            return [(name, symbol_type)]
+
         elif isinstance(symbol_type, ts.ScalarType):
-            assert isinstance(symbol_type, ts.ScalarType)
             dtype = dace_utils.as_dace_type(symbol_type)
-            # Scalar arguments passed to the program are represented as symbols in DaCe SDFG.
+            # Scalar arguments passed to the program are represented as symbols in DaCe SDFG;
+            # the exception are members of tuple arguments, that are represented as scalar containers.
             # The field size is sometimes passed as scalar argument to the program, so we have to
             # check if the shape symbol was already allocated by `_make_array_shape_and_strides`.
             # We assume that the scalar argument for field size always follows the field argument.
-            if name in sdfg.symbols:
+            if is_tuple_member:
+                sdfg.add_scalar(name, dtype, transient=transient)
+            elif name in sdfg.symbols:
                 assert sdfg.symbols[name].dtype == dtype
             else:
                 sdfg.add_symbol(name, dtype)
-        else:
-            raise RuntimeError(f"Data type '{type(symbol_type)}' not supported.")
+
+            return [(name, symbol_type)]
+
+        raise RuntimeError(f"Data type '{type(symbol_type)}' not supported.")
 
     def _add_storage_for_temporary(self, temp_decl: gtir.Temporary) -> dict[str, str]:
         """
@@ -199,28 +224,22 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         raise NotImplementedError("Temporaries not supported yet by GTIR DaCe backend.")
 
     def _visit_expression(
-        self, node: gtir.Expr, sdfg: dace.SDFG, head_state: dace.SDFGState
-    ) -> list[dace.nodes.AccessNode]:
+        self, node: gtir.Expr, sdfg: dace.SDFG, head_state: dace.SDFGState, use_temp: bool = True
+    ) -> list[gtir_builtin_translators.Field]:
         """
         Specialized visit method for fieldview expressions.
 
         This method represents the entry point to visit `ir.Stmt` expressions.
         As such, it must preserve the property of single exit state in the SDFG.
 
-        Returns a list of array nodes containing the result fields.
+        Returns:
+            A list of array nodes containing the result fields.
 
         TODO: Do we need to return the GT4Py `FieldType`/`ScalarType`? It is needed
         in case the transient arrays containing the expression result are not guaranteed
         to have the same memory layout as the target array.
         """
-        results: list[gtir_builtin_translators.TemporaryData] = self.visit(
-            node, sdfg=sdfg, head_state=head_state, reduce_identity=None
-        )
-
-        field_nodes = []
-        for node, _ in results:
-            assert isinstance(node, dace.nodes.AccessNode)
-            field_nodes.append(node)
+        result = self.visit(node, sdfg=sdfg, head_state=head_state, reduce_identity=None)
 
         # sanity check: each statement should preserve the property of single exit state (aka head state),
         # i.e. eventually only introduce internal branches, and keep the same head state
@@ -228,16 +247,29 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         assert len(sink_states) == 1
         assert sink_states[0] == head_state
 
-        return field_nodes
+        def make_temps(field: gtir_builtin_translators.Field) -> gtir_builtin_translators.Field:
+            desc = sdfg.arrays[field.data_node.data]
+            if desc.transient or not use_temp:
+                return field
+            else:
+                temp, _ = sdfg.add_temp_transient_like(desc)
+                temp_node = head_state.add_access(temp)
+                head_state.add_nedge(
+                    field.data_node, temp_node, sdfg.make_array_memlet(field.data_node.data)
+                )
+                return gtir_builtin_translators.Field(temp_node, field.data_type)
 
-    def _add_sdfg_params(self, sdfg: dace.SDFG, node_params: Sequence[gtir.Sym]) -> None:
-        """Helper function to add storage for node parameters and connectivity tables."""
+        temp_result = gtx_utils.tree_map(make_temps)(result)
+        return list(gtx_utils.flatten_nested_tuple((temp_result,)))
 
+    def _add_sdfg_params(self, sdfg: dace.SDFG, node_params: Sequence[gtir.Sym]) -> list[str]:
+        """Helper function to add storage for node parameters and connectivity tables."""
         # add non-transient arrays and/or SDFG symbols for the program arguments
+        sdfg_args = []
         for param in node_params:
             pname = str(param.id)
-            assert isinstance(param.type, (ts.FieldType, ts.ScalarType))
-            self._add_storage(sdfg, pname, param.type, transient=False)
+            assert isinstance(param.type, (ts.DataType))
+            sdfg_args += self._add_storage(sdfg, pname, param.type, transient=False)
             self.global_symbols[pname] = param.type
 
         # add SDFG storage for connectivity tables
@@ -256,6 +288,9 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
             # are removed by the dace simplify pass.
             self._add_storage(sdfg, dace_utils.connectivity_identifier(offset), type_)
 
+        # the list of all sdfg arguments (aka non-transient arrays) which include tuple-element fields
+        return [arg_name for arg_name, _ in sdfg_args]
+
     def visit_Program(self, node: gtir.Program) -> dace.SDFG:
         """Translates `ir.Program` to `dace.SDFG`.
 
@@ -283,58 +318,90 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         else:
             head_state = entry_state
 
-        self._add_sdfg_params(sdfg, node.params)
+        sdfg_arg_names = self._add_sdfg_params(sdfg, node.params)
 
         # visit one statement at a time and expand the SDFG from the current head state
         for i, stmt in enumerate(node.body):
             # include `debuginfo` only for `ir.Program` and `ir.Stmt` nodes: finer granularity would be too messy
             head_state = sdfg.add_state_after(head_state, f"stmt_{i}")
             head_state._debuginfo = dace_utils.debug_info(stmt, default=sdfg.debuginfo)
-            self.visit(stmt, sdfg=sdfg, state=head_state)
+            head_state = self.visit(stmt, sdfg=sdfg, state=head_state)
 
         # Create the call signature for the SDFG.
         #  Only the arguments required by the GT4Py program, i.e. `node.params`, are added
         #  as positional arguments. The implicit arguments, such as the offset providers or
         #  the arguments created by the translation process, must be passed as keyword arguments.
-        sdfg.arg_names = [str(a) for a in node.params]
+        sdfg.arg_names = sdfg_arg_names
 
         sdfg.validate()
         return sdfg
 
-    def visit_SetAt(self, stmt: gtir.SetAt, sdfg: dace.SDFG, state: dace.SDFGState) -> None:
+    def visit_SetAt(
+        self, stmt: gtir.SetAt, sdfg: dace.SDFG, state: dace.SDFGState
+    ) -> dace.SDFGState:
         """Visits a `SetAt` statement expression and writes the local result to some external storage.
 
         Each statement expression results in some sort of dataflow gragh writing to temporary storage.
         The translation of `SetAt` ensures that the result is written back to the target external storage.
+
+        Returns:
+          The SDFG head state, eventually updated if the target write requires a new state.
         """
 
-        expr_nodes = self._visit_expression(stmt.expr, sdfg, state)
+        temp_fields = self._visit_expression(stmt.expr, sdfg, state)
 
         # the target expression could be a `SymRef` to an output node or a `make_tuple` expression
         # in case the statement returns more than one field
-        target_nodes = self._visit_expression(stmt.target, sdfg, state)
+        target_fields = self._visit_expression(stmt.target, sdfg, state, use_temp=False)
 
         # convert domain expression to dictionary to ease access to dimension boundaries
         domain = dace_gtir_utils.get_domain_ranges(stmt.domain)
 
-        for expr_node, target_node in zip(expr_nodes, target_nodes, strict=True):
-            target_array = sdfg.arrays[target_node.data]
-            assert not target_array.transient
-            target_symbol_type = self.global_symbols[target_node.data]
+        expr_input_args = {
+            sym_id
+            for sym in eve.walk_values(stmt.expr).if_isinstance(gtir.SymRef)
+            if (sym_id := str(sym.id)) in sdfg.arrays
+        }
+        state_input_data = {
+            node.data
+            for node in state.data_nodes()
+            if node.data in expr_input_args and state.degree(node) != 0
+        }
+
+        target_state: Optional[dace.SDFGState] = None
+        for temp, target in zip(temp_fields, target_fields, strict=True):
+            target_desc = sdfg.arrays[target.data_node.data]
+            assert not target_desc.transient
 
-            if isinstance(target_symbol_type, ts.FieldType):
+            if isinstance(target.data_type, ts.FieldType):
                 subset = ",".join(
-                    f"{domain[dim][0]}:{domain[dim][1]}" for dim in target_symbol_type.dims
+                    f"{domain[dim][0]}:{domain[dim][1]}" for dim in target.data_type.dims
                 )
             else:
                 assert len(domain) == 0
                 subset = "0"
 
-            state.add_nedge(
-                expr_node,
-                target_node,
-                dace.Memlet(data=target_node.data, subset=subset, other_subset=subset),
-            )
+            if target.data_node.data in state_input_data:
+                # if inout argument, write the result in separate next state
+                # this is needed to avoid undefined behavior for expressions like: X, Y = X + 1, X
+                if not target_state:
+                    target_state = sdfg.add_state_after(state, f"post_{state.label}")
+                # create new access nodes in the target state
+                target_state.add_nedge(
+                    target_state.add_access(temp.data_node.data),
+                    target_state.add_access(target.data_node.data),
+                    dace.Memlet(data=target.data_node.data, subset=subset, other_subset=subset),
+                )
+                # remove isolated access node
+                state.remove_node(target.data_node)
+            else:
+                state.add_nedge(
+                    temp.data_node,
+                    target.data_node,
+                    dace.Memlet(data=target.data_node.data, subset=subset, other_subset=subset),
+                )
+
+        return target_state or state
 
     def visit_FunCall(
         self,
@@ -342,34 +409,41 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         sdfg: dace.SDFG,
         head_state: dace.SDFGState,
         reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
-    ) -> list[gtir_builtin_translators.TemporaryData]:
+    ) -> gtir_builtin_translators.FieldopResult:
         # use specialized dataflow builder classes for each builtin function
         if cpm.is_call_to(node, "if_"):
             return gtir_builtin_translators.translate_if(
                 node, sdfg, head_state, self, reduce_identity
             )
-        elif cpm.is_call_to(node.fun, "as_fieldop"):
+        elif cpm.is_call_to(node, "make_tuple"):
+            return gtir_builtin_translators.translate_make_tuple(
+                node, sdfg, head_state, self, reduce_identity
+            )
+        elif cpm.is_call_to(node, "tuple_get"):
+            return gtir_builtin_translators.translate_tuple_get(
+                node, sdfg, head_state, self, reduce_identity
+            )
+        elif cpm.is_applied_as_fieldop(node):
             return gtir_builtin_translators.translate_as_field_op(
                 node, sdfg, head_state, self, reduce_identity
             )
         elif isinstance(node.fun, gtir.Lambda):
-            node_args = []
-            for arg in node.args:
-                node_args.extend(
-                    self.visit(
-                        arg,
-                        sdfg=sdfg,
-                        head_state=head_state,
-                        reduce_identity=reduce_identity,
-                    )
+            lambda_args = [
+                self.visit(
+                    arg,
+                    sdfg=sdfg,
+                    head_state=head_state,
+                    reduce_identity=reduce_identity,
                 )
+                for arg in node.args
+            ]
 
             return self.visit(
                 node.fun,
                 sdfg=sdfg,
                 head_state=head_state,
                 reduce_identity=reduce_identity,
-                args=node_args,
+                args=lambda_args,
             )
         elif isinstance(node.type, ts.ScalarType):
             return gtir_builtin_translators.translate_scalar_expr(
@@ -384,8 +458,8 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         sdfg: dace.SDFG,
         head_state: dace.SDFGState,
         reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
-        args: list[gtir_builtin_translators.TemporaryData],
-    ) -> list[gtir_builtin_translators.TemporaryData]:
+        args: list[gtir_builtin_translators.FieldopResult],
+    ) -> gtir_builtin_translators.FieldopResult:
         """
         Translates a `Lambda` node to a nested SDFG in the current state.
 
@@ -397,46 +471,70 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         i.e. a lambda parameter with the same name as a symbol in scope, the parameter will shadow
         the previous symbol during traversal of the lambda expression.
         """
-
-        lambda_args_mapping = {str(p.id): arg for p, arg in zip(node.params, args, strict=True)}
+        lambda_args_mapping = [
+            (str(param.id), arg) for param, arg in zip(node.params, args, strict=True)
+        ]
 
         # inherit symbols from parent scope but eventually override with local symbols
-        lambda_symbols = {
-            sym: self.global_symbols[sym]
-            for sym in symbol_ref_utils.collect_symbol_refs(node.expr, self.global_symbols.keys())
+        lambda_symbols = self.global_symbols | {
+            pname: dace_gtir_utils.get_tuple_type(arg) if isinstance(arg, tuple) else arg.data_type
+            for pname, arg in lambda_args_mapping
         }
-        lambda_symbols |= {pname: type_ for pname, (_, type_) in lambda_args_mapping.items()}
 
-        nsdfg = dace.SDFG(f"{sdfg.label}_nested")
+        # lower let-statement lambda node as a nested SDFG
+        lambda_translator = GTIRToSDFG(self.offset_provider, lambda_symbols)
+        nsdfg = dace.SDFG(f"{sdfg.label}_lambda")
         nstate = nsdfg.add_state("lambda")
 
-        # add sdfg storage for the symbols that need to be passed as input parameters,
-        # that are only the symbols used in the context of the lambda node
-        self._add_sdfg_params(
+        # add sdfg storage for the symbols that need to be passed as input parameters
+        lambda_translator._add_sdfg_params(
             nsdfg,
-            [gtir.Sym(id=p_name, type=p_type) for p_name, p_type in lambda_symbols.items()],
+            node_params=[
+                gtir.Sym(id=p_name, type=p_type) for p_name, p_type in lambda_symbols.items()
+            ],
         )
 
-        lambda_nodes = GTIRToSDFG(self.offset_provider, lambda_symbols.copy()).visit(
+        lambda_result = lambda_translator.visit(
             node.expr,
             sdfg=nsdfg,
             head_state=nstate,
             reduce_identity=reduce_identity,
         )
 
+        def _flatten_tuples(
+            name: str,
+            arg: gtir_builtin_translators.FieldopResult,
+        ) -> list[tuple[str, gtir_builtin_translators.Field]]:
+            if isinstance(arg, tuple):
+                tuple_type = dace_gtir_utils.get_tuple_type(arg)
+                tuple_field_names = [
+                    arg_name for arg_name, _ in dace_gtir_utils.get_tuple_fields(name, tuple_type)
+                ]
+                tuple_args = zip(tuple_field_names, arg, strict=True)
+                return list(
+                    itertools.chain(*[_flatten_tuples(fname, farg) for fname, farg in tuple_args])
+                )
+            else:
+                return [(name, arg)]
+
+        # Process lambda inputs
+        #
+        lambda_arg_nodes = dict(
+            itertools.chain(*[_flatten_tuples(pname, arg) for pname, arg in lambda_args_mapping])
+        )
         connectivity_arrays = {
             dace_utils.connectivity_identifier(offset)
             for offset in dace_utils.filter_connectivities(self.offset_provider)
         }
-        nsdfg_symbols_mapping: dict[str, dace.symbolic.SymExpr] = {}
 
         input_memlets = {}
+        nsdfg_symbols_mapping: dict[str, dace.symbolic.SymExpr] = {}
         for nsdfg_dataname, nsdfg_datadesc in nsdfg.arrays.items():
             if nsdfg_datadesc.transient:
                 continue
-            datadesc: Optional[dace.dtypes.Array] = None
-            if nsdfg_dataname in lambda_args_mapping:
-                src_node, _ = lambda_args_mapping[nsdfg_dataname]
+            datadesc: Optional[dace.dtypes.Data] = None
+            if nsdfg_dataname in lambda_arg_nodes:
+                src_node = lambda_arg_nodes[nsdfg_dataname].data_node
                 dataname = src_node.data
                 datadesc = src_node.desc(sdfg)
             else:
@@ -459,38 +557,66 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
                 if isinstance(nested_symbol, dace.symbol)
             }
 
-        nsdfg_node = head_state.add_nested_sdfg(
-            nsdfg,
-            parent=sdfg,
-            inputs=set(input_memlets.keys()),
-            outputs=set(node.data for node, _ in lambda_nodes),
-            symbol_mapping=nsdfg_symbols_mapping,
-            debuginfo=dace_utils.debug_info(node, default=sdfg.debuginfo),
+        # Process lambda outputs
+        #
+        lambda_output_nodes: Iterable[gtir_builtin_translators.Field] = (
+            gtx_utils.flatten_nested_tuple(lambda_result)
         )
+        # sanity check on isolated nodes
+        assert all(
+            nstate.degree(x.data_node) == 0
+            for x in lambda_output_nodes
+            if x.data_node.data in input_memlets
+        )
+        # keep only non-isolated output nodes
+        lambda_outputs = {
+            x.data_node.data for x in lambda_output_nodes if x.data_node.data not in input_memlets
+        }
+
+        if lambda_outputs:
+            nsdfg_node = head_state.add_nested_sdfg(
+                nsdfg,
+                parent=sdfg,
+                inputs=set(input_memlets.keys()),
+                outputs=lambda_outputs,
+                symbol_mapping=nsdfg_symbols_mapping,
+                debuginfo=dace_utils.debug_info(node, default=sdfg.debuginfo),
+            )
 
-        for connector, memlet in input_memlets.items():
-            if connector in lambda_args_mapping:
-                src_node, _ = lambda_args_mapping[connector]
+            for connector, memlet in input_memlets.items():
+                if connector in lambda_arg_nodes:
+                    src_node = lambda_arg_nodes[connector].data_node
+                else:
+                    src_node = head_state.add_access(memlet.data)
+
+                head_state.add_edge(src_node, None, nsdfg_node, connector, memlet)
+
+        def make_temps(
+            x: gtir_builtin_translators.Field,
+        ) -> gtir_builtin_translators.Field:
+            if x.data_node.data in lambda_outputs:
+                connector = x.data_node.data
+                desc = x.data_node.desc(nsdfg)
+                # make lambda result non-transient and map it to external temporary
+                desc.transient = False
+                # isolated access node will make validation fail
+                if nstate.degree(x.data_node) == 0:
+                    nstate.remove_node(x.data_node)
+                temp, _ = sdfg.add_temp_transient_like(desc)
+                dst_node = head_state.add_access(temp)
+                head_state.add_edge(
+                    nsdfg_node, connector, dst_node, None, sdfg.make_array_memlet(temp)
+                )
+                return gtir_builtin_translators.Field(dst_node, x.data_type)
+            elif x.data_node.data in lambda_arg_nodes:
+                nstate.remove_node(x.data_node)
+                return lambda_arg_nodes[x.data_node.data]
             else:
-                src_node = head_state.add_access(memlet.data)
-
-            head_state.add_edge(src_node, None, nsdfg_node, connector, memlet)
-
-        results = []
-        for lambda_node, type_ in lambda_nodes:
-            connector = lambda_node.data
-            desc = lambda_node.desc(nsdfg)
-            # make lambda result non-transient and map it to external temporary
-            desc.transient = False
-            # isolated access node will make validation fail
-            if nstate.degree(lambda_node) == 0:
-                nstate.remove_node(lambda_node)
-            temp, _ = sdfg.add_temp_transient_like(desc)
-            dst_node = head_state.add_access(temp)
-            head_state.add_edge(nsdfg_node, connector, dst_node, None, sdfg.make_array_memlet(temp))
-            results.append((dst_node, type_))
-
-        return results
+                nstate.remove_node(x.data_node)
+                data_node = head_state.add_access(x.data_node.data)
+                return gtir_builtin_translators.Field(data_node, x.data_type)
+
+        return gtx_utils.tree_map(make_temps)(lambda_result)
 
     def visit_Literal(
         self,
@@ -498,7 +624,7 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         sdfg: dace.SDFG,
         head_state: dace.SDFGState,
         reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
-    ) -> list[gtir_builtin_translators.TemporaryData]:
+    ) -> gtir_builtin_translators.FieldopResult:
         return gtir_builtin_translators.translate_literal(
             node, sdfg, head_state, self, reduce_identity=None
         )
@@ -509,7 +635,7 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         sdfg: dace.SDFG,
         head_state: dace.SDFGState,
         reduce_identity: Optional[gtir_to_tasklet.SymbolExpr],
-    ) -> list[gtir_builtin_translators.TemporaryData]:
+    ) -> gtir_builtin_translators.FieldopResult:
         return gtir_builtin_translators.translate_symbol_ref(
             node, sdfg, head_state, self, reduce_identity=None
         )
@@ -517,7 +643,7 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
 
 def build_sdfg_from_gtir(
     program: gtir.Program,
-    offset_provider: dict[str, gtx_common.Connectivity | gtx_common.Dimension],
+    offset_provider: gtx_common.OffsetProvider,
 ) -> dace.SDFG:
     """
     Receives a GTIR program and lowers it to a DaCe SDFG.
diff --git a/src/gt4py/next/program_processors/runners/dace_fieldview/utility.py b/src/gt4py/next/program_processors/runners/dace_fieldview/utility.py
index 7915430d..64a3da7e 100644
--- a/src/gt4py/next/program_processors/runners/dace_fieldview/utility.py
+++ b/src/gt4py/next/program_processors/runners/dace_fieldview/utility.py
@@ -8,12 +8,16 @@
 
 from __future__ import annotations
 
+import itertools
+from typing import Any
+
 import dace
 
 from gt4py.next import common as gtx_common
 from gt4py.next.iterator import ir as gtir
 from gt4py.next.iterator.ir_utils import common_pattern_matcher as cpm
 from gt4py.next.program_processors.runners.dace_fieldview import gtir_python_codegen
+from gt4py.next.type_system import type_specifications as ts
 
 
 def get_domain(
@@ -62,3 +66,43 @@ def get_map_variable(dim: gtx_common.Dimension) -> str:
     """
     suffix = "dim" if dim.kind == gtx_common.DimensionKind.LOCAL else ""
     return f"i_{dim.value}_gtx_{dim.kind}{suffix}"
+
+
+def get_tuple_fields(
+    tuple_name: str, tuple_type: ts.TupleType, flatten: bool = False
+) -> list[tuple[str, ts.DataType]]:
+    """
+    Creates a list of names with the corresponding data type for all elements of the given tuple.
+
+    Examples
+    --------
+    >>> sty = ts.ScalarType(kind=ts.ScalarKind.INT32)
+    >>> fty = ts.FieldType(dims=[], dtype=ts.ScalarType(kind=ts.ScalarKind.FLOAT32))
+    >>> t = ts.TupleType(types=[sty, ts.TupleType(types=[fty, sty])])
+    >>> assert get_tuple_fields("a", t) == [("a_0", sty), ("a_1", ts.TupleType(types=[fty, sty]))]
+    >>> assert get_tuple_fields("a", t, flatten=True) == [
+    ...     ("a_0", sty),
+    ...     ("a_1_0", fty),
+    ...     ("a_1_1", sty),
+    ... ]
+    """
+    fields = [(f"{tuple_name}_{i}", field_type) for i, field_type in enumerate(tuple_type.types)]
+    if flatten:
+        expanded_fields = [
+            get_tuple_fields(field_name, field_type)
+            if isinstance(field_type, ts.TupleType)
+            else [(field_name, field_type)]
+            for field_name, field_type in fields
+        ]
+        return list(itertools.chain(*expanded_fields))
+    else:
+        return fields
+
+
+def get_tuple_type(data: tuple[Any, ...]) -> ts.TupleType:
+    """
+    Compute the `ts.TupleType` corresponding to the structure of a tuple of data nodes.
+    """
+    return ts.TupleType(
+        types=[get_tuple_type(d) if isinstance(d, tuple) else d.data_type for d in data]
+    )
diff --git a/src/gt4py/next/program_processors/runners/dace_fieldview/workflow.py b/src/gt4py/next/program_processors/runners/dace_fieldview/workflow.py
index af36c0cb..0365017a 100644
--- a/src/gt4py/next/program_processors/runners/dace_fieldview/workflow.py
+++ b/src/gt4py/next/program_processors/runners/dace_fieldview/workflow.py
@@ -43,7 +43,7 @@ class DaCeTranslator(
     def generate_sdfg(
         self,
         ir: itir.Program,
-        offset_provider: dict[str, common.Dimension | common.Connectivity],
+        offset_provider: common.OffsetProvider,
         column_axis: Optional[common.Dimension],
     ) -> dace.SDFG:
         # TODO(edopao): Call IR transformations and domain inference, finally lower IR to SDFG


Test patch:
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py
index 5f078e8c..79456e4d 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py
@@ -132,16 +132,12 @@ def constant_fold_accessed_domains(
 
 def translate_domain(
     domain: itir.FunCall,
-    shifts: dict[Union[common.Dimension, str], tuple[itir.Expr, itir.Expr]],
+    shifts: dict[str, tuple[itir.Expr, itir.Expr]],
     offset_provider: common.OffsetProvider,
 ) -> SymbolicDomain:
     shift_tuples = [
         (
-            im.ensure_offset(
-                itir.AxisLiteral(value=d.value, kind=d.kind)
-                if isinstance(d, common.Dimension)
-                else itir.AxisLiteral(value=d)
-            ),
+            im.ensure_offset(d),
             im.ensure_offset(r),
         )
         for d, r in shifts.items()
@@ -149,9 +145,7 @@ def translate_domain(
 
     shift_list = [item for sublist in shift_tuples for item in sublist]
 
-    translated_domain_expr = SymbolicDomain.translate(
-        SymbolicDomain.from_expr(domain), shift_list, offset_provider
-    )
+    translated_domain_expr = SymbolicDomain.from_expr(domain).translate(shift_list, offset_provider)
 
     return constant_fold_domain_exprs(translated_domain_expr.as_expr())
 
diff --git a/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/test_gtir_to_sdfg.py b/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/test_gtir_to_sdfg.py
index a1e5f51c..2f86972f 100644
--- a/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/test_gtir_to_sdfg.py
+++ b/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/test_gtir_to_sdfg.py
@@ -170,6 +170,214 @@ def test_gtir_copy_self():
     assert np.allclose(a, ref)
 
 
+def test_gtir_tuple_swap():
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
+    )
+    testee = gtir.Program(
+        id="gtir_tuple_swap",
+        function_definitions=[],
+        params=[
+            gtir.Sym(id="x", type=IFTYPE),
+            gtir.Sym(id="y", type=IFTYPE),
+            gtir.Sym(id="size", type=SIZE_TYPE),
+        ],
+        declarations=[],
+        body=[
+            gtir.SetAt(
+                expr=im.make_tuple("y", "x"),
+                domain=domain,
+                # TODO(havogt): add a frontend check for this pattern
+                target=im.make_tuple("x", "y"),
+            )
+        ],
+    )
+
+    a = np.random.rand(N)
+    b = np.random.rand(N)
+    ref = (a.copy(), b.copy())
+
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
+
+    sdfg(a, b, **FSYMBOLS)
+    assert np.allclose(a, ref[1])
+    assert np.allclose(b, ref[0])
+
+
+def test_gtir_tuple_args():
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
+    )
+    testee = gtir.Program(
+        id="gtir_tuple_args",
+        function_definitions=[],
+        params=[
+            gtir.Sym(
+                id="x", type=ts.TupleType(types=[IFTYPE, ts.TupleType(types=[IFTYPE, IFTYPE])])
+            ),
+            gtir.Sym(id="y", type=IFTYPE),
+            gtir.Sym(id="size", type=SIZE_TYPE),
+        ],
+        declarations=[],
+        body=[
+            gtir.SetAt(
+                expr=im.op_as_fieldop("plus", domain)(
+                    im.tuple_get(0, "x"),
+                    im.op_as_fieldop("plus", domain)(
+                        im.tuple_get(0, im.tuple_get(1, "x")),
+                        im.tuple_get(1, im.tuple_get(1, "x")),
+                    ),
+                ),
+                domain=domain,
+                target=gtir.SymRef(id="y"),
+            )
+        ],
+    )
+
+    a = np.random.rand(N)
+    b = np.random.rand(N)
+    c = np.empty_like(a)
+
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
+
+    x_fields = (a, a, b)
+    x_symbols = dict(
+        __x_0_size_0=FSYMBOLS["__x_size_0"],
+        __x_0_stride_0=FSYMBOLS["__x_stride_0"],
+        __x_1_0_size_0=FSYMBOLS["__x_size_0"],
+        __x_1_0_stride_0=FSYMBOLS["__x_stride_0"],
+        __x_1_1_size_0=FSYMBOLS["__y_size_0"],
+        __x_1_1_stride_0=FSYMBOLS["__y_stride_0"],
+    )
+
+    sdfg(*x_fields, c, **FSYMBOLS, **x_symbols)
+    assert np.allclose(c, a * 2 + b)
+
+
+def test_gtir_tuple_expr():
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
+    )
+    testee = gtir.Program(
+        id="gtir_tuple_expr",
+        function_definitions=[],
+        params=[
+            gtir.Sym(id="x", type=IFTYPE),
+            gtir.Sym(id="y", type=IFTYPE),
+            gtir.Sym(id="z", type=IFTYPE),
+            gtir.Sym(id="size", type=SIZE_TYPE),
+        ],
+        declarations=[],
+        body=[
+            gtir.SetAt(
+                expr=im.op_as_fieldop("plus", domain)(
+                    im.tuple_get(0, im.make_tuple("x", im.make_tuple("x", "y"))),
+                    im.op_as_fieldop("plus", domain)(
+                        im.tuple_get(
+                            0, im.tuple_get(1, im.make_tuple("x", im.make_tuple("x", "y")))
+                        ),
+                        im.tuple_get(
+                            1, im.tuple_get(1, im.make_tuple("x", im.make_tuple("x", "y")))
+                        ),
+                    ),
+                ),
+                domain=domain,
+                target=gtir.SymRef(id="z"),
+            )
+        ],
+    )
+
+    a = np.random.rand(N)
+    b = np.random.rand(N)
+    c = np.empty_like(a)
+
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
+
+    sdfg(a, b, c, **FSYMBOLS)
+    assert np.allclose(c, a * 2 + b)
+
+
+def test_gtir_tuple_return():
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
+    )
+    testee = gtir.Program(
+        id="gtir_tuple_return",
+        function_definitions=[],
+        params=[
+            gtir.Sym(id="x", type=IFTYPE),
+            gtir.Sym(id="y", type=IFTYPE),
+            gtir.Sym(
+                id="z", type=ts.TupleType(types=[ts.TupleType(types=[IFTYPE, IFTYPE]), IFTYPE])
+            ),
+            gtir.Sym(id="size", type=SIZE_TYPE),
+        ],
+        declarations=[],
+        body=[
+            gtir.SetAt(
+                expr=im.make_tuple(
+                    im.make_tuple(im.op_as_fieldop("plus", domain)("x", "y"), "x"), "y"
+                ),
+                domain=domain,
+                target=gtir.SymRef(id="z"),
+            )
+        ],
+    )
+
+    a = np.random.rand(N)
+    b = np.random.rand(N)
+
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
+
+    z_fields = (np.empty_like(a), np.empty_like(a), np.empty_like(a))
+    z_symbols = dict(
+        __z_0_0_size_0=FSYMBOLS["__x_size_0"],
+        __z_0_0_stride_0=FSYMBOLS["__x_stride_0"],
+        __z_0_1_size_0=FSYMBOLS["__x_size_0"],
+        __z_0_1_stride_0=FSYMBOLS["__x_stride_0"],
+        __z_1_size_0=FSYMBOLS["__x_size_0"],
+        __z_1_stride_0=FSYMBOLS["__x_stride_0"],
+    )
+
+    sdfg(a, b, *z_fields, **FSYMBOLS, **z_symbols)
+    assert np.allclose(z_fields[0], a + b)
+    assert np.allclose(z_fields[1], a)
+    assert np.allclose(z_fields[2], b)
+
+
+def test_gtir_tuple_target():
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
+    )
+    testee = gtir.Program(
+        id="gtir_tuple_target",
+        function_definitions=[],
+        params=[
+            gtir.Sym(id="x", type=IFTYPE),
+            gtir.Sym(id="y", type=IFTYPE),
+            gtir.Sym(id="size", type=SIZE_TYPE),
+        ],
+        declarations=[],
+        body=[
+            gtir.SetAt(
+                expr=im.make_tuple(im.op_as_fieldop("plus", domain)("x", 1.0), gtir.SymRef(id="x")),
+                domain=domain,
+                target=im.make_tuple("x", "y"),
+            )
+        ],
+    )
+
+    a = np.random.rand(N)
+    b = np.empty_like(a)
+    ref = a.copy()
+
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
+
+    sdfg(a, b, **FSYMBOLS)
+    assert np.allclose(a, ref + 1)
+    assert np.allclose(b, ref)
+
+
 def test_gtir_update():
     domain = im.call("cartesian_domain")(
         im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
@@ -362,6 +570,58 @@ def test_gtir_cond():
         assert np.allclose(d, (a + b + 1) if s1 > s2 else (a + c + 1))
 
 
+def test_gtir_cond_with_tuple_return():
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
+    )
+    testee = gtir.Program(
+        id="cond_with_tuple_return",
+        function_definitions=[],
+        params=[
+            gtir.Sym(id="x", type=IFTYPE),
+            gtir.Sym(id="y", type=IFTYPE),
+            gtir.Sym(id="w", type=IFTYPE),
+            gtir.Sym(id="z", type=ts.TupleType(types=[IFTYPE, IFTYPE])),
+            gtir.Sym(id="pred", type=ts.ScalarType(ts.ScalarKind.BOOL)),
+            gtir.Sym(id="size", type=SIZE_TYPE),
+        ],
+        declarations=[],
+        body=[
+            gtir.SetAt(
+                expr=im.tuple_get(
+                    0,
+                    im.if_(
+                        gtir.SymRef(id="pred"),
+                        im.make_tuple(im.make_tuple("x", "y"), "w"),
+                        im.make_tuple(im.make_tuple("y", "x"), "w"),
+                    ),
+                ),
+                domain=domain,
+                target=gtir.SymRef(id="z"),
+            )
+        ],
+    )
+
+    a = np.random.rand(N)
+    b = np.random.rand(N)
+    c = np.random.rand(N)
+
+    z_symbols = dict(
+        __z_0_size_0=FSYMBOLS["__x_size_0"],
+        __z_0_stride_0=FSYMBOLS["__x_stride_0"],
+        __z_1_size_0=FSYMBOLS["__x_size_0"],
+        __z_1_stride_0=FSYMBOLS["__x_stride_0"],
+    )
+
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
+
+    for s in [False, True]:
+        z_fields = (np.empty_like(a), np.empty_like(a))
+        sdfg(a, b, c, *z_fields, pred=np.bool_(s), **FSYMBOLS, **z_symbols)
+        assert np.allclose(z_fields[0], a if s else b)
+        assert np.allclose(z_fields[1], b if s else a)
+
+
 def test_gtir_cond_nested():
     domain = im.call("cartesian_domain")(
         im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
@@ -1386,6 +1646,52 @@ def test_gtir_let_lambda_with_cond():
         assert np.allclose(b, a if s else a * 2)
 
 
+def test_gtir_let_lambda_with_tuple():
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
+    )
+    testee = gtir.Program(
+        id="let_lambda_with_tuple",
+        function_definitions=[],
+        params=[
+            gtir.Sym(id="x", type=IFTYPE),
+            gtir.Sym(id="y", type=IFTYPE),
+            gtir.Sym(id="z", type=ts.TupleType(types=[IFTYPE, IFTYPE])),
+            gtir.Sym(id="size", type=SIZE_TYPE),
+        ],
+        declarations=[],
+        body=[
+            gtir.SetAt(
+                expr=im.let(
+                    "t",
+                    im.make_tuple(
+                        im.make_tuple(im.op_as_fieldop("plus", domain)("x", "y"), "x"), "y"
+                    ),
+                )(im.make_tuple(im.tuple_get(1, im.tuple_get(0, "t")), im.tuple_get(1, "t"))),
+                domain=domain,
+                target=gtir.SymRef(id="z"),
+            )
+        ],
+    )
+
+    a = np.random.rand(N)
+    b = np.random.rand(N)
+
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
+
+    z_fields = (np.empty_like(a), np.empty_like(a))
+    z_symbols = dict(
+        __z_0_size_0=FSYMBOLS["__x_size_0"],
+        __z_0_stride_0=FSYMBOLS["__x_stride_0"],
+        __z_1_size_0=FSYMBOLS["__x_size_0"],
+        __z_1_stride_0=FSYMBOLS["__x_stride_0"],
+    )
+
+    sdfg(a, b, *z_fields, **FSYMBOLS, **z_symbols)
+    assert np.allclose(z_fields[0], a)
+    assert np.allclose(z_fields[1], b)
+
+
 def test_gtir_if_scalars():
     domain = im.call("cartesian_domain")(
         im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
@@ -1394,9 +1700,10 @@ def test_gtir_if_scalars():
         id="if_scalars",
         function_definitions=[],
         params=[
-            gtir.Sym(id="x", type=IFTYPE),
-            gtir.Sym(id="y_0", type=SIZE_TYPE),
-            gtir.Sym(id="y_1", type=SIZE_TYPE),
+            gtir.Sym(
+                id="x",
+                type=ts.TupleType(types=[IFTYPE, ts.TupleType(types=[SIZE_TYPE, SIZE_TYPE])]),
+            ),
             gtir.Sym(id="z", type=IFTYPE),
             gtir.Sym(id="pred", type=ts.ScalarType(ts.ScalarKind.BOOL)),
             gtir.Sym(id="size", type=SIZE_TYPE),
@@ -1404,13 +1711,21 @@ def test_gtir_if_scalars():
         declarations=[],
         body=[
             gtir.SetAt(
-                expr=im.op_as_fieldop("plus", domain)(
-                    "x",
-                    im.if_(
-                        "pred",
-                        im.call("cast_")("y_0", "float64"),
-                        im.call("cast_")("y_1", "float64"),
-                    ),
+                expr=im.let("f", im.tuple_get(0, "x"))(
+                    im.let("y", im.tuple_get(1, "x"))(
+                        im.let("y_0", im.tuple_get(0, "y"))(
+                            im.let("y_1", im.tuple_get(1, "y"))(
+                                im.op_as_fieldop("plus", domain)(
+                                    "f",
+                                    im.if_(
+                                        "pred",
+                                        im.call("cast_")("y_0", "float64"),
+                                        im.call("cast_")("y_1", "float64"),
+                                    ),
+                                )
+                            )
+                        )
+                    )
                 ),
                 domain=domain,
                 target=gtir.SymRef(id="z"),
@@ -1424,9 +1739,13 @@ def test_gtir_if_scalars():
     d2 = np.random.randint(0, 1000)
 
     sdfg = dace_backend.build_sdfg_from_gtir(testee, {})
+    x_symbols = dict(
+        __x_0_size_0=FSYMBOLS["__x_size_0"],
+        __x_0_stride_0=FSYMBOLS["__x_stride_0"],
+    )
 
     for s in [False, True]:
-        sdfg(a, y_0=d1, y_1=d2, z=b, pred=np.bool_(s), **FSYMBOLS)
+        sdfg(x_0=a, x_1_0=d1, x_1_1=d2, z=b, pred=np.bool_(s), **FSYMBOLS, **x_symbols)
         assert np.allclose(b, (a + d1 if s else a + d2))