|
| 1 | +# Grid Dijkstra performance notes |
| 2 | + |
| 3 | +Baseline measurements and initial optimization recommendations for the |
| 4 | +correctness-first masked-grid Dijkstra implementation in |
| 5 | +`include/bioimage_cpp/distance/grid_dijkstra.hxx`. These notes cover the public |
| 6 | +`dijkstra_distance_field` and `dijkstra_path` primitives independently of |
| 7 | +TEASAR. No Dijkstra-specific optimizations had been applied when these numbers |
| 8 | +were collected. |
| 9 | + |
| 10 | +## Measurement setup |
| 11 | + |
| 12 | +Measured on 2026-07-14: |
| 13 | + |
| 14 | +- Intel Core i7-1185G7, 4 cores / 8 hardware threads, up to 4.8 GHz. |
| 15 | +- Optimized editable build (`-O3` through the normal CMake configuration). |
| 16 | +- Single-threaded. The initial Dijkstra implementation has no threaded path; |
| 17 | + the FMM comparison used `number_of_threads=1`. |
| 18 | +- One warmup followed by three measured calls; the median is the headline |
| 19 | + value and all raw measurements are reported below. |
| 20 | +- Full connectivity: 8 neighbors in 2D and 26 neighbors in 3D. |
| 21 | +- The mask is one connected, nearly dense domain with two walls and alternating |
| 22 | + openings. The source and target lie near opposite corners, forcing a long |
| 23 | + detour. This is intentionally a difficult field/path workload. |
| 24 | +- `physical-field` uses anisotropic spacing. `node-field` uses a smooth, |
| 25 | + positive spatial cost field. `early-path` targets the opposite corner. |
| 26 | +- The existing first-order fast-marching geodesic field is included as a |
| 27 | + performance reference only. It solves a different continuous approximation |
| 28 | + and is not a Dijkstra correctness oracle. |
| 29 | + |
| 30 | +Reproduce from the repository root: |
| 31 | + |
| 32 | +```bash |
| 33 | +python development/distance/benchmark_dijkstra.py \ |
| 34 | + --large --repeats 3 --warmup 1 --include-geodesic \ |
| 35 | + --json /tmp/dijkstra_large.json |
| 36 | +``` |
| 37 | + |
| 38 | +The benchmark tiers are: |
| 39 | + |
| 40 | +| tier | 2D | 3D | |
| 41 | +| --- | --- | --- | |
| 42 | +| small | `256²` | `32×96×96` | |
| 43 | +| default | `1024²` | `64×160×160` | |
| 44 | +| large | `2048²` | `128×256×256` | |
| 45 | + |
| 46 | +## Wall-clock results |
| 47 | + |
| 48 | +Median of three measured calls: |
| 49 | + |
| 50 | +| operation | `2048²` (4.19M foreground) | ns/fg voxel | `128×256×256` (8.32M foreground) | ns/fg voxel | |
| 51 | +| --- | ---: | ---: | ---: | ---: | |
| 52 | +| physical field | 1.001 s | 238.9 | 10.114 s | 1215.1 | |
| 53 | +| field + predecessors | 1.028 s | 245.4 | 10.002 s | 1201.6 | |
| 54 | +| node-cost field | 1.020 s | 243.4 | 8.943 s | 1074.5 | |
| 55 | +| early-stopping physical path | 0.999 s | 238.3 | 9.968 s | 1197.6 | |
| 56 | +| geodesic FMM field | 1.295 s | 309.1 | 5.709 s | 685.9 | |
| 57 | + |
| 58 | +Raw timings in seconds: |
| 59 | + |
| 60 | +| operation | `2048²` | `128×256×256` | |
| 61 | +| --- | --- | --- | |
| 62 | +| physical field | 1.001, 1.001, 0.996 | 10.114, 10.132, 9.898 | |
| 63 | +| field + predecessors | 1.032, 1.023, 1.028 | 10.002, 10.576, 9.962 | |
| 64 | +| node-cost field | 1.020, 1.033, 1.004 | 8.883, 8.943, 9.041 | |
| 65 | +| early path | 1.018, 0.999, 0.979 | 9.968, 9.746, 10.040 | |
| 66 | +| geodesic FMM field | 1.231, 1.295, 1.373 | 5.619, 5.730, 5.709 | |
| 67 | + |
| 68 | +## Findings |
| 69 | + |
| 70 | +1. **The 3D neighbor loop is the main visible scaling problem.** The physical |
| 71 | + field takes 239 ns/foreground voxel in 2D and 1215 ns/foreground voxel in |
| 72 | + 3D, a 5.1× increase per voxel. The neighbor count grows from 8 to 26, and |
| 73 | + the 3D case also puts substantially more pressure on the heap and memory |
| 74 | + hierarchy. |
| 75 | + |
| 76 | +2. **Dijkstra beats FMM in this 2D case but loses clearly in 3D.** Physical |
| 77 | + Dijkstra is 1.29× faster than FMM at `2048²`; FMM is 1.77× faster at |
| 78 | + `128×256×256`. This does not make the algorithms interchangeable: only |
| 79 | + Dijkstra provides the exact discrete predecessor chain required by TEASAR. |
| 80 | + |
| 81 | +3. **Writing predecessors is not the bottleneck.** The predecessor result adds |
| 82 | + about 2.7% in 2D and is within run-to-run noise in 3D. Avoiding predecessor |
| 83 | + writes alone will not materially improve the rail-path use case. |
| 84 | + |
| 85 | +4. **Early stopping does not help when the target is far away.** The opposite |
| 86 | + target and wall layout require nearly the whole reachable domain to settle, |
| 87 | + so `dijkstra_path` costs essentially the same as a full field. Early |
| 88 | + stopping should still help for nearby targets and should not be removed. |
| 89 | + |
| 90 | +5. **The node-cost 3D field is about 11.6% faster than the physical field.** |
| 91 | + This may reflect a different priority distribution and relaxation pattern; |
| 92 | + it is not yet phase-profiled and should not be treated as a general property |
| 93 | + of node costs. |
| 94 | + |
| 95 | +6. **The current solver constructs and initializes dense state for every |
| 96 | + call.** For `N` voxels, a solve allocates/fills at least the `float64` |
| 97 | + distance field, one-byte settled field, and `size_t` dense heap locator. |
| 98 | + Paths additionally allocate an `int64` predecessor field and a one-byte |
| 99 | + target bitmap. On the measured 64-bit build, the indexed heap stores up to |
| 100 | + one 24-byte entry per active key. At 8.4M voxels this makes allocation, |
| 101 | + zero/fill bandwidth, and heap locality important even before neighbor |
| 102 | + arithmetic is considered. |
| 103 | + |
| 104 | +7. **Weighted calls validate the full cost volume on every invocation.** This |
| 105 | + is correct for the public API but redundant for TEASAR, which owns a valid |
| 106 | + PDRF field and invokes repeated path solves on it. |
| 107 | + |
| 108 | +8. **Neighbor metadata and boundary work are dynamic.** Every solve rebuilds |
| 109 | + the offset vectors and physical lengths. Every settled voxel calls |
| 110 | + `valid_offset_target` for each candidate neighbor. The optimized FMM solver |
| 111 | + previously gained substantially by decoding a voxel once and replacing |
| 112 | + repeated generic offset validation with direct coordinate/bounds tests; the |
| 113 | + Dijkstra loop has the same structural opportunity. |
| 114 | + |
| 115 | +## Initial optimization recommendations |
| 116 | + |
| 117 | +Recommendations are ordered by expected value and implementation risk. They |
| 118 | +are hypotheses until confirmed by the repository profiler and repeated |
| 119 | +benchmarks. |
| 120 | + |
| 121 | +### 1. Profile initialization, heap, and relaxation separately |
| 122 | + |
| 123 | +Add `BIOIMAGE_PROFILE_SCOPE` regions around: |
| 124 | + |
| 125 | +- input/cost validation; |
| 126 | +- dense state initialization; |
| 127 | +- heap pop; |
| 128 | +- coordinate decoding and neighbor relaxation; |
| 129 | +- path reconstruction. |
| 130 | + |
| 131 | +Run the large 3D physical and node cases once with `BIOIMAGE_PROFILE=ON`. This |
| 132 | +will distinguish memory initialization, heap maintenance, and neighbor |
| 133 | +arithmetic before changing data structures. |
| 134 | + |
| 135 | +### 2. Specialize 2D and 3D neighbor traversal |
| 136 | + |
| 137 | +Decode the popped flat index once, then use fixed-size 2D/3D offset tables and |
| 138 | +direct bounds tests. Precompute signed linear deltas and physical edge lengths |
| 139 | +once in a reusable solver/workspace. Avoid `vector<vector<ptrdiff_t>>` and |
| 140 | +generic `valid_offset_target` calls in the inner loop. |
| 141 | + |
| 142 | +This is the safest first kernel optimization and should especially benefit the |
| 143 | +26-neighbor 3D case. Preserve lexicographic neighbor ordering and `(distance, |
| 144 | +flat_index)` tie-breaking so paths remain deterministic. |
| 145 | + |
| 146 | +### 3. Add a reusable Dijkstra workspace |
| 147 | + |
| 148 | +Follow the existing workspace pattern used by the graph solvers. Reuse: |
| 149 | + |
| 150 | +- distances and predecessors; |
| 151 | +- settled/state storage; |
| 152 | +- dense heap locator and heap capacity; |
| 153 | +- target marks; |
| 154 | +- strides and neighbor metadata. |
| 155 | + |
| 156 | +For repeated solves, use touched-index lists or generation counters so reset |
| 157 | +cost is proportional to visited voxels rather than the full bounding volume. |
| 158 | +Keep the current allocation-owning public functions as convenient wrappers. |
| 159 | + |
| 160 | +Provide a trusted internal entry point for callers such as TEASAR that have |
| 161 | +already validated an unchanged mask and cost field. Public Python calls must |
| 162 | +continue to perform full validation. |
| 163 | + |
| 164 | +### 4. Benchmark heap alternatives rather than assuming one winner |
| 165 | + |
| 166 | +The addressable dense heap avoids stale entries but requires an `N`-entry |
| 167 | +locator and random locator updates on every swap. Compare it against: |
| 168 | + |
| 169 | +- `std::priority_queue` with lazy stale-entry rejection; |
| 170 | +- a Dijkstra-specific indexed heap with a smaller entry/priority layout; |
| 171 | +- integer/radix or bucket queues only if realistic costs admit them. |
| 172 | + |
| 173 | +The physical and floating node-cost APIs are general, so bucket/radix schemes |
| 174 | +cannot replace the binary heap universally. Measure runtime, peak heap size, |
| 175 | +and memory before choosing. |
| 176 | + |
| 177 | +### 5. Reduce dense memory traffic |
| 178 | + |
| 179 | +After profiling, consider: |
| 180 | + |
| 181 | +- merging settled/heap state into a compact byte state where practical; |
| 182 | +- avoiding a full target bitmap for small target sets via generation marks or |
| 183 | + an indexed target lookup; |
| 184 | +- using touched lists to initialize only reached foreground; |
| 185 | +- compact foreground indexing for very sparse masks. |
| 186 | + |
| 187 | +Compact foreground indexing adds mapping overhead and is unlikely to help the |
| 188 | +nearly dense standalone benchmark, but it may be decisive for sparse TEASAR |
| 189 | +objects. |
| 190 | + |
| 191 | +### 6. Defer parallel shortest-path algorithms |
| 192 | + |
| 193 | +The current global priority order is inherently serial. Delta stepping or |
| 194 | +other parallel variants would be a substantial algorithmic change with harder |
| 195 | +determinism and weighted-cost semantics. First exhaust single-threaded |
| 196 | +neighbor, workspace, heap, and memory improvements. |
| 197 | + |
| 198 | +## Correctness and acceptance gates |
| 199 | + |
| 200 | +Any optimization should preserve: |
| 201 | + |
| 202 | +- exact fields against the independent Python heap oracle; |
| 203 | +- deterministic paths and target tie-breaking; |
| 204 | +- predecessor-chain costs; |
| 205 | +- all connectivity, anisotropy, zero-cost, and weighted-mode tests; |
| 206 | +- public validation and error behavior. |
| 207 | + |
| 208 | +Current full-suite baseline: `1126 passed`. Re-run |
| 209 | +`tests/distance/test_grid_dijkstra.py` after every kernel/data-structure change, |
| 210 | +then the full suite before accepting benchmark gains. |
0 commit comments