Skip to content

Commit 8058623

Browse files
Fix lifted nh (#48)
* Add script to reproduce SegFault * Thread safety in graph access
1 parent 5cfc422 commit 8058623

12 files changed

Lines changed: 381 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

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#!/usr/bin/env python
2+
"""Self-contained reproduction of the bug in
3+
``bioimage_cpp.graph.lifted_multicut.lifted_edges_from_node_labels``.
4+
5+
The function misbehaves once the graph grows past a few hundred nodes. The bug is
6+
*nondeterministic* and shows up in two ways on the very same (deterministic) input:
7+
8+
1. It intermittently SIGSEGVs.
9+
2. When it does return, the number of lifted edges varies from run to run and disagrees
10+
with the reference ``nifty.distributed.liftedNeighborhoodFromNodeLabels`` (which is
11+
stable). E.g. for a 2000-node chain at depth 3 we have seen 3288, 3837, ... while nifty
12+
consistently returns 3993.
13+
14+
A varying result for fixed input plus occasional crashes is the classic signature of a memory
15+
error (out-of-bounds read/write) in the C++ implementation. It reproduces with a trivial chain
16+
graph -- no RAG or production-scale data required -- and is independent of node-label values,
17+
``graph_depth`` and ``mode``. The RegionAdjacencyGraph path tends to crash the most reliably.
18+
19+
Minimal trigger (run on its own a few times: some runs crash, others print a different count):
20+
21+
import numpy as np
22+
import bioimage_cpp as bic
23+
24+
n = 2000
25+
uv = np.array([(i, i + 1) for i in range(n - 1)], dtype="uint64") # a simple chain
26+
g = bic.graph.UndirectedGraph.from_edges(n, uv)
27+
out = bic.graph.lifted_multicut.lifted_edges_from_node_labels(
28+
g, np.zeros(n, "uint64"), graph_depth=3, mode="all")
29+
print(len(out)) # -> Segmentation fault, or a different number each run
30+
31+
Each configuration below is run several times, each in its own child process, so a crash does
32+
not abort the sweep and the run-to-run variation is visible.
33+
34+
Run it with:
35+
36+
python repro_lifted_edges_segfault.py
37+
"""
38+
import multiprocessing as mp
39+
import queue as _queue
40+
import signal
41+
42+
import numpy as np
43+
import bioimage_cpp as bic
44+
45+
try:
46+
import nifty.distributed as ndist
47+
except ImportError:
48+
ndist = None
49+
50+
GRAPH_DEPTH = 3
51+
MODE = "all"
52+
NODE_LADDER = (100, 500, 1000, 2000)
53+
REPS = 5
54+
55+
56+
def _chain_edges(n_nodes):
57+
"""A simple connected chain 0-1-2-...-(n-1); enough to trigger the bug."""
58+
return np.array([(i, i + 1) for i in range(n_nodes - 1)], dtype="uint64")
59+
60+
61+
def _chain_segmentation(n_nodes):
62+
"""A labeled volume whose region adjacency graph is the same chain of ``n_nodes`` nodes."""
63+
return np.repeat(np.arange(n_nodes, dtype="uint32"), 16).reshape(n_nodes, 4, 4)
64+
65+
66+
# --- workers (each invocation runs in its own process) -------------------------------------
67+
def _bic_undirected(n_nodes, q):
68+
g = bic.graph.UndirectedGraph.from_edges(n_nodes, _chain_edges(n_nodes))
69+
node_labels = np.ones(n_nodes, dtype="uint64") # label values are irrelevant to the bug
70+
out = bic.graph.lifted_multicut.lifted_edges_from_node_labels(
71+
g, node_labels, graph_depth=GRAPH_DEPTH, mode=MODE)
72+
q.put(len(out))
73+
74+
75+
def _bic_rag(n_nodes, q):
76+
rag = bic.graph.region_adjacency_graph(_chain_segmentation(n_nodes))
77+
node_labels = np.ones(rag.numberOfNodes, dtype="uint64")
78+
out = bic.graph.lifted_multicut.lifted_edges_from_node_labels(
79+
rag, node_labels, graph_depth=GRAPH_DEPTH, mode=MODE)
80+
q.put(len(out))
81+
82+
83+
def _nifty_undirected(n_nodes, q):
84+
g = ndist.Graph(_chain_edges(n_nodes))
85+
node_labels = np.ones(n_nodes, dtype="uint64") # non-zero so ignoreLabel=0 keeps every pair
86+
out = ndist.liftedNeighborhoodFromNodeLabels(
87+
g, node_labels, GRAPH_DEPTH, mode=MODE, numberOfThreads=1, ignoreLabel=0)
88+
q.put(len(out))
89+
90+
91+
def _run_once(worker, n_nodes, timeout=120):
92+
"""Run ``worker(n_nodes, queue)`` in a child process; return its lifted count or a status."""
93+
q = mp.Queue()
94+
p = mp.Process(target=worker, args=(n_nodes, q))
95+
p.start()
96+
p.join(timeout)
97+
if p.is_alive():
98+
p.terminate()
99+
p.join()
100+
return "timeout"
101+
if p.exitcode == -signal.SIGSEGV:
102+
return "segfault"
103+
if p.exitcode == -signal.SIGABRT:
104+
return "abort" # glibc "double free or corruption"
105+
if p.exitcode != 0:
106+
return f"exit {p.exitcode}"
107+
try:
108+
return q.get(timeout=5)
109+
except _queue.Empty:
110+
return "no-result"
111+
112+
113+
def _summary(worker, n_nodes):
114+
"""Run ``worker`` REPS times and summarise crashes and the distinct lifted-edge counts."""
115+
results = [_run_once(worker, n_nodes) for _ in range(REPS)]
116+
crash_labels = ("segfault", "abort")
117+
crashes = sum(1 for r in results if r in crash_labels)
118+
counts = sorted({r for r in results if isinstance(r, int)})
119+
others = [r for r in results if not isinstance(r, int) and r not in crash_labels]
120+
121+
parts = []
122+
if crashes:
123+
kinds = "/".join(sorted({r.upper() for r in results if r in crash_labels}))
124+
parts.append(f"{crashes}/{REPS} {kinds}")
125+
if counts:
126+
flag = " <- NONDETERMINISTIC" if len(counts) > 1 else ""
127+
parts.append("lifted=" + ",".join(map(str, counts)) + flag)
128+
parts.extend(sorted(set(others)))
129+
return "; ".join(parts) if parts else "no output"
130+
131+
132+
def main():
133+
print("Reproducing the lifted_edges_from_node_labels bug "
134+
f"(graph_depth={GRAPH_DEPTH}, mode={MODE!r}, {REPS} runs per case).\n")
135+
136+
workers = [("bic UndirectedGraph ", _bic_undirected),
137+
("bic RegionAdjacencyGraph", _bic_rag)]
138+
if ndist is not None:
139+
workers.append(("nifty (reference) ", _nifty_undirected))
140+
141+
for n in NODE_LADDER:
142+
print(f"=== {n} nodes ===")
143+
for name, worker in workers:
144+
print(f" {name} : {_summary(worker, n)}")
145+
print()
146+
147+
print("Reference (nifty) returns a single stable count; bic varies and/or crashes -> "
148+
"memory corruption in lifted_edges_from_node_labels.")
149+
150+
151+
if __name__ == "__main__":
152+
# "spawn" gives each case a fresh interpreter, so a crash is cleanly attributed.
153+
mp.set_start_method("spawn", force=True)
154+
main()

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)

0 commit comments

Comments
 (0)