Skip to content

perf(scc): traverse iteratively and look each successor up once - #799

Merged
samueltardieu merged 1 commit into
evenfurther:mainfrom
tachsin:fix/scc-stack-overflow
Sep 9, 2026
Merged

perf(scc): traverse iteratively and look each successor up once#799
samueltardieu merged 1 commit into
evenfurther:mainfrom
tachsin:fix/scc-stack-overflow

Conversation

@tachsin

@tachsin tachsin commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Part of #795, which covers the same defect in topological_sort as well; that half is #800, and the issue can be closed once both have landed.

recurse_onto descended one stack frame per node, so the recursion depth was the depth of the graph. A 200 000 node chain overflows an 8 MiB stack, and a random graph of 60 000 nodes at average degree 4 is enough on its own. This replaces the recursion with an explicit stack.

While the traversal was being rewritten, three things it repeated go away:

  • scca and preorders answered two halves of the same question about each successor, costing two hash lookups per edge. A node whose component has been emitted is now marked in preorders itself, leaving one.
  • p held cloned nodes and looked their preorder numbers back up on every pop. It now holds the preorder numbers directly, so unwinding it does no hashing and no cloning.
  • strongly_connected_components picked its next unvisited node with preorders.keys().find(..) over a map it was emptying as it went, rescanning the vacated buckets each time. It now walks the caller's slice.

Worth reviewing

The order of the returned components now follows the order of the nodes argument rather than hash iteration order. It was never specified, but this is a visible change for anyone who happened to depend on it — it is at least deterministic now.

Checking

Correctness is checked against mutual reachability, for every pair of nodes, over 100 random graphs. A regression test runs a 200 000 node chain on a deliberately small 1 MiB stack, so a return to a recursive traversal fails loudly rather than silently.

About 64% off a 60 000 node graph of degree 4, measured against this branch's parent.

All timings measured on Windows 11, Intel Core Ultra 7 265K, 64 GB RAM.

`recurse_onto` descends one stack frame per node, so a graph only has to be
deep to bring the process down. A chain of 200 000 nodes overflows an 8 MiB
stack, and a random graph of 60 000 nodes with average degree 4 is already
enough. Walk an explicit stack instead.

While the traversal is being rewritten, three things it repeated go away:

- `scca` and `preorders` answered two halves of the same question about a
  successor, costing two hash lookups per edge. A node whose component has been
  emitted is now marked in `preorders` itself, leaving one.
- `p` held cloned nodes and looked their preorder numbers back up on every
  pop. It now holds the preorder numbers directly, so the loop that unwinds it
  does no hashing and no cloning at all.
- `strongly_connected_components` picked its next unvisited node with
  `preorders.keys().find(..)` over a map it was emptying as it went, rescanning
  the vacated buckets each time. It now walks the caller's slice, which also
  makes the order of the returned components deterministic rather than
  dependent on hash iteration order.

Nodes are still assigned to exactly one component each, which is checked
against mutual reachability over 100 random graphs.

About 64% off a 60 000 node graph of degree 4.
@samueltardieu
samueltardieu added this pull request to the merge queue Sep 9, 2026
Merged via the queue into evenfurther:main with commit f907410 Sep 9, 2026
11 of 12 checks passed
Sign up for free to 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