Skip to content

Commit 01fb787

Browse files
Add script to reproduce SegFault
1 parent 5cfc422 commit 01fb787

1 file changed

Lines changed: 154 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)