Skip to content

Commit b980b74

Browse files
Address review findings
1 parent 7870ee2 commit b980b74

27 files changed

Lines changed: 709 additions & 795 deletions

MIGRATION_GUIDE.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2382,11 +2382,11 @@ sources point to their own flat C-order index and background/unreachable voxels
23822382
contain `-1`. Paths are `int64` NumPy-axis-order coordinates from the source to
23832383
the selected target. Target ties are deterministic and use flat C-order indices.
23842384
`number_of_threads=1` retains the specialized sequential heaps; `0` uses
2385-
hardware concurrency. Threaded solves use exact FP64 delta stepping: distance
2386-
fields are bitwise-identical to the sequential result, while an equal-cost
2387-
predecessor or path may differ from the sequential heap but remains deterministic.
2388-
Small workloads and underperforming cost modes automatically retain the
2389-
sequential implementation.
2385+
hardware concurrency. Broad multi-source physical fields can use exact FP64
2386+
delta stepping: distance fields are bitwise-identical to the sequential result,
2387+
while an equal-cost predecessor may differ from the sequential heap but remains
2388+
deterministic. Paths, weighted modes, small workloads, and narrow source sets
2389+
retain the faster specialized heaps.
23902390

23912391
This Dijkstra field is deliberately distinct from
23922392
`geodesic_distance_field` below. Dijkstra is exact for the chosen 4/8- or
@@ -2401,7 +2401,8 @@ are close on well-sampled, uniform domains but are not numerically equivalent;
24012401
TEASAR therefore uses Dijkstra where it must reconstruct and join paths.
24022402

24032403
See `development/distance/benchmark_dijkstra.py` for a standalone benchmark of
2404-
the Dijkstra field, predecessor field, weighted field, and early-stopping path.
2404+
the Dijkstra field, predecessor field, weighted field, and early-stopping path;
2405+
pass `--broad-multisource` to exercise the delta-stepping dispatch.
24052406
The implementation uses reusable fixed neighbor metadata and specialized heap
24062407
strategies internally, but these do not change dtype, tie-breaking, or public
24072408
results.
@@ -2557,19 +2558,19 @@ Important differences and current scope:
25572558
change branch positions and vertex counts, so output is not expected to be
25582559
vertex-for-vertex identical to kimimaro.
25592560
- The C++ core remains dependency-free. `number_of_threads=1` is the default;
2560-
`0` uses hardware concurrency. One thread budget is shared by the exact
2561-
distance transform and the internal FP64 compact-foreground Dijkstra backend.
2562-
Dependent root sweeps and rails remain ordered, while sufficiently large
2563-
shortest-path wavefronts use deterministic delta stepping. Compact IDs avoid
2564-
full-volume shortest-path fields. The public Dijkstra functions remain dense
2565-
and exact FP64.
2561+
`0` uses hardware concurrency. The thread budget controls the exact distance
2562+
transform. Compact-foreground root sweeps and rails remain ordered on the
2563+
optimized heap: threshold benchmarks showed that staged delta stepping nearly
2564+
doubled runtime for large solid objects. Compact IDs avoid full-volume
2565+
shortest-path fields. The public Dijkstra functions remain dense and exact
2566+
FP64, with delta stepping reserved for broad physical multi-source fields.
25662567

25672568
Correctness tests are under `tests/skeleton/test_teasar.py`. The independent
25682569
Dijkstra benchmark is `development/distance/benchmark_dijkstra.py`; the
25692570
end-to-end synthetic branching-tube benchmark is
25702571
`development/skeleton/benchmark_teasar.py` and can optionally add kimimaro with
25712572
`--kimimaro` when it is installed. Pass `--sequential-backends` to reproduce
2572-
the dense/compact/precision design matrix; extended density, spacing, and PDRF
2573+
the dense/compact FP64 design matrix; extended density, spacing, and PDRF
25732574
regimes are in `development/skeleton/benchmark_teasar_sequential.py`.
25742575

25752576
## I/O and Build Dependencies

development/distance/DIJKSTRA_PERFORMANCE_NOTES.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,13 +543,16 @@ gate: matching contracted branch/leaf topology, bidirectional 95th-percentile
543543
distance at most one normalized voxel, Hausdorff distance at most two voxels,
544544
and total physical length within 2% of sequential compact FP64.
545545

546-
## Implemented parallel follow-up
546+
## Initial parallel follow-up (dispatch later narrowed)
547547

548548
Implemented after reviewing the draft above. The implementation scope was
549549
expanded to the dense public Dijkstra fields as well as the compact TEASAR
550550
backend. Both use one deterministic FP64 delta-stepping primitive in
551551
`distance/detail/delta_stepping.hxx`; the optimized heaps remain unchanged.
552552

553+
This section records the initial implementation. The review follow-up below
554+
supersedes its weighted-field and compact-TEASAR dispatch decisions.
555+
553556
The concrete design closes several gaps in the draft:
554557

555558
- frontier nodes and proposals are processed in fixed global batches independent
@@ -586,3 +589,59 @@ through the measured tiers while sharing the requested thread budget with EDT;
586589
the delta backend remains available for much larger compact domains. The
587590
development benchmarks now accept thread matrices so these thresholds can be
588591
revisited with evidence on other machines.
592+
593+
## Review follow-up: retained delta-stepping scope
594+
595+
The review follow-up on 2026-07-15 narrowed delta stepping to the case for which
596+
the benchmark shows a consistent benefit: broad multi-source **physical**
597+
fields. Paths, one/few-source fields, both weighted modes, small problems, and
598+
all compact TEASAR solves use their specialized heaps. The compact adapter and
599+
its duplicated sampled-cost-median heuristic were removed completely.
600+
601+
`development/distance/benchmark_dijkstra.py --broad-multisource` adds a full
602+
source plane and can reproduce the dispatch. Three-repeat final medians were:
603+
604+
| shape / mode | 1 thread | 2 threads | 4 threads | 1-to-4 scaling |
605+
| --- | ---: | ---: | ---: | ---: |
606+
| `256 x 256`, physical | 13.57 ms | 5.89 ms | 5.85 ms | 2.32x |
607+
| `256 x 256`, node-times-physical | 9.80 ms | 9.62 ms | 9.49 ms | 1.03x |
608+
| `32 x 96 x 96`, physical | 120.55 ms | 60.46 ms | 55.22 ms | 2.18x |
609+
| `32 x 96 x 96`, node-times-physical | 92.31 ms | 89.31 ms | 92.27 ms | 1.00x |
610+
611+
The weighted rows now show heap noise rather than parallel scaling. Before the
612+
dispatch was narrowed, their 2/4-thread medians were 149.35/148.64 ms in 3D,
613+
versus the final 89.31/92.27 ms. Retaining delta stepping for that mode would
614+
have knowingly shipped a roughly 1.6x regression. The physical 3D row retains
615+
the useful broad-wavefront speedup (the pre-change medians were 122.79, 60.81,
616+
and 51.06 ms; the four-thread final minimum was 50.79 ms).
617+
618+
The bucket-index quotient now uses `double`, matching the actual distance and
619+
delta types on every wheel target. Conversion uses an exclusive, exactly
620+
representable `2^64` upper bound to avoid undefined out-of-range conversion.
621+
A decimal-spacing case above the parallel threshold (`0.1, 0.5, 1.25`) asserts
622+
array-exact distances against the heap and deterministic predecessors between
623+
two and four threads.
624+
625+
Two proposed changes remain intentionally unimplemented:
626+
627+
- Equal-candidate predecessor handling still uses the existing strict-update
628+
behavior. Distances are exact, two/four-thread predecessor fields are
629+
deterministic, and refusing equal-distance rewrites prevents cycles in
630+
zero-weight components. Harmonizing the small-frontier and merged-proposal
631+
tie rules would change an observable predecessor/path choice, so it needs a
632+
separately chosen public tie contract rather than being bundled into a
633+
portability fix.
634+
- `std::map` buckets were not replaced speculatively. A flat vector gives O(1)
635+
lookup for dense bucket ranges but can allocate excessively when FP64 weights
636+
create large sparse index gaps; a ring or segmented design adds rebasing and
637+
overflow complexity. Profile bucket lookup/allocation separately on the
638+
retained broad physical workload before changing the data structure.
639+
640+
The dispatch threshold (`foreground >= 32768` and at least approximately one
641+
source per 256 foreground voxels) remains benchmark-derived and intentionally
642+
conservative. The standalone benchmark records both heap and dispatched cases
643+
so it can be retuned on materially different wheel hardware.
644+
645+
Final validation used a normal editable build and reported `1145 passed`. The
646+
optional array-view lifetime fix was also suite-tested in a separate build with
647+
`-fno-elide-constructors`, specifically removing any dependence on NRVO.

development/distance/benchmark_dijkstra.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
--------
1111
python development/distance/benchmark_dijkstra.py --small --repeats 3
1212
python development/distance/benchmark_dijkstra.py --repeats 10 --include-geodesic
13+
python development/distance/benchmark_dijkstra.py --small --threads 1 2 4 \
14+
--broad-multisource
1315
python development/distance/benchmark_dijkstra.py --large --repeats 3 \
1416
--include-geodesic --json /tmp/dijkstra.json
1517
"""
@@ -64,7 +66,20 @@ def time_call(function, repeats: int, warmup: int) -> list[float]:
6466
return samples
6567

6668

67-
def build_cases(shape: tuple[int, ...], include_geodesic: bool, number_of_threads: int):
69+
def source_plane(mask: np.ndarray) -> np.ndarray:
70+
"""Return all foreground coordinates on the first axis-zero plane."""
71+
plane_shape = mask.shape[1:]
72+
tail = np.indices(plane_shape, dtype=np.int64).reshape(len(plane_shape), -1).T
73+
sources = np.column_stack((np.zeros(len(tail), dtype=np.int64), tail))
74+
return np.ascontiguousarray(sources[mask[tuple(sources.T)] != 0])
75+
76+
77+
def build_cases(
78+
shape: tuple[int, ...],
79+
include_geodesic: bool,
80+
number_of_threads: int,
81+
broad_multisource: bool,
82+
):
6883
mask = make_mask(shape)
6984
source = np.full(len(shape), 2, dtype=np.int64)
7085
target = np.asarray(shape, dtype=np.int64) - 3
@@ -124,6 +139,32 @@ def build_cases(shape: tuple[int, ...], include_geodesic: bool, number_of_thread
124139
),
125140
),
126141
]
142+
if broad_multisource:
143+
sources = source_plane(mask)
144+
cases.extend(
145+
[
146+
(
147+
"dijkstra/broad-multisource-physical-field",
148+
lambda: bic.distance.dijkstra_distance_field(
149+
mask,
150+
sources,
151+
spacing=spacing,
152+
number_of_threads=number_of_threads,
153+
),
154+
),
155+
(
156+
"dijkstra/broad-multisource-node-times-physical-field",
157+
lambda: bic.distance.dijkstra_distance_field(
158+
mask,
159+
sources,
160+
costs=costs,
161+
cost_mode="node_times_physical",
162+
spacing=spacing,
163+
number_of_threads=number_of_threads,
164+
),
165+
),
166+
]
167+
)
127168
if include_geodesic:
128169
cases.append(
129170
(
@@ -147,6 +188,11 @@ def main() -> int:
147188
parser.add_argument("--repeats", type=int, default=7)
148189
parser.add_argument("--warmup", type=int, default=1)
149190
parser.add_argument("--include-geodesic", action="store_true")
191+
parser.add_argument(
192+
"--broad-multisource",
193+
action="store_true",
194+
help="include source-plane fields that exercise parallel delta stepping",
195+
)
150196
parser.add_argument(
151197
"--threads", type=int, nargs="+", default=[1],
152198
help="thread counts to benchmark (0 uses hardware concurrency)",
@@ -170,7 +216,10 @@ def main() -> int:
170216
for shape in shapes:
171217
for number_of_threads in args.threads:
172218
mask, cases = build_cases(
173-
shape, args.include_geodesic, number_of_threads
219+
shape,
220+
args.include_geodesic,
221+
number_of_threads,
222+
args.broad_multisource,
174223
)
175224
foreground = int(np.count_nonzero(mask))
176225
for name, function in cases:

development/skeleton/PERFORMANCE_NOTES.md

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ remains FP64. Parallel shortest paths remain a separate follow-up.
302302

303303
Final verification after selecting compact on-the-fly FP64: `1129 passed`.
304304

305-
## Implemented parallel follow-up
305+
## Initial parallel follow-up (compact dispatch later removed)
306306

307307
The shared thread budget for the distance transform and compact Dijkstra solves
308308
was implemented and measured on 2026-07-15. The reference matrix now passes
@@ -349,6 +349,9 @@ pool has one useful tracing task and additional workers mostly add process and
349349
shared-memory overhead. The only measured kimimaro improvement was about 3%
350350
at four workers on `256^3`.
351351

352+
This section records the initial implementation. The threshold-focused review
353+
follow-up below supersedes its compact-Dijkstra dispatch decision.
354+
352355
Worker count did not change either implementation's graph size:
353356

354357
| volume | bioimage-cpp vertices / edges | kimimaro vertices / edges |
@@ -447,3 +450,66 @@ and preallocated outputs.
447450

448451
Final verification: `1141 passed` with third-party pytest plugin autoload
449452
disabled. The normal editable build was restored after profiling.
453+
454+
## Review follow-up: compact dispatch and deferred optimizations
455+
456+
The post-optimization review was resolved on 2026-07-15. The compact
457+
delta-stepping adapter was removed rather than retained behind its
458+
`1 << 20`-foreground threshold. It was unreachable in the established sparse
459+
tube benchmarks and, when exercised by new threshold-focused cases, it caused
460+
a large regression. Compact root and rail solves now always use their optimized
461+
heap; `number_of_threads` still controls the exact distance transform.
462+
463+
Reproduce the dispatch matrix with:
464+
465+
```bash
466+
python development/skeleton/benchmark_teasar_dispatch.py \
467+
--threads 1 2 4 --repeats 5 --warmup 1
468+
```
469+
470+
The benchmark checks array-exact vertices, edges, and radii across worker
471+
counts. Five-repeat medians before and after removing the adapter were:
472+
473+
| case | workers | before | after | change |
474+
| --- | ---: | ---: | ---: | ---: |
475+
| solid cube, 1,061,208 foreground | 1 | 1037.89 ms | 1059.10 ms | +2.0% |
476+
| solid cube, 1,061,208 foreground | 2 | 1970.56 ms | 1044.70 ms | -47.0% |
477+
| solid cube, 1,061,208 foreground | 4 | 1872.36 ms | 1028.86 ms | -45.1% |
478+
| solid cuboid, 1,048,576 foreground | 1 | 1066.11 ms | 1115.94 ms | +4.7% |
479+
| solid cuboid, 1,048,576 foreground | 2 | 1985.48 ms | 1130.47 ms | -43.1% |
480+
| solid cuboid, 1,048,576 foreground | 4 | 1943.07 ms | 1093.50 ms | -43.7% |
481+
| branching tube, 132,619 foreground | 1 | 305.56 ms | 313.56 ms | +2.6% |
482+
| branching tube, 132,619 foreground | 2 | 228.84 ms | 239.68 ms | +4.7% |
483+
| branching tube, 132,619 foreground | 4 | 198.84 ms | 199.86 ms | +0.5% |
484+
485+
The small single-thread differences are within the predeclared 5% noise gate;
486+
the exact-threshold cases show that the removed dispatch nearly doubled
487+
runtime. The branching tube still scales through the threaded EDT without
488+
parallelizing its narrow shortest-path wavefronts.
489+
490+
Other review-driven cleanup retained exact behavior: dense TEASAR releases its
491+
first root field after selecting the final root; dense and compact TEASAR share
492+
one invalidation-bounds calculation; invalid compact targets are checked before
493+
workspace mutation; and the failed experimental FP32 backend is no longer
494+
compiled or exposed by the development selector. Dense FP64 remains an
495+
independent correctness oracle rather than being folded into a template-heavy
496+
common rail driver.
497+
498+
The following proposed optimizations were deliberately deferred:
499+
500+
- A pre-sorted target list would replace an O(P x V) scan, but the measured
501+
target-selection phase is only 0.8 ms on the `256^3` fixture. Its O(V log V)
502+
setup, extra memory, and invalidation bookkeeping cannot pay back materially
503+
at present.
504+
- Touched-node or generation-based compact state reset could avoid a V-byte
505+
clear per rail, but all rail-path Dijkstra work is only 26.6 ms in the same
506+
profile. Adding a branch/write to every discovery without first isolating
507+
reset time risks moving cost into the hotter relaxation loop. Revisit only
508+
after profiling reset as its own phase on a many-rail case.
509+
- A full dense/compact rail-loop abstraction was rejected for now. The indexing,
510+
storage, crop handling, and invalidation strategies are intentionally
511+
different, and keeping the dense oracle structurally independent helps it
512+
catch compact-specific regressions. Only the identical, policy-free bounds
513+
calculation was shared.
514+
- Candidate-target and state-reset changes are therefore benchmark follow-ups,
515+
not pending correctness fixes.

0 commit comments

Comments
 (0)