Skip to content

Commit 5aa0a13

Browse files
Optimize dijkstra implementation
1 parent c96eb64 commit 5aa0a13

11 files changed

Lines changed: 2100 additions & 185 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2395,6 +2395,9 @@ TEASAR therefore uses Dijkstra where it must reconstruct and join paths.
23952395

23962396
See `development/distance/benchmark_dijkstra.py` for a standalone benchmark of
23972397
the Dijkstra field, predecessor field, weighted field, and early-stopping path.
2398+
The implementation uses reusable fixed neighbor metadata and specialized heap
2399+
strategies internally, but these do not change dtype, tie-breaking, or public
2400+
results.
23982401

23992402
## scikit-fmm
24002403

@@ -2545,15 +2548,20 @@ Important differences and current scope:
25452548
cross-section metadata, or postprocessing heuristics. These differences can
25462549
change branch positions and vertex counts, so output is not expected to be
25472550
vertex-for-vertex identical to kimimaro.
2548-
- The C++ core is dependency-free and single-threaded. It deliberately reuses
2549-
the same public grid-Dijkstra implementation described above for root fields
2550-
and penalized rail paths.
2551+
- The C++ core is dependency-free and single-threaded. TEASAR uses an internal
2552+
FP64 compact-foreground Dijkstra backend for root fields and penalized rail
2553+
paths. Compact IDs retain C-order tie-breaking, so it is bitwise identical to
2554+
the dense public grid solver on the tested TEASAR workloads while avoiding
2555+
full-volume shortest-path fields. The public Dijkstra functions remain dense
2556+
and exact FP64.
25512557

25522558
Correctness tests are under `tests/skeleton/test_teasar.py`. The independent
25532559
Dijkstra benchmark is `development/distance/benchmark_dijkstra.py`; the
25542560
end-to-end synthetic branching-tube benchmark is
25552561
`development/skeleton/benchmark_teasar.py` and can optionally add kimimaro with
2556-
`--kimimaro` when it is installed.
2562+
`--kimimaro` when it is installed. Pass `--sequential-backends` to reproduce
2563+
the dense/compact/precision design matrix; extended density, spacing, and PDRF
2564+
regimes are in `development/skeleton/benchmark_teasar_sequential.py`.
25572565

25582566
## I/O and Build Dependencies
25592567

development/distance/DIJKSTRA_PERFORMANCE_NOTES.md

Lines changed: 335 additions & 1 deletion
Large diffs are not rendered by default.

development/skeleton/PERFORMANCE_NOTES.md

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,71 @@ intentionally change the graph. Land those separately with explicit expected
233233
topology/radius tests and rerun the kimimaro comparison without asserting
234234
vertex-for-vertex parity.
235235

236-
Current full-suite baseline: `1126 passed`. Use the `256³` three-repeat median
236+
Pre-optimization full-suite baseline: `1126 passed`. Use the `256³` three-repeat median
237237
and peak RSS as the performance acceptance benchmark for the next optimization
238238
step.
239+
240+
## Implemented compact-indexing results
241+
242+
The sequential design matrix in `OPTIM_DIJKSTRA.md` was implemented and
243+
measured on 2026-07-14. Reproduce the size sweep and extended regimes with:
244+
245+
```bash
246+
python development/skeleton/benchmark_teasar.py \
247+
--large --sequential-backends --repeats 5
248+
python development/skeleton/benchmark_teasar_sequential.py --repeats 5
249+
```
250+
251+
Compact IDs follow ascending full C-order indices. Compact FP64 therefore
252+
preserves dense FP64 heap tie-breaking and produced bitwise-identical vertices,
253+
edges, and radii for every size, spacing, density, and PDRF regime tested.
254+
255+
Five-repeat end-to-end medians for the main anisotropic branching tubes:
256+
257+
| backend | `128^3` | `192^3` | `256^3` |
258+
| --- | ---: | ---: | ---: |
259+
| optimized dense FP64 | 134.66 ms | 618.82 ms | 1.169 s |
260+
| compact on-the-fly FP64 | **74.04 ms** | **267.21 ms** | **686.58 ms** |
261+
| compact CSR FP64 | 75.19 ms | 284.57 ms | 730.72 ms |
262+
| compact CSR FP32 | 74.66 ms | 284.70 ms | 730.21 ms |
263+
264+
CSR was 1.6%, 6.5%, and 6.4% slower than on-the-fly FP64 on these tiers. It
265+
was only 2.3% faster on the separate extremely sparse `192^3` case and was
266+
40% slower on the relatively dense `96^3` ball. It therefore failed the rule
267+
requiring a 5% win on both large tiers. The selected production backend is
268+
compact on-the-fly FP64: a temporary/full `uint32` lookup plus
269+
`compact_to_full`, with root, PDRF, heap state, predecessors, skeleton targets,
270+
and voxel-to-vertex data indexed only over foreground.
271+
272+
On the `256^3` case, process peak RSS fell from 1,143,572 KiB in the original
273+
implementation to 266,536 KiB, a 76.7% reduction. The optimized dense backend
274+
measured 839,596 KiB in the same single-call process. Compact on-the-fly and
275+
CSR had effectively identical peaks because both must first construct the
276+
65.5 MiB full-to-compact lookup; CSR releases it only after its adjacency is
277+
built.
278+
279+
Profile-build phase totals on `256^3` show where the improvement lands:
280+
281+
| phase | optimized dense FP64 | selected compact FP64 |
282+
| --- | ---: | ---: |
283+
| compact-domain build | -- | 41.6 ms |
284+
| root Dijkstra (two fields) | 178.7 ms | 58.8 ms |
285+
| PDRF construction | 53.4 ms | 3.7 ms |
286+
| rail-path Dijkstra | 274.3 ms | 29.0 ms |
287+
| total TEASAR | 995.0 ms | 639.9 ms |
288+
289+
The exact distance-to-boundary transform is now 77% of selected-backend time;
290+
combined root/path Dijkstra is 5.2x faster than the optimized dense backend and
291+
12.5x faster than the original recorded Dijkstra phases.
292+
293+
### FP32 decision
294+
295+
Internal FP32 was rejected. On `256^3`, CSR FP32 combined root/path Dijkstra
296+
was 113.9 ms versus 108.9 ms for CSR FP64, end-to-end time was slightly slower,
297+
and peak RSS was unchanged at about 266.5 MiB. It also failed the quality gate
298+
on `192^3`: the contracted degree signature changed, bidirectional 95th
299+
percentile distance was 2.0 voxels, Hausdorff distance was 7.07 voxels, and
300+
total physical length differed by 2.26%. Automatic TEASAR execution therefore
301+
remains FP64. Parallel shortest paths remain a separate follow-up.
302+
303+
Final verification after selecting compact on-the-fly FP64: `1129 passed`.

development/skeleton/benchmark_teasar.py

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
--------
1111
python development/skeleton/benchmark_teasar.py --small --repeats 3
1212
python development/skeleton/benchmark_teasar.py --repeats 5
13+
python development/skeleton/benchmark_teasar.py --large --sequential-backends
1314
python development/skeleton/benchmark_teasar.py --large --kimimaro --repeats 3 \
1415
--json /tmp/teasar.json
1516
"""
@@ -27,6 +28,7 @@
2728
import numpy as np
2829

2930
import bioimage_cpp as bic
31+
from bioimage_cpp import _core
3032

3133

3234
def draw_ball(mask: np.ndarray, center: np.ndarray, radius: int) -> None:
@@ -85,6 +87,43 @@ def count_bic(result) -> tuple[int, int]:
8587
return len(vertices), len(edges)
8688

8789

90+
def compare_skeletons(reference, candidate, spacing):
91+
"""Return the FP32 acceptance metrics used by OPTIM_DIJKSTRA.md."""
92+
ref_vertices, ref_edges, _ = reference
93+
got_vertices, got_edges, _ = candidate
94+
95+
def degree_signature(edges, n_vertices):
96+
degrees = np.bincount(edges.ravel().astype(np.int64), minlength=n_vertices)
97+
return tuple(sorted(int(degree) for degree in degrees if degree != 2))
98+
99+
ref_voxels = ref_vertices / np.asarray(spacing, dtype=np.float64)
100+
got_voxels = got_vertices / np.asarray(spacing, dtype=np.float64)
101+
pairwise = np.linalg.norm(
102+
ref_voxels[:, None, :] - got_voxels[None, :, :], axis=2
103+
)
104+
ref_to_got = pairwise.min(axis=1)
105+
got_to_ref = pairwise.min(axis=0)
106+
107+
def total_length(vertices, edges):
108+
if len(edges) == 0:
109+
return 0.0
110+
return float(
111+
np.linalg.norm(vertices[edges[:, 0]] - vertices[edges[:, 1]], axis=1).sum()
112+
)
113+
114+
ref_length = total_length(ref_vertices, ref_edges)
115+
got_length = total_length(got_vertices, got_edges)
116+
return {
117+
"topology_match": degree_signature(ref_edges, len(ref_vertices))
118+
== degree_signature(got_edges, len(got_vertices)),
119+
"bidirectional_p95_voxels": float(
120+
max(np.percentile(ref_to_got, 95), np.percentile(got_to_ref, 95))
121+
),
122+
"hausdorff_voxels": float(max(ref_to_got.max(), got_to_ref.max())),
123+
"relative_length_error": abs(got_length - ref_length) / max(ref_length, 1e-300),
124+
}
125+
126+
88127
def kimimaro_call(mask, spacing, scale, constant, pdrf_scale, pdrf_exponent):
89128
import kimimaro
90129

@@ -112,6 +151,19 @@ def kimimaro_call(mask, spacing, scale, constant, pdrf_scale, pdrf_exponent):
112151
return skeletons[1]
113152

114153

154+
def bic_backend_call(mask, spacing, parameters, backend):
155+
"""Call a development-only C++ backend without changing the public API."""
156+
return _core._teasar_uint8_backend(
157+
mask,
158+
spacing,
159+
parameters["scale"],
160+
parameters["constant"],
161+
parameters["pdrf_scale"],
162+
parameters["pdrf_exponent"],
163+
backend,
164+
)
165+
166+
115167
def main() -> int:
116168
parser = argparse.ArgumentParser(description=__doc__)
117169
sizes = parser.add_mutually_exclusive_group()
@@ -120,6 +172,11 @@ def main() -> int:
120172
parser.add_argument("--repeats", type=int, default=5)
121173
parser.add_argument("--warmup", type=int, default=1)
122174
parser.add_argument("--kimimaro", action="store_true")
175+
parser.add_argument(
176+
"--sequential-backends",
177+
action="store_true",
178+
help="compare dense FP64, compact on-the-fly/CSR FP64, and CSR FP32",
179+
)
123180
parser.add_argument("--json", default="", help="optional JSON result path")
124181
args = parser.parse_args()
125182
if args.repeats < 1 or args.warmup < 0:
@@ -142,13 +199,30 @@ def main() -> int:
142199
"pdrf_scale": 100000.0,
143200
"pdrf_exponent": 4.0,
144201
}
145-
backends = [
146-
(
147-
"bioimage-cpp",
148-
lambda mask: bic.skeleton.teasar(mask, spacing=spacing, **parameters),
149-
count_bic,
150-
)
151-
]
202+
if args.sequential_backends:
203+
backends = [
204+
(
205+
backend,
206+
lambda mask, backend=backend: bic_backend_call(
207+
mask, spacing, parameters, backend
208+
),
209+
count_bic,
210+
)
211+
for backend in (
212+
"dense-fp64",
213+
"compact-on-the-fly-fp64",
214+
"compact-csr-fp64",
215+
"compact-csr-fp32",
216+
)
217+
]
218+
else:
219+
backends = [
220+
(
221+
"bioimage-cpp",
222+
lambda mask: bic.skeleton.teasar(mask, spacing=spacing, **parameters),
223+
count_bic,
224+
)
225+
]
152226
if args.kimimaro:
153227
backends.append(
154228
(
@@ -162,6 +236,7 @@ def main() -> int:
162236
)
163237

164238
rows = []
239+
quality_rows = []
165240
header = f"{'backend':>14} {'shape':>14} {'foreground':>11} {'vertices':>9} {'median ms':>11} {'min ms':>10}"
166241
print(header)
167242
print("-" * len(header))
@@ -193,7 +268,34 @@ def main() -> int:
193268
f"{name:>14} {str(mask.shape):>14} {foreground:11d} {vertices:9d} "
194269
f"{median_s * 1e3:11.2f} {min(samples) * 1e3:10.2f}"
195270
)
196-
if len(case_rows) == 2:
271+
if args.sequential_backends:
272+
dense_result = results_by_backend["dense-fp64"]
273+
for exact_backend in (
274+
"compact-on-the-fly-fp64", "compact-csr-fp64"
275+
):
276+
exact = all(
277+
np.array_equal(got, expected)
278+
for got, expected in zip(
279+
results_by_backend[exact_backend], dense_result
280+
)
281+
)
282+
print(f" {exact_backend} exact dense parity: {exact}")
283+
quality = compare_skeletons(
284+
results_by_backend["compact-csr-fp64"],
285+
results_by_backend["compact-csr-fp32"],
286+
spacing,
287+
)
288+
quality_rows.append({"shape": list(mask.shape), **quality})
289+
print(
290+
" FP32 quality: "
291+
f"topology={quality['topology_match']} "
292+
f"p95={quality['bidirectional_p95_voxels']:.3f} vox "
293+
f"hausdorff={quality['hausdorff_voxels']:.3f} vox "
294+
f"length_error={quality['relative_length_error']:.3%}"
295+
)
296+
if len(case_rows) == 2 and {row["backend"] for row in case_rows} == {
297+
"bioimage-cpp", "kimimaro"
298+
}:
197299
by_name = {row["backend"]: row for row in case_rows}
198300
ratio = (
199301
by_name["bioimage-cpp"]["median_s"]
@@ -203,7 +305,11 @@ def main() -> int:
203305

204306
if args.json:
205307
with open(args.json, "w", encoding="utf-8") as file:
206-
json.dump({"repeats": args.repeats, "results": rows}, file, indent=2)
308+
json.dump(
309+
{"repeats": args.repeats, "results": rows, "quality": quality_rows},
310+
file,
311+
indent=2,
312+
)
207313
print(f"wrote {args.json}", file=sys.stderr)
208314
return 0
209315

0 commit comments

Comments
 (0)