|
| 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