Repository: GridTools/gt4py
Pull request #2027 (2025-05-09 09:57:14)

fix[dace][next]: Fixed a Bug in `LoopBlocking`
This PR modifies the `LoopBlocking` in three different ways.

First it modifies how Maps are handled.
Maps are handled specially as they are not incrementally checked for independence but as a whole.
So if a MapEntry is encountered the whole Map scope is added.
Before it was checked that the incoming edges where independent and the loop body did not contains the blocking variable.
However, the output edges were not checked.
The problem was that not all Memlets in a Memlet path needs to refer to the same data, if they cross a scope node, which is what happened here.
Thus we have to explicitly check the that.
Because if we would not do that, the destination in which the Map writes to will be dependent (might be possible though).

The second changes adds a post processing to the set of independent nodes.
This is needed because in some cases it is possible that a Tasklet becomes a boundary node.
This happens if a tasklet writes into an AccessNode (see also later) that is directly connected to the MapExit, in which case the AccessNode is not independent.
If the Tasklet is independent then it will be a boundary, but this is not possible because there is nothing outside that could store/cache its output.
Thus we remove these kind of nodes.

The third modification is related to the second change.
If that AccessNode happens to be a scalar then it is handled.
Between the AccessNode and the MapExit a copy Tasklet is implicitly inserted, this prevent that the inner Map is empty, which is the whole reason for this "an independent node can not be connected to the outer MapExit node" anyway.
However, we only do it for scalars, the reason is, to ensure that no dynamic allocation happens and we do not copy too much around.

Notes:
- Only merge after Edoardo has confirmed that the bug has checked that the bug is really solved.
- Only merge after [PR#2024](https://github.com/GridTools/gt4py/pull/2024) has been merged.



Patch:
diff --git a/src/gt4py/next/iterator/transforms/dead_code_elimination.py b/src/gt4py/next/iterator/transforms/dead_code_elimination.py
index 213f37ca..6ea2739b 100644
--- a/src/gt4py/next/iterator/transforms/dead_code_elimination.py
+++ b/src/gt4py/next/iterator/transforms/dead_code_elimination.py
@@ -49,9 +49,7 @@ def dead_code_elimination(
 
     # remove the unreachable if branches
     # e.g. `if_(True, val1, val2)` -> `val1`
-    program = ConstantFolding.apply(
-        program, enabled_transformations=ConstantFolding.Transformation.FOLD_IF
-    )  # type: ignore[assignment]  # always an itir.Program
+    program = ConstantFolding.apply(program)  # type: ignore[assignment]  # always an itir.Program
 
     # inline again since after constant folding some expressions might not be referenced anymore,
     # e.g. `let field = as_fieldop(...) in val1 end` -> `val1`.
diff --git a/src/gt4py/next/program_processors/runners/dace/transformations/loop_blocking.py b/src/gt4py/next/program_processors/runners/dace/transformations/loop_blocking.py
index 69a57193..481e805d 100644
--- a/src/gt4py/next/program_processors/runners/dace/transformations/loop_blocking.py
+++ b/src/gt4py/next/program_processors/runners/dace/transformations/loop_blocking.py
@@ -11,6 +11,7 @@ from typing import Any, Optional, Union
 
 import dace
 from dace import (
+    data as dace_data,
     properties as dace_properties,
     subsets as dace_subsets,
     transformation as dace_transformation,
@@ -327,6 +328,17 @@ class LoopBlocking(dace_transformation.SingleStateTransformation):
             if not found_new_independent_node:
                 break
 
+        # Perform some cleaning on the independent nodes.
+        self._post_process_independent_nodes(state)
+
+        assert all(
+            all(
+                iedge.src in self._independent_nodes or iedge.src is self.outer_entry
+                for iedge in state.in_edges(inode)
+            )
+            for inode in self._independent_nodes
+        )
+
         # If requested check if the blocking is a good idea.
         if self.require_independent_nodes and (not self._check_if_blocking_is_favourable(state)):
             self._independent_nodes = None
@@ -383,6 +395,10 @@ class LoopBlocking(dace_transformation.SingleStateTransformation):
         if state.in_degree(node_to_classify) == 0 or state.out_degree(node_to_classify) == 0:
             return None
 
+        # The outer MapExit is always classified as dependent.
+        if node_to_classify is outer_exit:
+            return False
+
         # To fully understand what is going on we have to look at the input and output
         #  edges of the node. We define them here to allow to modify them in certain
         #  cases.
@@ -447,17 +463,28 @@ class LoopBlocking(dace_transformation.SingleStateTransformation):
                 return None
 
         elif isinstance(node_to_classify, dace_nodes.MapEntry):
-            # We check a Map as a whole, thus we must modify the output edges,
-            #  because these are not the output edges of the MapEntry, but the one
-            #  of the associated MapExit. Furthermore, we have to add all nodes
-            #  of the scope to the independent nodes.
+            # We check a Map as a whole and add it as a whole to the set of the
+            #  independent nodes. For that reason we must modify `out_edges` since
+            #  now the outputs of the associated `MapExit` must be checked.
+            #  However, we have to run some Map specific checks.
+            # TODO(phimuell): Do we also have to modify `node_to_classify`?
             map_exit = state.exit_node(node_to_classify)
-            out_edges = list(state.out_edges(map_exit))
 
+            # The blocking parameter can not be used inside the Map scope.
             map_scope = state.scope_subgraph(node_to_classify)
             if self.blocking_parameter in map_scope.free_symbols:
                 return False
 
+            # There is an obscure case, where the Memlet on the inside of a Map scope
+            #  and the Memlet on the outside refer to different data, thus we have to
+            #  check that here. Note that we only have to do it here in this case
+            #  because normally it would be spotted above where we checked the input.
+            out_edges = list(state.out_edges(map_exit))
+            if any(self.blocking_parameter in out_edge.data.free_symbols for out_edge in out_edges):
+                return False
+
+            # Add all nodes of the Map scope, including entry and exit node to the
+            #  set of new independent nodes.
             new_independent_nodes.update(map_scope.nodes())
 
         elif isinstance(node_to_classify, dace.libraries.standard.nodes.Reduce):
@@ -479,7 +506,16 @@ class LoopBlocking(dace_transformation.SingleStateTransformation):
         for out_edge in out_edges:
             # We consider nodes that are directly connected to the outer map exit as
             #  dependent. This is an implementation detail to avoid some hard cases.
+            #  The only exceptions are scalars which we will handle later.
+            # NOTE: We restrict ourself to scalars to make sure that no dynamic
+            #  allocation is needed. It could be extended to arrays later.
             if out_edge.dst is outer_exit:
+                if (
+                    isinstance(node_to_classify, dace_nodes.AccessNode)
+                    and isinstance(node_to_classify.desc(sdfg), dace_data.Scalar)
+                    and out_edge.data.wcr is None
+                ):
+                    continue
                 return False
 
         # Now we have to look at incoming edges individually.
@@ -515,6 +551,50 @@ class LoopBlocking(dace_transformation.SingleStateTransformation):
         self._independent_nodes.update(new_independent_nodes)
         return True
 
+    def _post_process_independent_nodes(
+        self,
+        state: dace.SDFGState,
+    ) -> None:
+        """Cleans the set of independent nodes.
+
+        This function is mostly there to handle cases that are not implemented.
+        This function might remove nodes from the set of independent nodes.
+        """
+        assert self._independent_nodes is not None  # silence MyPy
+
+        independent_nodes_were_updated = True
+        while independent_nodes_were_updated:
+            independent_nodes_were_updated = False
+            for node in list(self._independent_nodes):
+                # The only nodes that are important here, are the independent nodes
+                #  at the boundaries, i.e. independent nodes that are connected to
+                #  dependent nodes.
+                if all(
+                    oedge.dst in self._independent_nodes
+                    for oedge in state.out_edges(node)
+                    if not oedge.data.is_empty()
+                ):
+                    continue
+
+                if isinstance(node, dace_nodes.AccessNode):
+                    # This is actually the only case that is implemented, which is also the
+                    #  only case that makes sense, as the AccessNode is needed as cache.
+                    pass
+
+                elif isinstance(node, dace_nodes.Tasklet):
+                    # A Tasklet "generates" some data that has to be stored somewhere, for
+                    #  example in an AccessNode, that has to be independent. Thus we will
+                    #  now remove the node from the set of independent nodes.
+                    self._independent_nodes.remove(node)
+                    independent_nodes_were_updated = True
+                    break
+
+                else:
+                    # We can not handle this kind of boundary node, so remove it.
+                    self._independent_nodes.remove(node)
+                    independent_nodes_were_updated = True
+                    break
+
     def _rewire_map_scope(
         self,
         outer_entry: dace_nodes.MapEntry,
@@ -558,6 +638,54 @@ class LoopBlocking(dace_transformation.SingleStateTransformation):
                 if edge_dst in self._independent_nodes:
                     continue
 
+                # Special case, when we encounter an AccessNode, referring to a scalar,
+                #  that is connected to the outer MapExit node, then we will insert
+                #  a copy Tasklet, thus the copy Tasklet will then serve as dependent
+                #  node that writes into the output.
+                if isinstance(independent_node, dace_nodes.AccessNode) and edge_dst is outer_exit:
+                    assert isinstance(independent_node.desc(sdfg), dace_data.Scalar)
+                    assert not out_edge.data.is_empty()
+
+                    copy_tlet = state.add_tasklet(
+                        name=f"loop_blocking_copy_tlet_{independent_node.data}_{id(out_edge)}",
+                        inputs={"__in"},
+                        outputs={"__out"},
+                        code="__out = __in",
+                    )
+
+                    # remove the current output edge from the state, but we need it.
+                    org_out_edge = out_edge
+                    state.remove_edge(org_out_edge)
+
+                    # Now create a new `out_node`, that connects the independent node
+                    #  with the copy Tasklet. We can use a plain Memlet for that.
+                    #  This is the edge that we will split later.
+                    out_edge = state.add_edge(
+                        independent_node,
+                        org_out_edge.src_conn,
+                        copy_tlet,
+                        "__in",
+                        dace.Memlet(f"{independent_node.data}[0]"),
+                    )
+
+                    # Create the edge that connects the copy Tasklet with the outer MapExit.
+                    #  Here we are copying the original Memlet, however, because the
+                    #  new source is a Tasklet, instead of an AccessNode we must clear
+                    #  the source subset.
+                    new_output_edge = state.add_edge(
+                        copy_tlet,
+                        "__out",
+                        outer_exit,
+                        org_out_edge.dst_conn,
+                        dace.Memlet.from_memlet(org_out_edge.data),
+                    )
+                    new_output_edge.data.src_subset = None
+                    assert new_output_edge.data.dst_subset is not None
+
+                    # Update `edge_dst` and mark the copy Tasklet as processed.
+                    edge_dst = copy_tlet
+                    relocated_nodes.add(copy_tlet)
+
                 # Now split `out_edge` such that it passes through the new inner entry.
                 #  We do not need to modify the subsets, i.e. replacing the variable
                 #  on which we block, because the node is independent and the outgoing
@@ -565,7 +693,7 @@ class LoopBlocking(dace_transformation.SingleStateTransformation):
                 if out_edge.data.is_empty():
                     # `out_edge` is an empty Memlet that ensures its source, which is
                     #  independent, is sequenced before its destination, which is
-                    #  dependent. We now have to split it into two.
+                    #  dependent. We now have to split the Memlet into two.
                     # TODO(phimuell): Can we remove this edge? Is the map enough to
                     #   ensure proper sequencing?
                     new_in_conn = None
@@ -573,11 +701,12 @@ class LoopBlocking(dace_transformation.SingleStateTransformation):
                     new_memlet_outside = dace.Memlet()
 
                 elif not isinstance(independent_node, dace_nodes.AccessNode):
-                    # For syntactical reasons there must be an access node on the
-                    #  outside of the (inner) scope, that acts as cache. The
-                    #  classification and this preconditions on SDFG should ensure
-                    #  that, but there are a few super hard edge cases.
-                    # TODO(phimuell): Add an intermediate here in this case
+                    # For syntactical reasons the boundary, i.e. independent nodes
+                    #  that have a connection to a dependent node, must be AccessNodes,
+                    #  because something is needed as cache. Thus if you hit this
+                    #  case then there is a bug in the `classify_node()` function
+                    #  or your SDFG is wrong.
+                    # NOTE: We do not allow direct connections between Tasklets.
                     raise NotImplementedError()
 
                 else:


Test patch:
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_dead_code_elimination.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_dead_code_elimination.py
index f294bb12..b5786d89 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_dead_code_elimination.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_dead_code_elimination.py
@@ -41,7 +41,10 @@ def program_factory(expr: itir.Expr) -> itir.Program:
 
 @pytest.mark.parametrize(
     "input,expected",
-    [(im.let("val", "inp1")(im.if_(True, "val", "inp2")), im.ref("inp1"))],
+    [
+        (im.let("val", "inp1")(im.if_(True, "val", "inp2")), im.ref("inp1")),
+        (im.let("val", "inp1")(im.if_(im.and_(True, True), "val", "inp2")), im.ref("inp1")),
+    ],
 )
 def test_let_constant_foldable_if(input, expected):
     input_program = program_factory(input)
diff --git a/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/transformation_tests/test_loop_blocking.py b/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/transformation_tests/test_loop_blocking.py
index 2710f795..d5ee2946 100644
--- a/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/transformation_tests/test_loop_blocking.py
+++ b/tests/next_tests/unit_tests/program_processor_tests/runners_tests/dace_tests/transformation_tests/test_loop_blocking.py
@@ -639,9 +639,8 @@ def _make_loop_blocking_sdfg_with_inner_map(
         sdfg.add_array("C", shape=(10,), dtype=dace.float64, transient=False)
         sdfg.add_scalar("tmp", dtype=dace.float64, transient=True)
         sdfg.add_scalar("tmp2", dtype=dace.float64, transient=True)
-        tmp, tmp2, C = (state.add_access(name) for name in ("tmp", "tmp2", "C"))
-        state.add_edge(tmp, None, tmp2, None, dace.Memlet("tmp2[0]"))
-        state.add_edge(tmp2, None, mx_out, "IN_tmp", dace.Memlet("C[__i0]"))
+        tmp, C = (state.add_access(name) for name in ("tmp", "C"))
+        state.add_edge(tmp, None, mx_out, "IN_tmp", dace.Memlet("C[__i0]"))
         mx_out.add_in_connector("IN_tmp")
         state.add_edge(mx_out, "OUT_tmp", C, None, dace.Memlet("C[0:10]"))
         mx_out.add_out_connector("OUT_tmp")
@@ -725,6 +724,7 @@ def test_loop_blocking_inner_map_with_independent_part(independent_part):
     Tests with an inner map with an independent part.
     """
     sdfg, state, outer_map, inner_map = _make_loop_blocking_sdfg_with_inner_map(independent_part)
+    outer_map_exit = state.exit_node(outer_map)
 
     # Find the parts that are independent.
     independent_node: dace_nodes.Tasklet | dace_nodes.NestedSDFG = next(
@@ -737,6 +737,7 @@ def test_loop_blocking_inner_map_with_independent_part(independent_part):
         oedge.dst for oedge in state.out_edges(independent_node)
     )
     assert i_access_node.data == "tmp"
+    assert all(oedge.dst is outer_map_exit for oedge in state.out_edges(i_access_node))
 
     count = sdfg.apply_transformations_repeated(
         gtx_transformations.LoopBlocking(blocking_size=5, blocking_parameter="__i0"),
@@ -1334,3 +1335,299 @@ def test_blocking_size_too_big():
         validate_all=True,
     )
     assert count == 1
+
+
+def _make_loop_blocking_sdfg_with_semi_independent_map() -> (
+    tuple[dace.SDFG, dace.SDFGState, dace_nodes.MapEntry, dace_nodes.MapEntry]
+):
+    sdfg = dace.SDFG(util.unique_name("sdfg_with_inner_semi_independent_map"))
+    state = sdfg.add_state(is_start_block=True)
+
+    sdfg.add_array("A", shape=(40, 3), dtype=dace.float64, transient=False)
+    for name in "BC":
+        sdfg.add_array(name, shape=(40, 8), dtype=dace.float64, transient=False)
+    sdfg.add_scalar("t", dtype=dace.float64, transient=True)
+
+    # Note that creating `T` as an array is not useful at all, a scalar would be
+    #  enough. The only reason for doing it is, that the Memlets inside and outside
+    #  the inner Map scope can refer to different data, and the outside Memlet
+    #  is dependent.
+    sdfg.add_array("T", shape=(8,), dtype=dace.float64, transient=True)
+
+    A, B, C, T, t = (state.add_access(name) for name in "ABCTt")
+
+    me, mx = state.add_map("main_comp", ndrange={"__i0": "0:40", "__i1": "0:8"})
+
+    # The inner computation on its own is not useful.
+    ime, imx = state.add_map("inner_comp", ndrange={"__inner": "1"})
+    itlet = state.add_tasklet(
+        "inner_tasklet",
+        inputs={"__in"},
+        outputs={"__out"},
+        code="__out = __in + 10.0",
+    )
+
+    # This is the dependent Tasklet.
+    dtlet = state.add_tasklet(
+        "dependent_tlet",
+        inputs={"__in1", "__in2"},
+        outputs={"__out"},
+        code="__out = __in1 + __in2",
+    )
+
+    state.add_edge(A, None, me, "IN_A", dace.Memlet("A[0:40, 1]"))
+    state.add_edge(me, "OUT_A", ime, "IN_A", dace.Memlet("A[__i0, 1]"))
+    me.add_scope_connectors("A")
+
+    state.add_edge(ime, "OUT_A", itlet, "__in", dace.Memlet("A[__i0, 1]"))
+    ime.add_scope_connectors("A")
+
+    state.add_edge(itlet, "__out", t, None, dace.Memlet("t[0]"))
+
+    # Here is the interesting part, on the inside of the inner Map scope, the output
+    #  Memlet refers to `t` the scalar, but on the outside the Memlet refers to
+    #  `T` and this also includes the blocking variable `__i1`.
+    state.add_edge(t, None, imx, "IN_t", dace.Memlet("t[0]"))
+    state.add_edge(imx, "OUT_t", T, None, dace.Memlet("T[__i1]"))
+    imx.add_scope_connectors("t")
+
+    state.add_edge(B, None, me, "IN_B", dace.Memlet("B[0:40, 0:8]"))
+    state.add_edge(me, "OUT_B", dtlet, "__in2", dace.Memlet("B[__i0, __i1]"))
+    state.add_edge(T, None, dtlet, "__in1", dace.Memlet("T[__i1]"))
+    me.add_scope_connectors("B")
+
+    state.add_edge(dtlet, "__out", mx, "IN_C", dace.Memlet("C[__i0, __i1]"))
+    state.add_edge(mx, "OUT_C", C, None, dace.Memlet("C[0:40, 0:8]"))
+    mx.add_scope_connectors("C")
+
+    sdfg.validate()
+
+    return sdfg, state, me, ime
+
+
+def test_loop_blocking_sdfg_with_semi_independent_map():
+    sdfg, state, me, ime = _make_loop_blocking_sdfg_with_semi_independent_map()
+
+    scope_dict_before = state.scope_dict()
+    assert scope_dict_before[ime] is me
+
+    count = sdfg.apply_transformations_repeated(
+        gtx_transformations.LoopBlocking(
+            blocking_size=2,
+            blocking_parameter="__i1",
+            require_independent_nodes=False,
+        ),
+        validate=True,
+        validate_all=True,
+    )
+    assert count == 1
+
+    new_scope_of_inner_map = state.scope_dict()[ime]
+    assert isinstance(new_scope_of_inner_map, dace_nodes.MapEntry)
+    assert new_scope_of_inner_map is not me
+
+
+def _make_loop_blocking_only_independent_inner_map() -> (
+    tuple[dace.SDFG, dace.SDFGState, dace_nodes.MapEntry, dace_nodes.MapEntry]
+):
+    sdfg = dace.SDFG(util.unique_name("sdfg_with_only_independent_inner_map"))
+    state = sdfg.add_state(is_start_block=True)
+
+    sdfg.add_array("A", shape=(40, 3), dtype=dace.float64, transient=False)
+    sdfg.add_array("B", shape=(40, 8), dtype=dace.float64, transient=False)
+    sdfg.add_scalar("t", dtype=dace.float64, transient=True)
+
+    A, B, t = (state.add_access(name) for name in "ABt")
+    me, mx = state.add_map("main_comp", ndrange={"__i0": "0:40", "__i1": "0:8"})
+
+    ime, imx = state.add_map("inner_comp", ndrange={"__inner": "1"})
+    itlet = state.add_tasklet(
+        "inner_tasklet",
+        inputs={"__in"},
+        outputs={"__out"},
+        code="__out = __in + 10.0",
+    )
+
+    state.add_edge(A, None, me, "IN_A", dace.Memlet("A[0:40, 1]"))
+    state.add_edge(me, "OUT_A", ime, "IN_A", dace.Memlet("A[__i0, 1]"))
+    me.add_scope_connectors("A")
+
+    state.add_edge(ime, "OUT_A", itlet, "__in", dace.Memlet("A[__i0, 1]"))
+    state.add_edge(itlet, "__out", t, None, dace.Memlet("t[0]"))
+    state.add_edge(t, None, imx, "IN_B", dace.Memlet("t[0]"))
+    ime.add_scope_connectors("A")
+
+    state.add_edge(imx, "OUT_B", mx, "IN_B", dace.Memlet("B[__i0, __i1]"))
+    state.add_edge(mx, "OUT_B", B, None, dace.Memlet("B[0:40, 0:8]"))
+    imx.add_scope_connectors("B")
+    mx.add_scope_connectors("B")
+
+    sdfg.validate()
+
+    return sdfg, state, me, ime
+
+
+def test_loop_blocking_only_independent_inner_map():
+    sdfg, state, me, ime = _make_loop_blocking_only_independent_inner_map()
+
+    scope_dict_before = state.scope_dict()
+    assert scope_dict_before[ime] is me
+
+    count = sdfg.apply_transformations_repeated(
+        gtx_transformations.LoopBlocking(
+            blocking_size=2,
+            blocking_parameter="__i1",
+            require_independent_nodes=False,
+        ),
+        validate=True,
+        validate_all=True,
+    )
+    assert count == 1
+
+    new_scope_of_inner_map = state.scope_dict()[ime]
+    assert isinstance(new_scope_of_inner_map, dace_nodes.MapEntry)
+    assert new_scope_of_inner_map is not me
+
+
+def _make_loop_blocking_output_access_node(
+    scaler_temp: bool,
+) -> tuple[
+    dace.SDFG, dace.SDFGState, dace_nodes.MapEntry, dace_nodes.Tasklet, dace_nodes.AccessNode
+]:
+    sdfg = dace.SDFG(util.unique_name("sdfg_with_direct_output_access_node"))
+    state = sdfg.add_state(is_start_block=True)
+
+    sdfg.add_array("A", shape=(40,), dtype=dace.float64, transient=False)
+    sdfg.add_array("B", shape=(40, 10), dtype=dace.float64, transient=False)
+
+    if scaler_temp:
+        sdfg.add_scalar("t", dtype=dace.float64, transient=True)
+    else:
+        # The only reason why we use an array is because we want to trigger another
+        #  code pass
+        sdfg.add_array("t", shape=(2,), dtype=dace.float64, transient=True)
+
+    A, B, t = (state.add_access(name) for name in "ABt")
+    me, mx = state.add_map("main_comp", ndrange={"__i0": "0:40", "__i1": "0:10"})
+    tlet = state.add_tasklet(
+        "inner_tasklet",
+        inputs={"__in"},
+        outputs={"__out"},
+        code="__out = __in + 10.0",
+    )
+
+    state.add_edge(A, None, me, "IN_A", dace.Memlet("A[0:40]"))
+    state.add_edge(me, "OUT_A", tlet, "__in", dace.Memlet("A[__i0]"))
+    me.add_scope_connectors("A")
+    state.add_edge(tlet, "__out", t, None, dace.Memlet("t[0]"))
+    state.add_edge(t, None, mx, "IN_B", dace.Memlet("B[__i0, __i1]"))
+    state.add_edge(mx, "OUT_B", B, None, dace.Memlet("B[0:40, 0:10]"))
+    mx.add_scope_connectors("B")
+
+    sdfg.validate()
+
+    return sdfg, state, me, tlet, t
+
+
+def test_loop_blocking_direct_access_node_array():
+    sdfg, state, me, tlet, t = _make_loop_blocking_output_access_node(False)
+
+    scope_dict_before = state.scope_dict()
+    assert scope_dict_before[tlet] is me
+    assert scope_dict_before[t] is me
+
+    ref = {
+        "A": np.array(np.random.rand(40), dtype=np.float64, copy=True),
+        "B": np.array(np.random.rand(40, 10), dtype=np.float64, copy=True),
+    }
+    res = copy.deepcopy(ref)
+    csdfg_ref = sdfg.compile()
+    csdfg_ref(**ref)
+
+    # Because of the direct connection, of `t` with the Map exit node and the resulting
+    #  removal of `tlet` from the set of independent node, the transformation will
+    #  not apply, by default, but if requested.
+    #  This is because the intermediate is an array.
+    count = sdfg.apply_transformations_repeated(
+        gtx_transformations.LoopBlocking(
+            blocking_size=2,
+            blocking_parameter="__i1",
+            require_independent_nodes=True,
+        ),
+        validate=True,
+        validate_all=True,
+    )
+    assert count == 0
+
+    count = sdfg.apply_transformations_repeated(
+        gtx_transformations.LoopBlocking(
+            blocking_size=2,
+            blocking_parameter="__i1",
+            require_independent_nodes=False,
+        ),
+        validate=True,
+        validate_all=True,
+    )
+    assert count == 1
+
+    scope_dict_after = state.scope_dict()
+    assert isinstance(scope_dict_after[tlet], dace_nodes.MapEntry)
+    assert scope_dict_after[tlet] is not me
+    assert isinstance(scope_dict_after[t], dace_nodes.MapEntry)
+    assert scope_dict_after[t] is not me
+    assert util.count_nodes(state, dace_nodes.Tasklet) == 1
+
+    csdfg_res = sdfg.compile()
+    csdfg_res(**res)
+    assert all(np.allclose(ref[name], res[name]) for name in ref)
+
+    del csdfg_ref, csdfg_res
+
+
+def test_loop_blocking_direct_access_node_scalar():
+    sdfg, state, me, tlet, t = _make_loop_blocking_output_access_node(True)
+
+    scope_dict_before = state.scope_dict()
+    original_tlets = util.count_nodes(state, dace_nodes.Tasklet, return_nodes=True)
+    assert scope_dict_before[tlet] is me
+    assert scope_dict_before[t] is me
+    assert len(original_tlets) == 1
+
+    ref = {
+        "A": np.array(np.random.rand(40), dtype=np.float64, copy=True),
+        "B": np.array(np.random.rand(40, 10), dtype=np.float64, copy=True),
+    }
+    res = copy.deepcopy(ref)
+    csdfg_ref = sdfg.compile()
+    csdfg_ref(**ref)
+
+    # Because the intermediate is a scalar, it will be handled and both
+    #  the scalar and the tasklet will be independent.
+    count = sdfg.apply_transformations_repeated(
+        gtx_transformations.LoopBlocking(
+            blocking_size=2,
+            blocking_parameter="__i1",
+            require_independent_nodes=True,
+        ),
+        validate=True,
+        validate_all=True,
+    )
+    assert count == 1
+
+    scope_dict_after = state.scope_dict()
+    new_tlets = util.count_nodes(state, dace_nodes.Tasklet, return_nodes=True)
+    assert scope_dict_after[tlet] is me
+    assert scope_dict_after[t] is me
+    assert len(new_tlets) == 2
+
+    new_copy_tlet: dace_nodes.Tasklet = next(
+        iter(ntlet for ntlet in new_tlets if ntlet not in original_tlets)
+    )
+    assert new_copy_tlet.label.startswith("loop_blocking_copy_tlet_t_")
+    assert new_copy_tlet.code == "__out = __in"
+
+    csdfg_res = sdfg.compile()
+    csdfg_res(**res)
+    assert all(np.allclose(ref[name], res[name]) for name in ref)
+
+    del csdfg_ref, csdfg_res