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