Repository: GridTools/gt4py
Pull request #1824 (2025-01-24 14:11:45)

fix[next][dace]: handle if-expression as non-exclusive in field view
The implementation of scan field operator introduced a bug in the lowering of if-statements to SDFG. The scan requires extended lowering support for iterator view, therefore #1790 introduced the lowering of local-if with exclusive branch execution. However, attention was not paid to enable the exclusive behavior of if-expressions only in iterator view, that is only in the scope of scan field operators. Regular field operators should instead lower if-expressions to a tasklet, because the field view behavior is that of a local select.


Patch:
diff --git a/docs/development/ADRs/0018-Canonical_SDFG_in_GT4Py_Transformations.md b/docs/development/ADRs/0018-Canonical_SDFG_in_GT4Py_Transformations.md
index 18b9c1f8..69e09c7f 100644
--- a/docs/development/ADRs/0018-Canonical_SDFG_in_GT4Py_Transformations.md
+++ b/docs/development/ADRs/0018-Canonical_SDFG_in_GT4Py_Transformations.md
@@ -7,6 +7,7 @@ tags: [backend, dace, optimization]
 - **Status**: valid
 - **Authors**: Philip Müller (@philip-paul-mueller)
 - **Created**: 2024-08-27
+- **Updated**: 2025-01-15
 
 In the context of the implementation of the new DaCe fieldview we decided about a particular form of the SDFG.
 Their main intent is to reduce the complexity of the GT4Py specific transformations.
@@ -22,6 +23,12 @@ In the pipeline we distinguish between:
 
 The current (GT4Py) pipeline mainly focus on intrastate optimization and relays on DaCe, especially its simplify pass, for interstate optimizations.
 
+## Changelog
+
+#### 2025-01-15:
+
+- Made the rules clearer. Specifically, made a restriction on global memory more explicit.
+
 ## Decision
 
 The canonical form is defined by several rules that affect different aspects of an SDFG and what a transformation can assume.
@@ -38,20 +45,24 @@ The following rules especially affects transformations and how they operate:
    - [Note 2]: It is allowed for an _intrastate_ transformation to act in a way that allows state fusion by later intrastate transformations.
    - [Note 3]: The DaCe simplification pass violates this rule, for that reason this pass must always be called on its own, see also rule 2.
 
-2. It is invalid to call the simplification pass directly, i.e. the usage of `SDFG.simplify()` is not allowed. The only valid way to call _simplify()_ is to call the `gt_simplify()` function provided by GT4Py.
+2. It is invalid to call DaCe's simplification pass directly, i.e. the usage of `SDFG.simplify()` is not allowed. The only valid way to call _simplify()_ is to call the `gt_simplify()` function provided by GT4Py.
+
    - [Rationale]: It was observed that some sub-passes in _simplify()_ have a negative impact and that additional passes might be needed in the future.
      By using a single function later modifications to _simplify()_ are easy.
    - [Note]: One issue is that the remove redundant array transformation is not able to handle all cases.
 
 #### Global Memory
 
-The only restriction we impose on global memory is:
+Global memory has to adhere to the same rules as transient memory.
+However, the following rule takes precedence, i.e. if this rule is fulfilled then rules 6 to 10 may be violated.
+
+3. The same global memory is allowed to be used as input and output at the same time, either in the SDFG or in a state, if and only if the output depends _elementwise_ on the input.
 
-3. The same global memory is allowed to be used as input and output at the same time, if and only if the output depends _elementwise_ on the input.
    - [Rationale 1]: This allows the removal of double buffering, that DaCe may not remove. See also rule 2.
    - [Rationale 2]: This formulation allows writing expressions such as `a += 1`, with only memory for `a`.
      Phrased more technically, using global memory for input and output is allowed if and only if the two computations `tmp = computation(global_memory); global_memory = tmp;` and `global_memory = computation(global_memory);` are equivalent.
-   - [Note]: In the long term this rule will be changed to: Global memory (an array) is either used as input (only read from) or as output (only written to) but never for both.
+   - [Note 1]: This rule also forbids expressions such as `A[0:10] = A[1:11]`, where `A` refers to a global memory.
+   - [Note 2]: In the long term this rule will be changed to: Global memory (an array) is either used as input (only read from) or as output (only written to) but never for both.
 
 #### State Machine
 
@@ -63,6 +74,7 @@ For the SDFG state machine we assume that:
    - [Note]: Running _simplify()_ might actually result in the violation of this rule, see note of rule 9.
 
 5. The state graph does not contain any cycles, i.e. the implementation of a for/while loop using states is not allowed, the new loop construct or serial maps must be used in that case.
+
    - [Rationale]: This is a simplification that makes it much simpler to define what "later in the computation" means, as we will never have a cycle.
    - [Note]: Currently the code generator does not support the `LoopRegion` construct and it is transformed to a state machine.
 
@@ -93,7 +105,7 @@ It is important to note that these rules only have to be met after _simplify()_
 8. No two access nodes in a state can refer to the same array.
 
    - [Rationale]: Together with rule 5 this guarantees SSA style.
-   - [Note]: An SDFG can still be constructed using different access node for the same underlying data; _simplify()_ will combine them.
+   - [Note]: An SDFG can still be constructed using different access node for the same underlying data in the same state; _simplify()_ will combine them.
 
 9. Every access node that reads from an array (having an outgoing edge) that was not written to in the same state must be a source node.
 
@@ -103,6 +115,7 @@ It is important to note that these rules only have to be met after _simplify()_
      Excess interstate transients, that will be kept alive that way, will be removed by later calls to _simplify()_.
 
 10. Every AccessNode within a map scope must refer to a data descriptor whose lifetime must be `dace.dtypes.AllocationLifetime.Scope` and its storage class should either be `dace.dtypes.StorageType.Default` or _preferably_ `dace.dtypes.StorageType.Register`.
+
     - [Rationale 1]: This makes optimizations operating inside maps/kernels simpler, as it guarantees that the AccessNode does not propagate outside.
     - [Rationale 2]: The storage type avoids the need to dynamically allocate memory inside a kernel.
 
@@ -120,6 +133,7 @@ For maps we assume the following:
     - [Rationale]: Without this rule it is very hard to tell which map variable does what, this way we can transmit information from GT4Py to DaCe, see also rule 12.
 
 12. Two map ranges, i.e. the pair map/iteration variable and range, can only be fused if they have the same name _and_ cover the same range.
+
     - [Rationale 1]: Because of rule 11, we will only fuse maps that actually makes sense to fuse.
     - [Rationale 2]: This allows fusing maps without renaming the map variables.
     - [Note]: This rule might be dropped in the future.
diff --git a/src/gt4py/next/ffront/foast_to_gtir.py b/src/gt4py/next/ffront/foast_to_gtir.py
index 007e195f..f884ec55 100644
--- a/src/gt4py/next/ffront/foast_to_gtir.py
+++ b/src/gt4py/next/ffront/foast_to_gtir.py
@@ -138,7 +138,7 @@ class FieldOperatorLowering(eve.PreserveLocationVisitor, eve.NodeTranslator):
 
         definition = itir.Lambda(params=func_definition.params, expr=new_body)
 
-        body = im.as_fieldop(im.call("scan")(definition, forward, init))(*stencil_args)
+        body = im.as_fieldop(im.scan(definition, forward, init))(*stencil_args)
 
         return itir.FunctionDefinition(id=node.id, params=definition.params[1:], expr=body)
 
@@ -360,7 +360,7 @@ class FieldOperatorLowering(eve.PreserveLocationVisitor, eve.NodeTranslator):
         obj, new_type = self.visit(node.args[0], **kwargs), node.args[1].id
 
         def create_cast(expr: itir.Expr, t: tuple[ts.TypeSpec]) -> itir.FunCall:
-            return _map(im.lambda_("val")(im.call("cast_")("val", str(new_type))), (expr,), t)
+            return _map(im.lambda_("val")(im.cast_("val", str(new_type))), (expr,), t)
 
         if not isinstance(node.type, ts.TupleType):  # to keep the IR simpler
             return create_cast(obj, (node.args[0].type,))
@@ -409,7 +409,7 @@ class FieldOperatorLowering(eve.PreserveLocationVisitor, eve.NodeTranslator):
         # TODO(havogt): deal with nested reductions of the form neighbor_sum(neighbor_sum(field(off1)(off2)))
         it = self.visit(node.args[0], **kwargs)
         assert isinstance(node.kwargs["axis"].type, ts.DimensionType)
-        val = im.call(im.call("reduce")(op, init_expr))
+        val = im.reduce(op, init_expr)
         return im.op_as_fieldop(val)(it)
 
     def _visit_neighbor_sum(self, node: foast.Call, **kwargs: Any) -> itir.Expr:
@@ -462,14 +462,14 @@ class FieldOperatorLowering(eve.PreserveLocationVisitor, eve.NodeTranslator):
     def visit_Constant(self, node: foast.Constant, **kwargs: Any) -> itir.Expr:
         return self._make_literal(node.value, node.type)
 
-    def _lower_and_map(self, op: itir.Expr | str, *args: Any, **kwargs: Any) -> itir.FunCall:
+    def _lower_and_map(self, op: itir.Lambda | str, *args: Any, **kwargs: Any) -> itir.FunCall:
         return _map(
             op, tuple(self.visit(arg, **kwargs) for arg in args), tuple(arg.type for arg in args)
         )
 
 
 def _map(
-    op: itir.Expr | str,
+    op: itir.Lambda | str,
     lowered_args: tuple,
     original_arg_types: tuple[ts.TypeSpec, ...],
 ) -> itir.FunCall:
@@ -487,9 +487,9 @@ def _map(
             promote_to_list(arg_type)(larg)
             for arg_type, larg in zip(original_arg_types, lowered_args)
         )
-        op = im.call("map_")(op)
+        op = im.map_(op)
 
-    return im.op_as_fieldop(im.call(op))(*lowered_args)
+    return im.op_as_fieldop(op)(*lowered_args)
 
 
 class FieldOperatorLoweringError(Exception): ...
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 19d0802f..c16b9f2b 100644
--- a/src/gt4py/next/iterator/ir_utils/common_pattern_matcher.py
+++ b/src/gt4py/next/iterator/ir_utils/common_pattern_matcher.py
@@ -72,7 +72,7 @@ def is_call_to(node: Any, fun: str | Iterable[str]) -> TypeGuard[itir.FunCall]:
     attribute which can be anything.
 
     >>> from gt4py.next.iterator.ir_utils import ir_makers as im
-    >>> node = im.call("plus")(1, 2)
+    >>> node = im.plus(1, 2)
     >>> is_call_to(node, "plus")
     True
     >>> is_call_to(node, "minus")
diff --git a/src/gt4py/next/iterator/ir_utils/domain_utils.py b/src/gt4py/next/iterator/ir_utils/domain_utils.py
index c84e2c02..27900b6d 100644
--- a/src/gt4py/next/iterator/ir_utils/domain_utils.py
+++ b/src/gt4py/next/iterator/ir_utils/domain_utils.py
@@ -162,11 +162,11 @@ def domain_union(*domains: SymbolicDomain) -> SymbolicDomain:
     assert all(domain.ranges.keys() == domains[0].ranges.keys() for domain in domains)
     for dim in domains[0].ranges.keys():
         start = functools.reduce(
-            lambda current_expr, el_expr: im.call("minimum")(current_expr, el_expr),
+            lambda current_expr, el_expr: im.minimum(current_expr, el_expr),
             [domain.ranges[dim].start for domain in domains],
         )
         stop = functools.reduce(
-            lambda current_expr, el_expr: im.call("maximum")(current_expr, el_expr),
+            lambda current_expr, el_expr: im.maximum(current_expr, el_expr),
             [domain.ranges[dim].stop for domain in domains],
         )
         # constant fold expression to keep the tree small
diff --git a/src/gt4py/next/iterator/ir_utils/ir_makers.py b/src/gt4py/next/iterator/ir_utils/ir_makers.py
index c5cf2efa..24842ad3 100644
--- a/src/gt4py/next/iterator/ir_utils/ir_makers.py
+++ b/src/gt4py/next/iterator/ir_utils/ir_makers.py
@@ -169,18 +169,6 @@ def divides_(left, right):
     return call("divides")(left, right)
 
 
-def floordiv_(left, right):
-    """Create a floor division FunCall, shorthand for ``call("floordiv")(left, right)``."""
-    # TODO(tehrengruber): Use int(floor(left/right)) as soon as we support integer casting
-    #  and remove the `floordiv` builtin again.
-    return call("floordiv")(left, right)
-
-
-def mod(left, right):
-    """Create a modulo FunCall, shorthand for ``call("mod")(left, right)``."""
-    return call("mod")(left, right)
-
-
 def and_(left, right):
     """Create an and_ FunCall, shorthand for ``call("and_")(left, right)``."""
     return call("and_")(left, right)
@@ -302,7 +290,10 @@ def shift(offset, value=None):
     offset = ensure_offset(offset)
     args = [offset]
     if value is not None:
-        value = ensure_offset(value)
+        if isinstance(value, int):
+            value = ensure_offset(value)
+        elif isinstance(value, str):
+            value = ref(value)
         args.append(value)
     return call(call("shift")(*args))
 
@@ -469,7 +460,7 @@ def as_fieldop(expr: itir.Expr | str, domain: Optional[itir.Expr] = None) -> cal
 
 
 def op_as_fieldop(
-    op: str | itir.SymRef | Callable, domain: Optional[itir.FunCall] = None
+    op: str | itir.SymRef | itir.Lambda | Callable, domain: Optional[itir.FunCall] = None
 ) -> Callable[..., itir.FunCall]:
     """
     Promotes a function `op` to a field_operator.
@@ -536,3 +527,40 @@ def index(dim: common.Dimension) -> itir.FunCall:
 def map_(op):
     """Create a `map_` call."""
     return call(call("map_")(op))
+
+
+def reduce(op, expr):
+    """Create a `reduce` call."""
+    return call(call("reduce")(op, expr))
+
+
+def scan(expr, forward, init):
+    """Create a `scan` call."""
+    return call("scan")(expr, forward, init)
+
+
+def list_get(list_idx, list_):
+    """Create a `list_get` call."""
+    return call("list_get")(list_idx, list_)
+
+
+def maximum(expr1, expr2):
+    """Create a `maximum` call."""
+    return call("maximum")(expr1, expr2)
+
+
+def minimum(expr1, expr2):
+    """Create a `minimum` call."""
+    return call("minimum")(expr1, expr2)
+
+
+def cast_(expr, dtype: ts.ScalarType | str):
+    """Create a `cast_` call."""
+    if isinstance(dtype, ts.ScalarType):
+        dtype = dtype.kind.name.lower()
+    return call("cast_")(expr, dtype)
+
+
+def can_deref(expr):
+    """Create a `can_deref` call."""
+    return call("can_deref")(expr)
diff --git a/src/gt4py/next/iterator/transforms/collapse_list_get.py b/src/gt4py/next/iterator/transforms/collapse_list_get.py
index 4a354879..b0a0c1e1 100644
--- a/src/gt4py/next/iterator/transforms/collapse_list_get.py
+++ b/src/gt4py/next/iterator/transforms/collapse_list_get.py
@@ -27,8 +27,8 @@ class CollapseListGet(eve.PreserveLocationVisitor, eve.NodeTranslator):
                 cond, true_val, false_val = node.args[1].args
                 return im.if_(
                     cond,
-                    self.visit(im.call("list_get")(list_idx, true_val)),
-                    self.visit(im.call("list_get")(list_idx, false_val)),
+                    self.visit(im.list_get(list_idx, true_val)),
+                    self.visit(im.list_get(list_idx, false_val)),
                 )
             if cpm.is_call_to(node.args[1], "neighbors"):
                 offset_tag = node.args[1].args[0]
diff --git a/src/gt4py/next/iterator/transforms/inline_fundefs.py b/src/gt4py/next/iterator/transforms/inline_fundefs.py
index e4cae978..03b20d14 100644
--- a/src/gt4py/next/iterator/transforms/inline_fundefs.py
+++ b/src/gt4py/next/iterator/transforms/inline_fundefs.py
@@ -36,12 +36,12 @@ def prune_unreferenced_fundefs(program: itir.Program) -> itir.Program:
     >>> fun1 = itir.FunctionDefinition(
     ...     id="fun1",
     ...     params=[im.sym("a")],
-    ...     expr=im.call("deref")("a"),
+    ...     expr=im.deref("a"),
     ... )
     >>> fun2 = itir.FunctionDefinition(
     ...     id="fun2",
     ...     params=[im.sym("a")],
-    ...     expr=im.call("deref")("a"),
+    ...     expr=im.deref("a"),
     ... )
     >>> program = itir.Program(
     ...     id="testee",
diff --git a/src/gt4py/next/iterator/transforms/inline_lifts.py b/src/gt4py/next/iterator/transforms/inline_lifts.py
index f27dbbb7..7724aa86 100644
--- a/src/gt4py/next/iterator/transforms/inline_lifts.py
+++ b/src/gt4py/next/iterator/transforms/inline_lifts.py
@@ -197,11 +197,11 @@ class InlineLifts(
                 if len(args) == 0:
                     return im.literal_from_value(True)
 
-                res = ir.FunCall(fun=ir.SymRef(id="can_deref"), args=[args[0]])
+                res = im.can_deref(args[0])
                 for arg in args[1:]:
                     res = ir.FunCall(
                         fun=ir.SymRef(id="and_"),
-                        args=[res, ir.FunCall(fun=ir.SymRef(id="can_deref"), args=[arg])],
+                        args=[res, im.can_deref(arg)],
                     )
                 return res
         elif (
diff --git a/src/gt4py/next/iterator/transforms/unroll_reduce.py b/src/gt4py/next/iterator/transforms/unroll_reduce.py
index 042a86cd..6e993a2e 100644
--- a/src/gt4py/next/iterator/transforms/unroll_reduce.py
+++ b/src/gt4py/next/iterator/transforms/unroll_reduce.py
@@ -14,7 +14,7 @@ from gt4py.eve import NodeTranslator, PreserveLocationVisitor
 from gt4py.eve.utils import UIDGenerator
 from gt4py.next import common
 from gt4py.next.iterator import ir as itir
-from gt4py.next.iterator.ir_utils import common_pattern_matcher as cpm
+from gt4py.next.iterator.ir_utils import common_pattern_matcher as cpm, ir_makers as im
 from gt4py.next.iterator.ir_utils.common_pattern_matcher import is_applied_lift
 
 
@@ -85,34 +85,6 @@ def _get_connectivity(
     return connectivities[0]
 
 
-def _make_shift(offsets: list[itir.Expr], iterator: itir.Expr) -> itir.FunCall:
-    return itir.FunCall(
-        fun=itir.FunCall(fun=itir.SymRef(id="shift"), args=offsets),
-        args=[iterator],
-        location=iterator.location,
-    )
-
-
-def _make_deref(iterator: itir.Expr) -> itir.FunCall:
-    return itir.FunCall(fun=itir.SymRef(id="deref"), args=[iterator], location=iterator.location)
-
-
-def _make_can_deref(iterator: itir.Expr) -> itir.FunCall:
-    return itir.FunCall(
-        fun=itir.SymRef(id="can_deref"), args=[iterator], location=iterator.location
-    )
-
-
-def _make_if(cond: itir.Expr, true_expr: itir.Expr, false_expr: itir.Expr) -> itir.FunCall:
-    return itir.FunCall(
-        fun=itir.SymRef(id="if_"), args=[cond, true_expr, false_expr], location=cond.location
-    )
-
-
-def _make_list_get(offset: itir.Expr, expr: itir.Expr) -> itir.FunCall:
-    return itir.FunCall(fun=itir.SymRef(id="list_get"), args=[offset, expr], location=expr.location)
-
-
 @dataclasses.dataclass(frozen=True)
 class UnrollReduce(PreserveLocationVisitor, NodeTranslator):
     # we use one UID generator per instance such that the generated ids are
@@ -130,27 +102,25 @@ class UnrollReduce(PreserveLocationVisitor, NodeTranslator):
         max_neighbors = connectivity_type.max_neighbors
         has_skip_values = connectivity_type.has_skip_values
 
-        acc = itir.SymRef(id=self.uids.sequential_id(prefix="_acc"))
-        offset = itir.SymRef(id=self.uids.sequential_id(prefix="_i"))
-        step = itir.SymRef(id=self.uids.sequential_id(prefix="_step"))
+        acc: str = self.uids.sequential_id(prefix="_acc")
+        offset: str = self.uids.sequential_id(prefix="_i")
+        step: str = self.uids.sequential_id(prefix="_step")
 
         assert isinstance(node.fun, itir.FunCall)
         fun, init = node.fun.args
 
-        elems = [_make_list_get(offset, arg) for arg in node.args]
-        step_fun: itir.Expr = itir.FunCall(fun=fun, args=[acc, *elems])
+        elems = [im.list_get(offset, arg) for arg in node.args]
+        step_fun: itir.Expr = im.call(fun)(acc, *elems)
         if has_skip_values:
             check_arg = next(_get_neighbors_args(node.args))
             offset_tag, it = check_arg.args
-            can_deref = _make_can_deref(_make_shift([offset_tag, offset], it))
-            step_fun = _make_if(can_deref, step_fun, acc)
-        step_fun = itir.Lambda(params=[itir.Sym(id=acc.id), itir.Sym(id=offset.id)], expr=step_fun)
+            can_deref = im.can_deref(im.shift(offset_tag, offset)(it))
+            step_fun = im.if_(can_deref, step_fun, acc)
+        step_fun = im.lambda_(acc, offset)(step_fun)
         expr = init
         for i in range(max_neighbors):
-            expr = itir.FunCall(fun=step, args=[expr, itir.OffsetLiteral(value=i)])
-        expr = itir.FunCall(
-            fun=itir.Lambda(params=[itir.Sym(id=step.id)], expr=expr), args=[step_fun]
-        )
+            expr = im.call(step)(expr, itir.OffsetLiteral(value=i))
+        expr = im.let(step, step_fun)(expr)
 
         return expr
 
diff --git a/src/gt4py/next/program_processors/runners/dace/gtir_dataflow.py b/src/gt4py/next/program_processors/runners/dace/gtir_dataflow.py
index e00e363a..a34828af 100644
--- a/src/gt4py/next/program_processors/runners/dace/gtir_dataflow.py
+++ b/src/gt4py/next/program_processors/runners/dace/gtir_dataflow.py
@@ -332,6 +332,7 @@ class LambdaToDataflow(eve.NodeVisitor):
     sdfg: dace.SDFG
     state: dace.SDFGState
     subgraph_builder: gtir_sdfg.DataflowBuilder
+    scan_carry_symbol: Optional[gtir.Sym]
     input_edges: list[DataflowInputEdge] = dataclasses.field(default_factory=lambda: [])
     symbol_map: dict[
         str,
@@ -693,7 +694,7 @@ class LambdaToDataflow(eve.NodeVisitor):
         # visit each branch of the if-statement as if it was a Lambda node
         lambda_node = gtir.Lambda(params=lambda_params, expr=expr)
         input_edges, output_tree = translate_lambda_to_dataflow(
-            if_sdfg, if_branch_state, self.subgraph_builder, lambda_node, args=lambda_args
+            if_sdfg, if_branch_state, self.subgraph_builder, lambda_node, lambda_args
         )
 
         for data_node in if_branch_state.data_nodes():
@@ -736,7 +737,12 @@ class LambdaToDataflow(eve.NodeVisitor):
         """
         Lowers an if-expression with exclusive branch execution into a nested SDFG,
         in which each branch is lowered into a dataflow in a separate state and
-        the if-condition is represented as the inter-state edge condtion.
+        the if-condition is represented as the inter-state edge condition.
+
+        Exclusive branch execution for local if expressions is meant to be used
+        in iterator view. Iterator view is required ONLY inside scan field operators.
+        For regular field operators, the fieldview behavior of if-expressions
+        corresponds to a local select, therefore it should be lowered to a tasklet.
         """
 
         def write_output_of_nested_sdfg_to_temporary(inner_value: ValueExpr) -> ValueExpr:
@@ -1633,13 +1639,87 @@ class LambdaToDataflow(eve.NodeVisitor):
         tuple_fields = self.visit(node.args[1])
         return tuple_fields[index]
 
+    def requires_exclusive_if(self, node: gtir.FunCall) -> bool:
+        """
+        The meaning of `if_` builtin function is unclear in GTIR.
+        In some context, it corresponds to a ternary operator where, depending on
+        the condition result, only one branch or the other should be executed,
+        because one of them is invalid. The typical case is the use of `if_` to
+        decide whether it is possible or not to access a shifted iterator, for
+        example when the condition expression calls `can_deref`.
+        The ternary operator is also used in iterator view, where the field arguments
+        are not necessarily both defined on the entire output domain (this behavior
+        should not appear in field view, because there the user code should use
+        `concat_where` instead of `where` for such cases). It is difficult to catch
+        such behavior, because it would require to know the exact domain of all
+        fields, which is not known at compile time. However, the iterator view
+        behavior should only appear inside scan field operators.
+        A different usage of `if_` expressions is selecting one argument value or
+        the other, where both arguments are defined on the output domain, therefore
+        always valid.
+        In order to simplify the SDFG and facilitate the optimization stage, we
+        try to avoid the ternary operator form when not needed. The reason is that
+        exclusive branch execution is represented in the SDFG as a conditional
+        state transition, which prevents fusion.
+        """
+        assert cpm.is_call_to(node, "if_")
+        assert len(node.args) == 3
+
+        condition_vars = (
+            eve.walk_values(node.args[0])
+            .if_isinstance(gtir.SymRef)
+            .map(lambda node: str(node.id))
+            .filter(lambda x: x in self.symbol_map)
+            .to_set()
+        )
+
+        # first, check if any argument contains shift expressions that depend on the condition variables
+        for arg in node.args[1:3]:
+            shift_nodes = (
+                eve.walk_values(arg).filter(lambda node: cpm.is_applied_shift(node)).to_set()
+            )
+            for shift_node in shift_nodes:
+                shift_vars = (
+                    eve.walk_values(shift_node)
+                    .if_isinstance(gtir.SymRef)
+                    .map(lambda node: str(node.id))
+                    .filter(lambda x: x in self.symbol_map)
+                    .to_set()
+                )
+                # require exclusive branch execution if any shift expression one of
+                # the if branches accesses a variable used in the condition expression
+                depend_vars = condition_vars.intersection(shift_vars)
+                if len(depend_vars) != 0:
+                    return True
+
+        # secondly, check whether the `if_` branches access different sets of fields
+        # and this happens inside a scan field operator
+        if self.scan_carry_symbol is not None:
+            # the `if_` node is inside a scan stencil expression
+            scan_carry_var = str(self.scan_carry_symbol.id)
+            if scan_carry_var in condition_vars:
+                br1_vars, br2_vars = (
+                    eve.walk_values(arg)
+                    .if_isinstance(gtir.SymRef)
+                    .map(lambda node: str(node.id))
+                    .filter(lambda x: isinstance(self.symbol_map.get(x, None), MemletExpr))
+                    .to_set()
+                    for arg in node.args[1:3]
+                )
+                if br1_vars != br2_vars:
+                    # the two branches of the `if_` expression access different sets of fields,
+                    # depending on the scan carry value
+                    return True
+
+        return False
+
     def visit_FunCall(
         self, node: gtir.FunCall
     ) -> IteratorExpr | DataExpr | tuple[IteratorExpr | DataExpr | tuple[Any, ...], ...]:
         if cpm.is_call_to(node, "deref"):
             return self._visit_deref(node)
 
-        elif cpm.is_call_to(node, "if_"):
+        elif cpm.is_call_to(node, "if_") and self.requires_exclusive_if(node):
             return self._visit_if(node)
 
         elif cpm.is_call_to(node, "neighbors"):
@@ -1776,6 +1856,7 @@ def translate_lambda_to_dataflow(
         | ValueExpr
         | tuple[IteratorExpr | MemletExpr | ValueExpr | tuple[Any, ...], ...]
     ],
+    scan_carry_symbol: Optional[gtir.Sym] = None,
 ) -> tuple[
     list[DataflowInputEdge],
     tuple[DataflowOutputEdge | tuple[Any, ...], ...],
@@ -1794,13 +1875,15 @@ def translate_lambda_to_dataflow(
         sdfg_builder: Helper class to build the dataflow inside the given SDFG.
         node: Lambda node to visit.
         args: Arguments passed to lambda node.
+        scan_carry_symbol: When set, the lowering of `if_` expression will consider
+            using the ternary operator form with exclusive branch execution.
 
     Returns:
         A tuple of two elements:
         - List of connections for data inputs to the dataflow.
         - Tree representation of output data connections.
     """
-    taskgen = LambdaToDataflow(sdfg, state, sdfg_builder)
+    taskgen = LambdaToDataflow(sdfg, state, sdfg_builder, scan_carry_symbol)
     lambda_output = taskgen.visit_let(node, args)
 
     if isinstance(lambda_output, DataflowOutputEdge):
diff --git a/src/gt4py/next/program_processors/runners/dace/gtir_scan_translator.py b/src/gt4py/next/program_processors/runners/dace/gtir_scan_translator.py
index d3d8e101..ec88cd8f 100644
--- a/src/gt4py/next/program_processors/runners/dace/gtir_scan_translator.py
+++ b/src/gt4py/next/program_processors/runners/dace/gtir_scan_translator.py
@@ -417,7 +417,12 @@ def _lower_lambda_to_nested_sdfg(
     # stil inside the 'compute' state, generate the dataflow representing the stencil
     # to be applied on the horizontal domain
     lambda_input_edges, lambda_result = gtir_dataflow.translate_lambda_to_dataflow(
-        nsdfg, compute_state, lambda_translator, lambda_node, args=stencil_args
+        nsdfg,
+        compute_state,
+        lambda_translator,
+        lambda_node,
+        stencil_args,
+        scan_carry_symbol=scan_carry_symbol,
     )
     # connect the dataflow input directly to the source data nodes, without passing through a map node;
     # the reason is that the map for horizontal domain is outside the scan loop region
diff --git a/src/gt4py/next/program_processors/runners/dace/transformations/utils.py b/src/gt4py/next/program_processors/runners/dace/transformations/utils.py
index 87308061..3cc2dadd 100644
--- a/src/gt4py/next/program_processors/runners/dace/transformations/utils.py
+++ b/src/gt4py/next/program_processors/runners/dace/transformations/utils.py
@@ -50,6 +50,7 @@ def gt_make_transients_persistent(
         not_modify_lifetime: set[str] = set()
 
         for state in nsdfg.states():
+            scope_dict = state.scope_dict()
             for dnode in state.data_nodes():
                 if dnode.data in not_modify_lifetime:
                     continue
@@ -70,6 +71,15 @@ def gt_make_transients_persistent(
                     not_modify_lifetime.add(dnode.data)
                     continue
 
+                # If the data is referenced inside a scope, such as a map, it might be possible
+                #  that it is only used inside that scope. If we would make it persistent, then
+                #  it would essentially be allocated outside and be shared among the different
+                #  map iterations. So we can not make it persistent.
+                #  The downside is, that we might have to perform dynamic allocation.
+                if scope_dict[dnode] is not None:
+                    not_modify_lifetime.add(dnode.data)
+                    continue
+
                 try:
                     # The symbols describing the total size must be a subset of the
                     #  free symbols of the SDFG (symbols passed as argument).


Test patch:
diff --git a/tests/next_tests/unit_tests/ffront_tests/test_foast_to_gtir.py b/tests/next_tests/unit_tests/ffront_tests/test_foast_to_gtir.py
index d2d5404c..c0d762ef 100644
--- a/tests/next_tests/unit_tests/ffront_tests/test_foast_to_gtir.py
+++ b/tests/next_tests/unit_tests/ffront_tests/test_foast_to_gtir.py
@@ -91,7 +91,7 @@ def test_scalar_arg_only():
     parsed = FieldOperatorParser.apply_to_function(foo)
     lowered = FieldOperatorLowering.apply(parsed)
 
-    reference = im.call("multiplies")("alpha", "bar")
+    reference = im.multiplies_("alpha", "bar")
 
     assert lowered.expr == reference
 
@@ -297,7 +297,7 @@ def test_astype_local_field():
     parsed = FieldOperatorParser.apply_to_function(foo)
     lowered = FieldOperatorLowering.apply(parsed)
 
-    reference = im.op_as_fieldop(im.map_(im.lambda_("val")(im.call("cast_")("val", "int32"))))("a")
+    reference = im.op_as_fieldop(im.map_(im.lambda_("val")(im.cast_("val", "int32"))))("a")
 
     assert lowered.expr == reference
 
@@ -310,7 +310,7 @@ def test_astype_scalar():
     lowered = FieldOperatorLowering.apply(parsed)
     lowered_inlined = inline_lambdas.InlineLambdas.apply(lowered)
 
-    reference = im.call("cast_")("a", "int32")
+    reference = im.cast_("a", "int32")
 
     assert lowered_inlined.expr == reference
 
@@ -341,7 +341,7 @@ def test_astype_tuple_scalar_and_field():
 
     reference = im.make_tuple(
         im.cast_as_fieldop("int32")(im.tuple_get(0, "a")),
-        im.call("cast_")(im.tuple_get(1, "a"), "int32"),
+        im.cast_(im.tuple_get(1, "a"), "int32"),
     )
 
     assert lowered_inlined.expr == reference
@@ -551,7 +551,7 @@ def test_add_scalar_literals():
 
     reference = im.let(
         ssa.unique_name("tmp", 0),
-        im.call("plus")(
+        im.plus(
             im.literal("1", "int32"),
             im.literal("1", "int32"),
         ),
@@ -656,7 +656,7 @@ def test_compare_scalars():
     parsed = FieldOperatorParser.apply_to_function(foo)
     lowered = FieldOperatorLowering.apply(parsed)
 
-    reference = im.call("greater")(
+    reference = im.greater(
         im.literal("3", "int32"),
         im.literal("4", "int32"),
     )
@@ -761,11 +761,9 @@ def test_reduction_lowering_neighbor_sum():
     lowered = FieldOperatorLowering.apply(parsed)
 
     reference = im.op_as_fieldop(
-        im.call(
-            im.call("reduce")(
-                "plus",
-                im.literal(value="0", typename="float64"),
-            )
+        im.reduce(
+            "plus",
+            im.literal(value="0", typename="float64"),
         )
     )(im.as_fieldop_neighbors("V2E", "edge_f"))
 
@@ -780,11 +778,9 @@ def test_reduction_lowering_max_over():
     lowered = FieldOperatorLowering.apply(parsed)
 
     reference = im.op_as_fieldop(
-        im.call(
-            im.call("reduce")(
-                "maximum",
-                im.literal(value=str(np.finfo(np.float64).min), typename="float64"),
-            )
+        im.reduce(
+            "maximum",
+            im.literal(value=str(np.finfo(np.float64).min), typename="float64"),
         )
     )(im.as_fieldop_neighbors("V2E", "edge_f"))
 
@@ -799,11 +795,9 @@ def test_reduction_lowering_min_over():
     lowered = FieldOperatorLowering.apply(parsed)
 
     reference = im.op_as_fieldop(
-        im.call(
-            im.call("reduce")(
-                "minimum",
-                im.literal(value=str(np.finfo(np.float64).max), typename="float64"),
-            )
+        im.reduce(
+            "minimum",
+            im.literal(value=str(np.finfo(np.float64).max), typename="float64"),
         )
     )(im.as_fieldop_neighbors("V2E", "edge_f"))
 
@@ -828,11 +822,9 @@ def test_reduction_lowering_expr():
         im.as_fieldop_neighbors("V2E", "e1"),
     )(
         im.op_as_fieldop(
-            im.call(
-                im.call("reduce")(
-                    "plus",
-                    im.literal(value="0", typename="float64"),
-                )
+            im.reduce(
+                "plus",
+                im.literal(value="0", typename="float64"),
             )
         )(mapped)
     )
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 d4d7c60d..a39fe3c6 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
@@ -77,10 +77,10 @@ def expression_test_cases():
         (im.plus(1, 2), int_type),
         (im.eq(1, 2), bool_type),
         (im.deref(im.ref("it", it_on_e_of_e_type)), it_on_e_of_e_type.element_type),
-        (im.call("can_deref")(im.ref("it", it_on_e_of_e_type)), bool_type),
+        (im.can_deref(im.ref("it", it_on_e_of_e_type)), bool_type),
         (im.if_(True, 1, 2), int_type),
         (im.call("make_const_list")(True), ts.ListType(element_type=bool_type)),
-        (im.call("list_get")(0, im.ref("l", ts.ListType(element_type=bool_type))), bool_type),
+        (im.list_get(0, im.ref("l", ts.ListType(element_type=bool_type))), bool_type),
         (
             im.call("named_range")(
                 itir.AxisLiteral(value="Vertex", kind=common.DimensionKind.HORIZONTAL), 0, 1
@@ -119,7 +119,7 @@ def expression_test_cases():
             ts.ListType(element_type=it_on_e_of_e_type.element_type),
         ),
         # cast
-        (im.call("cast_")(1, "int32"), int_type),
+        (im.cast_(1, int_type), int_type),
         # TODO: lift
         # TODO: scan
         # map
@@ -128,18 +128,16 @@ def expression_test_cases():
             int_list_type,
         ),
         # reduce
-        (im.call(im.call("reduce")("plus", 0))(im.ref("l", int_list_type)), int_type),
+        (im.reduce("plus", 0)(im.ref("l", int_list_type)), int_type),
         (
-            im.call(
-                im.call("reduce")(
-                    im.lambda_("acc", "a", "b")(
-                        im.make_tuple(
-                            im.plus(im.tuple_get(0, "acc"), "a"),
-                            im.plus(im.tuple_get(1, "acc"), "b"),
-                        )
-                    ),
-                    im.make_tuple(0, 0.0),
-                )
+            im.reduce(
+                im.lambda_("acc", "a", "b")(
+                    im.make_tuple(
+                        im.plus(im.tuple_get(0, "acc"), "a"),
+                        im.plus(im.tuple_get(1, "acc"), "b"),
+                    )
+                ),
+                im.make_tuple(0, 0.0),
             )(im.ref("la", int_list_type), im.ref("lb", float64_list_type)),
             ts.TupleType(types=[int_type, float64_type]),
         ),
@@ -148,42 +146,36 @@ def expression_test_cases():
         (im.shift("Ioff", 1)(im.ref("it", it_ijk_type)), it_ijk_type),
         # as_fieldop
         (
-            im.call(
-                im.call("as_fieldop")(
-                    "deref",
-                    im.call("cartesian_domain")(
-                        im.call("named_range")(itir.AxisLiteral(value="IDim"), 0, 1)
-                    ),
-                )
+            im.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,
         ),
         (
-            im.call(
-                im.call("as_fieldop")(
-                    im.lambda_("it")(im.deref(im.shift("V2E", 0)("it"))),
-                    im.call("unstructured_domain")(
-                        im.call("named_range")(
-                            itir.AxisLiteral(value="Vertex", kind=common.DimensionKind.HORIZONTAL),
-                            0,
-                            1,
-                        ),
-                        im.call("named_range")(
-                            itir.AxisLiteral(value="KDim", kind=common.DimensionKind.VERTICAL), 0, 1
-                        ),
+            im.as_fieldop(
+                im.lambda_("it")(im.deref(im.shift("V2E", 0)("it"))),
+                im.call("unstructured_domain")(
+                    im.call("named_range")(
+                        itir.AxisLiteral(value="Vertex", kind=common.DimensionKind.HORIZONTAL),
+                        0,
+                        1,
                     ),
-                )
+                    im.call("named_range")(
+                        itir.AxisLiteral(value="KDim", kind=common.DimensionKind.VERTICAL), 0, 1
+                    ),
+                ),
             )(im.ref("inp", float_edge_k_field)),
             float_vertex_k_field,
         ),
         (
-            im.call(
-                im.call("as_fieldop")(
-                    im.lambda_("a", "b")(im.make_tuple(im.deref("a"), im.deref("b"))),
-                    im.call("cartesian_domain")(
-                        im.call("named_range")(itir.AxisLiteral(value="IDim"), 0, 1)
-                    ),
-                )
+            im.as_fieldop(
+                im.lambda_("a", "b")(im.make_tuple(im.deref("a"), im.deref("b"))),
+                im.call("cartesian_domain")(
+                    im.call("named_range")(itir.AxisLiteral(value="IDim"), 0, 1)
+                ),
             )(im.ref("inp1", float_i_field), im.ref("inp2", float_i_field)),
             ts.TupleType(types=[float_i_field, float_i_field]),
         ),
@@ -197,21 +189,17 @@ def expression_test_cases():
         (
             im.if_(
                 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.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.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,
@@ -276,9 +264,7 @@ def test_cast_first_arg_inference():
     # since cast_ is a grammar builtin whose return type is given by its second argument it is
     # easy to forget inferring the types of the first argument and its children. Simply check
     # if the first argument has a type inferred correctly here.
-    testee = im.call("cast_")(
-        im.plus(im.literal_from_value(1), im.literal_from_value(2)), "float64"
-    )
+    testee = im.cast_(im.plus(im.literal_from_value(1), im.literal_from_value(2)), "float64")
     result = itir_type_inference.infer(
         testee, offset_provider_type={}, allow_undeclared_symbols=True
     )
@@ -299,9 +285,7 @@ def test_cartesian_fencil_definition():
         declarations=[],
         body=[
             itir.SetAt(
-                expr=im.call(im.call("as_fieldop")(im.ref("deref"), cartesian_domain))(
-                    im.ref("inp")
-                ),
+                expr=im.as_fieldop(im.ref("deref"), cartesian_domain)(im.ref("inp")),
                 domain=cartesian_domain,
                 target=im.ref("out"),
             ),
@@ -336,10 +320,8 @@ def test_unstructured_fencil_definition():
         declarations=[],
         body=[
             itir.SetAt(
-                expr=im.call(
-                    im.call("as_fieldop")(
-                        im.lambda_("it")(im.deref(im.shift("V2E", 0)("it"))), unstructured_domain
-                    )
+                expr=im.as_fieldop(
+                    im.lambda_("it")(im.deref(im.shift("V2E", 0)("it"))), unstructured_domain
                 )(im.ref("inp")),
                 domain=unstructured_domain,
                 target=im.ref("out"),
@@ -375,7 +357,7 @@ def test_function_definition():
         body=[
             itir.SetAt(
                 domain=cartesian_domain,
-                expr=im.call(im.call("as_fieldop")(im.ref("bar"), cartesian_domain))(im.ref("inp")),
+                expr=im.as_fieldop(im.ref("bar"), cartesian_domain)(im.ref("inp")),
                 target=im.ref("out"),
             ),
         ],
@@ -408,11 +390,9 @@ def test_fencil_with_nb_field_input():
         body=[
             itir.SetAt(
                 domain=unstructured_domain,
-                expr=im.call(
-                    im.call("as_fieldop")(
-                        im.lambda_("it")(im.call(im.call("reduce")("plus", 0.0))(im.deref("it"))),
-                        unstructured_domain,
-                    )
+                expr=im.as_fieldop(
+                    im.lambda_("it")(im.reduce("plus", 0.0)(im.deref("it"))),
+                    unstructured_domain,
                 )(im.ref("inp")),
                 target=im.ref("out"),
             ),
@@ -438,9 +418,7 @@ def test_program_tuple_setat_short_target():
         declarations=[],
         body=[
             itir.SetAt(
-                expr=im.call(
-                    im.call("as_fieldop")(im.lambda_()(im.make_tuple(1.0, 2.0)), cartesian_domain)
-                )(),
+                expr=im.as_fieldop(im.lambda_()(im.make_tuple(1.0, 2.0)), cartesian_domain)(),
                 domain=cartesian_domain,
                 target=im.make_tuple("out"),
             )
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py
index 0bf8dcb6..cf325c2d 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py
@@ -31,7 +31,7 @@ def test_constant_folding_math_op():
 
 
 def test_constant_folding_if():
-    expected = im.call("plus")("a", 2)
+    expected = im.plus("a", 2)
     testee = im.if_(
         im.literal_from_value(True),
         im.plus(im.ref("a"), im.literal_from_value(2)),
@@ -42,7 +42,7 @@ def test_constant_folding_if():
 
 
 def test_constant_folding_minimum():
-    testee = im.call("minimum")("a", "a")
+    testee = im.minimum("a", "a")
     expected = im.ref("a")
     actual = ConstantFolding.apply(testee)
     assert actual == expected
@@ -56,7 +56,7 @@ def test_constant_folding_literal():
 
 
 def test_constant_folding_literal_maximum():
-    testee = im.call("maximum")(im.literal_from_value(1), im.literal_from_value(2))
+    testee = im.maximum(im.literal_from_value(1), im.literal_from_value(2))
     expected = im.literal_from_value(2)
     actual = ConstantFolding.apply(testee)
     assert actual == expected
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_cse.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_cse.py
index 14860d9b..3909c6f2 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_cse.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_cse.py
@@ -135,7 +135,7 @@ def test_if_can_deref_no_extraction(offset_provider_type):
 
     # if can_deref(⟪Iₒ, 1ₒ⟫(it)) then ·⟪Iₒ, 1ₒ⟫(it) + ·⟪Iₒ, 1ₒ⟫(it) else 1
     testee = im.if_(
-        im.call("can_deref")(im.shift("I", 1)("it")),
+        im.can_deref(im.shift("I", 1)("it")),
         im.plus(im.deref(im.shift("I", 1)("it")), im.deref(im.shift("I", 1)("it"))),
         # use something more involved where a subexpression can still be eliminated
         im.literal("1", "int32"),
@@ -143,7 +143,7 @@ def test_if_can_deref_no_extraction(offset_provider_type):
     # (λ(_cs_1) → if can_deref(_cs_1) then (λ(_cs_2) → _cs_2 + _cs_2)(·_cs_1) else 1)(⟪Iₒ, 1ₒ⟫(it))
     expected = im.let("_cs_1", im.shift("I", 1)("it"))(
         im.if_(
-            im.call("can_deref")("_cs_1"),
+            im.can_deref("_cs_1"),
             im.let("_cs_2", im.deref("_cs_1"))(im.plus("_cs_2", "_cs_2")),
             im.literal("1", "int32"),
         )
@@ -159,14 +159,14 @@ def test_if_can_deref_eligible_extraction(offset_provider_type):
 
     # if can_deref(⟪Iₒ, 1ₒ⟫(it)) then ·⟪Iₒ, 1ₒ⟫(it) else ·⟪Iₒ, 1ₒ⟫(it) + ·⟪Iₒ, 1ₒ⟫(it)
     testee = im.if_(
-        im.call("can_deref")(im.shift("I", 1)("it")),
+        im.can_deref(im.shift("I", 1)("it")),
         im.deref(im.shift("I", 1)("it")),
         im.plus(im.deref(im.shift("I", 1)("it")), im.deref(im.shift("I", 1)("it"))),
     )
     # (λ(_cs_3) → (λ(_cs_1) → if can_deref(_cs_3) then _cs_1 else _cs_1 + _cs_1)(·_cs_3))(⟪Iₒ, 1ₒ⟫(it))
     expected = im.let("_cs_3", im.shift("I", 1)("it"))(
         im.let("_cs_1", im.deref("_cs_3"))(
-            im.if_(im.call("can_deref")("_cs_3"), "_cs_1", im.plus("_cs_1", "_cs_1"))
+            im.if_(im.can_deref("_cs_3"), "_cs_1", im.plus("_cs_1", "_cs_1"))
         )
     )
 
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py
index 779ab738..4a2a4415 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py
@@ -1028,10 +1028,10 @@ def test_arithmetic_builtin(offset_provider):
 def test_scan(offset_provider):
     domain = im.domain(common.GridType.CARTESIAN, {IDim: (0, 11)})
     testee = im.as_fieldop(
-        im.call("scan")(im.lambda_("init", "it")(im.deref(im.shift("Ioff", 1)("it"))), True, 0.0)
+        im.scan(im.lambda_("init", "it")(im.deref(im.shift("Ioff", 1)("it"))), True, 0.0)
     )("a")
     expected = im.as_fieldop(
-        im.call("scan")(im.lambda_("init", "it")(im.deref(im.shift("Ioff", 1)("it"))), True, 0.0),
+        im.scan(im.lambda_("init", "it")(im.deref(im.shift("Ioff", 1)("it"))), True, 0.0),
         domain,
     )("a")
 
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_inline_lifts.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_inline_lifts.py
index f81ca5a6..957e7ffe 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_inline_lifts.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_inline_lifts.py
@@ -27,15 +27,15 @@ def inline_lift_test_data():
         ),
         (
             # can_deref(lift(f)(args...)) -> and(can_deref(arg[0]), and(can_deref(arg[1]), ...))
-            im.call("can_deref")(im.lift("f")("arg1", "arg2")),
-            im.and_(im.call("can_deref")("arg1"), im.call("can_deref")("arg2")),
+            im.can_deref(im.lift("f")("arg1", "arg2")),
+            im.and_(im.can_deref("arg1"), im.can_deref("arg2")),
         ),
         (
             # can_deref(shift(...)(lift(f)(args...)) -> and(can_deref(shift(...)(arg[0])), and(can_deref(shift(...)(arg[1])), ...))
-            im.call("can_deref")(im.shift("I", 1)(im.lift("f")("arg1", "arg2"))),
+            im.can_deref(im.shift("I", 1)(im.lift("f")("arg1", "arg2"))),
             im.and_(
-                im.call("can_deref")(im.shift("I", 1)("arg1")),
-                im.call("can_deref")(im.shift("I", 1)("arg2")),
+                im.can_deref(im.shift("I", 1)("arg1")),
+                im.can_deref(im.shift("I", 1)("arg2")),
             ),
         ),
         (
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_prune_casts.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_prune_casts.py
index 77d3323f..b1a18dda 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_prune_casts.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_prune_casts.py
@@ -16,10 +16,10 @@ from gt4py.next.type_system import type_specifications as ts
 def test_prune_casts_simple():
     x_ref = im.ref("x", ts.ScalarType(kind=ts.ScalarKind.FLOAT32))
     y_ref = im.ref("y", ts.ScalarType(kind=ts.ScalarKind.FLOAT64))
-    testee = im.call("plus")(im.call("cast_")(x_ref, "float64"), im.call("cast_")(y_ref, "float64"))
+    testee = im.plus(im.cast_(x_ref, "float64"), im.cast_(y_ref, "float64"))
     testee = type_inference.infer(testee, offset_provider_type={}, allow_undeclared_symbols=True)
 
-    expected = im.call("plus")(im.call("cast_")(x_ref, "float64"), y_ref)
+    expected = im.plus(im.cast_(x_ref, "float64"), y_ref)
     actual = PruneCasts.apply(testee)
     assert actual == expected
 
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_trace_shifts.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_trace_shifts.py
index 1cf662e2..dd7a8f4d 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_trace_shifts.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_trace_shifts.py
@@ -44,7 +44,7 @@ def test_neighbors():
 
 def test_reduce():
     # λ(inp) → reduce(plus, 0.)(·inp)
-    testee = im.lambda_("inp")(im.call(im.call("reduce")("plus", 0.0))(im.deref("inp")))
+    testee = im.lambda_("inp")(im.reduce("plus", 0.0)(im.deref("inp")))
     expected = [{()}]
 
     actual = TraceShifts.trace_stencil(testee)
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_unroll_reduce.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_unroll_reduce.py
index 07602479..2415a422 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_unroll_reduce.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_unroll_reduce.py
@@ -35,27 +35,25 @@ def has_skip_values(request):
 @pytest.fixture
 def basic_reduction():
     UIDs.reset_sequence()
-    return im.call(im.call("reduce")("foo", 0.0))(im.neighbors("Dim", "x"))
+    return im.reduce("foo", 0.0)(im.neighbors("Dim", "x"))
 
 
 @pytest.fixture
 def reduction_with_shift_on_second_arg():
     UIDs.reset_sequence()
-    return im.call(im.call("reduce")("foo", 0.0))("x", im.neighbors("Dim", "y"))
+    return im.reduce("foo", 0.0)("x", im.neighbors("Dim", "y"))
 
 
 @pytest.fixture
 def reduction_with_incompatible_shifts():
     UIDs.reset_sequence()
-    return im.call(im.call("reduce")("foo", 0.0))(
-        im.neighbors("Dim", "x"), im.neighbors("Dim2", "y")
-    )
+    return im.reduce("foo", 0.0)(im.neighbors("Dim", "x"), im.neighbors("Dim2", "y"))
 
 
 @pytest.fixture
 def reduction_with_irrelevant_full_shift():
     UIDs.reset_sequence()
-    return im.call(im.call("reduce")("foo", 0.0))(
+    return im.reduce("foo", 0.0)(
         im.neighbors("Dim", im.shift("IrrelevantDim", 0)("x")), im.neighbors("Dim", "y")
     )
 
@@ -63,7 +61,7 @@ def reduction_with_irrelevant_full_shift():
 @pytest.fixture
 def reduction_if():
     UIDs.reset_sequence()
-    return im.call(im.call("reduce")("foo", 0.0))(im.if_(True, im.neighbors("Dim", "x"), "y"))
+    return im.reduce("foo", 0.0)(im.if_(True, im.neighbors("Dim", "x"), "y"))
 
 
 @pytest.mark.parametrize(
@@ -83,35 +81,26 @@ def test_get_partial_offsets(reduction, request):
 
 
 def _expected(red, dim, max_neighbors, has_skip_values, shifted_arg=0):
-    acc = ir.SymRef(id="_acc_1")
-    offset = ir.SymRef(id="_i_2")
-    step = ir.SymRef(id="_step_3")
+    acc, offset, step = "_acc_1", "_i_2", "_step_3"
 
     red_fun, red_init = red.fun.args
 
-    elements = [ir.FunCall(fun=ir.SymRef(id="list_get"), args=[offset, arg]) for arg in red.args]
+    elements = [im.list_get(offset, arg) for arg in red.args]
 
-    step_expr = ir.FunCall(fun=red_fun, args=[acc] + elements)
+    step_expr = im.call(red_fun)(acc, *elements)
     if has_skip_values:
         neighbors_offset = red.args[shifted_arg].args[0]
         neighbors_it = red.args[shifted_arg].args[1]
-        can_deref = ir.FunCall(
-            fun=ir.SymRef(id="can_deref"),
-            args=[
-                ir.FunCall(
-                    fun=ir.FunCall(fun=ir.SymRef(id="shift"), args=[neighbors_offset, offset]),
-                    args=[neighbors_it],
-                )
-            ],
-        )
-        step_expr = ir.FunCall(fun=ir.SymRef(id="if_"), args=[can_deref, step_expr, acc])
-    step_fun = ir.Lambda(params=[ir.Sym(id=acc.id), ir.Sym(id=offset.id)], expr=step_expr)
+        can_deref = im.can_deref(im.shift(neighbors_offset, offset)(neighbors_it))
+
+        step_expr = im.if_(can_deref, step_expr, acc)
+    step_fun = im.lambda_(acc, offset)(step_expr)
 
     step_app = red_init
     for i in range(max_neighbors):
-        step_app = ir.FunCall(fun=step, args=[step_app, ir.OffsetLiteral(value=i)])
+        step_app = im.call(step)(step_app, ir.OffsetLiteral(value=i))
 
-    return ir.FunCall(fun=ir.Lambda(params=[ir.Sym(id=step.id)], expr=step_app), args=[step_fun])
+    return im.let(step, step_fun)(step_app)
 
 
 def test_basic(basic_reduction, has_skip_values):
diff --git a/tests/next_tests/unit_tests/program_processor_tests/codegens_tests/gtfn_tests/test_gtfn_module.py b/tests/next_tests/unit_tests/program_processor_tests/codegens_tests/gtfn_tests/test_gtfn_module.py
index 53e463c6..e7053d33 100644
--- a/tests/next_tests/unit_tests/program_processor_tests/codegens_tests/gtfn_tests/test_gtfn_module.py
+++ b/tests/next_tests/unit_tests/program_processor_tests/codegens_tests/gtfn_tests/test_gtfn_module.py
@@ -60,7 +60,7 @@ def program_example():
         declarations=[],
         body=[
             itir.SetAt(
-                expr=im.call(im.call("as_fieldop")(itir.SymRef(id="stencil"), domain))(
+                expr=im.as_fieldop(itir.SymRef(id="stencil"), domain)(
                     itir.SymRef(id="buf"), itir.SymRef(id="sc")
                 ),
                 domain=domain,
diff --git a/tests/next_tests/unit_tests/program_processor_tests/codegens_tests/gtfn_tests/test_itir_to_gtfn_ir.py b/tests/next_tests/unit_tests/program_processor_tests/codegens_tests/gtfn_tests/test_itir_to_gtfn_ir.py
index 97591122..50e8fa43 100644
--- a/tests/next_tests/unit_tests/program_processor_tests/codegens_tests/gtfn_tests/test_itir_to_gtfn_ir.py
+++ b/tests/next_tests/unit_tests/program_processor_tests/codegens_tests/gtfn_tests/test_itir_to_gtfn_ir.py
@@ -47,7 +47,7 @@ def test_get_domains():
         declarations=[],
         body=[
             itir.SetAt(
-                expr=im.call(im.call("as_fieldop")("deref"))(),
+                expr=im.as_fieldop("deref")(),
                 domain=domain,
                 target=itir.SymRef(id="bar"),
             )
diff --git a/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/test_gtir_to_sdfg.py b/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/test_gtir_to_sdfg.py
index faf61187..bfde179e 100644
--- a/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/test_gtir_to_sdfg.py
+++ b/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/test_gtir_to_sdfg.py
@@ -1183,9 +1183,7 @@ def test_gtir_neighbors_as_input():
             gtir.SetAt(
                 expr=im.as_fieldop(
                     im.lambda_("it")(
-                        im.call(im.call("reduce")("plus", im.literal_from_value(init_value)))(
-                            im.deref("it")
-                        )
+                        im.reduce("plus", im.literal_from_value(init_value))(im.deref("it"))
                     ),
                     vertex_domain,
                 )(
@@ -1283,25 +1281,15 @@ def test_gtir_neighbors_as_output():
 def test_gtir_reduce():
     init_value = np.random.rand()
     vertex_domain = im.domain(gtx_common.GridType.UNSTRUCTURED, ranges={Vertex: (0, "nvertices")})
-    stencil_inlined = im.call(
-        im.call("as_fieldop")(
-            im.lambda_("it")(
-                im.call(im.call("reduce")("plus", im.literal_from_value(init_value)))(
-                    im.neighbors("V2E", "it")
-                )
-            ),
-            vertex_domain,
-        )
+    stencil_inlined = im.as_fieldop(
+        im.lambda_("it")(
+            im.reduce("plus", im.literal_from_value(init_value))(im.neighbors("V2E", "it"))
+        ),
+        vertex_domain,
     )("edges")
-    stencil_fieldview = im.call(
-        im.call("as_fieldop")(
-            im.lambda_("it")(
-                im.call(im.call("reduce")("plus", im.literal_from_value(init_value)))(
-                    im.deref("it")
-                )
-            ),
-            vertex_domain,
-        )
+    stencil_fieldview = im.as_fieldop(
+        im.lambda_("it")(im.reduce("plus", im.literal_from_value(init_value))(im.deref("it"))),
+        vertex_domain,
     )(im.as_fieldop_neighbors("V2E", "edges", vertex_domain))
 
     connectivity_V2E = SIMPLE_MESH.offset_provider["V2E"]
@@ -1349,25 +1337,15 @@ def test_gtir_reduce():
 def test_gtir_reduce_with_skip_values():
     init_value = np.random.rand()
     vertex_domain = im.domain(gtx_common.GridType.UNSTRUCTURED, ranges={Vertex: (0, "nvertices")})
-    stencil_inlined = im.call(
-        im.call("as_fieldop")(
-            im.lambda_("it")(
-                im.call(im.call("reduce")("plus", im.literal_from_value(init_value)))(
-                    im.neighbors("V2E", "it")
-                )
-            ),
-            vertex_domain,
-        )
+    stencil_inlined = im.as_fieldop(
+        im.lambda_("it")(
+            im.reduce("plus", im.literal_from_value(init_value))(im.neighbors("V2E", "it"))
+        ),
+        vertex_domain,
     )("edges")
-    stencil_fieldview = im.call(
-        im.call("as_fieldop")(
-            im.lambda_("it")(
-                im.call(im.call("reduce")("plus", im.literal_from_value(init_value)))(
-                    im.deref("it")
-                )
-            ),
-            vertex_domain,
-        )
+    stencil_fieldview = im.as_fieldop(
+        im.lambda_("it")(im.reduce("plus", im.literal_from_value(init_value))(im.deref("it"))),
+        vertex_domain,
     )(im.as_fieldop_neighbors("V2E", "edges", vertex_domain))
 
     connectivity_V2E = SKIP_VALUE_MESH.offset_provider["V2E"]
@@ -1450,15 +1428,11 @@ def test_gtir_reduce_dot_product():
         declarations=[],
         body=[
             gtir.SetAt(
-                expr=im.call(
-                    im.call("as_fieldop")(
-                        im.lambda_("it")(
-                            im.call(im.call("reduce")("plus", im.literal_from_value(init_value)))(
-                                im.deref("it")
-                            )
-                        ),
-                        vertex_domain,
-                    )
+                expr=im.as_fieldop(
+                    im.lambda_("it")(
+                        im.reduce("plus", im.literal_from_value(init_value))(im.deref("it"))
+                    ),
+                    vertex_domain,
                 )(
                     im.op_as_fieldop(im.map_("plus"), vertex_domain)(
                         im.op_as_fieldop(im.map_("multiplies"), vertex_domain)(
@@ -1508,9 +1482,7 @@ def test_gtir_reduce_with_cond_neighbors():
             gtir.SetAt(
                 expr=im.as_fieldop(
                     im.lambda_("it")(
-                        im.call(im.call("reduce")("plus", im.literal_from_value(init_value)))(
-                            im.deref("it")
-                        )
+                        im.reduce("plus", im.literal_from_value(init_value))(im.deref("it"))
                     ),
                     vertex_domain,
                 )(
@@ -1958,8 +1930,8 @@ def test_gtir_if_scalars():
                                     "f",
                                     im.if_(
                                         "pred",
-                                        im.call("cast_")("y_0", "float64"),
-                                        im.call("cast_")("y_1", "float64"),
+                                        im.cast_("y_0", "float64"),
+                                        im.cast_("y_1", "float64"),
                                     ),
                                 )
                             )
diff --git a/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/transformation_tests/test_make_transients_persistent.py b/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/transformation_tests/test_make_transients_persistent.py
new file mode 100644
index 00000000..d8cf8e33
--- /dev/null
+++ b/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/transformation_tests/test_make_transients_persistent.py
@@ -0,0 +1,74 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2024, ETH Zurich
+# All rights reserved.
+#
+# Please, refer to the LICENSE file in the root directory.
+# SPDX-License-Identifier: BSD-3-Clause
+
+import pytest
+
+dace = pytest.importorskip("dace")
+from dace.sdfg import nodes as dace_nodes
+
+from gt4py.next.program_processors.runners.dace import (
+    transformations as gtx_transformations,
+)
+
+from . import util
+
+import dace
+
+
+def _make_transients_persistent_inner_access_sdfg() -> tuple[dace.SDFG, dace.SDFGState]:
+    sdfg = dace.SDFG(util.unique_name("transients_persistent_inner_access_sdfg"))
+    state = sdfg.add_state(is_start_block=True)
+
+    for name in "abc":
+        sdfg.add_array(
+            name,
+            shape=(10,),
+            dtype=dace.float64,
+            transient=False,
+        )
+    sdfg.arrays["b"].transient = True
+
+    me: dace_nodes.MapEntry
+    mx: dace_nodes.MapExit
+    me, mx = state.add_map("comp", ndrange={"__i0": "0:10"})
+    a, b, c = (state.add_access(name) for name in "abc")
+    tsklt: dace_nodes.Tasklet = state.add_tasklet(
+        "tsklt",
+        inputs={"__in"},
+        code="__out = __in + 1.0",
+        outputs={"__out"},
+    )
+
+    me.add_in_connector("IN_A")
+    state.add_edge(a, None, me, "IN_A", dace.Memlet("a[0:10]"))
+
+    me.add_out_connector("OUT_A")
+    state.add_edge(me, "OUT_A", b, None, dace.Memlet("a[__i0] -> [__i0]"))
+
+    state.add_edge(b, None, tsklt, "__in", dace.Memlet("b[__i0]"))
+
+    mx.add_in_connector("IN_C")
+    state.add_edge(tsklt, "__out", mx, "IN_C", dace.Memlet("c[__i0]"))
+
+    mx.add_out_connector("OUT_C")
+    state.add_edge(mx, "OUT_C", c, None, dace.Memlet("c[0:10]"))
+    sdfg.validate()
+    return sdfg, state
+
+
+def test_make_transients_persistent_inner_access():
+    sdfg, state = _make_transients_persistent_inner_access_sdfg()
+    assert sdfg.arrays["b"].lifetime is dace.dtypes.AllocationLifetime.Scope
+
+    # Because `b`, the only transient, is used inside a map scope, it is not selected,
+    #  although in this situation it would be possible.
+    change_report: dict[int, set[str]] = gtx_transformations.gt_make_transients_persistent(
+        sdfg, device=dace.DeviceType.CPU
+    )
+    assert len(change_report) == 1
+    assert change_report[sdfg.cfg_id] == set()