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