Skip to content

Add subgraph isomorphism (monomorphism) - #5598

Open
alexfallin wants to merge 14 commits into
rapidsai:mainfrom
alexfallin:add_subgraph_isomorphism
Open

Add subgraph isomorphism (monomorphism)#5598
alexfallin wants to merge 14 commits into
rapidsai:mainfrom
alexfallin:add_subgraph_isomorphism

Conversation

@alexfallin

@alexfallin alexfallin commented Jul 21, 2026

Copy link
Copy Markdown

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_iter and rustworkx VF2 with induced=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:

  • Batch sizes are chosen automatically per merge step: sampled join fan-out × a memory budget scaled to free device memory (free/8, floored at 0.5 GiB, read-only query), row-clamped so no single join output can exceed cuDF's 2³¹−1 column-size limit on any GPU or vertex dtype.
  • Intermediate results are held as partitions, each under the cuDF limit, so intermediates are no longer capped by it. EX: citationCiteseer 4-cycle (105.7 M embeddings) completes, a case that is structurally impossible unstreamed (its single join needs 2.49 B rows). Final results are still bound by cuDF, but that's much less likely to bite than intermediates, which carry many candidates that don't survive to the end.
  • Results fitting in one partition (the common case) stay on device end to end; multi-partition results assemble on host with 64-bit indexing, then are delivered as a cuDF DataFrame either way.
  • For beyond-VRAM problems the docstring points at application-level spilling (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):

Graph Pattern This PR: solve s / peak GiB Unstreamed baseline: solve s / peak GiB
enron triangle 0.82 / 5.87 0.83 / 4.99
citationCiteseer triangle 0.84 / 6.13 0.62 / 5.25
soc-Slashdot0902 triangle 0.92 / 7.74 0.98 / 8.61
coAuthorsCiteseer triangle 0.85 / 2.52 0.76 / 2.17
smallworld 4-cycle 1.01 / 6.89 0.98 / 6.59
citationCiteseer 4-cycle 84.4 s with managed memory, OOM on the GPU w/o (105.7 M solutions) cuDF OverflowError (impossible: join > 2³¹ rows)

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:

  • New pure-Python subpackage python/cugraph/cugraph/experimental/isomorphism/ (motif, slicing, solver, public wrapper)
  • Registration in experimental/__init__.py via experimental_warning_wrapper; MotifData and default_motif_library helpers exported unwrapped
  • Tests in tests/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 changes

Notes for reviewers:

  • subgraph_isomorphism vs subgraph_monomorphism naming: open to either; kept the former since it's the conventional name in related work.
  • There's no batching/tuning parameter in the public API (an earlier revision had batch_size; it was a footgun), let me know if a knob is wanted anyway.

@copy-pr-bot

copy-pr-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@sauravsingla

Copy link
Copy Markdown
Contributor

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.

@alexfallin

alexfallin commented Jul 23, 2026

Copy link
Copy Markdown
Author

WRT second code update: memory-streaming work folded in, plus test expansion.

Delta vs the original version:
Solver rework (solver.py): the join phase is now streamed and partitioned

  • Intermediate results are held as partitions, each under cuDF's 2³¹-1 column-size limit, so intermediates are no longer capped by it (previously one oversized join failed outright). EX: citationCiteseer 4-cycle (105.7 M embeddings) completes using this new approach, a case that is structurally impossible unstreamed (its single join needs 2.49 B rows, over the cuDF limit). Final results are still bound by cuDF, but much less likely to be an issue than intermediate results which have many candidates that do not make it to the end.
  • Batch sizes are chosen automatically per merge step: sampled join fan-out * a memory budget scaled to free device memory (free/8, floored at 0.5 GiB, read-only query), row-clamped so no single join output can exceed the cuDF row limit on any GPU or vertex dtype.
  • Duplicate join-key columns are dropped after each merge; the injectivity filter accumulates per-column instead of materializing a rows * |prev| * |new| boolean tensor.
  • Results fitting in one partition (the common case) now stay on device end to end (~35× faster output formatting by avoiding the trip down to host); multi-partition results assemble on host with 64-bit indexing then are delivered to user as cuDF dataframe.
  • API change: batch_size is removed from the public signature (was a footgun atp) - batching is fully automatic with no tuning surface. For beyond-VRAM problems the docstring points at application-level spilling (rmm.reinitialize(managed_memory=True) / cudf.set_option("spill", True)). Let me know if this is inappropriate.
  • Tests: added 7 more, user-supplied motifs (including a whole-pattern-as-motif case), a larger count-validated dataset (email_Eu_core), and forced coverage of the rare streaming paths (multi-batch and multi-partition, via monkeypatched budget/row-limit). All 17 pass on an L40S computelab node.

@alexfallin

Copy link
Copy Markdown
Author

@sauravsingla
Thanks for the interest! It's still a draft and experimental, so the API/behavior may shift as review happens - but feel free to try it on your workloads. Any feedback here or on #5597 would be useful.

@alexbarghi-nv alexbarghi-nv added feature request New feature or request non-breaking Non-breaking change labels Jul 23, 2026
@alexbarghi-nv

Copy link
Copy Markdown
Member

/ok to test 88f9c2f

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexbarghi-nv

Copy link
Copy Markdown
Member

/ok to test 91f9725

@alexbarghi-nv
alexbarghi-nv marked this pull request as ready for review July 24, 2026 17:00
@alexbarghi-nv
alexbarghi-nv requested a review from a team as a code owner July 24, 2026 17:00
@alexbarghi-nv

Copy link
Copy Markdown
Member

/ok to test 3fc34cc

@rapidsai rapidsai deleted a comment from copy-pr-bot Bot Jul 30, 2026
@ChuckHastings

Copy link
Copy Markdown
Collaborator

/ok to test f6346a0

@alexbarghi-nv alexbarghi-nv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

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_rows

A 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:

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:

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:

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=False argument 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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, this is a lot of CPU code in a GPU library...

@alexbarghi-nv

Copy link
Copy Markdown
Member

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.

@alexfallin

alexfallin commented Aug 11, 2026

Copy link
Copy Markdown
Author

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
AMD EPYC 7313P + L40S system. Baseline is GraphMatcher.subgraph_monomorphisms_iter (the implementation this PR would replace). GPU times are the solve step with the graph already resident as a cuDF edge list (motif-table prep adds <0.1 s); NX times are the enumeration loop with the graph in host memory; 1800 s timeout (~1.6x the slowest completing case). Note: I did verify that NetworkX produced the same solutions:

Graph Pattern NetworkX VF2 (CPU) This PR (median of 5) Speedup
enron (36.7 K / 183 K) triangle 1098s 0.80s ~1370x
citationCiteseer (268 K / 1.16 M) triangle 507s 0.65s ~780x
soc-Slashdot0902 (82 K / 504 K) triangle 1062s 0.85s ~1250x
coAuthorsCiteseer (227 K / 814 K) triangle 397s 0.82s ~480x
smallworld (100 K / 500 K) 4-cycle 544s 0.90s ~600x
citationCiteseer 4-cycle (105.7 M embeddings) timeout @ 1800s 96.7s (managed memory, 3 runs) >18x

Also see point 5 for some of the CPU code stuff.

2. Empty joins losing schema fixed
When every batch of a merge filters to zero rows, the fallback now constructs the empty frame by merging the empty inputs, preserving the post-merge schema, so later merges still find their join columns and empty results keep one column per pattern vertex. I added the regression test you suggested (a path pattern in a star graph, where partial embeddings exist but no complete injective one does) in two variants: P4 catches the missing-column case, and P5 catches a merge occurring after the intermediate became empty which would previously cause KeyError.

3. _ROW_LIMIT enforcement fixed
_PartitionWriter.add() now splits any chunk above the limit, the 'seed' partition now also goes through the writer, so every partition honors the limit. Added tests for this. The small-partition estimation bypass is also removed. Sampling can't fully bound estimation error, but a pathological distribution fails loudly inside cuDF rather than returning wrong results.

4. NetworkX dependency restructured per your comment
MotifData is now dependency-free (a plain edge list; no nx objects in any public data structure or signature). NetworkX use is confined to private helpers on the CPU pattern/motif side (_to_nx(), _pattern_to_nx). networkx stays out of the run dependencies while this is experimental: it remains declared in the test extra, and the public function raises a clear error with install guidance if it's absent. Let me know if this is a bad way to do this, and what would be preferred.

5. RE: "a lot of CPU code in a GPU library"
The CPU VF2 runs only on the pattern graph which is usually relatively small, never on the target/data graph. When timed, the decomposition step measures ~1 ms in the benchmark cases. When a single partition, host work is essentially only in slicing.py + the private nx conversion and validation. When multi-partition, results are assembled host-side only because they exceed what a single device table can hold. Everything else related to the target graph or solution is cuDF/CuPy.

6. Naming -- renamed
The API is now cugraph.experimental.subgraph_monomorphism (everything updated to reflect). I think the rename over an induced= parameter makes more sense since induced matching isn't implemented (and is out of scope for me, I'm just adapting this code to cuGraph from the internal source).

Additional changes from self-review in this push
MotifData.copy() now shallow-copies embeddings tables (slices of one motif share a single GPU table instead of deep-copying per slice)
MotifData.isomorphisms renamed to embeddings
MotifData/default_motif_library now go through the standard experimental warning wrapper
Malformed motifs raise clear ValueErrors
Solution sets or motif/M2 tables that would exceed cuDF's 2^31−1 single-table limit raise clear errors instead of failing inside cuDF

Test suite is now 25 tests from the fixes etc., all passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature request New feature or request non-breaking Non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEA]: Subgraph isomorphism (monomorphism) matching

4 participants