|
| 1 | +# Watershed — performance notes |
| 2 | + |
| 3 | +Optimization log for `bioimage_cpp.segmentation.watershed`. Re-run with: |
| 4 | + |
| 5 | +```bash |
| 6 | +python development/segmentation/check_watershed_2d.py --repeats 5 |
| 7 | +python development/segmentation/check_watershed_3d.py --repeats 3 |
| 8 | +``` |
| 9 | + |
| 10 | +Both scripts build the same heightmap from the cached ISBI affinity volume |
| 11 | +(1 − mean of the nearest-neighbour affinity channels), generate seeds by |
| 12 | +labelling local minima of a Gaussian-smoothed copy, then time |
| 13 | +`bioimage_cpp.segmentation.watershed` against |
| 14 | +`skimage.segmentation.watershed(connectivity=1)` on identical inputs with one |
| 15 | +untimed warmup and interleaved repeats. Partition agreement is measured with |
| 16 | +Rand Index / VI / adapted-rand-error from `elf.evaluation`. Exact label |
| 17 | +equality is not expected — tie-breaking on equal heights is documented as |
| 18 | +unspecified for `bioimage_cpp.watershed`. |
| 19 | + |
| 20 | +## Setup |
| 21 | + |
| 22 | +- CPU: 11th Gen Intel Core i7-1185G7 (Tiger Lake, 4C/8T) |
| 23 | +- Compiler: gcc 14.3.0 (conda-forge), `-O3`, no `-march=native` |
| 24 | +- Python 3.12.12 on Linux x86_64 |
| 25 | +- `scikit-image 0.25.2`, `numpy 1.26.4`, `bioimage_cpp 0.1.0` |
| 26 | + |
| 27 | +## Headline numbers |
| 28 | + |
| 29 | +Median wall-clock per call on the ISBI test volume. |
| 30 | + |
| 31 | +| problem | shape | seeds | baseline (v1) | optimized | speedup over baseline | skimage | speedup vs skimage | Rand Index | |
| 32 | +|---|---|---:|---:|---:|---:|---:|---:|---:| |
| 33 | +| 2D | (512, 512) | 1162 | 47.1 ms | **8.1 ms** | **5.8×** | 66.0 ms | **8.1×** | 0.982 | |
| 34 | +| 3D | (6, 512, 512) | 2407 | 720 ms | **77.3 ms** | **9.3×** | 937 ms | **12.1×** | 0.997 | |
| 35 | + |
| 36 | +`baseline (v1)` is the heap-based version from the initial watershed |
| 37 | +implementation, before this optimization round. `optimized` is the |
| 38 | +65536-bucket Meyer-flooding variant landed below. Rand Index is measured |
| 39 | +against `skimage.segmentation.watershed` — values above 0.97 indicate |
| 40 | +near-identical partitions with boundary jitter of a few percent. |
| 41 | + |
| 42 | +## Optimization phases |
| 43 | + |
| 44 | +### Baseline profile |
| 45 | + |
| 46 | +With `BIOIMAGE_PROFILE=ON` on a single 3D run: |
| 47 | + |
| 48 | +``` |
| 49 | +init_output 0.0003 s ( 0.0%) |
| 50 | +seed_pass 0.0024 s ( 0.4%) |
| 51 | +main_loop 0.6183 s ( 99.6%) |
| 52 | +total 0.6210 s |
| 53 | +``` |
| 54 | + |
| 55 | +Everything is in the heap loop. The inner loop calls |
| 56 | +`detail::valid_offset_target` once per neighbour, which does `ndim` |
| 57 | +divisions and modulos per call — 18 div+mod per heap pop in 3D. |
| 58 | + |
| 59 | +### Phase 2+3 — Precomputed offsets + 2D/3D specialization |
| 60 | + |
| 61 | +`watershed<HeightT, LabelT>` split into `detail_ws::watershed_2d` and |
| 62 | +`detail_ws::watershed_3d` with manually-unrolled 4- / 6-neighbour blocks. |
| 63 | +Replaced `valid_offset_target` (which does per-axis div+mod every call) with: |
| 64 | + |
| 65 | +- one (2D) or two (3D) div+mods per heap pop to decompose `node` into |
| 66 | + `(y, x)` or `(z, y, x)`; |
| 67 | +- branchless boundary checks of the form `coord[axis] > 0` / |
| 68 | + `coord[axis] + 1 < shape[axis]`; |
| 69 | +- flat-index neighbour arithmetic via `node ± strides[axis]`. |
| 70 | + |
| 71 | +Heap stays as `std::priority_queue<std::pair<HeightT, uint64_t>>`. |
| 72 | + |
| 73 | +| problem | before | after | delta | |
| 74 | +|---|---:|---:|---:| |
| 75 | +| 2D | 38.8 ms | 36.1 ms | 1.07× | |
| 76 | +| 3D | 602 ms | 484 ms | 1.24× | |
| 77 | + |
| 78 | +3D got the bigger win because it had more div+mod to remove per pop (18 → 2) |
| 79 | +than 2D (8 → 1). Rand Index unchanged (algorithm semantics preserved). |
| 80 | + |
| 81 | +### Phase 4 — Smaller heap entries |
| 82 | + |
| 83 | +Skipped. The profile showed the entire cost is in `main_loop` and the next |
| 84 | +phase replaces the heap entirely, so the 10–20% potential from a tighter |
| 85 | +heap entry isn't worth the binding-layer churn. |
| 86 | + |
| 87 | +### Phase 5 — Bucket-queue Meyer flooding |
| 88 | + |
| 89 | +Replaced `std::priority_queue` with a 65536-bucket queue. Algorithm: |
| 90 | + |
| 91 | +1. Scan the heightmap (skipping masked pixels) to find `[h_min, h_max]`. |
| 92 | +2. Quantize each pixel to `uint16_t` with |
| 93 | + `level = floor((image[i] - h_min) / (h_max - h_min) * 65535)`. |
| 94 | +3. For each level, maintain a `std::vector<uint64_t>` of pending pixel |
| 95 | + indices. A `current_level` cursor advances monotonically. |
| 96 | +4. Pop pixels from `buckets[current_level]` via `pop_back` (LIFO inside a |
| 97 | + level). For each unlabeled neighbour, set its label and push it into |
| 98 | + `buckets[max(level[neighbour], current_level)]` — the Meyer monotone |
| 99 | + semantic: a neighbour pushed from above never lands below the cursor. |
| 100 | +5. Advance `current_level` when the current bucket drains. |
| 101 | + |
| 102 | +`O(N + L)` total work where `L = 65536` levels. For `N >> L` this is |
| 103 | +effectively `O(N)` instead of the heap's `O(N log N)`. |
| 104 | + |
| 105 | +| problem | before (heap+specialization) | after (bucket) | delta | |
| 106 | +|---|---:|---:|---:| |
| 107 | +| 2D | 36.1 ms | 8.1 ms | 4.5× | |
| 108 | +| 3D | 484 ms | 77.3 ms | 6.3× | |
| 109 | + |
| 110 | +Post-Phase-5 3D profile: |
| 111 | + |
| 112 | +``` |
| 113 | +init_output 0.0002 s ( 0.3%) |
| 114 | +range 0.0023 s ( 2.9%) |
| 115 | +quantize 0.0020 s ( 2.5%) |
| 116 | +seed_pass 0.0013 s ( 1.7%) |
| 117 | +main_loop 0.0721 s ( 92.6%) |
| 118 | +total 0.0779 s |
| 119 | +``` |
| 120 | + |
| 121 | +`main_loop` is still ~93% of the time; the per-pixel work in the bucket |
| 122 | +queue is dramatically lower than in the heap, but it remains the dominant |
| 123 | +phase. `range`+`quantize`+`seed_pass` together are ~7% and aren't worth |
| 124 | +chasing further at this stage — saving all of them would be ~5 ms. |
| 125 | + |
| 126 | +## Tradeoff: partition agreement |
| 127 | + |
| 128 | +Rand Index drops from `0.998 / 0.999` (heap) to `0.982 / 0.997` (bucket). |
| 129 | +2D agreement dropped more because the ISBI 2D crop has small regions |
| 130 | +(avg ~225 pixels per seed) where boundary tie-breaking dominates the |
| 131 | +metric. |
| 132 | + |
| 133 | +Two semantic differences cause this: |
| 134 | + |
| 135 | +1. **Quantization.** 65536 levels can't distinguish ULP-close floats. Two |
| 136 | + pixels at heights `1e-6` apart end up in the same level instead of being |
| 137 | + strictly ordered as the heap would. Tested at 65536 levels; bumping to |
| 138 | + 1M didn't materially improve agreement (tried during this round) but |
| 139 | + costs more memory, so kept at 65536. |
| 140 | +2. **Meyer monotone flooding.** A "valley pixel" reached from a higher |
| 141 | + cursor is processed at the cursor's level, not its own. The heap-based |
| 142 | + watershed would pop it immediately and let it propagate from there. The |
| 143 | + bucket-queue version delays its processing until the cursor naturally |
| 144 | + reaches it, which can change which seed claims it. |
| 145 | + |
| 146 | +Both are documented as unspecified tie-breaking in the Python wrapper. |
| 147 | +The 21-test correctness suite (deterministic ridge tests, mask handling, |
| 148 | +dtype matrix, error cases) still passes; the agreement metric vs |
| 149 | +`skimage.segmentation.watershed` quantifies the boundary jitter on a real |
| 150 | +problem. |
| 151 | + |
| 152 | +The benchmark script's `min_rand_index` gate is set to 0.97 to reflect |
| 153 | +this — below that we'd treat it as a real regression to investigate. |
| 154 | + |
| 155 | +## What was tried and not landed |
| 156 | + |
| 157 | +- **More quantization levels (1M).** Marginal improvement in agreement |
| 158 | + (0.982 → 0.985 on 2D), 4× memory cost for `levels[]`, and the bucket |
| 159 | + vector grows to 24 MB of empty `std::vector` shells. Not worth it. |
| 160 | +- **`std::deque` for FIFO inside a level.** Doesn't change Meyer semantics |
| 161 | + (the issue isn't intra-level order). Adds per-op overhead. Discarded. |
| 162 | +- **`uint32_t` indices in the bucket queue.** Cuts bucket storage in half |
| 163 | + on the inner index path; preserves the existing `uint64_t` flat-index |
| 164 | + semantics in the rest of the codebase. Skipped to keep the change |
| 165 | + minimal; revisit if a single watershed > 2³² voxels ever shows up |
| 166 | + (currently ruled out by memory anyway). |
| 167 | + |
| 168 | +## How to reproduce |
| 169 | + |
| 170 | +```bash |
| 171 | +# Clean build (production) |
| 172 | +pip install -e . --no-build-isolation |
| 173 | +python -m pytest tests/segmentation/test_watershed.py -q # 21 pass |
| 174 | +python development/segmentation/check_watershed_2d.py --repeats 5 |
| 175 | +python development/segmentation/check_watershed_3d.py --repeats 3 |
| 176 | + |
| 177 | +# Profile build (per-phase breakdown to stderr) |
| 178 | +pip install -e . --no-build-isolation -C cmake.define.BIOIMAGE_PROFILE=ON |
| 179 | +python development/segmentation/check_watershed_3d.py --repeats 1 |
| 180 | +``` |
| 181 | + |
| 182 | +The profile macros (`BIOIMAGE_PROFILE_INIT/SCOPE/REPORT`) are gated by the |
| 183 | +`BIOIMAGE_PROFILE` cmake option and are no-ops in production builds, so |
| 184 | +they're left in `watershed.hxx` for the next round of work. |
0 commit comments