|
| 1 | +# Filter benchmark — performance notes |
| 2 | + |
| 3 | +Results from `development/filters/benchmark.py` for the six Tier-1 filters |
| 4 | +on 2D and 3D test data from `skimage.data`. Re-run with: |
| 5 | + |
| 6 | +```bash |
| 7 | +python development/filters/check_parity.py # parity gate, must PASS first |
| 8 | +python development/filters/benchmark.py # headline numbers |
| 9 | +``` |
| 10 | + |
| 11 | +All four libraries produce the same response (verified by `check_parity.py` |
| 12 | +on the image interior, dropping a `window_size * sigma` margin on each side; |
| 13 | +or `inner + outer` for the structure tensor). Reported numbers are |
| 14 | +**median wall-clock per call** across 5 timed repeats, after one untimed |
| 15 | +warmup, with calls interleaved round-robin between libraries to share cache |
| 16 | +state fairly. |
| 17 | + |
| 18 | +## Setup |
| 19 | + |
| 20 | +- CPU: Intel Core i7-1185G7 (Tiger Lake, 4C/8T, AVX2 + AVX-512) |
| 21 | +- Compiler: gcc 15.2.0 (conda-forge), `-O3`, no `-march=native` |
| 22 | +- Python 3.11.15 on Linux x86_64 |
| 23 | +- `numpy 2.4.5`, `scipy 1.17.1`, `vigra 1.12.3`, `fastfilters 0.3-4-g87f08b5`, |
| 24 | + `bioimage_cpp 0.1.0` |
| 25 | +- All libraries called single-threaded; `bioimage_cpp` has no SIMD intrinsics |
| 26 | + (Tier-1: scalar C++20 + compiler auto-vectorisation). |
| 27 | + |
| 28 | +## Benchmark configuration |
| 29 | + |
| 30 | +| parameter | value | notes | |
| 31 | +|---|---|---| |
| 32 | +| sigma | 1.5 | smoothing / derivative / LoG / gradient / Hessian | |
| 33 | +| inner_sigma | 1.0 | structure-tensor gradient scale | |
| 34 | +| outer_sigma | 2.0 | structure-tensor smoothing scale | |
| 35 | +| window_size | 3.0 | kernel half-width / sigma; matched across libraries | |
| 36 | +| repeats | 5 | timed, interleaved round-robin | |
| 37 | +| 2D image | `skimage.data.camera()` | 512×512 → float32, normalised to [0, 1] | |
| 38 | +| 3D volume | `skimage.data.cells3d()[:, 1]` | 60×256×256 nuclei channel → float32 | |
| 39 | + |
| 40 | +`gaussian_derivative` uses order = 1 along the trailing axis only. `fastfilters` |
| 41 | +only supports uniform per-axis order, so its row is `n/a`. |
| 42 | + |
| 43 | +## Headline ratios |
| 44 | + |
| 45 | +Geometric mean of `bioimage_cpp.median / other.median` across all benched |
| 46 | +(filter, dim) combinations. **>1.0 means `bioimage_cpp` is slower** than the |
| 47 | +other library; **<1.0 means faster**. |
| 48 | + |
| 49 | +| comparison | geomean ratio | n | |
| 50 | +|---|---|---| |
| 51 | +| `bioimage_cpp` / `fastfilters` | **2.00** | 10 | |
| 52 | +| `bioimage_cpp` / `vigra` | **0.18** | 12 | |
| 53 | +| `bioimage_cpp` / `scipy` | **0.20** | 12 | |
| 54 | + |
| 55 | +Interpretation: `fastfilters` (hand-AVX2) is 2× faster than ours on average; |
| 56 | +ours is ~5× faster than vigra and ~5× faster than scipy/numpy. |
| 57 | + |
| 58 | +## 2D results — `camera()` 512×512 |
| 59 | + |
| 60 | +Times in milliseconds (median of 5 repeats). The `x ours` column is |
| 61 | +`bioimage_cpp.median / this_lib.median`; **values >1.0 mean the library is |
| 62 | +faster than `bioimage_cpp`**. |
| 63 | + |
| 64 | +| filter | bioimage_cpp ms | fastfilters ms | x ours | vigra ms | x ours | scipy ms | x ours | |
| 65 | +|---|---:|---:|---:|---:|---:|---:|---:| |
| 66 | +| gaussian_smoothing | 0.82 | 0.49 | 1.67 | 5.18 | 0.16 | 3.59 | 0.23 | |
| 67 | +| gaussian_derivative | 0.83 | n/a | n/a | 5.15 | 0.16 | 3.85 | 0.21 | |
| 68 | +| gaussian_gradient_magnitude | 2.11 | 0.91 | 2.32 | 13.85 | 0.15 | 7.77 | 0.27 | |
| 69 | +| laplacian_of_gaussian | 1.92 | 0.96 | 2.00 | 8.88 | 0.22 | 7.50 | 0.26 | |
| 70 | +| hessian_of_gaussian_eigenvalues | 3.02 | 1.67 | 1.81 | 19.55 | 0.15 | 77.79 | 0.04 | |
| 71 | +| structure_tensor_eigenvalues | 5.16 | 2.86 | 1.80 | 26.22 | 0.20 | 82.99 | 0.06 | |
| 72 | + |
| 73 | +## 3D results — `cells3d()[:, 1]` 60×256×256 |
| 74 | + |
| 75 | +| filter | bioimage_cpp ms | fastfilters ms | x ours | vigra ms | x ours | scipy ms | x ours | |
| 76 | +|---|---:|---:|---:|---:|---:|---:|---:| |
| 77 | +| gaussian_smoothing | 33.27 | 15.02 | 2.22 | 192.29 | 0.17 | 85.37 | 0.39 | |
| 78 | +| gaussian_derivative | 33.05 | n/a | n/a | 195.27 | 0.17 | 86.19 | 0.38 | |
| 79 | +| gaussian_gradient_magnitude | 106.42 | 61.91 | 1.72 | 640.83 | 0.17 | 276.64 | 0.38 | |
| 80 | +| laplacian_of_gaussian | 103.69 | 61.93 | 1.67 | 584.71 | 0.18 | 266.87 | 0.39 | |
| 81 | +| hessian_of_gaussian_eigenvalues | 486.38 | 175.75 | 2.77 | 2195.57 | 0.22 | 4031.79 | 0.12 | |
| 82 | +| structure_tensor_eigenvalues | 599.90 | 263.14 | 2.28 | 1938.37 | 0.31 | 4301.12 | 0.14 | |
| 83 | + |
| 84 | +## Per-filter throughput vs `bioimage_cpp` (megapixels / second) |
| 85 | + |
| 86 | +Throughput = `image_size / median_time`. Useful for cross-shape comparison. |
| 87 | + |
| 88 | +### 2D (512×512 = 0.262 megapixels per output) |
| 89 | + |
| 90 | +| filter | bioimage_cpp | fastfilters | vigra | scipy | |
| 91 | +|---|---:|---:|---:|---:| |
| 92 | +| gaussian_smoothing | 320 | 535 | 51 | 73 | |
| 93 | +| gaussian_gradient_magnitude | 124 | 288 | 19 | 34 | |
| 94 | +| laplacian_of_gaussian | 136 | 273 | 30 | 35 | |
| 95 | +| hessian_of_gaussian_eigenvalues | 87 | 157 | 13 | 3 | |
| 96 | +| structure_tensor_eigenvalues | 51 | 92 | 10 | 3 | |
| 97 | + |
| 98 | +### 3D (60×256×256 = 3.93 megavoxels per output) |
| 99 | + |
| 100 | +| filter | bioimage_cpp | fastfilters | vigra | scipy | |
| 101 | +|---|---:|---:|---:|---:| |
| 102 | +| gaussian_smoothing | 118 | 262 | 20 | 46 | |
| 103 | +| gaussian_gradient_magnitude | 37 | 64 | 6 | 14 | |
| 104 | +| laplacian_of_gaussian | 38 | 64 | 7 | 15 | |
| 105 | +| hessian_of_gaussian_eigenvalues | 8 | 22 | 2 | 1 | |
| 106 | +| structure_tensor_eigenvalues | 7 | 15 | 2 | 1 | |
| 107 | + |
| 108 | +## Takeaways |
| 109 | + |
| 110 | +- **vs `fastfilters`** (the hand-AVX2 target). We sit at 1.67× – 2.77× |
| 111 | + slower across the board, exactly in the 1.0×–2.0× band the Tier-1 plan |
| 112 | + predicted (a touch worse on Hessian/structure-tensor 3D where eigenvalue |
| 113 | + arithmetic dominates and benefits less from auto-vectorisation). This is |
| 114 | + the gap a Tier-2 manual-AVX2 path would aim to close. |
| 115 | +- **vs `vigra`**. ~5× faster across the board. Same algorithmic family, |
| 116 | + scalar code in both cases; the win comes from fewer abstraction layers |
| 117 | + and tighter kernels (constant-bound inner loops, half-kernel storage, |
| 118 | + X-strip pattern for the strided pass). |
| 119 | +- **vs `scipy.ndimage` (+ `numpy.linalg.eigvalsh` for the eigenvalue |
| 120 | + filters)**. ~5× faster on simple filters and ~10–25× faster on the |
| 121 | + eigenvalue paths. `scipy.ndimage` itself is competitive on plain |
| 122 | + convolution; the eigenvalue gap is almost entirely the per-pixel numpy |
| 123 | + `eigvalsh` cost — exactly what scipy-only users currently pay. |
| 124 | + |
| 125 | +## Where the Tier-2 SIMD work would help most |
| 126 | + |
| 127 | +Largest absolute gaps to `fastfilters` (3D, in ms per call): |
| 128 | + |
| 129 | +| filter | ours | ff | absolute gap | gap × 5 calls/sec | |
| 130 | +|---|---:|---:|---:|---:| |
| 131 | +| hessian_of_gaussian_eigenvalues | 486 | 176 | 311 ms | 1.55 s/sec saved | |
| 132 | +| structure_tensor_eigenvalues | 600 | 263 | 337 ms | 1.69 s/sec saved | |
| 133 | +| gaussian_gradient_magnitude | 106 | 62 | 44 ms | 0.22 s/sec saved | |
| 134 | + |
| 135 | +If/when Tier-2 lands, instrument the Hessian and structure-tensor 3D paths |
| 136 | +first — they share the same separable-FIR primitives plus the 3×3 trig |
| 137 | +eigensolver, so closing the gap on those carries the gradient/magnitude / |
| 138 | +LoG paths along for free. |
| 139 | + |
| 140 | +## Reproducibility notes |
| 141 | + |
| 142 | +- Run after `pip install -e . --no-build-isolation` so the C++ extension |
| 143 | + matches the source tree. |
| 144 | +- For absolute-time comparisons across machines, also report CPU model, |
| 145 | + compiler version, and whether `-march=native` was set (we do NOT set it |
| 146 | + in normal builds; this benchmark used the default `-O3`). |
| 147 | +- For a quick re-check use `--small` (crops to 128×128 and 32×64×64); |
| 148 | + finishes in a few seconds. |
| 149 | +- For raw per-(filter, library) timings (useful for plotting), pass |
| 150 | + `--csv path.csv`. |
| 151 | + |
| 152 | +## Known caveats reflected in the adapters |
| 153 | + |
| 154 | +- `fastfilters.gaussianDerivative` only accepts a uniform per-axis order; |
| 155 | + the bench cell is `n/a` rather than running an unequal operation. |
| 156 | +- `fastfilters.structureTensorEigenvalues` swaps `innerScale` / |
| 157 | + `outerScale` at the Python boundary (its wrapper calls the C function |
| 158 | + with the args in the opposite order — `src/python/core.cxx:328` vs |
| 159 | + `src/library/fir_filters.c:156` in the fastfilters source). The adapter |
| 160 | + swaps them back so the bench compares the same operation as `vigra` / |
| 161 | + `bioimage_cpp`. |
| 162 | +- `scipy` and `bioimage_cpp` use scipy-style `mirror` (reflect without |
| 163 | + edge-pixel repeat); `vigra` / `fastfilters` use reflect with edge |
| 164 | + repeat. Parity is checked on the image interior to absorb the |
| 165 | + difference. |
0 commit comments