@@ -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
43471 . ** 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]` +
118154cache-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,
157220and the gap widens for larger pairs.
158221
159222nifty 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
1742361 . Re-run ` cd development/graph/lifted_multicut && python check_kernighan_lin.py --size 3d --repeats 5 ` to confirm the baseline hasn't drifted.
1752372 . 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