Skip to content

Commit 7870ee2

Browse files
Further optimize skeletonization
1 parent b9dace9 commit 7870ee2

7 files changed

Lines changed: 484 additions & 86 deletions

File tree

development/skeleton/PERFORMANCE_NOTES.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,149 @@ total physical length differed by 2.26%. Automatic TEASAR execution therefore
301301
remains FP64. Parallel shortest paths remain a separate follow-up.
302302

303303
Final verification after selecting compact on-the-fly FP64: `1129 passed`.
304+
305+
## Implemented parallel follow-up
306+
307+
The shared thread budget for the distance transform and compact Dijkstra solves
308+
was implemented and measured on 2026-07-15. The reference matrix now passes
309+
the same worker counts to kimimaro through `parallel=N`. Reproduce it with:
310+
311+
```bash
312+
python development/skeleton/benchmark_teasar.py \
313+
--large --kimimaro --threads 1 2 4 8 \
314+
--repeats 3 --warmup 1 \
315+
--json /tmp/teasar_large_parallel.json
316+
```
317+
318+
The environment was the same Intel Core i7-1185G7 host (4 cores / 8 hardware
319+
threads), with kimimaro 5.8.1 and EDT 3.1.1. Each backend received one warmup;
320+
backend order was shuffled deterministically for each of the three measured
321+
calls. The table reports the median with the minimum in parentheses. Scaling
322+
is relative to the corresponding single-worker median, and `bio / kimi` is the
323+
matched median-time ratio (lower favors bioimage-cpp).
324+
325+
| volume | workers | bioimage-cpp median (min) | bio scaling | kimimaro median (min) | kimi scaling | bio / kimi |
326+
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
327+
| `128^3` | 1 | 77.60 ms (73.83 ms) | 1.00x | 110.43 ms (103.32 ms) | 1.00x | 0.70x |
328+
| `128^3` | 2 | 60.42 ms (56.16 ms) | 1.28x | 143.28 ms (136.60 ms) | 0.77x | 0.42x |
329+
| `128^3` | 4 | 50.01 ms (43.15 ms) | 1.55x | 152.51 ms (149.16 ms) | 0.72x | 0.33x |
330+
| `128^3` | 8 | 55.71 ms (45.21 ms) | 1.39x | 172.32 ms (171.98 ms) | 0.64x | 0.32x |
331+
| `192^3` | 1 | 303.83 ms (283.12 ms) | 1.00x | 381.02 ms (364.20 ms) | 1.00x | 0.80x |
332+
| `192^3` | 2 | 235.93 ms (216.57 ms) | 1.29x | 393.61 ms (389.73 ms) | 0.97x | 0.60x |
333+
| `192^3` | 4 | 209.54 ms (205.03 ms) | 1.45x | 413.88 ms (409.37 ms) | 0.92x | 0.51x |
334+
| `192^3` | 8 | 195.08 ms (188.79 ms) | 1.56x | 431.37 ms (417.77 ms) | 0.88x | 0.45x |
335+
| `256^3` | 1 | 694.69 ms (681.63 ms) | 1.00x | 931.81 ms (883.13 ms) | 1.00x | 0.75x |
336+
| `256^3` | 2 | 510.91 ms (509.73 ms) | 1.36x | 950.05 ms (933.76 ms) | 0.98x | 0.54x |
337+
| `256^3` | 4 | 444.05 ms (424.72 ms) | 1.56x | 902.87 ms (902.36 ms) | 1.03x | 0.49x |
338+
| `256^3` | 8 | 434.67 ms (424.57 ms) | 1.60x | 938.22 ms (911.45 ms) | 0.99x | 0.46x |
339+
340+
Bioimage-cpp improved through four workers on every tier and was fastest at
341+
eight workers on `192^3` and `256^3`. Its best measured scaling was 1.60x on
342+
`256^3`; at four workers it was approximately 3.0x, 2.0x, and 2.0x faster than
343+
kimimaro on the three volumes.
344+
345+
Kimimaro's `parallel` path is relevant but not fully exercised by this fixture.
346+
It threads the full-volume EDT and then uses a process pool to dispatch
347+
connected components. Each branching tube contains only one component, so the
348+
pool has one useful tracing task and additional workers mostly add process and
349+
shared-memory overhead. The only measured kimimaro improvement was about 3%
350+
at four workers on `256^3`.
351+
352+
Worker count did not change either implementation's graph size:
353+
354+
| volume | bioimage-cpp vertices / edges | kimimaro vertices / edges |
355+
| --- | ---: | ---: |
356+
| `128^3` | 213 / 212 | 213 / 212 |
357+
| `192^3` | 340 / 339 | 323 / 322 |
358+
| `256^3` | 415 / 414 | 443 / 442 |
359+
360+
Verification after the benchmark update: `24 passed` in
361+
`tests/skeleton/test_teasar.py`; the matched-matrix and sequential-backend
362+
smoke runs also completed successfully.
363+
364+
## Implemented non-Dijkstra optimization follow-up
365+
366+
Profiling after the compact and parallel-Dijkstra work showed that further
367+
Dijkstra changes were no longer the best target. On the `256^3` branching tube
368+
at four workers, the two root fields plus all rail paths took about 85 ms, while
369+
the full-volume EDT alone took about 219 ms. The following exact-semantics
370+
changes were implemented on 2026-07-15:
371+
372+
- the compact TEASAR backends crop to the tight foreground bounding box plus a
373+
one-voxel zero halo and restore the integer crop origin before converting
374+
vertices to physical coordinates;
375+
- DBF values are gathered into compact-node order and the dense DBF is released
376+
before the root/path solves; the padded mask is moved into active state rather
377+
than copied;
378+
- the shared EDT initializes its uninitialized squared-distance buffer during
379+
the first-axis gather and parallelizes distance materialization;
380+
- invalidation maintains a persistent union of x intervals for each `(z, y)`
381+
row, so overlapping cubes scan only newly covered spans.
382+
383+
The dense backend remains unchanged as a correctness oracle. Compact FP64
384+
vertices, edges, and radii remained array-exact with dense FP64 across embedded,
385+
boundary-touching, sparse, dense, isotropic, anisotropic, and low/high-PDRF
386+
cases.
387+
388+
### Profile and memory result
389+
390+
One profile-build call on the `256^3` branching tube at four workers:
391+
392+
| phase | before | after |
393+
| --- | ---: | ---: |
394+
| input crop / padding | unprofiled full volume | 30.6 ms |
395+
| distance transform | 218.5 ms | 48.6 ms |
396+
| compact domain | 42.0 ms | 14.1 ms |
397+
| DBF compaction | -- | 3.3 ms |
398+
| root Dijkstra | 56.6 ms | 57.1 ms |
399+
| PDRF | 3.8 ms | 3.3 ms |
400+
| target selection | 0.8 ms | 0.8 ms |
401+
| rail-path Dijkstra | 28.7 ms | 26.6 ms |
402+
| invalidation | 11.0 ms | 3.7 ms |
403+
| measured Python call | 446.3 ms | 202.3 ms |
404+
| peak RSS | 266,804 KiB | 120,288 KiB |
405+
406+
The unchanged Dijkstra phases confirm that the gain came from the intended
407+
non-Dijkstra work. Peak RSS fell by 54.9%. On the relatively dense `96^3` ball,
408+
the row-interval union reduced invalidation from 27.2 ms to 2.7 ms and the full
409+
call from 159.8 ms to 118.1 ms, comfortably clearing the invalidation retention
410+
gate.
411+
412+
### Normal-build wall-clock result
413+
414+
Five-repeat medians with one warmup, using the same deterministic backend-order
415+
shuffle as the earlier matrix:
416+
417+
| volume | workers | optimized median | previous median | speedup |
418+
| --- | ---: | ---: | ---: | ---: |
419+
| `128^3` | 1 | 36.71 ms | 77.60 ms | 2.11x |
420+
| `128^3` | 2 | 29.06 ms | 60.42 ms | 2.08x |
421+
| `128^3` | 4 | 24.17 ms | 50.01 ms | 2.07x |
422+
| `128^3` | 8 | 24.09 ms | 55.71 ms | 2.31x |
423+
| `192^3` | 1 | 124.50 ms | 303.83 ms | 2.44x |
424+
| `192^3` | 2 | 94.47 ms | 235.93 ms | 2.50x |
425+
| `192^3` | 4 | 85.25 ms | 209.54 ms | 2.46x |
426+
| `192^3` | 8 | 78.89 ms | 195.08 ms | 2.47x |
427+
| `256^3` | 1 | 338.64 ms | 694.69 ms | 2.05x |
428+
| `256^3` | 2 | 241.10 ms | 510.91 ms | 2.12x |
429+
| `256^3` | 4 | 207.33 ms | 444.05 ms | 2.14x |
430+
| `256^3` | 8 | 203.90 ms | 434.67 ms | 2.13x |
431+
432+
The extended sequential matrix also retained exact dense parity. Its production
433+
compact medians were 65.67 ms for the sparse embedded `192^3` tube, 118.03 ms
434+
for the relatively dense `96^3` ball, and 35.61--38.84 ms across the `128^3`
435+
spacing/PDRF cases.
436+
437+
A fresh three-repeat matched reference run put bioimage-cpp at 0.19x, 0.21x,
438+
and 0.20x kimimaro's four-worker time on `128^3`, `192^3`, and `256^3`
439+
respectively (28.75 vs 152.76 ms, 81.64 vs 386.13 ms, and 203.82 vs
440+
1002.83 ms). Kimimaro still has only one useful component-tracing task on these
441+
single-component fixtures.
442+
443+
The shared EDT change was checked separately on a deterministic 50%-foreground
444+
`64 x 512 x 512` volume: five-call medians were 1.028 s at one worker and
445+
344.33 ms at four workers, with exact threaded distances, indices, vectors,
446+
and preallocated outputs.
447+
448+
Final verification: `1141 passed` with third-party pytest plugin autoload
449+
disabled. The normal editable build was restored after profiling.

development/skeleton/benchmark_teasar.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
33
The default benchmark has no optional dependencies and measures only
44
``bioimage_cpp.skeleton.teasar``. Pass ``--kimimaro`` to add kimimaro as an
5-
external reference backend when it is installed. The implementations do not
5+
external reference backend when it is installed. ``--threads`` applies the
6+
same worker-count matrix to both implementations. The implementations do not
67
have identical production heuristics, so compare topology and timings rather
78
than expecting vertex-for-vertex equality.
89
@@ -11,8 +12,8 @@
1112
python development/skeleton/benchmark_teasar.py --small --repeats 3
1213
python development/skeleton/benchmark_teasar.py --repeats 5
1314
python development/skeleton/benchmark_teasar.py --large --sequential-backends
14-
python development/skeleton/benchmark_teasar.py --large --kimimaro --repeats 3 \
15-
--json /tmp/teasar.json
15+
python development/skeleton/benchmark_teasar.py --large --kimimaro \
16+
--threads 1 2 4 8 --repeats 3 --json /tmp/teasar.json
1617
"""
1718

1819
from __future__ import annotations
@@ -124,7 +125,15 @@ def total_length(vertices, edges):
124125
}
125126

126127

127-
def kimimaro_call(mask, spacing, scale, constant, pdrf_scale, pdrf_exponent):
128+
def kimimaro_call(
129+
mask,
130+
spacing,
131+
scale,
132+
constant,
133+
pdrf_scale,
134+
pdrf_exponent,
135+
number_of_threads=1,
136+
):
128137
import kimimaro
129138

130139
skeletons = kimimaro.skeletonize(
@@ -146,7 +155,7 @@ def kimimaro_call(mask, spacing, scale, constant, pdrf_scale, pdrf_exponent):
146155
fix_branching=True,
147156
fix_borders=False,
148157
fill_holes=False,
149-
parallel=1,
158+
parallel=number_of_threads,
150159
)
151160
return skeletons[1]
152161

@@ -181,7 +190,7 @@ def main() -> int:
181190
parser.add_argument("--json", default="", help="optional JSON result path")
182191
parser.add_argument(
183192
"--threads", type=int, nargs="+", default=[1],
184-
help="public TEASAR thread counts (0 uses hardware concurrency)",
193+
help="worker counts for public TEASAR and kimimaro (0 uses all CPUs)",
185194
)
186195
args = parser.parse_args()
187196
if args.repeats < 1 or args.warmup < 0:
@@ -235,15 +244,18 @@ def main() -> int:
235244
for number_of_threads in args.threads
236245
]
237246
if args.kimimaro:
238-
backends.append(
247+
kimimaro_thread_counts = (1,) if args.sequential_backends else args.threads
248+
backends.extend(
239249
(
240-
"kimimaro",
241-
lambda mask: kimimaro_call(
250+
f"kimimaro/t{number_of_threads}",
251+
lambda mask, number_of_threads=number_of_threads: kimimaro_call(
242252
mask, spacing, parameters["scale"], parameters["constant"],
243-
parameters["pdrf_scale"], parameters["pdrf_exponent"]
253+
parameters["pdrf_scale"], parameters["pdrf_exponent"],
254+
number_of_threads,
244255
),
245256
lambda skeleton: (len(skeleton.vertices), len(skeleton.edges)),
246257
)
258+
for number_of_threads in kimimaro_thread_counts
247259
)
248260

249261
rows = []
@@ -304,15 +316,17 @@ def main() -> int:
304316
f"hausdorff={quality['hausdorff_voxels']:.3f} vox "
305317
f"length_error={quality['relative_length_error']:.3%}"
306318
)
307-
if len(args.threads) == 1 and len(case_rows) == 2 and {
308-
row["backend"] for row in case_rows
309-
} == {f"bioimage-cpp/t{args.threads[0]}", "kimimaro"}:
319+
if args.kimimaro and not args.sequential_backends:
310320
by_name = {row["backend"]: row for row in case_rows}
311-
ratio = (
312-
by_name[f"bioimage-cpp/t{args.threads[0]}"]["median_s"]
313-
/ by_name["kimimaro"]["median_s"]
314-
)
315-
print(f"{'bioimage-cpp / kimimaro':>54}: {ratio:.2f}x")
321+
for number_of_threads in args.threads:
322+
bioimage_name = f"bioimage-cpp/t{number_of_threads}"
323+
kimimaro_name = f"kimimaro/t{number_of_threads}"
324+
ratio = (
325+
by_name[bioimage_name]["median_s"]
326+
/ by_name[kimimaro_name]["median_s"]
327+
)
328+
label = f"{bioimage_name} / {kimimaro_name}"
329+
print(f"{label:>54}: {ratio:.2f}x")
316330

317331
if args.json:
318332
with open(args.json, "w", encoding="utf-8") as file:

include/bioimage_cpp/distance/distance_transform.hxx

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include <cstddef>
1010
#include <cstdint>
1111
#include <limits>
12+
#include <memory>
1213
#include <stdexcept>
1314
#include <string>
1415
#include <vector>
@@ -276,22 +277,16 @@ inline void distance_transform(
276277
// Squared sampled distance buffer. ndim per-axis feature-coord buffers
277278
// (int32) replace the previous flat int64 feature index — this lets the
278279
// output pass materialize indices/vectors without re-unraveling per pixel.
279-
std::vector<double> squared_distance(static_cast<std::size_t>(n));
280+
auto squared_distance = std::make_unique_for_overwrite<double[]>(
281+
static_cast<std::size_t>(n)
282+
);
280283
std::vector<std::vector<std::int32_t>> feature_coord;
281284
if (track_feature) {
282285
feature_coord.resize(static_cast<std::size_t>(ndim));
283286
for (auto &arr : feature_coord) {
284287
arr.assign(static_cast<std::size_t>(n), 0);
285288
}
286289
}
287-
{
288-
BIOIMAGE_PROFILE_SCOPE(profiler, "init_buffers")
289-
for (std::ptrdiff_t i = 0; i < n; ++i) {
290-
squared_distance[static_cast<std::size_t>(i)] =
291-
(input.data[i] == 0) ? 0.0 : detail::kInfinity;
292-
}
293-
}
294-
295290
for (std::ptrdiff_t ax = 0; ax < ndim; ++ax) {
296291
BIOIMAGE_PROFILE_SCOPE(profiler, "sweep_axis")
297292
const std::ptrdiff_t line_length = input.shape[static_cast<std::size_t>(ax)];
@@ -317,10 +312,20 @@ inline void distance_transform(
317312
const std::ptrdiff_t base = outer_idx * axis_block + inner_idx;
318313
ws.ensure(line_length, feature_axes_in);
319314

320-
// Gather f along the line.
321-
for (std::ptrdiff_t i = 0; i < line_length; ++i) {
322-
ws.f[static_cast<std::size_t>(i)] =
323-
squared_distance[static_cast<std::size_t>(base + i * stride)];
315+
// The first-axis gather initializes the uninitialized squared-
316+
// distance buffer directly from the input. Later axes gather the
317+
// preceding sweep, avoiding a redundant full-volume init pass.
318+
if (ax == 0) {
319+
for (std::ptrdiff_t i = 0; i < line_length; ++i) {
320+
const auto index = static_cast<std::size_t>(base + i * stride);
321+
ws.f[static_cast<std::size_t>(i)] =
322+
input.data[index] == 0 ? 0.0 : detail::kInfinity;
323+
}
324+
} else {
325+
for (std::ptrdiff_t i = 0; i < line_length; ++i) {
326+
const auto index = static_cast<std::size_t>(base + i * stride);
327+
ws.f[static_cast<std::size_t>(i)] = squared_distance[index];
328+
}
324329
}
325330
// Gather already-tracked feature coords (axes < ax).
326331
for (std::ptrdiff_t a = 0; a < feature_axes_in; ++a) {
@@ -395,9 +400,25 @@ inline void distance_transform(
395400
// per-axis feature buffers, vectors use an incremental coord counter.
396401
if (want_distances) {
397402
BIOIMAGE_PROFILE_SCOPE(profiler, "output_distances")
398-
for (std::ptrdiff_t i = 0; i < n; ++i) {
399-
outputs.distances.data[i] =
400-
static_cast<float>(std::sqrt(squared_distance[static_cast<std::size_t>(i)]));
403+
const auto output_threads = bioimage_cpp::detail::normalize_thread_count(
404+
n_threads, static_cast<std::size_t>(n)
405+
);
406+
const auto write_distances = [&](const std::size_t begin, const std::size_t end) {
407+
for (std::size_t i = begin; i < end; ++i) {
408+
outputs.distances.data[i] =
409+
static_cast<float>(std::sqrt(squared_distance[i]));
410+
}
411+
};
412+
if (output_threads <= 1) {
413+
write_distances(0, static_cast<std::size_t>(n));
414+
} else {
415+
bioimage_cpp::detail::parallel_for_chunks(
416+
output_threads,
417+
static_cast<std::size_t>(n),
418+
[&](const std::size_t, const std::size_t begin, const std::size_t end) {
419+
write_distances(begin, end);
420+
}
421+
);
401422
}
402423
}
403424

0 commit comments

Comments
 (0)