From db7bc02fa973588a5f1a46be42b448b96ad62d9f Mon Sep 17 00:00:00 2001 From: Alex Fallin Date: Tue, 21 Jul 2026 14:48:52 -0700 Subject: [PATCH 1/4] Add experimental motif-based subgraph isomorphism algorithm --- .../cugraph/cugraph/experimental/__init__.py | 13 + .../experimental/isomorphism/__init__.py | 2 + .../cugraph/experimental/isomorphism/motif.py | 104 +++++++ .../experimental/isomorphism/slicing.py | 189 ++++++++++++ .../experimental/isomorphism/solver.py | 292 ++++++++++++++++++ .../isomorphism/subgraph_isomorphism.py | 142 +++++++++ .../isomorphism/test_subgraph_isomorphism.py | 226 ++++++++++++++ 7 files changed, 968 insertions(+) create mode 100644 python/cugraph/cugraph/experimental/isomorphism/__init__.py create mode 100644 python/cugraph/cugraph/experimental/isomorphism/motif.py create mode 100644 python/cugraph/cugraph/experimental/isomorphism/slicing.py create mode 100644 python/cugraph/cugraph/experimental/isomorphism/solver.py create mode 100644 python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py create mode 100644 python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py diff --git a/python/cugraph/cugraph/experimental/__init__.py b/python/cugraph/cugraph/experimental/__init__.py index fef61916e32..22b6a569cef 100644 --- a/python/cugraph/cugraph/experimental/__init__.py +++ b/python/cugraph/cugraph/experimental/__init__.py @@ -15,3 +15,16 @@ strong_connected_component = experimental_warning_wrapper( EXPERIMENTAL__strong_connected_component, _ns_name ) + +from cugraph.experimental.isomorphism.subgraph_isomorphism import EXPERIMENTAL__subgraph_isomorphism + +subgraph_isomorphism = experimental_warning_wrapper( + EXPERIMENTAL__subgraph_isomorphism, _ns_name +) + +# MotifData and default_motif_library are plain data helpers, not +# algorithms, exported without the experimental wrapper +from cugraph.experimental.isomorphism.motif import ( + MotifData, + default_motif_library, +) diff --git a/python/cugraph/cugraph/experimental/isomorphism/__init__.py b/python/cugraph/cugraph/experimental/isomorphism/__init__.py new file mode 100644 index 00000000000..8eca3cc68ad --- /dev/null +++ b/python/cugraph/cugraph/experimental/isomorphism/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: Apache-2.0 diff --git a/python/cugraph/cugraph/experimental/isomorphism/motif.py b/python/cugraph/cugraph/experimental/isomorphism/motif.py new file mode 100644 index 00000000000..deb6cad93d9 --- /dev/null +++ b/python/cugraph/cugraph/experimental/isomorphism/motif.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import dataclass, field + +import cudf + +from cugraph.utilities.utils import import_optional + +# networkx is an optional (test) dependency of cugraph; only the code paths +# that use it require it to be installed. +nx = import_optional("networkx") + + +def count_unique(edge_list): + """Count the number of unique vertices in an edge list.""" + return len(set().union(*edge_list)) + + +@dataclass +class MotifData: + """A small building-block graph ("motif") used to decompose the pattern + graph during motif-based subgraph isomorphism. + + Parameters + ---------- + name : str + Identifier for the motif, used in the solver's decomposition report. + motif : list of (int, int) + Edge list over vertices ``0..k-1`` defining the motif graph. + """ + + name: str + motif: list + graph: object = field(init=False, default=None, repr=False) + size: int = field(init=False, default=0) + isomorphisms: object = field(init=False, default=None, repr=False) + + def __post_init__(self): + self.size = count_unique(self.motif) if self.motif else 1 + self.graph = nx.Graph() + self.graph.add_nodes_from(range(self.size)) + self.graph.add_edges_from(self.motif) + + def copy(self): + """Copy this MotifData; the isomorphisms DataFrame is deep-copied.""" + new_instance = self.__class__(motif=self.motif, name=self.name) + new_instance.graph = self.graph + new_instance.size = self.size + if self.isomorphisms is not None: + new_instance.isomorphisms = self.isomorphisms.copy() + return new_instance + + +def data_to_dataframe(data, num_vertices): + # Down-cast by vertex-id range; every motif table uses the same rule so + # cudf merge keys stay dtype-consistent across tables. + if num_vertices <= 256: + dtype = "uint8" + elif num_vertices <= 65536: + dtype = "uint16" + else: + dtype = "uint32" + return cudf.DataFrame(data, dtype=dtype) + + +def make_m2_motif(edge_df, num_vertices): + """Build the base single-edge ("M2") motif whose isomorphisms table is + the bidirectional, de-duplicated edge list of the target graph. + + The concat + drop_duplicates normalizes the input regardless of whether + ``edge_df`` is already symmetrized or holds each edge in one direction. + + Parameters + ---------- + edge_df : cudf.DataFrame + Two columns (source, destination) of target edges in the compact + ``0..num_vertices-1`` vertex space, with self-loops already removed. + num_vertices : int + Number of vertices in the target graph. + """ + m2_motif = MotifData(name="M2", motif=[(0, 1)]) + df = data_to_dataframe(edge_df.to_cupy(), num_vertices) + df_rev = df[[1, 0]] + df_rev.columns = [0, 1] + m2_motif.isomorphisms = cudf.concat( + [df, df_rev], ignore_index=True + ).drop_duplicates(ignore_index=True, keep="first") + return m2_motif + + +def default_motif_library(): + """Return a small library of 3-vertex motifs usable as building blocks. + + Passing these to ``subgraph_isomorphism`` makes the solver precompute + their embeddings in the target graph (a full solve per motif), which can + speed up large patterns at the cost of upfront work and memory. + """ + return [ + MotifData(name="M3-path", motif=[(0, 1), (1, 2)]), + MotifData(name="M3-triangle", motif=[(0, 1), (1, 2), (0, 2)]), + ] diff --git a/python/cugraph/cugraph/experimental/isomorphism/slicing.py b/python/cugraph/cugraph/experimental/isomorphism/slicing.py new file mode 100644 index 00000000000..fc85b8da6be --- /dev/null +++ b/python/cugraph/cugraph/experimental/isomorphism/slicing.py @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Tuple + +from cugraph.experimental.isomorphism.motif import MotifData +from cugraph.utilities.utils import import_optional + +nx = import_optional("networkx") + + +@dataclass +class SlicingResults: + """ + Stores the results from pattern graph slicing into motifs. + + Attributes: + chosen_motifs: A list of MotifData objects selected during slicing. + slices: Each sublist contains pattern vertex ids for a slice. + boundaries: A list where each element represents the boundary + conditions for a slice as tuples of + ([slice_index, vertex_index], vertex_index). + intersections: Each set contains pattern vertices at the intersection + of a slice and its boundary. + """ + + chosen_motifs: List[MotifData] = field(default_factory=list) + slices: List[List[int]] = field(default_factory=list) + boundaries: List[List[Tuple[List[int], int]]] = field(default_factory=list) + intersections: List[set] = field(default_factory=list) + + def extend(self, other: "SlicingResults") -> None: + """Appends the content of another SlicingResults instance to the + current one.""" + self.chosen_motifs.extend(other.chosen_motifs) + self.slices.extend(other.slices) + self.boundaries.extend(other.boundaries) + self.intersections.extend(other.intersections) + + +def slice_pattern_graph_using_motifs( + pattern_graph: nx.Graph, + motif_metadata: List[MotifData], +) -> SlicingResults: + """Slice the pattern graph into subgraphs based on predefined motifs.""" + graph = nx.Graph() + graph.add_nodes_from(pattern_graph.nodes) + graph.add_edges_from(pattern_graph.edges) + results = SlicingResults() + boundary_nodes: set = set() + + # Precompute adjacency list for fast lookups + adjacency_map = build_adjacency_map(graph) + + def _extract_next_slice( + graph: nx.Graph, + motif_metadata: List[MotifData], + adjacency_map: dict, + boundary_nodes: set, + existing_slices: List[List[int]], + ) -> SlicingResults | None: + """Attempt to extract the next valid motif slice.""" + remaining_nodes = set(graph.nodes) + existing_slices_node = set( + node for slice_ in existing_slices for node in slice_ + ) + + # Start from the largest and most complex motifs + for motif_data in motif_metadata[::-1]: + if len(remaining_nodes) < motif_data.size: + continue + + # Node-induced subgraph matches of the motif in the residual + # pattern graph (same semantics as rustworkx vf2_mapping with + # subgraph=True). + matcher = nx.algorithms.isomorphism.GraphMatcher( + graph, motif_data.graph + ) + + for mapping in matcher.subgraph_isomorphisms_iter(): + # mapping is {pattern_node: motif_node}; invert it so that + # slice_nodes[i] is the pattern vertex matched to motif + # vertex i. Dict iteration order carries no such guarantee. + inv = { + motif_node: pat_node for pat_node, motif_node in mapping.items() + } + slice_nodes = [inv[i] for i in range(motif_data.size)] + slice_set = set(slice_nodes) + + # The found slice should cover at least one existing + # boundary node + if boundary_nodes and not (slice_set & boundary_nodes): + continue + + combined_nodes = slice_set | existing_slices_node + slice_edges = [ + (slice_nodes[u], slice_nodes[v]) for (u, v) in motif_data.motif + ] + updated_boundary = find_boundary_nodes( + combined_nodes, slice_edges, adjacency_map + ) + + nodes_to_remove = slice_set - updated_boundary + # The found slice should contribute to the removal of pattern + # graph nodes. If no nodes are removed, it indicates a + # potential infinite loop. + if not nodes_to_remove: + # If the slice fully covers existing slices, skip it + if any( + not (slice_set - set(slice_)) for slice_ in existing_slices + ): + continue + graph.remove_nodes_from(list(nodes_to_remove)) + + intersection = slice_set & boundary_nodes + boundary_nodes.clear() + boundary_nodes.update(updated_boundary) + boundary_condition = [ + ( + find_element_index(existing_slices, node), + slice_nodes.index(node), + ) + for node in intersection + ] + return SlicingResults( + [motif_data.copy()], + [slice_nodes], + [boundary_condition], + [intersection], + ) + return None + + while graph.number_of_nodes(): + _step_res = _extract_next_slice( + graph, + motif_metadata, + adjacency_map, + boundary_nodes, + results.slices, + ) + + if not _step_res: + raise ValueError("Cannot slice the Pattern Graph.") + + results.extend(_step_res) + + return results + + +def build_adjacency_map(graph: nx.Graph) -> dict: + """Build adjacency map from graph edges.""" + adjacency_map = {u: set() for u in graph.nodes} + for u, v in graph.edges: + adjacency_map[u].add(v) + adjacency_map[v].add(u) + return adjacency_map + + +def find_element_index(slices: List[List[int]], target: int) -> List[int]: + """Find the index of an element in a ragged 2D list. + Return [slice_index, vertex_index] if found, else [].""" + for i, group in enumerate(slices): + if target in group: + return [i, group.index(target)] + return [] + + +def find_boundary_nodes( + combined_nodes: set, + current_slice_edges: list, + adjacency_map: dict, +) -> set: + """ + Find updated boundary nodes after extracting a motif slice. + A node is a boundary if it connects to any untouched node outside the + current slice. + """ + # Remove the adjacent node if the edge is covered by the current slice + for node1, node2 in current_slice_edges: + if node1 in adjacency_map and node2 in adjacency_map[node1]: + adjacency_map[node1].remove(node2) + if node2 in adjacency_map and node1 in adjacency_map[node2]: + adjacency_map[node2].remove(node1) + + # Identify boundary nodes + return {node for node in combined_nodes if adjacency_map.get(node, set())} diff --git a/python/cugraph/cugraph/experimental/isomorphism/solver.py b/python/cugraph/cugraph/experimental/isomorphism/solver.py new file mode 100644 index 00000000000..c10484910ee --- /dev/null +++ b/python/cugraph/cugraph/experimental/isomorphism/solver.py @@ -0,0 +1,292 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from time import time +from typing import Optional + +import cudf +import cupy as cp +import numpy as np + +from cugraph.experimental.isomorphism.motif import ( + MotifData, + data_to_dataframe, + make_m2_motif, +) +from cugraph.experimental.isomorphism.slicing import ( + slice_pattern_graph_using_motifs, +) +from cugraph.utilities.utils import import_optional + +nx = import_optional("networkx") + + +@dataclass +class SolverResult: + # One row per embedding; columns follow pattern_vertices order. + mappings: np.ndarray + # Sorted pattern vertex ids corresponding to the columns of mappings. + pattern_vertices: np.ndarray + timings: Optional[dict] = None + + +class _MotifSubgraphIsomorphismSolver: + """Solve the subgraph isomorphism (monomorphism) problem using + motif-based decomposition: slice the pattern graph into small motifs, + look up each motif's embeddings in the target graph, and assemble full + embeddings via relational joins with an overlap-consistency filter. + + The target graph is given as a cudf edge list in the compact + ``0..num_target_vertices-1`` vertex space with self-loops removed. + """ + + def __init__( + self, + target_edge_df, + num_target_vertices, + motif_metadata=None, + batch_size=None, + ): + self._target_edge_df = target_edge_df + self._num_target_vertices = num_target_vertices + # Merge in row batches so no single join input exceeds cuDF's + # 2**31 - 1 column size limit; <= 0 disables batching. + self._batch_size = int(batch_size) if batch_size else 0 + self.motif_metadata = motif_metadata if motif_metadata is not None else [] + self.decomposition = None + + def generate_graph_motif_data(self, motifs=None): + """Precompute embeddings for the given motifs in the target graph. + + The base single-edge M2 motif is always generated first; each further + motif is solved recursively using the motifs generated before it. + Sets and returns ``self.motif_metadata``. + """ + if motifs is None: + motifs = [] + + m2_motif = make_m2_motif(self._target_edge_df, self._num_target_vertices) + motif_data_list = [m2_motif] + for motif_data in motifs: + solver = _MotifSubgraphIsomorphismSolver( + self._target_edge_df, + self._num_target_vertices, + motif_metadata=motif_data_list, + batch_size=self._batch_size, + ) + result = solver.solve(motif_data.graph) + if result is None: + continue + + motif_data.isomorphisms = data_to_dataframe( + result.mappings, self._num_target_vertices + ) + motif_data_list.append(motif_data) + + self.motif_metadata = motif_data_list + return motif_data_list + + def solve(self, pattern_graph: nx.Graph) -> SolverResult | None: + """Find all embeddings of pattern_graph in the target graph.""" + self._validate_input(pattern_graph) + self._times = defaultdict(float) + + # Step 1: Decompose the pattern graph into motif slices + start = time() + slicing_res = slice_pattern_graph_using_motifs( + pattern_graph, self.motif_metadata + ) + self.decomposition = [motif.name for motif in slicing_res.chosen_motifs] + self._times["step 1 pattern decomposition"] = time() - start + + if not slicing_res.chosen_motifs: + return None + + # Step 2: Seed with the first motif's embeddings + initial_motif = slicing_res.chosen_motifs.pop(0) + slicing_res.boundaries.pop(0) + slicing_res.intersections.pop(0) + + if len(initial_motif.isomorphisms) == 0: + return None + + current_mappings_df = initial_motif.isomorphisms + # Rename columns: m4_v5 means 4th motif, 5th motif vertex + current_mappings_df.columns = [ + f"m0_v{col}" for col in current_mappings_df.columns + ] + + count = 0 + while slicing_res.chosen_motifs: + next_motif = slicing_res.chosen_motifs.pop(0) + next_boundary = slicing_res.boundaries.pop(0) + intersection = slicing_res.intersections.pop(0) + count += 1 + + processed_batches = [] + for batch_df in self._create_batches( + current_mappings_df, self._batch_size + ): + # Step 2-1: Match the two tables on the boundary conditions + start = time() + batch_df = self._merging_with_new_motif( + batch_df, count, next_motif, next_boundary + ) + self._times["step 2-1 join"] += time() - start + + # Step 2-2: Keep only rows with the correct number of + # overlapped vertices + start = time() + batch_df = self._filtering_invalid_mappings( + batch_df, slicing_res.slices, intersection, count + ) + self._times["step 2-2 filter"] += time() - start + processed_batches.append(batch_df) + + start = time() + if len(processed_batches) == 1: + current_mappings_df = processed_batches[0] + else: + current_mappings_df = cudf.concat( + processed_batches, ignore_index=True + ) + del processed_batches + self._times["step 2-3 concat batches"] += time() - start + + # Step 3: Format the output + start = time() + mappings, pattern_vertices = self._format_output( + slicing_res.slices, current_mappings_df + ) + self._times["step 3 format output"] = time() - start + + del current_mappings_df + + return SolverResult(mappings, pattern_vertices, dict(self._times)) + + def _merging_with_new_motif( + self, + current_mappings_df, + count, + next_motif: MotifData, + next_boundary, + ): + next_motifs_df = next_motif.isomorphisms + original_column_names = next_motifs_df.columns + next_motifs_df.columns = [ + f"m{count}_v{col}" for col in next_motifs_df.columns + ] + + current_boundary_columns, next_boundary_columns = [], [] + for (group, node_idx), next_node_idx in next_boundary: + current_boundary_columns.append(f"m{group}_v{node_idx}") + next_boundary_columns.append(f"m{count}_v{next_node_idx}") + + # The input ordering is not preserved with cuDF + current_mappings_df = current_mappings_df.merge( + next_motifs_df, + how="inner", + left_on=current_boundary_columns, + right_on=next_boundary_columns, + ) + # Restore names so this motif can be merged again (next batch or + # reuse) + next_motifs_df.columns = original_column_names + return current_mappings_df + + @staticmethod + def _create_batches(df, batch_size): + if batch_size <= 0 or len(df) == 0: + yield df + return + for i in range(0, len(df), batch_size): + yield df.iloc[i : i + batch_size] + + def _filtering_invalid_mappings( + self, + current_mappings_df, + slices, + intersection, + count, + ): + num_overlapped_nodes = sum( + node in intersection + for each_slice in slices[:count] + for node in each_slice + ) + + prev_cols = [ + col + for col in current_mappings_df.columns + if not col.startswith(f"m{count}_") + ] + new_cols = [ + col for col in current_mappings_df.columns if col.startswith(f"m{count}_") + ] + + prev_values = cp.from_dlpack(current_mappings_df[prev_cols].to_dlpack()) + new_values = cp.from_dlpack(current_mappings_df[new_cols].to_dlpack()) + del prev_cols, new_cols + + # Use broadcasting to compare every row's previous values with its + # new values, summing in chunks so no chunk exceeds 2**32 elements + output_shape = cp.broadcast( + prev_values[..., None], new_values[:, None, :] + ).shape + match_counts = cp.empty(output_shape[0], dtype=cp.int64) + max_size = 2**32 + chunk_size = max(1, int(max_size / np.prod(output_shape[1:]))) + for chunk_start in range(0, output_shape[0], chunk_size): + chunk_end = min(chunk_start + chunk_size, output_shape[0]) + match_counts[chunk_start:chunk_end] = ( + prev_values[chunk_start:chunk_end, ..., None] + == new_values[chunk_start:chunk_end, None, :] + ).sum(axis=(1, 2)) + del prev_values, new_values + + # Keep only rows where the exact overlap count occurs + current_mappings_df = current_mappings_df[ + match_counts == num_overlapped_nodes + ] + del match_counts + return current_mappings_df + + def _format_output(self, slices, mappings_df): + """Reorder columns from motif-slice order to sorted pattern-vertex + order and return (mappings ndarray, sorted pattern vertices).""" + flat_vertices = np.array([node for slice_ in slices for node in slice_]) + pattern_vertices, idx = np.unique(flat_vertices, return_index=True) + mappings = cp.asnumpy(cp.from_dlpack(mappings_df.to_dlpack())[:, idx]) + return mappings, pattern_vertices + + def _validate_input(self, pattern_graph: nx.Graph) -> None: + """Validate the input pattern graph against the target graph.""" + if pattern_graph.number_of_nodes() == 0: + raise ValueError("Validation failed: Pattern graph is empty.") + + if not nx.is_connected(pattern_graph): + raise ValueError("Validation failed: Pattern graph must be connected.") + + if not self.motif_metadata: + raise ValueError( + "Motif metadata has not been generated; call " + "generate_graph_motif_data() before solve()." + ) + + if pattern_graph.number_of_nodes() > self._num_target_vertices: + raise ValueError( + "Validation failed: Pattern graph exceeds target graph capacity." + ) + + # motif_metadata[0] is always M2, whose isomorphisms table is the + # bidirectional target edge list. + num_target_edges = len(self.motif_metadata[0].isomorphisms) + if 2 * pattern_graph.number_of_edges() > num_target_edges: + raise ValueError( + "Validation failed: Pattern graph requires more connectivity " + "than available on the target graph." + ) diff --git a/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py b/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py new file mode 100644 index 00000000000..a1baf17e4fb --- /dev/null +++ b/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py @@ -0,0 +1,142 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: Apache-2.0 + +import cudf + +from cugraph.experimental.isomorphism.solver import ( + _MotifSubgraphIsomorphismSolver, +) +from cugraph.utilities.utils import import_optional + +nx = import_optional("networkx") + + +def _pattern_to_nx(pattern_G): + """Convert a small cugraph.Graph pattern into a networkx.Graph on the + original (unrenumbered) vertex ids.""" + edge_df = pattern_G.view_edge_list().to_pandas() + # view_edge_list may keep the user's original column names; take the + # source/destination columns positionally. + src_col, dst_col = edge_df.columns[0], edge_df.columns[1] + + if (edge_df[src_col] == edge_df[dst_col]).any(): + raise ValueError( + "Pattern graph must not contain self-loops: a self-loop can " + "never be matched since target self-loops are ignored." + ) + + pattern_nx = nx.Graph() + pattern_nx.add_nodes_from(pattern_G.nodes().to_pandas()) + pattern_nx.add_edges_from(zip(edge_df[src_col], edge_df[dst_col])) + return pattern_nx + + +def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None, batch_size=None): + """ + Find all subgraph isomorphisms (monomorphisms) of a pattern graph in a + target graph using GPU-accelerated motif-based decomposition. Algortithm + described in: + Wang, Y., Ginez, E., Friel, J., Baum, Y., Kim, J. S., Shih, A, O. Green, + “Δ-Motif: Parallel Subgraph Isomorphism via Tabular Operations for Scalable Layout Selection”, + IEEE Quantum Week (QCE), 2026 + + The pattern is decomposed into small motifs (CPU VF2 on the pattern + only), each motif's embeddings in the target are computed as cuDF + tables, and full embeddings are assembled with cuDF joins plus an + overlap-consistency filter. + + Note on semantics: the returned embeddings are *monomorphisms*, i.e., + every pattern edge maps to a target edge and vertices map injectively, + but non-adjacent pattern vertices are allowed to map to adjacent target + vertices. + This matches NetworkX ``GraphMatcher.subgraph_monomorphisms_iter``, + not the induced ``subgraph_isomorphisms_iter``. + + Parameters + ---------- + G : cugraph.Graph + Undirected target graph. Self-loops are ignored. + + pattern_G : cugraph.Graph + Undirected, connected pattern graph to search for. Must not contain + self-loops and may not have more vertices or edges than the target. + + motifs : list of MotifData, optional (default=None) + Optional motif building blocks used to decompose the pattern (see + ``cugraph.experimental.MotifData`` and ``default_motif_library``). + The single-edge M2 motif is always included automatically, so the + default finds embeddings edge by edge. Larger motifs can speed up + big patterns, but each one costs a full pre-solve to enumerate its + embeddings in the target. + + batch_size : int, optional (default=None) + If set to a positive integer, intermediate join inputs are processed + in row batches of this size. Use this when large intermediate join + products approach cuDF's 2**31 - 1 column-size limit or exhaust GPU + memory. None or 0 disables batching. + + Returns + ------- + result : cudf.DataFrame + One row per embedding found. One column per pattern vertex, named by + the pattern vertex id (as a string, sorted order); values are the + target vertex ids (original ids if the graph was renumbered). An + empty DataFrame (with the same columns) means no embedding exists. + + Examples + -------- + >>> import cudf + >>> from cugraph import Graph + >>> from cugraph.datasets import karate + >>> from cugraph.experimental import subgraph_isomorphism + >>> G = karate.get_graph(download=True) + >>> triangle = cudf.DataFrame( + ... {"src": [0, 1, 2], "dst": [1, 2, 0]} + ... ) + >>> pattern_G = Graph() + >>> pattern_G.from_cudf_edgelist(triangle, source="src", + ... destination="dst") + >>> mappings = subgraph_isomorphism(G, pattern_G) + + """ + if G.is_directed() or pattern_G.is_directed(): + raise ValueError("input graphs must be undirected") + + if G.edgelist is None: + # Materialize an edge list (e.g. for graphs built from an adjacency + # list). + G.view_edge_list() + + # Work in the renumbered internal vertex space: the solver expects + # compact 0..n-1 ids. Do not use view_edge_list() here (it unrenumbers). + edge_df = G.edgelist.edgelist_df[["src", "dst"]] + edge_df = edge_df[edge_df["src"] != edge_df["dst"]] + num_vertices = G.number_of_vertices() + + pattern_nx = _pattern_to_nx(pattern_G) + + solver = _MotifSubgraphIsomorphismSolver( + edge_df, num_vertices, batch_size=batch_size + ) + solver.generate_graph_motif_data(motifs) + result = solver.solve(pattern_nx) + + vertex_dtype = G.edgelist.edgelist_df["src"].dtype + if result is None: + column_names = [str(v) for v in sorted(pattern_nx.nodes)] + return cudf.DataFrame( + {name: cudf.Series([], dtype=vertex_dtype) for name in column_names} + ) + + column_names = [str(v) for v in result.pattern_vertices] + result_df = cudf.DataFrame(result.mappings, columns=column_names) + # Cast up from the solver's compact uint dtypes so unrenumber's merge + # against the renumber map does not hit a dtype mismatch. + for col in column_names: + result_df[col] = result_df[col].astype(vertex_dtype) + + if G.renumbered: + for col in column_names: + result_df = G.unrenumber(result_df, col) + + return result_df diff --git a/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py b/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py new file mode 100644 index 00000000000..f4daa29ce98 --- /dev/null +++ b/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py @@ -0,0 +1,226 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: Apache-2.0 + +import gc + +import cudf +import networkx as nx +import pytest +from networkx.algorithms.isomorphism import GraphMatcher + +import cugraph +from cugraph.datasets import karate +from cugraph.experimental import ( # noqa: F401 + MotifData, + default_motif_library, + subgraph_isomorphism, +) + +# ============================================================================= +# Pytest Setup / Teardown - called for each test function +# ============================================================================= + + +def setup_function(): + gc.collect() + + +# ============================================================================= +# Helpers +# ============================================================================= + +PATTERNS = { + "triangle": [(0, 1), (1, 2), (2, 0)], + "P3-path": [(0, 1), (1, 2)], + "4-cycle": [(0, 1), (1, 2), (2, 3), (3, 0)], + "K4": [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)], +} + + +def build_cugraph_from_edges(edges): + df = cudf.DataFrame( + {"src": [u for u, v in edges], "dst": [v for u, v in edges]} + ) + G = cugraph.Graph(directed=False) + G.from_cudf_edgelist(df, source="src", destination="dst") + return G + + +def cugraph_to_nx(G): + edge_df = G.view_edge_list().to_pandas() + src_col, dst_col = edge_df.columns[0], edge_df.columns[1] + nx_graph = nx.Graph() + nx_graph.add_edges_from(zip(edge_df[src_col], edge_df[dst_col])) + return nx_graph + + +def result_to_set(result_df): + """Rows of the result as a set of (pattern_vertex, target_vertex) tuple + tuples, independent of row order.""" + pattern_vertices = [int(c) for c in result_df.columns] + rows = result_df.to_pandas().itertuples(index=False) + return { + tuple(sorted(zip(pattern_vertices, (int(v) for v in row)))) + for row in rows + } + + +def nx_monomorphisms_set(target_nx, pattern_nx): + matcher = GraphMatcher(target_nx, pattern_nx) + return { + tuple( + sorted((int(p), int(t)) for t, p in mapping.items()) + ) + for mapping in matcher.subgraph_monomorphisms_iter() + } + + +# ============================================================================= +# Tests +# ============================================================================= + + +@pytest.mark.sg +@pytest.mark.parametrize("pattern_name", list(PATTERNS.keys())) +def test_matches_networkx_monomorphisms_on_karate(pattern_name): + G = karate.get_graph(download=True) + pattern_G = build_cugraph_from_edges(PATTERNS[pattern_name]) + + result_df = subgraph_isomorphism(G, pattern_G) + + expected = nx_monomorphisms_set( + cugraph_to_nx(G), cugraph_to_nx(pattern_G) + ) + assert result_to_set(result_df) == expected + + +@pytest.mark.sg +def test_matches_networkx_on_small_handbuilt_graph(): + # bowtie: two triangles sharing vertex 2, plus a pendant vertex + target_edges = [(0, 1), (1, 2), (2, 0), (2, 3), (3, 4), (4, 2), (4, 5)] + G = build_cugraph_from_edges(target_edges) + + for pattern_name, pattern_edges in PATTERNS.items(): + pattern_G = build_cugraph_from_edges(pattern_edges) + result_df = subgraph_isomorphism(G, pattern_G) + expected = nx_monomorphisms_set( + cugraph_to_nx(G), cugraph_to_nx(pattern_G) + ) + assert result_to_set(result_df) == expected, pattern_name + + +@pytest.mark.sg +def test_triangle_count_on_karate(): + G = karate.get_graph(download=True) + pattern_G = build_cugraph_from_edges(PATTERNS["triangle"]) + + result_df = subgraph_isomorphism(G, pattern_G) + + n_triangles = sum(nx.triangles(cugraph_to_nx(G)).values()) // 3 + # each undirected triangle appears as 3! = 6 ordered embeddings + assert len(result_df) == 6 * n_triangles + + +@pytest.mark.sg +def test_embeddings_are_valid(): + G = karate.get_graph(download=True) + pattern_edges = PATTERNS["4-cycle"] + pattern_G = build_cugraph_from_edges(pattern_edges) + + result_df = subgraph_isomorphism(G, pattern_G) + assert len(result_df) > 0 + + target_nx = cugraph_to_nx(G) + pattern_vertices = [int(c) for c in result_df.columns] + for row in result_df.to_pandas().itertuples(index=False): + mapping = dict(zip(pattern_vertices, (int(v) for v in row))) + # injective + assert len(set(mapping.values())) == len(mapping) + # every pattern edge maps to a target edge + for u, v in pattern_edges: + assert target_nx.has_edge(mapping[u], mapping[v]) + + +@pytest.mark.sg +def test_renumbering_with_noncontiguous_ids(): + # bowtie graph with non-contiguous, shifted vertex ids + target_edges = [(0, 1), (1, 2), (2, 0), (2, 3), (3, 4), (4, 2)] + shifted_edges = [(u * 10 + 5, v * 10 + 5) for u, v in target_edges] + G = build_cugraph_from_edges(shifted_edges) + pattern_G = build_cugraph_from_edges(PATTERNS["triangle"]) + + result_df = subgraph_isomorphism(G, pattern_G) + + expected = nx_monomorphisms_set( + cugraph_to_nx(G), cugraph_to_nx(pattern_G) + ) + assert result_to_set(result_df) == expected + # output must contain the original (shifted) ids + returned_ids = set() + for col in result_df.columns: + returned_ids.update(int(v) for v in result_df[col].to_pandas()) + assert returned_ids.issubset({u * 10 + 5 for u in range(5)}) + + +@pytest.mark.sg +def test_directed_graph_raises(): + df = cudf.DataFrame({"src": [0, 1], "dst": [1, 2]}) + dG = cugraph.Graph(directed=True) + dG.from_cudf_edgelist(df, source="src", destination="dst") + pattern_G = build_cugraph_from_edges([(0, 1)]) + G = build_cugraph_from_edges([(0, 1), (1, 2)]) + + with pytest.raises(ValueError): + subgraph_isomorphism(dG, pattern_G) + with pytest.raises(ValueError): + subgraph_isomorphism(G, dG) + + +@pytest.mark.sg +def test_disconnected_pattern_raises(): + G = karate.get_graph(download=True) + pattern_G = build_cugraph_from_edges([(0, 1), (2, 3)]) + + with pytest.raises(ValueError): + subgraph_isomorphism(G, pattern_G) + + +@pytest.mark.sg +def test_pattern_larger_than_target_raises(): + G = build_cugraph_from_edges([(0, 1), (1, 2)]) + pattern_G = build_cugraph_from_edges(PATTERNS["K4"]) + + with pytest.raises(ValueError): + subgraph_isomorphism(G, pattern_G) + + +@pytest.mark.sg +def test_no_match_returns_empty_dataframe(): + # tree target has no triangles + G = build_cugraph_from_edges([(0, 1), (1, 2), (1, 3), (3, 4)]) + pattern_G = build_cugraph_from_edges(PATTERNS["triangle"]) + + result_df = subgraph_isomorphism(G, pattern_G) + assert len(result_df) == 0 + assert list(result_df.columns) == ["0", "1", "2"] + + +@pytest.mark.sg +@pytest.mark.parametrize("batch_size", [None, 7, 100000]) +def test_batch_size_gives_identical_results(batch_size): + G = karate.get_graph(download=True) + pattern_G = build_cugraph_from_edges(PATTERNS["4-cycle"]) + + result_df = subgraph_isomorphism(G, pattern_G, batch_size=batch_size) + baseline_df = subgraph_isomorphism(G, pattern_G) + assert result_to_set(result_df) == result_to_set(baseline_df) + + +@pytest.mark.sg +def test_motif_library_gives_identical_results(): + G = karate.get_graph(download=True) + pattern_G = build_cugraph_from_edges(PATTERNS["K4"]) + + baseline_df = subgraph_isomorphism(G, pattern_G) + result_df = subgraph_isomorphism(G, pattern_G, motifs=default_motif_library()) + assert result_to_set(result_df) == result_to_set(baseline_df) From 38c9cdfd80b8ffb410cccc2644310688589b5674 Mon Sep 17 00:00:00 2001 From: Alex Fallin Date: Thu, 23 Jul 2026 12:59:30 -0700 Subject: [PATCH 2/4] Stream motif joins in memory-bounded batches with partitioned intermediates --- .../experimental/isomorphism/solver.py | 381 ++++++++++++------ .../isomorphism/subgraph_isomorphism.py | 21 +- .../isomorphism/test_subgraph_isomorphism.py | 89 +++- 3 files changed, 360 insertions(+), 131 deletions(-) diff --git a/python/cugraph/cugraph/experimental/isomorphism/solver.py b/python/cugraph/cugraph/experimental/isomorphism/solver.py index c10484910ee..981f49b0eaf 100644 --- a/python/cugraph/cugraph/experimental/isomorphism/solver.py +++ b/python/cugraph/cugraph/experimental/isomorphism/solver.py @@ -27,23 +27,89 @@ @dataclass class SolverResult: - # One row per embedding; columns follow pattern_vertices order. - mappings: np.ndarray + # One row per embedding; columns follow pattern_vertices order. A device + # (cupy) array when the result fit in a single partition (the common + # case, avoiding a device->host round trip), otherwise a host (numpy, + # 64-bit indexed) array assembled from multiple partitions. + mappings: object # Sorted pattern vertex ids corresponding to the columns of mappings. pattern_vertices: np.ndarray timings: Optional[dict] = None +class _PartitionWriter: + """Accumulate filtered chunks into partitions each below a row limit. + + cuDF columns cannot exceed ~2**31 rows. This writer buffers small + filtered chunks and emits partitions just under ``row_limit`` so that + arbitrarily large solution sets stay representable; they are combined + only as NumPy arrays (64-bit indexed) at the very end. + """ + + def __init__(self, row_limit): + self._row_limit = row_limit + self._done = [] + self._buffer = [] + self._buffered_rows = 0 + + def add(self, df): + n = len(df) + if n == 0: + return + if self._buffer and self._buffered_rows + n > self._row_limit: + self._flush() + self._buffer.append(df) + self._buffered_rows += n + if self._buffered_rows >= self._row_limit: + self._flush() + + def _flush(self): + if not self._buffer: + return + if len(self._buffer) == 1: + part = self._buffer[0] + else: + part = cudf.concat(self._buffer, ignore_index=True) + self._done.append(part) + self._buffer = [] + self._buffered_rows = 0 + + def finish(self): + self._flush() + return self._done + + class _MotifSubgraphIsomorphismSolver: """Solve the subgraph isomorphism (monomorphism) problem using motif-based decomposition: slice the pattern graph into small motifs, look up each motif's embeddings in the target graph, and assemble full embeddings via relational joins with an overlap-consistency filter. + The join-and-filter step is streamed so the intermediate produced by + each motif merge stays within a memory budget rather than being + materialized in full; the running table is held as a list of partitions + (each below cuDF's ~2**31 row limit) and combined only as NumPy at the + end. Join-key columns contributed by each new motif duplicate the + columns they were joined on and are dropped after every merge, which + nearly halves the table width for path-like decompositions. + The target graph is given as a cudf edge list in the compact ``0..num_target_vertices-1`` vertex space with self-loops removed. """ + #: Fraction of currently-free device memory targeted for a single + #: streamed join's intermediate result when no explicit batch_size is + #: given. The filter step allocates transients of a few times the merged + #: table's size, so this stays well below 1. + _JOIN_MEM_BUDGET_FRACTION = 1 / 8 + #: Floor (GiB) for the join memory budget, used when free-memory + #: information is unavailable or very low. + _JOIN_MEM_BUDGET_MIN_GIB = 0.5 + #: Number of left rows sampled to estimate join fan-out before batching. + _FANOUT_SAMPLE_ROWS = 8192 + #: Max rows per cuDF partition (kept below the ~2**31 column row limit). + _ROW_LIMIT = 1_800_000_000 + def __init__( self, target_edge_df, @@ -53,8 +119,8 @@ def __init__( ): self._target_edge_df = target_edge_df self._num_target_vertices = num_target_vertices - # Merge in row batches so no single join input exceeds cuDF's - # 2**31 - 1 column size limit; <= 0 disables batching. + # Explicit rows-per-batch override; <= 0 selects adaptive batching + # that targets _JOIN_MEM_BUDGET_GIB per join intermediate. self._batch_size = int(batch_size) if batch_size else 0 self.motif_metadata = motif_metadata if motif_metadata is not None else [] self.decomposition = None @@ -114,11 +180,10 @@ def solve(self, pattern_graph: nx.Graph) -> SolverResult | None: if len(initial_motif.isomorphisms) == 0: return None - current_mappings_df = initial_motif.isomorphisms - # Rename columns: m4_v5 means 4th motif, 5th motif vertex - current_mappings_df.columns = [ - f"m0_v{col}" for col in current_mappings_df.columns - ] + initial_df = initial_motif.isomorphisms + # Positional renaming: m4_v5 means 4th motif, 5th motif vertex. + initial_df.columns = [f"m0_v{i}" for i in range(len(initial_df.columns))] + partitions = [initial_df] count = 0 while slicing_res.chosen_motifs: @@ -126,141 +191,225 @@ def solve(self, pattern_graph: nx.Graph) -> SolverResult | None: next_boundary = slicing_res.boundaries.pop(0) intersection = slicing_res.intersections.pop(0) count += 1 - - processed_batches = [] - for batch_df in self._create_batches( - current_mappings_df, self._batch_size - ): - # Step 2-1: Match the two tables on the boundary conditions - start = time() - batch_df = self._merging_with_new_motif( - batch_df, count, next_motif, next_boundary - ) - self._times["step 2-1 join"] += time() - start - - # Step 2-2: Keep only rows with the correct number of - # overlapped vertices - start = time() - batch_df = self._filtering_invalid_mappings( - batch_df, slicing_res.slices, intersection, count - ) - self._times["step 2-2 filter"] += time() - start - processed_batches.append(batch_df) - - start = time() - if len(processed_batches) == 1: - current_mappings_df = processed_batches[0] - else: - current_mappings_df = cudf.concat( - processed_batches, ignore_index=True - ) - del processed_batches - self._times["step 2-3 concat batches"] += time() - start + partitions = self._merge_and_filter_streamed( + partitions, count, next_motif, next_boundary, intersection + ) # Step 3: Format the output start = time() - mappings, pattern_vertices = self._format_output( - slicing_res.slices, current_mappings_df + mappings, pattern_vertices = self._format_output_parts( + slicing_res.slices, partitions ) self._times["step 3 format output"] = time() - start - del current_mappings_df + del partitions return SolverResult(mappings, pattern_vertices, dict(self._times)) - def _merging_with_new_motif( + def _merge_and_filter_streamed( self, - current_mappings_df, + partitions, count, next_motif: MotifData, next_boundary, + intersection, ): - next_motifs_df = next_motif.isomorphisms - original_column_names = next_motifs_df.columns - next_motifs_df.columns = [ - f"m{count}_v{col}" for col in next_motifs_df.columns - ] + """Merge each partition with the next motif and filter, batch by + batch. - current_boundary_columns, next_boundary_columns = [], [] + The join-key columns contributed by the new motif duplicate the + columns they were joined on, so they are dropped after filtering. + Output is re-partitioned to keep every cuDF table below the ~2**31 + row limit. + """ + next_df = next_motif.isomorphisms + next_df.columns = [f"m{count}_v{i}" for i in range(len(next_df.columns))] + + left_keys, right_keys = [], [] for (group, node_idx), next_node_idx in next_boundary: - current_boundary_columns.append(f"m{group}_v{node_idx}") - next_boundary_columns.append(f"m{count}_v{next_node_idx}") - - # The input ordering is not preserved with cuDF - current_mappings_df = current_mappings_df.merge( - next_motifs_df, - how="inner", - left_on=current_boundary_columns, - right_on=next_boundary_columns, - ) - # Restore names so this motif can be merged again (next batch or - # reuse) - next_motifs_df.columns = original_column_names - return current_mappings_df + left_keys.append(f"m{group}_v{node_idx}") + right_keys.append(f"m{count}_v{next_node_idx}") + + # With duplicate columns dropped, each retained vertex is unique, so + # a valid row shares exactly ``len(intersection)`` values between the + # previous block and the new motif's block. + num_overlapped_nodes = len(intersection) + + writer = _PartitionWriter(self._ROW_LIMIT) + budget_bytes = self._join_mem_budget_bytes() + total_batches = 0 + for part in partitions: + n_rows = len(part) + if n_rows == 0: + continue + if self._batch_size > 0: + batch_rows = self._batch_size + else: + batch_rows = self._choose_batch_rows( + part, next_df, left_keys, right_keys, budget_bytes + ) + for chunk_start in range(0, n_rows, batch_rows): + left_batch = part.iloc[chunk_start : chunk_start + batch_rows] + writer.add( + self._merge_filter_one( + left_batch, + next_df, + left_keys, + right_keys, + count, + num_overlapped_nodes, + ) + ) + del left_batch + total_batches += 1 + self._times["step 2 batches"] += total_batches - @staticmethod - def _create_batches(df, batch_size): - if batch_size <= 0 or len(df) == 0: - yield df - return - for i in range(0, len(df), batch_size): - yield df.iloc[i : i + batch_size] + result = writer.finish() + return result if result else [partitions[0].iloc[:0]] - def _filtering_invalid_mappings( + def _merge_filter_one( self, - current_mappings_df, - slices, - intersection, + left_df, + next_df, + left_keys, + right_keys, count, + num_overlapped_nodes, ): - num_overlapped_nodes = sum( - node in intersection - for each_slice in slices[:count] - for node in each_slice + """Merge one left batch with the motif table, filter, and drop the + duplicate join-key columns.""" + start = time() + merged = left_df.merge( + next_df, how="inner", left_on=left_keys, right_on=right_keys ) + self._times["step 2-1 join"] += time() - start + if len(merged) == 0: + return merged.drop(columns=right_keys) + start = time() + filtered = self._filter_merged(merged, count, num_overlapped_nodes) + filtered = filtered.drop(columns=right_keys) + self._times["step 2-2 filter"] += time() - start + return filtered + + def _join_mem_budget_bytes(self): + """Memory budget for one streamed join's intermediate result. + + Scales with the free device memory reported by the driver (a + read-only query; allocator state is never modified) so larger GPUs + run fewer, bigger batches. Falls back to the floor if the query + fails or memory is tight. Note: under a pooling allocator the + driver's free-memory figure can undercount what is actually + available; the floor keeps progress possible in that case. + """ + floor = int(self._JOIN_MEM_BUDGET_MIN_GIB * (1024**3)) + try: + free_bytes, _total = cp.cuda.runtime.memGetInfo() + except Exception: + return floor + return max(floor, int(free_bytes * self._JOIN_MEM_BUDGET_FRACTION)) + + def _choose_batch_rows(self, left_df, next_df, left_keys, right_keys, budget_bytes): + """Pick a left-batch size so the merged intermediate stays within + the memory budget. + + The join fan-out is estimated from a small sample of the left table; + the batch size is then the memory budget divided by + ``fan-out * row_width``. + """ + n_rows = len(left_df) + if n_rows <= self._FANOUT_SAMPLE_ROWS: + return n_rows + dtype_bytes = int(np.dtype(left_df[left_df.columns[0]].dtype).itemsize) + row_width = len(left_df.columns) + len(next_df.columns) + bytes_per_row = max(row_width * dtype_bytes, 1) + + sample = left_df.iloc[: self._FANOUT_SAMPLE_ROWS] + sample_merged = sample.merge( + next_df, how="inner", left_on=left_keys, right_on=right_keys + ) + fanout = len(sample_merged) / self._FANOUT_SAMPLE_ROWS + del sample, sample_merged + if fanout <= 0: + return n_rows + + budget_rows = budget_bytes / bytes_per_row + batch_rows = int(budget_rows / fanout) + # Independent of the byte budget, never let a single merge output + # exceed the cuDF row limit: with small vertex dtypes on large GPUs + # the byte budget alone can project past 2**31 rows. fanout is a + # sampled estimate; _ROW_LIMIT's ~15% margin under 2**31 absorbs + # estimation error. + batch_rows = min(batch_rows, int(self._ROW_LIMIT / fanout)) + return max(1, min(batch_rows, n_rows)) + + def _filter_merged(self, merged_df, count, num_overlapped_nodes): prev_cols = [ - col - for col in current_mappings_df.columns - if not col.startswith(f"m{count}_") + col for col in merged_df.columns if not col.startswith(f"m{count}_") ] new_cols = [ - col for col in current_mappings_df.columns if col.startswith(f"m{count}_") + col for col in merged_df.columns if col.startswith(f"m{count}_") ] + match_counts = self._count_cross_matches(merged_df, prev_cols, new_cols) + return merged_df[match_counts == num_overlapped_nodes] - prev_values = cp.from_dlpack(current_mappings_df[prev_cols].to_dlpack()) - new_values = cp.from_dlpack(current_mappings_df[new_cols].to_dlpack()) - del prev_cols, new_cols - - # Use broadcasting to compare every row's previous values with its - # new values, summing in chunks so no chunk exceeds 2**32 elements - output_shape = cp.broadcast( - prev_values[..., None], new_values[:, None, :] - ).shape - match_counts = cp.empty(output_shape[0], dtype=cp.int64) - max_size = 2**32 - chunk_size = max(1, int(max_size / np.prod(output_shape[1:]))) - for chunk_start in range(0, output_shape[0], chunk_size): - chunk_end = min(chunk_start + chunk_size, output_shape[0]) - match_counts[chunk_start:chunk_end] = ( - prev_values[chunk_start:chunk_end, ..., None] - == new_values[chunk_start:chunk_end, None, :] - ).sum(axis=(1, 2)) - del prev_values, new_values - - # Keep only rows where the exact overlap count occurs - current_mappings_df = current_mappings_df[ - match_counts == num_overlapped_nodes - ] - del match_counts - return current_mappings_df - - def _format_output(self, slices, mappings_df): - """Reorder columns from motif-slice order to sorted pattern-vertex - order and return (mappings ndarray, sorted pattern vertices).""" - flat_vertices = np.array([node for slice_ in slices for node in slice_]) - pattern_vertices, idx = np.unique(flat_vertices, return_index=True) - mappings = cp.asnumpy(cp.from_dlpack(mappings_df.to_dlpack())[:, idx]) + @staticmethod + def _count_cross_matches(df, prev_cols, new_cols): + """Count, per row, shared vertex assignments between two column + blocks. + + Accumulating one new column at a time uses ``rows x |prev|`` memory + instead of the ``rows x |prev| x |new|`` boolean tensor, with the + same result. + """ + prev_values = cp.from_dlpack(df[prev_cols].to_dlpack()) + new_values = cp.from_dlpack(df[new_cols].to_dlpack()) + match_counts = cp.zeros(prev_values.shape[0], dtype=cp.int64) + for j in range(new_values.shape[1]): + match_counts += (prev_values == new_values[:, j : j + 1]).sum(axis=1) + return match_counts + + def _format_output_parts(self, slices, partitions): + """Format result partitions into one ``(n_solutions, n_vertices)`` + array plus the sorted pattern-vertex order of its columns. + + Each retained column ``m{g}_v{i}`` holds the target vertex matched + to pattern vertex ``slices[g][i]``; duplicate columns were dropped + during the joins, so every pattern vertex maps to exactly one + retained column. Partitions are formatted independently and the + (NumPy, 64-bit indexed) arrays concatenated, so the combined + solution set can exceed cuDF's ~2**31 row limit. + """ + available = set(partitions[0].columns) + col_for_vertex = {} + for group, slice_ in enumerate(slices): + for node_idx, vertex in enumerate(slice_): + col = f"m{group}_v{node_idx}" + if col in available: + col_for_vertex.setdefault(vertex, col) + + pattern_vertices = np.array(sorted(col_for_vertex)) + ordered_cols = [col_for_vertex[v] for v in pattern_vertices] + + parts = [part for part in partitions if len(part)] + if not parts: + mappings = np.empty((0, len(ordered_cols)), dtype=np.int64) + elif len(parts) == 1: + # Common case: the whole result fits in one partition; keep it + # on device (to_dlpack yields a self-owned contiguous copy). + mappings = cp.from_dlpack(parts[0][ordered_cols].to_dlpack()) + else: + # Multi-partition results are near or beyond cuDF's 2**31 row + # limit; assemble on host with 64-bit indexing. An on-device + # concat is deliberately avoided: it would transiently need + # ~2x the (tens-of-GiB) result resident at once. + mappings = np.concatenate( + [ + cp.asnumpy(cp.from_dlpack(part[ordered_cols].to_dlpack())) + for part in parts + ], + axis=0, + ) return mappings, pattern_vertices def _validate_input(self, pattern_graph: nx.Graph) -> None: diff --git a/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py b/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py index a1baf17e4fb..1ee709b8246 100644 --- a/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py +++ b/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py @@ -31,7 +31,7 @@ def _pattern_to_nx(pattern_G): return pattern_nx -def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None, batch_size=None): +def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None): """ Find all subgraph isomorphisms (monomorphisms) of a pattern graph in a target graph using GPU-accelerated motif-based decomposition. Algortithm @@ -43,7 +43,14 @@ def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None, batch_size=Non The pattern is decomposed into small motifs (CPU VF2 on the pattern only), each motif's embeddings in the target are computed as cuDF tables, and full embeddings are assembled with cuDF joins plus an - overlap-consistency filter. + overlap-consistency filter. The joins are streamed in adaptively sized + batches (scaled to free device memory) and intermediate results are + held as partitions below cuDF's 2**31 - 1 column-size limit, so + intermediate solution sets may exceed that limit. For problems whose + intermediates exceed GPU memory entirely, enable cuDF spilling or RMM + managed memory in your application before calling (e.g. + ``cudf.set_option("spill", True)`` or + ``rmm.reinitialize(managed_memory=True)``). Note on semantics: the returned embeddings are *monomorphisms*, i.e., every pattern edge maps to a target edge and vertices map injectively, @@ -69,12 +76,6 @@ def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None, batch_size=Non big patterns, but each one costs a full pre-solve to enumerate its embeddings in the target. - batch_size : int, optional (default=None) - If set to a positive integer, intermediate join inputs are processed - in row batches of this size. Use this when large intermediate join - products approach cuDF's 2**31 - 1 column-size limit or exhaust GPU - memory. None or 0 disables batching. - Returns ------- result : cudf.DataFrame @@ -115,9 +116,7 @@ def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None, batch_size=Non pattern_nx = _pattern_to_nx(pattern_G) - solver = _MotifSubgraphIsomorphismSolver( - edge_df, num_vertices, batch_size=batch_size - ) + solver = _MotifSubgraphIsomorphismSolver(edge_df, num_vertices) solver.generate_graph_motif_data(motifs) result = solver.solve(pattern_nx) diff --git a/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py b/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py index f4daa29ce98..33fd8778ed9 100644 --- a/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py +++ b/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py @@ -9,7 +9,7 @@ from networkx.algorithms.isomorphism import GraphMatcher import cugraph -from cugraph.datasets import karate +from cugraph.datasets import email_Eu_core, karate from cugraph.experimental import ( # noqa: F401 MotifData, default_motif_library, @@ -206,13 +206,44 @@ def test_no_match_returns_empty_dataframe(): @pytest.mark.sg -@pytest.mark.parametrize("batch_size", [None, 7, 100000]) -def test_batch_size_gives_identical_results(batch_size): +def test_tiny_join_budget_gives_identical_results(monkeypatch): + # Force many small streamed-join batches (and multiple partition-writer + # flushes) through the public API by shrinking the adaptive memory + # budget; results must be identical to the default single-batch solve. + from cugraph.experimental.isomorphism.solver import ( + _MotifSubgraphIsomorphismSolver, + ) + G = karate.get_graph(download=True) pattern_G = build_cugraph_from_edges(PATTERNS["4-cycle"]) + baseline_df = subgraph_isomorphism(G, pattern_G) - result_df = subgraph_isomorphism(G, pattern_G, batch_size=batch_size) + monkeypatch.setattr( + _MotifSubgraphIsomorphismSolver, "_JOIN_MEM_BUDGET_FRACTION", 0.0 + ) + monkeypatch.setattr( + _MotifSubgraphIsomorphismSolver, "_JOIN_MEM_BUDGET_MIN_GIB", 1e-6 + ) + monkeypatch.setattr(_MotifSubgraphIsomorphismSolver, "_FANOUT_SAMPLE_ROWS", 8) + result_df = subgraph_isomorphism(G, pattern_G) + assert result_to_set(result_df) == result_to_set(baseline_df) + + +@pytest.mark.sg +def test_multi_partition_results_give_identical_results(monkeypatch): + # Force intermediate and final results to span many partitions so the + # multi-partition (host NumPy) assembly path is exercised; in normal + # operation it only triggers for results beyond ~1.8B rows. + from cugraph.experimental.isomorphism.solver import ( + _MotifSubgraphIsomorphismSolver, + ) + + G = karate.get_graph(download=True) + pattern_G = build_cugraph_from_edges(PATTERNS["4-cycle"]) baseline_df = subgraph_isomorphism(G, pattern_G) + + monkeypatch.setattr(_MotifSubgraphIsomorphismSolver, "_ROW_LIMIT", 100) + result_df = subgraph_isomorphism(G, pattern_G) assert result_to_set(result_df) == result_to_set(baseline_df) @@ -224,3 +255,53 @@ def test_motif_library_gives_identical_results(): baseline_df = subgraph_isomorphism(G, pattern_G) result_df = subgraph_isomorphism(G, pattern_G, motifs=default_motif_library()) assert result_to_set(result_df) == result_to_set(baseline_df) + + +@pytest.mark.sg +def test_user_supplied_motifs_give_identical_results(): + # Motifs passed directly as MotifData objects, without going through + # default_motif_library(). + G = karate.get_graph(download=True) + pattern_G = build_cugraph_from_edges(PATTERNS["K4"]) + baseline_df = subgraph_isomorphism(G, pattern_G) + + motifs = [ + MotifData(name="M3-path", motif=[(0, 1), (1, 2)]), + MotifData(name="M3-triangle", motif=[(0, 1), (1, 2), (0, 2)]), + MotifData(name="M4-star", motif=[(0, 1), (0, 2), (0, 3)]), + ] + result_df = subgraph_isomorphism(G, pattern_G, motifs=motifs) + assert result_to_set(result_df) == result_to_set(baseline_df) + + # Supplying the pattern itself as a motif also works: the decomposition + # then covers the pattern with a single slice. + result_df = subgraph_isomorphism( + G, pattern_G, motifs=[MotifData(name="K4", motif=PATTERNS["K4"])] + ) + assert result_to_set(result_df) == result_to_set(baseline_df) + + +@pytest.mark.sg +def test_large_graph_triangle_count(): + # A denser, larger target than karate (~1k vertices, ~25k edges, + # ~100k triangles); validated by count against nx.triangles plus + # validity spot-checks, since full NetworkX enumeration would be slow. + G = email_Eu_core.get_graph(download=True, ignore_weights=True) + pattern_G = build_cugraph_from_edges(PATTERNS["triangle"]) + + result_df = subgraph_isomorphism(G, pattern_G) + + target_nx = cugraph_to_nx(G) + target_nx.remove_edges_from(nx.selfloop_edges(target_nx)) + n_triangles = sum(nx.triangles(target_nx).values()) // 3 + # each undirected triangle appears as 3! = 6 ordered embeddings + assert len(result_df) == 6 * n_triangles + + # validity spot-check on a sample of embeddings + pattern_vertices = [int(c) for c in result_df.columns] + sample = result_df.head(500).to_pandas() + for row in sample.itertuples(index=False): + mapping = dict(zip(pattern_vertices, (int(v) for v in row))) + assert len(set(mapping.values())) == len(mapping) + for u, v in PATTERNS["triangle"]: + assert target_nx.has_edge(mapping[u], mapping[v]) From 91f9725f255d674cf579d7fa5121f72e5af5bfb4 Mon Sep 17 00:00:00 2001 From: Alex Fallin Date: Thu, 23 Jul 2026 16:40:23 -0700 Subject: [PATCH 3/4] Fix style: pre-commit formatting, copyright headers, trailing whitespace Co-Authored-By: Claude Fable 5 --- .../cugraph/cugraph/experimental/__init__.py | 2 +- .../experimental/isomorphism/__init__.py | 2 +- .../cugraph/experimental/isomorphism/motif.py | 2 +- .../experimental/isomorphism/slicing.py | 14 +++------ .../experimental/isomorphism/solver.py | 6 ++-- .../isomorphism/subgraph_isomorphism.py | 14 ++++----- .../isomorphism/test_subgraph_isomorphism.py | 29 +++++-------------- 7 files changed, 24 insertions(+), 45 deletions(-) diff --git a/python/cugraph/cugraph/experimental/__init__.py b/python/cugraph/cugraph/experimental/__init__.py index 22b6a569cef..7bf89d372af 100644 --- a/python/cugraph/cugraph/experimental/__init__.py +++ b/python/cugraph/cugraph/experimental/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from pylibcugraph.utilities.api_tools import ( diff --git a/python/cugraph/cugraph/experimental/isomorphism/__init__.py b/python/cugraph/cugraph/experimental/isomorphism/__init__.py index 8eca3cc68ad..d51c4fe1e08 100644 --- a/python/cugraph/cugraph/experimental/isomorphism/__init__.py +++ b/python/cugraph/cugraph/experimental/isomorphism/__init__.py @@ -1,2 +1,2 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 diff --git a/python/cugraph/cugraph/experimental/isomorphism/motif.py b/python/cugraph/cugraph/experimental/isomorphism/motif.py index deb6cad93d9..9a6c0ba055f 100644 --- a/python/cugraph/cugraph/experimental/isomorphism/motif.py +++ b/python/cugraph/cugraph/experimental/isomorphism/motif.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations diff --git a/python/cugraph/cugraph/experimental/isomorphism/slicing.py b/python/cugraph/cugraph/experimental/isomorphism/slicing.py index fc85b8da6be..413626f33b6 100644 --- a/python/cugraph/cugraph/experimental/isomorphism/slicing.py +++ b/python/cugraph/cugraph/experimental/isomorphism/slicing.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations @@ -76,17 +76,13 @@ def _extract_next_slice( # Node-induced subgraph matches of the motif in the residual # pattern graph (same semantics as rustworkx vf2_mapping with # subgraph=True). - matcher = nx.algorithms.isomorphism.GraphMatcher( - graph, motif_data.graph - ) + matcher = nx.algorithms.isomorphism.GraphMatcher(graph, motif_data.graph) for mapping in matcher.subgraph_isomorphisms_iter(): # mapping is {pattern_node: motif_node}; invert it so that # slice_nodes[i] is the pattern vertex matched to motif # vertex i. Dict iteration order carries no such guarantee. - inv = { - motif_node: pat_node for pat_node, motif_node in mapping.items() - } + inv = {motif_node: pat_node for pat_node, motif_node in mapping.items()} slice_nodes = [inv[i] for i in range(motif_data.size)] slice_set = set(slice_nodes) @@ -109,9 +105,7 @@ def _extract_next_slice( # potential infinite loop. if not nodes_to_remove: # If the slice fully covers existing slices, skip it - if any( - not (slice_set - set(slice_)) for slice_ in existing_slices - ): + if any(not (slice_set - set(slice_)) for slice_ in existing_slices): continue graph.remove_nodes_from(list(nodes_to_remove)) diff --git a/python/cugraph/cugraph/experimental/isomorphism/solver.py b/python/cugraph/cugraph/experimental/isomorphism/solver.py index 981f49b0eaf..949c9facc0c 100644 --- a/python/cugraph/cugraph/experimental/isomorphism/solver.py +++ b/python/cugraph/cugraph/experimental/isomorphism/solver.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations @@ -347,9 +347,7 @@ def _filter_merged(self, merged_df, count, num_overlapped_nodes): prev_cols = [ col for col in merged_df.columns if not col.startswith(f"m{count}_") ] - new_cols = [ - col for col in merged_df.columns if col.startswith(f"m{count}_") - ] + new_cols = [col for col in merged_df.columns if col.startswith(f"m{count}_")] match_counts = self._count_cross_matches(merged_df, prev_cols, new_cols) return merged_df[match_counts == num_overlapped_nodes] diff --git a/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py b/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py index 1ee709b8246..546b90ec137 100644 --- a/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py +++ b/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import cudf @@ -35,9 +35,9 @@ def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None): """ Find all subgraph isomorphisms (monomorphisms) of a pattern graph in a target graph using GPU-accelerated motif-based decomposition. Algortithm - described in: - Wang, Y., Ginez, E., Friel, J., Baum, Y., Kim, J. S., Shih, A, O. Green, - “Δ-Motif: Parallel Subgraph Isomorphism via Tabular Operations for Scalable Layout Selection”, + described in: + Wang, Y., Ginez, E., Friel, J., Baum, Y., Kim, J. S., Shih, A, O. Green, + “Δ-Motif: Parallel Subgraph Isomorphism via Tabular Operations for Scalable Layout Selection”, IEEE Quantum Week (QCE), 2026 The pattern is decomposed into small motifs (CPU VF2 on the pattern @@ -52,10 +52,10 @@ def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None): ``cudf.set_option("spill", True)`` or ``rmm.reinitialize(managed_memory=True)``). - Note on semantics: the returned embeddings are *monomorphisms*, i.e., - every pattern edge maps to a target edge and vertices map injectively, + Note on semantics: the returned embeddings are *monomorphisms*, i.e., + every pattern edge maps to a target edge and vertices map injectively, but non-adjacent pattern vertices are allowed to map to adjacent target - vertices. + vertices. This matches NetworkX ``GraphMatcher.subgraph_monomorphisms_iter``, not the induced ``subgraph_isomorphisms_iter``. diff --git a/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py b/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py index 33fd8778ed9..62af9d2c865 100644 --- a/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py +++ b/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import gc @@ -10,7 +10,7 @@ import cugraph from cugraph.datasets import email_Eu_core, karate -from cugraph.experimental import ( # noqa: F401 +from cugraph.experimental import ( MotifData, default_motif_library, subgraph_isomorphism, @@ -38,9 +38,7 @@ def setup_function(): def build_cugraph_from_edges(edges): - df = cudf.DataFrame( - {"src": [u for u, v in edges], "dst": [v for u, v in edges]} - ) + df = cudf.DataFrame({"src": [u for u, v in edges], "dst": [v for u, v in edges]}) G = cugraph.Graph(directed=False) G.from_cudf_edgelist(df, source="src", destination="dst") return G @@ -59,18 +57,13 @@ def result_to_set(result_df): tuples, independent of row order.""" pattern_vertices = [int(c) for c in result_df.columns] rows = result_df.to_pandas().itertuples(index=False) - return { - tuple(sorted(zip(pattern_vertices, (int(v) for v in row)))) - for row in rows - } + return {tuple(sorted(zip(pattern_vertices, (int(v) for v in row)))) for row in rows} def nx_monomorphisms_set(target_nx, pattern_nx): matcher = GraphMatcher(target_nx, pattern_nx) return { - tuple( - sorted((int(p), int(t)) for t, p in mapping.items()) - ) + tuple(sorted((int(p), int(t)) for t, p in mapping.items())) for mapping in matcher.subgraph_monomorphisms_iter() } @@ -88,9 +81,7 @@ def test_matches_networkx_monomorphisms_on_karate(pattern_name): result_df = subgraph_isomorphism(G, pattern_G) - expected = nx_monomorphisms_set( - cugraph_to_nx(G), cugraph_to_nx(pattern_G) - ) + expected = nx_monomorphisms_set(cugraph_to_nx(G), cugraph_to_nx(pattern_G)) assert result_to_set(result_df) == expected @@ -103,9 +94,7 @@ def test_matches_networkx_on_small_handbuilt_graph(): for pattern_name, pattern_edges in PATTERNS.items(): pattern_G = build_cugraph_from_edges(pattern_edges) result_df = subgraph_isomorphism(G, pattern_G) - expected = nx_monomorphisms_set( - cugraph_to_nx(G), cugraph_to_nx(pattern_G) - ) + expected = nx_monomorphisms_set(cugraph_to_nx(G), cugraph_to_nx(pattern_G)) assert result_to_set(result_df) == expected, pattern_name @@ -151,9 +140,7 @@ def test_renumbering_with_noncontiguous_ids(): result_df = subgraph_isomorphism(G, pattern_G) - expected = nx_monomorphisms_set( - cugraph_to_nx(G), cugraph_to_nx(pattern_G) - ) + expected = nx_monomorphisms_set(cugraph_to_nx(G), cugraph_to_nx(pattern_G)) assert result_to_set(result_df) == expected # output must contain the original (shifted) ids returned_ids = set() From eaffd704decce30475d087d3782dbbbb97957505 Mon Sep 17 00:00:00 2001 From: Alex Fallin Date: Mon, 10 Aug 2026 17:36:58 -0700 Subject: [PATCH 4/4] Address review: empty-join schema, row-limit enforcement, dependency-free MotifData, rename to subgraph_monomorphism --- .../cugraph/cugraph/experimental/__init__.py | 20 +- .../experimental/isomorphism/__init__.py | 0 .../cugraph/experimental/isomorphism/motif.py | 99 ++++++--- .../experimental/isomorphism/slicing.py | 8 +- .../experimental/isomorphism/solver.py | 131 +++++++++--- ...somorphism.py => subgraph_monomorphism.py} | 50 ++++- ...phism.py => test_subgraph_monomorphism.py} | 193 +++++++++++++++--- 7 files changed, 397 insertions(+), 104 deletions(-) mode change 100644 => 100755 python/cugraph/cugraph/experimental/__init__.py mode change 100644 => 100755 python/cugraph/cugraph/experimental/isomorphism/__init__.py mode change 100644 => 100755 python/cugraph/cugraph/experimental/isomorphism/motif.py mode change 100644 => 100755 python/cugraph/cugraph/experimental/isomorphism/slicing.py mode change 100644 => 100755 python/cugraph/cugraph/experimental/isomorphism/solver.py rename python/cugraph/cugraph/experimental/isomorphism/{subgraph_isomorphism.py => subgraph_monomorphism.py} (71%) mode change 100644 => 100755 rename python/cugraph/cugraph/tests/isomorphism/{test_subgraph_isomorphism.py => test_subgraph_monomorphism.py} (57%) mode change 100644 => 100755 diff --git a/python/cugraph/cugraph/experimental/__init__.py b/python/cugraph/cugraph/experimental/__init__.py old mode 100644 new mode 100755 index 7bf89d372af..f9f9efb3358 --- a/python/cugraph/cugraph/experimental/__init__.py +++ b/python/cugraph/cugraph/experimental/__init__.py @@ -16,15 +16,21 @@ EXPERIMENTAL__strong_connected_component, _ns_name ) -from cugraph.experimental.isomorphism.subgraph_isomorphism import EXPERIMENTAL__subgraph_isomorphism +from cugraph.experimental.isomorphism.subgraph_monomorphism import ( + EXPERIMENTAL__subgraph_monomorphism, +) -subgraph_isomorphism = experimental_warning_wrapper( - EXPERIMENTAL__subgraph_isomorphism, _ns_name +subgraph_monomorphism = experimental_warning_wrapper( + EXPERIMENTAL__subgraph_monomorphism, _ns_name ) -# MotifData and default_motif_library are plain data helpers, not -# algorithms, exported without the experimental wrapper from cugraph.experimental.isomorphism.motif import ( - MotifData, - default_motif_library, + EXPERIMENTAL__MotifData, + EXPERIMENTAL__default_motif_library, +) + +MotifData = experimental_warning_wrapper(EXPERIMENTAL__MotifData, _ns_name) + +default_motif_library = experimental_warning_wrapper( + EXPERIMENTAL__default_motif_library, _ns_name ) diff --git a/python/cugraph/cugraph/experimental/isomorphism/__init__.py b/python/cugraph/cugraph/experimental/isomorphism/__init__.py old mode 100644 new mode 100755 diff --git a/python/cugraph/cugraph/experimental/isomorphism/motif.py b/python/cugraph/cugraph/experimental/isomorphism/motif.py old mode 100644 new mode 100755 index 9a6c0ba055f..0f787fbe3b2 --- a/python/cugraph/cugraph/experimental/isomorphism/motif.py +++ b/python/cugraph/cugraph/experimental/isomorphism/motif.py @@ -9,20 +9,18 @@ from cugraph.utilities.utils import import_optional -# networkx is an optional (test) dependency of cugraph; only the code paths -# that use it require it to be installed. +# networkx is an optional dependency of cugraph (declared for tests only); +# import_optional defers the failure to first use, and only the private +# pattern-decomposition code paths use it — never MotifData instances. nx = import_optional("networkx") -def count_unique(edge_list): - """Count the number of unique vertices in an edge list.""" - return len(set().union(*edge_list)) - - @dataclass -class MotifData: +class EXPERIMENTAL__MotifData: """A small building-block graph ("motif") used to decompose the pattern - graph during motif-based subgraph isomorphism. + graph during motif-based subgraph monomorphism. Holds only plain Python + data (an edge list) plus, after precomputation, a cuDF table of the + motif's embeddings in the target graph. Parameters ---------- @@ -34,40 +32,68 @@ class MotifData: name: str motif: list - graph: object = field(init=False, default=None, repr=False) size: int = field(init=False, default=0) - isomorphisms: object = field(init=False, default=None, repr=False) + embeddings: object = field(init=False, default=None, repr=False) def __post_init__(self): - self.size = count_unique(self.motif) if self.motif else 1 - self.graph = nx.Graph() - self.graph.add_nodes_from(range(self.size)) - self.graph.add_edges_from(self.motif) + # Materialize first so a generator input can't be silently + # exhausted between validation and use. + self.motif = list(self.motif) + vertices = set().union(*self.motif) if self.motif else set() + if not self.motif or vertices != set(range(len(vertices))): + raise ValueError( + "motif must be a non-empty edge list over contiguous vertices 0..k-1" + ) + self.size = len(vertices) + + def _to_nx(self): + """Build a networkx.Graph of this motif (private; used by the CPU + pattern-decomposition step only).""" + graph = nx.Graph() + graph.add_nodes_from(range(self.size)) + graph.add_edges_from(self.motif) + return graph def copy(self): - """Copy this MotifData; the isomorphisms DataFrame is deep-copied.""" - new_instance = self.__class__(motif=self.motif, name=self.name) - new_instance.graph = self.graph + """Copy this MotifData. The embeddings table is copied shallowly + (shared data buffers, independent column metadata): the solver only + ever renames the copy's columns, so slices of the same motif can + share one embeddings table instead of duplicating it on the GPU.""" + # Explicit class (not self.__class__) so copies of instances created + # through the experimental warning wrapper don't re-warn. + new_instance = EXPERIMENTAL__MotifData(motif=self.motif, name=self.name) new_instance.size = self.size - if self.isomorphisms is not None: - new_instance.isomorphisms = self.isomorphisms.copy() + if self.embeddings is not None: + new_instance.embeddings = self.embeddings.copy(deep=False) return new_instance -def data_to_dataframe(data, num_vertices): +# Internal alias: intra-package code (and type hints) use the plain name; +# the public export in cugraph.experimental applies the warning wrapper to +# the EXPERIMENTAL__-prefixed name above. Do not import this alias from +# outside the package — it bypasses the experimental warning. Note that +# instances made internally (default_motif_library, copy()) are of the raw +# class, so isinstance/== checks against the wrapped public class will not +# match them; experimental users should not rely on either. +MotifData = EXPERIMENTAL__MotifData + + +def _data_to_dataframe(data, num_vertices): # Down-cast by vertex-id range; every motif table uses the same rule so # cudf merge keys stay dtype-consistent across tables. if num_vertices <= 256: dtype = "uint8" elif num_vertices <= 65536: dtype = "uint16" - else: + elif num_vertices <= 2**32: dtype = "uint32" + else: + dtype = "uint64" return cudf.DataFrame(data, dtype=dtype) -def make_m2_motif(edge_df, num_vertices): - """Build the base single-edge ("M2") motif whose isomorphisms table is +def _make_m2_motif(edge_df, num_vertices): + """Build the base single-edge ("M2") motif whose embeddings table is the bidirectional, de-duplicated edge list of the target graph. The concat + drop_duplicates normalizes the input regardless of whether @@ -81,20 +107,30 @@ def make_m2_motif(edge_df, num_vertices): num_vertices : int Number of vertices in the target graph. """ + if len(edge_df) >= 2**30: + # The concat below materializes 2 * len(edge_df) rows BEFORE the + # dedup, so at 2**30 input rows it exceeds cuDF's 2**31 - 1 row + # limit regardless of how many duplicates the dedup would remove; + # fail clearly rather than inside the concat. Support for larger + # targets would need a partitioned M2 table. + raise ValueError( + f"Target graph has {len(edge_df)} edges; the bidirectional " + "M2 motif table would exceed cuDF's 2**31 - 1 row limit." + ) m2_motif = MotifData(name="M2", motif=[(0, 1)]) - df = data_to_dataframe(edge_df.to_cupy(), num_vertices) + df = _data_to_dataframe(edge_df.to_cupy(), num_vertices) df_rev = df[[1, 0]] df_rev.columns = [0, 1] - m2_motif.isomorphisms = cudf.concat( - [df, df_rev], ignore_index=True - ).drop_duplicates(ignore_index=True, keep="first") + m2_motif.embeddings = cudf.concat([df, df_rev], ignore_index=True).drop_duplicates( + ignore_index=True, keep="first" + ) return m2_motif -def default_motif_library(): +def EXPERIMENTAL__default_motif_library(): """Return a small library of 3-vertex motifs usable as building blocks. - Passing these to ``subgraph_isomorphism`` makes the solver precompute + Passing these to ``subgraph_monomorphism`` makes the solver precompute their embeddings in the target graph (a full solve per motif), which can speed up large patterns at the cost of upfront work and memory. """ @@ -102,3 +138,6 @@ def default_motif_library(): MotifData(name="M3-path", motif=[(0, 1), (1, 2)]), MotifData(name="M3-triangle", motif=[(0, 1), (1, 2), (0, 2)]), ] + + +default_motif_library = EXPERIMENTAL__default_motif_library diff --git a/python/cugraph/cugraph/experimental/isomorphism/slicing.py b/python/cugraph/cugraph/experimental/isomorphism/slicing.py old mode 100644 new mode 100755 index 413626f33b6..eae210336a9 --- a/python/cugraph/cugraph/experimental/isomorphism/slicing.py +++ b/python/cugraph/cugraph/experimental/isomorphism/slicing.py @@ -76,7 +76,7 @@ def _extract_next_slice( # Node-induced subgraph matches of the motif in the residual # pattern graph (same semantics as rustworkx vf2_mapping with # subgraph=True). - matcher = nx.algorithms.isomorphism.GraphMatcher(graph, motif_data.graph) + matcher = nx.algorithms.isomorphism.GraphMatcher(graph, motif_data._to_nx()) for mapping in matcher.subgraph_isomorphisms_iter(): # mapping is {pattern_node: motif_node}; invert it so that @@ -95,6 +95,12 @@ def _extract_next_slice( slice_edges = [ (slice_nodes[u], slice_nodes[v]) for (u, v) in motif_data.motif ] + # NOTE: find_boundary_nodes mutates adjacency_map (removes + # the slice's edges) before this candidate can still be + # rejected below. That is safe only because a rejected + # candidate here is an induced match over already-covered + # slices, whose edges were removed when first covered — + # i.e. the mutation is idempotent for rejected candidates. updated_boundary = find_boundary_nodes( combined_nodes, slice_edges, adjacency_map ) diff --git a/python/cugraph/cugraph/experimental/isomorphism/solver.py b/python/cugraph/cugraph/experimental/isomorphism/solver.py old mode 100644 new mode 100755 index 949c9facc0c..4e0953c4a10 --- a/python/cugraph/cugraph/experimental/isomorphism/solver.py +++ b/python/cugraph/cugraph/experimental/isomorphism/solver.py @@ -14,8 +14,8 @@ from cugraph.experimental.isomorphism.motif import ( MotifData, - data_to_dataframe, - make_m2_motif, + _data_to_dataframe, + _make_m2_motif, ) from cugraph.experimental.isomorphism.slicing import ( slice_pattern_graph_using_motifs, @@ -56,6 +56,13 @@ def add(self, df): n = len(df) if n == 0: return + if n > self._row_limit: + # A single filtered chunk can exceed the limit (batch sizing is + # based on an estimated fan-out); split it before buffering so + # every emitted partition honors the limit. + for start in range(0, n, self._row_limit): + self.add(df.iloc[start : start + self._row_limit]) + return if self._buffer and self._buffered_rows + n > self._row_limit: self._flush() self._buffer.append(df) @@ -119,8 +126,11 @@ def __init__( ): self._target_edge_df = target_edge_df self._num_target_vertices = num_target_vertices - # Explicit rows-per-batch override; <= 0 selects adaptive batching - # that targets _JOIN_MEM_BUDGET_GIB per join intermediate. + # Explicit rows-per-batch override (internal debug knob); <= 0 + # selects adaptive batching that targets the dynamic join memory + # budget per intermediate. CAUTION: an explicit batch size bypasses + # the row-limit clamp, so batch_size * fanout can exceed cuDF's + # 2**31 row limit and fail loudly inside the merge. self._batch_size = int(batch_size) if batch_size else 0 self.motif_metadata = motif_metadata if motif_metadata is not None else [] self.decomposition = None @@ -130,12 +140,17 @@ def generate_graph_motif_data(self, motifs=None): The base single-edge M2 motif is always generated first; each further motif is solved recursively using the motifs generated before it. - Sets and returns ``self.motif_metadata``. + Sets and returns ``self.motif_metadata``. Note: user-supplied + MotifData objects are updated in place — their ``embeddings`` + attribute is set to the precomputed cuDF table. A precomputed motif + table is a single cuDF table, so a motif with 2**31 or more + embeddings in the target is rejected (unlike pattern solutions, + which are partitioned). """ if motifs is None: motifs = [] - m2_motif = make_m2_motif(self._target_edge_df, self._num_target_vertices) + m2_motif = _make_m2_motif(self._target_edge_df, self._num_target_vertices) motif_data_list = [m2_motif] for motif_data in motifs: solver = _MotifSubgraphIsomorphismSolver( @@ -144,11 +159,19 @@ def generate_graph_motif_data(self, motifs=None): motif_metadata=motif_data_list, batch_size=self._batch_size, ) - result = solver.solve(motif_data.graph) + result = solver.solve(motif_data._to_nx()) if result is None: continue - motif_data.isomorphisms = data_to_dataframe( + if len(result.mappings) >= 2**31: + raise ValueError( + f"Motif '{motif_data.name}' has {len(result.mappings)} " + "embeddings in the target graph, which exceeds the " + "2**31 - 1 row limit of a single precomputed motif " + "table. Omit this motif; the pattern can still be " + "solved from smaller motifs." + ) + motif_data.embeddings = _data_to_dataframe( result.mappings, self._num_target_vertices ) motif_data_list.append(motif_data) @@ -177,13 +200,18 @@ def solve(self, pattern_graph: nx.Graph) -> SolverResult | None: slicing_res.boundaries.pop(0) slicing_res.intersections.pop(0) - if len(initial_motif.isomorphisms) == 0: + if len(initial_motif.embeddings) == 0: return None - initial_df = initial_motif.isomorphisms + initial_df = initial_motif.embeddings # Positional renaming: m4_v5 means 4th motif, 5th motif vertex. initial_df.columns = [f"m0_v{i}" for i in range(len(initial_df.columns))] - partitions = [initial_df] + # Route the seed through the writer too: motif tables may hold up + # to 2**31 - 1 rows, above _ROW_LIMIT, and every partition in the + # pipeline must honor the limit. + seed_writer = _PartitionWriter(self._ROW_LIMIT) + seed_writer.add(initial_df) + partitions = seed_writer.finish() count = 0 while slicing_res.chosen_motifs: @@ -222,7 +250,7 @@ def _merge_and_filter_streamed( Output is re-partitioned to keep every cuDF table below the ~2**31 row limit. """ - next_df = next_motif.isomorphisms + next_df = next_motif.embeddings next_df.columns = [f"m{count}_v{i}" for i in range(len(next_df.columns))] left_keys, right_keys = [], [] @@ -265,7 +293,24 @@ def _merge_and_filter_streamed( self._times["step 2 batches"] += total_batches result = writer.finish() - return result if result else [partitions[0].iloc[:0]] + if result: + return result + # Every batch filtered down to zero rows. Fall back to an empty + # frame with the POST-merge schema (previous columns plus the new + # motif's non-key columns) so later merges can still reference this + # motif's vertices and the final output keeps all pattern-vertex + # columns. + empty = ( + partitions[0] + .iloc[:0] + .merge( + next_df.iloc[:0], + how="inner", + left_on=left_keys, + right_on=right_keys, + ) + ) + return [empty.drop(columns=right_keys)] def _merge_filter_one( self, @@ -312,34 +357,56 @@ def _choose_batch_rows(self, left_df, next_df, left_keys, right_keys, budget_byt """Pick a left-batch size so the merged intermediate stays within the memory budget. - The join fan-out is estimated from a small sample of the left table; - the batch size is then the memory budget divided by - ``fan-out * row_width``. + The join fan-out is estimated by joining samples of the left rows + (a prefix and a stride; all rows, for small partitions) against the + new motif's per-key match counts; summing the counts gives the + exact number of rows the sampled keys would produce without + materializing any of them, and the larger of the two estimates is + used. The batch size is then the memory budget divided by + ``fan-out * row_width``, subject to the row-limit clamp. """ n_rows = len(left_df) - if n_rows <= self._FANOUT_SAMPLE_ROWS: - return n_rows - dtype_bytes = int(np.dtype(left_df[left_df.columns[0]].dtype).itemsize) row_width = len(left_df.columns) + len(next_df.columns) bytes_per_row = max(row_width * dtype_bytes, 1) + budget_rows = budget_bytes / bytes_per_row - sample = left_df.iloc[: self._FANOUT_SAMPLE_ROWS] - sample_merged = sample.merge( - next_df, how="inner", left_on=left_keys, right_on=right_keys - ) - fanout = len(sample_merged) / self._FANOUT_SAMPLE_ROWS - del sample, sample_merged + # Partitions are key-clustered, so any single sample can be biased + # in either direction: a prefix misses hub keys later in the + # partition, while a stride dilutes a hub-dense head. Estimate from + # both and keep the LARGER fan-out — over-estimating only makes + # batches smaller (lower peak), while under-estimating risks a + # single merge output beyond the row limit. + sample_rows = min(n_rows, self._FANOUT_SAMPLE_ROWS) + stride = n_rows // sample_rows + key_counts = next_df[right_keys].value_counts().reset_index() + count_col = key_counts.columns[-1] + fanout = 0.0 + for sample_keys in ( + left_df[left_keys].iloc[:sample_rows], + left_df[left_keys].take(cp.arange(0, stride * sample_rows, stride)), + ): + sample_matches = sample_keys.merge( + key_counts, how="inner", left_on=left_keys, right_on=right_keys + ) + fanout = max(fanout, float(sample_matches[count_col].sum()) / sample_rows) + del key_counts, sample_matches if fanout <= 0: - return n_rows + # No sampled key matches. The unsampled rows may still match, + # so do NOT merge the whole partition in one batch; fall back + # to the byte budget with an assumed fan-out of 1, still + # subject to the row-limit clamp. + return max(1, min(n_rows, int(budget_rows), self._ROW_LIMIT)) - budget_rows = budget_bytes / bytes_per_row batch_rows = int(budget_rows / fanout) # Independent of the byte budget, never let a single merge output # exceed the cuDF row limit: with small vertex dtypes on large GPUs - # the byte budget alone can project past 2**31 rows. fanout is a - # sampled estimate; _ROW_LIMIT's ~15% margin under 2**31 absorbs - # estimation error. + # the byte budget alone can project past 2**31 rows. Sampling + # reduces but cannot bound estimation error (a hub-key cluster + # narrower than the stride can be missed entirely); _ROW_LIMIT's + # ~16% margin under 2**31 absorbs moderate skew, and a merge that + # still exceeds the limit fails loudly inside cuDF rather than + # returning wrong results. batch_rows = min(batch_rows, int(self._ROW_LIMIT / fanout)) return max(1, min(batch_rows, n_rows)) @@ -429,9 +496,9 @@ def _validate_input(self, pattern_graph: nx.Graph) -> None: "Validation failed: Pattern graph exceeds target graph capacity." ) - # motif_metadata[0] is always M2, whose isomorphisms table is the + # motif_metadata[0] is always M2, whose embeddings table is the # bidirectional target edge list. - num_target_edges = len(self.motif_metadata[0].isomorphisms) + num_target_edges = len(self.motif_metadata[0].embeddings) if 2 * pattern_graph.number_of_edges() > num_target_edges: raise ValueError( "Validation failed: Pattern graph requires more connectivity " diff --git a/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py b/python/cugraph/cugraph/experimental/isomorphism/subgraph_monomorphism.py old mode 100644 new mode 100755 similarity index 71% rename from python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py rename to python/cugraph/cugraph/experimental/isomorphism/subgraph_monomorphism.py index 546b90ec137..e5833331a96 --- a/python/cugraph/cugraph/experimental/isomorphism/subgraph_isomorphism.py +++ b/python/cugraph/cugraph/experimental/isomorphism/subgraph_monomorphism.py @@ -6,7 +6,7 @@ from cugraph.experimental.isomorphism.solver import ( _MotifSubgraphIsomorphismSolver, ) -from cugraph.utilities.utils import import_optional +from cugraph.utilities.utils import MissingModule, import_optional nx = import_optional("networkx") @@ -31,14 +31,14 @@ def _pattern_to_nx(pattern_G): return pattern_nx -def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None): +def EXPERIMENTAL__subgraph_monomorphism(G, pattern_G, motifs=None): """ - Find all subgraph isomorphisms (monomorphisms) of a pattern graph in a - target graph using GPU-accelerated motif-based decomposition. Algortithm - described in: - Wang, Y., Ginez, E., Friel, J., Baum, Y., Kim, J. S., Shih, A, O. Green, - “Δ-Motif: Parallel Subgraph Isomorphism via Tabular Operations for Scalable Layout Selection”, - IEEE Quantum Week (QCE), 2026 + Find all subgraph monomorphisms of a pattern graph in a target graph + using GPU-accelerated motif-based decomposition. Algorithm described in: + Wang, Y., Ginez, E., Friel, J., Baum, Y., Kim, J. S., Shih, A., + O. Green, "Delta-Motif: Parallel Subgraph Isomorphism via Tabular + Operations for Scalable Layout Selection", IEEE Quantum Week (QCE), + 2026 The pattern is decomposed into small motifs (CPU VF2 on the pattern only), each motif's embeddings in the target are computed as cuDF @@ -59,6 +59,11 @@ def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None): This matches NetworkX ``GraphMatcher.subgraph_monomorphisms_iter``, not the induced ``subgraph_isomorphisms_iter``. + This function requires ``networkx`` at runtime, used only to decompose + the (small) pattern graph on the CPU; all work proportional to the + target graph or the solution set runs on the GPU. networkx is not a + hard dependency of cugraph, so install it separately if needed. + Parameters ---------- G : cugraph.Graph @@ -74,7 +79,9 @@ def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None): The single-edge M2 motif is always included automatically, so the default finds embeddings edge by edge. Larger motifs can speed up big patterns, but each one costs a full pre-solve to enumerate its - embeddings in the target. + embeddings in the target. The passed MotifData objects are updated + in place: their ``embeddings`` attribute is set to the precomputed + cuDF table. Returns ------- @@ -83,13 +90,16 @@ def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None): the pattern vertex id (as a string, sorted order); values are the target vertex ids (original ids if the graph was renumbered). An empty DataFrame (with the same columns) means no embedding exists. + The returned DataFrame is a single cuDF table, so a solution set of + 2**31 or more embeddings raises ValueError (intermediates larger + than that are handled internally via partitioning). Examples -------- >>> import cudf >>> from cugraph import Graph >>> from cugraph.datasets import karate - >>> from cugraph.experimental import subgraph_isomorphism + >>> from cugraph.experimental import subgraph_monomorphism >>> G = karate.get_graph(download=True) >>> triangle = cudf.DataFrame( ... {"src": [0, 1, 2], "dst": [1, 2, 0]} @@ -97,9 +107,16 @@ def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None): >>> pattern_G = Graph() >>> pattern_G.from_cudf_edgelist(triangle, source="src", ... destination="dst") - >>> mappings = subgraph_isomorphism(G, pattern_G) + >>> mappings = subgraph_monomorphism(G, pattern_G) """ + if isinstance(nx, MissingModule): + raise RuntimeError( + "subgraph_monomorphism requires networkx (used only for CPU " + "decomposition of the small pattern graph); please install " + "networkx to use this function." + ) + if G.is_directed() or pattern_G.is_directed(): raise ValueError("input graphs must be undirected") @@ -127,6 +144,17 @@ def EXPERIMENTAL__subgraph_isomorphism(G, pattern_G, motifs=None): {name: cudf.Series([], dtype=vertex_dtype) for name in column_names} ) + if len(result.mappings) >= 2**31: + # The solver holds intermediates as partitions beyond this size, + # but the returned cuDF DataFrame is a single table subject to + # cuDF's 2**31 - 1 column-size limit; fail clearly rather than + # deep inside cuDF. + raise ValueError( + f"The solution set has {len(result.mappings)} embeddings, " + "which exceeds the 2**31 - 1 row limit of the returned cuDF " + "DataFrame. Use a more selective pattern, or a chunked result " + "API (planned follow-up) for solution sets this large." + ) column_names = [str(v) for v in result.pattern_vertices] result_df = cudf.DataFrame(result.mappings, columns=column_names) # Cast up from the solver's compact uint dtypes so unrenumber's merge diff --git a/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py b/python/cugraph/cugraph/tests/isomorphism/test_subgraph_monomorphism.py old mode 100644 new mode 100755 similarity index 57% rename from python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py rename to python/cugraph/cugraph/tests/isomorphism/test_subgraph_monomorphism.py index 62af9d2c865..1f5b955c5dc --- a/python/cugraph/cugraph/tests/isomorphism/test_subgraph_isomorphism.py +++ b/python/cugraph/cugraph/tests/isomorphism/test_subgraph_monomorphism.py @@ -13,7 +13,7 @@ from cugraph.experimental import ( MotifData, default_motif_library, - subgraph_isomorphism, + subgraph_monomorphism, ) # ============================================================================= @@ -79,7 +79,7 @@ def test_matches_networkx_monomorphisms_on_karate(pattern_name): G = karate.get_graph(download=True) pattern_G = build_cugraph_from_edges(PATTERNS[pattern_name]) - result_df = subgraph_isomorphism(G, pattern_G) + result_df = subgraph_monomorphism(G, pattern_G) expected = nx_monomorphisms_set(cugraph_to_nx(G), cugraph_to_nx(pattern_G)) assert result_to_set(result_df) == expected @@ -93,7 +93,7 @@ def test_matches_networkx_on_small_handbuilt_graph(): for pattern_name, pattern_edges in PATTERNS.items(): pattern_G = build_cugraph_from_edges(pattern_edges) - result_df = subgraph_isomorphism(G, pattern_G) + result_df = subgraph_monomorphism(G, pattern_G) expected = nx_monomorphisms_set(cugraph_to_nx(G), cugraph_to_nx(pattern_G)) assert result_to_set(result_df) == expected, pattern_name @@ -103,7 +103,7 @@ def test_triangle_count_on_karate(): G = karate.get_graph(download=True) pattern_G = build_cugraph_from_edges(PATTERNS["triangle"]) - result_df = subgraph_isomorphism(G, pattern_G) + result_df = subgraph_monomorphism(G, pattern_G) n_triangles = sum(nx.triangles(cugraph_to_nx(G)).values()) // 3 # each undirected triangle appears as 3! = 6 ordered embeddings @@ -116,7 +116,7 @@ def test_embeddings_are_valid(): pattern_edges = PATTERNS["4-cycle"] pattern_G = build_cugraph_from_edges(pattern_edges) - result_df = subgraph_isomorphism(G, pattern_G) + result_df = subgraph_monomorphism(G, pattern_G) assert len(result_df) > 0 target_nx = cugraph_to_nx(G) @@ -138,7 +138,7 @@ def test_renumbering_with_noncontiguous_ids(): G = build_cugraph_from_edges(shifted_edges) pattern_G = build_cugraph_from_edges(PATTERNS["triangle"]) - result_df = subgraph_isomorphism(G, pattern_G) + result_df = subgraph_monomorphism(G, pattern_G) expected = nx_monomorphisms_set(cugraph_to_nx(G), cugraph_to_nx(pattern_G)) assert result_to_set(result_df) == expected @@ -158,9 +158,9 @@ def test_directed_graph_raises(): G = build_cugraph_from_edges([(0, 1), (1, 2)]) with pytest.raises(ValueError): - subgraph_isomorphism(dG, pattern_G) + subgraph_monomorphism(dG, pattern_G) with pytest.raises(ValueError): - subgraph_isomorphism(G, dG) + subgraph_monomorphism(G, dG) @pytest.mark.sg @@ -169,7 +169,7 @@ def test_disconnected_pattern_raises(): pattern_G = build_cugraph_from_edges([(0, 1), (2, 3)]) with pytest.raises(ValueError): - subgraph_isomorphism(G, pattern_G) + subgraph_monomorphism(G, pattern_G) @pytest.mark.sg @@ -178,7 +178,42 @@ def test_pattern_larger_than_target_raises(): pattern_G = build_cugraph_from_edges(PATTERNS["K4"]) with pytest.raises(ValueError): - subgraph_isomorphism(G, pattern_G) + subgraph_monomorphism(G, pattern_G) + + +@pytest.mark.sg +def test_pattern_self_loop_raises(): + G = karate.get_graph(download=True) + pattern_G = build_cugraph_from_edges([(0, 0), (0, 1)]) + + with pytest.raises(ValueError, match="self-loop"): + subgraph_monomorphism(G, pattern_G) + + +@pytest.mark.sg +def test_target_self_loops_are_ignored(): + # Identical results with and without self-loops on the target. + base_edges = [(0, 1), (1, 2), (2, 0), (2, 3)] + G_clean = build_cugraph_from_edges(base_edges) + G_loops = build_cugraph_from_edges(base_edges + [(0, 0), (3, 3)]) + pattern_G = build_cugraph_from_edges(PATTERNS["triangle"]) + + assert result_to_set(subgraph_monomorphism(G_loops, pattern_G)) == ( + result_to_set(subgraph_monomorphism(G_clean, pattern_G)) + ) + + +@pytest.mark.sg +def test_pattern_with_noncontiguous_ids(): + # Pattern vertex ids are arbitrary labels; result columns are named by + # the original ids, and matches equal those of the relabeled pattern. + G = karate.get_graph(download=True) + pattern_G = build_cugraph_from_edges([(5, 17), (17, 42)]) + + result_df = subgraph_monomorphism(G, pattern_G) + assert list(result_df.columns) == ["5", "17", "42"] + expected = nx_monomorphisms_set(cugraph_to_nx(G), cugraph_to_nx(pattern_G)) + assert result_to_set(result_df) == expected @pytest.mark.sg @@ -187,11 +222,57 @@ def test_no_match_returns_empty_dataframe(): G = build_cugraph_from_edges([(0, 1), (1, 2), (1, 3), (3, 4)]) pattern_G = build_cugraph_from_edges(PATTERNS["triangle"]) - result_df = subgraph_isomorphism(G, pattern_G) + result_df = subgraph_monomorphism(G, pattern_G) assert len(result_df) == 0 assert list(result_df.columns) == ["0", "1", "2"] +@pytest.mark.sg +def test_empty_result_keeps_unmatched_new_vertex_columns(): + # Star target: partial path embeddings (leaf-center-leaf) exist, but no + # injective embedding of a longer path does, so a mid-assembly merge + # filters down to zero rows. The empty result must still carry one + # column per pattern vertex, including vertices introduced by motifs + # merged at or after the point the intermediate became empty (P4), and + # merges after that point must still find their join columns (P5). + star_edges = [(0, 1), (0, 2), (0, 3), (0, 4)] + G = build_cugraph_from_edges(star_edges) + + for path_edges, n_vertices in ( + ([(0, 1), (1, 2), (2, 3)], 4), + ([(0, 1), (1, 2), (2, 3), (3, 4)], 5), + ): + pattern_G = build_cugraph_from_edges(path_edges) + result_df = subgraph_monomorphism(G, pattern_G) + assert len(result_df) == 0 + assert list(result_df.columns) == [str(v) for v in range(n_vertices)] + + +@pytest.mark.sg +def test_partition_writer_splits_oversized_chunks(): + # A single filtered chunk larger than the row limit must be split so + # that every emitted partition honors the limit. + from cugraph.experimental.isomorphism.solver import _PartitionWriter + + writer = _PartitionWriter(row_limit=10) + writer.add(cudf.DataFrame({"a": range(35)})) + writer.add(cudf.DataFrame({"a": range(35, 42)})) + parts = writer.finish() + assert all(len(part) <= 10 for part in parts) + combined = cudf.concat(parts) + assert sorted(combined["a"].to_pandas()) == list(range(42)) + + # An oversized chunk that is an exact multiple of the limit, arriving + # while the buffer is non-empty. + writer = _PartitionWriter(row_limit=10) + writer.add(cudf.DataFrame({"a": range(3)})) + writer.add(cudf.DataFrame({"a": range(3, 33)})) + parts = writer.finish() + assert all(len(part) <= 10 for part in parts) + combined = cudf.concat(parts) + assert sorted(combined["a"].to_pandas()) == list(range(33)) + + @pytest.mark.sg def test_tiny_join_budget_gives_identical_results(monkeypatch): # Force many small streamed-join batches (and multiple partition-writer @@ -203,7 +284,7 @@ def test_tiny_join_budget_gives_identical_results(monkeypatch): G = karate.get_graph(download=True) pattern_G = build_cugraph_from_edges(PATTERNS["4-cycle"]) - baseline_df = subgraph_isomorphism(G, pattern_G) + baseline_df = subgraph_monomorphism(G, pattern_G) monkeypatch.setattr( _MotifSubgraphIsomorphismSolver, "_JOIN_MEM_BUDGET_FRACTION", 0.0 @@ -212,7 +293,7 @@ def test_tiny_join_budget_gives_identical_results(monkeypatch): _MotifSubgraphIsomorphismSolver, "_JOIN_MEM_BUDGET_MIN_GIB", 1e-6 ) monkeypatch.setattr(_MotifSubgraphIsomorphismSolver, "_FANOUT_SAMPLE_ROWS", 8) - result_df = subgraph_isomorphism(G, pattern_G) + result_df = subgraph_monomorphism(G, pattern_G) assert result_to_set(result_df) == result_to_set(baseline_df) @@ -220,17 +301,83 @@ def test_tiny_join_budget_gives_identical_results(monkeypatch): def test_multi_partition_results_give_identical_results(monkeypatch): # Force intermediate and final results to span many partitions so the # multi-partition (host NumPy) assembly path is exercised; in normal - # operation it only triggers for results beyond ~1.8B rows. + # operation it only triggers for results beyond ~1.8B rows. Every + # partition emitted anywhere in the solve must honor the row limit. + from cugraph.experimental.isomorphism import solver as solver_mod + + G = karate.get_graph(download=True) + pattern_G = build_cugraph_from_edges(PATTERNS["4-cycle"]) + baseline_df = subgraph_monomorphism(G, pattern_G) + + emitted_partition_sizes = [] + + class RecordingWriter(solver_mod._PartitionWriter): + def finish(self): + parts = super().finish() + emitted_partition_sizes.extend(len(part) for part in parts) + return parts + + monkeypatch.setattr(solver_mod._MotifSubgraphIsomorphismSolver, "_ROW_LIMIT", 100) + monkeypatch.setattr(solver_mod, "_PartitionWriter", RecordingWriter) + result_df = subgraph_monomorphism(G, pattern_G) + assert result_to_set(result_df) == result_to_set(baseline_df) + # End-to-end: all partition output flows through the writer, and every + # emitted partition satisfies the configured limit. + assert len(emitted_partition_sizes) > 1 + assert all(size <= 100 for size in emitted_partition_sizes) + + +@pytest.mark.sg +def test_choose_batch_rows_bounds_skewed_partitions(monkeypatch): + # A partition whose leading rows match nothing must not be merged in a + # single whole-partition batch: the unsampled tail may join a hub key, + # and an unbounded batch could overflow cuDF's row limit at scale. The + # strided sample (or, failing that, the byte-budget fallback) must + # return a batch smaller than the partition under a tiny budget. + from cugraph.experimental.isomorphism.solver import ( + _MotifSubgraphIsomorphismSolver, + ) + + monkeypatch.setattr(_MotifSubgraphIsomorphismSolver, "_FANOUT_SAMPLE_ROWS", 8) + solver = _MotifSubgraphIsomorphismSolver( + cudf.DataFrame({"src": [0], "dst": [1]}), 2 + ) + # 64 non-matching rows followed by 64 rows all joining key 0, which has + # fan-out 8 in next_df. + left_df = cudf.DataFrame({"m0_v0": [1] * 128, "m0_v1": [99] * 64 + [0] * 64}) + next_df = cudf.DataFrame({"m1_v0": [0] * 8, "m1_v1": range(8)}) + batch_rows = solver._choose_batch_rows( + left_df, next_df, ["m0_v1"], ["m1_v0"], budget_bytes=64 + ) + assert 1 <= batch_rows < len(left_df) + + +@pytest.mark.sg +def test_malformed_motifs_raise(): + with pytest.raises(ValueError): + MotifData(name="empty", motif=[]) + with pytest.raises(ValueError): + # vertices must be contiguous 0..k-1 + MotifData(name="shifted", motif=[(1, 2)]) + + +@pytest.mark.sg +def test_user_motifs_with_multi_partition_results(monkeypatch): + # Precomputed motif embeddings pass through the solver's output path; + # with a small _ROW_LIMIT that output is a multi-partition (host NumPy) + # array, exercising the NumPy branch of the motif-table construction. from cugraph.experimental.isomorphism.solver import ( _MotifSubgraphIsomorphismSolver, ) G = karate.get_graph(download=True) - pattern_G = build_cugraph_from_edges(PATTERNS["4-cycle"]) - baseline_df = subgraph_isomorphism(G, pattern_G) + pattern_G = build_cugraph_from_edges(PATTERNS["K4"]) + baseline_df = subgraph_monomorphism(G, pattern_G) monkeypatch.setattr(_MotifSubgraphIsomorphismSolver, "_ROW_LIMIT", 100) - result_df = subgraph_isomorphism(G, pattern_G) + result_df = subgraph_monomorphism( + G, pattern_G, motifs=[MotifData(name="M3-path", motif=[(0, 1), (1, 2)])] + ) assert result_to_set(result_df) == result_to_set(baseline_df) @@ -239,8 +386,8 @@ def test_motif_library_gives_identical_results(): G = karate.get_graph(download=True) pattern_G = build_cugraph_from_edges(PATTERNS["K4"]) - baseline_df = subgraph_isomorphism(G, pattern_G) - result_df = subgraph_isomorphism(G, pattern_G, motifs=default_motif_library()) + baseline_df = subgraph_monomorphism(G, pattern_G) + result_df = subgraph_monomorphism(G, pattern_G, motifs=default_motif_library()) assert result_to_set(result_df) == result_to_set(baseline_df) @@ -250,19 +397,19 @@ def test_user_supplied_motifs_give_identical_results(): # default_motif_library(). G = karate.get_graph(download=True) pattern_G = build_cugraph_from_edges(PATTERNS["K4"]) - baseline_df = subgraph_isomorphism(G, pattern_G) + baseline_df = subgraph_monomorphism(G, pattern_G) motifs = [ MotifData(name="M3-path", motif=[(0, 1), (1, 2)]), MotifData(name="M3-triangle", motif=[(0, 1), (1, 2), (0, 2)]), MotifData(name="M4-star", motif=[(0, 1), (0, 2), (0, 3)]), ] - result_df = subgraph_isomorphism(G, pattern_G, motifs=motifs) + result_df = subgraph_monomorphism(G, pattern_G, motifs=motifs) assert result_to_set(result_df) == result_to_set(baseline_df) # Supplying the pattern itself as a motif also works: the decomposition # then covers the pattern with a single slice. - result_df = subgraph_isomorphism( + result_df = subgraph_monomorphism( G, pattern_G, motifs=[MotifData(name="K4", motif=PATTERNS["K4"])] ) assert result_to_set(result_df) == result_to_set(baseline_df) @@ -276,7 +423,7 @@ def test_large_graph_triangle_count(): G = email_Eu_core.get_graph(download=True, ignore_weights=True) pattern_G = build_cugraph_from_edges(PATTERNS["triangle"]) - result_df = subgraph_isomorphism(G, pattern_G) + result_df = subgraph_monomorphism(G, pattern_G) target_nx = cugraph_to_nx(G) target_nx.remove_edges_from(nx.selfloop_edges(target_nx))