Skip to content

Commit 0a12940

Browse files
Thread safety in graph access
1 parent 01fb787 commit 0a12940

11 files changed

Lines changed: 227 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,32 @@ new fusion-move / proposal-based / contraction-based solver:
6060
- `detail/threading.hxx::parallel_for_chunks` — the only threading primitive
6161
we use. New parallel solvers should not introduce alternatives.
6262

63+
### Freeze graphs before parallel fan-out (thread-safety contract)
64+
65+
`UndirectedGraph` (and its subclasses) build the CSR adjacency *lazily*: the
66+
first `node_adjacency` read on a graph built incrementally rebuilds it through
67+
a `mutable` write inside a `const` method, and that rebuild is **not
68+
thread-safe**. Graphs are "dirty" (not yet built) after `insert_edge`-based
69+
construction — this includes the `from_edges` binding, `region_adjacency_graph`,
70+
and `GridGraph` (which defers the build on purpose). `from_sorted_unique_edges`
71+
returns an already-frozen graph.
72+
73+
Rule: **any algorithm that reads `node_adjacency` from `parallel_for_chunks`
74+
(or other threads) MUST call `graph.freeze()` on the calling thread before the
75+
fan-out.** "Reads `node_adjacency`" includes the indirect readers
76+
`breadth_first_search`, `extract_subgraph_from_nodes`, and the multicut
77+
sub-solver `greedy_additive` (via `DynamicGraph::reset`, which sizes per-node
78+
adjacency from `graph.node_adjacency(node)`). Edge-only iteration
79+
(`uv`, `uv_ids`, `number_of_edges`) and `find_edge` (edge-lookup hashmap) do
80+
*not* trigger the rebuild and need no freeze. Symptoms when this is missed:
81+
nondeterministic results for fixed input and intermittent segfaults.
82+
83+
Canonical fixed call sites that already follow this (copy the pattern):
84+
`lifted_multicut/lifted_from_node_labels.hxx` and both
85+
`{multicut,lifted_multicut}/fusion_move.hxx::FusionMoveSolver::optimize`
86+
(the lifted driver freezes the *base* graph — its proposal generators read base
87+
adjacency, while its warm-start only touches the lifted graph).
88+
6389
When porting fusion moves to a new objective (e.g. lifted multicut):
6490

6591
1. The driver loop in `multicut/fusion_move.hxx::FusionMoveSolver::optimize`

MIGRATION_GUIDE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,10 +430,17 @@ Important differences:
430430
- `graph.clone()` returns an independent deep copy. The C++ class is
431431
move-only (it owns a CSR adjacency buffer), so prefer this over
432432
reassignment-by-value.
433-
- `graph.freeze()` eagerly builds the internal adjacency. Call it after a
434-
batch of `insert_edge` calls if you intend to hand the graph to multiple
435-
reader threads, or if you want to ensure subsequent `node_adjacency`
436-
reads carry no first-call rebuild cost.
433+
- The internal adjacency is built *lazily* on the first `node_adjacency`
434+
read, and that lazy build is **not thread-safe**. The built-in
435+
multi-threaded algorithms freeze the graph internally before fanning out, so
436+
passing a graph straight into them is safe. But if you build a graph and then
437+
share it across **your own** threads (concurrent `node_adjacency` reads, a
438+
BFS, etc.), call `graph.freeze()` once on the construction thread first —
439+
racing the first read across threads corrupts the adjacency (nondeterministic
440+
results, possible crashes). `freeze()` eagerly builds the adjacency and is a
441+
no-op once built; it also removes the first-call rebuild cost from later
442+
`node_adjacency` reads. This applies to all graph types (`GridGraph2D`,
443+
`GridGraph3D`, `RegionAdjacencyGraph`).
437444

438445
Common method/property mapping:
439446

include/bioimage_cpp/graph/lifted_multicut/fusion_move.hxx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ public:
9191
return objective.labels();
9292
}
9393

94+
// Proposal generators read base_graph.node_adjacency() concurrently in the
95+
// stage-1 parallel region (the greedy-additive generator does, via
96+
// DynamicGraph::reset). The lazy CSR rebuild is not thread-safe, and the
97+
// warm-start below freezes the *lifted* graph, not the base graph, so freeze
98+
// the base graph on this thread before fan-out. See UndirectedGraph
99+
// thread-safety. (The lifted graph is only read by edge iteration here.)
100+
base_graph.freeze();
101+
94102
const auto effective_threads = ::bioimage_cpp::detail::normalize_thread_count(
95103
number_of_threads_, number_of_parallel_proposals_
96104
);

include/bioimage_cpp/graph/lifted_multicut/lifted_from_node_labels.hxx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ std::vector<bioimage_cpp::detail::Edge> lifted_edges_from_node_labels(
6060
return {};
6161
}
6262

63+
// The CSR adjacency is rebuilt lazily on the first node_adjacency() read and
64+
// that rebuild is not thread-safe. Freeze it on this thread before the
65+
// parallel BFS fan-out below so worker threads only ever do const reads of
66+
// an already-built adjacency (see the UndirectedGraph thread-safety notes).
67+
graph.freeze();
68+
6369
const auto n_threads = bioimage_cpp::detail::normalize_thread_count(
6470
number_of_threads, n_nodes
6571
);

include/bioimage_cpp/graph/multicut/fusion_move.hxx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ public:
7777
return objective.labels();
7878
}
7979

80+
// Proposal generators may read graph.node_adjacency() concurrently in the
81+
// stage-1 parallel region (the greedy-additive generator does, via
82+
// DynamicGraph::reset). The lazy CSR rebuild is not thread-safe, and the
83+
// warm-start below only freezes the graph for a singleton initial labeling,
84+
// so freeze on this thread before fan-out. See UndirectedGraph thread-safety.
85+
graph.freeze();
86+
8087
// One workspace per worker thread; reused across the warm-start, every
8188
// pairwise fuse, and the stage-2 joint fuse.
8289
const auto effective_threads = ::bioimage_cpp::detail::normalize_thread_count(

include/bioimage_cpp/graph/undirected_graph.hxx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,22 @@ struct Adjacency {
3737
// `rebuild_adjacency_from_edges()` explicitly, which keeps reads cheap and
3838
// thread-safe.
3939
//
40-
// Thread safety: as long as a graph is "frozen" before being shared with
41-
// reader threads (no concurrent inserts, no first-read-from-dirty-state
42-
// race), reads of `node_adjacency` are safe to share across threads. The
43-
// lazy rebuild is not internally synchronized — call
44-
// `rebuild_adjacency_from_edges()` once on the construction thread before
45-
// fan-out if you built the graph via `insert_edge*`.
40+
// Thread safety: the lazy rebuild is not internally synchronized. If two
41+
// threads each take the first `node_adjacency` read on a still-dirty graph
42+
// they race on the rebuild — concurrently reallocating `adjacency_data_` and
43+
// overwriting `adjacency_offsets_` — which corrupts the CSR (garbage neighbor
44+
// ids, out-of-bounds reads) and intermittently segfaults. The rule:
45+
//
46+
// Any algorithm that reads `node_adjacency` (directly, or via
47+
// `breadth_first_search`, `extract_subgraph_from_nodes`, or a sub-solver
48+
// such as `multicut::greedy_additive`'s `DynamicGraph::reset`) from
49+
// `parallel_for_chunks` or other threads MUST `freeze()` the graph on the
50+
// calling thread *before* the fan-out.
51+
//
52+
// Once frozen (or built via `from_sorted_unique_edges`, which rebuilds the CSR
53+
// eagerly), the graph has no mutable read path and is safe to share by
54+
// `const&` across reader threads. Graphs built incrementally via `insert_edge*`
55+
// (including the `from_edges` binding and `region_adjacency_graph`) start dirty.
4656
class UndirectedGraph {
4757
public:
4858
using NodeId = std::uint64_t;
@@ -139,6 +149,10 @@ public:
139149
return edges_;
140150
}
141151

152+
// Adjacency slice of `node`. The first call on a dirty graph triggers a
153+
// non-thread-safe lazy CSR rebuild (mutable write through this `const`
154+
// method); call `freeze()` on the construction thread before sharing the
155+
// graph with concurrent readers. See the class-level thread-safety note.
142156
[[nodiscard]] AdjacencyList node_adjacency(const NodeId node) const {
143157
validate_node(node);
144158
ensure_adjacency_built();

src/bindings/graph.cxx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1599,7 +1599,15 @@ void bind_graph(nb::module_ &m) {
15991599
nb::arg("nodes")
16001600
)
16011601
.def("edges_from_node_list", &graph_edges_from_node_list, nb::arg("nodes"))
1602-
.def("freeze", &Graph::freeze)
1602+
.def(
1603+
"freeze",
1604+
&Graph::freeze,
1605+
"Build the internal adjacency representation now (it is otherwise "
1606+
"built lazily on first use). Call this on the construction thread "
1607+
"before sharing the graph with concurrent reader threads: the lazy "
1608+
"build is not thread-safe. No-op if already built; safe to call "
1609+
"repeatedly."
1610+
)
16031611
.def("clone", &Graph::clone)
16041612
.def_static(
16051613
"from_edges",

src/bioimage_cpp/graph/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@
1919
(with and without semantic constraints).
2020
- :mod:`bioimage_cpp.graph.features` — edge-feature accumulation on RAGs and
2121
grid graphs.
22+
23+
Thread safety
24+
-------------
25+
All graph types (:class:`UndirectedGraph`, :class:`GridGraph2D`,
26+
:class:`GridGraph3D`, :class:`RegionAdjacencyGraph`) build their internal
27+
adjacency representation *lazily*, on the first call that reads it. The
28+
built-in multi-threaded algorithms freeze the graph internally before fanning
29+
out, so passing a graph straight into them is safe and needs no extra step.
30+
31+
If you build a graph yourself and then share it across **your own** threads
32+
(reading adjacency, running a BFS, etc. concurrently), call ``graph.freeze()``
33+
once on the construction thread first: the lazy build is not thread-safe, and
34+
racing the first read across threads corrupts the adjacency. ``freeze()`` is a
35+
no-op on an already-built graph.
2236
"""
2337

2438
from __future__ import annotations
@@ -46,6 +60,11 @@ class UndirectedGraph(_core.UndirectedGraph):
4660
``0 .. number_of_nodes - 1``. Edges are inserted lazily and receive
4761
consecutive ids in insertion order. Re-inserting an existing undirected edge
4862
returns the existing edge id.
63+
64+
The adjacency representation is built lazily on first use. Before sharing a
65+
freshly built graph across threads of your own, call :meth:`freeze` once on
66+
the construction thread — see the module-level "Thread safety" note. The
67+
built-in multi-threaded algorithms already freeze internally.
4968
"""
5069

5170
def insert_edges(self, uvs):

tests/graph/lifted_multicut/test_fusion_move.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,47 @@ def test_fusion_move_default_parallel_proposals_tracks_threads():
309309
)
310310
assert one_thread.number_of_parallel_proposals == 2
311311
assert four_threads.number_of_parallel_proposals == 4
312+
313+
314+
def test_greedy_proposals_parallel_is_deterministic_on_dirty_base_graph():
315+
# Regression guard for the lazy-CSR-adjacency data race on the *base* graph.
316+
# The greedy-additive proposal generator reads base_graph.node_adjacency()
317+
# (via DynamicGraph::reset); with T>1 the parallel proposal slots used to
318+
# race on the first rebuild of a not-yet-frozen base graph. Unlike the
319+
# multicut driver, here the singleton warm-start only freezes the *lifted*
320+
# graph, so the race is reachable from the default start. The solver now
321+
# freezes the base graph before fan-out; the multi-threaded result must equal
322+
# the single-threaded reference on every run.
323+
#
324+
# Note: a regression here can surface as a process crash (it is a data race),
325+
# not just a value mismatch.
326+
n = 2000
327+
base_edges = np.array([[i, i + 1] for i in range(n - 1)], dtype=np.uint64)
328+
base_costs = np.array(
329+
[1.0 if i % 3 else -2.0 for i in range(n - 1)], dtype=np.float64
330+
)
331+
# A handful of lifted edges keeps the lifted graph small (fast warm-start)
332+
# while the large base graph drives the parallel proposal generation.
333+
lifted_uvs = np.array(
334+
[[i, i + 5] for i in range(0, n - 5, 250)], dtype=np.uint64
335+
)
336+
lifted_costs = np.array([-3.0] * len(lifted_uvs), dtype=np.float64)
337+
parallel_proposals = 4
338+
339+
def run(threads):
340+
# Fresh base graph per run so each multi-threaded run starts dirty.
341+
base = bic.graph.UndirectedGraph.from_edges(n, base_edges)
342+
objective = bic.graph.lifted_multicut.LiftedMulticutObjective(
343+
base, base_costs, lifted_uvs=lifted_uvs, lifted_costs=lifted_costs
344+
)
345+
solver = bic.graph.lifted_multicut.FusionMoveLiftedMulticut(
346+
proposal_generator=bic.graph.lifted_multicut.GreedyAdditiveProposalGenerator(seed=0),
347+
number_of_threads=threads,
348+
number_of_parallel_proposals=parallel_proposals,
349+
number_of_iterations=3,
350+
)
351+
return solver.optimize(objective)
352+
353+
reference = run(1)
354+
for _ in range(15):
355+
np.testing.assert_array_equal(run(4), reference)

tests/graph/lifted_multicut/test_lifted_edges_from_node_labels.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,41 @@ def test_empty_graph():
238238
graph, labels, graph_depth=2, mode="all"
239239
)
240240
assert out.shape == (0, 2)
241+
242+
243+
def test_default_threading_is_deterministic_on_large_chain():
244+
# Regression guard for a data race in the lazy CSR-adjacency rebuild: with
245+
# default (multi-threaded) execution, every worker used to trigger the
246+
# not-thread-safe rebuild concurrently on the first node_adjacency() read,
247+
# corrupting the adjacency. That produced run-to-run varying counts and
248+
# intermittent segfaults. A graph this size reliably exposes the race
249+
# (a 10-node chain does not). The result must equal the single-threaded
250+
# reference on every run.
251+
n = 2000
252+
graph = _make_chain(n) # built via from_edges -> arrives "dirty"
253+
labels = np.ones(n, dtype=np.uint64)
254+
reference = bic.graph.lifted_multicut.lifted_edges_from_node_labels(
255+
graph, labels, graph_depth=3, mode="all", number_of_threads=1
256+
)
257+
for _ in range(25):
258+
out = bic.graph.lifted_multicut.lifted_edges_from_node_labels(
259+
graph, labels, graph_depth=3, mode="all" # default: multi-threaded
260+
)
261+
assert out.tolist() == reference.tolist()
262+
263+
264+
def test_default_threading_is_deterministic_on_rag():
265+
# Same race, reached through the region_adjacency_graph construction path,
266+
# which also returns a graph with a dirty (not-yet-built) adjacency.
267+
n = 2000
268+
segmentation = np.repeat(np.arange(n, dtype=np.uint32), 16).reshape(n, 4, 4)
269+
rag = bic.graph.region_adjacency_graph(segmentation)
270+
labels = np.ones(rag.numberOfNodes, dtype=np.uint64)
271+
reference = bic.graph.lifted_multicut.lifted_edges_from_node_labels(
272+
rag, labels, graph_depth=3, mode="all", number_of_threads=1
273+
)
274+
for _ in range(25):
275+
out = bic.graph.lifted_multicut.lifted_edges_from_node_labels(
276+
rag, labels, graph_depth=3, mode="all" # default: multi-threaded
277+
)
278+
assert out.tolist() == reference.tolist()

0 commit comments

Comments
 (0)