Skip to content

Commit 179feb8

Browse files
Optimize KL
1 parent 440dbf9 commit 179feb8

2 files changed

Lines changed: 354 additions & 74 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# Lifted Multicut Performance Notes
2+
3+
State of the lifted-multicut solvers vs nifty on the standard benchmark
4+
problems and notes on remaining optimization headroom. Read this before the
5+
next round of perf work.
6+
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) | ~200 ms | ~102 ms | **1.95×** (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+
## What's done
20+
21+
### Greedy additive
22+
23+
Three Python-side wins (~3× speedup, 39 → 13 ms):
24+
25+
1. **Bulk `_add_lifted_edges` fast path** in `src/bioimage_cpp/graph/__init__.py`.
26+
Replaced a per-row Python loop over `lifted_uvs` with one
27+
`insert_edges` call plus `np.bincount` for the residual
28+
collision case.
29+
2. **`UndirectedGraph.from_unique_edges` binding** in `src/bindings/graph.cxx`.
30+
Bypasses the per-edge hash dedup that `insert_edge` performs; used
31+
from `_copy_graph` and from the lifted-graph construction path.
32+
3. **Dropped defensive base-graph copy** in `LiftedMulticutObjective`.
33+
The C++ `Objective` already only holds a `const UndirectedGraph &`; the
34+
Python wrapper now matches.
35+
36+
The C++ greedy kernel itself runs in ~4 ms — already at the algorithmic
37+
floor for ~21 k heap operations.
38+
39+
### Kernighan-Lin
40+
41+
One C++ optimization (~22% speedup, 245 → 200 ms):
42+
43+
1. **Pre-built per-node filtered adjacency** in
44+
`include/bioimage_cpp/graph/lifted_multicut/kernighan_lin.hxx`.
45+
`ChainScratch` gained `filtered_offset`, `filtered_count`, and
46+
`filtered_entries` (each entry caches `{node, weight, is_base}`).
47+
`chain_gain_init` populates these alongside the gain accumulation; the
48+
chain loop iterates only in-pair neighbors and skips the
49+
`bufs.in_pair[u_key]` filter check. Also caches `was_in_heap` once per
50+
iteration so the three subsequent heap operations don't each re-query
51+
the locator.
52+
53+
## What remains — KL is the open item
54+
55+
C++ KL takes ~200 ms; nifty ~102 ms. Profile breakdown
56+
(`BIOIMAGE_PROFILE=ON`, 1 repeat) — note the inner scopes inflate total by
57+
~80% so trust the relative shares:
58+
59+
```
60+
cc_repartition 0.0033 s ( 0.7%)
61+
energy_eval 0.0013 s ( 0.4%)
62+
compute_pairs 0.0073 s ( 2.0%)
63+
chain_init 0.0037 s ( 1.0%)
64+
chain_gain_init 0.0812 s ( 22.1%) <-- ~36% of pair_chains
65+
chain_loop 0.0880 s ( 23.9%) <-- ~63% of pair_chains
66+
chain_cleanup 0.0017 s ( 0.5%)
67+
pair_chains 0.1744 s ( 47.4%)
68+
cluster_splits 0.0083 s ( 2.3%)
69+
```
70+
71+
Workload statistics on the 3D problem after the greedy warm-start:
72+
73+
- 643 clusters. Sizes: min 1, max 139, mean 3.8, median 2.
74+
- 4443 base cluster pairs per outer iter. Pair sizes: mean 20, median 7,
75+
max 198 (long tail).
76+
- Average lifted node degree: 32.
77+
78+
## Ranked future optimizations
79+
80+
### 1. Pre-bucketed gain init (estimated landing point: ~150 ms total, −25%)
81+
82+
**Idea.** For each outer iteration, bucket every lifted edge by its
83+
endpoint cluster pair (sorted `(min_label, max_label)`). For pair-chain
84+
`(A, B)` the gain init iterates only the three relevant buckets —
85+
`(A, A)`, `(B, B)`, `(A, B)` — instead of walking the full lifted
86+
adjacency of every node in the pair.
87+
88+
**Why it would help.** Each edge currently contributes to `chain_gain_init`
89+
exactly once per pair-chain that touches one of its endpoint clusters.
90+
With buckets, each edge contributes O(1) per outer iter. The 80 ms
91+
`chain_gain_init` collapses to roughly O(E) = ~5 ms per outer iter, saving
92+
~70 ms.
93+
94+
**The wrinkle.** Bucket membership goes stale as nodes move between
95+
clusters during the sequential pair-chains. The right fix is **incremental
96+
bucket maintenance** — every node move performs O(degree) bucket-membership
97+
updates (remove from old bucket, push into new). Bucket entries store
98+
their position via an `edge_id → bucket_pos` index so removal is
99+
`swap-with-back` in O(1). Total maintenance cost per outer iter:
100+
`O(num_moves × avg_degree)` ≈ 30 µs on our problem.
101+
102+
**Complexity.** Substantial: ~200 lines, new `LiftedEdgeBuckets` struct in
103+
`detail_kl`, hooks in `chain_loop` to call `buckets.relabel(v, old, new)`
104+
on every committed move, careful invariant management.
105+
106+
**Sanity check before implementing.** Try a quick prototype where buckets
107+
are rebuilt fresh at the start of each outer iter (O(E) per outer iter)
108+
and gain init uses buckets, accepting that within-outer-iter moves create
109+
stale entries. If the resulting energy is comparable to the exact version,
110+
the incremental maintenance is worthwhile.
111+
112+
### 2. CSR adjacency layout for lifted graph (estimated: 10–15% off)
113+
114+
**Idea.** `UndirectedGraph` stores adjacency as `vector<vector<Adjacency>>`
115+
— one heap allocation per node. Walking adjacency for many distinct nodes
116+
in a pair pays per-node pointer chasing. A flat CSR (`offsets[n+1]` +
117+
`entries[2E]`) built once at the start of `kernighan_lin` would be more
118+
cache-friendly.
119+
120+
**Caveat.** The actual access pattern in `chain_gain_init` walks
121+
adjacency for nodes in arbitrary order (whichever pair we're processing),
122+
so spatial locality across nodes is poor regardless of layout. The win is
123+
limited to per-node cache-line savings (one miss per node vs one per
124+
adjacency vector header). Estimate ~10% based on rough cycle counting.
125+
126+
**Complexity.** Localized: ~50 lines, build CSR in `kernighan_lin`,
127+
replace `lifted_graph.node_adjacency(v)` calls in `chain_gain_init` with
128+
CSR iteration.
129+
130+
**Worth combining** with optimization 1, since CSR walking is what bucket
131+
maintenance would need anyway.
132+
133+
### 3. Inspect nifty's internals (estimated: unknown, possibly clarifying)
134+
135+
The gap between our `chain_gain_init` and nifty's equivalent
136+
`computeDifferences` is suspiciously ~2× per adjacency entry given that
137+
both algorithms walk the same data. nifty's source is at:
138+
139+
```
140+
/home/pape/Work/software/src/nifty/include/nifty/graph/opt/lifted_multicut/detail/lifted_twocut_kernighan_lin.hxx
141+
```
142+
143+
Worth checking:
144+
- How nifty stores `liftedGraph_.adjacency(v)` — is it CSR-like?
145+
- Whether nifty's `referencedBy` array is `uint32` or larger (we use
146+
`uint32`).
147+
- Whether nifty's `differences` (= our `stash_gain`) cache line layout
148+
differs.
149+
150+
### 4. Linear-scan border instead of a heap
151+
152+
**Verdict: not worth pursuing for this problem.**
153+
154+
I worked through it: heap is faster than linear scan for our pair-size
155+
distribution. The heap pop is O(log N) and heap.change is also O(log N);
156+
for pair size 7 (the median) that's ~2× faster than O(N) linear scan,
157+
and the gap widens for larger pairs.
158+
159+
nifty uses linear scan, but that's not where its speed advantage comes
160+
from — likely it's the adjacency-walking constants (optimization 3).
161+
162+
### 5. Skip pair-chains where heap stays empty (estimated: <5 ms)
163+
164+
After `chain_gain_init`, if `heap.empty()` we already skip
165+
`chain_loop`. But we still pay for `chain_init` and the full
166+
`chain_gain_init` walk. For pair-chains where the cluster pair has only
167+
one alive node per side (post-staleness filtering of `cluster_to_nodes`),
168+
we could skip earlier. Need to maintain live cluster sizes.
169+
170+
Low priority — only a few ms.
171+
172+
## Where to start next time
173+
174+
1. Re-run `cd development/graph/lifted_multicut && python check_kernighan_lin.py --size 3d --repeats 5` to confirm the baseline hasn't drifted.
175+
2. Build with `pip install -e . --no-build-isolation -C cmake.define.BIOIMAGE_PROFILE=ON` to get the profile breakdown back.
176+
3. Prototype the rebuild-per-outer-iter bucket approach (optimization 1
177+
simplified) to validate the energy quality before committing to the
178+
incremental maintenance version.
179+
4. Targets:
180+
- 30%-of-nifty: ≤132 ms.
181+
- Match nifty: ≤102 ms.
182+
183+
## Files that matter
184+
185+
- `include/bioimage_cpp/graph/lifted_multicut/kernighan_lin.hxx`
186+
KL kernel; existing profile scopes wrap each phase.
187+
- `include/bioimage_cpp/graph/lifted_multicut/greedy_additive.hxx`
188+
greedy kernel; profile scopes already present, currently at ~4 ms
189+
so not the focus.
190+
- `include/bioimage_cpp/graph/lifted_multicut/objective.hxx` — objective
191+
state, including `n_base_edges` and the lifted graph.
192+
- `src/bioimage_cpp/graph/__init__.py::LiftedMulticutObjective`
193+
Python construction path (already optimized).
194+
- `development/graph/lifted_multicut/_compatibility.py`
195+
bic-vs-nifty harness; uses `run_comparison(...)`.
196+
- `tests/graph/lifted_multicut/test_external_problem.py` — regression
197+
test on the 2D problem; energy bound is ENERGY_BOUND = -1574.5.

0 commit comments

Comments
 (0)