Skip to content

Commit a8832a7

Browse files
Add benchmark script
1 parent fd625fa commit a8832a7

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Cross-check bioimage-cpp's compute_embedding_distances against affogato.
2+
3+
Affogato exposes ``l2`` and ``cosine`` distances over an embedding tensor
4+
``(C, *spatial)`` with a list of spatial offsets. bioimage-cpp additionally
5+
supports ``l1``; that norm is benchmarked here but only compared against a
6+
NumPy reference (affogato has no L1 path).
7+
8+
Benchmarks use synthetic standard-normal float32 embeddings sized to
9+
exercise both 2D and 3D kernels. Not part of the pytest suite.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
import sys
16+
from statistics import mean, median
17+
from time import perf_counter
18+
19+
import numpy as np
20+
21+
import bioimage_cpp as bic
22+
23+
try:
24+
import affogato.affinities as affo
25+
except ImportError as error: # pragma: no cover - dev script
26+
sys.stderr.write(f"affogato not installed: {error}\n")
27+
sys.exit(1)
28+
29+
30+
CASES = [
31+
# (name, values shape (C, *spatial), offsets)
32+
(
33+
"2d_small",
34+
(12, 256, 256),
35+
[(-1, 0), (0, -1), (-3, 0), (0, -3), (-9, 0), (0, -9)],
36+
),
37+
(
38+
"2d_large",
39+
(16, 512, 512),
40+
[(-1, 0), (0, -1), (-3, 0), (0, -3), (-9, 0), (0, -9), (-27, 0), (0, -27)],
41+
),
42+
(
43+
"3d_small",
44+
(12, 16, 128, 128),
45+
[(-1, 0, 0), (0, -1, 0), (0, 0, -1),
46+
(-3, 0, 0), (0, -3, 0), (0, 0, -3)],
47+
),
48+
(
49+
"3d_large",
50+
(16, 32, 256, 256),
51+
[(-1, 0, 0), (0, -1, 0), (0, 0, -1),
52+
(-3, 0, 0), (0, -3, 0), (0, 0, -3),
53+
(0, -9, 0), (0, 0, -9)],
54+
),
55+
]
56+
57+
58+
def time_call(fn, repeats):
59+
timings = []
60+
result = None
61+
for _ in range(repeats):
62+
start = perf_counter()
63+
result = fn()
64+
timings.append(perf_counter() - start)
65+
return timings, result
66+
67+
68+
def numpy_reference_l1(values, offsets):
69+
"""Vectorized NumPy reference for the L1 norm (no affogato analogue)."""
70+
spatial = values.shape[1:]
71+
out = np.zeros((len(offsets),) + spatial, dtype=np.float32)
72+
for oi, offset in enumerate(offsets):
73+
src_slices = []
74+
dst_slices = []
75+
ok = True
76+
for d, extent in zip(offset, spatial):
77+
if d > 0:
78+
src_slices.append(slice(0, extent - d))
79+
dst_slices.append(slice(d, extent))
80+
elif d < 0:
81+
src_slices.append(slice(-d, extent))
82+
dst_slices.append(slice(0, extent + d))
83+
else:
84+
src_slices.append(slice(0, extent))
85+
dst_slices.append(slice(0, extent))
86+
if src_slices[-1].stop <= src_slices[-1].start:
87+
ok = False
88+
break
89+
if not ok:
90+
continue
91+
a = values[(slice(None),) + tuple(src_slices)]
92+
b = values[(slice(None),) + tuple(dst_slices)]
93+
out_slices = [slice(0, extent) for extent in spatial]
94+
# Output position corresponds to the "source" position (p, with
95+
# neighbor p + offset). Negative-offset entries fill from the high
96+
# end of the spatial axis; positive offsets fill from the low end.
97+
for axis, d in enumerate(offset):
98+
extent = spatial[axis]
99+
if d > 0:
100+
out_slices[axis] = slice(0, extent - d)
101+
elif d < 0:
102+
out_slices[axis] = slice(-d, extent)
103+
else:
104+
out_slices[axis] = slice(0, extent)
105+
out[(oi,) + tuple(out_slices)] = np.sum(np.abs(a - b), axis=0).astype(
106+
np.float32
107+
)
108+
return out
109+
110+
111+
def run_case(name, shape, offsets, repeats, rng):
112+
values = rng.standard_normal(size=shape).astype(np.float32)
113+
n_threads = 1
114+
offsets_list = [list(o) for o in offsets]
115+
n_voxels = int(np.prod(shape[1:]))
116+
117+
rows = []
118+
for norm in ("l1", "l2", "cosine"):
119+
bic_timings, bic_out = time_call(
120+
lambda norm=norm: bic.affinities.compute_embedding_distances(
121+
values,
122+
offsets_list,
123+
norm=norm,
124+
number_of_threads=n_threads,
125+
),
126+
repeats,
127+
)
128+
129+
if norm == "l1":
130+
ref_label = "numpy"
131+
ref_timings = [float("nan")]
132+
ref_out = numpy_reference_l1(values, offsets_list)
133+
else:
134+
ref_label = "affogato"
135+
ref_timings, ref_out = time_call(
136+
lambda norm=norm: affo.compute_embedding_distances(
137+
values,
138+
offsets_list,
139+
norm=norm,
140+
),
141+
repeats,
142+
)
143+
144+
if bic_out.shape != ref_out.shape:
145+
ok = False
146+
max_abs = float("nan")
147+
else:
148+
max_abs = float(np.max(np.abs(bic_out - ref_out)))
149+
# Allow small floating-point drift from differing accumulation
150+
# orders. atol scaled by sqrt(C) since errors compound across
151+
# channels.
152+
atol = 1e-4 * np.sqrt(shape[0])
153+
ok = np.allclose(bic_out, ref_out, atol=atol, rtol=1e-4)
154+
155+
rows.append({
156+
"case": name,
157+
"shape": shape,
158+
"n_offsets": len(offsets_list),
159+
"n_voxels": n_voxels,
160+
"norm": norm,
161+
"ref": ref_label,
162+
"ok": ok,
163+
"max_abs_diff": max_abs,
164+
"bic_median_s": median(bic_timings),
165+
"bic_mean_s": mean(bic_timings),
166+
"ref_median_s": median(ref_timings),
167+
"ref_mean_s": mean(ref_timings),
168+
})
169+
return rows
170+
171+
172+
def main():
173+
parser = argparse.ArgumentParser(description=__doc__)
174+
parser.add_argument("--repeats", type=int, default=3)
175+
parser.add_argument("--seed", type=int, default=0)
176+
args = parser.parse_args()
177+
178+
rng = np.random.default_rng(args.seed)
179+
180+
all_rows = []
181+
for name, shape, offsets in CASES:
182+
all_rows.extend(run_case(name, shape, offsets, args.repeats, rng))
183+
184+
print()
185+
header = (
186+
f"{'case':>10} {'norm':>6} {'ref':>8} {'n_off':>5} {'n_vox':>11}"
187+
f" {'check':>5} {'max_abs':>10} {'bic_s':>9} {'ref_s':>9} {'speedup':>8}"
188+
)
189+
print(header)
190+
print("-" * len(header))
191+
all_ok = True
192+
for r in all_rows:
193+
if r["ref_median_s"] > 0 and not np.isnan(r["ref_median_s"]):
194+
speedup = r["ref_median_s"] / r["bic_median_s"]
195+
speedup_str = f"{speedup:>7.2f}x"
196+
else:
197+
speedup_str = f"{'n/a':>8}"
198+
ref_s_str = (
199+
f"{r['ref_median_s']:>9.4f}"
200+
if not np.isnan(r["ref_median_s"])
201+
else f"{'n/a':>9}"
202+
)
203+
print(
204+
f"{r['case']:>10} {r['norm']:>6} {r['ref']:>8} {r['n_offsets']:>5d}"
205+
f" {r['n_voxels']:>11,d}"
206+
f" {'OK' if r['ok'] else 'FAIL':>5}"
207+
f" {r['max_abs_diff']:>10.2e}"
208+
f" {r['bic_median_s']:>9.4f}"
209+
f" {ref_s_str}"
210+
f" {speedup_str}"
211+
)
212+
all_ok = all_ok and r["ok"]
213+
214+
if not all_ok:
215+
print("\nFAIL: output mismatch", file=sys.stderr)
216+
sys.exit(1)
217+
218+
219+
if __name__ == "__main__":
220+
main()

0 commit comments

Comments
 (0)