Skip to content

Avoid reference cycles in graphs - #33

Merged
MridulS merged 1 commit into
mainfrom
avoid-cyclic-garbage
Sep 2, 2026
Merged

Avoid reference cycles in graphs#33
MridulS merged 1 commit into
mainfrom
avoid-cyclic-garbage

Conversation

@SimonHeybrock

@SimonHeybrockSimonHeybrock commented Aug 31, 2026

Copy link
Copy Markdown
Member

networkx caches report views such as G.edges or G.out_degree in G.__dict__, and each of those views holds a reference back to the graph. Merely looking at a graph therefore places it in a reference cycle, so it can be freed only by the cyclic garbage collector. That collector is triggered by the number of allocated objects, so a handful of discarded graphs holding large node data can survive indefinitely. Cyclebane creates and discards graphs in most of its operations, and the graph returned by to_networkx becomes self-referential as soon as a consumer inspects it, so applications that repeatedly update a graph grow without bound. This OOM-killed a 32 GB host in 34 minutes, see scipp/esslivedata#1264 and scipp/esslivedata#1266.

Avoiding the view accesses in cyclebane itself does not work: nx.relabel_nodes and nx.compose_all materialise G.edges on their inputs internally, and the graph handed to the caller by to_networkx gets views taken on it by the caller. Instead, use a nx.DiGraph subclass in which the six back-referencing views are recreated on each access. Graphs derived by networkx operations are created via G.__class__(), so all intermediates, to_networkx results, subgraphs and copies inherit this, without changes at any of the call sites.

Note that Graph.__init__ now copies a graph that is not already of this type, with the same semantics as nx.DiGraph.copy.

Each of the added tests fails without the change.

Verification

With the reproducer from scipp/esslivedata#1264 (sciline + numpy only) and the cyclic collector disabled, 60 iterations of pipeline[Chunk] = <67 MB array>; pipeline.compute(Result) now stay flat at 126 MB instead of reaching 4.1 GB. With gc.DEBUG_SAVEALL, map/reduce/groupby/to_networkx produce no cyclic garbage at all.

Cost

Creating a view costs ~0.2 us, which is measurable only for code re-accessing a view per node, e.g., [G.degree(n) for n in G] is 2x slower. Complete networkx algorithms (topological_sort, dag_longest_path, pagerank, ancestors) are unaffected within noise, since they bind G.adj once and the views that do not hold a graph reference (adj, succ, pred, nodes) are still cached. to_networkx is unchanged, __setitem__ is slightly faster.

The views are no longer identical across accesses. They compare equal where networkx defines equality for them, but the degree views do not, i.e., G.degree == G.degree is now False.

@SimonHeybrock

Copy link
Copy Markdown
MemberAuthor

networkx caches report views such as G.edges or G.out_degree in G.__dict__, and each of those views holds a reference back to the graph. Merely looking at a graph therefore places it in a reference cycle, so it can be freed only by the cyclic garbage collector. That collector is triggered by the number of allocated objects, so a handful of discarded graphs holding large node data can survive indefinitely.

@MridulS Is networkx aware of this behavior?

NetworkX caches report views such as `G.edges` or `G.out_degree` in
`G.__dict__`, and each of those views holds a reference back to the graph.
A graph a view was taken of is thus reclaimable only by the cyclic garbage
collector, which is triggered by the number of allocated objects and may
therefore not run for a long time when few but large objects are involved.
Cyclebane creates and discards graphs in most of its operations and node
attributes can hold large data, so applications that repeatedly update a
graph (streaming workflows inserting chunks via `Graph.__setitem__`) grew
without bound.
Use a `nx.DiGraph` subclass that recreates the views on each access instead.
Graphs derived by NetworkX operations inherit the subclass, so this also
covers the intermediates built inside `nx.compose` and `nx.relabel_nodes`,
as well as the graph returned by `to_networkx`.
Verified with the reproducer from scipp/esslivedata#1264: 60 iterations of
`pipeline[Chunk] = <67 MB array>; pipeline.compute(Result)` with the cyclic
collector disabled stay flat at 126 MB instead of reaching 4.1 GB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MridulS

Copy link
Copy Markdown
Member

I remember this networkx/networkx#7697, and the hope was the CPython gc should take care of it. I'll look a bit more into this.

@MridulS
MridulS merged commit f42329a into mainSep 2, 2026
4 checks passed
@MridulS
MridulS deleted the avoid-cyclic-garbage branch September 2, 2026 13:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@SimonHeybrock@MridulS