|
| 1 | +# Geodesic FMM performance notes |
| 2 | + |
| 3 | +Optimization of the two geodesic Fast Marching Method solvers, with the |
| 4 | +before/after measurements that justify the changes. Both changes are pure |
| 5 | +reorganizations of identical arithmetic — the solved fields are **bitwise |
| 6 | +identical** to the pre-optimization solvers on every code path tested. |
| 7 | + |
| 8 | +## What changed |
| 9 | + |
| 10 | +- **Lead A — mask/grid solver** (`distance/detail/fast_marching.hxx`): the FMM |
| 11 | + march no longer calls `detail::valid_offset_target` (one integer division + |
| 12 | + modulo *per axis, per call*, ~42 calls / popped voxel in 3D). Each popped voxel |
| 13 | + is decoded **once** with the new `detail::coords_from_index` (divide-and-subtract, |
| 14 | + no modulo); axis neighbours are reached with a single `p ± strides_[d]` add and an |
| 15 | + integer bounds compare, and the neighbour's coordinates are handed to |
| 16 | + `solve_eikonal` so it never re-decodes. The `offsets_` `vector<vector>` is gone. |
| 17 | +- **Lead B — mesh solver** (`distance/detail/mesh_fast_marching.hxx`): when a |
| 18 | + vertex `v` freezes, each incident face relaxes its other corners from *that face |
| 19 | + alone* instead of calling `update_vertex`, which rescanned every incident face of |
| 20 | + every touched vertex. `dist_[w]` is a running minimum that already reflects the |
| 21 | + other faces' contributions (applied when their corners froze), so the single-face |
| 22 | + update reaches the same value. `update_vertex` was removed. Operands are passed to |
| 23 | + `triangle_update` in face-storage order so the acute update is bit-identical, not |
| 24 | + just equal to within rounding. |
| 25 | + |
| 26 | +## Measurement setup |
| 27 | + |
| 28 | +8-core machine, `powersave` governor (frequency-scaling noise present; `perf` |
| 29 | +unavailable, `perf_event_paranoid=4`). Mitigations: 1 warmup call + 20 timed |
| 30 | +repeats, **min** used as the headline estimator (least-interference), spread |
| 31 | +reported. Single-source **field** solve, `--threads 1` — the single-source solve is |
| 32 | +the whole kernel (pairwise = N × field). Baseline and optimized were built |
| 33 | +separately; the **`mesh/field` case is a null control for Lead A** (its code is |
| 34 | +untouched between the baseline and A-only builds) and moved <1%, bounding machine |
| 35 | +drift below the measured mask gains. Lead A's mask numbers also reproduced within |
| 36 | +~1% across two independent builds (A-only and A+B). |
| 37 | + |
| 38 | +Reproduce (from `development/distance/`): |
| 39 | + |
| 40 | +``` |
| 41 | +python benchmark_geodesic.py --large --only field --threads 1 --repeats 20 --no-ref --json base.json |
| 42 | +python benchmark_geodesic.py --xlarge --only field --threads 1 --repeats 20 --no-ref --json opt.json |
| 43 | +python /path/to/compare.py base.json opt.json # or diff the JSON directly |
| 44 | +``` |
| 45 | + |
| 46 | +## Wall-clock: baseline → optimized (single-thread field, min of 20) |
| 47 | + |
| 48 | +| case | baseline min | optimized min | speedup | |
| 49 | +|---|---|---|---| |
| 50 | +| mask2d/field (1024²) | 294.0 ms | 208.9 ms | **1.41×** | |
| 51 | +| mask2d/field (1536²) | 685.6 ms | 492.7 ms | **1.39×** | |
| 52 | +| mask3d/field (64·128²) | 745.1 ms | 370.2 ms | **2.01×** | |
| 53 | +| mask3d/field (128³) | 1811.6 ms | 912.4 ms | **1.99×** | |
| 54 | +| mesh/field (V=20000) | 23.2 ms | 7.9 ms | **2.94×** | |
| 55 | +| mesh/field (V=40000) | 49.1 ms | 17.5 ms | **2.81×** | |
| 56 | + |
| 57 | +Lead A: ~2.0× in 3D (matches the div/mod arithmetic dominating 3 axes), ~1.4× in |
| 58 | +2D. Lead B: ~2.8–2.9× on the mesh. Both exceed the pre-work predictions (A: 1.5–2× |
| 59 | +3D; B: ~2×). |
| 60 | + |
| 61 | +## Mechanism confirmation (BIOIMAGE_PROFILE=ON, one field solve) |
| 62 | + |
| 63 | +Coarse phases in `solve()`: `pop` (heap pop, untouched by either change) vs `relax` |
| 64 | +(the neighbour/face loop where the removed work lived). |
| 65 | + |
| 66 | +| solve | phase | baseline | optimized | change | |
| 67 | +|---|---|---|---|---| |
| 68 | +| mask 128³ | pop | 0.319 s | 0.316 s | unchanged | |
| 69 | +| mask 128³ | relax | 1.700 s | 0.762 s | **2.23× smaller** | |
| 70 | +| mesh 40k | pop | 0.0035 s | 0.0034 s | unchanged | |
| 71 | +| mesh 40k | relax | 0.0500 s | 0.0163 s | **3.07× smaller** | |
| 72 | + |
| 73 | +The heap phase is invariant; the targeted `relax` phase shrank by 2.2× (mask) and |
| 74 | +3.1× (mesh). `pop`'s *share* rose (15.8%→29.3% mask; 6.5%→17.1% mesh) only because |
| 75 | +`relax` shrank around it — the gain came from exactly the phase each change targeted. |
| 76 | + |
| 77 | +## Versus reference (single-thread field, min of 10) |
| 78 | + |
| 79 | +| case | bioimage-cpp | reference | speedup | |
| 80 | +|---|---|---|---| |
| 81 | +| mask2d/field (1024²) | 210.5 ms | scikit-fmm 331.8 ms | **1.58×** | |
| 82 | +| mask3d/field (64·128²) | 368.9 ms | scikit-fmm 761.4 ms | **2.06×** | |
| 83 | +| mesh/field (V=20000) | 8.4 ms | pygeodesic 1745 ms | 208× (apples-to-oranges: pygeodesic is *exact* MMP, ours is first-order) | |
| 84 | + |
| 85 | +The mask solver went from parity with scikit-fmm's compiled C (pre-work ~1.0–1.1×) |
| 86 | +to a clear lead (1.58× in 2D, 2.06× in 3D). |
| 87 | + |
| 88 | +## Correctness (all green on the shipping build) |
| 89 | + |
| 90 | +- `pytest tests/distance/ -q` — 64 passed. The tight external cross-checks ran |
| 91 | + (not skipped): `test_mask_matches_scikit_fmm` (residual < 1e-6), |
| 92 | + `test_mesh_matches_pygeodesic` (mean rel < 0.06, p95 < 0.12). |
| 93 | +- `python check_geodesic_distance.py` — all 6 cases OK, exit 0 (mask residuals |
| 94 | + ~1e-12 vs scikit-fmm; mesh rel mean 0.027). |
| 95 | +- **Bitwise equivalence**: 13 solver outputs (2D/3D field, anisotropic `sampling`, |
| 96 | + `speed`, gradient, mask + mesh pairwise, mesh `speed`) are exactly equal |
| 97 | + (`np.array_equal`) between the baseline and optimized builds. Determinism verified |
| 98 | + (repeat solves identical). This is the dev-time check behind the "bitwise |
| 99 | + identical" claim; the pytest suite guards correctness against the external |
| 100 | + references on every run. |
| 101 | + |
| 102 | +The profiler instrumentation (`BIOIMAGE_PROFILE_SCOPE` "pop"/"relax") is left in both |
| 103 | +solvers; it is a no-op unless built with `-C cmake.define.BIOIMAGE_PROFILE=ON`. |
0 commit comments