Skip to content

Commit b9dace9

Browse files
Implement parallek dijkstra
1 parent 5aa0a13 commit b9dace9

15 files changed

Lines changed: 1158 additions & 65 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ venv/
2828
# Data
2929
*.h5
3030
*.npz
31+
*.mrc
3132

3233
CLAUDE.md
3334
pypi-release.txt

MIGRATION_GUIDE.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2355,6 +2355,7 @@ field = bic.distance.dijkstra_distance_field(
23552355
sources, # (N, mask.ndim) integer coordinates
23562356
connectivity=None, # full connectivity by default
23572357
spacing=(2.0, 1.0, 1.0),
2358+
number_of_threads=4,
23582359
)
23592360

23602361
# Flat C-order predecessor indices can be requested with the same solve.
@@ -2379,7 +2380,13 @@ All costs must be finite and non-negative. Distances are `float64`; background
23792380
and unreachable voxels are `+inf`. Predecessors are `int64` with the mask shape:
23802381
sources point to their own flat C-order index and background/unreachable voxels
23812382
contain `-1`. Paths are `int64` NumPy-axis-order coordinates from the source to
2382-
the selected target. Ties are deterministic and use flat C-order indices.
2383+
the selected target. Target ties are deterministic and use flat C-order indices.
2384+
`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.
23832390

23842391
This Dijkstra field is deliberately distinct from
23852392
`geodesic_distance_field` below. Dijkstra is exact for the chosen 4/8- or
@@ -2526,6 +2533,7 @@ vertices, edges, radii = bic.skeleton.teasar(
25262533
constant=0,
25272534
pdrf_scale=100000,
25282535
pdrf_exponent=4,
2536+
number_of_threads=4,
25292537
)
25302538
```
25312539

@@ -2548,10 +2556,11 @@ Important differences and current scope:
25482556
cross-section metadata, or postprocessing heuristics. These differences can
25492557
change branch positions and vertex counts, so output is not expected to be
25502558
vertex-for-vertex identical to kimimaro.
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
2559+
- 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
25552564
full-volume shortest-path fields. The public Dijkstra functions remain dense
25562565
and exact FP64.
25572566

development/distance/DIJKSTRA_PERFORMANCE_NOTES.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,3 +542,47 @@ predecessor parity is intentionally relaxed, retain the previously used TEASAR
542542
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.
545+
546+
## Implemented parallel follow-up
547+
548+
Implemented after reviewing the draft above. The implementation scope was
549+
expanded to the dense public Dijkstra fields as well as the compact TEASAR
550+
backend. Both use one deterministic FP64 delta-stepping primitive in
551+
`distance/detail/delta_stepping.hxx`; the optimized heaps remain unchanged.
552+
553+
The concrete design closes several gaps in the draft:
554+
555+
- frontier nodes and proposals are processed in fixed global batches independent
556+
of thread count, with at most 1,048,576 worst-case proposals per batch;
557+
- workers write only per-thread buffers, then the calling thread sorts, reduces,
558+
and applies strict improvements in a deterministic order;
559+
- equal-distance updates do not rewrite existing predecessors, preventing cycles
560+
in zero-weight components;
561+
- the heavy phase uses the unique set removed during the complete light closure;
562+
- early stopping happens only after both phases and after checking that FP64
563+
rounding did not reinsert work into the current bucket;
564+
- generation-marked compact state avoids clearing every rail-search array;
565+
- invalid bucket widths or bucket-index overflow restart on the sequential heap.
566+
567+
The bucket width is the minimum physical neighbor length for physical costs, the
568+
deterministically sampled positive-cost median for node costs, and their product
569+
for node-times-physical costs. All-zero costs use a positive sentinel width and
570+
remain entirely in the light closure.
571+
572+
Initial measurements on the four-core development machine also rejected the
573+
assumption that every large one-source solve benefits from delta stepping.
574+
Representative one-shot default-tier dense physical fields took about 0.38 s on
575+
the heap versus 0.64 s with four-thread staged relaxation, while a full source
576+
plane on the same `64 x 160 x 160` domain improved from about 0.72 s to 0.30 s.
577+
The directed-node heap remained decisively faster. TEASAR's compact wavefronts
578+
were likewise too narrow through the `256^3` tier, although threading its EDT
579+
still improved end-to-end time.
580+
581+
The production dispatch therefore follows the benchmark rather than forcing the
582+
new backend: one-thread calls, paths, directed-node costs, small fields, and
583+
narrow multi-source fields retain the heap. Broad multi-source physical and
584+
node-times-physical fields use delta stepping. Compact TEASAR keeps heap Dijkstra
585+
through the measured tiers while sharing the requested thread budget with EDT;
586+
the delta backend remains available for much larger compact domains. The
587+
development benchmarks now accept thread matrices so these thresholds can be
588+
revisited with evidence on other machines.

development/distance/benchmark_dijkstra.py

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def time_call(function, repeats: int, warmup: int) -> list[float]:
6464
return samples
6565

6666

67-
def build_cases(shape: tuple[int, ...], include_geodesic: bool):
67+
def build_cases(shape: tuple[int, ...], include_geodesic: bool, number_of_threads: int):
6868
mask = make_mask(shape)
6969
source = np.full(len(shape), 2, dtype=np.int64)
7070
target = np.asarray(shape, dtype=np.int64) - 3
@@ -79,25 +79,48 @@ def build_cases(shape: tuple[int, ...], include_geodesic: bool):
7979
(
8080
"dijkstra/physical-field",
8181
lambda: bic.distance.dijkstra_distance_field(
82-
mask, source, spacing=spacing
82+
mask, source, spacing=spacing, number_of_threads=number_of_threads
8383
),
8484
),
8585
(
8686
"dijkstra/field+parents",
8787
lambda: bic.distance.dijkstra_distance_field(
88-
mask, source, spacing=spacing, return_predecessors=True
88+
mask,
89+
source,
90+
spacing=spacing,
91+
return_predecessors=True,
92+
number_of_threads=number_of_threads,
8993
),
9094
),
9195
(
9296
"dijkstra/node-field",
9397
lambda: bic.distance.dijkstra_distance_field(
94-
mask, source, costs=costs, cost_mode="node"
98+
mask,
99+
source,
100+
costs=costs,
101+
cost_mode="node",
102+
number_of_threads=number_of_threads,
103+
),
104+
),
105+
(
106+
"dijkstra/node-times-physical-field",
107+
lambda: bic.distance.dijkstra_distance_field(
108+
mask,
109+
source,
110+
costs=costs,
111+
cost_mode="node_times_physical",
112+
spacing=spacing,
113+
number_of_threads=number_of_threads,
95114
),
96115
),
97116
(
98117
"dijkstra/early-path",
99118
lambda: bic.distance.dijkstra_path(
100-
mask, source, target, spacing=spacing
119+
mask,
120+
source,
121+
target,
122+
spacing=spacing,
123+
number_of_threads=number_of_threads,
101124
),
102125
),
103126
]
@@ -106,7 +129,10 @@ def build_cases(shape: tuple[int, ...], include_geodesic: bool):
106129
(
107130
"geodesic/FMM-field",
108131
lambda: bic.distance.geodesic_distance_field(
109-
mask, source, sampling=spacing, number_of_threads=1
132+
mask,
133+
source,
134+
sampling=spacing,
135+
number_of_threads=number_of_threads,
110136
),
111137
)
112138
)
@@ -121,6 +147,10 @@ def main() -> int:
121147
parser.add_argument("--repeats", type=int, default=7)
122148
parser.add_argument("--warmup", type=int, default=1)
123149
parser.add_argument("--include-geodesic", action="store_true")
150+
parser.add_argument(
151+
"--threads", type=int, nargs="+", default=[1],
152+
help="thread counts to benchmark (0 uses hardware concurrency)",
153+
)
124154
parser.add_argument("--json", default="", help="optional JSON result path")
125155
args = parser.parse_args()
126156
if args.repeats < 1 or args.warmup < 0:
@@ -134,29 +164,34 @@ def main() -> int:
134164
shapes = ((1024, 1024), (64, 160, 160))
135165

136166
rows = []
137-
header = f"{'case':>29} {'shape':>16} {'median ms':>11} {'min ms':>10} {'ns/fg voxel':>13}"
167+
header = f"{'case':>36} {'threads':>7} {'shape':>16} {'median ms':>11} {'min ms':>10} {'ns/fg voxel':>13}"
138168
print(header)
139169
print("-" * len(header))
140170
for shape in shapes:
141-
mask, cases = build_cases(shape, args.include_geodesic)
142-
foreground = int(np.count_nonzero(mask))
143-
for name, function in cases:
144-
samples = time_call(function, args.repeats, args.warmup)
145-
median_s = median(samples)
146-
row = {
147-
"case": name,
148-
"shape": list(shape),
149-
"foreground_voxels": foreground,
150-
"samples_s": samples,
151-
"median_s": median_s,
152-
"min_s": min(samples),
153-
"ns_per_foreground_voxel": median_s * 1e9 / foreground,
154-
}
155-
rows.append(row)
156-
print(
157-
f"{name:>29} {str(shape):>16} {median_s * 1e3:11.2f} "
158-
f"{min(samples) * 1e3:10.2f} {row['ns_per_foreground_voxel']:13.1f}"
171+
for number_of_threads in args.threads:
172+
mask, cases = build_cases(
173+
shape, args.include_geodesic, number_of_threads
159174
)
175+
foreground = int(np.count_nonzero(mask))
176+
for name, function in cases:
177+
samples = time_call(function, args.repeats, args.warmup)
178+
median_s = median(samples)
179+
row = {
180+
"case": name,
181+
"number_of_threads": number_of_threads,
182+
"shape": list(shape),
183+
"foreground_voxels": foreground,
184+
"samples_s": samples,
185+
"median_s": median_s,
186+
"min_s": min(samples),
187+
"ns_per_foreground_voxel": median_s * 1e9 / foreground,
188+
}
189+
rows.append(row)
190+
print(
191+
f"{name:>36} {number_of_threads:7d} {str(shape):>16} "
192+
f"{median_s * 1e3:11.2f} {min(samples) * 1e3:10.2f} "
193+
f"{row['ns_per_foreground_voxel']:13.1f}"
194+
)
160195

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

development/skeleton/benchmark_teasar.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def kimimaro_call(mask, spacing, scale, constant, pdrf_scale, pdrf_exponent):
151151
return skeletons[1]
152152

153153

154-
def bic_backend_call(mask, spacing, parameters, backend):
154+
def bic_backend_call(mask, spacing, parameters, backend, number_of_threads=1):
155155
"""Call a development-only C++ backend without changing the public API."""
156156
return _core._teasar_uint8_backend(
157157
mask,
@@ -161,6 +161,7 @@ def bic_backend_call(mask, spacing, parameters, backend):
161161
parameters["pdrf_scale"],
162162
parameters["pdrf_exponent"],
163163
backend,
164+
number_of_threads,
164165
)
165166

166167

@@ -178,6 +179,10 @@ def main() -> int:
178179
help="compare dense FP64, compact on-the-fly/CSR FP64, and CSR FP32",
179180
)
180181
parser.add_argument("--json", default="", help="optional JSON result path")
182+
parser.add_argument(
183+
"--threads", type=int, nargs="+", default=[1],
184+
help="public TEASAR thread counts (0 uses hardware concurrency)",
185+
)
181186
args = parser.parse_args()
182187
if args.repeats < 1 or args.warmup < 0:
183188
parser.error("--repeats must be >= 1 and --warmup must be >= 0")
@@ -218,10 +223,16 @@ def main() -> int:
218223
else:
219224
backends = [
220225
(
221-
"bioimage-cpp",
222-
lambda mask: bic.skeleton.teasar(mask, spacing=spacing, **parameters),
226+
f"bioimage-cpp/t{number_of_threads}",
227+
lambda mask, number_of_threads=number_of_threads: bic.skeleton.teasar(
228+
mask,
229+
spacing=spacing,
230+
number_of_threads=number_of_threads,
231+
**parameters,
232+
),
223233
count_bic,
224234
)
235+
for number_of_threads in args.threads
225236
]
226237
if args.kimimaro:
227238
backends.append(
@@ -293,12 +304,12 @@ def main() -> int:
293304
f"hausdorff={quality['hausdorff_voxels']:.3f} vox "
294305
f"length_error={quality['relative_length_error']:.3%}"
295306
)
296-
if len(case_rows) == 2 and {row["backend"] for row in case_rows} == {
297-
"bioimage-cpp", "kimimaro"
298-
}:
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"}:
299310
by_name = {row["backend"]: row for row in case_rows}
300311
ratio = (
301-
by_name["bioimage-cpp"]["median_s"]
312+
by_name[f"bioimage-cpp/t{args.threads[0]}"]["median_s"]
302313
/ by_name["kimimaro"]["median_s"]
303314
)
304315
print(f"{'bioimage-cpp / kimimaro':>54}: {ratio:.2f}x")

0 commit comments

Comments
 (0)