Skip to content

Commit 66ed315

Browse files
Add bic-only timing benchmarks for union-find, accumulate-labels, BFS, mutex-ws
Standalone (no nifty/affogato) benchmarks for the areas touched by the code review that lacked timing coverage, used for the A/B perf validation: - utils/benchmark_union_find.py: bulk merge/find (B2 bounds-check cost) - graph/benchmark_accumulate_labels.py: accumulate_labels across 1/4/8 threads - graph/benchmark_bfs.py: lifted_edges_from_node_labels (workspace-reused BFS, I3) - segmentation/benchmark_mutex_watershed.py: mutex_watershed grid kernel (B1) Each follows the warmup + median/min pattern and exposes run() for a driver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0f0c540 commit 66ed315

4 files changed

Lines changed: 311 additions & 0 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Benchmark RAG label accumulation (``accumulate_labels``).
2+
3+
The code review replaced the per-thread ``n_threads x n_nodes`` map-of-maps with
4+
a single combined-key ``(node, other)`` histogram per thread, and made the
5+
per-node argmax single-threaded. This script times ``accumulate_labels`` across
6+
thread counts (to confirm the allocation win and rule out a high-thread-count
7+
regression from the now-sequential argmax) and across ``other``-label
8+
cardinalities.
9+
10+
Inputs: a deterministic block-wise over-segmentation (each node is a contiguous
11+
block, as in a real over-segmentation), so the RAG adjacency is local.
12+
13+
Not part of the test suite. Run::
14+
15+
python development/graph/benchmark_accumulate_labels.py --repeats 11
16+
"""
17+
from __future__ import annotations
18+
19+
import argparse
20+
from statistics import median
21+
from time import perf_counter
22+
23+
import numpy as np
24+
25+
import bioimage_cpp as bic
26+
27+
28+
def _timeit(fn, repeats: int, warmup: int = 1) -> dict:
29+
for _ in range(warmup):
30+
fn()
31+
timings = []
32+
for _ in range(repeats):
33+
t0 = perf_counter()
34+
fn()
35+
timings.append(perf_counter() - t0)
36+
return {"median": median(timings), "min": min(timings), "n": repeats}
37+
38+
39+
def _make_labels(shape=(40, 256, 256), coarsen=(2, 4, 4)) -> np.ndarray:
40+
coarse_shape = tuple(s // c for s, c in zip(shape, coarsen))
41+
n_nodes = int(np.prod(coarse_shape))
42+
coarse = np.arange(n_nodes, dtype=np.uint64).reshape(coarse_shape)
43+
block = np.ones(coarsen, dtype=np.uint64)
44+
return np.ascontiguousarray(np.kron(coarse, block))
45+
46+
47+
def run(repeats: int = 11) -> dict:
48+
labels = _make_labels()
49+
rag = bic.graph.region_adjacency_graph(labels)
50+
n_nodes = int(rag.number_of_nodes)
51+
rng = np.random.default_rng(0)
52+
53+
results: dict[str, dict] = {}
54+
for n_other, tag in ((8, "dense8"), (2000, "sparse2000")):
55+
other = rng.integers(0, n_other, size=labels.shape).astype(np.uint64)
56+
other = np.ascontiguousarray(other)
57+
for threads in (1, 4, 8):
58+
def fn(t=threads, o=other):
59+
bic.graph.features.accumulate_labels(rag, labels, o, number_of_threads=t)
60+
61+
key = f"accumulate_labels_{tag}_t{threads}"
62+
results[key] = _timeit(fn, repeats)
63+
results[key]["meta"] = {
64+
"n_nodes": n_nodes,
65+
"n_pixels": int(labels.size),
66+
"n_other": n_other,
67+
"threads": threads,
68+
}
69+
return results
70+
71+
72+
def main() -> None:
73+
parser = argparse.ArgumentParser(description=__doc__)
74+
parser.add_argument("--repeats", type=int, default=11)
75+
args = parser.parse_args()
76+
results = run(repeats=args.repeats)
77+
for name, r in results.items():
78+
print(f"{name:<32} median={r['median'] * 1e3:8.3f} ms min={r['min'] * 1e3:8.3f} ms")
79+
80+
81+
if __name__ == "__main__":
82+
main()

development/graph/benchmark_bfs.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Benchmark the workspace-reused BFS path (``lifted_edges_from_node_labels``).
2+
3+
The code review changed ``BfsWorkspace::reset`` from an O(N) clear of the
4+
visited/distance buffers to an O(1) generation-stamp bump. That only pays off
5+
when one workspace is reset across many sources, which is exactly what
6+
``lifted_edges_from_node_labels`` does (one workspace per chunk, reset per
7+
source). The single-call ``breadth_first_search`` builds a fresh workspace each
8+
call and would NOT show the change, so we benchmark the lifted-edge path.
9+
10+
Single-threaded so the per-source reset cost is not hidden by parallelism. The
11+
node count is swept to expose the previous O(N^2)-of-memset behavior.
12+
13+
Not part of the test suite. Run::
14+
15+
python development/graph/benchmark_bfs.py --repeats 5
16+
"""
17+
from __future__ import annotations
18+
19+
import argparse
20+
from statistics import median
21+
from time import perf_counter
22+
23+
import numpy as np
24+
25+
import bioimage_cpp as bic
26+
27+
28+
def _timeit(fn, repeats: int, warmup: int = 1) -> dict:
29+
for _ in range(warmup):
30+
fn()
31+
timings = []
32+
for _ in range(repeats):
33+
t0 = perf_counter()
34+
fn()
35+
timings.append(perf_counter() - t0)
36+
return {"median": median(timings), "min": min(timings), "n": repeats}
37+
38+
39+
def run(repeats: int = 5, depth: int = 2) -> dict:
40+
shapes = [(100, 100), (160, 160), (220, 220)]
41+
results: dict[str, dict] = {}
42+
for shape in shapes:
43+
n_nodes = int(np.prod(shape))
44+
graph = bic.graph.grid_graph(shape)
45+
rng = np.random.default_rng(0)
46+
node_labels = rng.integers(0, 50, size=(n_nodes,), dtype=np.uint64)
47+
48+
def fn(g=graph, nl=node_labels, d=depth):
49+
bic.graph.lifted_multicut.lifted_edges_from_node_labels(
50+
g, nl, graph_depth=d, number_of_threads=1
51+
)
52+
53+
key = f"bfs_lifted_{n_nodes}nodes_d{depth}"
54+
results[key] = _timeit(fn, repeats)
55+
results[key]["meta"] = {"n_nodes": n_nodes, "shape": shape, "depth": depth}
56+
return results
57+
58+
59+
def main() -> None:
60+
parser = argparse.ArgumentParser(description=__doc__)
61+
parser.add_argument("--repeats", type=int, default=5)
62+
parser.add_argument("--depth", type=int, default=2)
63+
args = parser.parse_args()
64+
results = run(repeats=args.repeats, depth=args.depth)
65+
for name, r in results.items():
66+
print(f"{name:<28} median={r['median'] * 1e3:9.3f} ms min={r['min'] * 1e3:9.3f} ms")
67+
68+
69+
if __name__ == "__main__":
70+
main()
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Benchmark the mutex-watershed grid kernel (bic only).
2+
3+
The code review routed the neighbor computation in ``mutex_watershed_grid``
4+
through ``detail::valid_offset_target`` (per-axis bounds check) instead of a
5+
single precomputed flat-offset add. This script times ``mutex_watershed`` on the
6+
ISBI affinities for 2D and 3D so we can A/B that inner-loop change against a
7+
pre-review build. No external (affogato) dependency — bic only.
8+
9+
Not part of the test suite. Run::
10+
11+
python development/segmentation/benchmark_mutex_watershed.py --repeats 5
12+
"""
13+
from __future__ import annotations
14+
15+
import argparse
16+
from statistics import median
17+
from time import perf_counter
18+
19+
import numpy as np
20+
21+
import bioimage_cpp as bic
22+
from bioimage_cpp._data import load_isbi_affinities
23+
24+
25+
def _timeit(fn, repeats: int, warmup: int = 1) -> dict:
26+
for _ in range(warmup):
27+
fn()
28+
timings = []
29+
for _ in range(repeats):
30+
t0 = perf_counter()
31+
fn()
32+
timings.append(perf_counter() - t0)
33+
return {"median": median(timings), "min": min(timings), "n": repeats}
34+
35+
36+
def _attractive_flip(affs: np.ndarray, n_attractive: int) -> np.ndarray:
37+
# Match the convention in the equivalence checker: attractive channels are
38+
# turned into merge affinities (1 - aff).
39+
out = affs.copy()
40+
out[:n_attractive] *= -1
41+
out[:n_attractive] += 1
42+
return out
43+
44+
45+
def run(repeats: int = 5) -> dict:
46+
affinities, offsets = load_isbi_affinities()
47+
affinities = np.ascontiguousarray(affinities)
48+
offsets = [tuple(o) for o in offsets]
49+
results: dict[str, dict] = {}
50+
51+
# --- 2D: in-plane channels of a single z slice ---
52+
channels_2d = [i for i, o in enumerate(offsets) if o[0] == 0]
53+
aff2d = np.ascontiguousarray(affinities[channels_2d, 0, :256, :256])
54+
offsets_2d = [offsets[i][1:] for i in channels_2d]
55+
aff2d_flipped = _attractive_flip(aff2d, 2)
56+
57+
def mws_2d():
58+
bic.segmentation.mutex_watershed(
59+
aff2d_flipped, offsets_2d, number_of_attractive_channels=2
60+
)
61+
62+
results["mws_2d_256x256"] = _timeit(mws_2d, repeats)
63+
results["mws_2d_256x256"]["meta"] = {"shape": list(aff2d.shape)}
64+
65+
# --- 3D: small crop, all offsets ---
66+
aff3d = np.ascontiguousarray(affinities[:, :6, :256, :256])
67+
aff3d_flipped = _attractive_flip(aff3d, 3)
68+
69+
def mws_3d():
70+
bic.segmentation.mutex_watershed(
71+
aff3d_flipped, offsets, number_of_attractive_channels=3
72+
)
73+
74+
results["mws_3d_6x256x256"] = _timeit(mws_3d, repeats)
75+
results["mws_3d_6x256x256"]["meta"] = {"shape": list(aff3d.shape)}
76+
return results
77+
78+
79+
def main() -> None:
80+
parser = argparse.ArgumentParser(description=__doc__)
81+
parser.add_argument("--repeats", type=int, default=5)
82+
args = parser.parse_args()
83+
results = run(repeats=args.repeats)
84+
for name, r in results.items():
85+
print(f"{name:<20} median={r['median'] * 1e3:9.3f} ms min={r['min'] * 1e3:9.3f} ms")
86+
87+
88+
if __name__ == "__main__":
89+
main()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Micro-benchmark for the UnionFind Python bindings.
2+
3+
Times the bulk ``merge((N, 2) edges)`` and ``find(node_array)`` entry points,
4+
which the code review changed to validate every node id against ``uf.size``
5+
before touching the C++ structure (an extra O(N) pre-pass). This script lets us
6+
A/B that pre-pass against a pre-review build.
7+
8+
Not part of the test suite. Run::
9+
10+
python development/utils/benchmark_union_find.py --repeats 11
11+
"""
12+
from __future__ import annotations
13+
14+
import argparse
15+
from statistics import median
16+
from time import perf_counter
17+
18+
import numpy as np
19+
20+
import bioimage_cpp as bic
21+
22+
23+
def _timeit(fn, repeats: int, warmup: int = 1) -> dict:
24+
for _ in range(warmup):
25+
fn()
26+
timings = []
27+
for _ in range(repeats):
28+
t0 = perf_counter()
29+
fn()
30+
timings.append(perf_counter() - t0)
31+
return {"median": median(timings), "min": min(timings), "n": repeats}
32+
33+
34+
def run(repeats: int = 11, n_nodes: int = 1_000_000, n_edges: int = 2_000_000) -> dict:
35+
rng = np.random.default_rng(0)
36+
edges = rng.integers(0, n_nodes, size=(n_edges, 2), dtype=np.uint64)
37+
query = rng.integers(0, n_nodes, size=(n_edges,), dtype=np.uint64)
38+
39+
# Pre-merged structure for the find benchmark (find does not mutate the
40+
# partition, so it can be reused across repeats).
41+
merged = bic.utils.UnionFind(n_nodes)
42+
merged.merge(edges)
43+
44+
def bulk_merge():
45+
uf = bic.utils.UnionFind(n_nodes)
46+
uf.merge(edges)
47+
48+
def bulk_find():
49+
merged.find(query)
50+
51+
results = {
52+
"uf_bulk_merge": _timeit(bulk_merge, repeats),
53+
"uf_bulk_find": _timeit(bulk_find, repeats),
54+
}
55+
for r in results.values():
56+
r["meta"] = {"n_nodes": n_nodes, "n_edges": n_edges}
57+
return results
58+
59+
60+
def main() -> None:
61+
parser = argparse.ArgumentParser(description=__doc__)
62+
parser.add_argument("--repeats", type=int, default=11)
63+
args = parser.parse_args()
64+
results = run(repeats=args.repeats)
65+
for name, r in results.items():
66+
print(f"{name:<16} median={r['median'] * 1e3:8.3f} ms min={r['min'] * 1e3:8.3f} ms")
67+
68+
69+
if __name__ == "__main__":
70+
main()

0 commit comments

Comments
 (0)