Skip to content

Commit f1b680a

Browse files
Update performance notes
1 parent 4de7658 commit f1b680a

1 file changed

Lines changed: 171 additions & 103 deletions

File tree

development/graph/lifted_multicut/PERFORMANCE_NOTES.md

Lines changed: 171 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ Problem dimensions: 2462 nodes, 17 949 local edges, 21 444 lifted edges.
1111
| Solver | bic | nifty | Ratio | Status |
1212
|---|---|---|---|---|
1313
| 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 |
14+
| Kernighan-Lin (greedy + 10 outer) | ~195 ms | ~103 ms | **1.92×** (slower) | Outside 30%-of-nifty target |
1515

16-
Energies match nifty to within numerical noise on both solvers (greedy diff
17-
~0.3, KL diff ~0.08).
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).
1822

1923
## What's done
2024

@@ -38,78 +42,110 @@ floor for ~21 k heap operations.
3842

3943
### Kernighan-Lin
4044

41-
One C++ optimization (~22% speedup, 245 → 200 ms):
45+
One C++ optimization landed (~22% speedup, 245 → 195 ms):
4246

4347
1. **Pre-built per-node filtered adjacency** in
4448
`include/bioimage_cpp/graph/lifted_multicut/kernighan_lin.hxx`.
4549
`ChainScratch` gained `filtered_offset`, `filtered_count`, and
4650
`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)
51+
The chain loop iterates only in-pair neighbors and caches `was_in_heap`
52+
once per iteration so the three subsequent heap operations don't each
53+
re-query the locator.
54+
55+
## Post-mortem: cluster-pair bucket optimization (2026-05-16, reverted)
56+
57+
Tried the optimization the previous notes had ranked #1 — a per-outer-iter
58+
bucket index over all lifted-graph edges, grouped by their endpoint
59+
cluster-label pair, so `chain_gain_init` could read three buckets
60+
(`(A, A)`, `(B, B)`, `(A, B)`) per pair-chain instead of walking the full
61+
lifted adjacency of every in-pair node.
62+
63+
**Wall-clock outcomes:**
64+
65+
| Variant | Time | 3D energy diff vs nifty | Notes |
66+
|---|---|---|---|
67+
| Pre-bucket (current) | 195 ms | 0.08 | Reference |
68+
| Simplified buckets, 1 rebuild / outer iter | 135 ms | 0.94 | Staleness during cluster_splits |
69+
| Simplified buckets, 2 rebuilds / outer iter | 139 ms | 0.17 | Mid-iter rebuild recovers most of the loss |
70+
| Incremental maintenance (relabel-on-commit) | 198 ms | 0.16 | Slower than pre-bucket, no energy win |
71+
72+
The simplified bucket version delivered the predicted time win on
73+
`chain_gain_init` (79 ms → 31 ms), but the 0.08 → 0.17 energy regression
74+
that came with it could not be closed. The incremental-maintenance
75+
follow-up — designed specifically to eliminate within-iter staleness —
76+
turned out to be both slower (memory-fragmentation regression in
77+
`chain_gain_init` and `chain_loop` from the `unordered_map<key,
78+
vector<Entry>>` layout) and unable to close the energy gap. We reverted
79+
the entire bucket experiment.
80+
81+
**Why the energy gap didn't close.** I assumed the 0.08 → 0.17 regression
82+
was caused by buckets going stale between pair-chains, so incremental
83+
maintenance should restore it. The data says otherwise: simplified
84+
buckets with maximum staleness (1 rebuild/iter) and incremental
85+
buckets with zero staleness landed at 0.17 and 0.16 respectively —
86+
essentially identical. The gap is not from staleness; it's from a
87+
**different filtered-adjacency iteration order** that the bucket
88+
construction produces vs. the original direct-adjacency walk. KL is
89+
sensitive to tie-breaking when multiple candidate moves have near-equal
90+
gain, and the order in which `filtered_entries[v]` entries get pushed
91+
into the heap influences the local optimum the chain converges to.
92+
Floating-point summation order across many lifted edges also produces
93+
bit-level different `stash_gain` values, contributing tie-shifts.
94+
95+
The 2D problem stays at **exact** 0.000 diff because it has fewer ties
96+
and fewer summed edges. The 3D problem has more opportunity for
97+
divergence.
98+
99+
**What this means for the bucket approach.** The bucket idea is sound
100+
in the abstract — `chain_gain_init`'s data-volume floor really is O(E)
101+
per outer iter, not O(pairs × pair_size × adjacency). But the
102+
implementation creates a different `filtered_entries` ordering than the
103+
adjacency-walk version, and that ordering difference is what produces
104+
the 0.09 energy gap on 3D. To get bucket-level speed *and* pre-bucket
105+
tie-breaking parity, you would need to either:
106+
107+
1. Build `filtered_entries[v]` by walking `lifted_graph.node_adjacency(v)`
108+
*after* using buckets to compute `stash_gain`. Adds back most of the
109+
adjacency walk cost (estimated +30–50 ms), undoing most of the bucket
110+
win.
111+
2. Sort `filtered_entries[v]` by a canonical edge_id order after bucket
112+
construction. Adds ~30 ms in per-pair sort cost; only partially
113+
matches pre-bucket order because pre-bucket follows insertion order,
114+
not edge_id order.
115+
3. Accept the 0.17 diff. It's 0.005% relative on the only problem
116+
where it shows, doesn't violate any test bound, and is well below
117+
downstream segmentation noise. The 60 ms time win on the 3D problem
118+
is real.
119+
120+
I implemented option 3 (simplified buckets + mid-iter rebuild), and at
121+
the user's request escalated to incremental maintenance hoping it would
122+
also restore energy parity. It did not, and was slower besides. We are
123+
back at the pre-bucket baseline.
124+
125+
**Specific implementation lessons:**
126+
127+
- `unordered_map<uint64_t, vector<Entry>>` for per-bucket storage caused
128+
~40 ms of cache-locality regression in `chain_gain_init` and
129+
`chain_loop` vs the flat sorted vector. If revisiting buckets,
130+
keep them in one contiguous flat array.
131+
- The `relabel_node` machinery itself is cheap (~4 ms total over 10
132+
outer iters in the profile). Incremental maintenance is *not*
133+
expensive in absolute terms; the slowdown came from the layout
134+
switch.
135+
- Predicted bucket-rebuild cost (notes said "~5 ms per outer iter")
136+
was 4× off — sort on 40 k 40-byte entries actually takes ~11 ms.
137+
The cheap-path radix-sort optimization listed below would address
138+
this, but only matters if buckets come back.
139+
140+
## Future optimizations (re-prioritised after the bucket post-mortem)
141+
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.
147+
148+
### 1. CSR adjacency layout for lifted graph (estimated: 10–15% off)
113149

114150
**Idea.** `UndirectedGraph` stores adjacency as `vector<vector<Adjacency>>`
115151
— one heap allocation per node. Walking adjacency for many distinct nodes
@@ -118,36 +154,63 @@ in a pair pays per-node pointer chasing. A flat CSR (`offsets[n+1]` +
118154
cache-friendly.
119155

120156
**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.
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.
125162

126163
**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.
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.
167+
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.
171+
172+
### 2. Skip pair-chains where heap stays empty (estimated: <5 ms)
173+
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.
180+
181+
Low priority — only a few ms.
182+
183+
### 3. Revisit bucket gain init *if* tie-breaking parity is acceptable
129184

130-
**Worth combining** with optimization 1, since CSR walking is what bucket
131-
maintenance would need anyway.
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).
132192

133-
### 3. Inspect nifty's internals (estimated: unknown, possibly clarifying)
193+
Do not pursue incremental maintenance — it's a strict regression.
134194

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:
195+
### 4. Inspect nifty's internals — DONE 2026-05-16
196+
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:
138201

139202
```
140203
/home/pape/Work/software/src/nifty/include/nifty/graph/opt/lifted_multicut/detail/lifted_twocut_kernighan_lin.hxx
141204
```
142205

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

150-
### 4. Linear-scan border instead of a heap
213+
### 5. Linear-scan border instead of a heap
151214

152215
**Verdict: not worth pursuing for this problem.**
153216

@@ -157,28 +220,33 @@ for pair size 7 (the median) that's ~2× faster than O(N) linear scan,
157220
and the gap widens for larger pairs.
158221

159222
nifty uses linear scan, but that's not where its speed advantage comes
160-
from — likely it's the adjacency-walking constants (optimization 3).
223+
from — likely it's the adjacency-walking constants (optimization 1).
161224

162-
### 5. Skip pair-chains where heap stays empty (estimated: <5 ms)
225+
## Workload statistics (3D problem, post greedy warm-start)
163226

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.
227+
Unchanged from before; useful for sanity-checking future estimates:
169228

170-
Low priority — only a few ms.
229+
- 643 clusters. Sizes: min 1, max 139, mean 3.8, median 2.
230+
- 4443 base cluster pairs per outer iter. Pair sizes: mean 20,
231+
median 7, max 198 (long tail).
232+
- Average lifted node degree: 32.
171233

172234
## Where to start next time
173235

174236
1. Re-run `cd development/graph/lifted_multicut && python check_kernighan_lin.py --size 3d --repeats 5` to confirm the baseline hasn't drifted.
175237
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.
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.
182250

183251
## Files that matter
184252

0 commit comments

Comments
 (0)