Repository: GridTools/gt4py
Pull request #1589 (2024-07-25 09:47:03)

feat[next][dace]: GTIR-to-SDFG lowering of let-lambdas
This PR adds lowering of let-lambdas to DaCe SDFG.


Patch:
diff --git a/AUTHORS.md b/AUTHORS.md
index 6c76e575..1aea5386 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -18,6 +18,7 @@
 - Häuselmann, Rico. ETH Zurich - CSCS
 - Kardos, Péter. ETH Zurich - EXCLAIM
 - Kellerhals, Samuel. ETH Zurich - EXCLAIM
+- Kotsalos, Christos. ETH Zurich - CSCS
 - Luz, Magdalena. ETH Zurich - EXCLAIM
 - Madonna, Alberto. ETH Zurich - CSCS
 - Mariotti, Kean. ETH Zurich - CSCS
diff --git a/src/gt4py/next/__init__.py b/src/gt4py/next/__init__.py
index 00b95011..175849d8 100644
--- a/src/gt4py/next/__init__.py
+++ b/src/gt4py/next/__init__.py
@@ -44,6 +44,7 @@ from .ffront.decorator import field_operator, program, scan_operator
 from .ffront.fbuiltins import *  # noqa: F403 [undefined-local-with-import-star]  explicitly reexport all from fbuiltins.__all__
 from .ffront.fbuiltins import FieldOffset
 from .iterator.embedded import (
+    CompileTimeConnectivity,
     NeighborTableOffsetProvider,
     StridedNeighborOffsetProvider,
     index_field,
@@ -80,6 +81,7 @@ __all__ = [
     "as_connectivity",
     # from iterator
     "NeighborTableOffsetProvider",
+    "CompileTimeConnectivity",
     "StridedNeighborOffsetProvider",
     "index_field",
     "np_as_located_field",
diff --git a/src/gt4py/next/embedded/nd_array_field.py b/src/gt4py/next/embedded/nd_array_field.py
index 3322d693..8b6845d6 100644
--- a/src/gt4py/next/embedded/nd_array_field.py
+++ b/src/gt4py/next/embedded/nd_array_field.py
@@ -19,6 +19,7 @@ import dataclasses
 import functools
 from collections.abc import Callable, Sequence
 from types import ModuleType
+from typing import Any
 
 import numpy as np
 from numpy import typing as npt
@@ -47,12 +48,17 @@ from gt4py.next.iterator import embedded as itir_embedded
 try:
     import cupy as cp
 except ImportError:
-    cp: Optional[ModuleType] = None  # type:ignore[no-redef]
+    cp: Optional[ModuleType] = None  # type: ignore[no-redef]
 
 try:
     from jax import numpy as jnp
 except ImportError:
-    jnp: Optional[ModuleType] = None  # type:ignore[no-redef]
+    jnp: Optional[ModuleType] = None  # type: ignore[no-redef]
+
+try:
+    import dace
+except ImportError:
+    dace: Optional[ModuleType] = None  # type: ignore[no-redef]
 
 
 def _get_nd_array_class(*fields: common.Field | core_defs.Scalar) -> type[NdArrayField]:
@@ -419,6 +425,34 @@ class NdArrayField(
         assert common.is_relative_index_sequence(slice_)
         return new_domain, slice_
 
+    if dace:
+        # Extension of NdArrayField adding SDFGConvertible support in GT4Py Programs
+        def _dace_data_ptr(self) -> int:
+            array_ns = self.array_ns
+            array_byte_bounds = (  # TODO(egparedes): make this part of some Array namespace protocol
+                array_ns.byte_bounds
+                if hasattr(array_ns, "byte_bounds")
+                else array_ns.lib.array_utils.byte_bounds
+            )
+            return array_byte_bounds(self.ndarray)[0]
+
+        def _dace_descriptor(self) -> dace.data.Data:
+            return dace.data.create_datadescriptor(self.ndarray)
+    else:
+
+        def _dace_data_ptr(self) -> int:
+            raise NotImplementedError(
+                "data_ptr is only supported when the 'dace' module is available."
+            )
+
+        def _dace_descriptor(self) -> Any:
+            raise NotImplementedError(
+                "__descriptor__ is only supported when the 'dace' module is available."
+            )
+
+    data_ptr = _dace_data_ptr
+    __descriptor__ = _dace_descriptor
+
 
 @dataclasses.dataclass(frozen=True)
 class NdArrayConnectivityField(  # type: ignore[misc] # for __ne__, __eq__
diff --git a/src/gt4py/next/ffront/decorator.py b/src/gt4py/next/ffront/decorator.py
index be1b3c1f..a83e337b 100644
--- a/src/gt4py/next/ffront/decorator.py
+++ b/src/gt4py/next/ffront/decorator.py
@@ -35,7 +35,7 @@ from gt4py.next import (
     embedded as next_embedded,
     errors,
 )
-from gt4py.next.common import Dimension, GridType
+from gt4py.next.common import Connectivity, Dimension, GridType
 from gt4py.next.embedded import operators as embedded_operators
 from gt4py.next.ffront import (
     field_operator_ast as foast,
@@ -78,10 +78,14 @@ class Program:
         definition: The Python function object corresponding to the PAST node.
         grid_type: The grid type (cartesian or unstructured) to be used. If not explicitly given
             it will be deduced from actually occurring dimensions.
+        connectivities: A dictionary holding static/compile-time information about the offset providers.
+            For now, it is used for ahead of time compilation in DaCe orchestrated programs,
+            i.e. DaCe programs that call GT4Py Programs -SDFGConvertible interface-.
     """
 
     definition_stage: ffront_stages.ProgramDefinition
     backend: Optional[next_backend.Backend]
+    connectivities: Optional[dict[str, Connectivity]]
 
     @classmethod
     def from_function(
@@ -89,9 +93,10 @@ class Program:
         definition: types.FunctionType,
         backend: Optional[next_backend],
         grid_type: Optional[GridType] = None,
+        connectivities: Optional[dict[str, Connectivity]] = None,
     ) -> Program:
         program_def = ffront_stages.ProgramDefinition(definition=definition, grid_type=grid_type)
-        return cls(definition_stage=program_def, backend=backend)
+        return cls(definition_stage=program_def, backend=backend, connectivities=connectivities)
 
     # needed in testing
     @property
@@ -127,6 +132,9 @@ class Program:
     def with_backend(self, backend: ppi.ProgramExecutor) -> Program:
         return dataclasses.replace(self, backend=backend)
 
+    def with_connectivities(self, connectivities: dict[str, Connectivity]) -> Program:
+        return dataclasses.replace(self, connectivities=connectivities)
+
     def with_grid_type(self, grid_type: GridType) -> Program:
         return dataclasses.replace(
             self, definition_stage=dataclasses.replace(self.definition_stage, grid_type=grid_type)
@@ -206,6 +214,12 @@ class Program:
         )
 
 
+try:
+    from gt4py.next.program_processors.runners.dace_iterator import Program
+except ImportError:
+    pass
+
+
 @dataclasses.dataclass(frozen=True)
 class ProgramFromPast(Program):
     """
diff --git a/src/gt4py/next/iterator/embedded.py b/src/gt4py/next/iterator/embedded.py
index e5383f18..0a352b80 100644
--- a/src/gt4py/next/iterator/embedded.py
+++ b/src/gt4py/next/iterator/embedded.py
@@ -63,6 +63,14 @@ from gt4py.next.iterator import builtins, runtime
 from gt4py.next.type_system import type_specifications as ts, type_translation
 
 
+try:
+    import dace
+except ImportError:
+    from types import ModuleType
+
+    dace: Optional[ModuleType] = None  # type: ignore[no-redef]
+
+
 EMBEDDED = "embedded"
 
 
@@ -113,6 +121,64 @@ class NeighborTableOffsetProvider:
         assert common.is_int_index(res)
         return res
 
+    if dace:
+        # Extension of NeighborTableOffsetProvider adding SDFGConvertible support in GT4Py Programs
+        def _dace_data_ptr(self) -> int:
+            obj = self.table
+            if dace.dtypes.is_array(obj):
+                if hasattr(obj, "__array_interface__"):
+                    return obj.__array_interface__["data"][0]
+                if hasattr(obj, "__cuda_array_interface__"):
+                    return obj.__cuda_array_interface__["data"][0]
+            raise ValueError("Unsupported data container.")
+
+        def _dace_descriptor(self) -> dace.data.Data:
+            return dace.data.create_datadescriptor(self.table)
+    else:
+
+        def _dace_data_ptr(self) -> NoReturn:  # type: ignore[misc]
+            raise NotImplementedError(
+                "data_ptr is only supported when the 'dace' module is available."
+            )
+
+        def _dace_descriptor(self) -> NoReturn:  # type: ignore[misc]
+            raise NotImplementedError(
+                "__descriptor__ is only supported when the 'dace' module is available."
+            )
+
+    data_ptr = _dace_data_ptr
+    __descriptor__ = _dace_descriptor
+
+
+@dataclasses.dataclass(frozen=True)
+class CompileTimeConnectivity:
+    max_neighbors: int
+    has_skip_values: bool
+    origin_axis: common.Dimension
+    neighbor_axis: common.Dimension
+    index_type: type[int] | type[np.int32] | type[np.int64]
+
+    def mapped_index(
+        self, cur_index: int | np.integer, neigh_index: int | np.integer
+    ) -> Optional[int | np.integer]:
+        raise NotImplementedError(
+            "A CompileTimeConnectivity instance should not call `mapped_index`."
+        )
+
+    @classmethod
+    def from_connectivity(cls, connectivity: common.Connectivity) -> Self:
+        return cls(
+            max_neighbors=connectivity.max_neighbors,
+            has_skip_values=connectivity.has_skip_values,
+            origin_axis=connectivity.origin_axis,
+            neighbor_axis=connectivity.neighbor_axis,
+            index_type=connectivity.index_type,
+        )
+
+    @property
+    def table(self):
+        return None
+
 
 class StridedNeighborOffsetProvider:
     def __init__(
diff --git a/src/gt4py/next/iterator/ir_utils/ir_makers.py b/src/gt4py/next/iterator/ir_utils/ir_makers.py
index b1c9ec40..c97d4c0e 100644
--- a/src/gt4py/next/iterator/ir_utils/ir_makers.py
+++ b/src/gt4py/next/iterator/ir_utils/ir_makers.py
@@ -423,3 +423,32 @@ def as_fieldop(expr: itir.Expr, domain: Optional[itir.FunCall] = None) -> call:
             )
         )
     )
+
+
+def op_as_fieldop(
+    op: str | itir.SymRef | Callable, domain: Optional[itir.FunCall] = None
+) -> Callable[..., itir.FunCall]:
+    """
+    Promotes a function `op` to a field_operator.
+
+    Args:
+        op: a function from values to value.
+        domain: the domain of the returned field.
+
+    Returns:
+        A function from Fields to Field.
+
+    Examples:
+        >>> str(op_as_fieldop("op")("a", "b"))
+        '(⇑(λ(__arg0, __arg1) → op(·__arg0, ·__arg1)))(a, b)'
+    """
+    if isinstance(op, (str, itir.SymRef, itir.Lambda)):
+        op = call(op)
+
+    def _impl(*its: itir.Expr) -> itir.FunCall:
+        args = [
+            f"__arg{i}" for i in range(len(its))
+        ]  # TODO: `op` must not contain `SymRef(id="__argX")`
+        return as_fieldop(lambda_(*args)(op(*[deref(arg) for arg in args])), domain)(*its)
+
+    return _impl
diff --git a/src/gt4py/next/program_processors/runners/dace.py b/src/gt4py/next/program_processors/runners/dace.py
index 266d7b35..9d2456c1 100644
--- a/src/gt4py/next/program_processors/runners/dace.py
+++ b/src/gt4py/next/program_processors/runners/dace.py
@@ -86,8 +86,10 @@ run_dace_cpu = DaCeBackendFactory(cached=True, auto_optimize=True)
 run_dace_cpu_with_temporaries = DaCeBackendFactory(
     cached=True, auto_optimize=True, use_temporaries=True
 )
+run_dace_cpu_noopt = DaCeBackendFactory(cached=True, auto_optimize=False)
 
 run_dace_gpu = DaCeBackendFactory(gpu=True, cached=True, auto_optimize=True)
 run_dace_gpu_with_temporaries = DaCeBackendFactory(
     gpu=True, cached=True, auto_optimize=True, use_temporaries=True
 )
+run_dace_gpu_noopt = DaCeBackendFactory(gpu=True, cached=True, auto_optimize=False)
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 0ce9952a..06a5678d 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
@@ -38,6 +38,7 @@ if TYPE_CHECKING:
 
 
 IteratorIndexDType: TypeAlias = dace.int32  # type of iterator indexes
+LetSymbol: TypeAlias = tuple[str, ts.FieldType | ts.ScalarType]
 TemporaryData: TypeAlias = tuple[dace.nodes.Node, ts.FieldType | ts.ScalarType]
 
 
@@ -49,6 +50,7 @@ class PrimitiveTranslator(Protocol):
         sdfg: dace.SDFG,
         state: dace.SDFGState,
         sdfg_builder: gtir_to_sdfg.SDFGBuilder,
+        let_symbols: dict[str, LetSymbol],
     ) -> list[TemporaryData]:
         """Creates the dataflow subgraph representing a GTIR primitive function.
 
@@ -60,6 +62,7 @@ class PrimitiveTranslator(Protocol):
             sdfg: The SDFG where the primitive subgraph should be instantiated
             state: The SDFG state where the result of the primitive function should be made available
             sdfg_builder: The object responsible for visiting child nodes of the primitive node.
+            let_symbols: Mapping of symbols (i.e. lambda parameters) to known temporary fields.
 
         Returns:
             A list of data access nodes and the associated GT4Py data type, which provide
@@ -77,8 +80,14 @@ def _parse_arg_expr(
     domain: list[
         tuple[gtx_common.Dimension, dace.symbolic.SymbolicType, dace.symbolic.SymbolicType]
     ],
+    let_symbols: dict[str, LetSymbol],
 ) -> gtir_to_tasklet.IteratorExpr | gtir_to_tasklet.MemletExpr:
-    fields: list[TemporaryData] = sdfg_builder.visit(node, sdfg=sdfg, head_state=state)
+    fields: list[TemporaryData] = sdfg_builder.visit(
+        node,
+        sdfg=sdfg,
+        head_state=state,
+        let_symbols=let_symbols,
+    )
 
     assert len(fields) == 1
     data_node, arg_type = fields[0]
@@ -155,8 +164,9 @@ def translate_as_field_op(
     sdfg: dace.SDFG,
     state: dace.SDFGState,
     sdfg_builder: gtir_to_sdfg.SDFGBuilder,
+    let_symbols: dict[str, LetSymbol],
 ) -> list[TemporaryData]:
-    """Generates the dataflow subgraph for the `as_field_op` builtin function."""
+    """Generates the dataflow subgraph for the `as_fieldop` builtin function."""
     assert isinstance(node, gtir.FunCall)
     assert cpm.is_call_to(node.fun, "as_fieldop")
 
@@ -173,7 +183,9 @@ def translate_as_field_op(
     assert isinstance(node.type, ts.FieldType)
 
     # first visit the list of arguments and build a symbol map
-    stencil_args = [_parse_arg_expr(arg, sdfg, state, sdfg_builder, domain) for arg in node.args]
+    stencil_args = [
+        _parse_arg_expr(arg, sdfg, state, sdfg_builder, domain, let_symbols) for arg in node.args
+    ]
 
     # represent the field operator as a mapped tasklet graph, which will range over the field domain
     taskgen = gtir_to_tasklet.LambdaToTasklet(sdfg, state, sdfg_builder)
@@ -236,6 +248,7 @@ def translate_cond(
     sdfg: dace.SDFG,
     state: dace.SDFGState,
     sdfg_builder: gtir_to_sdfg.SDFGBuilder,
+    let_symbols: dict[str, LetSymbol],
 ) -> list[TemporaryData]:
     """Generates the dataflow subgraph for the `cond` builtin function."""
     assert cpm.is_call_to(node, "cond")
@@ -273,8 +286,18 @@ def translate_cond(
     sdfg.add_edge(cond_state, false_state, dace.InterstateEdge(condition=(f"not bool({cond})")))
     sdfg.add_edge(false_state, state, dace.InterstateEdge())
 
-    true_br_args = sdfg_builder.visit(true_expr, sdfg=sdfg, head_state=true_state)
-    false_br_args = sdfg_builder.visit(false_expr, sdfg=sdfg, head_state=false_state)
+    true_br_args = sdfg_builder.visit(
+        true_expr,
+        sdfg=sdfg,
+        head_state=true_state,
+        let_symbols=let_symbols,
+    )
+    false_br_args = sdfg_builder.visit(
+        false_expr,
+        sdfg=sdfg,
+        head_state=false_state,
+        let_symbols=let_symbols,
+    )
 
     output_nodes = []
     for true_br, false_br in zip(true_br_args, false_br_args, strict=True):
@@ -309,6 +332,7 @@ def translate_symbol_ref(
     sdfg: dace.SDFG,
     state: dace.SDFGState,
     sdfg_builder: gtir_to_sdfg.SDFGBuilder,
+    let_symbols: dict[str, LetSymbol],
 ) -> list[TemporaryData]:
     """Generates the dataflow subgraph for a `ir.SymRef` node."""
     assert isinstance(node, (gtir.Literal, gtir.SymRef))
@@ -320,7 +344,16 @@ def translate_symbol_ref(
         temp_name = "literal"
     else:
         sym_value = str(node.id)
-        data_type = sdfg_builder.get_symbol_type(sym_value)
+        if sym_value in let_symbols:
+            # The `let_symbols` dictionary maps a `gtir.SymRef` string to a temporary
+            # data container. These symbols are visited and initialized in a state
+            # that preceeds the current state, therefore a new access node is created
+            # everytime they are accessed. It is therefore possible that multiple access
+            # nodes are created in one state for the same data container. We rely
+            # on the simplify to remove duplicated access nodes.
+            sym_value, data_type = let_symbols[sym_value]
+        else:
+            data_type = sdfg_builder.get_symbol_type(sym_value)
         temp_name = sym_value
 
     if isinstance(data_type, ts.FieldType):
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 b0cf2215..78a3047d 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
@@ -108,7 +108,7 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
     """
 
     offset_provider: dict[str, gtx_common.Connectivity | gtx_common.Dimension]
-    symbol_types: dict[str, ts.FieldType | ts.ScalarType] = dataclasses.field(
+    global_symbols: dict[str, ts.FieldType | ts.ScalarType] = dataclasses.field(
         default_factory=lambda: {}
     )
     map_uids: eve.utils.UIDGenerator = dataclasses.field(
@@ -119,12 +119,10 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
     )
 
     def get_offset_provider(self, offset: str) -> gtx_common.Connectivity | gtx_common.Dimension:
-        assert offset in self.offset_provider
         return self.offset_provider[offset]
 
     def get_symbol_type(self, symbol_name: str) -> ts.FieldType | ts.ScalarType:
-        assert symbol_name in self.symbol_types
-        return self.symbol_types[symbol_name]
+        return self.global_symbols[symbol_name]
 
     def unique_map_name(self, name: str) -> str:
         return f"{self.map_uids.sequential_id()}_{name}"
@@ -184,7 +182,7 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
 
         # TODO: unclear why mypy complains about incompatible types
         assert isinstance(symbol_type, (ts.FieldType, ts.ScalarType))
-        self.symbol_types[name] = symbol_type
+        self.global_symbols[name] = symbol_type
 
     def _add_storage_for_temporary(self, temp_decl: gtir.Temporary) -> dict[str, str]:
         """
@@ -210,7 +208,7 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         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
+            node, sdfg=sdfg, head_state=head_state, let_symbols={}
         )
 
         field_nodes = []
@@ -303,7 +301,7 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         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.symbol_types[target_node.data]
+            target_symbol_type = self.global_symbols[target_node.data]
 
             if isinstance(target_symbol_type, ts.FieldType):
                 subset = ",".join(
@@ -324,38 +322,102 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         node: gtir.FunCall,
         sdfg: dace.SDFG,
         head_state: dace.SDFGState,
+        let_symbols: dict[str, gtir_builtin_translators.LetSymbol],
     ) -> list[gtir_builtin_translators.TemporaryData]:
         # use specialized dataflow builder classes for each builtin function
         if cpm.is_call_to(node, "cond"):
-            return gtir_builtin_translators.translate_cond(node, sdfg, head_state, self)
+            return gtir_builtin_translators.translate_cond(
+                node, sdfg, head_state, self, let_symbols
+            )
         elif cpm.is_call_to(node.fun, "as_fieldop"):
-            return gtir_builtin_translators.translate_as_field_op(node, sdfg, head_state, self)
+            return gtir_builtin_translators.translate_as_field_op(
+                node, sdfg, head_state, self, let_symbols
+            )
+        elif isinstance(node.fun, gtir.Lambda):
+            # We use a separate state to ensure that the lambda arguments are evaluated
+            # before the computation starts. This is required in case the let-symbols
+            # are used in conditional branch execution, which happens in different states.
+            lambda_state = sdfg.add_state_before(head_state, f"{head_state.label}_symbols")
+
+            node_args = []
+            for arg in node.args:
+                node_args.extend(
+                    self.visit(
+                        arg,
+                        sdfg=sdfg,
+                        head_state=lambda_state,
+                        let_symbols=let_symbols,
+                    )
+                )
+
+            # some cleanup: remove isolated nodes for program arguments in lambda state
+            isolated_node_args = [node for node, _ in node_args if lambda_state.degree(node) == 0]
+            assert all(
+                isinstance(node, dace.nodes.AccessNode) and node.data in self.global_symbols
+                for node in isolated_node_args
+            )
+            lambda_state.remove_nodes_from(isolated_node_args)
+
+            return self.visit(
+                node.fun,
+                sdfg=sdfg,
+                head_state=head_state,
+                let_symbols=let_symbols,
+                args=node_args,
+            )
         else:
             raise NotImplementedError(f"Unexpected 'FunCall' expression ({node}).")
 
-    def visit_Lambda(self, node: gtir.Lambda) -> Any:
+    def visit_Lambda(
+        self,
+        node: gtir.Lambda,
+        sdfg: dace.SDFG,
+        head_state: dace.SDFGState,
+        let_symbols: dict[str, gtir_builtin_translators.LetSymbol],
+        args: list[gtir_builtin_translators.TemporaryData],
+    ) -> list[gtir_builtin_translators.TemporaryData]:
         """
-        This visitor class should never encounter `itir.Lambda` expressions
-        because a lambda represents a stencil, which operates from iterator to values.
-        In fieldview, lambdas should only be arguments to field operators (`as_field_op`).
+        Translates a `Lambda` node to a tasklet subgraph in the current SDFG state.
+
+        All arguments to lambda functions are fields (i.e. `as_fieldop`, field or scalar `gtir.SymRef`,
+        nested let-lambdas thereof). The dictionary called `let_symbols` maps the lambda parameters
+        to symbols, e.g. temporary fields or program arguments. If the lambda has a parameter whose name
+        is already present in `let_symbols`, i.e. a paramater with the same name as a previously defined
+        symbol, the parameter will shadow the previous symbol during traversal of the lambda expression.
         """
-        raise RuntimeError("Unexpected 'itir.Lambda' node encountered in GTIR.")
+        lambda_symbols = let_symbols | {
+            str(p.id): (temp_node.data, type_)
+            for p, (temp_node, type_) in zip(node.params, args, strict=True)
+        }
+
+        return self.visit(
+            node.expr,
+            sdfg=sdfg,
+            head_state=head_state,
+            let_symbols=lambda_symbols,
+        )
 
     def visit_Literal(
         self,
         node: gtir.Literal,
         sdfg: dace.SDFG,
         head_state: dace.SDFGState,
+        let_symbols: dict[str, gtir_builtin_translators.LetSymbol],
     ) -> list[gtir_builtin_translators.TemporaryData]:
-        return gtir_builtin_translators.translate_symbol_ref(node, sdfg, head_state, self)
+        return gtir_builtin_translators.translate_symbol_ref(
+            node, sdfg, head_state, self, let_symbols={}
+        )
 
     def visit_SymRef(
         self,
         node: gtir.SymRef,
         sdfg: dace.SDFG,
         head_state: dace.SDFGState,
+        let_symbols: dict[str, gtir_builtin_translators.LetSymbol],
     ) -> list[gtir_builtin_translators.TemporaryData]:
-        return gtir_builtin_translators.translate_symbol_ref(node, sdfg, head_state, self)
+        return gtir_builtin_translators.translate_symbol_ref(
+            node, sdfg, head_state, self, let_symbols
+        )
 
 
 def build_sdfg_from_gtir(
diff --git a/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_to_tasklet.py b/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_to_tasklet.py
index f41f7cd5..c15dcf9f 100644
--- a/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_to_tasklet.py
+++ b/src/gt4py/next/program_processors/runners/dace_fieldview/gtir_to_tasklet.py
@@ -80,7 +80,7 @@ class IteratorExpr:
 class LambdaToTasklet(eve.NodeVisitor):
     """Translates an `ir.Lambda` expression to a dataflow graph.
 
-    Lambda functions should only be encountered as argument to the `as_field_op`
+    Lambda functions should only be encountered as argument to the `as_fieldop`
     builtin function, therefore the dataflow graph generated here typically
     represents the stencil function of a field operator.
     """
diff --git a/src/gt4py/next/program_processors/runners/dace_iterator/__init__.py b/src/gt4py/next/program_processors/runners/dace_iterator/__init__.py
index 7147182f..eeca53ea 100644
--- a/src/gt4py/next/program_processors/runners/dace_iterator/__init__.py
+++ b/src/gt4py/next/program_processors/runners/dace_iterator/__init__.py
@@ -11,10 +11,14 @@
 # distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
+import dataclasses
 import warnings
+from collections import OrderedDict
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import field
 from inspect import currentframe, getframeinfo
 from pathlib import Path
-from typing import Any, Callable, Mapping, Optional, Sequence
+from typing import Any, ClassVar, Optional
 
 import dace
 import numpy as np
@@ -23,12 +27,20 @@ from dace.transformation.auto import auto_optimize as autoopt
 
 import gt4py.next.iterator.ir as itir
 from gt4py.next import common
+from gt4py.next.ffront import decorator
 from gt4py.next.iterator import transforms as itir_transforms
 from gt4py.next.iterator.type_system import inference as itir_type_inference
 from gt4py.next.type_system import type_specifications as ts
 
 from .itir_to_sdfg import ItirToSDFG
-from .utility import connectivity_identifier, filter_connectivities, get_sorted_dims
+from .utility import (
+    as_dace_type,
+    connectivity_identifier,
+    field_size_symbol_name,
+    field_stride_symbol_name,
+    filter_connectivities,
+    get_sorted_dims,
+)
 
 
 try:
@@ -307,3 +319,195 @@ def build_sdfg_from_itir(
         sdfg.save(sdfg_filename)
 
     return sdfg
+
+
+@dataclasses.dataclass(frozen=True)
+class Program(decorator.Program, dace.frontend.python.common.SDFGConvertible):
+    """Extension of GT4Py Program implementing the SDFGConvertible interface."""
+
+    sdfg_closure_vars: dict[str, Any] = field(default_factory=dict)
+
+    # Being a ClassVar ensures that in an SDFG with multiple nested GT4Py Programs,
+    # there is no name mangling of the connectivity tables used across the nested SDFGs
+    # since they share the same memory address.
+    connectivity_tables_data_descriptors: ClassVar[
+        dict[str, dace.data.Array]
+    ] = {}  # symbolically defined
+
+    def __sdfg__(self, *args, **kwargs) -> dace.sdfg.sdfg.SDFG:
+        if "dace" not in self.backend.executor.name.lower():  # type: ignore[union-attr]
+            raise ValueError("The SDFG can be generated only for the DaCe backend.")
+
+        params = {str(p.id): p.type for p in self.itir.params}
+        fields = {str(p.id): p.type for p in self.itir.params if hasattr(p.type, "dims")}
+        arg_types = [*params.values()]
+
+        dace_parsed_args = [*args, *kwargs.values()]
+        gt4py_program_args = [*params.values()]
+        _crosscheck_dace_parsing(dace_parsed_args, gt4py_program_args)
+
+        if self.connectivities is None:
+            raise ValueError(
+                "[DaCe Orchestration] Connectivities -at compile time- are required to generate the SDFG. Use `with_connectivities` method."
+            )
+        offset_provider = self.connectivities  # tables are None at this point
+
+        sdfg = self.backend.executor.otf_workflow.step.translation.generate_sdfg(  # type: ignore[union-attr]
+            self.itir,
+            arg_types,
+            offset_provider=offset_provider,
+            column_axis=kwargs.get("column_axis", None),
+        )
+        self.sdfg_closure_vars["sdfg.arrays"] = sdfg.arrays  # use it in __sdfg_closure__
+
+        # Halo exchange related metadata, i.e. gt4py_program_input_fields, gt4py_program_output_fields, offset_providers_per_input_field
+        # Add them as dynamic properties to the SDFG
+
+        input_fields = [
+            str(in_field.id)
+            for closure in self.itir.closures
+            for in_field in closure.inputs
+            if str(in_field.id) in fields
+        ]
+        sdfg.gt4py_program_input_fields = {
+            in_field: dim
+            for in_field in input_fields
+            for dim in fields[in_field].dims  # type: ignore[union-attr]
+            if dim.kind == common.DimensionKind.HORIZONTAL
+        }
+
+        output_fields = []
+        for closure in self.itir.closures:
+            output = closure.output
+            if isinstance(output, itir.SymRef):
+                if str(output.id) in fields:
+                    output_fields.append(str(output.id))
+            else:
+                for arg in output.args:
+                    if str(arg.id) in fields:  # type: ignore[attr-defined]
+                        output_fields.append(str(arg.id))  # type: ignore[attr-defined]
+        sdfg.gt4py_program_output_fields = {
+            output: dim
+            for output in output_fields
+            for dim in fields[output].dims  # type: ignore[union-attr]
+            if dim.kind == common.DimensionKind.HORIZONTAL
+        }
+
+        sdfg.offset_providers_per_input_field = {}
+        itir_tmp = itir_transforms.apply_common_transforms(
+            self.itir, offset_provider=offset_provider
+        )
+        for closure in itir_tmp.closures:  # type: ignore[union-attr]
+            shifts = itir_transforms.trace_shifts.TraceShifts.apply(closure)
+            for k, v in shifts.items():
+                if not isinstance(k, str):
+                    continue
+                if k not in sdfg.gt4py_program_input_fields:
+                    continue
+                sdfg.offset_providers_per_input_field.setdefault(k, []).extend(list(v))
+
+        return sdfg
+
+    def __sdfg_closure__(self, reevaluate: Optional[dict[str, str]] = None) -> dict[str, Any]:
+        """
+        Returns the closure arrays of the SDFG represented by this object
+        as a mapping between array name and the corresponding value.
+
+        The connectivity tables are defined symbolically, i.e. table sizes & strides are DaCe symbols.
+        The need to define the connectivity tables in the `__sdfg_closure__` arises from the fact that
+        the offset providers are not part of GT4Py Program's arguments.
+        Keep in mind, that `__sdfg_closure__` is called after `__sdfg__` method.
+        """
+        offset_provider = self.connectivities
+
+        # Define DaCe symbols
+        connectivity_table_size_symbols = {
+            field_size_symbol_name(connectivity_identifier(k), axis): dace.symbol(
+                field_size_symbol_name(connectivity_identifier(k), axis)
+            )
+            for k, v in offset_provider.items()  # type: ignore[union-attr]
+            for axis in [0, 1]
+            if hasattr(v, "table")
+            and connectivity_identifier(k) in self.sdfg_closure_vars["sdfg.arrays"]
+        }
+
+        connectivity_table_stride_symbols = {
+            field_stride_symbol_name(connectivity_identifier(k), axis): dace.symbol(
+                field_stride_symbol_name(connectivity_identifier(k), axis)
+            )
+            for k, v in offset_provider.items()  # type: ignore[union-attr]
+            for axis in [0, 1]
+            if hasattr(v, "table")
+            and connectivity_identifier(k) in self.sdfg_closure_vars["sdfg.arrays"]
+        }
+
+        symbols = {**connectivity_table_size_symbols, **connectivity_table_stride_symbols}
+
+        # Define the storage location (e.g. CPU, GPU) of the connectivity tables
+        if "storage" not in Program.connectivity_tables_data_descriptors:
+            for k, v in offset_provider.items():  # type: ignore[union-attr]
+                if not hasattr(v, "table"):
+                    continue
+                if connectivity_identifier(k) in self.sdfg_closure_vars["sdfg.arrays"]:
+                    Program.connectivity_tables_data_descriptors["storage"] = (
+                        self.sdfg_closure_vars["sdfg.arrays"][connectivity_identifier(k)].storage
+                    )
+                    break
+
+        # Build the closure dictionary
+        closure_dict = {}
+        for k, v in offset_provider.items():  # type: ignore[union-attr]
+            conn_id = connectivity_identifier(k)
+            if hasattr(v, "table") and conn_id in self.sdfg_closure_vars["sdfg.arrays"]:
+                if conn_id not in Program.connectivity_tables_data_descriptors:
+                    Program.connectivity_tables_data_descriptors[conn_id] = dace.data.Array(
+                        dtype=dace.int64 if v.index_type == np.int64 else dace.int32,
+                        shape=[
+                            symbols[field_size_symbol_name(conn_id, 0)],
+                            symbols[field_size_symbol_name(conn_id, 1)],
+                        ],
+                        strides=[
+                            symbols[field_stride_symbol_name(conn_id, 0)],
+                            symbols[field_stride_symbol_name(conn_id, 1)],
+                        ],
+                        storage=Program.connectivity_tables_data_descriptors["storage"],
+                    )
+                closure_dict[conn_id] = Program.connectivity_tables_data_descriptors[conn_id]
+
+        return closure_dict
+
+    def __sdfg_signature__(self) -> tuple[Sequence[str], Sequence[str]]:
+        args = []
+        for arg in self.past_stage.past_node.params:
+            args.append(arg.id)
+        return (args, [])
+
+
+def _crosscheck_dace_parsing(dace_parsed_args: list[Any], gt4py_program_args: list[Any]) -> bool:
+    for dace_parsed_arg, gt4py_program_arg in zip(dace_parsed_args, gt4py_program_args):
+        if isinstance(dace_parsed_arg, dace.data.Scalar):
+            assert dace_parsed_arg.dtype == as_dace_type(gt4py_program_arg)
+        elif isinstance(
+            dace_parsed_arg, (bool, int, float, str, np.bool_, np.integer, np.floating, np.str_)
+        ):  # compile-time constant scalar
+            assert isinstance(gt4py_program_arg, ts.ScalarType)
+            if isinstance(dace_parsed_arg, (bool, np.bool_)):
+                assert gt4py_program_arg.kind == ts.ScalarKind.BOOL
+            elif isinstance(dace_parsed_arg, (int, np.integer)):
+                assert gt4py_program_arg.kind in [ts.ScalarKind.INT32, ts.ScalarKind.INT64]
+            elif isinstance(dace_parsed_arg, (float, np.floating)):
+                assert gt4py_program_arg.kind in [ts.ScalarKind.FLOAT32, ts.ScalarKind.FLOAT64]
+            elif isinstance(dace_parsed_arg, (str, np.str_)):
+                assert gt4py_program_arg.kind == ts.ScalarKind.STRING
+        elif isinstance(dace_parsed_arg, dace.data.Array):
+            assert isinstance(gt4py_program_arg, ts.FieldType)
+            assert len(dace_parsed_arg.shape) == len(gt4py_program_arg.dims)
+            assert dace_parsed_arg.dtype == as_dace_type(gt4py_program_arg.dtype)
+        elif isinstance(
+            dace_parsed_arg, (dace.data.Structure, dict, OrderedDict)
+        ):  # offset_provider
+            continue
+        else:
+            raise ValueError(f"Unresolved case for {dace_parsed_arg} (==, !=) {gt4py_program_arg}")
+
+    return True
diff --git a/src/gt4py/next/program_processors/runners/dace_iterator/itir_to_sdfg.py b/src/gt4py/next/program_processors/runners/dace_iterator/itir_to_sdfg.py
index 40a8c1b8..94799687 100644
--- a/src/gt4py/next/program_processors/runners/dace_iterator/itir_to_sdfg.py
+++ b/src/gt4py/next/program_processors/runners/dace_iterator/itir_to_sdfg.py
@@ -39,6 +39,8 @@ from .utility import (
     as_dace_type,
     connectivity_identifier,
     dace_debuginfo,
+    field_size_symbol_name,
+    field_stride_symbol_name,
     filter_connectivities,
     flatten_list,
     get_sorted_dims,
@@ -119,11 +121,11 @@ def _make_array_shape_and_strides(
             neighbor_tables[dim.value].max_neighbors
             if dim.kind == DimensionKind.LOCAL
             # we reuse the same gt4py symbol for field size passed as scalar argument which is used in closure domain
-            else dace.symbol(f"__{name}_size_{i}", dtype)
+            else dace.symbol(field_size_symbol_name(name, i), dtype)
         )
         for i, dim in sorted_dims
     ]
-    strides = [dace.symbol(f"__{name}_stride_{i}", dtype) for i, _ in sorted_dims]
+    strides = [dace.symbol(field_stride_symbol_name(name, i), dtype) for i, _ in sorted_dims]
     return shape, strides
 
 
diff --git a/src/gt4py/next/program_processors/runners/dace_iterator/utility.py b/src/gt4py/next/program_processors/runners/dace_iterator/utility.py
index 67784240..8c8ee0c6 100644
--- a/src/gt4py/next/program_processors/runners/dace_iterator/utility.py
+++ b/src/gt4py/next/program_processors/runners/dace_iterator/utility.py
@@ -72,6 +72,14 @@ def connectivity_identifier(name: str) -> str:
     return f"__connectivity_{name}"
 
 
+def field_size_symbol_name(field_name: str, axis: int) -> str:
+    return f"__{field_name}_size_{axis}"
+
+
+def field_stride_symbol_name(field_name: str, axis: int) -> str:
+    return f"__{field_name}_stride_{axis}"
+
+
 def get_sorted_dims(dims: Sequence[Dimension]) -> Sequence[tuple[int, Dimension]]:
     return sorted(enumerate(dims), key=lambda v: v[1].value)
 


Test patch:
diff --git a/tests/next_tests/integration_tests/feature_tests/dace/__init__.py b/tests/next_tests/integration_tests/feature_tests/dace/__init__.py
new file mode 100644
index 00000000..6c43e2f1
--- /dev/null
+++ b/tests/next_tests/integration_tests/feature_tests/dace/__init__.py
@@ -0,0 +1,13 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2023, ETH Zurich
+# All rights reserved.
+#
+# This file is part of the GT4Py project and the GridTools framework.
+# GT4Py is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or any later
+# version. See the LICENSE.txt file at the top-level directory of this
+# distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
diff --git a/tests/next_tests/integration_tests/feature_tests/dace/test_orchestration.py b/tests/next_tests/integration_tests/feature_tests/dace/test_orchestration.py
new file mode 100644
index 00000000..f69dbc84
--- /dev/null
+++ b/tests/next_tests/integration_tests/feature_tests/dace/test_orchestration.py
@@ -0,0 +1,200 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2023, ETH Zurich
+# All rights reserved.
+#
+# This file is part of the GT4Py project and the GridTools framework.
+# GT4Py is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or any later
+# version. See the LICENSE.txt file at the top-level directory of this
+# distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import numpy as np
+from typing import Optional
+from types import ModuleType
+import pytest
+
+import gt4py.next as gtx
+from gt4py.next import backend as next_backend
+
+from next_tests.integration_tests import cases
+from next_tests.integration_tests.cases import cartesian_case, unstructured_case
+from next_tests.integration_tests.feature_tests.ffront_tests.ffront_test_utils import (
+    exec_alloc_descriptor,
+    mesh_descriptor,
+    Vertex,
+    Edge,
+    E2V,
+)
+from next_tests.integration_tests.multi_feature_tests.ffront_tests.test_laplacian import (
+    lap_program,
+    laplap_program,
+    lap_ref,
+)
+
+try:
+    import dace
+    from gt4py.next.program_processors.runners.dace import run_dace_cpu, run_dace_gpu
+except ImportError:
+    dace: Optional[ModuleType] = None  # type:ignore[no-redef]
+    run_dace_cpu: Optional[next_backend.Backend] = None
+    run_dace_gpu: Optional[next_backend.Backend] = None
+
+pytestmark = pytest.mark.requires_dace
+
+
+def test_sdfgConvertible_laplap(cartesian_case):
+    # TODO(kotsaloscv): Temporary solution until the `requires_dace` marker is fully functional
+    if cartesian_case.executor not in [run_dace_cpu, run_dace_gpu]:
+        pytest.skip("DaCe-related test: Test SDFGConvertible interface for GT4Py programs")
+
+    if cartesian_case.executor == run_dace_gpu:
+        import cupy as xp
+    else:
+        import numpy as xp
+
+    in_field = cases.allocate(cartesian_case, laplap_program, "in_field")()
+    out_field = cases.allocate(cartesian_case, laplap_program, "out_field")()
+
+    connectivities = {}  # Dict of NeighborOffsetProviders, where self.table = None
+    for k, v in cartesian_case.offset_provider.items():
+        if hasattr(v, "table"):
+            connectivities[k] = gtx.CompileTimeConnectivity(
+                v.max_neighbors, v.has_skip_values, v.origin_axis, v.neighbor_axis, v.table.dtype
+            )
+        else:
+            connectivities[k] = v
+
+    # Test DaCe closure support
+    @dace.program
+    def sdfg():
+        tmp_field = xp.empty_like(out_field)
+        lap_program.with_grid_type(cartesian_case.grid_type).with_backend(
+            cartesian_case.executor
+        ).with_connectivities(connectivities)(in_field, tmp_field)
+        lap_program.with_grid_type(cartesian_case.grid_type).with_backend(
+            cartesian_case.executor
+        ).with_connectivities(connectivities)(tmp_field, out_field)
+
+    sdfg()
+
+    assert np.allclose(
+        gtx.field_utils.asnumpy(out_field)[2:-2, 2:-2],
+        lap_ref(lap_ref(in_field.array_ns.asarray(in_field.ndarray))),
+    )
+
+
+@gtx.field_operator
+def _testee(a: gtx.Field[gtx.Dims[Vertex], gtx.float64]):
+    return a(E2V[0])
+
+
+@gtx.program
+def testee(a: gtx.Field[gtx.Dims[Vertex], gtx.float64], b: gtx.Field[gtx.Dims[Edge], gtx.float64]):
+    _testee(a, out=b)
+
+
+@pytest.mark.uses_unstructured_shift
+def test_sdfgConvertible_connectivities(unstructured_case):
+    # TODO(kotsaloscv): Temporary solution until the `requires_dace` marker is fully functional
+    if unstructured_case.executor not in [run_dace_cpu, run_dace_gpu]:
+        pytest.skip("DaCe-related test: Test SDFGConvertible interface for GT4Py programs")
+
+    allocator, backend = unstructured_case.allocator, unstructured_case.executor
+
+    if backend == run_dace_gpu:
+        import cupy as xp
+
+        dace_storage_type = dace.StorageType.GPU_Global
+    else:
+        import numpy as xp
+
+        dace_storage_type = dace.StorageType.Default
+
+    rows = dace.symbol("rows")
+    cols = dace.symbol("cols")
+    OffsetProvider_t = dace.data.Structure(
+        dict(E2V=dace.data.Array(dtype=dace.int64, shape=[rows, cols], storage=dace_storage_type)),
+        name="OffsetProvider",
+    )
+
+    @dace.program
+    def sdfg(
+        a: dace.data.Array(dtype=dace.float64, shape=(rows,), storage=dace_storage_type),
+        out: dace.data.Array(dtype=dace.float64, shape=(rows,), storage=dace_storage_type),
+        offset_provider: OffsetProvider_t,
+        connectivities: dace.compiletime,
+    ):
+        testee.with_backend(backend).with_connectivities(connectivities)(
+            a, out, offset_provider=offset_provider
+        )
+
+    e2v = gtx.NeighborTableOffsetProvider(
+        xp.asarray([[0, 1], [1, 2], [2, 0]]), Edge, Vertex, 2, False
+    )
+    connectivities = {}
+    connectivities["E2V"] = gtx.CompileTimeConnectivity(
+        e2v.max_neighbors, e2v.has_skip_values, e2v.origin_axis, e2v.neighbor_axis, e2v.table.dtype
+    )
+    offset_provider = OffsetProvider_t.dtype._typeclass.as_ctypes()(E2V=e2v.data_ptr())
+
+    SDFG = sdfg.to_sdfg(connectivities=connectivities)
+    cSDFG = SDFG.compile()
+
+    a = gtx.as_field([Vertex], xp.asarray([0.0, 1.0, 2.0]), allocator=allocator)
+    out = gtx.zeros({Edge: 3}, allocator=allocator)
+    # This is a low level interface to call the compiled SDFG.
+    # It is not supposed to be used in user code.
+    # The high level interface should be provided by a DaCe Orchestrator,
+    # i.e. decorator that hides the low level operations.
+    # This test checks only that the SDFGConvertible interface works correctly.
+    cSDFG(
+        a,
+        out,
+        offset_provider,
+        rows=3,
+        cols=2,
+        __connectivity_E2V=e2v.table,
+        ____connectivity_E2V_stride_0=get_stride_from_numpy_to_dace(
+            xp.asnumpy(e2v.table) if backend == run_dace_gpu else e2v.table, 0
+        ),
+        ____connectivity_E2V_stride_1=get_stride_from_numpy_to_dace(
+            xp.asnumpy(e2v.table) if backend == run_dace_gpu else e2v.table, 1
+        ),
+    )
+
+    e2v_xp = xp.asnumpy(e2v.table) if backend == run_dace_gpu else e2v.table
+    assert np.allclose(gtx.field_utils.asnumpy(out), gtx.field_utils.asnumpy(a)[e2v_xp[:, 0]])
+
+    e2v = gtx.NeighborTableOffsetProvider(
+        xp.asarray([[1, 0], [2, 1], [0, 2]]), Edge, Vertex, 2, False
+    )
+    offset_provider = OffsetProvider_t.dtype._typeclass.as_ctypes()(E2V=e2v.data_ptr())
+    cSDFG(
+        a,
+        out,
+        offset_provider,
+        rows=3,
+        cols=2,
+        __connectivity_E2V=e2v.table,
+        ____connectivity_E2V_stride_0=get_stride_from_numpy_to_dace(
+            xp.asnumpy(e2v.table) if backend == run_dace_gpu else e2v.table, 0
+        ),
+        ____connectivity_E2V_stride_1=get_stride_from_numpy_to_dace(
+            xp.asnumpy(e2v.table) if backend == run_dace_gpu else e2v.table, 1
+        ),
+    )
+
+    e2v_xp = xp.asnumpy(e2v.table) if backend == run_dace_gpu else e2v.table
+    assert np.allclose(gtx.field_utils.asnumpy(out), gtx.field_utils.asnumpy(a)[e2v_xp[:, 0]])
+
+
+def get_stride_from_numpy_to_dace(numpy_array: np.ndarray, axis: int) -> int:
+    """
+    NumPy strides: number of bytes to jump
+    DaCe strides: number of elements to jump
+    """
+    return numpy_array.strides[axis] // numpy_array.itemsize
diff --git a/tests/next_tests/integration_tests/feature_tests/ffront_tests/ffront_test_utils.py b/tests/next_tests/integration_tests/feature_tests/ffront_tests/ffront_test_utils.py
index 840f6d61..d55e3d7d 100644
--- a/tests/next_tests/integration_tests/feature_tests/ffront_tests/ffront_test_utils.py
+++ b/tests/next_tests/integration_tests/feature_tests/ffront_tests/ffront_test_utils.py
@@ -13,7 +13,6 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 import types
-from collections import namedtuple
 from typing import Any, Protocol, TypeVar
 
 import numpy as np
@@ -24,16 +23,6 @@ from gt4py.next import backend as next_backend, common
 from gt4py.next.ffront import decorator
 from gt4py.next.iterator import ir as itir
 from gt4py.next.program_processors import processor_interface as ppi
-from gt4py.next.program_processors.runners import gtfn, roundtrip
-
-
-try:
-    from gt4py.next.program_processors.runners import dace_iterator
-except ModuleNotFoundError as e:
-    if "dace" in str(e):
-        dace_iterator = None
-    else:
-        raise e
 
 import next_tests
 
@@ -52,19 +41,6 @@ class NoBackend(next_backend.Backend):
 no_backend = NoBackend(executor=no_exec, transforms_prog=None, allocator=None)
 
 
-OPTIONAL_PROCESSORS = []
-if dace_iterator:
-    OPTIONAL_PROCESSORS.append(next_tests.definitions.OptionalProgramBackendId.DACE_CPU)
-    (
-        OPTIONAL_PROCESSORS.append(
-            pytest.param(
-                next_tests.definitions.OptionalProgramBackendId.DACE_GPU,
-                marks=pytest.mark.requires_gpu,
-            )
-        ),
-    )
-
-
 @pytest.fixture(
     params=[
         next_tests.definitions.ProgramBackendId.ROUNDTRIP,
@@ -79,8 +55,15 @@ if dace_iterator:
         pytest.param(
             next_tests.definitions.EmbeddedIds.CUPY_EXECUTION, marks=pytest.mark.requires_gpu
         ),
-    ]
-    + OPTIONAL_PROCESSORS,
+        pytest.param(
+            next_tests.definitions.OptionalProgramBackendId.DACE_CPU,
+            marks=pytest.mark.requires_dace,
+        ),
+        pytest.param(
+            next_tests.definitions.OptionalProgramBackendId.DACE_GPU,
+            marks=(pytest.mark.requires_dace, pytest.mark.requires_gpu),
+        ),
+    ],
     ids=lambda p: p.short_id(),
 )
 def exec_alloc_descriptor(request):
diff --git a/tests/next_tests/unit_tests/conftest.py b/tests/next_tests/unit_tests/conftest.py
index 82932466..4af8ecb1 100644
--- a/tests/next_tests/unit_tests/conftest.py
+++ b/tests/next_tests/unit_tests/conftest.py
@@ -19,33 +19,13 @@ import dataclasses
 import pytest
 
 import gt4py.next as gtx
-from gt4py.next.iterator import runtime, transforms
+from gt4py.next.iterator import runtime
 from gt4py.next.program_processors import processor_interface as ppi
 
 
-try:
-    from gt4py.next.program_processors.runners import dace_iterator
-except ModuleNotFoundError as e:
-    if "dace" in str(e):
-        dace_iterator = None
-    else:
-        raise e
-
-
 import next_tests
 
 
-OPTIONAL_PROCESSORS = []
-if dace_iterator:
-    OPTIONAL_PROCESSORS.append((next_tests.definitions.OptionalProgramBackendId.DACE_CPU, True))
-    # TODO(havogt): update tests to use proper allocation
-    # OPTIONAL_PROCESSORS.append(
-    #     pytest.param(
-    #         (definitions.OptionalProgramBackendId.DACE_GPU, True), marks=pytest.mark.requires_gpu
-    #     )
-    # ),
-
-
 @pytest.fixture(
     params=[
         (None, True),
@@ -59,8 +39,16 @@ if dace_iterator:
         (next_tests.definitions.ProgramFormatterId.LISP_FORMATTER, False),
         (next_tests.definitions.ProgramFormatterId.ITIR_PRETTY_PRINTER, False),
         (next_tests.definitions.ProgramFormatterId.GTFN_CPP_FORMATTER, False),
-    ]
-    + OPTIONAL_PROCESSORS,
+        pytest.param(
+            (next_tests.definitions.OptionalProgramBackendId.DACE_CPU, True),
+            marks=pytest.mark.requires_dace,
+        ),
+        # TODO(havogt): update tests to use proper allocation
+        # pytest.param(
+        #     (next_tests.definitions.OptionalProgramBackendId.DACE_GPU, True),
+        #     marks=(pytest.mark.requires_dace, pytest.mark.requires_gpu),
+        # ),
+    ],
     ids=lambda p: p[0].short_id() if p[0] is not None else "None",
 )
 def program_processor(request) -> tuple[ppi.ProgramProcessor, bool]:
diff --git a/tests/next_tests/unit_tests/program_processor_tests/runners_tests/test_dace_fieldview.py b/tests/next_tests/unit_tests/program_processor_tests/runners_tests/test_dace_fieldview.py
index c5d24700..222c82bd 100644
--- a/tests/next_tests/unit_tests/program_processor_tests/runners_tests/test_dace_fieldview.py
+++ b/tests/next_tests/unit_tests/program_processor_tests/runners_tests/test_dace_fieldview.py
@@ -34,7 +34,7 @@ from next_tests.integration_tests.feature_tests.ffront_tests.ffront_test_utils i
 import numpy as np
 import pytest
 
-dace = pytest.importorskip("dace")
+pytestmark = pytest.mark.requires_dace
 
 
 N = 10
@@ -132,10 +132,7 @@ def test_gtir_update():
         im.lambda_("a")(im.plus(im.deref("a"), 1.0)),
         domain,
     )("x")
-    stencil2 = im.as_fieldop(
-        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-        domain,
-    )("x", 1.0)
+    stencil2 = im.op_as_fieldop("plus", domain)("x", 1.0)
 
     for i, stencil in enumerate([stencil1, stencil2]):
         testee = gtir.Program(
@@ -179,10 +176,7 @@ def test_gtir_sum2():
         declarations=[],
         body=[
             gtir.SetAt(
-                expr=im.as_fieldop(
-                    im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                    domain,
-                )("x", "y"),
+                expr=im.op_as_fieldop("plus", domain)("x", "y"),
                 domain=domain,
                 target=gtir.SymRef(id="z"),
             )
@@ -214,10 +208,7 @@ def test_gtir_sum2_sym():
         declarations=[],
         body=[
             gtir.SetAt(
-                expr=im.as_fieldop(
-                    im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                    domain,
-                )("x", "x"),
+                expr=im.op_as_fieldop("plus", domain)("x", "x"),
                 domain=domain,
                 target=gtir.SymRef(id="z"),
             )
@@ -237,15 +228,9 @@ def test_gtir_sum3():
     domain = im.call("cartesian_domain")(
         im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
     )
-    stencil1 = im.as_fieldop(
-        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-        domain,
-    )(
+    stencil1 = im.op_as_fieldop("plus", domain)(
         "x",
-        im.as_fieldop(
-            im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-            domain,
-        )("y", "w"),
+        im.op_as_fieldop("plus", domain)("y", "w"),
     )
     stencil2 = im.as_fieldop(
         im.lambda_("a", "b", "c")(im.plus(im.deref("a"), im.plus(im.deref("b"), im.deref("c")))),
@@ -304,21 +289,12 @@ def test_gtir_cond():
         declarations=[],
         body=[
             gtir.SetAt(
-                expr=im.as_fieldop(
-                    im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                    domain,
-                )(
+                expr=im.op_as_fieldop("plus", domain)(
                     "x",
                     im.call("cond")(
                         gtir.SymRef(id="pred"),
-                        im.as_fieldop(
-                            im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                            domain,
-                        )("y", "scalar"),
-                        im.as_fieldop(
-                            im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                            domain,
-                        )("w", "scalar"),
+                        im.op_as_fieldop("plus", domain)("y", "scalar"),
+                        im.op_as_fieldop("plus", domain)("w", "scalar"),
                     ),
                 ),
                 domain=domain,
@@ -358,20 +334,11 @@ def test_gtir_cond_nested():
             gtir.SetAt(
                 expr=im.call("cond")(
                     gtir.SymRef(id="pred_1"),
-                    im.as_fieldop(
-                        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                        domain,
-                    )("x", 1.0),
+                    im.op_as_fieldop("plus", domain)("x", 1.0),
                     im.call("cond")(
                         gtir.SymRef(id="pred_2"),
-                        im.as_fieldop(
-                            im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                            domain,
-                        )("x", 2.0),
-                        im.as_fieldop(
-                            im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                            domain,
-                        )("x", 3.0),
+                        im.op_as_fieldop("plus", domain)("x", 2.0),
+                        im.op_as_fieldop("plus", domain)("x", 3.0),
                     ),
                 ),
                 domain=domain,
@@ -408,18 +375,12 @@ def test_gtir_cartesian_shift_left():
         domain,
     )("x")
     # fieldview flavor of same stencil, in which a temporary field is initialized with the `DELTA` constant value
-    stencil1_fieldview = im.as_fieldop(
-        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-        domain,
-    )(
+    stencil1_fieldview = im.op_as_fieldop("plus", domain)(
         im.as_fieldop(
             im.lambda_("a")(im.deref(im.shift("IDim", OFFSET)("a"))),
             domain,
         )("x"),
-        im.as_fieldop(
-            im.lambda_()(DELTA),
-            domain,
-        )(),
+        im.as_fieldop(im.lambda_()(DELTA), domain)(),
     )
 
     # use dynamic offset retrieved from field
@@ -428,10 +389,7 @@ def test_gtir_cartesian_shift_left():
         domain,
     )("x", "x_offset")
     # fieldview flavor of same stencil
-    stencil2_fieldview = im.as_fieldop(
-        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-        domain,
-    )(
+    stencil2_fieldview = im.op_as_fieldop("plus", domain)(
         im.as_fieldop(
             im.lambda_("a", "off")(im.deref(im.shift("IDim", im.deref("off"))("a"))),
             domain,
@@ -447,19 +405,13 @@ def test_gtir_cartesian_shift_left():
         domain,
     )("x", "x_offset")
     # fieldview flavor of same stencil
-    stencil3_fieldview = im.as_fieldop(
-        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-        domain,
-    )(
+    stencil3_fieldview = im.op_as_fieldop("plus", domain)(
         im.as_fieldop(
             im.lambda_("a", "off")(im.deref(im.shift("IDim", im.deref("off"))("a"))),
             domain,
         )(
             "x",
-            im.as_fieldop(
-                im.lambda_("it")(im.plus(im.deref("it"), 0)),
-                domain,
-            )("x_offset"),
+            im.op_as_fieldop("plus", domain)("x_offset", 0),
         ),
         im.as_fieldop(im.lambda_()(DELTA), domain)(),
     )
@@ -520,10 +472,7 @@ def test_gtir_cartesian_shift_right():
         domain,
     )("x")
     # fieldview flavor of same stencil, in which a temporary field is initialized with the `DELTA` constant value
-    stencil1_fieldview = im.as_fieldop(
-        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-        domain,
-    )(
+    stencil1_fieldview = im.op_as_fieldop("plus", domain)(
         im.as_fieldop(
             im.lambda_("a")(im.deref(im.shift("IDim", -OFFSET)("a"))),
             domain,
@@ -537,10 +486,7 @@ def test_gtir_cartesian_shift_right():
         domain,
     )("x", "x_offset")
     # fieldview flavor of same stencil
-    stencil2_fieldview = im.as_fieldop(
-        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-        domain,
-    )(
+    stencil2_fieldview = im.op_as_fieldop("plus", domain)(
         im.as_fieldop(
             im.lambda_("a", "off")(im.deref(im.shift("IDim", im.deref("off"))("a"))),
             domain,
@@ -556,19 +502,13 @@ def test_gtir_cartesian_shift_right():
         domain,
     )("x", "x_offset")
     # fieldview flavor of same stencil
-    stencil3_fieldview = im.as_fieldop(
-        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-        domain,
-    )(
+    stencil3_fieldview = im.op_as_fieldop("plus", domain)(
         im.as_fieldop(
             im.lambda_("a", "off")(im.deref(im.shift("IDim", im.deref("off"))("a"))),
             domain,
         )(
             "x",
-            im.as_fieldop(
-                im.lambda_("it")(im.plus(im.deref("it"), 0)),
-                domain,
-            )("x_offset"),
+            im.op_as_fieldop("plus", domain)("x_offset", 0),
         ),
         im.as_fieldop(im.lambda_()(DELTA), domain)(),
     )
@@ -699,12 +639,12 @@ def test_gtir_connectivity_shift():
     )(
         "ev_field",
         "c2e_offset",
-        im.as_fieldop(
-            im.lambda_("it")(im.plus(im.deref("it"), 0)),
+        im.op_as_fieldop(
+            "plus",
             im.call("unstructured_domain")(
                 im.call("named_range")(gtir.AxisLiteral(value=Edge.value), 0, "nedges"),
             ),
-        )("e2v_offset"),
+        )("e2v_offset", 0),
     )
 
     CE_FTYPE = ts.FieldType(dims=[Cell, Edge], dtype=ts.ScalarType(kind=ts.ScalarKind.FLOAT64))
@@ -834,3 +774,91 @@ def test_gtir_connectivity_shift_chain():
         __edges_out_stride_0=1,
     )
     assert np.allclose(e_out, ref)
+
+
+def test_gtir_let_lambda():
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
+    )
+    testee = gtir.Program(
+        id="let_lambda",
+        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(
+                # `x1` is a let-lambda expression representing `x * 3`
+                # `x2` is a let-lambda expression representing `x * 4`
+                #  - note that the let-symbol `x2` is used twice, in a nested let-expression, to test aliasing of the symbol
+                # `x3` is a let-lambda expression simply accessing `x` field symref
+                expr=im.let("x1", im.op_as_fieldop("multiplies", domain)(3.0, "x"))(
+                    im.let(
+                        "x2",
+                        im.let("x2", im.op_as_fieldop("multiplies", domain)(2.0, "x"))(
+                            im.op_as_fieldop("plus", domain)("x2", "x2")
+                        ),
+                    )(
+                        im.let("x3", "x")(
+                            im.op_as_fieldop("plus", domain)(
+                                "x1", im.op_as_fieldop("plus", domain)("x2", "x3")
+                            )
+                        )
+                    )
+                ),
+                domain=domain,
+                target=gtir.SymRef(id="y"),
+            )
+        ],
+    )
+
+    a = np.random.rand(N)
+    b = np.empty_like(a)
+
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, {})
+
+    sdfg(x=a, y=b, **FSYMBOLS)
+    assert np.allclose(b, a * 8)
+
+
+def test_gtir_let_lambda_with_cond():
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
+    )
+    testee = gtir.Program(
+        id="let_lambda_with_cond",
+        function_definitions=[],
+        params=[
+            gtir.Sym(id="x", type=IFTYPE),
+            gtir.Sym(id="y", type=IFTYPE),
+            gtir.Sym(id="pred", type=ts.ScalarType(ts.ScalarKind.BOOL)),
+            gtir.Sym(id="size", type=SIZE_TYPE),
+        ],
+        declarations=[],
+        body=[
+            gtir.SetAt(
+                expr=im.let("x1", "x")(
+                    im.let("x2", im.op_as_fieldop("multiplies", domain)(2.0, "x"))(
+                        im.call("cond")(
+                            gtir.SymRef(id="pred"),
+                            im.as_fieldop(im.lambda_("a")(im.deref("a")), domain)("x1"),
+                            im.as_fieldop(im.lambda_("a")(im.deref("a")), domain)("x2"),
+                        )
+                    )
+                ),
+                domain=domain,
+                target=gtir.SymRef(id="y"),
+            )
+        ],
+    )
+
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, {})
+
+    a = np.random.rand(N)
+    for s in [False, True]:
+        b = np.empty_like(a)
+        sdfg(pred=np.bool_(s), x=a, y=b, **FSYMBOLS)
+        assert np.allclose(b, a if s else a * 2)