Repository: GridTools/gt4py
Pull request #1527 (2024-04-12 08:52:13)

feat[next]: GT4Py programs as DaCe SDFGConvertibles
With this fieature, a GT4Py.next Program can be placed inside a DaCe.program region, where its SDFG is automatically extracted and placed as a nested SDFG. This allows fusing multiple stencils under one SDFG -compilation unit-.

This feature is needed for implementing DaCe orchestration, e.g. in ICON4Py.

havogt: Just a general comment, I am wondering if DaCe offers alternatively the option to register the conversion functions, instead of having to make them part of the object. (E.g. like jax pytrees work) If only the current option is available I am wondering if we should go with a monkey patching approach to remove the dace import from unrelated modules. But this is for @egparedes to answer. (And we could think of the other option as a contribution to productice DaCe.)

egparedes: My first impression is that @havogt is right and the best option would be to enhance DaCe with support for registering SDFG convertible functions defined outside of the object. It would improve both the Gt4Py and DaCe codebases and would make this PR much cleaner. Another option, even simpler to implement in the DaCe side, would be to remove the requirement to inherit from `SDFGConvertible` (which is not even an ABC) and implement it as a typing `Protocol` using `runtime_checkable` or a custom typeguard for `isinstance()` checks.

If we want to merge this PR in the short term before waiting for changes in DaCe, I think it'd be better to add the required DaCe methods and bases conditionally to avoid adding DaCe as a non-optional requirement, as @edopao and @havogt suggested. For example:

```python
class Program:
    ....

if dace:
    class Program(Program, SDFGConvertible):
        def __sdfg__(self, *args, **kwargs) -> Optional[Any]:
             ....

```
havogt: Please
- add a few more details to the PR description (which will become the commit message)
- add yourself to the Authors file
kotsaloscv: > Please
> 
> * add a few more details to the PR description (which will become the commit message)
> * add yourself to the Authors file

Thank you Hannes! It's done.
egparedes: cscs-ci run
egparedes: cscs-ci run default
kotsaloscv: cscs-ci run
kotsaloscv: cscs-ci run default

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/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_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