@@ -4,21 +4,35 @@ State of the lifted-multicut solvers vs nifty on the standard benchmark
44problems and notes on remaining optimization headroom. Read this before the
55next round of perf work.
66
7- ## Current benchmark (3D ISBI lifted problem)
8-
9- Problem dimensions: 2462 nodes, 17 949 local edges, 21 444 lifted edges.
10-
11- | Solver | bic | nifty | Ratio | Status |
12- | ---| ---| ---| ---| ---|
13- | Greedy additive | ~ 13 ms | ~ 14.7 ms | ** 0.88×** (faster) | Goal met |
14- | Kernighan-Lin (greedy + 10 outer) | ~ 195 ms | ~ 103 ms | ** 1.92×** (slower) | Outside 30%-of-nifty target |
15-
16- Energies match nifty to within numerical noise on both solvers: greedy diff
17- ~ 0.3, KL diff ~ 0.08.
18-
19- On the 2D ISBI lifted problem (756 nodes, 2 134 local + 3 541 lifted edges):
20- bic KL runs in ~ 9 ms vs nifty's ~ 4.5 ms (2.0× — small-problem overhead
21- dominates).
7+ ## Current benchmark matrix
8+
9+ Produced by ` python evaluate_solvers.py ` (2026-05-17). All runs
10+ single-threaded; fusion-move uses ` n_seeds_fraction=0.1 ` ,
11+ ` number_of_iterations=10 ` , ` stop_if_no_improvement=4 ` . nifty side uses
12+ matched settings via the chained-solver factory (greedy warm-start +
13+ KL/fusion with the same iteration counts, ` SEED_FROM_LOCAL ` ).
14+ ` runtime ratio ` is ` nifty_runtime / bic_runtime ` — values > 1 mean bic
15+ is faster.
16+
17+ | Problem | Solver | bic energy | nifty energy | Δenergy | bic runtime | nifty runtime | runtime ratio |
18+ | ---| ---| ---| ---| ---| ---| ---| ---|
19+ | 2D | greedy | -1575.04 | -1575.04 | 0.00 | 0.70 ms | 1.50 ms | 2.15× faster |
20+ | 2D | KL (10 outer) | -1575.21 | -1575.21 | 0.00 | 4.14 ms | 4.77 ms | 1.15× faster |
21+ | 2D | fusion-move | -1575.43 | -1575.43 | 0.00 | 8.31 ms | 12.3 ms | 1.48× faster |
22+ | 3D | greedy | -15891.4 | -15891.0 | −0.35 | 6.40 ms | 15.9 ms | 2.48× faster |
23+ | 3D | KL (10 outer) | -15921.0 | -15921.1 | +0.07 | 78.1 ms | 103 ms | 1.32× faster |
24+ | 3D | fusion-move | -15915.1 | -15915.1 | 0.00 | 128 ms | 196 ms | 1.53× faster |
25+ | grid | greedy | -690 014 | -690 050 | +35.9 | 16.4 s | 20.6 s | 1.25× faster |
26+ | grid | fusion-move | -690 271 | -690 356 | +84.7 | 51.8 s | 60.2 s | 1.16× faster |
27+
28+ Δenergy = bic − nifty; negative means bic is better, positive means
29+ nifty is better. Energies are exact matches on 2D/3D fusion-move and
30+ within 0.05 % on the rest; bic is faster than nifty on every row.
31+
32+ KL on the 262 k-node grid is omitted from the matrix — it is correct
33+ but takes several minutes (heavy chain-init work scales with cluster
34+ count). See the fusion-move post-script below for the grid-specific
35+ behavior.
2236
2337## What's done
2438
@@ -42,16 +56,99 @@ floor for ~21 k heap operations.
4256
4357### Kernighan-Lin
4458
45- One C++ optimization landed (~ 22% speedup, 245 → 195 ms):
59+ Two C++ optimizations landed (cumulative 2.85× speedup, 245 → 86 ms):
4660
47- 1 . ** Pre-built per-node filtered adjacency** in
61+ 1 . ** Pre-built per-node filtered adjacency** ( ~ 22% speedup, 245 → 195 ms) in
4862 ` include/bioimage_cpp/graph/lifted_multicut/kernighan_lin.hxx ` .
4963 ` ChainScratch ` gained ` filtered_offset ` , ` filtered_count ` , and
5064 ` filtered_entries ` (each entry caches ` {node, weight, is_base} ` ).
5165 The chain loop iterates only in-pair neighbors and caches ` was_in_heap `
5266 once per iteration so the three subsequent heap operations don't each
5367 re-query the locator.
5468
69+ 2 . ** ` changed_[] ` cross-iteration pair-skip** (~ 2.27× speedup, 195 → 86 ms)
70+ in the outer ` kernighan_lin() ` driver. Mirrors nifty's
71+ ` checkIfPartitonChanged() ` / ` changed_[piU] || changed_[piV] ` gate. A
72+ pair ` (A, B) ` whose endpoints' node-sets are identical to the previous
73+ outer iter must produce the same result it did last time (zero gain —
74+ otherwise A or B would have changed). After iter 1, this typically
75+ skips >50% of pairs. New helper ` detail_kl::compute_cluster_changed `
76+ runs in <0.2 ms per outer iter. Tie-breaking is preserved (iter 0
77+ processes every pair); only pairs whose runs would commit no moves are
78+ skipped. Same gate also applied to ` cluster_splits ` .
79+
80+ ### Fusion-move
81+
82+ No solver-level optimizations were needed — the driver, fuse step and
83+ sub-solver are already competitive with nifty's. The only change was a
84+ ** proposal-generator parameter-semantics fix** in
85+ ` include/bioimage_cpp/graph/proposal_generators/watershed.hxx ` . Pre-fix
86+ ` n_seeds_fraction=0.1 ` produced 2× nifty's seed density: the loop
87+ iterated ` 0.1 * N ` times placing 2 seeds per iter (0.2 N total seeds),
88+ whereas nifty iterates ` nSeeds / 2 ` times placing 2 each (` 0.1 * N `
89+ total seeds). Effect on grid: -690099 → -690270 (closes 67 % of the
90+ 256-unit energy gap), 2D becomes an exact energy match, 3D drops 1.5
91+ energy units (still matches nifty). Loop now runs ` n_seeds / 2 ` times
92+ to match nifty exactly. Documented in the header and Python docstring.
93+
94+ ** Diagnosis of the grid energy gap (2026-05-17).** On grid, bic-fusion
95+ finishes 85 units worse than nifty-fusion, despite bic-greedy starting
96+ 36 units * better* than nifty-greedy. The 121-unit reversal across the
97+ fusion-move step is the thing to explain.
98+
99+ 1 . ** Cross-feed test localizes 100 % of the final gap to the greedy
100+ warm-start, not to the fusion-move code.** Feeding nifty-greedy
101+ labels into bic-fusion produced -690 355.67, matching nifty-fusion's
102+ -690 355.63 (within float noise). bic's fusion-move algorithm
103+ reproduces nifty's result from the same starting state.
104+
105+ 2 . ** bic-greedy and nifty-greedy land in structurally different local
106+ optima** , not in two equivalent tie-breaks of the same optimum. The
107+ reversal (bic ahead by 36 after greedy, behind by 85 after fusion)
108+ means bic-greedy converges to a * deeper* local minimum that the
109+ watershed proposals cannot escape — agreement-contraction between
110+ bic-greedy's labels and the proposals leaves the sub-solver no room
111+ to commit improvements. nifty-greedy's higher-energy optimum has
112+ partition boundaries that the same proposals * can* perturb, so its
113+ fusion-move makes much more progress per iteration.
114+
115+ 3 . ** Tie density is the structural cause.** 47 % of grid base edges
116+ (339 946 of 718 848) carry the same weight ~ +0.1. With so many
117+ identical priorities, greedy-additive's merge order can pick wildly
118+ different partition topologies. 2D and 3D have far fewer ties, so
119+ both sides land in similar optima and the fusion-move ratios stay
120+ exact.
121+
122+ 4 . ** bic's existing merge direction is the right one on this problem.**
123+ Swapping bic's union-by-adjacency-size for nifty's union-by-rank
124+ regressed bic-greedy by 100 units. Reverted.
125+
126+ To close the gap we have to escape bic-greedy's deeper-but-rigid
127+ optimum before invoking fusion. KL refinement does exactly this:
128+
129+ | Variant on grid | Energy | Runtime | Δ vs nifty energy |
130+ | ---| ---| ---| ---|
131+ | current (greedy + fusion 10/4) | -690 270.93 | 52 s | +85 (worse) |
132+ | fusion 20/8 (more iters, no KL) | -690 341.37 | 87 s | +14 |
133+ | fusion 50/15 | -690 357.68 | 193 s | -2 |
134+ | ** greedy + KL(1) + fusion** | ** -690 392.90** | ** 82 s** | ** -37 (better)** |
135+ | greedy + KL(2) + fusion | -690 539.43 | 173 s | -184 |
136+
137+ At equal runtime, one outer iter of KL between greedy and fusion buys
138+ ~ 50 more energy units than the equivalent extra fusion iterations.
139+ KL(1) on 2D shifts the result by 0.02 units (still matches nifty
140+ within noise), 3D improves by ~ 5 units, runtime cost is 10–15 % per
141+ problem.
142+
143+ ** Not adopted as a default.** The current ` FusionMoveLiftedMulticut `
144+ matches nifty exactly on 2D/3D and runs 1.16× faster than nifty on
145+ grid at 0.012 % worse energy; this is acceptable for downstream
146+ segmentation use. Users who need the better grid energy can chain
147+ greedy → KL → fusion explicitly via ` LiftedChainedSolvers ` . If that
148+ turns out to be the common workflow, a ` kl_warm_refinement_iters `
149+ parameter on ` FusionMoveLiftedMulticut ` would make the opt-in
150+ self-contained.
151+
55152## Post-mortem: cluster-pair bucket optimization (2026-05-16, reverted)
56153
57154Tried the optimization the previous notes had ranked #1 — a per-outer-iter
@@ -137,90 +234,97 @@ back at the pre-bucket baseline.
137234 The cheap-path radix-sort optimization listed below would address
138235 this, but only matters if buckets come back.
139236
140- ## Future optimizations (re-prioritised after the bucket post-mortem)
237+ ## Post-script: how the ` changed_[] ` flag closed the gap (2026-05-16)
238+
239+ The bucket post-mortem (above) concluded with two recommended levers
240+ (CSR adjacency, accept bucket tie-breaking) and the note that "nifty has
241+ no algorithmic advantage" — both rooted in a careful read of
242+ ` lifted_twocut_kernighan_lin.hxx ` (the per-pair two-cut routine).
141243
142- The bucket approach is ** not** the recommended next step. It changes
143- algorithm output (different tie-breaking) which costs us energy parity
144- on the 3D problem. If we revisit it, we'd want to invest first in
145- verifying we can recover pre-bucket order (option 1 or 2 above) before
146- committing to the layout work.
244+ That read missed the outer driver. ` lifted_multicut_kernighan_lin.hxx `
245+ maintains a ` changed_[] ` flag per partition and gates the inner two-cut
246+ on it:
147247
148- ### 1. CSR adjacency layout for lifted graph (estimated: 10–15% off)
248+ ``` cpp
249+ if (!pV.empty() && (changed_[piU] || changed_[piV]))
250+ twoCut_.optimizeTwoCut(pU, pV, twoCutBuffers_);
251+ ```
149252
150- ** Idea. ** ` UndirectedGraph ` stores adjacency as ` vector<vector<Adjacency>> `
151- — one heap allocation per node. Walking adjacency for many distinct nodes
152- in a pair pays per- node pointer chasing. A flat CSR ( ` offsets[n+1] ` +
153- ` entries[2E] ` ) built once at the start of ` kernighan_lin ` would be more
154- cache-friendly .
253+ The flag is refreshed each outer iter by ` checkIfPartitonChanged() ` , a
254+ linear-time CC-style walk over base adjacency that marks a new partition
255+ as "changed" iff its node-set differs from the previous iter's
256+ partition that contained it (split or merge). The same gate is applied
257+ to ` introduceNewPartitions ` (== our ` cluster_splits ` ) .
155258
156- ** Caveat.** The actual access pattern in ` chain_gain_init ` walks
157- adjacency for nodes in arbitrary order (whichever pair we're
158- processing), so spatial locality across nodes is poor regardless of
159- layout. The win is limited to per-node cache-line savings (one miss per
160- node vs one per adjacency vector header). Estimate ~ 10% based on rough
161- cycle counting.
259+ We added the equivalent: ` detail_kl::compute_cluster_changed ` (~ 60 lines)
260+ plus gates in the ` kernighan_lin() ` driver. Result on 3D:
162261
163- ** Complexity.** Localized: ~ 50 lines, build CSR in ` kernighan_lin ` ,
164- replace ` lifted_graph.node_adjacency(v) ` calls in ` chain_gain_init ` and
165- ` chain_loop ` with CSR iteration. Doesn't change algorithm output —
166- adjacency iteration order is preserved.
262+ | Phase | Pre-flag | Post-flag |
263+ | ---| ---| ---|
264+ | ` pair_chains ` | 167 ms | 61 ms |
265+ | ` chain_gain_init ` | 78 ms | 28 ms |
266+ | ` chain_loop ` | 85 ms | 31 ms |
267+ | ` cluster_splits ` | 6.9 ms | 1.8 ms |
268+ | ` compute_changed ` | — | 0.2 ms |
269+ | total (profiled) | 354 ms | 135 ms |
270+ | total (wall) | 195 ms | 86 ms |
167271
168- ** Why now.** This is the most attractive remaining lever: localized,
169- non-invasive, preserves tie-breaking, and the win is real cache
170- savings rather than an algorithmic restructure with side effects.
272+ Energy stayed at 0.07 diff (vs 0.08 pre-flag — within noise); 2D stayed
273+ at exact 0.000 diff. Tie-breaking is preserved by construction: iter 0
274+ has all partitions "changed" so it processes every pair (and every
275+ split) — identical to today's algorithm. From iter 1, the only pairs
276+ skipped are those where neither input changed since the previous iter,
277+ where the chain is mathematically guaranteed to commit zero moves.
171278
172- ### 2. Skip pair-chains where heap stays empty (estimated: <5 ms)
279+ Why this works so well on the benchmark: the workload is 10 outer iters
280+ on a problem that essentially converges after ~ 3 iters. Late iterations
281+ were 80%+ wasted re-walking adjacency for partitions that hadn't moved.
173282
174- After ` chain_gain_init ` , if ` heap.empty() ` we already skip
175- ` chain_loop ` . But we still pay for ` chain_init ` and the full
176- ` chain_gain_init ` walk. For pair-chains where the cluster pair has
177- only one alive node per side (post-staleness filtering of
178- ` cluster_to_nodes ` ), we could skip earlier. Need to maintain live
179- cluster sizes.
283+ ## Future optimizations
180284
181- Low priority — only a few ms.
285+ The KL solver now beats nifty by ~ 17% on 3D and matches it on 2D.
286+ Further optimization is not currently a priority. Sketched levers, in
287+ case the workload changes:
182288
183- ### 3. Revisit bucket gain init * if * tie-breaking parity is acceptable
289+ ### CSR adjacency layout (estimated: 10–15% off ` pair_chains ` )
184290
185- If a future use-case is OK with the 0.17 energy diff on 3D (e.g., the
186- bucket version's output is fed into a downstream solver that re-optimises
187- anyway), the simplified flat-sorted-vector bucket implementation with
188- mid-iter rebuild is a known ~ 60 ms win. See git history for the
189- implementation; the key files were
190- ` include/bioimage_cpp/graph/lifted_multicut/kernighan_lin.hxx `
191- (LiftedEdgeBuckets struct + chain_gain_init rewrite).
291+ ` UndirectedGraph ` stores adjacency as ` vector<vector<Adjacency>> ` — one
292+ heap allocation per node. A flat CSR built once at the start of
293+ ` kernighan_lin ` would reduce per-node cache-line misses in
294+ ` chain_gain_init ` and ` chain_loop ` . Doesn't change algorithm output.
192295
193- Do not pursue incremental maintenance — it's a strict regression.
296+ ### Skip pair-chains where heap stays empty (estimated: <5 ms)
194297
195- ### 4. Inspect nifty's internals — DONE 2026-05-16
298+ Already implicitly handled by ` heap.empty() ` check, but ` chain_init `
299+ and ` chain_gain_init ` still run. Track live cluster sizes incrementally
300+ to skip earlier when one side is empty/singleton.
196301
197- Read of ` lifted_twocut_kernighan_lin.hxx ` confirmed nifty has no
198- algorithmic advantage over our pre-bucket version: same per-pair
199- ` O(pair_size × full_adjacency) ` work, same NodeMap-based difference
200- cache, no precomputed pair-bucket index. nifty's source is at:
302+ ### Bucket gain init
201303
202- ```
203- /home/pape/Work/software/src/nifty/include/nifty/graph/opt/lifted_multicut/detail/lifted_twocut_kernighan_lin.hxx
204- ```
304+ Documented in the post-mortem (above). Would give ~ 60 ms on the
305+ pre-flag baseline but only ~ 30 ms on the post-flag baseline (most of
306+ the ` chain_gain_init ` time is now in iter 0 work, which buckets would
307+ still cover). Same tie-breaking concern stands; not recommended without
308+ a use-case that tolerates the 0.17 energy diff.
205309
206- Their per-entry constant factor is competitive (separate
207- ` graph_.adjacency(v) ` and ` liftedGraph_.adjacency(v) ` walks, no per-entry
208- ` is_base ` classification), but the algorithmic class is identical. The
209- ~ 2× gap on ` chain_gain_init ` per entry is most plausibly the
210- adjacency-walk constants — which is what optimization #1 (CSR layout)
211- would address.
310+ ### Inspect nifty's internals — DONE 2026-05-16
212311
213- ### 5. Linear-scan border instead of a heap
312+ Read of ` lifted_twocut_kernighan_lin.hxx ` confirmed nifty's per-pair
313+ two-cut has no algorithmic advantage over ours. The outer-driver
314+ ` changed_[] ` flag (in ` lifted_multicut_kernighan_lin.hxx ` ) was the
315+ missing piece — now implemented. Source at:
214316
215- ** Verdict: not worth pursuing for this problem.**
317+ ```
318+ /home/pape/Work/software/src/nifty/include/nifty/graph/opt/lifted_multicut/lifted_multicut_kernighan_lin.hxx
319+ /home/pape/Work/software/src/nifty/include/nifty/graph/opt/lifted_multicut/detail/lifted_twocut_kernighan_lin.hxx
320+ ```
216321
217- I worked through it: heap is faster than linear scan for our pair-size
218- distribution. The heap pop is O(log N) and heap.change is also O(log N);
219- for pair size 7 (the median) that's ~ 2× faster than O(N) linear scan,
220- and the gap widens for larger pairs.
322+ ### Linear-scan border instead of a heap
221323
222- nifty uses linear scan, but that's not where its speed advantage comes
223- from — likely it's the adjacency-walking constants (optimization 1).
324+ ** Verdict: not worth pursuing.** Heap is faster than linear scan for
325+ our pair-size distribution; both ` pop ` and ` change ` are O(log N) on the
326+ addressable indexed heap, beating O(N) linear scan for the median pair
327+ size of 7.
224328
225329## Workload statistics (3D problem, post greedy warm-start)
226330
@@ -234,19 +338,9 @@ Unchanged from before; useful for sanity-checking future estimates:
234338## Where to start next time
235339
2363401 . Re-run ` cd development/graph/lifted_multicut && python check_kernighan_lin.py --size 3d --repeats 5 ` to confirm the baseline hasn't drifted.
237- 2 . Build with ` pip install -e . --no-build-isolation -C cmake.define.BIOIMAGE_PROFILE=ON ` to get the profile breakdown back.
238- 3 . Try optimization 1 (CSR adjacency) — it's the safest remaining
239- lever, preserves tie-breaking, and addresses the per-entry
240- constant-factor gap we measured against nifty.
241- 4 . Targets (downgraded from previous attempt — pre-bucket KL is at
242- 195 ms, not 200 as the original notes said):
243- - 30%-of-nifty: ≤132 ms (currently 195 ms — 63 ms over).
244- - Match nifty: ≤103 ms.
245-
246- The CSR change alone won't close 60+ ms. Closing the full gap
247- probably requires either (a) accepting the bucket tie-breaking
248- regression, or (b) finding an actually new algorithmic lever we
249- haven't identified.
341+ 2 . Build with ` pip install -e . --no-build-isolation -C cmake.define.BIOIMAGE_PROFILE=ON ` for the profile breakdown.
342+ 3 . The next-most-attractive lever is CSR adjacency layout (10–15%),
343+ but only worth doing if a heavier workload reveals the need.
250344
251345## Files that matter
252346
0 commit comments