Skip to content

Commit 846f6c2

Browse files
Further optimization of the teasar implementation
1 parent 4b4680d commit 846f6c2

6 files changed

Lines changed: 506 additions & 11 deletions

File tree

development/distance/DIJKSTRA_PERFORMANCE_NOTES.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,3 +645,29 @@ so it can be retuned on materially different wheel hardware.
645645
Final validation used a normal editable build and reported `1145 passed`. The
646646
optional array-view lifetime fix was also suite-tested in a separate build with
647647
`-fno-elide-constructors`, specifically removing any dependence on NRVO.
648+
649+
## Real-filament compact TEASAR confirmation
650+
651+
A later TEASAR investigation tested the removed compact delta-stepping adapter
652+
on a real connected filament component with 3,346,043 foreground nodes. This
653+
is well above the former `1 << 20` compact dispatch threshold. The complete
654+
sequential-heap TEASAR call took 21.9 s. An eight-worker delta-stepping call
655+
finished its threaded distance transform in 356 ms but was stopped after more
656+
than 90 s without completing, a greater than 4x end-to-end regression lower
657+
bound.
658+
659+
The real workload therefore reinforces the retained dispatch policy: compact
660+
TEASAR root fields and weighted early-stop rail searches remain on their
661+
specialized sequential heaps. Their one-source wavefronts are too narrow to
662+
amortize staged proposal generation, sorting, merging, and synchronization.
663+
The existing delta-stepping implementation remains appropriate only for broad
664+
multi-source physical fields.
665+
666+
After optimizing TEASAR's separate repeated target-selection scan, root and
667+
rail Dijkstra account for 76.5% of the dominant component's profiled time.
668+
That makes sequential shortest-path work the next performance target, but it
669+
does not change the failed parallel result. Any future compact parallel solver
670+
must use a materially different design and clear the existing exact-output,
671+
small-case regression, and end-to-end speedup gates. The full MRC profile,
672+
size sweep, adaptive-target implementation, and synthetic retention results
673+
are recorded in `development/skeleton/PERFORMANCE_NOTES.md`.

development/skeleton/PERFORMANCE_NOTES.md

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -498,9 +498,10 @@ common rail driver.
498498
The following proposed optimizations were deliberately deferred:
499499

500500
- 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.
501+
target-selection phase was only 0.8 ms on the `256^3` fixture. It was
502+
therefore deferred at this point. The later real-filament MRC follow-up
503+
below showed that this conclusion was workload-specific and implemented an
504+
adaptive ordered selector for large, many-rail components.
504505
- Touched-node or generation-based compact state reset could avoid a V-byte
505506
clear per rail, but all rail-path Dijkstra work is only 26.6 ms in the same
506507
profile. Adding a branch/write to every discovery without first isolating
@@ -511,8 +512,8 @@ The following proposed optimizations were deliberately deferred:
511512
different, and keeping the dense oracle structurally independent helps it
512513
catch compact-specific regressions. Only the identical, policy-free bounds
513514
calculation was shared.
514-
- Candidate-target and state-reset changes are therefore benchmark follow-ups,
515-
not pending correctness fixes.
515+
- State-reset changes remain benchmark follow-ups rather than pending
516+
correctness fixes. Candidate-target selection is revisited below.
516517

517518
## Multi-component and multi-label dispatch
518519

@@ -605,3 +606,133 @@ A profile build on the 27-label packed case at one worker reported:
605606
The normal optimized build was restored after profiling. Skeleton outputs were
606607
exact across worker counts `1, 2, 4, 8`, and the benchmark validates `E = V-C`,
607608
semantic keys, paired binary/label parity, and raw samples before writing JSON.
609+
610+
## Real filament MRC target-selection follow-up
611+
612+
The synthetic branching tubes used above require only a small number of rails,
613+
so their repeated full-domain target scan was negligible. A real binary mask
614+
with many long, closely packed filaments exposed a different scaling regime.
615+
The input `examples/skeleton/00004_gt_mask.mrc` has shape
616+
`324 x 1251 x 1251`, 10,971,478 foreground voxels, and produces 1,293
617+
26-connected skeleton components. With `scale=3.0` and eight workers, the
618+
original full-volume call took 194.737 s.
619+
620+
The real-data size sweep is reproducible with:
621+
622+
```bash
623+
python development/skeleton/benchmark_teasar_mrc.py
624+
python development/skeleton/benchmark_teasar_mrc.py --include-full
625+
python development/skeleton/benchmark_teasar_mrc.py \
626+
--fractions 0.125 0.25 --threads 1 2 4 8 \
627+
--repeats 3 --warmup 1 --json /tmp/teasar_mrc.json
628+
```
629+
630+
The harness memory-maps the MRC, takes centered nested crops, copies each crop
631+
to `uint8` outside the timed region, and reports shape, foreground count,
632+
component count (`V - E`), graph size, raw samples, median, and minimum. It
633+
compares vertices, edges, and radii array-exactly when several worker counts
634+
are requested. The default is one no-warmup measurement at eight workers for
635+
12.5%, 25%, and 50% crops; the full volume is opt-in because it was initially
636+
more than a three-minute call.
637+
638+
### Profile diagnosis
639+
640+
The centered 25% crop has 81 components and 972,194 foreground voxels, but one
641+
component contains 757,982 voxels. On the centered 50% crop, one of 286
642+
components contains 3,346,043 of the 4,356,501 foreground voxels. This
643+
imbalance explains why component-level fan-out alone does not scale well:
644+
when the component count exceeds the thread budget, each component receives a
645+
one-thread local budget and the dominant component determines wall time.
646+
647+
A profile build isolated the dominant 50% component and called the compact
648+
on-the-fly FP64 backend with one worker. The before and after columns use the
649+
same mask, TEASAR parameters, backend, compiler configuration, and worker
650+
count:
651+
652+
| phase | repeated scan | adaptive ordering | change |
653+
| --- | ---: | ---: | ---: |
654+
| input crop / padding | 204.1 ms | 198.9 ms | -2.5% |
655+
| distance transform | 1.652 s | 1.631 s | -1.3% |
656+
| compact domain | 147.4 ms | 149.6 ms | +1.5% |
657+
| DBF compaction | 35.6 ms | 40.5 ms | +13.8% |
658+
| root Dijkstra | 2.808 s | 2.789 s | -0.7% |
659+
| PDRF | 80.2 ms | 80.8 ms | +0.7% |
660+
| target selection | 12.887 s | 628.1 ms | **-95.1%** |
661+
| rail-path Dijkstra | 3.530 s | 3.612 s | +2.3% |
662+
| invalidation | 465.3 ms | 457.3 ms | -1.7% |
663+
| total | 21.810 s | 9.587 s | **-56.0%** |
664+
665+
The unchanged neighboring phases isolate the improvement to target selection.
666+
With eight workers after the change, the same component took 8.651 s: EDT was
667+
427.5 ms, target selection 650.8 ms, root Dijkstra 2.857 s, and rail Dijkstra
668+
3.765 s. Shortest-path work is therefore the new dominant cost, accounting for
669+
76.5% of the profiled total.
670+
671+
### Adaptive target ordering
672+
673+
Every rail previously found its target by scanning all compact nodes and
674+
choosing the farthest active root-field value, an O(P x V) operation for `P`
675+
rails and `V` foreground nodes. The optimized compact backend retains that
676+
linear scan for small and short skeletonizations. Components with at least
677+
65,536 compact nodes switch only after
678+
`max(16, bit_width(number_of_nodes))` rail selections. At that point the
679+
remaining active nodes are sorted once by descending root distance and
680+
ascending compact node ID. Later selections advance through the array and
681+
skip nodes removed by invalidation.
682+
683+
The secondary compact-ID order exactly preserves the original ascending-scan
684+
tie break. The dense FP64 backend remains unchanged as a correctness oracle. A
685+
connected thick-comb fixture forces more than 16 rails above the size crossover
686+
and verifies array-exact dense/compact vertices, edges, and radii.
687+
688+
Single-run eight-worker measurements on the MRC sweep were:
689+
690+
| crop | shape | foreground | components | before | after | change |
691+
| --- | --- | ---: | ---: | ---: | ---: | ---: |
692+
| 12.5% | `40 x 156 x 156` | 152,662 | 23 | 100 ms | 104 ms | +4.0% |
693+
| 25% | `81 x 312 x 312` | 972,194 | 81 | 1.655 s | 1.463 s | -11.6% |
694+
| 50% | `162 x 625 x 625` | 4,356,501 | 286 | 22.271 s | 10.061 s | **-54.8%** |
695+
| full | `324 x 1251 x 1251` | 10,971,478 | 1,293 | 194.737 s | 43.150 s | **-77.8%** |
696+
697+
The full-volume result is a 4.51x speedup. The smallest crop is below the
698+
ordered-selector crossover for every component, so its small difference is
699+
run-to-run noise on an unchanged target-selection path. The full-volume
700+
baseline is the original example measurement; the optimized value and crop
701+
rows were collected through the new harness.
702+
703+
### Parallel Dijkstra decision on this data
704+
705+
The removed compact delta-stepping adapter was rebuilt from the last commit
706+
that contained it and tested on the isolated 3,346,043-voxel component. The
707+
complete sequential-heap call took 21.9 s. With eight workers, delta stepping
708+
finished its EDT in 356 ms but the call was stopped after more than 90 s without
709+
completing. It therefore regressed this workload by more than 4x. These
710+
one-source root fields and early-stopping weighted rail paths have narrow
711+
wavefronts and do not amortize proposal generation, sorting, merging, and
712+
synchronization. Compact root and rail solves remain on the optimized
713+
sequential heap; the thread budget continues to apply to EDT and independent
714+
components.
715+
716+
### Synthetic retention benchmark
717+
718+
The established large binary suite was rerun before and after with one worker,
719+
one warmup, three measured calls, and the deterministic backend-order shuffle:
720+
721+
```bash
722+
python development/skeleton/benchmark_teasar.py \
723+
--large --suite binary --threads 1 --repeats 3 --warmup 1
724+
```
725+
726+
| case | before median | after median | change |
727+
| --- | ---: | ---: | ---: |
728+
| branching tube `128^3` | 34.57 ms | 33.93 ms | -1.8% |
729+
| branching tube `192^3` | 111.27 ms | 107.24 ms | -3.6% |
730+
| branching tube `256^3` | 292.69 ms | 291.82 ms | -0.3% |
731+
| packed binary, 27 components | 53.03 ms | 51.34 ms | -3.2% |
732+
733+
No synthetic case regressed. The `128^3` and `192^3` components stay below
734+
the size crossover, while the `256^3` tube does not generate enough rails to
735+
leave the initial linear mode. Skeleton output remained array-exact across
736+
backends and worker counts. Final verification after the optimization and
737+
benchmark addition: `1182 passed` with third-party pytest plugin autoload
738+
disabled.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import importlib.metadata
5+
import json
6+
import os
7+
from pathlib import Path
8+
import platform
9+
import random
10+
from statistics import median
11+
import sys
12+
from time import perf_counter
13+
14+
import mrcfile
15+
import numpy as np
16+
17+
import bioimage_cpp as bic
18+
19+
20+
def centered_crop(shape: tuple[int, ...], fraction: float):
21+
shape_array = np.asarray(shape, dtype=np.int64)
22+
crop_shape = np.maximum(1, np.floor(shape_array * fraction).astype(np.int64))
23+
begin = (shape_array - crop_shape) // 2
24+
end = begin + crop_shape
25+
slices = tuple(slice(int(lo), int(hi)) for lo, hi in zip(begin, end))
26+
return slices, tuple(int(value) for value in begin)
27+
28+
29+
def package_version(name: str):
30+
try:
31+
return importlib.metadata.version(name)
32+
except importlib.metadata.PackageNotFoundError:
33+
return None
34+
35+
36+
def exact_result(first, second):
37+
return all(np.array_equal(a, b) for a, b in zip(first, second))
38+
39+
40+
def time_threads(mask, threads, scale, repeats, warmup):
41+
results = {}
42+
samples = {thread: [] for thread in threads}
43+
for _ in range(warmup):
44+
for thread in threads:
45+
results[thread] = bic.skeleton.teasar(
46+
mask,
47+
scale=scale,
48+
number_of_threads=thread,
49+
)
50+
rng = random.Random(20260715)
51+
for _ in range(repeats):
52+
order = list(threads)
53+
rng.shuffle(order)
54+
for thread in order:
55+
start = perf_counter()
56+
results[thread] = bic.skeleton.teasar(
57+
mask,
58+
scale=scale,
59+
number_of_threads=thread,
60+
)
61+
samples[thread].append(perf_counter() - start)
62+
reference_thread = threads[0]
63+
for thread in threads[1:]:
64+
if not exact_result(results[reference_thread], results[thread]):
65+
raise RuntimeError(
66+
f"thread count {thread} changed the skeleton compared with "
67+
f"thread count {reference_thread}"
68+
)
69+
return samples, results
70+
71+
72+
def parse_args():
73+
root = Path(__file__).resolve().parents[2]
74+
parser = argparse.ArgumentParser()
75+
parser.add_argument(
76+
"--input",
77+
type=Path,
78+
default=root / "examples" / "skeleton" / "00004_gt_mask.mrc",
79+
)
80+
parser.add_argument(
81+
"--fractions",
82+
type=float,
83+
nargs="+",
84+
default=[0.125, 0.25, 0.5],
85+
)
86+
parser.add_argument("--include-full", action="store_true")
87+
parser.add_argument("--threads", type=int, nargs="+", default=[8])
88+
parser.add_argument("--repeats", type=int, default=1)
89+
parser.add_argument("--warmup", type=int, default=0)
90+
parser.add_argument("--scale", type=float, default=3.0)
91+
parser.add_argument("--json", type=Path)
92+
args = parser.parse_args()
93+
if args.repeats < 1 or args.warmup < 0:
94+
parser.error("--repeats must be >= 1 and --warmup must be >= 0")
95+
if not args.threads or any(thread < 1 for thread in args.threads):
96+
parser.error("--threads must contain positive values")
97+
if any(not 0.0 < fraction <= 1.0 for fraction in args.fractions):
98+
parser.error("--fractions values must be in (0, 1]")
99+
fractions = set(args.fractions)
100+
if args.include_full:
101+
fractions.add(1.0)
102+
args.fractions = sorted(fractions)
103+
args.threads = list(dict.fromkeys(args.threads))
104+
return args
105+
106+
107+
def main() -> int:
108+
args = parse_args()
109+
rows = []
110+
header = (
111+
f"{'fraction':>8} {'threads':>7} {'shape':>18} {'foreground':>11} "
112+
f"{'components':>10} {'vertices':>10} {'median s':>10} {'min s':>10}"
113+
)
114+
print(header)
115+
print("-" * len(header))
116+
with mrcfile.mmap(args.input, mode="r", permissive=True) as mrc:
117+
source = mrc.data
118+
source_shape = tuple(int(value) for value in source.shape)
119+
for fraction in args.fractions:
120+
slices, origin = centered_crop(source_shape, fraction)
121+
mask = np.array(source[slices], dtype=np.uint8, copy=True)
122+
foreground = int(np.count_nonzero(mask))
123+
samples, results = time_threads(
124+
mask,
125+
args.threads,
126+
args.scale,
127+
args.repeats,
128+
args.warmup,
129+
)
130+
for thread in args.threads:
131+
vertices, edges, _ = results[thread]
132+
components = len(vertices) - len(edges)
133+
row = {
134+
"fraction": fraction,
135+
"origin": list(origin),
136+
"shape": list(mask.shape),
137+
"full_voxels": int(mask.size),
138+
"foreground_voxels": foreground,
139+
"number_of_threads": thread,
140+
"components": components,
141+
"vertices": len(vertices),
142+
"edges": len(edges),
143+
"samples_s": samples[thread],
144+
"median_s": median(samples[thread]),
145+
"min_s": min(samples[thread]),
146+
}
147+
rows.append(row)
148+
print(
149+
f"{fraction:8.3f} {thread:7d} {str(mask.shape):>18} "
150+
f"{foreground:11d} {components:10d} {len(vertices):10d} "
151+
f"{row['median_s']:10.3f} {row['min_s']:10.3f}"
152+
)
153+
del results
154+
del mask
155+
if args.json is not None:
156+
payload = {
157+
"environment": {
158+
"python": sys.version,
159+
"platform": platform.platform(),
160+
"cpu_count": os.cpu_count(),
161+
"numpy": np.__version__,
162+
"bioimage_cpp": package_version("bioimage-cpp"),
163+
"mrcfile": package_version("mrcfile"),
164+
},
165+
"input": str(args.input.resolve()),
166+
"source_shape": list(source_shape),
167+
"fractions": args.fractions,
168+
"threads": args.threads,
169+
"repeats": args.repeats,
170+
"warmup": args.warmup,
171+
"scale": args.scale,
172+
"results": rows,
173+
}
174+
args.json.write_text(json.dumps(payload, indent=2), encoding="utf-8")
175+
print(f"wrote {args.json}", file=sys.stderr)
176+
return 0
177+
178+
179+
if __name__ == "__main__":
180+
raise SystemExit(main())

0 commit comments

Comments
 (0)