Add subgraph isomorphism (monomorphism) - #5598
Conversation
|
This is a very interesting feature request. Our team works extensively with large-scale graph analytics, and we’ve encountered similar requirements where efficiently matching known graph patterns in very large graphs is important. GPU-accelerated subgraph matching has the potential to significantly improve both scalability and analysis time for pattern-based graph workloads. Once an implementation is available, we’d be happy to evaluate it on one of our production use cases (using representative datasets and appropriate internal constraints) and share feedback on performance, scalability, API usability, and any observed improvements compared to our current approach. Looking forward to seeing how this feature evolves. I’d also be happy to contribute by validating the implementation, benchmarking it on large graphs, and suggesting additional test scenarios if that would be helpful. |
…aph_subgraph_iso into add_subgraph_isomorphism
|
WRT second code update: memory-streaming work folded in, plus test expansion. Delta vs the original version:
|
|
@sauravsingla |
|
/ok to test 88f9c2f |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
/ok to test 91f9725 |
|
/ok to test 3fc34cc |
|
/ok to test f6346a0 |
alexbarghi-nv
left a comment
There was a problem hiding this comment.
I asked Codex to review first, before I took a deeper look. Here's what it found:
1. Empty joins lose the newly introduced schema
_PartitionWriter.add() discards empty DataFrames. If every batch in a merge produces zero matches, _merge_and_filter_streamed() falls back to:
[partitions[0].iloc[:0]]That restores the schema from before the new motif was joined, so columns introduced by the new motif disappear.
This can have two effects:
- If it is the final merge, the returned empty DataFrame can omit some pattern-vertex columns.
- If additional merges remain and one references a vertex introduced by the failed merge, the next join can encounter a missing column.
Relevant code:
[_PartitionWriter.add()](https://github.com/rapidsai/cugraph/blob/f6346a0097f26a9904535d97910f6d3930bcba07/python/cugraph/cugraph/experimental/isomorphism/solver.py#L48-L59)- [Empty-result fallback]()
cugraph/python/cugraph/cugraph/experimental/isomorphism/solver.py
Lines 267 to 268 in f6346a0
- [Output schema reconstruction]()
cugraph/python/cugraph/cugraph/experimental/isomorphism/solver.py
Lines 381 to 390 in f6346a0
I suggest preserving an empty DataFrame with the post-merge schema, or short-circuiting the solve and constructing the documented empty result from all pattern vertices.
A useful regression test would search for a path in a star graph: partial embeddings exist, but the complete injective embedding does not. The result should be empty while retaining every pattern-vertex column. A longer path would also exercise failure before the final merge.
2. _ROW_LIMIT is estimated but not actually enforced
The implementation states that each intermediate partition remains below _ROW_LIMIT, but _PartitionWriter.add() does not split an incoming DataFrame that already exceeds the limit.
There is also a path in _choose_batch_rows() that returns the entire left partition without estimating fan-out whenever it has at most _FANOUT_SAMPLE_ROWS rows:
if n_rows <= self._FANOUT_SAMPLE_ROWS:
return n_rowsA small number of high-degree or highly skewed join keys can still generate a very large join. For larger partitions, sampling only the first rows can likewise underestimate fan-out by substantially more than the current margin.
Relevant code:
[_PartitionWriter](https://github.com/rapidsai/cugraph/blob/f6346a0097f26a9904535d97910f6d3930bcba07/python/cugraph/cugraph/experimental/isomorphism/solver.py#L36-L70)[_choose_batch_rows()](https://github.com/rapidsai/cugraph/blob/f6346a0097f26a9904535d97910f6d3930bcba07/python/cugraph/cugraph/experimental/isomorphism/solver.py#L311-L344)
I suggest making _PartitionWriter.add() physically split every incoming result at row_limit, independent of the batching estimate. The batching logic should also estimate or otherwise bound fan-out for small left partitions.
The partition test should assert that every emitted partition satisfies the configured row limit, rather than only comparing the final result set.
3. NetworkX becomes an undeclared runtime dependency
The implementation uses NetworkX in production paths to construct motif graphs, check connectivity, and run GraphMatcher. However, NetworkX is currently an optional test dependency for cuGraph, and the PR states that it adds no new dependencies.
Relevant code:
MotifDataconstructs annx.Graph- [NetworkX matching in the slicer]()
cugraph/python/cugraph/cugraph/experimental/isomorphism/slicing.py
Lines 75 to 78 in f6346a0
At minimum, NetworkX should be declared and documented as a runtime dependency for this feature. My preference would be to keep MotifData as a dependency-free motif description and move any NetworkX conversion into the private decomposition implementation.
It may also be worth separating the immutable motif definition from target-specific embedding data. Currently MotifData.copy() deep-copies its cuDF isomorphisms table, and the slicer calls that copy for every selected slice. Repeated M2 slices may therefore duplicate the target edge-embedding table unnecessarily:
[MotifData.copy()](https://github.com/rapidsai/cugraph/blob/f6346a0097f26a9904535d97910f6d3930bcba07/python/cugraph/cugraph/experimental/isomorphism/motif.py#L45-L53)- [Copy performed during slicing]()
cugraph/python/cugraph/cugraph/experimental/isomorphism/slicing.py
Lines 120 to 125 in f6346a0
A lightweight immutable motif specification plus private, shared compiled embeddings would avoid both the dependency leakage and these potentially large copies.
Additional API consideration
The implementation returns non-induced monomorphisms, despite being named subgraph_isomorphism. The docstring explains this clearly, but the name is still likely to surprise users familiar with NetworkX terminology. I would consider either:
- naming it
subgraph_monomorphism, or - adding an explicit
induced=Falseargument that leaves room for induced matching later.
The empty-result behavior and unenforced partition limit are correctness/scalability blockers. The dependency and motif-state separation should also be resolved or explicitly documented before this becomes part of the experimental public API.
| from cugraph.experimental.isomorphism.motif import MotifData | ||
| from cugraph.utilities.utils import import_optional | ||
|
|
||
| nx = import_optional("networkx") |
There was a problem hiding this comment.
I'd prefer we use cudf/cupy instead of networkx both for scalability and dependency reasons.
| existing_slices: List[List[int]], | ||
| ) -> SlicingResults | None: | ||
| """Attempt to extract the next valid motif slice.""" | ||
| remaining_nodes = set(graph.nodes) |
There was a problem hiding this comment.
Again; I think it would be better to just use raw arrays - I don't think the conversion should be too hard.
| # 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) |
There was a problem hiding this comment.
This is not performant code - I think we'd want to implement graph matching in cugraph/cudf, or I suppose we could allow the user to specify a graph matching function - networkx could be one option.
| # subgraph=True). | ||
| matcher = nx.algorithms.isomorphism.GraphMatcher(graph, motif_data.graph) | ||
|
|
||
| for mapping in matcher.subgraph_isomorphisms_iter(): |
There was a problem hiding this comment.
again, this is a lot of CPU code in a GPU library...
|
Looking at the code and implementation here - this looks like it's wrapping a lot of networkx code. Is this really any more performant than the networkx version? I see streamed vs. unstreamed performance benchmarks, but not a comparison to the networkx implementation. |
…free MotifData, rename to subgraph_monomorphism
|
Thank you for the review. Sorry about the missing NetworkX comparison, I was so deep in the weeds on the different fixes that the original author applied, I didn't even think to include the comparison to CPU. I made it the first point of the summary below (eaffd70): 1. Performance vs. NetworkX
Also see point 5 for some of the CPU code stuff. 2. Empty joins losing schema fixed 3. 4. NetworkX dependency restructured per your comment 5. RE: "a lot of CPU code in a GPU library" 6. Naming -- renamed Additional changes from self-review in this push Test suite is now 25 tests from the fixes etc., all passing. |
Closes #5597
Adds a GPU-accelerated subgraph matching algorithm as
cugraph.experimental.subgraph_isomorphism(G, pattern_G, motifs=None). See the linked issue for motivation and use cases.Semantics: returns monomorphisms (non-induced matches), same as NetworkX
GraphMatcher.subgraph_monomorphisms_iterand rustworkx VF2 withinduced=False. Documented in the docstring.Approach: the pattern graph is decomposed into small motifs, each motif's embeddings in the target are materialized as cuDF tables, full embeddings are assembled through cuDF inner joins on shared boundary vertices, with a cuPy injectivity filter. All work proportional to the target graph size happens on the GPU as DataFrame operations.
The join phase is streamed and partitioned, with no user-facing tuning surface:
rmm.reinitialize(managed_memory=True)/cudf.set_option("spill", True)), the library itself never touches global allocator state.Numbers (L40S 46 GB, median of 5, cross-validated against NetworkX monomorphisms on every case):
On comfortably-fitting cases, times and peaks are comparable to the unstreamed version (streaming only engages under pressure), memory use is bounded and the structural intermediate-size limit is gone.
Changes:
python/cugraph/cugraph/experimental/isomorphism/(motif, slicing, solver, public wrapper)experimental/__init__.pyviaexperimental_warning_wrapper;MotifDataanddefault_motif_libraryhelpers exported unwrappedtests/isomorphism/(17): cross-validation against NetworkX on karate and hand-built graphs, a larger count-validated dataset (email_Eu_core), renumbering, input validation, user-supplied motifs, and forced coverage of the rare streaming paths (multi-batch and multi-partition, via monkeypatched budget/row-limit)No new dependencies(see below) and no build-system changesNotes for reviewers:
subgraph_isomorphismvssubgraph_monomorphismnaming: open to either; kept the former since it's the conventional name in related work.batch_size; it was a footgun), let me know if a knob is wanted anyway.