Skip to content

Commit 50b9651

Browse files
Rag optimization (#17)
* Optimize lifted affinity features * More optimization
1 parent 0c49c36 commit 50b9651

4 files changed

Lines changed: 477 additions & 98 deletions

File tree

development/graph/_rag_compatibility.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ def make_watershed_labels(
8585

8686

8787
def time_call(function, repeats: int):
88+
# One untimed warm-up call before the measured loop so the first sample
89+
# doesn't carry nanobind tuple-shape caching, numpy ufunc init, code-page
90+
# faults, etc. Mirrors the grid-affinity helper for consistency.
91+
function()
8892
timings = []
8993
result = None
9094
for _ in range(repeats):
@@ -136,7 +140,9 @@ def compare_boundary_features(
136140
import bioimage_cpp as bic
137141
import nifty.graph.rag as nrag
138142

139-
block_shape = list(labels.shape)
143+
# Don't force blockShape on the nifty side — its default block layout is
144+
# what its parallelism is tuned for, and forcing a single block (== labels
145+
# shape) starves nifty's worker pool.
140146
bic_timings, bic_features = time_call(
141147
lambda: bic.graph.edge_map_features(
142148
bic_rag, labels, boundary_map, number_of_threads=threads
@@ -147,7 +153,6 @@ def compare_boundary_features(
147153
lambda: nrag.accumulateEdgeMeanAndLength(
148154
nifty_rag,
149155
np.ascontiguousarray(boundary_map, dtype=np.float32),
150-
blockShape=block_shape,
151156
numberOfThreads=threads,
152157
),
153158
repeats,
@@ -193,16 +198,20 @@ def compare_affinity_features(
193198
),
194199
repeats,
195200
)
196-
# The installed Nifty wrapper can crash or fail when an explicit
197-
# numberOfThreads is passed here. The default path is still the reference
198-
# implementation and keeps this compatibility script robust.
201+
# nifty's accumulateAffinityStandartFeatures crashes inside vigra's
202+
# UserRangeHistogram when numberOfThreads=1 (the accumulators never get
203+
# setMinMax). For threads >= 2 we can pass the value through; for the
204+
# single-thread case we fall back to nifty's default (-1, all cores) and
205+
# surface the unfairness in the printed report.
206+
nifty_threads_effective = threads if threads != 1 else -1
199207
nifty_timings, nifty_features_full = time_call(
200208
lambda: nrag.accumulateAffinityStandartFeatures(
201209
nifty_rag,
202210
affs_float32,
203211
offsets_for_nifty,
204212
min_val,
205213
max_val,
214+
numberOfThreads=nifty_threads_effective,
206215
),
207216
repeats,
208217
)
@@ -213,6 +222,8 @@ def compare_affinity_features(
213222
np.testing.assert_allclose(aligned_bic, nifty_features, rtol=1.0e-5, atol=1.0e-6)
214223
return bic_timings, nifty_timings, {
215224
"max_abs_diff": float(np.max(np.abs(aligned_bic - nifty_features))),
225+
"nifty_threads_effective": nifty_threads_effective,
226+
"nifty_threads_requested": threads,
216227
}
217228

218229

@@ -290,7 +301,15 @@ def run_compatibility_check(
290301
print(f"boundary-map size convention: {boundary_summary['size_convention']}")
291302
_print_timing("affinity features", affinity_bic_timings, affinity_nifty_timings)
292303
print(f"affinity feature max abs diff: {affinity_summary['max_abs_diff']:.6g}")
293-
print("nifty affinity timing uses the wrapper default thread handling")
304+
requested = affinity_summary["nifty_threads_requested"]
305+
effective = affinity_summary["nifty_threads_effective"]
306+
if requested != effective:
307+
print(
308+
f"WARNING: affinity features — requested {requested} thread(s) but "
309+
f"nifty was called with numberOfThreads={effective} "
310+
"(its single-thread path crashes inside vigra's UserRangeHistogram). "
311+
f"bioimage-cpp used {requested} thread(s); the timing comparison is NOT apples-to-apples."
312+
)
294313

295314

296315
def _print_timing(name: str, bic_timings: list[float], nifty_timings: list[float]):
@@ -303,7 +322,9 @@ def _print_timing(name: str, bic_timings: list[float], nifty_timings: list[float
303322

304323

305324
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
306-
parser.add_argument("--repeats", type=int, default=3)
325+
# 5 matches the grid-affinity helper; median of 3 is noisy because one
326+
# GC stall in the middle sample becomes the median.
327+
parser.add_argument("--repeats", type=int, default=5)
307328
parser.add_argument("--threads", type=int, default=1)
308329
parser.add_argument("--watershed-min-distance", type=int, default=5)
309330
parser.add_argument("--watershed-grid-spacing", type=int, default=12)

include/bioimage_cpp/graph/feature_accumulation.hxx

Lines changed: 211 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#include "bioimage_cpp/array_view.hxx"
44
#include "bioimage_cpp/detail/grid.hxx"
5+
#include "bioimage_cpp/detail/profile.hxx"
56
#include "bioimage_cpp/detail/threading.hxx"
67
#include "bioimage_cpp/graph/region_adjacency_graph.hxx"
78

@@ -204,31 +205,168 @@ void scan_edge_map_3d_chunk(
204205
}
205206
}
206207

208+
// Given an offset along one axis and the axis length, return the half-open
209+
// range of axis coordinates `[lo, hi)` for which `coord + delta` stays in
210+
// `[0, length)`. Returns `lo >= hi` if the offset is larger than the axis.
211+
inline void valid_axis_range(
212+
const std::ptrdiff_t delta,
213+
const std::size_t length,
214+
std::size_t &lo,
215+
std::size_t &hi
216+
) {
217+
if (delta >= 0) {
218+
lo = 0;
219+
const auto d = static_cast<std::size_t>(delta);
220+
hi = (d >= length) ? 0 : (length - d);
221+
} else {
222+
const auto d = static_cast<std::size_t>(-delta);
223+
lo = (d >= length) ? length : d;
224+
hi = length;
225+
}
226+
}
227+
228+
// Sweep every (node, target) pair on a 2D grid for which `node + offset` stays
229+
// in bounds, restricted to the half-open y-slab [y_begin, y_end). The body
230+
// receives flat C-order indices for both endpoints and is expected to inline
231+
// at -O2 since this is a header-only template with a fully-known callable
232+
// type at instantiation.
233+
template <class Body>
234+
void sweep_offset_box_2d(
235+
const std::ptrdiff_t dy,
236+
const std::ptrdiff_t dx,
237+
const std::size_t height,
238+
const std::size_t width,
239+
const std::size_t y_begin,
240+
const std::size_t y_end,
241+
const Body &body
242+
) {
243+
std::size_t y_lo_full, y_hi_full, x_lo, x_hi;
244+
valid_axis_range(dy, height, y_lo_full, y_hi_full);
245+
valid_axis_range(dx, width, x_lo, x_hi);
246+
const auto y_lo = std::max(y_lo_full, y_begin);
247+
const auto y_hi = std::min(y_hi_full, y_end);
248+
if (y_lo >= y_hi || x_lo >= x_hi) {
249+
return;
250+
}
251+
const auto offset_stride = dy * static_cast<std::ptrdiff_t>(width) + dx;
252+
for (std::size_t y = y_lo; y < y_hi; ++y) {
253+
const auto row_offset = y * width;
254+
for (std::size_t x = x_lo; x < x_hi; ++x) {
255+
const auto node = row_offset + x;
256+
const auto target = static_cast<std::uint64_t>(
257+
static_cast<std::ptrdiff_t>(node) + offset_stride
258+
);
259+
body(static_cast<std::uint64_t>(node), target);
260+
}
261+
}
262+
}
263+
264+
// 3D variant of `sweep_offset_box_2d`. Restricts the sweep to a z-slab.
265+
template <class Body>
266+
void sweep_offset_box_3d(
267+
const std::ptrdiff_t dz,
268+
const std::ptrdiff_t dy,
269+
const std::ptrdiff_t dx,
270+
const std::size_t depth,
271+
const std::size_t height,
272+
const std::size_t width,
273+
const std::size_t z_begin,
274+
const std::size_t z_end,
275+
const Body &body
276+
) {
277+
std::size_t z_lo_full, z_hi_full, y_lo, y_hi, x_lo, x_hi;
278+
valid_axis_range(dz, depth, z_lo_full, z_hi_full);
279+
valid_axis_range(dy, height, y_lo, y_hi);
280+
valid_axis_range(dx, width, x_lo, x_hi);
281+
const auto z_lo = std::max(z_lo_full, z_begin);
282+
const auto z_hi = std::min(z_hi_full, z_end);
283+
if (z_lo >= z_hi || y_lo >= y_hi || x_lo >= x_hi) {
284+
return;
285+
}
286+
const auto slice_size = height * width;
287+
const auto offset_stride =
288+
dz * static_cast<std::ptrdiff_t>(slice_size) +
289+
dy * static_cast<std::ptrdiff_t>(width) + dx;
290+
for (std::size_t z = z_lo; z < z_hi; ++z) {
291+
const auto slice_offset = z * slice_size;
292+
for (std::size_t y = y_lo; y < y_hi; ++y) {
293+
const auto row_offset = slice_offset + y * width;
294+
for (std::size_t x = x_lo; x < x_hi; ++x) {
295+
const auto node = row_offset + x;
296+
const auto target = static_cast<std::uint64_t>(
297+
static_cast<std::ptrdiff_t>(node) + offset_stride
298+
);
299+
body(static_cast<std::uint64_t>(node), target);
300+
}
301+
}
302+
}
303+
}
304+
207305
template <class LabelT, class ValueT, class Stats>
208-
void scan_affinity_chunk(
306+
void scan_affinity_2d_chunk(
209307
const RegionAdjacencyGraph &rag,
210308
const LabelT *labels,
211309
const ValueT *affinities,
212310
const std::vector<std::vector<std::ptrdiff_t>> &offsets,
213-
const std::vector<std::ptrdiff_t> &shape,
214-
const std::size_t node_begin,
215-
const std::size_t node_end,
311+
const std::size_t height,
312+
const std::size_t width,
313+
const std::size_t y_begin,
314+
const std::size_t y_end,
216315
std::vector<Stats> &stats
217316
) {
218-
const auto spatial_strides = bioimage_cpp::detail::c_order_strides(shape);
219-
const auto number_of_nodes = static_cast<std::uint64_t>(number_of_pixels(shape));
317+
const auto number_of_nodes = static_cast<std::uint64_t>(height * width);
220318
for (std::size_t channel = 0; channel < offsets.size(); ++channel) {
221-
const auto channel_offset = static_cast<std::uint64_t>(channel) * number_of_nodes;
222-
for (std::uint64_t node = node_begin; node < node_end; ++node) {
223-
std::uint64_t target = 0;
224-
if (!bioimage_cpp::detail::valid_offset_target(node, offsets[channel], shape, spatial_strides, target)) {
225-
continue;
319+
const auto &off = offsets[channel];
320+
const auto channel_offset =
321+
static_cast<std::uint64_t>(channel) * number_of_nodes;
322+
sweep_offset_box_2d(
323+
off[0], off[1], height, width, y_begin, y_end,
324+
[&](const std::uint64_t node, const std::uint64_t target) {
325+
const auto u = label_at(labels, node);
326+
const auto v = label_at(labels, target);
327+
const auto edge = edge_for_labels(rag, u, v);
328+
if (edge >= 0) {
329+
stats[static_cast<std::size_t>(edge)].add(
330+
affinities[channel_offset + node]
331+
);
332+
}
226333
}
227-
const auto edge = edge_for_labels(rag, label_at(labels, node), label_at(labels, target));
228-
if (edge >= 0) {
229-
stats[static_cast<std::size_t>(edge)].add(affinities[channel_offset + node]);
334+
);
335+
}
336+
}
337+
338+
template <class LabelT, class ValueT, class Stats>
339+
void scan_affinity_3d_chunk(
340+
const RegionAdjacencyGraph &rag,
341+
const LabelT *labels,
342+
const ValueT *affinities,
343+
const std::vector<std::vector<std::ptrdiff_t>> &offsets,
344+
const std::size_t depth,
345+
const std::size_t height,
346+
const std::size_t width,
347+
const std::size_t z_begin,
348+
const std::size_t z_end,
349+
std::vector<Stats> &stats
350+
) {
351+
const auto slice_size = height * width;
352+
const auto number_of_nodes = static_cast<std::uint64_t>(depth * slice_size);
353+
for (std::size_t channel = 0; channel < offsets.size(); ++channel) {
354+
const auto &off = offsets[channel];
355+
const auto channel_offset =
356+
static_cast<std::uint64_t>(channel) * number_of_nodes;
357+
sweep_offset_box_3d(
358+
off[0], off[1], off[2], depth, height, width, z_begin, z_end,
359+
[&](const std::uint64_t node, const std::uint64_t target) {
360+
const auto u = label_at(labels, node);
361+
const auto v = label_at(labels, target);
362+
const auto edge = edge_for_labels(rag, u, v);
363+
if (edge >= 0) {
364+
stats[static_cast<std::size_t>(edge)].add(
365+
affinities[channel_offset + node]
366+
);
367+
}
230368
}
231-
}
369+
);
232370
}
233371
}
234372

@@ -408,40 +546,79 @@ void accumulate_affinity_features(
408546
throw std::invalid_argument("out shape must be (number_of_edges, number_of_features)");
409547
}
410548

411-
const auto number_of_nodes = detail_features::number_of_pixels(labels.shape);
412-
const auto n_threads = detail::normalize_thread_count(number_of_threads, number_of_nodes);
549+
const auto work_items = static_cast<std::size_t>(labels.shape[0]);
550+
const auto n_threads = detail::normalize_thread_count(number_of_threads, work_items);
413551
const auto number_of_edges = static_cast<std::size_t>(rag.number_of_edges());
414552

553+
BIOIMAGE_PROFILE_INIT(aff_profiler);
554+
415555
const auto run_scan = [&](auto &per_thread_stats) {
556+
BIOIMAGE_PROFILE_SCOPE(aff_profiler, "aff:scan");
416557
bioimage_cpp::detail::parallel_for_chunks(
417558
n_threads,
418-
number_of_nodes,
559+
work_items,
419560
[&](const std::size_t thread_id, const std::size_t begin, const std::size_t end) {
420-
detail_features::scan_affinity_chunk(
421-
rag, labels.data, affinities.data, offsets, labels.shape,
422-
begin, end, per_thread_stats[thread_id]
423-
);
561+
if (labels.ndim() == 2) {
562+
detail_features::scan_affinity_2d_chunk(
563+
rag, labels.data, affinities.data, offsets,
564+
static_cast<std::size_t>(labels.shape[0]),
565+
static_cast<std::size_t>(labels.shape[1]),
566+
begin, end, per_thread_stats[thread_id]
567+
);
568+
} else {
569+
detail_features::scan_affinity_3d_chunk(
570+
rag, labels.data, affinities.data, offsets,
571+
static_cast<std::size_t>(labels.shape[0]),
572+
static_cast<std::size_t>(labels.shape[1]),
573+
static_cast<std::size_t>(labels.shape[2]),
574+
begin, end, per_thread_stats[thread_id]
575+
);
576+
}
424577
}
425578
);
426579
};
427580

428581
if (compute_complex_features) {
429-
std::vector<std::vector<detail_features::ComplexStats>> per_thread_stats(
430-
n_threads,
431-
std::vector<detail_features::ComplexStats>(number_of_edges)
432-
);
582+
std::vector<std::vector<detail_features::ComplexStats>> per_thread_stats;
583+
{
584+
BIOIMAGE_PROFILE_SCOPE(aff_profiler, "aff:alloc");
585+
per_thread_stats.assign(
586+
n_threads,
587+
std::vector<detail_features::ComplexStats>(number_of_edges)
588+
);
589+
}
433590
run_scan(per_thread_stats);
434-
auto stats = detail_features::merge_stats(per_thread_stats, number_of_edges);
435-
detail_features::write_complex_features(stats, out);
591+
std::vector<detail_features::ComplexStats> stats;
592+
{
593+
BIOIMAGE_PROFILE_SCOPE(aff_profiler, "aff:merge");
594+
stats = detail_features::merge_stats(per_thread_stats, number_of_edges);
595+
}
596+
{
597+
BIOIMAGE_PROFILE_SCOPE(aff_profiler, "aff:write");
598+
detail_features::write_complex_features(stats, out);
599+
}
436600
} else {
437-
std::vector<std::vector<detail_features::SimpleStats>> per_thread_stats(
438-
n_threads,
439-
std::vector<detail_features::SimpleStats>(number_of_edges)
440-
);
601+
std::vector<std::vector<detail_features::SimpleStats>> per_thread_stats;
602+
{
603+
BIOIMAGE_PROFILE_SCOPE(aff_profiler, "aff:alloc");
604+
per_thread_stats.assign(
605+
n_threads,
606+
std::vector<detail_features::SimpleStats>(number_of_edges)
607+
);
608+
}
441609
run_scan(per_thread_stats);
442-
auto stats = detail_features::merge_stats(per_thread_stats, number_of_edges);
443-
detail_features::write_simple_features(stats, out);
610+
std::vector<detail_features::SimpleStats> stats;
611+
{
612+
BIOIMAGE_PROFILE_SCOPE(aff_profiler, "aff:merge");
613+
stats = detail_features::merge_stats(per_thread_stats, number_of_edges);
614+
}
615+
{
616+
BIOIMAGE_PROFILE_SCOPE(aff_profiler, "aff:write");
617+
detail_features::write_simple_features(stats, out);
618+
}
444619
}
620+
621+
BIOIMAGE_PROFILE_REPORT(aff_profiler);
445622
}
446623

447624
} // namespace bioimage_cpp::graph

0 commit comments

Comments
 (0)