Skip to content

Commit 090463b

Browse files
Finalize filter implementation
1 parent f462a2a commit 090463b

2 files changed

Lines changed: 348 additions & 1 deletion

File tree

development/filters/PERFORMANCE_NOTES.md

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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;

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ classifiers = [
2424
]
2525

2626
[project.optional-dependencies]
27-
test = ["pytest", "pooch"]
27+
test = ["pytest", "pooch", "scipy"]
2828
data = ["pooch"]
2929

3030
[tool.scikit-build]

0 commit comments

Comments
 (0)