@@ -99,6 +99,17 @@ table and the iteration-major loop (with its per-iteration alive scan) with:
9999 and binding layer remain baseline-safe. The build can disable this path with
100100 ` -C cmake.define.BIOIMAGE_FLOW_FMA_DISPATCH=OFF ` .
101101
102+ 11 . ** 3-way lockstep trajectory interleave for RK2**
103+ (` detail::trace_particle_block ` , 2026-07-12). Each worker traces three
104+ particles per group in lockstep instead of one trajectory to completion.
105+ The trajectories are independent, so the out-of-order core fills one
106+ lane's serial sample→lerp→update latency chain with the other lanes'
107+ work; a converged/frozen lane costs one predictable branch per remaining
108+ group iteration. Euler keeps the plain per-particle loop — its shorter
109+ single-sample chain measured ~ 10 % * slower* interleaved. Results are
110+ bitwise identical (per-trajectory arithmetic is untouched). See
111+ "Trajectory interleave (2026-07-12)" below for measurements and K choice.
112+
102113Chosen defaults (see ` src/bioimage_cpp/flow/_flow.py ` ) are unchanged:
103114
104115``` python
@@ -245,6 +256,59 @@ Implementation details that matter:
245256- Automatic ` target_clones ` remains rejected: putting a target boundary around
246257 the driver or chunk inhibited the hot-loop inlining and regressed runtime.
247258
259+ ## Trajectory interleave (2026-07-12)
260+
261+ The rejected midpoint-reuse experiment showed the per-step loads are
262+ latency-hidden * within* one trajectory — so the remaining 1T lever is ILP
263+ * across* trajectories. ` trace_particle_block<D, K, ...> ` traces K consecutive
264+ particles in lockstep (local position/alive lane state, per-step body identical
265+ to ` trace_particle ` ); the RK2 selectors use K=3, with a plain ` trace_particle `
266+ remainder loop.
267+
268+ Measured with tightly paired ` .so ` -swap ABA runs (K1 = per-particle loop,
269+ ` paired_bench.py ` , min-of-3 per invocation, Tiger Lake). Two flag regimes,
270+ because the conda toolchain exports default ` CXXFLAGS `
271+ (` -march=nocona -mtune=haswell -ftree-vectorize ... ` ) while wheel builds get
272+ plain flags — see the pitfall below:
273+
274+ | Workload (RK2 defaults) | conda flags | plain flags |
275+ | ---| ---:| ---:|
276+ | registered 3D, 1T | ** −8.6 %** (1.93 → 1.76 s) | ** −13 %** |
277+ | registered 2D, 1T | ** −27 %** (0.33 → 0.24 s) | ** −28 %** |
278+ | registered 3D, 8T | −3.5 % | ~ parity |
279+ | euler 3D, 1T (gated to K=1) | parity | — |
280+ | stripes 1/4 long-lived, 1T (lane-divergence worst case) | +1.2 % | parity |
281+ | stripes 3/4 long-lived, 1T | −1.6 % | — |
282+ | random flow s=1 / s=10, 1T | −7 % / −10 % | — |
283+
284+ - ** K choice** : K=3 and K=4 both beat K=1 by 6–16 % on the 3D fixture in the
285+ triage; K=2 was inside noise. Head-to-head, K=3 beat K=4 by ~ 4 % and carries
286+ less lane state, so K=3 landed. GCC 14 does * not* unroll the K-lane loop —
287+ the ILP comes from the out-of-order window overlapping loop iterations, and
288+ that was measurably sufficient; manual unrolling was not needed.
289+ - ** 2D vs 3D** : the 2D win (~ 27 %) dwarfs 3D — 2D has less per-lane state
290+ (position + 4-corner sampling), so the interleave adds ILP without register
291+ pressure.
292+ - ** Lane divergence** (the lockstep concern: a group lives as long as its
293+ slowest lane) is benign: the stripe worst case — one never-converging
294+ orbiter per group of 4, neighbours converging on step 1 — costs ~ 1 %,
295+ because dead lanes skip their step body and only pay an alive-bit branch.
296+ - ** Adversarial cases** live in ` development/flow/benchmark_interleave.py ` .
297+ - Verified bitwise identical: 320-case randomized digest harness, registered
298+ 2D/3D fixtures at 1/4/8 threads, full suite (1070 tests).
299+
300+ ### Build-flag pitfall (affects all local benchmarking)
301+
302+ The micromamba GCC 14 environment exports ` CXXFLAGS ` . CMake appends it to
303+ every TU, so * normal* local builds are `-march=nocona -mtune=haswell
304+ -ftree-vectorize ...` on top of the project's ` -O3` . Setting ` CXXFLAGS=<x >`
305+ for an experiment build ** replaces** those defaults rather than adding to
306+ them, silently changing codegen of every TU — an early triage here compared
307+ mixed flag regimes before this was caught. Wheel builds (cibuildwheel, no
308+ conda) get the plain flags, so plain-flag numbers are the shipping-relevant
309+ ones. When A/B-benchmarking local builds: ` echo $CXXFLAGS ` first, and keep
310+ the regime identical on both sides.
311+
248312## Correctness
249313
250314- The current suite reports 1057 passed / 8 skipped. The 17
@@ -324,6 +388,33 @@ Kept so these avenues are not blindly re-attempted.
324388 regressed ~ 34 %. Keeping one trajectory in registers beats interleaving 16 to
325389 expose memory-level parallelism. This is also why a per-particle prefetch of the
326390 * next* particle is unlikely to pay: the dependent chain is within one trajectory.
391+ - ** RK2 midpoint same-cell corner reuse** (2026-07-12, reverted). After the first
392+ sample of a step, keep the 2^D·D corner values and skip the midpoint's offset
393+ computation and reloads when ` trunc(mid) == trunc(position) ` (bitwise-exact by
394+ construction: equal lower coords imply equal clamped offsets and corner
395+ values). The mechanism worked as designed — measured with temporary counters,
396+ the reuse hit rate is ** 96.7 %** on the registered 3D fixture (default
397+ ` dt=0.2 ` ; the midpoint moves ` 0.1·|flow| ` voxels) — and a 320-case randomized
398+ digest harness plus the full suite confirmed bitwise-identical output. It was
399+ still rejected on tightly A/B-paired benchmarks (alternating the two built
400+ ` .so ` files per run; stash-and-rebuild rounds proved useless against this
401+ laptop's 15–20 % thermal drift):
402+ - Registered fixtures: 3D 1T ~ 2–3 % faster, 2D 1T ~ 5 %, 3D 8T ~ 2–5 % — the
403+ midpoint loads are L1 hits whose latency the out-of-order core already hides
404+ behind the trajectory's dependent chain, so removing them buys little.
405+ - Adversarial random flows (` development/flow/benchmark_midpoint_reuse.py ` ,
406+ scale sweep): at scale 10 (~ 30 % hit rate, maximally unpredictable branch)
407+ the kernel regressed ** 13–15 %** at 1T, consistently across pairs; ~ 1–4 %
408+ regression even at scale 1 (92 % hit).
409+ - Two codegen traps worth remembering: (a) expressing ` sample_flow ` as
410+ load-cell + interpolate-cell made GCC 14 materialize the corner array on the
411+ stack in the single-sample Euler tracer (~ 20 % Euler regression; fixed by
412+ keeping ` sample_flow ` monolithic); (b) the fatter RK2 ` trace_particle ` made
413+ GCC stop inlining it, emitting it out-of-line as an * untagged* weak symbol
414+ with AVX code in the FMA TU — the COMDAT/ODR hazard the dispatch design had
415+ only avoided through full inlining. The ` CodegenVariant ` tag is therefore
416+ now propagated through ` trace_particle ` (landed; instruction streams verified
417+ identical to the pre-change build in both TUs).
327418
328419## What remains worth trying
329420
@@ -333,7 +424,9 @@ Lower priority than the landed work; all need a fixed-clock host with `perf`
3334241 . ** Software prefetching of the next sample's cache lines.** Targets the residual
334425 load latency on the existing channel-first layout. Expected modest; the failed
335426 16-block experiment shows added machinery to expose independent loads can lose
336- more than it gains, so measure carefully.
427+ more than it gains, so measure carefully. The rejected midpoint-reuse
428+ experiment further weakens this idea: eliminating half the per-step loads
429+ outright bought only ~ 2–3 % at 1T, so the loads are already latency-hidden.
3374302 . ** A real ` perf stat ` ** around a warm-kernel harness (fixture loaded once,
338431 several kernel calls) to confirm where the remaining cycles go (front-end vs
339432 back-end memory vs back-end core) and to provide a low-noise environment for
0 commit comments