diff --git a/dace/codegen/codegen.py b/dace/codegen/codegen.py index fc6791599f..1a6e5a6636 100644 --- a/dace/codegen/codegen.py +++ b/dace/codegen/codegen.py @@ -245,6 +245,10 @@ def generate_code(sdfg: SDFG, validate=True) -> List[CodeObject]: for k, v in frame._dispatcher.instrumentation.items() } + # Sort SDFG for deterministic code generation, if enabled. + if Config.get_bool('compiler', 'sdfg_alphabetical_sorting'): + sdfg.sort_sdfg_alphabetically() + # NOTE: THE SDFG IS ASSUMED TO BE FROZEN (not change) FROM THIS POINT ONWARDS # Generate frame code (and the rest of the code) diff --git a/dace/config_schema.yml b/dace/config_schema.yml index 2b05d45232..28e1efefb9 100644 --- a/dace/config_schema.yml +++ b/dace/config_schema.yml @@ -251,6 +251,17 @@ required: If set, specifies additional arguments to the initial invocation of ``cmake``. + sdfg_alphabetical_sorting: + type: bool + default: false + title: SDFG alphabetical sorting + description: > + When enabled, sorts all internal SDFG dictionaries + (nodes, edges, arrays, symbols) into a canonical + alphabetical order before code generation. This + guarantees reproducible output across consecutive + compilations at the cost of additional sorting overhead. + ############################################# # CPU compiler cpu: diff --git a/dace/sdfg/sdfg.py b/dace/sdfg/sdfg.py index 3ea7d63c29..12d5d3e82e 100644 --- a/dace/sdfg/sdfg.py +++ b/dace/sdfg/sdfg.py @@ -3006,3 +3006,60 @@ def recheck_using_explicit_control_flow(self) -> bool: break self.root_sdfg.using_explicit_control_flow = found_explicit_cf_block return found_explicit_cf_block + + def sort_sdfg_alphabetically(self, rebuild_nx: bool = False) -> None: + """ + Forces all internal dictionaries, graph structures, and metadata registries + into a deterministic, semantically-aware order to guarantee stable code generation. + + In DaCe, the code generators rely heavily on the iteration order + of internal dictionaries. This method performs a deep, in-place stabilization + of the entire SDFG hierarchy to eliminate stochastic compilation jitter + caused by memory addresses or volatile UUIDs. + + The stabilization process executes in four phases: + 1. Global Metadata: Alphabetizes arrays, symbols, and constants. + 2. Control Flow Regions: Sorts every ``ControlFlowRegion`` in this + SDFG (including the SDFG itself, ``LoopRegion``s, + ``ConditionalBlock``s, etc.) at the state machine level. + 3. Dataflow: Sorts the internal nodes and memlet edges within every + ``SDFGState``. + 4. Nested SDFGs: Recursively applies this stabilization to all + ``NestedSDFG`` nodes found in the states. + + :param rebuild_nx: If True, rebuilds the internal NetworkX graph in each + sorted graph. Default is False for performance, since + DaCe's codegen and pattern matching do not rely on the + internal _nx iteration order. + """ + # Avoid import loops + from dace.sdfg.utils import sort_graph_dicts_alphabetically + + # Stack-local cache for node keys, private to this sort invocation. + # Safe for concurrent SDFG processing — no global state. + node_key_cache: Dict[int, tuple] = {} + + # 1. Stabilize Global Metadata (Arrays, Symbols, Constants) + for attr in ['_arrays', 'symbols', 'constants_prop']: + val = getattr(self, attr) + if val: + sorted_items = sorted(val.items(), key=lambda item: item[0]) + val.clear() + val.update(sorted_items) + + # 2. Stabilize all ControlFlowRegions (state machine level). + # This includes the SDFG itself, plus any nested LoopRegions, + # ConditionalBlocks, etc. — but does NOT recurse into nested SDFGs. + for cfr in self.all_control_flow_regions(recursive=False): + sort_graph_dicts_alphabetically(cfr, rebuild_nx=rebuild_nx, node_key_cache=node_key_cache) + + # 3. Stabilize the Dataflow inside each SDFGState. + # all_states() traverses into nested ControlFlowRegions but NOT + # into nested SDFGs. + for state in self.all_states(): + sort_graph_dicts_alphabetically(state, rebuild_nx=rebuild_nx, node_key_cache=node_key_cache) + + # 4. Recurse into Nested SDFGs + for node in state.nodes(): + if isinstance(node, nd.NestedSDFG): + node.sdfg.sort_sdfg_alphabetically(rebuild_nx=rebuild_nx) diff --git a/dace/sdfg/utils.py b/dace/sdfg/utils.py index 43ebdf569e..7cda7011ca 100644 --- a/dace/sdfg/utils.py +++ b/dace/sdfg/utils.py @@ -2762,3 +2762,217 @@ def expand_nodes(sdfg: SDFG, predicate: Callable[[nd.Node], bool]): if expanded_something: states.append(state) + + +def get_deterministic_node_key(graph: Any, node: Any, node_key_cache: Optional[Dict[int, tuple]] = None) -> tuple: + """ + Generates a highly stable, deterministic key for DaCe graph nodes + based on their semantic properties rather than memory locations. + + During SDFG compilation, relying on memory addresses or volatile UUIDs + for sorting leads to non-deterministic code generation. This function + extracts the intrinsic semantic identity of a node to ensure structural + collisions are resolved deterministically. + + The generated key is a tuple of ``(node_type, label, dynamic_parts)`` + which enforces lexicographic ordering: nodes are first grouped by type, + then by label, with the remaining semantic properties acting as a + tiebreaker. + + The dynamic parts incorporate: + - Graph topology (in-degree and out-degree) + - Interface semantics (in/out connectors, always included for + ``nd.Node`` subclasses even when empty) + - Loop and memory semantics (map parameters, ranges, schedules) + - Internal code logic (Tasklet code string) + - Nested SDFG structural size and symbol mapping + + When called from the sorting path, an optional ``node_key_cache`` dict + can be provided to avoid redundant recomputation during sort comparisons. + The cache is private to each ``sort_sdfg_alphabetically()`` invocation + and lives on the stack, so concurrent SDFG processing is safe. + + :param graph: The containing graph (e.g., SDFGState or ControlFlowRegion) + used to compute in-degree and out-degree. May be ``None``, + in which case degree information is omitted from the key. + :param node: The DaCe graph node (e.g., Tasklet, AccessNode, MapEntry) + to be evaluated. + :param node_key_cache: Optional node-key cache dict, private to the current + ``sort_sdfg_alphabetically()`` call. When ``None``, + no caching is performed. + :return: A tuple ``(node_type, label, dynamic_parts)`` representing + the node's semantic identity. + """ + if node_key_cache is not None: + node_id = id(node) + if node_id in node_key_cache: + return node_key_cache[node_id] + + node_type = type(node).__name__ + + # Extract core identifier + if hasattr(node, 'data'): + raw_label = node.data + elif hasattr(node, 'label'): + raw_label = node.label + else: + raw_label = str(node) + + parts = [] + + # 1. Topological Context (requires the containing graph) + if graph is not None: + try: + in_deg = graph.in_degree(node) + out_deg = graph.out_degree(node) + parts.append(f"i{in_deg}o{out_deg}") + except (ValueError, KeyError): + # Node might not belong to this graph (e.g., during NX rebuild) + pass + + # 2. Interface Semantics (Connectors) + if isinstance(node, nd.Node): + parts.append("inC:{" + "-".join(sorted(node.in_connectors.keys())) + "}") + parts.append("outC:{" + "-".join(sorted(node.out_connectors.keys())) + "}") + + # 3. Map Semantics (Parameters, Ranges & Schedules) + if isinstance(node, (nd.MapEntry, nd.MapExit)): + parts.append("map:" + "-".join(node.map.params)) + parts.append("range:" + str(node.map.range)) + parts.append(f"sch:{str(node.map.schedule)}") + + # 4. Internal Code Semantics (Tasklets) + if isinstance(node, nd.Tasklet): + code_str = str(node.code.as_string).strip() + parts.append(f"code:{code_str}") + + # 5. Nested SDFG Differentiation + if isinstance(node, nd.NestedSDFG): + parts.append(f"nsdfg_states:{len(node.sdfg.nodes())}") + # Include symbol mapping for further differentiation + sorted_syms = dict(sorted({str(k): str(v) for k, v in node.symbol_mapping.items()}.items())) + parts.append(f"symmap:{sorted_syms}") + + # Tuple enforces lexicographic order: group by type, then label, + # then use dynamic parts as a tiebreaker. + result = (node_type, str(raw_label), "_".join(parts)) + if node_key_cache is not None: + node_key_cache[id(node)] = result + return result + + +def get_deterministic_edge_key(graph: Any, edge: Any, node_key_cache: Optional[Dict[int, tuple]] = None) -> tuple: + """ + Generates a highly stable key for graph edges to ensure + deterministic sorting. + + This function extracts the semantic connection points (connectors) + and the data payload (Memlets or Interstate conditions) to prevent + non-deterministic compiler graph traversals. + + For interstate edges, whose data object lacks a stable ``__str__()`` + and would produce a memory-address-based representation, the function + extracts the ``condition`` and ``assignments`` attributes instead. + + :param graph: The containing graph, passed through to + :func:`get_deterministic_node_key` for degree computation. + :param edge: The DaCe graph edge (or InterstateEdge) to be evaluated. + :param node_key_cache: Optional node-key cache dict, passed through to + :func:`get_deterministic_node_key`. + :return: A tuple representing the edge's routing and payload. + """ + # 1. Extract connector strings + raw_src_conn = str(getattr(edge, 'src_conn', '')) + raw_dst_conn = str(getattr(edge, 'dst_conn', '')) + + # 2. Extract data payload, handling InterstateEdge specially since it + # lacks __str__() and would produce an unstable memory-address-based + # representation. + edge_data = getattr(edge, 'data', '') + if hasattr(edge_data, 'condition') and hasattr(edge_data, 'assignments'): + cond_str = str(edge_data.condition) + sorted_assigns = dict(sorted({str(k): str(v) for k, v in edge_data.assignments.items()}.items())) + raw_data_str = f"cond:{cond_str}_assign:{sorted_assigns}" + else: + raw_data_str = str(edge_data) + + # 3. Retrieve the stabilized keys for the source and destination nodes + src_key = get_deterministic_node_key(graph, edge.src, node_key_cache) + dst_key = get_deterministic_node_key(graph, edge.dst, node_key_cache) + + return (src_key, raw_src_conn, dst_key, raw_dst_conn, raw_data_str) + + +def sort_graph_dicts_alphabetically(graph: Union['ControlFlowRegion', 'SDFGState'], + rebuild_nx: bool = False, + node_key_cache: Optional[Dict[int, tuple]] = None) -> None: + """ + Sorts internal graph nodes, edge dictionaries, and NetworkX backends in-place + using semantically-aware deterministic keys. + + In DaCe, the order in which code is generated heavily depends on + the iteration order of the graph's internal dictionaries. This function performs + a deep, in-place sort of these structures to guarantee that graph traversal + and code generation are perfectly deterministic across different executions. + + The stabilization process occurs in four phases: + 1. Alphabetizes the master `_nodes` dictionary based on semantic node keys. + 2. Alphabetizes the nested adjacency lists (`in_edges`, `out_edges`) for + every node based on semantic edge keys. + 3. Alphabetizes the master `_edges` dictionary. + 4. (Optional) Tears down and sequentially rebuilds the underlying NetworkX + graph (`_nx`) so its internal node/edge registries match the newly + stabilized order. Skipped by default since pattern matching builds + its own NetworkX digraph via collapse_multigraph_to_nx. + + :param graph: The DaCe graph structure (e.g., SDFGState or ControlFlowRegion) + whose internal dictionaries need to be stabilized. + :param rebuild_nx: If True, rebuilds the internal NetworkX graph to match + the new order. Default is False for performance, since + DaCe's codegen and pattern matching do not rely on the + internal _nx iteration order. + :param node_key_cache: Optional node-key cache dict, private to the current + ``sort_sdfg_alphabetically()`` call. Passed through to + key functions to avoid redundant computation. + """ + # 1. Sort the master Nodes dictionary + sorted_node_items = sorted(graph._nodes.items(), + key=lambda item: get_deterministic_node_key(graph, item[0], node_key_cache)) + graph._nodes.clear() + graph._nodes.update(sorted_node_items) + + # 2. Sort the nested adjacency lists (In/Out Edges) within each node + for node, (in_edges, out_edges) in graph._nodes.items(): + for edges in [in_edges, out_edges]: + sorted_edges = sorted(edges.items(), + key=lambda item: get_deterministic_edge_key(graph, item[1], node_key_cache)) + edges.clear() + edges.update(sorted_edges) + + # 3. Sort the master Edges dictionary + sorted_edge_items = sorted(graph._edges.items(), + key=lambda item: get_deterministic_edge_key(graph, item[1], node_key_cache)) + graph._edges.clear() + graph._edges.update(sorted_edge_items) + + # 4. Optionally rebuild the NetworkX backend to match the new deterministic order + if rebuild_nx and hasattr(graph, '_nx'): + old_nx = graph._nx + graph._nx = type(old_nx)() + + # Add nodes sequentially + for n in graph._nodes.keys(): + graph._nx.add_node(n, **old_nx.nodes.get(n, {})) + + # Add edges sequentially using the newly sorted master edge list + for e_obj in graph._edges.values(): + edge_attrs = {'data': e_obj.data} + + if hasattr(e_obj, 'src_conn'): + edge_attrs['src_conn'] = e_obj.src_conn + edge_attrs['dst_conn'] = e_obj.dst_conn + + if hasattr(e_obj, 'key'): + graph._nx.add_edge(e_obj.src, e_obj.dst, key=e_obj.key, **edge_attrs) + else: + graph._nx.add_edge(e_obj.src, e_obj.dst, **edge_attrs) diff --git a/tests/sdfg/sdfg_alphabetical_sorting_test.py b/tests/sdfg/sdfg_alphabetical_sorting_test.py new file mode 100644 index 0000000000..e80ef644ae --- /dev/null +++ b/tests/sdfg/sdfg_alphabetical_sorting_test.py @@ -0,0 +1,255 @@ +import random + +import dace +from dace.sdfg.utils import get_deterministic_node_key, get_deterministic_edge_key + +# Enable alphabetical sorting for all tests in this module. +dace.Config.set("compiler", "sdfg_alphabetical_sorting", value=True) + + +def _scramble_dict_in_place(d): + """Helper to randomize dictionary insertion order without changing its type.""" + if not d: + return + keys = list(d.keys()) + random.shuffle(keys) + items = [(k, d[k]) for k in keys] + d.clear() + d.update(items) + + +def _scramble_sdfg(sdfg): + """Deeply scramble all internal dictionaries of an SDFG to simulate non-determinism.""" + # Scramble top-level metadata + _scramble_dict_in_place(sdfg._arrays) + if hasattr(sdfg, 'symbols') and sdfg.symbols: + _scramble_dict_in_place(sdfg.symbols) + if hasattr(sdfg, 'constants_prop') and sdfg.constants_prop: + _scramble_dict_in_place(sdfg.constants_prop) + + # Scramble state machine level + _scramble_dict_in_place(sdfg._nodes) + _scramble_dict_in_place(sdfg._edges) + + # Scramble dataflow inside each state + for state in sdfg.nodes(): + _scramble_dict_in_place(state._nodes) + _scramble_dict_in_place(state._edges) + + for node, (in_edges, out_edges) in state._nodes.items(): + _scramble_dict_in_place(in_edges) + _scramble_dict_in_place(out_edges) + + # Recurse into nested SDFGs + for node in state.nodes(): + if hasattr(node, 'sdfg') and node.sdfg is not None: + _scramble_sdfg(node.sdfg) + + +def _snapshot_order(sdfg): + """Capture the current iteration order of all internal dictionaries as a hashable snapshot.""" + result = [] + + # Metadata + result.append(('arrays', tuple(sdfg._arrays.keys()))) + + # State machine + state_node_keys = tuple(get_deterministic_node_key(sdfg, n) for n in sdfg._nodes.keys()) + result.append(('sdfg_nodes', state_node_keys)) + + # Dataflow per state + for i, state in enumerate(sdfg.nodes()): + node_keys = tuple(get_deterministic_node_key(state, n) for n in state._nodes.keys()) + edge_keys = tuple(get_deterministic_edge_key(state, state._edges[k]) for k in state._edges.keys()) + result.append((f'state_{i}_nodes', node_keys)) + result.append((f'state_{i}_edges', edge_keys)) + + for j, (node, (in_edges, out_edges)) in enumerate(state._nodes.items()): + in_keys = tuple(get_deterministic_edge_key(state, in_edges[k]) for k in in_edges.keys()) + out_keys = tuple(get_deterministic_edge_key(state, out_edges[k]) for k in out_edges.keys()) + result.append((f'state_{i}_node_{j}_in', in_keys)) + result.append((f'state_{i}_node_{j}_out', out_keys)) + + return tuple(result) + + +def _build_test_sdfg(): + """Build a simple SDFG with enough structure to exercise all sorting paths.""" + sdfg = dace.SDFG('deterministic_test') + sdfg.add_array('A', [10], dace.float64) + sdfg.add_array('B', [10], dace.float64) + sdfg.add_scalar('s', dace.float64, transient=True) + + state = sdfg.add_state('state0') + a = state.add_read('A') + b = state.add_write('B') + tasklet = state.add_tasklet('compute', {'a'}, {'b'}, 'b = a + 1') + state.add_edge(a, None, tasklet, 'a', dace.Memlet.from_array('A', sdfg.arrays['A'])) + state.add_edge(tasklet, 'b', b, None, dace.Memlet.from_array('B', sdfg.arrays['B'])) + + return sdfg + + +def test_sdfg_alphabetical_sorting_basic(): + """ + Tests that the SDFG and its internal states can be forced into a strictly + deterministic topological order, regardless of dictionary insertion history. + """ + sdfg = _build_test_sdfg() + state = sdfg.nodes()[0] + + # Scramble everything + random.seed(42) + _scramble_sdfg(sdfg) + + # Apply the canonicalizer + sdfg.sort_sdfg_alphabetically() + + # Assert that graph nodes are sorted + node_keys = list(state._nodes.keys()) + expected_node_keys = sorted(node_keys, key=lambda n: get_deterministic_node_key(state, n)) + assert node_keys == expected_node_keys, "Graph nodes were not deterministically sorted!" + + # Assert that graph edges are sorted + edge_keys = list(state._edges.keys()) + expected_edge_keys = sorted(edge_keys, key=lambda k: get_deterministic_edge_key(state, state._edges[k])) + assert edge_keys == expected_edge_keys, "Graph edges were not deterministically sorted!" + + # Assert that metadata dicts are sorted + array_keys = list(sdfg._arrays.keys()) + assert array_keys == sorted(array_keys), "SDFG arrays were not alphabetically sorted!" + + # Assert that per-node adjacency lists are sorted + for node, (in_edges, out_edges) in state._nodes.items(): + in_keys = list(in_edges.keys()) + expected_in = sorted(in_keys, key=lambda k: get_deterministic_edge_key(state, in_edges[k])) + assert in_keys == expected_in, f"In-edges for {node} were not deterministically sorted!" + + out_keys = list(out_edges.keys()) + expected_out = sorted(out_keys, key=lambda k: get_deterministic_edge_key(state, out_edges[k])) + assert out_keys == expected_out, f"Out-edges for {node} were not deterministically sorted!" + + +def test_sdfg_alphabetical_sorting_rebuild_nx(): + """ + Tests that when rebuild_nx=True, the NetworkX backend matches + the sorted DaCe dictionary order. + """ + sdfg = _build_test_sdfg() + state = sdfg.nodes()[0] + + random.seed(42) + _scramble_sdfg(sdfg) + + # Sort with NX rebuild enabled + sdfg.sort_sdfg_alphabetically(rebuild_nx=True) + + # The NX node order must match the _nodes dict order + nx_nodes = list(state._nx.nodes()) + dace_nodes = list(state._nodes.keys()) + assert nx_nodes == dace_nodes, ("NetworkX node order does not match sorted DaCe _nodes dict order!") + + +def test_sdfg_alphabetical_sorting_stability(): + """ + Tests that regardless of the initial scrambling, the sort always + produces the same canonical order. Runs multiple scramble+sort + cycles with different random seeds. + """ + reference_snapshot = None + + for seed in range(10): + sdfg = _build_test_sdfg() + + random.seed(seed) + _scramble_sdfg(sdfg) + + sdfg.sort_sdfg_alphabetically() + + snapshot = _snapshot_order(sdfg) + + if reference_snapshot is None: + reference_snapshot = snapshot + else: + assert snapshot == reference_snapshot, (f"Sort produced different order with seed={seed}! " + f"Expected:\n{reference_snapshot}\nGot:\n{snapshot}") + + +def test_sdfg_alphabetical_sorting_idempotency(): + """ + Tests that sorting an already-sorted SDFG produces the same result, + i.e., the operation is idempotent. + """ + sdfg = _build_test_sdfg() + + random.seed(42) + _scramble_sdfg(sdfg) + + # Sort once + sdfg.sort_sdfg_alphabetically() + snapshot_first = _snapshot_order(sdfg) + + # Sort again + sdfg.sort_sdfg_alphabetically() + snapshot_second = _snapshot_order(sdfg) + + assert snapshot_first == snapshot_second, ("Sorting is not idempotent! Second sort produced a different order.") + + +def _build_multistate_test_sdfg(): + """Build an SDFG with multiple states and interstate edges to exercise + ControlFlowRegion sorting paths.""" + sdfg = dace.SDFG('multistate_test') + sdfg.add_array('A', [10], dace.float64) + sdfg.add_array('B', [10], dace.float64) + sdfg.add_scalar('s', dace.float64, transient=True) + + state0 = sdfg.add_state('init') + state1 = sdfg.add_state('compute') + state2 = sdfg.add_state('finalize') + + # Add interstate edges + sdfg.add_edge(state0, state1, dace.InterstateEdge()) + sdfg.add_edge(state1, state2, dace.InterstateEdge()) + + # Add dataflow in the compute state + a = state1.add_read('A') + b = state1.add_write('B') + tasklet = state1.add_tasklet('work', {'a'}, {'b'}, 'b = a * 2') + state1.add_edge(a, None, tasklet, 'a', dace.Memlet.from_array('A', sdfg.arrays['A'])) + state1.add_edge(tasklet, 'b', b, None, dace.Memlet.from_array('B', sdfg.arrays['B'])) + + return sdfg + + +def test_sdfg_alphabetical_sorting_multistate(): + """ + Tests that an SDFG with multiple states and interstate edges is + sorted correctly at both the state machine and dataflow levels. + Exercises the all_control_flow_regions() / all_states() paths. + """ + reference_snapshot = None + + for seed in range(10): + sdfg = _build_multistate_test_sdfg() + + random.seed(seed) + _scramble_sdfg(sdfg) + + sdfg.sort_sdfg_alphabetically() + + snapshot = _snapshot_order(sdfg) + + if reference_snapshot is None: + reference_snapshot = snapshot + else: + assert snapshot == reference_snapshot, (f"Multi-state sort produced different order with seed={seed}! " + f"Expected:\n{reference_snapshot}\nGot:\n{snapshot}") + + +if __name__ == "__main__": + test_sdfg_alphabetical_sorting_basic() + test_sdfg_alphabetical_sorting_rebuild_nx() + test_sdfg_alphabetical_sorting_stability() + test_sdfg_alphabetical_sorting_idempotency() + test_sdfg_alphabetical_sorting_multistate()