Skip to content

Commit 47cf0f2

Browse files
Lifted Multicut Solver Optimizations
1 parent f2bb4e1 commit 47cf0f2

5 files changed

Lines changed: 342 additions & 102 deletions

File tree

development/graph/lifted_multicut/PERFORMANCE_NOTES.md

Lines changed: 188 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,35 @@ State of the lifted-multicut solvers vs nifty on the standard benchmark
44
problems and notes on remaining optimization headroom. Read this before the
55
next 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

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

236340
1. 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

development/graph/lifted_multicut/evaluate_solvers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ class SolverConfig:
2121
def solver_configs():
2222
import bioimage_cpp as bic
2323

24+
# Fair single-threaded comparison: nifty's lifted fusion-move backend is
25+
# single-threaded, so we pin the bic side to threads=1 and one parallel
26+
# proposal per iteration (matching nifty's "generate one, fuse" loop) and
27+
# chain greedy-additive in front of nifty to mirror bic's auto warm-start.
28+
# Watershed seeding strategy is forced to SEED_FROM_LOCAL on the nifty
29+
# side because the bic proposal generator only sees the base graph.
2430
return {
2531
"lifted_greedy_additive": SolverConfig(
2632
make_bic_solver=lambda: bic.graph.LiftedGreedyAdditiveMulticut(),
@@ -39,6 +45,28 @@ def solver_configs():
3945
]
4046
),
4147
),
48+
"lifted_fusion_move": SolverConfig(
49+
make_bic_solver=lambda: bic.graph.FusionMoveLiftedMulticut(
50+
proposal_generator=bic.graph.WatershedProposalGenerator(),
51+
number_of_iterations=10,
52+
stop_if_no_improvement=4,
53+
number_of_threads=1,
54+
number_of_parallel_proposals=1,
55+
),
56+
make_nifty_factory=lambda objective: objective.chainedSolversFactory(
57+
[
58+
objective.liftedMulticutGreedyAdditiveFactory(),
59+
objective.fusionMoveBasedFactory(
60+
proposalGenerator=objective.watershedProposalGenerator(
61+
seedingStrategy="SEED_FROM_LOCAL",
62+
),
63+
numberOfIterations=10,
64+
stopIfNoImprovement=4,
65+
numberOfThreads=1,
66+
),
67+
]
68+
),
69+
),
4270
}
4371

4472

0 commit comments

Comments
 (0)