Repository: GridTools/gt4py
Pull request #1588 (2024-07-25 07:28:54)

test[next]: Start using requires_dace marker (as cartesian tests already do)
Start using `requires_dace` marker in next tests, same as cartesian tests already do.


Patch:
diff --git a/src/gt4py/next/iterator/ir_utils/common_pattern_matcher.py b/src/gt4py/next/iterator/ir_utils/common_pattern_matcher.py
index 4933307c..e3dac7a5 100644
--- a/src/gt4py/next/iterator/ir_utils/common_pattern_matcher.py
+++ b/src/gt4py/next/iterator/ir_utils/common_pattern_matcher.py
@@ -27,6 +27,16 @@ def is_applied_lift(arg: itir.Node) -> TypeGuard[itir.FunCall]:
     )
 
 
+def is_applied_shift(arg: itir.Node) -> TypeGuard[itir.FunCall]:
+    """Match expressions of the form `shift(λ(...) → ...)(...)`."""
+    return (
+        isinstance(arg, itir.FunCall)
+        and isinstance(arg.fun, itir.FunCall)
+        and isinstance(arg.fun.fun, itir.SymRef)
+        and arg.fun.fun.id == "shift"
+    )
+
+
 def is_let(node: itir.Node) -> TypeGuard[itir.FunCall]:
     """Match expression of the form `(λ(...) → ...)(...)`."""
     return isinstance(node, itir.FunCall) and isinstance(node.fun, itir.Lambda)
diff --git a/src/gt4py/next/iterator/ir_utils/ir_makers.py b/src/gt4py/next/iterator/ir_utils/ir_makers.py
index 40bfc0ab..b1c9ec40 100644
--- a/src/gt4py/next/iterator/ir_utils/ir_makers.py
+++ b/src/gt4py/next/iterator/ir_utils/ir_makers.py
@@ -13,7 +13,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 import typing
-from typing import Callable, Iterable, Union
+from typing import Callable, Iterable, Optional, Union
 
 from gt4py._core import definitions as core_defs
 from gt4py.next.iterator import ir as itir
@@ -401,3 +401,25 @@ def promote_to_lifted_stencil(op: str | itir.SymRef | Callable) -> Callable[...,
 def map_(op):
     """Create a `map_` call."""
     return call(call("map_")(op))
+
+
+def as_fieldop(expr: itir.Expr, domain: Optional[itir.FunCall] = None) -> call:
+    """
+    Create an `as_fieldop` call.
+    Examples
+    --------
+    >>> str(as_fieldop(lambda_("it1", "it2")(plus(deref("it1"), deref("it2"))))("field1", "field2"))
+    '(⇑(λ(it1, it2) → ·it1 + ·it2))(field1, field2)'
+    """
+    return call(
+        call("as_fieldop")(
+            *(
+                (
+                    expr,
+                    domain,
+                )
+                if domain
+                else (expr,)
+            )
+        )
+    )
diff --git a/src/gt4py/next/iterator/transforms/unroll_reduce.py b/src/gt4py/next/iterator/transforms/unroll_reduce.py
index b058fc0a..47b8556c 100644
--- a/src/gt4py/next/iterator/transforms/unroll_reduce.py
+++ b/src/gt4py/next/iterator/transforms/unroll_reduce.py
@@ -23,14 +23,6 @@ from gt4py.next.iterator import ir as itir
 from gt4py.next.iterator.ir_utils.common_pattern_matcher import is_applied_lift
 
 
-def _is_shifted(arg: itir.Expr) -> TypeGuard[itir.FunCall]:
-    return (
-        isinstance(arg, itir.FunCall)
-        and isinstance(arg.fun, itir.FunCall)
-        and arg.fun.fun == itir.SymRef(id="shift")
-    )
-
-
 def _is_neighbors(arg: itir.Expr) -> TypeGuard[itir.FunCall]:
     return isinstance(arg, itir.FunCall) and arg.fun == itir.SymRef(id="neighbors")
 
diff --git a/src/gt4py/next/iterator/type_system/type_synthesizer.py b/src/gt4py/next/iterator/type_system/type_synthesizer.py
index eff6b2f4..ac580196 100644
--- a/src/gt4py/next/iterator/type_system/type_synthesizer.py
+++ b/src/gt4py/next/iterator/type_system/type_synthesizer.py
@@ -280,6 +280,17 @@ def as_fieldop(
     return applied_as_fieldop
 
 
+@_register_builtin_type_synthesizer
+def cond(
+    pred: ts.ScalarType, true_branch: ts.DataType, false_branch: ts.DataType
+) -> ts.FieldType | ts.DeferredType:
+    assert isinstance(pred, ts.ScalarType) and pred.kind == ts.ScalarKind.BOOL
+    assert true_branch == false_branch
+    assert isinstance(true_branch, (ts.FieldType, ts.DeferredType))
+
+    return true_branch
+
+
 @_register_builtin_type_synthesizer
 def scan(
     scan_pass: TypeSynthesizer, direction: ts.ScalarType, init: ts.ScalarType
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 f401f464..0ce9952a 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
@@ -24,6 +24,7 @@ import dace.subsets as sbs
 from gt4py.next import common as gtx_common
 from gt4py.next.iterator import ir as gtir
 from gt4py.next.iterator.ir_utils import common_pattern_matcher as cpm
+from gt4py.next.iterator.type_system import type_specifications as itir_ts
 from gt4py.next.program_processors.runners.dace_fieldview import (
     gtir_python_codegen,
     gtir_to_tasklet,
@@ -108,7 +109,7 @@ def _create_temporary_field(
     domain: list[
         tuple[gtx_common.Dimension, dace.symbolic.SymbolicType, dace.symbolic.SymbolicType]
     ],
-    node_type: ts.ScalarType,
+    node_type: ts.FieldType,
     output_desc: dace.data.Data,
     output_field_type: ts.DataType,
 ) -> tuple[dace.nodes.AccessNode, ts.FieldType]:
@@ -126,22 +127,25 @@ def _create_temporary_field(
     if isinstance(output_desc, dace.data.Array):
         # extend the result arrays with the local dimensions added by the field operator e.g. `neighbors`)
         assert isinstance(output_field_type, ts.FieldType)
-        # TODO: enable `assert output_field_type.dtype == node_type`, remove variable `dtype`
-        node_type = output_field_type.dtype
+        if isinstance(node_type.dtype, itir_ts.ListType):
+            raise NotImplementedError
+        else:
+            field_dtype = node_type.dtype
+        assert output_field_type.dtype == field_dtype
         field_dims.extend(output_field_type.dims)
         field_shape.extend(output_desc.shape)
     else:
         assert isinstance(output_desc, dace.data.Scalar)
         assert isinstance(output_field_type, ts.ScalarType)
-        # TODO: enable `assert output_field_type == node_type`, remove variable `dtype`
-        node_type = output_field_type
+        field_dtype = node_type.dtype
+        assert output_field_type == field_dtype
 
     # allocate local temporary storage for the result field
     temp_name, _ = sdfg.add_temp_transient(
-        field_shape, dace_fieldview_util.as_dace_type(node_type), offset=field_offset
+        field_shape, dace_fieldview_util.as_dace_type(field_dtype), offset=field_offset
     )
     field_node = state.add_access(temp_name)
-    field_type = ts.FieldType(field_dims, node_type)
+    field_type = ts.FieldType(field_dims, field_dtype)
 
     return field_node, field_type
 
@@ -165,9 +169,8 @@ def translate_as_field_op(
     assert isinstance(domain_expr, gtir.FunCall)
 
     # add local storage to compute the field operator over the given domain
-    # TODO: use type inference to determine the result type
-    node_type = ts.ScalarType(kind=ts.ScalarKind.FLOAT64)
     domain = dace_fieldview_util.get_domain(domain_expr)
+    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]
@@ -190,7 +193,7 @@ def translate_as_field_op(
 
     # allocate local temporary storage for the result field
     field_node, field_type = _create_temporary_field(
-        sdfg, state, domain, node_type, output_desc, output_expr.field_type
+        sdfg, state, domain, node.type, output_desc, output_expr.field_type
     )
 
     # assume tasklet with single output
@@ -235,13 +238,9 @@ def translate_cond(
     sdfg_builder: gtir_to_sdfg.SDFGBuilder,
 ) -> list[TemporaryData]:
     """Generates the dataflow subgraph for the `cond` builtin function."""
-    assert isinstance(node, gtir.FunCall)
-    assert cpm.is_call_to(node.fun, "cond")
-    assert len(node.args) == 0
-
-    fun_node = node.fun
-    assert len(fun_node.args) == 3
-    cond_expr, true_expr, false_expr = fun_node.args
+    assert cpm.is_call_to(node, "cond")
+    assert len(node.args) == 3
+    cond_expr, true_expr, false_expr = node.args
 
     # expect condition as first argument
     cond = gtir_python_codegen.get_source(cond_expr)
@@ -318,11 +317,11 @@ def translate_symbol_ref(
     if isinstance(node, gtir.Literal):
         sym_value = node.value
         data_type = node.type
-        tasklet_name = "get_literal"
+        temp_name = "literal"
     else:
         sym_value = str(node.id)
         data_type = sdfg_builder.get_symbol_type(sym_value)
-        tasklet_name = f"get_{sym_value}"
+        temp_name = sym_value
 
     if isinstance(data_type, ts.FieldType):
         # add access node to current state
@@ -332,13 +331,18 @@ def translate_symbol_ref(
         # scalar symbols are passed to the SDFG as symbols: build tasklet node
         # to write the symbol to a scalar access node
         tasklet_node = sdfg_builder.add_tasklet(
-            tasklet_name,
+            f"get_{temp_name}",
             state,
             {},
             {"__out"},
             f"__out = {sym_value}",
         )
-        temp_name, _ = sdfg.add_temp_transient((1,), dace_fieldview_util.as_dace_type(data_type))
+        temp_name, _ = sdfg.add_scalar(
+            f"__{temp_name}",
+            dace_fieldview_util.as_dace_type(data_type),
+            find_new_name=True,
+            transient=True,
+        )
         sym_node = state.add_access(temp_name)
         state.add_edge(
             tasklet_node,
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 7468056f..b0cf2215 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
@@ -30,11 +30,12 @@ from gt4py.eve import concepts
 from gt4py.next import common as gtx_common
 from gt4py.next.iterator import ir as gtir
 from gt4py.next.iterator.ir_utils import common_pattern_matcher as cpm
+from gt4py.next.iterator.type_system import inference as gtir_type_inference
 from gt4py.next.program_processors.runners.dace_fieldview import (
     gtir_builtin_translators,
     utility as dace_fieldview_util,
 )
-from gt4py.next.type_system import type_specifications as ts
+from gt4py.next.type_system import type_specifications as ts, type_translation as tt
 
 
 class DataflowBuilder(Protocol):
@@ -157,18 +158,23 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         strides = [dace.symbol(f"__{name}_stride_{i}", dtype) for i in range(len(dims))]
         return shape, strides
 
-    def _add_storage(self, sdfg: dace.SDFG, name: str, symbol_type: ts.DataType) -> None:
+    def _add_storage(
+        self, sdfg: dace.SDFG, name: str, symbol_type: ts.DataType, transient: bool = True
+    ) -> None:
         """
-        Add external storage (aka non-transient) for data containers passed as arguments to the SDFG.
+        Add storage for data containers used in the SDFG. For fields, it allocates dace arrays,
+        while scalars are stored as SDFG symbols.
 
-        For fields, it allocates dace arrays, while scalars are stored as SDFG symbols.
+        The fields used as temporary arrays, when `transient = True`, are allocated and exist
+        only within the SDFG; when `transient = False`, the fields have to be allocated outside
+        and have to be passed as array arguments to the SDFG.
         """
         if isinstance(symbol_type, ts.FieldType):
             dtype = dace_fieldview_util.as_dace_type(symbol_type.dtype)
             # use symbolic shape, which allows to invoke the program with fields of different size;
             # and symbolic strides, which enables decoupling the memory layout from generated code.
             sym_shape, sym_strides = self._make_array_shape_and_strides(name, symbol_type.dims)
-            sdfg.add_array(name, sym_shape, dtype, strides=sym_strides, transient=False)
+            sdfg.add_array(name, sym_shape, dtype, strides=sym_strides, transient=transient)
         elif isinstance(symbol_type, ts.ScalarType):
             dtype = dace_fieldview_util.as_dace_type(symbol_type)
             # scalar arguments passed to the program are represented as symbols in DaCe SDFG
@@ -250,7 +256,23 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         # add non-transient arrays and/or SDFG symbols for the program arguments
         for param in node.params:
             assert isinstance(param.type, ts.DataType)
-            self._add_storage(sdfg, str(param.id), param.type)
+            self._add_storage(sdfg, str(param.id), param.type, transient=False)
+
+        # add SDFG storage for connectivity tables
+        for offset, offset_provider in dace_fieldview_util.filter_connectivities(
+            self.offset_provider
+        ).items():
+            scalar_kind = tt.get_scalar_kind(offset_provider.index_type)
+            local_dim = gtx_common.Dimension(offset, kind=gtx_common.DimensionKind.LOCAL)
+            type_ = ts.FieldType(
+                [offset_provider.origin_axis, local_dim], ts.ScalarType(scalar_kind)
+            )
+            # We store all connectivity tables as transient arrays here; later, while building
+            # the field operator expressions, we change to non-transient (i.e. allocated extrenally)
+            # the tables that are actually used. This way, we avoid adding SDFG arguments for
+            # the connectivity tables that are not used. The remaining unused transient arrays
+            # are removed by the dace simplify pass.
+            self._add_storage(sdfg, dace_fieldview_util.connectivity_identifier(offset), type_)
 
         # visit one statement at a time and expand the SDFG from the current head state
         for i, stmt in enumerate(node.body):
@@ -304,10 +326,10 @@ class GTIRToSDFG(eve.NodeVisitor, SDFGBuilder):
         head_state: dace.SDFGState,
     ) -> list[gtir_builtin_translators.TemporaryData]:
         # use specialized dataflow builder classes for each builtin function
-        if cpm.is_call_to(node.fun, "as_fieldop"):
-            return gtir_builtin_translators.translate_as_field_op(node, sdfg, head_state, self)
-        elif cpm.is_call_to(node.fun, "cond"):
+        if cpm.is_call_to(node, "cond"):
             return gtir_builtin_translators.translate_cond(node, sdfg, head_state, self)
+        elif cpm.is_call_to(node.fun, "as_fieldop"):
+            return gtir_builtin_translators.translate_as_field_op(node, sdfg, head_state, self)
         else:
             raise NotImplementedError(f"Unexpected 'FunCall' expression ({node}).")
 
@@ -354,8 +376,8 @@ def build_sdfg_from_gtir(
     Returns:
         An SDFG in the DaCe canonical form (simplified)
     """
+    program = gtir_type_inference.infer(program, offset_provider=offset_provider)
     sdfg_genenerator = GTIRToSDFG(offset_provider)
-    # TODO: run type inference on the `program` node before passing it to `GTIRToSDFG`
     sdfg = sdfg_genenerator.visit(program)
     assert isinstance(sdfg, dace.SDFG)
 
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 485db254..f41f7cd5 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
@@ -16,7 +16,7 @@
 from __future__ import annotations
 
 import dataclasses
-from typing import Any, Dict, List, Optional, Set, Tuple, TypeAlias, Union
+from typing import Any, Dict, Final, List, Optional, Set, Tuple, TypeAlias, Union
 
 import dace
 import dace.subsets as sbs
@@ -112,6 +112,17 @@ class LambdaToTasklet(eve.NodeVisitor):
     ) -> None:
         self.input_connections.append((src, src_subset, dst_node, dst_conn))
 
+    def _add_edge(
+        self,
+        src_node: dace.Node,
+        src_node_connector: Optional[str],
+        dst_node: dace.Node,
+        dst_node_connector: Optional[str],
+        memlet: dace.Memlet,
+    ) -> None:
+        """Helper method to add an edge in current state."""
+        self.state.add_edge(src_node, src_node_connector, dst_node, dst_node_connector, memlet)
+
     def _add_map(
         self,
         name: str,
@@ -121,7 +132,7 @@ class LambdaToTasklet(eve.NodeVisitor):
         ],
         **kwargs: Any,
     ) -> Tuple[dace.nodes.MapEntry, dace.nodes.MapExit]:
-        """Helper method to add a map with unique ame in current state."""
+        """Helper method to add a map with unique name in current state."""
         return self.subgraph_builder.add_map(name, self.state, ndrange, **kwargs)
 
     def _add_tasklet(
@@ -132,7 +143,7 @@ class LambdaToTasklet(eve.NodeVisitor):
         code: str,
         **kwargs: Any,
     ) -> dace.nodes.Tasklet:
-        """Helper method to add a tasklet with unique ame in current state."""
+        """Helper method to add a tasklet with unique name in current state."""
         return self.subgraph_builder.add_tasklet(name, self.state, inputs, outputs, code, **kwargs)
 
     def _get_tasklet_result(
@@ -145,7 +156,7 @@ class LambdaToTasklet(eve.NodeVisitor):
         self.sdfg.add_scalar(temp_name, dtype, transient=True)
         data_type = dace_fieldview_util.as_scalar_type(str(dtype.as_numpy_dtype()))
         temp_node = self.state.add_access(temp_name)
-        self.state.add_edge(
+        self._add_edge(
             src_node,
             src_connector,
             temp_node,
@@ -155,6 +166,23 @@ class LambdaToTasklet(eve.NodeVisitor):
         return ValueExpr(temp_node, data_type)
 
     def _visit_deref(self, node: gtir.FunCall) -> MemletExpr | ValueExpr:
+        """
+        Visit a `deref` node, which represents dereferencing of an iterator.
+        The iterator is the argument of this node.
+
+        The iterator contains the information for accessing a field, that is the
+        sorted list of dimensions in the field domain and the index values for
+        each dimension. The index values can be either symbol values, that is
+        literal values or scalar arguments which are constant in the SDFG scope;
+        or they can be the result of some expression, that computes a dynamic
+        index offset or gets an neighbor index from a connectivity table.
+        In case all indexes are symbol values, the `deref` node is lowered to a
+        memlet; otherwise dereferencing is a runtime operation represented in
+        the SDFG as a tasklet node.
+        """
+        # format used for field index tasklet connector
+        IndexConnectorFmt: Final = "__index_{dim}"
+
         assert len(node.args) == 1
         it = self.visit(node.args[0])
 
@@ -167,16 +195,286 @@ class LambdaToTasklet(eve.NodeVisitor):
                 return MemletExpr(it.field, field_subset)
 
             else:
-                raise NotImplementedError
+                # we use a tasklet to dereference an iterator when one or more indices are the result of some computation,
+                # either indirection through connectivity table or dynamic cartesian offset.
+                assert all(dim in it.indices for dim in it.dimensions)
+                field_indices = [(dim, it.indices[dim]) for dim in it.dimensions]
+                index_connectors = [
+                    IndexConnectorFmt.format(dim=dim.value)
+                    for dim, index in field_indices
+                    if not isinstance(index, SymbolExpr)
+                ]
+                # here `internals` refer to the names used as index in the tasklet code string:
+                # an index can be either a connector name (for dynamic/indirect indices)
+                # or a symbol value (for literal values and scalar arguments).
+                index_internals = ",".join(
+                    str(index.value)
+                    if isinstance(index, SymbolExpr)
+                    else IndexConnectorFmt.format(dim=dim.value)
+                    for dim, index in field_indices
+                )
+                deref_node = self._add_tasklet(
+                    "runtime_deref",
+                    {"field"} | set(index_connectors),
+                    {"val"},
+                    code=f"val = field[{index_internals}]",
+                )
+                # add new termination point for the field parameter
+                self._add_entry_memlet_path(
+                    it.field,
+                    sbs.Range.from_array(field_desc),
+                    deref_node,
+                    "field",
+                )
+
+                for dim, index_expr in field_indices:
+                    # add termination points for the dynamic iterator indices
+                    deref_connector = IndexConnectorFmt.format(dim=dim.value)
+                    if isinstance(index_expr, MemletExpr):
+                        self._add_entry_memlet_path(
+                            index_expr.node,
+                            index_expr.subset,
+                            deref_node,
+                            deref_connector,
+                        )
+
+                    elif isinstance(index_expr, ValueExpr):
+                        self._add_edge(
+                            index_expr.node,
+                            None,
+                            deref_node,
+                            deref_connector,
+                            dace.Memlet(data=index_expr.node.data, subset="0"),
+                        )
+                    else:
+                        assert isinstance(index_expr, SymbolExpr)
+
+                dtype = it.field.desc(self.sdfg).dtype
+                return self._get_tasklet_result(dtype, deref_node, "val")
 
         else:
             assert isinstance(it, MemletExpr)
             return it
 
+    def _split_shift_args(
+        self, args: list[gtir.Expr]
+    ) -> tuple[tuple[gtir.Expr, gtir.Expr], Optional[list[gtir.Expr]]]:
+        """
+        Splits the arguments to `shift` builtin function as pairs, each pair containing
+        the offset provider and the offset expression in one dimension.
+        """
+        nargs = len(args)
+        assert nargs >= 2 and nargs % 2 == 0
+        return (args[-2], args[-1]), args[: nargs - 2] if nargs > 2 else None
+
+    def _visit_shift_multidim(
+        self, iterator: gtir.Expr, shift_args: list[gtir.Expr]
+    ) -> tuple[gtir.Expr, gtir.Expr, IteratorExpr]:
+        """Transforms a multi-dimensional shift into recursive shift calls, each in a single dimension."""
+        (offset_provider_arg, offset_value_arg), tail = self._split_shift_args(shift_args)
+        if tail:
+            node = gtir.FunCall(
+                fun=gtir.FunCall(fun=gtir.SymRef(id="shift"), args=tail),
+                args=[iterator],
+            )
+            it = self.visit(node)
+        else:
+            it = self.visit(iterator)
+
+        assert isinstance(it, IteratorExpr)
+        return offset_provider_arg, offset_value_arg, it
+
+    def _make_cartesian_shift(
+        self, it: IteratorExpr, offset_dim: gtx_common.Dimension, offset_expr: IteratorIndexExpr
+    ) -> IteratorExpr:
+        """Implements cartesian shift along one dimension."""
+        assert offset_dim in it.dimensions
+        new_index: SymbolExpr | ValueExpr
+        assert offset_dim in it.indices
+        index_expr = it.indices[offset_dim]
+        if isinstance(index_expr, SymbolExpr) and isinstance(offset_expr, SymbolExpr):
+            # purely symbolic expression which can be interpreted at compile time
+            new_index = SymbolExpr(
+                dace.symbolic.SymExpr(index_expr.value) + offset_expr.value, index_expr.dtype
+            )
+        else:
+            # the offset needs to be calculated by means of a tasklet (i.e. dynamic offset)
+            new_index_connector = "shifted_index"
+            if isinstance(index_expr, SymbolExpr):
+                dynamic_offset_tasklet = self._add_tasklet(
+                    "dynamic_offset",
+                    {"offset"},
+                    {new_index_connector},
+                    f"{new_index_connector} = {index_expr.value} + offset",
+                )
+            elif isinstance(offset_expr, SymbolExpr):
+                dynamic_offset_tasklet = self._add_tasklet(
+                    "dynamic_offset",
+                    {"index"},
+                    {new_index_connector},
+                    f"{new_index_connector} = index + {offset_expr}",
+                )
+            else:
+                dynamic_offset_tasklet = self._add_tasklet(
+                    "dynamic_offset",
+                    {"index", "offset"},
+                    {new_index_connector},
+                    f"{new_index_connector} = index + offset",
+                )
+            for input_expr, input_connector in [(index_expr, "index"), (offset_expr, "offset")]:
+                if isinstance(input_expr, MemletExpr):
+                    self._add_entry_memlet_path(
+                        input_expr.node,
+                        input_expr.subset,
+                        dynamic_offset_tasklet,
+                        input_connector,
+                    )
+                elif isinstance(input_expr, ValueExpr):
+                    self._add_edge(
+                        input_expr.node,
+                        None,
+                        dynamic_offset_tasklet,
+                        input_connector,
+                        dace.Memlet(data=input_expr.node.data, subset="0"),
+                    )
+
+            if isinstance(index_expr, SymbolExpr):
+                dtype = index_expr.dtype
+            else:
+                dtype = index_expr.node.desc(self.sdfg).dtype
+
+            new_index = self._get_tasklet_result(dtype, dynamic_offset_tasklet, new_index_connector)
+
+        # a new iterator with a shifted index along one dimension
+        return IteratorExpr(
+            it.field,
+            it.dimensions,
+            {dim: (new_index if dim == offset_dim else index) for dim, index in it.indices.items()},
+        )
+
+    def _make_dynamic_neighbor_offset(
+        self,
+        offset_expr: MemletExpr | ValueExpr,
+        offset_table_node: dace.nodes.AccessNode,
+        origin_index: SymbolExpr,
+    ) -> ValueExpr:
+        """
+        Implements access to neighbor connectivity table by means of a tasklet node.
+
+        It requires a dynamic offset value, either obtained from a field/scalar argument (`MemletExpr`)
+        or computed by another tasklet (`ValueExpr`).
+        """
+        new_index_connector = "neighbor_index"
+        tasklet_node = self._add_tasklet(
+            "dynamic_neighbor_offset",
+            {"table", "offset"},
+            {new_index_connector},
+            f"{new_index_connector} = table[{origin_index.value}, offset]",
+        )
+        self._add_entry_memlet_path(
+            offset_table_node,
+            sbs.Range.from_array(offset_table_node.desc(self.sdfg)),
+            tasklet_node,
+            "table",
+        )
+        if isinstance(offset_expr, MemletExpr):
+            self._add_entry_memlet_path(
+                offset_expr.node,
+                offset_expr.subset,
+                tasklet_node,
+                "offset",
+            )
+        else:
+            self._add_edge(
+                offset_expr.node,
+                None,
+                tasklet_node,
+                "offset",
+                dace.Memlet(data=offset_expr.node.data, subset="0"),
+            )
+
+        dtype = offset_table_node.desc(self.sdfg).dtype
+        return self._get_tasklet_result(dtype, tasklet_node, new_index_connector)
+
+    def _make_unstructured_shift(
+        self,
+        it: IteratorExpr,
+        connectivity: gtx_common.Connectivity,
+        offset_table_node: dace.nodes.AccessNode,
+        offset_expr: IteratorIndexExpr,
+    ) -> IteratorExpr:
+        """Implements shift in unstructured domain by means of a neighbor table."""
+        assert connectivity.neighbor_axis in it.dimensions
+        neighbor_dim = connectivity.neighbor_axis
+        assert neighbor_dim not in it.indices
+
+        origin_dim = connectivity.origin_axis
+        assert origin_dim in it.indices
+        origin_index = it.indices[origin_dim]
+        assert isinstance(origin_index, SymbolExpr)
+
+        shifted_indices = {dim: idx for dim, idx in it.indices.items() if dim != origin_dim}
+        if isinstance(offset_expr, SymbolExpr):
+            # use memlet to retrieve the neighbor index
+            shifted_indices[neighbor_dim] = MemletExpr(
+                offset_table_node,
+                sbs.Indices([origin_index.value, offset_expr.value]),
+            )
+        else:
+            # dynamic offset: we cannot use a memlet to retrieve the offset value, use a tasklet node
+            shifted_indices[neighbor_dim] = self._make_dynamic_neighbor_offset(
+                offset_expr, offset_table_node, origin_index
+            )
+
+        return IteratorExpr(it.field, it.dimensions, shifted_indices)
+
+    def _visit_shift(self, node: gtir.FunCall) -> IteratorExpr:
+        # convert builtin-index type to dace type
+        IndexDType: Final = dace_fieldview_util.as_dace_type(
+            ts.ScalarType(kind=getattr(ts.ScalarKind, gtir.INTEGER_INDEX_BUILTIN.upper()))
+        )
+
+        assert isinstance(node.fun, gtir.FunCall)
+        # the iterator to be shifted is the node argument, while the shift arguments
+        # are provided by the nested function call; the shift arguments consist of
+        # the offset provider and the offset value in each dimension to be shifted
+        offset_provider_arg, offset_value_arg, it = self._visit_shift_multidim(
+            node.args[0], node.fun.args
+        )
+
+        # first argument of the shift node is the offset provider
+        assert isinstance(offset_provider_arg, gtir.OffsetLiteral)
+        offset = offset_provider_arg.value
+        assert isinstance(offset, str)
+        offset_provider = self.subgraph_builder.get_offset_provider(offset)
+        # second argument should be the offset value, which could be a symbolic expression or a dynamic offset
+        offset_expr = (
+            SymbolExpr(offset_value_arg.value, IndexDType)
+            if isinstance(offset_value_arg, gtir.OffsetLiteral)
+            else self.visit(offset_value_arg)
+        )
+
+        if isinstance(offset_provider, gtx_common.Dimension):
+            return self._make_cartesian_shift(it, offset_provider, offset_expr)
+        else:
+            # initially, the storage for the connectivity tables is created as transient;
+            # when the tables are used, the storage is changed to non-transient,
+            # so the corresponding arrays are supposed to be allocated by the SDFG caller
+            offset_table = dace_fieldview_util.connectivity_identifier(offset)
+            self.sdfg.arrays[offset_table].transient = False
+            offset_table_node = self.state.add_access(offset_table)
+
+            return self._make_unstructured_shift(
+                it, offset_provider, offset_table_node, offset_expr
+            )
+
     def visit_FunCall(self, node: gtir.FunCall) -> IteratorExpr | MemletExpr | ValueExpr:
         if cpm.is_call_to(node, "deref"):
             return self._visit_deref(node)
 
+        elif cpm.is_applied_shift(node):
+            return self._visit_shift(node)
+
         else:
             assert isinstance(node.fun, gtir.SymRef)
 
@@ -208,7 +506,7 @@ class LambdaToTasklet(eve.NodeVisitor):
 
         for connector, arg_expr in node_connections.items():
             if isinstance(arg_expr, ValueExpr):
-                self.state.add_edge(
+                self._add_edge(
                     arg_expr.node,
                     None,
                     tasklet_node,
@@ -223,18 +521,8 @@ class LambdaToTasklet(eve.NodeVisitor):
                     connector,
                 )
 
-        # TODO: use type inference to determine the result type
-        if len(node_connections) == 1:
-            dtype = None
-            for conn_name in ["__inp_0", "__inp_1"]:
-                if conn_name in node_connections:
-                    dtype = node_connections[conn_name].node.desc(self.sdfg).dtype
-                    break
-            if dtype is None:
-                raise ValueError("Failed to determine the type")
-        else:
-            node_type = ts.ScalarType(kind=ts.ScalarKind.FLOAT64)
-            dtype = dace_fieldview_util.as_dace_type(node_type)
+        assert node.type
+        dtype = dace_fieldview_util.as_dace_type(node.type)
 
         return self._get_tasklet_result(dtype, tasklet_node, "result")
 
diff --git a/src/gt4py/next/program_processors/runners/dace_fieldview/utility.py b/src/gt4py/next/program_processors/runners/dace_fieldview/utility.py
index 34e95506..8c419c9d 100644
--- a/src/gt4py/next/program_processors/runners/dace_fieldview/utility.py
+++ b/src/gt4py/next/program_processors/runners/dace_fieldview/utility.py
@@ -25,8 +25,9 @@ from gt4py.next.program_processors.runners.dace_fieldview import gtir_python_cod
 from gt4py.next.type_system import type_specifications as ts
 
 
-def as_dace_type(type_: ts.ScalarType) -> dace.typeclass:
+def as_dace_type(type_: ts.TypeSpec) -> dace.typeclass:
     """Converts GT4Py scalar type to corresponding DaCe type."""
+    assert isinstance(type_, ts.ScalarType)
     match type_.kind:
         case ts.ScalarKind.BOOL:
             return dace.bool_
@@ -51,6 +52,10 @@ def as_scalar_type(typestr: str) -> ts.ScalarType:
     return ts.ScalarType(kind)
 
 
+def connectivity_identifier(name: str) -> str:
+    return f"connectivity_{name}"
+
+
 def debug_info(
     node: gtir.Node, *, default: Optional[dace.dtypes.DebugInfo] = None
 ) -> Optional[dace.dtypes.DebugInfo]:


Test patch:
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/iterator_tests/test_type_inference.py b/tests/next_tests/unit_tests/iterator_tests/test_type_inference.py
index 6a57f777..7b5d3e6a 100644
--- a/tests/next_tests/unit_tests/iterator_tests/test_type_inference.py
+++ b/tests/next_tests/unit_tests/iterator_tests/test_type_inference.py
@@ -176,6 +176,29 @@ def expression_test_cases():
             )(im.ref("inp1", float_i_field), im.ref("inp2", float_i_field)),
             ts.TupleType(types=[float_i_field, float_i_field]),
         ),
+        # cond
+        (
+            im.call("cond")(
+                False,
+                im.call(
+                    im.call("as_fieldop")(
+                        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
+                        im.call("cartesian_domain")(
+                            im.call("named_range")(itir.AxisLiteral(value="IDim"), 0, 1)
+                        ),
+                    )
+                )(im.ref("inp", float_i_field), 1.0),
+                im.call(
+                    im.call("as_fieldop")(
+                        "deref",
+                        im.call("cartesian_domain")(
+                            im.call("named_range")(itir.AxisLiteral(value="IDim"), 0, 1)
+                        ),
+                    )
+                )(im.ref("inp", float_i_field)),
+            ),
+            float_i_field,
+        ),
     )
 
 
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_collapse_tuple.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_collapse_tuple.py
index 6fd876b6..924de176 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_collapse_tuple.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_collapse_tuple.py
@@ -165,10 +165,10 @@ def test_inline_trivial_make_tuple():
 
 def test_propagate_to_if_on_tuples():
     testee = im.tuple_get(
-        0, im.if_(im.ref("cond", "bool"), im.make_tuple(1, 2), im.make_tuple(3, 4))
+        0, im.if_(im.ref("pred", "bool"), im.make_tuple(1, 2), im.make_tuple(3, 4))
     )
     expected = im.if_(
-        im.ref("cond", "bool"),
+        im.ref("pred", "bool"),
         im.tuple_get(0, im.make_tuple(1, 2)),
         im.tuple_get(0, im.make_tuple(3, 4)),
     )
@@ -183,10 +183,10 @@ def test_propagate_to_if_on_tuples():
 
 def test_propagate_to_if_on_tuples_with_let():
     testee = im.let(
-        "val", im.if_(im.ref("cond", "bool"), im.make_tuple(1, 2), im.make_tuple(3, 4))
+        "val", im.if_(im.ref("pred", "bool"), im.make_tuple(1, 2), im.make_tuple(3, 4))
     )(im.tuple_get(0, "val"))
     expected = im.if_(
-        im.ref("cond"), im.tuple_get(0, im.make_tuple(1, 2)), im.tuple_get(0, im.make_tuple(3, 4))
+        im.ref("pred"), im.tuple_get(0, im.make_tuple(1, 2)), im.tuple_get(0, im.make_tuple(3, 4))
     )
     actual = CollapseTuple.apply(
         testee,
@@ -212,9 +212,9 @@ def test_propagate_nested_lift():
 
 def test_if_on_tuples_with_let():
     testee = im.let(
-        "val", im.if_(im.ref("cond", "bool"), im.make_tuple(1, 2), im.make_tuple(3, 4))
+        "val", im.if_(im.ref("pred", "bool"), im.make_tuple(1, 2), im.make_tuple(3, 4))
     )(im.tuple_get(0, "val"))
-    expected = im.if_("cond", 1, 3)
+    expected = im.if_("pred", 1, 3)
     actual = CollapseTuple.apply(
         testee, remove_letified_make_tuple_elements=False, allow_undeclared_symbols=True
     )
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 d674e71f..e0d82033 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
@@ -23,16 +23,32 @@ from gt4py.next.iterator import ir as gtir
 from gt4py.next.iterator.ir_utils import ir_makers as im
 from gt4py.next.program_processors.runners import dace_fieldview as dace_backend
 from gt4py.next.type_system import type_specifications as ts
-from next_tests.integration_tests.feature_tests.ffront_tests.ffront_test_utils import IDim
-
+from next_tests.integration_tests.feature_tests.ffront_tests.ffront_test_utils import (
+    Cell,
+    Edge,
+    IDim,
+    MeshDescriptor,
+    Vertex,
+    simple_mesh,
+)
 import numpy as np
 import pytest
 
-dace = pytest.importorskip("dace")
+pytestmark = pytest.mark.requires_dace
 
 
 N = 10
 IFTYPE = ts.FieldType(dims=[IDim], dtype=ts.ScalarType(kind=ts.ScalarKind.FLOAT64))
+CFTYPE = ts.FieldType(dims=[Cell], dtype=ts.ScalarType(kind=ts.ScalarKind.FLOAT64))
+EFTYPE = ts.FieldType(dims=[Edge], dtype=ts.ScalarType(kind=ts.ScalarKind.FLOAT64))
+VFTYPE = ts.FieldType(dims=[Vertex], dtype=ts.ScalarType(kind=ts.ScalarKind.FLOAT64))
+CARTESIAN_OFFSETS = {
+    "IDim": IDim,
+}
+SIMPLE_MESH: MeshDescriptor = simple_mesh()
+SIMPLE_MESH_OFFSET_PROVIDER: dict[str, gtx_common.Connectivity | gtx_common.Dimension] = (
+    SIMPLE_MESH.offset_provider | CARTESIAN_OFFSETS
+)
 SIZE_TYPE = ts.ScalarType(ts.ScalarKind.INT32)
 FSYMBOLS = dict(
     __w_size_0=N,
@@ -47,6 +63,36 @@ FSYMBOLS = dict(
 )
 
 
+def make_mesh_symbols(mesh: MeshDescriptor):
+    return dict(
+        ncells=mesh.num_cells,
+        nedges=mesh.num_edges,
+        nvertices=mesh.num_vertices,
+        __cells_size_0=mesh.num_cells,
+        __cells_stride_0=1,
+        __edges_size_0=mesh.num_edges,
+        __edges_stride_0=1,
+        __vertices_size_0=mesh.num_vertices,
+        __vertices_stride_0=1,
+        __connectivity_C2E_size_0=mesh.num_cells,
+        __connectivity_C2E_size_1=mesh.offset_provider["C2E"].max_neighbors,
+        __connectivity_C2E_stride_0=mesh.offset_provider["C2E"].max_neighbors,
+        __connectivity_C2E_stride_1=1,
+        __connectivity_C2V_size_0=mesh.num_cells,
+        __connectivity_C2V_size_1=mesh.offset_provider["C2V"].max_neighbors,
+        __connectivity_C2V_stride_0=mesh.offset_provider["C2V"].max_neighbors,
+        __connectivity_C2V_stride_1=1,
+        __connectivity_E2V_size_0=mesh.num_edges,
+        __connectivity_E2V_size_1=mesh.offset_provider["E2V"].max_neighbors,
+        __connectivity_E2V_stride_0=mesh.offset_provider["E2V"].max_neighbors,
+        __connectivity_E2V_stride_1=1,
+        __connectivity_V2E_size_0=mesh.num_vertices,
+        __connectivity_V2E_size_1=mesh.offset_provider["V2E"].max_neighbors,
+        __connectivity_V2E_stride_0=mesh.offset_provider["V2E"].max_neighbors,
+        __connectivity_V2E_stride_1=1,
+    )
+
+
 def test_gtir_copy():
     domain = im.call("cartesian_domain")(
         im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
@@ -62,12 +108,7 @@ def test_gtir_copy():
         declarations=[],
         body=[
             gtir.SetAt(
-                expr=im.call(
-                    im.call("as_fieldop")(
-                        im.lambda_("a")(im.deref("a")),
-                        domain,
-                    )
-                )("x"),
+                expr=im.as_fieldop(im.lambda_("a")(im.deref("a")), domain)("x"),
                 domain=domain,
                 target=gtir.SymRef(id="y"),
             )
@@ -77,7 +118,7 @@ def test_gtir_copy():
     a = np.random.rand(N)
     b = np.empty_like(a)
 
-    sdfg = dace_backend.build_sdfg_from_gtir(testee, {})
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
 
     sdfg(x=a, y=b, **FSYMBOLS)
     assert np.allclose(a, b)
@@ -87,17 +128,13 @@ def test_gtir_update():
     domain = im.call("cartesian_domain")(
         im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
     )
-    stencil1 = im.call(
-        im.call("as_fieldop")(
-            im.lambda_("a")(im.plus(im.deref("a"), 1.0)),
-            domain,
-        )
+    stencil1 = im.as_fieldop(
+        im.lambda_("a")(im.plus(im.deref("a"), 1.0)),
+        domain,
     )("x")
-    stencil2 = im.call(
-        im.call("as_fieldop")(
-            im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-            domain,
-        )
+    stencil2 = im.as_fieldop(
+        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
+        domain,
     )("x", 1.0)
 
     for i, stencil in enumerate([stencil1, stencil2]):
@@ -142,11 +179,9 @@ def test_gtir_sum2():
         declarations=[],
         body=[
             gtir.SetAt(
-                expr=im.call(
-                    im.call("as_fieldop")(
-                        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                        domain,
-                    )
+                expr=im.as_fieldop(
+                    im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
+                    domain,
                 )("x", "y"),
                 domain=domain,
                 target=gtir.SymRef(id="z"),
@@ -158,7 +193,7 @@ def test_gtir_sum2():
     b = np.random.rand(N)
     c = np.empty_like(a)
 
-    sdfg = dace_backend.build_sdfg_from_gtir(testee, {})
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
 
     sdfg(x=a, y=b, z=c, **FSYMBOLS)
     assert np.allclose(c, (a + b))
@@ -179,11 +214,9 @@ def test_gtir_sum2_sym():
         declarations=[],
         body=[
             gtir.SetAt(
-                expr=im.call(
-                    im.call("as_fieldop")(
-                        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                        domain,
-                    )
+                expr=im.as_fieldop(
+                    im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
+                    domain,
                 )("x", "x"),
                 domain=domain,
                 target=gtir.SymRef(id="z"),
@@ -204,27 +237,19 @@ def test_gtir_sum3():
     domain = im.call("cartesian_domain")(
         im.call("named_range")(gtir.AxisLiteral(value=IDim.value), 0, "size")
     )
-    stencil1 = im.call(
-        im.call("as_fieldop")(
-            im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-            domain,
-        )
+    stencil1 = im.as_fieldop(
+        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
+        domain,
     )(
         "x",
-        im.call(
-            im.call("as_fieldop")(
-                im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                domain,
-            )
+        im.as_fieldop(
+            im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
+            domain,
         )("y", "w"),
     )
-    stencil2 = im.call(
-        im.call("as_fieldop")(
-            im.lambda_("a", "b", "c")(
-                im.plus(im.deref("a"), im.plus(im.deref("b"), im.deref("c")))
-            ),
-            domain,
-        )
+    stencil2 = im.as_fieldop(
+        im.lambda_("a", "b", "c")(im.plus(im.deref("a"), im.plus(im.deref("b"), im.deref("c")))),
+        domain,
     )("x", "y", "w")
 
     a = np.random.rand(N)
@@ -279,30 +304,22 @@ def test_gtir_cond():
         declarations=[],
         body=[
             gtir.SetAt(
-                expr=im.call(
-                    im.call("as_fieldop")(
-                        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                        domain,
-                    )
+                expr=im.as_fieldop(
+                    im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
+                    domain,
                 )(
                     "x",
-                    im.call(
-                        im.call("cond")(
-                            im.deref("pred"),
-                            im.call(
-                                im.call("as_fieldop")(
-                                    im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                                    domain,
-                                )
-                            )("y", "scalar"),
-                            im.call(
-                                im.call("as_fieldop")(
-                                    im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                                    domain,
-                                )
-                            )("w", "scalar"),
-                        )
-                    )(),
+                    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"),
+                    ),
                 ),
                 domain=domain,
                 target=gtir.SymRef(id="z"),
@@ -314,7 +331,7 @@ def test_gtir_cond():
     b = np.random.rand(N)
     c = np.random.rand(N)
 
-    sdfg = dace_backend.build_sdfg_from_gtir(testee, {})
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
 
     for s in [False, True]:
         d = np.empty_like(a)
@@ -339,34 +356,24 @@ def test_gtir_cond_nested():
         declarations=[],
         body=[
             gtir.SetAt(
-                expr=im.call(
+                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.call("cond")(
-                        im.deref("pred_1"),
-                        im.call(
-                            im.call("as_fieldop")(
-                                im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                                domain,
-                            )
-                        )("x", 1),
-                        im.call(
-                            im.call("cond")(
-                                im.deref("pred_2"),
-                                im.call(
-                                    im.call("as_fieldop")(
-                                        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                                        domain,
-                                    )
-                                )("x", 2),
-                                im.call(
-                                    im.call("as_fieldop")(
-                                        im.lambda_("a", "b")(im.plus(im.deref("a"), im.deref("b"))),
-                                        domain,
-                                    )
-                                )("x", 3),
-                            )
-                        )(),
-                    )
-                )(),
+                        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),
+                    ),
+                ),
                 domain=domain,
                 target=gtir.SymRef(id="z"),
             )
@@ -382,3 +389,448 @@ def test_gtir_cond_nested():
             b = np.empty_like(a)
             sdfg(pred_1=np.bool_(s1), pred_2=np.bool_(s2), x=a, z=b, **FSYMBOLS)
             assert np.allclose(b, (a + 1.0) if s1 else (a + 2.0) if s2 else (a + 3.0))
+
+
+def test_gtir_cartesian_shift_left():
+    DELTA = 3.0
+    OFFSET = 1
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(
+            gtir.AxisLiteral(value=IDim.value),
+            0,
+            im.minus(gtir.SymRef(id="size"), gtir.Literal(value=str(OFFSET), type=SIZE_TYPE)),
+        )
+    )
+
+    # cartesian shift with literal integer offset
+    stencil1_inlined = im.as_fieldop(
+        im.lambda_("a")(im.plus(im.deref(im.shift("IDim", OFFSET)("a")), DELTA)),
+        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,
+    )(
+        im.as_fieldop(
+            im.lambda_("a")(im.deref(im.shift("IDim", OFFSET)("a"))),
+            domain,
+        )("x"),
+        im.as_fieldop(
+            im.lambda_()(DELTA),
+            domain,
+        )(),
+    )
+
+    # use dynamic offset retrieved from field
+    stencil2_inlined = im.as_fieldop(
+        im.lambda_("a", "off")(im.plus(im.deref(im.shift("IDim", im.deref("off"))("a")), DELTA)),
+        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,
+    )(
+        im.as_fieldop(
+            im.lambda_("a", "off")(im.deref(im.shift("IDim", im.deref("off"))("a"))),
+            domain,
+        )("x", "x_offset"),
+        im.as_fieldop(im.lambda_()(DELTA), domain)(),
+    )
+
+    # use the result of an arithmetic field operation as dynamic offset
+    stencil3_inlined = im.as_fieldop(
+        im.lambda_("a", "off")(
+            im.plus(im.deref(im.shift("IDim", im.plus(im.deref("off"), 0))("a")), DELTA)
+        ),
+        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,
+    )(
+        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.as_fieldop(im.lambda_()(DELTA), domain)(),
+    )
+
+    a = np.random.rand(N)
+    a_offset = np.full(N, OFFSET, dtype=np.int32)
+    b = np.empty(N)
+
+    IOFFSET_FTYPE = ts.FieldType(dims=[IDim], dtype=ts.ScalarType(kind=ts.ScalarKind.INT32))
+
+    for i, stencil in enumerate(
+        [
+            stencil1_inlined,
+            stencil1_fieldview,
+            stencil2_inlined,
+            stencil2_fieldview,
+            stencil3_inlined,
+            stencil3_fieldview,
+        ]
+    ):
+        testee = gtir.Program(
+            id=f"cartesian_shift_left_{i}",
+            function_definitions=[],
+            params=[
+                gtir.Sym(id="x", type=IFTYPE),
+                gtir.Sym(id="x_offset", type=IOFFSET_FTYPE),
+                gtir.Sym(id="y", type=IFTYPE),
+                gtir.Sym(id="size", type=SIZE_TYPE),
+            ],
+            declarations=[],
+            body=[
+                gtir.SetAt(
+                    expr=stencil,
+                    domain=domain,
+                    target=gtir.SymRef(id="y"),
+                )
+            ],
+        )
+
+        sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
+
+        FSYMBOLS_tmp = FSYMBOLS.copy()
+        FSYMBOLS_tmp["__x_offset_stride_0"] = 1
+        sdfg(x=a, x_offset=a_offset, y=b, **FSYMBOLS_tmp)
+        assert np.allclose(a[OFFSET:] + DELTA, b[:-OFFSET])
+
+
+def test_gtir_cartesian_shift_right():
+    DELTA = 3.0
+    OFFSET = 1
+    domain = im.call("cartesian_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=IDim.value), OFFSET, "size")
+    )
+
+    # cartesian shift with literal integer offset
+    stencil1_inlined = im.as_fieldop(
+        im.lambda_("a")(im.plus(im.deref(im.shift("IDim", -OFFSET)("a")), DELTA)),
+        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,
+    )(
+        im.as_fieldop(
+            im.lambda_("a")(im.deref(im.shift("IDim", -OFFSET)("a"))),
+            domain,
+        )("x"),
+        im.as_fieldop(im.lambda_()(DELTA), domain)(),
+    )
+
+    # use dynamic offset retrieved from field
+    stencil2_inlined = im.as_fieldop(
+        im.lambda_("a", "off")(im.plus(im.deref(im.shift("IDim", im.deref("off"))("a")), DELTA)),
+        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,
+    )(
+        im.as_fieldop(
+            im.lambda_("a", "off")(im.deref(im.shift("IDim", im.deref("off"))("a"))),
+            domain,
+        )("x", "x_offset"),
+        im.as_fieldop(im.lambda_()(DELTA), domain)(),
+    )
+
+    # use the result of an arithmetic field operation as dynamic offset
+    stencil3_inlined = im.as_fieldop(
+        im.lambda_("a", "off")(
+            im.plus(im.deref(im.shift("IDim", im.plus(im.deref("off"), 0))("a")), DELTA)
+        ),
+        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,
+    )(
+        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.as_fieldop(im.lambda_()(DELTA), domain)(),
+    )
+
+    a = np.random.rand(N)
+    a_offset = np.full(N, -OFFSET, dtype=np.int32)
+    b = np.empty(N)
+
+    IOFFSET_FTYPE = ts.FieldType(dims=[IDim], dtype=ts.ScalarType(kind=ts.ScalarKind.INT32))
+
+    for i, stencil in enumerate(
+        [
+            stencil1_inlined,
+            stencil1_fieldview,
+            stencil2_inlined,
+            stencil2_fieldview,
+            stencil3_inlined,
+            stencil3_fieldview,
+        ]
+    ):
+        testee = gtir.Program(
+            id=f"cartesian_shift_right_{i}",
+            function_definitions=[],
+            params=[
+                gtir.Sym(id="x", type=IFTYPE),
+                gtir.Sym(id="x_offset", type=IOFFSET_FTYPE),
+                gtir.Sym(id="y", type=IFTYPE),
+                gtir.Sym(id="size", type=SIZE_TYPE),
+            ],
+            declarations=[],
+            body=[
+                gtir.SetAt(
+                    expr=stencil,
+                    domain=domain,
+                    target=gtir.SymRef(id="y"),
+                )
+            ],
+        )
+
+        sdfg = dace_backend.build_sdfg_from_gtir(testee, CARTESIAN_OFFSETS)
+
+        FSYMBOLS_tmp = FSYMBOLS.copy()
+        FSYMBOLS_tmp["__x_offset_stride_0"] = 1
+        sdfg(x=a, x_offset=a_offset, y=b, **FSYMBOLS_tmp)
+        assert np.allclose(a[:-OFFSET] + DELTA, b[OFFSET:])
+
+
+def test_gtir_connectivity_shift():
+    C2E_neighbor_idx = 2
+    E2V_neighbor_idx = 1
+    cv_domain = im.call("unstructured_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=Cell.value), 0, "ncells"),
+        im.call("named_range")(gtir.AxisLiteral(value=Vertex.value), 0, "nvertices"),
+    )
+    ce_domain = im.call("unstructured_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=Cell.value), 0, "ncells"),
+        im.call("named_range")(gtir.AxisLiteral(value=Edge.value), 0, "nedges"),
+    )
+
+    # apply shift 2 times along different dimensions
+    stencil1_inlined = im.as_fieldop(
+        im.lambda_("it")(
+            im.deref(im.shift("C2E", C2E_neighbor_idx)(im.shift("E2V", E2V_neighbor_idx)("it")))
+        ),
+        ce_domain,
+    )("ev_field")
+
+    # fieldview flavor of the same stncil: create an intermediate temporary field
+    stencil1_fieldview = im.as_fieldop(
+        im.lambda_("it")(im.deref(im.shift("E2V", E2V_neighbor_idx)("it"))),
+        ce_domain,
+    )(
+        im.as_fieldop(
+            im.lambda_("it")(im.deref(im.shift("C2E", C2E_neighbor_idx)("it"))),
+            cv_domain,
+        )("ev_field")
+    )
+
+    # multi-dimensional shift in one function call
+    stencil2 = im.as_fieldop(
+        im.lambda_("it")(
+            im.deref(
+                im.call(
+                    im.call("shift")(
+                        im.ensure_offset("E2V"),
+                        im.ensure_offset(E2V_neighbor_idx),
+                        im.ensure_offset("C2E"),
+                        im.ensure_offset(C2E_neighbor_idx),
+                    )
+                )("it")
+            )
+        ),
+        ce_domain,
+    )("ev_field")
+
+    # again multi-dimensional shift in one function call, but this time with dynamic offset values
+    stencil3_inlined = im.as_fieldop(
+        im.lambda_("it", "c2e_off", "e2v_off")(
+            im.deref(
+                im.call(
+                    im.call("shift")(
+                        im.ensure_offset("E2V"),
+                        im.plus(im.deref("e2v_off"), 0),
+                        im.ensure_offset("C2E"),
+                        im.deref("c2e_off"),
+                    )
+                )("it")
+            )
+        ),
+        ce_domain,
+    )("ev_field", "c2e_offset", "e2v_offset")
+
+    # fieldview flavor of same stencil with dynamic offset
+    stencil3_fieldview = im.as_fieldop(
+        im.lambda_("it", "c2e_off", "e2v_off")(
+            im.deref(
+                im.call(
+                    im.call("shift")(
+                        im.ensure_offset("E2V"),
+                        im.deref("e2v_off"),
+                        im.ensure_offset("C2E"),
+                        im.deref("c2e_off"),
+                    )
+                )("it")
+            )
+        ),
+        ce_domain,
+    )(
+        "ev_field",
+        "c2e_offset",
+        im.as_fieldop(
+            im.lambda_("it")(im.plus(im.deref("it"), 0)),
+            im.call("unstructured_domain")(
+                im.call("named_range")(gtir.AxisLiteral(value=Edge.value), 0, "nedges"),
+            ),
+        )("e2v_offset"),
+    )
+
+    CE_FTYPE = ts.FieldType(dims=[Cell, Edge], dtype=ts.ScalarType(kind=ts.ScalarKind.FLOAT64))
+    EV_FTYPE = ts.FieldType(dims=[Edge, Vertex], dtype=ts.ScalarType(kind=ts.ScalarKind.FLOAT64))
+    CELL_OFFSET_FTYPE = ts.FieldType(dims=[Cell], dtype=SIZE_TYPE)
+    EDGE_OFFSET_FTYPE = ts.FieldType(dims=[Edge], dtype=SIZE_TYPE)
+
+    connectivity_C2E = SIMPLE_MESH_OFFSET_PROVIDER["C2E"]
+    assert isinstance(connectivity_C2E, gtx_common.NeighborTable)
+    connectivity_E2V = SIMPLE_MESH_OFFSET_PROVIDER["E2V"]
+    assert isinstance(connectivity_E2V, gtx_common.NeighborTable)
+
+    ev = np.random.rand(SIMPLE_MESH.num_edges, SIMPLE_MESH.num_vertices)
+    ref = ev[connectivity_C2E.table[:, C2E_neighbor_idx], :][
+        :, connectivity_E2V.table[:, E2V_neighbor_idx]
+    ]
+
+    for i, stencil in enumerate(
+        [stencil1_inlined, stencil1_fieldview, stencil2, stencil3_inlined, stencil3_fieldview]
+    ):
+        testee = gtir.Program(
+            id=f"connectivity_shift_2d_{i}",
+            function_definitions=[],
+            params=[
+                gtir.Sym(id="ce_field", type=CE_FTYPE),
+                gtir.Sym(id="ev_field", type=EV_FTYPE),
+                gtir.Sym(id="c2e_offset", type=CELL_OFFSET_FTYPE),
+                gtir.Sym(id="e2v_offset", type=EDGE_OFFSET_FTYPE),
+                gtir.Sym(id="ncells", type=SIZE_TYPE),
+                gtir.Sym(id="nedges", type=SIZE_TYPE),
+                gtir.Sym(id="nvertices", type=SIZE_TYPE),
+            ],
+            declarations=[],
+            body=[
+                gtir.SetAt(
+                    expr=stencil,
+                    domain=ce_domain,
+                    target=gtir.SymRef(id="ce_field"),
+                )
+            ],
+        )
+
+        sdfg = dace_backend.build_sdfg_from_gtir(testee, SIMPLE_MESH_OFFSET_PROVIDER)
+
+        ce = np.empty([SIMPLE_MESH.num_cells, SIMPLE_MESH.num_edges])
+
+        sdfg(
+            ce_field=ce,
+            ev_field=ev,
+            c2e_offset=np.full(SIMPLE_MESH.num_cells, C2E_neighbor_idx, dtype=np.int32),
+            e2v_offset=np.full(SIMPLE_MESH.num_edges, E2V_neighbor_idx, dtype=np.int32),
+            connectivity_C2E=connectivity_C2E.table,
+            connectivity_E2V=connectivity_E2V.table,
+            **FSYMBOLS,
+            **make_mesh_symbols(SIMPLE_MESH),
+            __ce_field_size_0=SIMPLE_MESH.num_cells,
+            __ce_field_size_1=SIMPLE_MESH.num_edges,
+            __ce_field_stride_0=SIMPLE_MESH.num_edges,
+            __ce_field_stride_1=1,
+            __ev_field_size_0=SIMPLE_MESH.num_edges,
+            __ev_field_size_1=SIMPLE_MESH.num_vertices,
+            __ev_field_stride_0=SIMPLE_MESH.num_vertices,
+            __ev_field_stride_1=1,
+            __c2e_offset_stride_0=1,
+            __e2v_offset_stride_0=1,
+        )
+        assert np.allclose(ce, ref)
+
+
+def test_gtir_connectivity_shift_chain():
+    E2V_neighbor_idx = 1
+    V2E_neighbor_idx = 2
+    edge_domain = im.call("unstructured_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=Edge.value), 0, "nedges")
+    )
+    vertex_domain = im.call("unstructured_domain")(
+        im.call("named_range")(gtir.AxisLiteral(value=Vertex.value), 0, "nvertices")
+    )
+    testee = gtir.Program(
+        id="connectivity_shift_chain",
+        function_definitions=[],
+        params=[
+            gtir.Sym(id="edges", type=EFTYPE),
+            gtir.Sym(id="edges_out", type=EFTYPE),
+            gtir.Sym(id="nedges", type=SIZE_TYPE),
+            gtir.Sym(id="nvertices", type=SIZE_TYPE),
+        ],
+        declarations=[],
+        body=[
+            gtir.SetAt(
+                expr=im.as_fieldop(
+                    im.lambda_("it")(im.deref(im.shift("E2V", E2V_neighbor_idx)("it"))),
+                    edge_domain,
+                )(
+                    im.as_fieldop(
+                        im.lambda_("it")(im.deref(im.shift("V2E", V2E_neighbor_idx)("it"))),
+                        vertex_domain,
+                    )("edges")
+                ),
+                domain=edge_domain,
+                target=gtir.SymRef(id="edges_out"),
+            )
+        ],
+    )
+
+    sdfg = dace_backend.build_sdfg_from_gtir(testee, SIMPLE_MESH_OFFSET_PROVIDER)
+
+    connectivity_E2V = SIMPLE_MESH_OFFSET_PROVIDER["E2V"]
+    assert isinstance(connectivity_E2V, gtx_common.NeighborTable)
+    connectivity_V2E = SIMPLE_MESH_OFFSET_PROVIDER["V2E"]
+    assert isinstance(connectivity_V2E, gtx_common.NeighborTable)
+
+    e = np.random.rand(SIMPLE_MESH.num_edges)
+    ref = e[connectivity_V2E.table[connectivity_E2V.table[:, E2V_neighbor_idx], V2E_neighbor_idx]]
+
+    # new empty output field
+    e_out = np.empty_like(e)
+
+    sdfg(
+        edges=e,
+        edges_out=e_out,
+        connectivity_E2V=connectivity_E2V.table,
+        connectivity_V2E=connectivity_V2E.table,
+        **FSYMBOLS,
+        **make_mesh_symbols(SIMPLE_MESH),
+        __edges_out_size_0=SIMPLE_MESH.num_edges,
+        __edges_out_stride_0=1,
+    )
+    assert np.allclose(e_out, ref)