Skip to content

Commit ac42a33

Browse files
Add perf notes
1 parent 1066874 commit ac42a33

1 file changed

Lines changed: 260 additions & 0 deletions

File tree

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
# `compute_flow_density` — performance notes
2+
3+
A running record of the optimization work on `bioimage_cpp.flow.compute_flow_density`.
4+
Captures the baseline, the changes that landed, the experiments that were rolled
5+
back, and a roofline-style diagnosis of where remaining time goes.
6+
7+
Test data, here and below, refers to the registered `flow_data_2d.h5` /
8+
`flow_data_3d.h5` fixtures (`bioimage_cpp._data.load_flow_data`), invoked via
9+
`development/flow/check_flow_density.py`.
10+
11+
Hardware on which these numbers were taken: **11th Gen Intel Core i7-1185G7
12+
@ 3.00 GHz** (Tiger Lake, 4 physical cores / 8 SMT threads, 12 MB L3, AVX2 +
13+
AVX-512 capable).
14+
15+
## Headline numbers
16+
17+
```
18+
baseline current gain
19+
3D, 1 thread : 5.55 s → 5.40 s 1.03×
20+
3D, 8 threads : 5.55 s → 1.51 s 3.67×
21+
2D, 1 thread : 0.43 s → 0.45 s 0.96×
22+
2D, 8 threads : 0.43 s → 0.18 s 2.4×
23+
24+
Accuracy (3D vs reference):
25+
rel_diff = mean(|ours - ref|) / mean(ref) = 0.021 (gate ≤ 0.15)
26+
pearson = 0.998
27+
```
28+
29+
The big win is threading. Algorithmic changes only marginally help the
30+
single-threaded path; they pay off because they cap the work and give the
31+
threaded path something to scale.
32+
33+
## Changes that landed
34+
35+
In rough order of impact:
36+
37+
1. **Threading via `detail::parallel_for_chunks`** — particle tracing is
38+
embarrassingly parallel; the final density scatter stays single-threaded
39+
(`<1 %` of time per the profile). Determinism preserved.
40+
2. **Convergence-based early exit** (`tol` parameter) — per-particle alive
41+
flag; freeze a particle once `max_axis(|dt·step|) < tol`. Outer iteration
42+
loop breaks once all particles are frozen.
43+
3. **Mask-restricted tracing** (`restrict_to_mask` parameter) — a particle
44+
that rounds to a background voxel freezes in place. Combined with (2) it
45+
sheds slow trajectories that would otherwise run the full `n_iter`.
46+
4. **RK2 (midpoint) integrator** (`method="rk2"`) — two flow samples per
47+
step but allows larger `dt`; combined with early-exit it converges in
48+
fewer iterations.
49+
5. **Corner-table sharing inside `sample_channel`**`compute_corners`
50+
builds the `2^D` (offset, weight) table once per particle per iteration;
51+
all `D` channel samples reuse it. Cuts the redundant lower/frac work that
52+
the original `sample_linear_nearest` did per axis.
53+
6. **Hoisted invariants** out of the per-particle loop (`upper[axis]`,
54+
channel base pointers, channel stride).
55+
7. **Profiling instrumentation** (`BIOIMAGE_PROFILE_SCOPE`) around `init`,
56+
`iter_loop`, `scatter`, `mask_zero`. Free when the CMake flag is off; live
57+
when `-C cmake.define.BIOIMAGE_PROFILE=ON`.
58+
59+
New chosen defaults (see `src/bioimage_cpp/flow/_flow.py`):
60+
61+
```python
62+
n_iter=50, dt=0.2, tol=0.005, method="rk2", restrict_to_mask=True,
63+
number_of_threads=1
64+
```
65+
66+
A caller who wants the threaded speedup passes `number_of_threads=8` (or
67+
whatever value fits their box).
68+
69+
## Profile breakdown (1-thread, 3D, profile build)
70+
71+
```
72+
init ~0.4 % density-zero + positions collection
73+
iter_loop ~99 % the particle tracing loop
74+
scatter ~0.1 % final density write
75+
mask_zero ~0.1 % final mask zeroing
76+
```
77+
78+
The iteration loop dominates so completely that none of the other phases
79+
were worth touching. A "single-pass init" cleanup and per-thread scatter
80+
buffers were planned and skipped after the profile.
81+
82+
## Thread scaling (3D, current defaults)
83+
84+
```
85+
threads runtime speedup vs 1T
86+
1 5.40 s 1.00×
87+
2 3.76 s 1.44×
88+
4 2.07 s 2.61×
89+
6 1.71 s 3.17×
90+
8 1.52 s 3.57×
91+
```
92+
93+
Sub-linear from `1→4` (2.61× on 4 physical cores) and a marginal
94+
improvement from `4→8` (SMT pair on each core). Likely contributors:
95+
96+
- The per-iter `alive` scan is sequential.
97+
- Single-thread TurboBoost is higher than all-core sustained clock.
98+
- Memory subsystem contention even before bandwidth saturation
99+
(L3 sharing, prefetcher load).
100+
101+
It is **not** memory-bandwidth saturation — see the next section.
102+
103+
## Bandwidth diagnosis (no `perf` available, used a streaming probe)
104+
105+
Sustained streaming read bandwidth, measured via `numpy.sum` on float32
106+
arrays larger than L3:
107+
108+
```
109+
array size best wall GB/s
110+
16 MB 1.44 ms 11.67
111+
64 MB 5.83 ms 11.52
112+
144 MB 13.08 ms 11.54 (matches the 3D flow array layout)
113+
256 MB 23.63 ms 11.36
114+
```
115+
116+
Sustained read bandwidth on this machine: **≈ 11.5 GB/s**.
117+
118+
Upper-bound bytes read from the flow array per `compute_flow_density` call,
119+
3D RK2 path:
120+
121+
```
122+
729 236 particles × 50 iter × 2 samples (RK2) × 3 channels × 8 corners × 4 B
123+
≈ 7.0 GB (upper bound — convergence early-exit reduces this in practice)
124+
```
125+
126+
Effective kernel bandwidth, against that upper bound:
127+
128+
```
129+
1 thread @ 5.40 s → 1.3 GB/s = 11 % of sustained streaming
130+
8 threads @ 1.52 s → 4.6 GB/s = 40 % of sustained streaming
131+
```
132+
133+
So even at 8 threads the kernel only utilises ~40 % of the memory subsystem.
134+
The conclusion is that **the hot loop is compute-bound**, not
135+
bandwidth-bound. Almost certainly bound by gather latency / per-particle
136+
serial instruction count, not RAM throughput.
137+
138+
## SIMD experiment (rolled back)
139+
140+
GCC/Clang `__attribute__((target_clones("default,arch=haswell,arch=skylake-avx512")))`
141+
was wired up end-to-end so the IFUNC resolver would pick the AVX2 /
142+
AVX-512F clone at first call on capable CPUs. Wheel-portability was
143+
preserved: the attribute is only enabled on Linux x86_64 GCC/Clang; macOS
144+
(Mach-O lacks IFUNC) and MSVC fall back to a single default clone.
145+
146+
The dispatcher was confirmed to work — the IFUNC resolver was emitted, and
147+
the three clones diverged in their object code once the templated kernel was
148+
forced inline via `[[gnu::always_inline]]` + `[[gnu::flatten]]`. Result on
149+
the AVX2 clone:
150+
151+
-`vfmadd231ss` + ymm registers in scalar FMA encoding.
152+
- ✗ No packed-vector ops (`*ps` instructions, `vfmadd*ps`).
153+
- ✗ No `vpgatherdd` or `vgatherqps`.
154+
155+
GCC's autovectorizer evaluated the `sample_channel` corner-sum (8 gather
156+
loads + multiply-add) and decided the gather pattern wasn't profitable.
157+
Tried in sequence:
158+
159+
1. `target_clones` alone — all clones folded to the same code (the template
160+
was called via a normal function boundary, target attributes did not
161+
propagate). Net: no change.
162+
2. `[[gnu::always_inline]] inline` on the template + `[[gnu::flatten]]` on
163+
the dispatch wrappers — clones diverged but only emitted scalar FMA.
164+
8-thread runtime regressed from 1.7 s to 2.3 s (icache pressure from
165+
three full inlined copies in the same TU).
166+
3. `#pragma omp simd reduction(+:value)` on the corner sum + `-fopenmp-simd`
167+
in CMake — packed SIMD still did not materialise.
168+
4. Narrowing corner offsets from `ptrdiff_t` to `int32_t` to enable 8-wide
169+
`vpgatherdd` — no codegen change observed.
170+
171+
Final decision: **roll back**. The complexity was non-zero (a new TU, IFUNC
172+
plumbing, force-inline annotations on the templated kernel, an `omp-simd`
173+
flag) and the 8-thread regression was real.
174+
175+
What would actually work, but was out of scope for the session: hand-written
176+
AVX2 intrinsics for `sample_channel<3>` (and `<2>`), specifically:
177+
178+
```cpp
179+
__attribute__((target("avx2,fma")))
180+
inline float sample_channel_avx2_3d(const float *channel,
181+
const SamplingCorners<3> &c) {
182+
const __m256 w = _mm256_loadu_ps(c.weights.data());
183+
const __m256i o = _mm256_loadu_si256(
184+
reinterpret_cast<const __m256i*>(c.offsets.data()));
185+
const __m256 v = _mm256_i32gather_ps(channel, o, sizeof(float));
186+
const __m256 p = _mm256_mul_ps(w, v);
187+
// horizontal reduce
188+
__m128 lo = _mm256_castps256_ps128(p);
189+
__m128 hi = _mm256_extractf128_ps(p, 1);
190+
__m128 s4 = _mm_add_ps(lo, hi);
191+
__m128 s2 = _mm_add_ps(s4, _mm_movehl_ps(s4, s4));
192+
__m128 s1 = _mm_add_ss(s2, _mm_shuffle_ps(s2, s2, 1));
193+
return _mm_cvtss_f32(s1);
194+
}
195+
```
196+
197+
Wired up behind a runtime dispatch (`__builtin_cpu_supports("avx2")`) so the
198+
wheel still loads on SSE2-only CPUs. Expected speedup at 1T: ~20–30 %, less
199+
at 8T due to closer-to-bandwidth saturation. Estimated effort: half a day
200+
plus a careful microbenchmark of `vpgatherdd` cost on the target CPUs.
201+
202+
## What was tried and rejected
203+
204+
- **Per-thread density scatter buffers** — scatter is <1 % of runtime,
205+
not worth it.
206+
- **Single-pass init** (fuse density-zero with positions collection) — init
207+
is 0.4 % of runtime, not worth it.
208+
- **Active-list compaction** (rebuild a `vector<size_t> active_idx` to skip
209+
dead-particle scans) — projected impact <1 %; the current alive-byte
210+
check is one load + one branch.
211+
- **`target_clones` SIMD** — see above, no net win, rolled back.
212+
- **Half-precision (fp16) flow storage** — would halve memory traffic, but
213+
the kernel isn't bandwidth-bound (see diagnosis), and it's a breaking API
214+
change.
215+
216+
## What remains worth trying
217+
218+
In rough order of expected return on effort:
219+
220+
1. **Hand-written AVX2 (and SSE2-fallback) intrinsics for the corner sum**
221+
inside a runtime-dispatched specialization. Plausible 20–30 % at 1T. The
222+
plumbing scaffolding is documented above in the SIMD section.
223+
2. **SoA position layout** (`vector<float> pos_x, pos_y, pos_z` instead of
224+
`vector<array<float,3>>`). Lets the autovectorizer batch the clip + step
225+
+ convergence check across N particles. Pairs naturally with (1).
226+
3. **Reduce gather miss latency** via batch prefetching: prefetch the
227+
next-K particles' corner cache lines before computing the current one.
228+
Likely modest gain; needs profiling.
229+
4. **A real `perf stat`** run on a host where `linux-perf-tools` is
230+
installed, to confirm where cycles actually go (front-end, back-end
231+
memory, back-end core, retired-FMA-ratio). The streaming-probe upper
232+
bound is necessary but not sufficient — counter data would localise the
233+
bottleneck precisely.
234+
235+
## Reproducing these measurements
236+
237+
```bash
238+
# Build with profiling instrumentation
239+
pip install -e . --no-build-isolation -C cmake.define.BIOIMAGE_PROFILE=ON
240+
241+
# Per-phase breakdown
242+
python development/flow/check_flow_density.py --dim both --repeats 1
243+
244+
# Production build (no profile)
245+
pip install -e . --no-build-isolation
246+
247+
# Thread scaling
248+
for nt in 1 2 4 6 8; do
249+
python development/flow/check_flow_density.py --dim 3 --repeats 3 --threads $nt
250+
done
251+
252+
# Accuracy gate at the chosen defaults
253+
python development/flow/check_flow_density.py --dim both --repeats 3
254+
```
255+
256+
`check_flow_density.py` accepts `--method`, `--dt`, `--tol`, `--n-iter`,
257+
`--restrict-to-mask` / `--no-restrict-to-mask`, and `--threads` to override
258+
defaults; useful for sweep work. The PASS gate is
259+
`rel_diff = mean(|ours-ref|)/mean(ref) ≤ 0.15` by default
260+
(`--rel-tol` to override).

0 commit comments

Comments
 (0)