@@ -149,6 +149,353 @@ LoG paths along for free.
149149- For raw per-(filter, library) timings (useful for plotting), pass
150150 ` --csv path.csv ` .
151151
152+ ## Tier 2 SIMD — design notes (deferred)
153+
154+ ** Status (2026-05-17): not pursued for now.** Tier 1 sits within ~ 2× of
155+ fastfilters' hand-AVX2 on the headline benchmark while being ~ 5× faster
156+ than ` vigra ` and ` scipy.ndimage ` . The marginal user value of closing that
157+ 2× gap doesn't yet justify the extra build complexity and dual-path
158+ maintenance burden. This section captures the design so a future coding
159+ agent (or future-us) can pick it up without re-deriving the choices.
160+
161+ ### Trigger conditions — when to revisit
162+
163+ Open this section again when ** at least one** is true:
164+
165+ 1 . Real users are hitting the Hessian-3D / structure-tensor-3D paths on
166+ volumes large enough that ~ 300 ms vs ~ 150 ms per call materially
167+ matters in their pipeline (typically batch feature extraction over
168+ many large 3D blocks).
169+ 2 . "Performance parity with fastfilters" becomes a stated project goal
170+ (e.g. for a migration story or comparison documentation).
171+ 3 . Profiling on a real downstream workflow shows ` bioimage_cpp.filters `
172+ is the bottleneck and the gap to fastfilters is the dominant slice.
173+
174+ If none of those is true: stay on Tier 1.
175+
176+ ### Scope — what to ship, what to keep out
177+
178+ ** In scope** (the whole Tier 2 delivery):
179+
180+ - Hand-written AVX2 + FMA implementations of exactly two inner kernels:
181+ - ` convolve_x_radius<R, Symmetric> ` — the X (innermost contiguous) pass.
182+ - ` convolve_strided_radius<R, Symmetric> ` — the Y/Z (strided) pass.
183+ - Both currently live in
184+ ` include/bioimage_cpp/filters/convolve.hxx::detail ` .
185+ - One-time CPUID dispatch at module load that picks scalar vs AVX2
186+ function pointers for those two kernels.
187+
188+ ** Out of scope** (do NOT add any of these as part of Tier 2):
189+
190+ - AVX-512 path. The win over AVX2 is small on memory-bound separable FIR
191+ and doubles the kernel binary footprint; revisit only if a user with a
192+ Sapphire Rapids / Zen 5 workload asks specifically.
193+ - NEON / arm64 hand-tuning. Tier 1 auto-vectorization on Apple Clang is
194+ already competitive; this would be a separate project with its own
195+ trigger conditions.
196+ - Replacing ` std::acos ` / ` std::cos ` in ` eigenvalues.hxx ` with a
197+ vectorized math library (this is what ` fastfilters ` vendors as
198+ ` avx_mathfun.h ` , 924 lines). The Tier-1 plan explicitly rejected
199+ vendoring it; revisit only if eigenvalue profiling shows the trig
200+ calls dominate the remaining gap. Don't bundle this into Tier 2.
201+ - Any change to ` kernel.hxx ` , ` eigenvalues.hxx ` , ` gaussian.hxx ` , the
202+ binding layer, or the Python wrapper. Tier 2 is a * drop-in* speedup of
203+ two leaf functions; if you find yourself changing anything else,
204+ something is wrong.
205+
206+ ### File layout
207+
208+ ```
209+ include/bioimage_cpp/filters/
210+ convolve.hxx # existing scalar; renamed entry points
211+ # to point at function pointers (see below)
212+ convolve_dispatch.hxx # NEW — function-pointer table + CPUID
213+
214+ src/cpp/filters/
215+ convolve_avx2.cxx # NEW — AVX2+FMA kernels (compiled with
216+ # per-file -mavx2 -mfma / /arch:AVX2)
217+ convolve_dispatch.cxx # NEW — one-time init of the pointers
218+ ```
219+
220+ The existing ` convolve_axis_x ` / ` convolve_axis_strided ` entry points in
221+ ` convolve.hxx ` keep their signatures. Their bodies switch from "directly
222+ call ` detail::convolve_x_radius<R, Sym> ` " to "call
223+ ` bioimage_cpp::filters::dispatch::convolve_x_table[R][Sym] ` ". Higher
224+ levels (` gaussian.hxx ` , the six composite filters, the binding layer) are
225+ unchanged.
226+
227+ ### CMake wiring
228+
229+ Add to the ` nanobind_add_module(_core ...) ` source list:
230+
231+ ``` cmake
232+ src/cpp/filters/convolve_avx2.cxx
233+ src/cpp/filters/convolve_dispatch.cxx
234+ ```
235+
236+ Then attach per-file flags so only the AVX2 TU gets AVX2 instructions
237+ (the rest of the wheel stays at the manylinux SSE2 baseline):
238+
239+ ``` cmake
240+ if(MSVC)
241+ set_source_files_properties(
242+ src/cpp/filters/convolve_avx2.cxx
243+ PROPERTIES COMPILE_OPTIONS "/arch:AVX2"
244+ )
245+ else()
246+ set_source_files_properties(
247+ src/cpp/filters/convolve_avx2.cxx
248+ PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma"
249+ )
250+ endif()
251+ ```
252+
253+ Do ** not** add ` -march=native ` or change the global ` -O3 ` . The wheel
254+ must keep installing on any pre-Haswell x86_64 machine that
255+ manylinux2014 supports; the AVX2 instructions only execute behind the
256+ CPUID check.
257+
258+ ### Runtime dispatch pattern
259+
260+ In ` convolve_dispatch.hxx ` :
261+
262+ ``` cpp
263+ namespace bioimage_cpp ::filters::dispatch {
264+
265+ using ConvolveXFn = void (* )(
266+ const float* , float* , std::ptrdiff_t, std::ptrdiff_t, const float*
267+ ) ;
268+ using ConvolveStridedFn = void (*)(
269+ const float *, float *, std::ptrdiff_t , std::ptrdiff_t , std::ptrdiff_t ,
270+ const float *
271+ );
272+
273+ // One entry per (radius R in 1..kMaxSpecialisedRadius, Symmetric in {0,1}).
274+ // Filled at module load by init().
275+ extern ConvolveXFn convolve_x_table[kMaxSpecialisedRadius + 1 ][2 ];
276+ extern ConvolveStridedFn convolve_strided_table[kMaxSpecialisedRadius + 1 ][2 ];
277+
278+ void init (); // called once from bind_filters()
279+
280+ }
281+ ```
282+
283+ In ` convolve_dispatch.cxx ` :
284+
285+ ``` cpp
286+ namespace {
287+ bool detect_avx2_fma() {
288+ #if defined(__ GNUC__ ) || defined(__ clang__ )
289+ __ builtin_cpu_init();
290+ return __ builtin_cpu_supports("avx2") && __ builtin_cpu_supports("fma");
291+ #elif defined(_ MSC_VER)
292+ int regs1[ 4] ; __ cpuid(regs1, 1);
293+ const bool fma = (regs1[ 2] & (1 << 12)) != 0;
294+ int regs7[ 4] ; __ cpuidex(regs7, 7, 0);
295+ const bool avx2 = (regs7[ 1] & (1 << 5)) != 0;
296+ // Also OSXSAVE + XGETBV to confirm OS-saved YMM state.
297+ ...
298+ return avx2 && fma;
299+ #else
300+ return false;
301+ #endif
302+ }
303+ }
304+
305+ void init () {
306+ const bool use_avx2 = detect_avx2_fma();
307+ // Macros generate the per-R table entries to avoid 24 hand-written
308+ // lines (the same boost-preprocessor-style explosion fastfilters
309+ // does, kept tiny with simple X-macros).
310+ #define BIO_FILL(R) \
311+ if (use_avx2) { \
312+ convolve_x_table[R][0] = &avx2::convolve_x_radius_sym<R>; \
313+ convolve_x_table[R][1] = &avx2::convolve_x_radius_anti<R>; \
314+ convolve_strided_table[R][0] = &avx2::convolve_strided_radius_sym<R>; \
315+ convolve_strided_table[R][1] = &avx2::convolve_strided_radius_anti<R>; \
316+ } else { \
317+ convolve_x_table[R][0] = &detail::convolve_x_radius_sym<R>; \
318+ convolve_x_table[R][1] = &detail::convolve_x_radius_anti<R>; \
319+ convolve_strided_table[R][0] = &detail::convolve_strided_radius_sym<R>; \
320+ convolve_strided_table[R][1] = &detail::convolve_strided_radius_anti<R>; \
321+ }
322+ BIO_FILL (1) BIO_FILL(2) ... BIO_FILL(12)
323+ #undef BIO_FILL
324+ }
325+ ```
326+
327+ (Internally split each existing ` template <int R, bool Symmetric> ` into
328+ two non-templated-on-` Symmetric ` aliases — ` _sym ` and ` _anti ` — so the
329+ function-pointer types are concrete and the table is plain data.)
330+
331+ Call ` dispatch::init() ` from ` bind_filters() ` in ` src/bindings/filters.cxx `
332+ (once, before any kernel binding can be invoked). Use a
333+ ` static std::once_flag ` guard so re-imports don't double-initialise.
334+
335+ ### AVX2 kernel skeleton
336+
337+ The X-pass kernel in ` convolve_avx2.cxx ` is essentially the scalar main
338+ loop with explicit ` __m256 ` registers:
339+
340+ ``` cpp
341+ namespace bioimage_cpp ::filters::avx2 {
342+
343+ template <int R >
344+ void convolve_x_radius_sym(
345+ const float* __ restrict in,
346+ float* __ restrict out,
347+ std::ptrdiff_t n_rows,
348+ std::ptrdiff_t n_cols,
349+ const float* __ restrict h
350+ ) {
351+ const std::ptrdiff_t prologue_end = std::min<std::ptrdiff_t>(R, n_cols);
352+ const std::ptrdiff_t epilogue_start = std::max<std::ptrdiff_t>(prologue_end, n_cols - R);
353+
354+ for (std::ptrdiff_t row = 0; row < n_rows; ++row) {
355+ const float* __restrict in_row = in + row * n_cols;
356+ float* __restrict out_row = out + row * n_cols;
357+
358+ // --- border prologue: reuse scalar mirror code unchanged ---
359+ scalar_border_sym<R>(in_row, out_row, 0, prologue_end, n_cols, h);
360+
361+ // --- main AVX2 loop ---
362+ std::ptrdiff_t x = prologue_end;
363+ const __m256 h0 = _mm256_set1_ps(h[0]);
364+ for (; x + 8 <= epilogue_start; x += 8) {
365+ __m256 acc = _mm256_mul_ps(_mm256_loadu_ps(in_row + x), h0);
366+ for (int k = 1; k <= R; ++k) {
367+ const __m256 hk = _mm256_set1_ps(h[k]);
368+ const __m256 sum = _mm256_add_ps(
369+ _mm256_loadu_ps (in_row + x + k),
370+ _ mm256_loadu_ps(in_row + x - k)
371+ );
372+ acc = _ mm256_fmadd_ps(hk, sum, acc);
373+ }
374+ _ mm256_storeu_ps(out_row + x, acc);
375+ }
376+ // --- scalar tail (0..7 floats) ---
377+ scalar_main_sym<R >(in_row, out_row, x, epilogue_start, h);
378+
379+ // --- border epilogue ---
380+ scalar_border_sym<R>(in_row, out_row, epilogue_start, n_cols, n_cols, h);
381+ }
382+ }
383+
384+ template <int R>
385+ void convolve_x_radius_anti (...) { /* same shape, _ mm256_sub_ps instead of _ add_ps * / }
386+
387+ }
388+ ```
389+
390+ The strided kernel follows the same pattern but loops over `kStripBlock`
391+ in steps of 8, using `__m256` for the accumulator strip. Crucially the
392+ strip stays at 64 floats (`kStripBlock` is already a multiple of 8), so
393+ no new tiling decision is needed.
394+
395+ `scalar_border_sym` / `scalar_main_sym` are just the existing scalar
396+ loop bodies hoisted into small inline helpers callable from both the
397+ AVX2 and the scalar TU. **The mirror-boundary handling code must not be
398+ duplicated between the two TUs** — that's where divergence bugs would
399+ hide. Make the helpers `inline` in a shared header.
400+
401+ ### What stays byte-for-byte identical
402+
403+ - Kernel-coefficient generation in `kernel.hxx`.
404+ - Eigenvalue solvers in `eigenvalues.hxx`.
405+ - Composite filters in `gaussian.hxx`.
406+ - Binding layer in `src/bindings/filters.cxx`.
407+ - Python wrapper in `src/bioimage_cpp/filters/_filters.py`.
408+ - The public `convolve_axis_x` / `convolve_axis_strided` signatures in
409+ `convolve.hxx`.
410+ - The mirror-index function `detail::mirror_index` and the border
411+ prologue/epilogue logic.
412+
413+ If a Tier-2 change is touching any of these, stop and re-read the scope
414+ section — it's almost certainly not what Tier 2 is for.
415+
416+ ### Expected speedup
417+
418+ Based on the gap to `fastfilters` in this benchmark
419+ (`bioimage_cpp / fastfilters` geomean = 2.00):
420+
421+ - Simple filters (smoothing, derivative, gradient_magnitude, LoG):
422+ realistic post-Tier-2 ratio **1.0 – 1.3×** fastfilters (essentially
423+ tied to slightly behind).
424+ - 3D Hessian / structure-tensor eigenvalues: realistic post-Tier-2
425+ ratio **1.4 – 1.7×** fastfilters. The remaining gap is in `acos`/`cos`
426+ inside the 3×3 trig eigensolver, which intrinsics alone do not help
427+ with.
428+
429+ Do not expect to *match* fastfilters exactly without also vendoring a
430+ vectorized math library and per-radius file-copy specialisation — that's
431+ the next-tier-after-Tier-2 work, deliberately out of scope here.
432+
433+ ### Verification
434+
435+ 1. **Parity gate stays green**:
436+ `python development/filters/check_parity.py` on a machine with AVX2
437+ support must still PASS at the same tolerances. If it doesn't, the
438+ AVX2 kernel disagrees with the scalar kernel — that's the most
439+ likely failure mode and is almost always an off-by-one in the
440+ prologue / main / epilogue boundary handling.
441+ 2. **Scalar path stays green**: re-run with the AVX2 path forced off
442+ (set the function-pointer table to the scalar entries unconditionally
443+ in a debug build, or guard the dispatch decision with an environment
444+ variable like `BIOIMAGE_FORCE_SCALAR=1`). The full pytest suite must
445+ still pass; this catches scalar-only regressions introduced when
446+ refactoring shared helpers.
447+ 3. **Benchmark**:
448+ `python development/filters/benchmark.py` should show the
449+ `bioimage_cpp / fastfilters` geomean drop from ~2.00 toward ~1.2.
450+ Update this file with the new numbers.
451+ 4. **Pre-Haswell smoke**: the dispatch must take the scalar path on a
452+ machine without AVX2. Easiest local check: temporarily make
453+ `detect_avx2_fma()` return `false` and confirm correctness +
454+ performance fall back to today's Tier-1 numbers.
455+
456+ ### Smallest first step
457+
458+ Don't ship both kernels at once. The recommended sequence is:
459+
460+ 1. Land the dispatch scaffolding (`convolve_dispatch.hxx` /
461+ `.cxx`, function-pointer tables, CMake wiring) **with both pointers
462+ still pointing at the existing scalar kernels**. No behavior change.
463+ Tests stay green. This isolates the build-system part of the work.
464+ 2. Add `convolve_x_radius_avx2` only. Re-run parity + benchmark.
465+ Expect the simple filters to move; the Y/Z-bound filters
466+ (gradient_magnitude, LoG, Hessian) move proportionally less.
467+ 3. Add `convolve_strided_radius_avx2`. Re-run parity + benchmark.
468+ Expect the 3D filters to move significantly.
469+
470+ If after step 2 the speedup is smaller than expected, stop and profile
471+ before continuing — it usually means the autovectorizer was already
472+ doing better than this section assumes, and the marginal value of
473+ step 3 is lower than it appears here.
474+
475+ ### Watch-outs
476+
477+ - **Boundary-mode divergence** between scalar and AVX2 paths is the
478+ single most likely correctness bug. Share the prologue/epilogue
479+ helpers via an `inline` header; don't copy-paste.
480+ - **Unaligned loads only.** Use `_mm256_loadu_ps` / `_mm256_storeu_ps`,
481+ not the aligned variants. The bench inputs are not guaranteed to be
482+ 32-byte aligned, and on modern Intel/AMD the unaligned-load
483+ performance penalty is essentially zero. Trying to force alignment in
484+ the binding layer is more complexity than the win.
485+ - **MSVC AVX2 detection.** `__builtin_cpu_supports` is GCC/Clang only.
486+ Use raw `__cpuid` / `__cpuidex` + an `_xgetbv` check (the OS must
487+ have saved the YMM state for AVX to be safe to use). There's example
488+ code in `fastfilters/src/library/cpu_intel.c` if you need a
489+ reference; do not vendor it, just write the small bit you need.
490+ - **Don't introduce OpenMP, std::thread, or any threading primitive in
491+ this work.** Threading is a separate follow-up that should layer on
492+ top of the dispatch scheme via `parallel_for_chunks` (see
493+ `include/bioimage_cpp/detail/threading.hxx`). Mixing the two changes
494+ is asking for trouble.
495+ - **Don't add AVX-512 "while we're here."** It is a separate trigger
496+ decision with separate trade-offs (frequency throttling on older
497+ Xeons, larger binary, marginal win on memory-bound separable FIR).
498+
152499## Known caveats reflected in the adapters
153500
154501- `fastfilters.gaussianDerivative` only accepts a uniform per-axis order;
0 commit comments