From daf8d0871f1c1a477f6f716b8c670134a4f65222 Mon Sep 17 00:00:00 2001 From: Philip Mueller Date: Fri, 13 Feb 2026 07:30:36 +0100 Subject: [PATCH 1/8] Corrected the `remove_{in, out}_connector()` functions. --- dace/sdfg/nodes.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/dace/sdfg/nodes.py b/dace/sdfg/nodes.py index 1382ce273d..2eb174f8c5 100644 --- a/dace/sdfg/nodes.py +++ b/dace/sdfg/nodes.py @@ -194,11 +194,14 @@ def remove_in_connector(self, connector_name: str): :param connector_name: The name of the connector to remove. :return: True if the operation was successful. """ + assert connector_name is not None - if connector_name in self.in_connectors: - connectors = self.in_connectors - del connectors[connector_name] - self.in_connectors = connectors + if connector_name not in self.in_connectors: + return False + + connectors = self.in_connectors + del connectors[connector_name] + self.in_connectors = connectors return True def remove_out_connector(self, connector_name: str): @@ -207,11 +210,14 @@ def remove_out_connector(self, connector_name: str): :param connector_name: The name of the connector to remove. :return: True if the operation was successful. """ + assert connector_name is not None + + if connector_name not in self.out_connectors: + return False - if connector_name in self.out_connectors: - connectors = self.out_connectors - del connectors[connector_name] - self.out_connectors = connectors + connectors = self.out_connectors + del connectors[connector_name] + self.out_connectors = connectors return True def _next_connector_int(self) -> int: From b80ef26eae38f23b3e46c96cfba410714a947220 Mon Sep 17 00:00:00 2001 From: Philip Mueller Date: Fri, 13 Feb 2026 07:32:00 +0100 Subject: [PATCH 2/8] Made a new version of `remove_edge_and_dangling_path()` that can also handle cases on the global scope, but some more tests are needed. --- dace/sdfg/utils.py | 70 ++++++++++++------- .../remove_edge_and_dangling_path_test.py | 43 ++++++++++++ 2 files changed, 86 insertions(+), 27 deletions(-) create mode 100644 tests/sdfg/remove_edge_and_dangling_path_test.py diff --git a/dace/sdfg/utils.py b/dace/sdfg/utils.py index 035be3fdc1..503d792f6c 100644 --- a/dace/sdfg/utils.py +++ b/dace/sdfg/utils.py @@ -866,41 +866,57 @@ def remove_edge_and_dangling_path(state: SDFGState, edge: MultiConnectorEdge): :param state: The state in which the edge exists. :param edge: The edge to remove. """ - mtree = state.memlet_tree(edge) - inwards = (isinstance(edge.src, nd.EntryNode) or isinstance(edge.dst, nd.EntryNode)) + + if edge.data.is_empty(): + state.remove_edge(edge) + if state.degree(edge.dst) == 0: + state.remove_node(edge.dst) + if state.degree(edge.src) == 0: + state.remove_node(edge.src) + return # Traverse tree upwards, removing edges and connectors as necessary - curedge = mtree - while curedge is not None: - e = curedge.edge - state.remove_edge(e) - if inwards: - neighbors = [] if not e.src_conn else [ - neighbor for neighbor in state.out_edges_by_connector(e.src, e.src_conn) - ] - else: - neighbors = [] if not e.dst_conn else [ - neighbor for neighbor in state.in_edges_by_connector(e.dst, e.dst_conn) - ] - if len(neighbors) > 0: # There are still edges connected, leave as-is - break + mtree = state.memlet_tree(edge) + curr_tree = mtree + while curr_tree is not None: + curr_edge = curr_tree.edge + assert not curr_edge.data.is_empty() + state.remove_edge(curr_edge) + + if curr_tree.downwards: + if state.degree(curr_edge.dst) == 0: + # If the edge is isolated we can remove it. + state.remove_node(curr_edge.dst) + else: + # If the node is not isolated we must look at its connectors and clean them. + if isinstance(curr_edge.dst, nd.EntryNode) and curr_edge.dst_conn.startswith("IN_"): + curr_edge.dst.remove_out_connector("OUT_" + curr_edge.dst_conn[3:]) + if curr_edge.dst_conn: + curr_edge.dst.remove_in_connector(curr_edge.dst_conn) + + # There is a fan-out, i.e. the `curr_edge.src_conn` is still in use and we are done here. + if len(list(state.out_edges_by_connector(curr_edge.src, curr_edge.src_conn))) != 0: + return - # Remove connector and matching outer connector - if inwards: - if e.src_conn: - e.src.remove_out_connector(e.src_conn) - e.src.remove_in_connector('IN' + e.src_conn[3:]) else: - if e.dst_conn: - e.dst.remove_in_connector(e.dst_conn) - e.dst.remove_out_connector('OUT' + e.dst_conn[2:]) + if state.degree(curr_edge.src) == 0: + state.remove_node(curr_edge.src) + else: + if isinstance(curr_edge.src, nd.ExitNode) and curr_edge.src_conn.startswith("OUT_"): + curr_edge.src.remove_in_connector("IN_" + curr_edge.src_conn[4:]) + if curr_edge.src_conn: + curr_edge.src.remove_in_connector(curr_edge.src_conn) + + # The connector might be collecting. + if len(list(state.in_edges_by_connector(curr_edge.dst, curr_edge.dst_conn))) != 0: + return - # Continue traversing upwards - curedge = curedge.parent + # Continue traversing tree upwards + curr_tree = curr_tree.parent else: # Check if an isolated node have been created at the root and remove root_edge = mtree.root().edge - root_node: nd.Node = root_edge.src if inwards else root_edge.dst + root_node: nd.Node = root_edge.src if mtree.downwards else root_edge.dst if state.degree(root_node) == 0: state.remove_node(root_node) diff --git a/tests/sdfg/remove_edge_and_dangling_path_test.py b/tests/sdfg/remove_edge_and_dangling_path_test.py new file mode 100644 index 0000000000..400d4dd63f --- /dev/null +++ b/tests/sdfg/remove_edge_and_dangling_path_test.py @@ -0,0 +1,43 @@ +# Copyright 2019-2026 ETH Zurich and the DaCe authors. All rights reserved. +import dace + + +def test_remove_edge_global_scope(): + sdfg = dace.SDFG("simple_edge_remover_test") + state = sdfg.add_state() + + sdfg.add_array("a", shape=(1, ), dtype=dace.float64, transient=False) + sdfg.add_array("b", shape=(1, ), dtype=dace.float64, transient=False) + + tlet = state.add_tasklet( + "comp", + inputs={"__in"}, + outputs={"__out"}, + code="__out = __in + 1.0", + ) + a = state.add_access("a") + b = state.add_access("b") + + up_edge = state.add_edge(a, None, tlet, "__in", dace.Memlet("a[0]")) + down_edge = state.add_edge(tlet, "__out", b, None, dace.Memlet("b[0]")) + sdfg.validate() + + # Now remove the edge using the function. Note that this makes the SDFG invalid, + # because the tasklet wants an input. We ignore that for now. + dace.sdfg.utils.remove_edge_and_dangling_path(state, up_edge) + assert a not in state.nodes() + assert tlet in state.nodes() + assert b in state.nodes() + assert sdfg.arrays.keys() == {"a", "b"} + assert len(tlet.in_connectors) == 0 + assert state.in_degree(tlet) == 0 + assert tlet.out_connectors.keys() == {"__out"} + assert state.out_degree(tlet) == 1 + + # If we now also delete the `down_edge` then the state will become empty. + dace.sdfg.utils.remove_edge_and_dangling_path(state, down_edge) + assert state.number_of_nodes() == 0 + + +if __name__ == '__main__': + test_remove_edge_global_scope() From ebc4fbd7f2d15896e9b0d6ad2d762b5eaf8fb85c Mon Sep 17 00:00:00 2001 From: Philip Mueller Date: Fri, 13 Feb 2026 08:42:17 +0100 Subject: [PATCH 3/8] Fixed a bug --- dace/sdfg/utils.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/dace/sdfg/utils.py b/dace/sdfg/utils.py index 503d792f6c..2cfee08e91 100644 --- a/dace/sdfg/utils.py +++ b/dace/sdfg/utils.py @@ -858,10 +858,11 @@ def set_outer_subset(e: MultiConnectorEdge[dace.Memlet], new_subset: sbs.Subset) return consolidated -def remove_edge_and_dangling_path(state: SDFGState, edge: MultiConnectorEdge): +def remove_edge_and_dangling_path(state: SDFGState, edge: MultiConnectorEdge) -> int: """ - Removes an edge and all of its parent edges in a memlet path, cleaning - dangling connectors and isolated nodes resulting from the removal. + Removes an edge and all of its parent edges in a memlet path, including now + unused connectors. Furthermore, all nodes that become isolated are also + removed from the state. :param state: The state in which the edge exists. :param edge: The edge to remove. @@ -873,15 +874,17 @@ def remove_edge_and_dangling_path(state: SDFGState, edge: MultiConnectorEdge): state.remove_node(edge.dst) if state.degree(edge.src) == 0: state.remove_node(edge.src) - return + return 1 # Traverse tree upwards, removing edges and connectors as necessary mtree = state.memlet_tree(edge) curr_tree = mtree + nb_removed_edges = 0 while curr_tree is not None: curr_edge = curr_tree.edge assert not curr_edge.data.is_empty() state.remove_edge(curr_edge) + nb_removed_edges += 1 if curr_tree.downwards: if state.degree(curr_edge.dst) == 0: @@ -896,7 +899,7 @@ def remove_edge_and_dangling_path(state: SDFGState, edge: MultiConnectorEdge): # There is a fan-out, i.e. the `curr_edge.src_conn` is still in use and we are done here. if len(list(state.out_edges_by_connector(curr_edge.src, curr_edge.src_conn))) != 0: - return + return nb_removed_edges else: if state.degree(curr_edge.src) == 0: @@ -905,20 +908,22 @@ def remove_edge_and_dangling_path(state: SDFGState, edge: MultiConnectorEdge): if isinstance(curr_edge.src, nd.ExitNode) and curr_edge.src_conn.startswith("OUT_"): curr_edge.src.remove_in_connector("IN_" + curr_edge.src_conn[4:]) if curr_edge.src_conn: - curr_edge.src.remove_in_connector(curr_edge.src_conn) + curr_edge.src.remove_out_connector(curr_edge.src_conn) # The connector might be collecting. if len(list(state.in_edges_by_connector(curr_edge.dst, curr_edge.dst_conn))) != 0: - return + return nb_removed_edges # Continue traversing tree upwards curr_tree = curr_tree.parent - else: - # Check if an isolated node have been created at the root and remove - root_edge = mtree.root().edge - root_node: nd.Node = root_edge.src if mtree.downwards else root_edge.dst - if state.degree(root_node) == 0: - state.remove_node(root_node) + + # Check if an isolated node have been created at the root and remove + root_edge = mtree.root().edge + root_node: nd.Node = root_edge.src if mtree.downwards else root_edge.dst + if state.degree(root_node) == 0: + state.remove_node(root_node) + + return nb_removed_edges def consolidate_edges( From 2f7c88d6a9e44f6d1097fb06956b0be22a841f2f Mon Sep 17 00:00:00 2001 From: Philip Mueller Date: Fri, 13 Feb 2026 08:42:32 +0100 Subject: [PATCH 4/8] Added a new test. --- .../remove_edge_and_dangling_path_test.py | 92 ++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/tests/sdfg/remove_edge_and_dangling_path_test.py b/tests/sdfg/remove_edge_and_dangling_path_test.py index 400d4dd63f..07ccd5ac55 100644 --- a/tests/sdfg/remove_edge_and_dangling_path_test.py +++ b/tests/sdfg/remove_edge_and_dangling_path_test.py @@ -24,7 +24,8 @@ def test_remove_edge_global_scope(): # Now remove the edge using the function. Note that this makes the SDFG invalid, # because the tasklet wants an input. We ignore that for now. - dace.sdfg.utils.remove_edge_and_dangling_path(state, up_edge) + nb_removed_edges = dace.sdfg.utils.remove_edge_and_dangling_path(state, up_edge) + assert nb_removed_edges == 1 assert a not in state.nodes() assert tlet in state.nodes() assert b in state.nodes() @@ -35,7 +36,94 @@ def test_remove_edge_global_scope(): assert state.out_degree(tlet) == 1 # If we now also delete the `down_edge` then the state will become empty. - dace.sdfg.utils.remove_edge_and_dangling_path(state, down_edge) + nb_removed_edges = dace.sdfg.utils.remove_edge_and_dangling_path(state, down_edge) + assert nb_removed_edges == 1 + assert state.number_of_nodes() == 0 + + +def test_remove_edge_nested_scope(): + sdfg = dace.SDFG("nested_edge_remover_test") + state = sdfg.add_state() + + sdfg.add_array("a", shape=(10, 10), dtype=dace.float64, transient=False) + sdfg.add_array("b", shape=(10, 10, 2), dtype=dace.float64, transient=False) + + tlet = state.add_tasklet( + "comp", + inputs={"__in1", "__in2"}, + outputs={"__out1", "__out2"}, + code="__out1 = __in1 + 1.0\n__out2 = __in2 - 1.0", + ) + a, b = (state.add_access(name) for name in "ab") + me, mx = state.add_map("outer_map", ndrange={"__i": "0:10"}) + nme, nmx = state.add_map("nested_map", ndrange={"__j": "0:10"}) + + state.add_edge(a, None, me, "IN_a", dace.Memlet("a[0:10, 0:10]")) + me.add_scope_connectors("a") + state.add_edge(me, "OUT_a", nme, "IN_a1", dace.Memlet("a[__i, 0:10]")) + state.add_edge(me, "OUT_a", nme, "IN_a2", dace.Memlet("a[0:10, __i]")) + nme.add_scope_connectors("a1") + nme.add_scope_connectors("a2") + + up_edge1 = state.add_edge(nme, "OUT_a1", tlet, "__in1", dace.Memlet("a[__i, __j]")) + up_edge2 = state.add_edge(nme, "OUT_a2", tlet, "__in2", dace.Memlet("a[__j, __i]")) + + down_edge1 = state.add_edge(tlet, "__out1", nmx, "IN_b", dace.Memlet("b[__i, __j, 0]")) + down_edge2 = state.add_edge(tlet, "__out2", nmx, "IN_b", dace.Memlet("b[__j, __i, 1]")) + nmx.add_scope_connectors("b") + + state.add_edge(nmx, "OUT_b", mx, "IN_b", dace.Memlet("b[0:10, 0:10, 0:2]")) + mx.add_scope_connectors("b") + state.add_edge(mx, "OUT_b", b, None, dace.Memlet("b[0:10, 0:10, 0:2]")) + sdfg.validate() + + assert state.number_of_nodes() == 7 + + # Because of the fan out, the deletion will stop. + nb_rm_up_edge1 = dace.sdfg.utils.remove_edge_and_dangling_path(state, up_edge1) + assert nb_rm_up_edge1 == 2 + assert state.number_of_nodes() == 7 + assert set(tlet.in_connectors.keys()) == {"__in2"} + assert state.in_degree(tlet) == 1 + assert set(tlet.out_connectors.keys()) == {"__out1", "__out2"} + assert state.out_degree(tlet) == 2 + assert set(nme.out_connectors.keys()) == {"OUT_a2"} + assert set(nme.in_connectors.keys()) == {"IN_a2"} + assert state.out_degree(nme) == 1 + assert state.in_degree(nme) == 1 + assert state.out_degree(me) == 1 + assert state.in_degree(me) == 1 + assert set(me.out_connectors.keys()) == {"OUT_a"} + assert set(me.in_connectors.keys()) == {"IN_a"} + assert state.degree(a) == 1 + + # Now the deletion will go up and remove the map entries; which leads to a + # technical and functional invalid SDFG. + nb_rm_up_edge2 = dace.sdfg.utils.remove_edge_and_dangling_path(state, up_edge2) + assert nb_rm_up_edge2 == 3 + assert state.number_of_nodes() == 4 + assert state.number_of_edges() == 4 + assert {tlet, nmx, mx, b} == set(state.nodes()) + assert len(tlet.in_connectors) == 0 + assert set(tlet.out_connectors.keys()) == {"__out1", "__out2"} + + # The first down edge, will only delete one edge, due to the location of the fan + # in, which is a bit different compared to the location of the upper fan out. + nb_rm_down_edge1 = dace.sdfg.utils.remove_edge_and_dangling_path(state, down_edge1) + assert nb_rm_down_edge1 == 1 + assert state.number_of_nodes() == 4 + assert state.number_of_edges() == 3 + assert {tlet, nmx, mx, b} == set(state.nodes()) + assert len(tlet.in_connectors) == 0 + assert set(tlet.out_connectors.keys()) == {"__out2"} + assert len(nmx.in_connectors) == 1 + assert len(nmx.out_connectors) == 1 + assert len(mx.in_connectors) == 1 + assert len(mx.out_connectors) == 1 + + # This will remove all nodes. + nb_rm_down_edge2 = dace.sdfg.utils.remove_edge_and_dangling_path(state, down_edge2) + assert nb_rm_up_edge2 == 3 assert state.number_of_nodes() == 0 From c97d11c24df9219425d5dd4200e5b56727594e38 Mon Sep 17 00:00:00 2001 From: Philip Mueller Date: Fri, 13 Feb 2026 09:38:37 +0100 Subject: [PATCH 5/8] Fxied some issues. --- dace/sdfg/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dace/sdfg/utils.py b/dace/sdfg/utils.py index 2cfee08e91..1d3e50fcdc 100644 --- a/dace/sdfg/utils.py +++ b/dace/sdfg/utils.py @@ -894,7 +894,8 @@ def remove_edge_and_dangling_path(state: SDFGState, edge: MultiConnectorEdge) -> # If the node is not isolated we must look at its connectors and clean them. if isinstance(curr_edge.dst, nd.EntryNode) and curr_edge.dst_conn.startswith("IN_"): curr_edge.dst.remove_out_connector("OUT_" + curr_edge.dst_conn[3:]) - if curr_edge.dst_conn: + if curr_edge.dst_conn and len(list(state.in_edges_by_connector(curr_edge.dst, + curr_edge.dst_conn))) == 0: curr_edge.dst.remove_in_connector(curr_edge.dst_conn) # There is a fan-out, i.e. the `curr_edge.src_conn` is still in use and we are done here. @@ -907,7 +908,8 @@ def remove_edge_and_dangling_path(state: SDFGState, edge: MultiConnectorEdge) -> else: if isinstance(curr_edge.src, nd.ExitNode) and curr_edge.src_conn.startswith("OUT_"): curr_edge.src.remove_in_connector("IN_" + curr_edge.src_conn[4:]) - if curr_edge.src_conn: + if curr_edge.src_conn and len(list(state.out_edges_by_connector(curr_edge.src, + curr_edge.src_conn))) == 0: curr_edge.src.remove_out_connector(curr_edge.src_conn) # The connector might be collecting. From 3c1b6f3ea45677ee3990ba0a35bd18c0442dd66a Mon Sep 17 00:00:00 2001 From: Philip Mueller Date: Fri, 13 Feb 2026 13:16:32 +0100 Subject: [PATCH 6/8] Less restrictive. --- dace/sdfg/nodes.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/dace/sdfg/nodes.py b/dace/sdfg/nodes.py index 2eb174f8c5..a0d26e1db4 100644 --- a/dace/sdfg/nodes.py +++ b/dace/sdfg/nodes.py @@ -194,7 +194,11 @@ def remove_in_connector(self, connector_name: str): :param connector_name: The name of the connector to remove. :return: True if the operation was successful. """ - assert connector_name is not None + if not connector_name: + warnings.warn( + f'Tried to remove the { "\'None\'" if connector_name is None else "EMPTY-STRING"} from the in-connectors of node {str(self)}', + stacklevel=1) + return False if connector_name not in self.in_connectors: return False @@ -210,7 +214,11 @@ def remove_out_connector(self, connector_name: str): :param connector_name: The name of the connector to remove. :return: True if the operation was successful. """ - assert connector_name is not None + if not connector_name: + warnings.warn( + f'Tried to remove the { "\'None\'" if connector_name is None else "EMPTY-STRING"} from the out-connectors of node {str(self)}', + stacklevel=1) + return False if connector_name not in self.out_connectors: return False From 085b101f87de87d89b1c006c7ae9ff19e3a6903a Mon Sep 17 00:00:00 2001 From: Philip Mueller Date: Fri, 13 Feb 2026 13:19:08 +0100 Subject: [PATCH 7/8] Seems to be picker. --- dace/sdfg/nodes.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/dace/sdfg/nodes.py b/dace/sdfg/nodes.py index a0d26e1db4..74ffb9a928 100644 --- a/dace/sdfg/nodes.py +++ b/dace/sdfg/nodes.py @@ -195,9 +195,8 @@ def remove_in_connector(self, connector_name: str): :return: True if the operation was successful. """ if not connector_name: - warnings.warn( - f'Tried to remove the { "\'None\'" if connector_name is None else "EMPTY-STRING"} from the in-connectors of node {str(self)}', - stacklevel=1) + warnings.warn(f'Tried to remove the {connector_name} from the in-connectors of node {str(self)}', + stacklevel=1) return False if connector_name not in self.in_connectors: @@ -215,9 +214,8 @@ def remove_out_connector(self, connector_name: str): :return: True if the operation was successful. """ if not connector_name: - warnings.warn( - f'Tried to remove the { "\'None\'" if connector_name is None else "EMPTY-STRING"} from the out-connectors of node {str(self)}', - stacklevel=1) + warnings.warn(f'Tried to remove the {connector_name} from the out-connectors of node {str(self)}', + stacklevel=1) return False if connector_name not in self.out_connectors: From 34760d10fd51708f70e6d0bb64af0ab51bafbe0c Mon Sep 17 00:00:00 2001 From: "Philip Mueller, CSCS" Date: Mon, 18 May 2026 08:39:21 +0200 Subject: [PATCH 8/8] Applied Tal's comments. --- dace/sdfg/nodes.py | 4 ++-- dace/sdfg/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dace/sdfg/nodes.py b/dace/sdfg/nodes.py index 990168a8d2..157bcfd379 100644 --- a/dace/sdfg/nodes.py +++ b/dace/sdfg/nodes.py @@ -193,7 +193,7 @@ def remove_in_connector(self, connector_name: str): :return: True if the operation was successful. """ if not connector_name: - warnings.warn(f'Tried to remove the {connector_name} from the in-connectors of node {str(self)}', + warnings.warn(f'Tried to remove `{connector_name}` from the in-connectors of node {str(self)}', stacklevel=1) return False @@ -212,7 +212,7 @@ def remove_out_connector(self, connector_name: str): :return: True if the operation was successful. """ if not connector_name: - warnings.warn(f'Tried to remove the {connector_name} from the out-connectors of node {str(self)}', + warnings.warn(f'Tried to remove `{connector_name}` from the out-connectors of node {str(self)}', stacklevel=1) return False diff --git a/dace/sdfg/utils.py b/dace/sdfg/utils.py index 1210f5fd2d..9e8eeda1bc 100644 --- a/dace/sdfg/utils.py +++ b/dace/sdfg/utils.py @@ -887,7 +887,7 @@ def remove_edge_and_dangling_path(state: SDFGState, edge: MultiConnectorEdge) -> if curr_tree.downwards: if state.degree(curr_edge.dst) == 0: - # If the edge is isolated we can remove it. + # If target node is isolated we can remove it. state.remove_node(curr_edge.dst) else: # If the node is not isolated we must look at its connectors and clean them.