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