Skip to content

Commit 0f0c540

Browse files
Revert two perf regressions found by A/B benchmarking; trim a third
A/B benchmarks (pre-review build vs the review branch) showed two of the review's changes were net-negative, so they are reverted: I4 (label accumulation): the combined-key (node, other) histogram was 3-15x SLOWER than the per-node map-of-maps and scaled negatively with threads (a giant hashmap probed once per pixel, plus a serial argmax). Restore the per-node map-of-maps with the parallel merge. The detail::number_of_elements dedup (R1) is kept. Tests confirm identical output. B1 (mutex watershed): routing the neighbor through detail::valid_offset_target (per-axis bounds check) cost ~17% on 3D in the hot loop. The public API already guarantees valid_edges excludes out-of-bounds / wrapping edges, so restore the single precomputed offset-stride add and document that precondition instead. B2 (UnionFind bindings): keep the safety bounds check but replace the per-element validation loop with a single std::max_element scan, cutting bulk find overhead from ~1.35x to ~1.1x. The residual cost is the unavoidable extra read of the id array; it only affects the non-hot Python convenience API. BFS (I3) stays 4.5x faster; greedy-additive (I1) unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 67c8c88 commit 0f0c540

4 files changed

Lines changed: 86 additions & 77 deletions

File tree

include/bioimage_cpp/graph/label_accumulation.hxx

Lines changed: 40 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,6 @@ namespace bioimage_cpp::graph {
1717

1818
namespace detail_label_accumulation {
1919

20-
// Combined (node, other) histogram key. A single map per thread keyed by this
21-
// pair avoids the n_threads * n_nodes map-of-maps (one std::unordered_map per
22-
// node per thread), which dominates allocation for RAGs with many nodes.
23-
template <class OtherT>
24-
struct NodeOther {
25-
std::uint64_t node;
26-
OtherT other;
27-
bool operator==(const NodeOther &rhs) const noexcept {
28-
return node == rhs.node && other == rhs.other;
29-
}
30-
};
31-
32-
template <class OtherT>
33-
struct NodeOtherHash {
34-
std::size_t operator()(const NodeOther<OtherT> &key) const noexcept {
35-
std::uint64_t h = key.node * 0x9E3779B97F4A7C15ULL;
36-
h ^= static_cast<std::uint64_t>(key.other) + 0x9E3779B97F4A7C15ULL +
37-
(h << 6) + (h >> 2);
38-
return static_cast<std::size_t>(h);
39-
}
40-
};
41-
42-
template <class OtherT>
43-
using NodeOtherHistogram =
44-
std::unordered_map<NodeOther<OtherT>, std::uint64_t, NodeOtherHash<OtherT>>;
45-
4620
template <class LabelT, class OtherT>
4721
void scan_chunk(
4822
const LabelT *labels,
@@ -51,15 +25,15 @@ void scan_chunk(
5125
const std::size_t pixel_end,
5226
const bool has_ignore_value,
5327
const OtherT ignore_value,
54-
NodeOtherHistogram<OtherT> &histogram
28+
std::vector<std::unordered_map<OtherT, std::uint64_t>> &histograms
5529
) {
5630
for (std::size_t index = pixel_begin; index < pixel_end; ++index) {
5731
const auto other = other_labels[index];
5832
if (has_ignore_value && other == ignore_value) {
5933
continue;
6034
}
6135
const auto node = detail::checked_label_to_node(labels[index]);
62-
++histogram[NodeOther<OtherT>{static_cast<std::uint64_t>(node), other}];
36+
++histograms[static_cast<std::size_t>(node)][other];
6337
}
6438
}
6539

@@ -98,7 +72,10 @@ void accumulate_labels(
9872
const auto n_pixels = bioimage_cpp::detail::number_of_elements(labels.shape);
9973
const auto n_threads = detail::normalize_thread_count(number_of_threads, n_pixels);
10074

101-
std::vector<detail_label_accumulation::NodeOtherHistogram<OtherT>> per_thread(n_threads);
75+
std::vector<std::vector<std::unordered_map<OtherT, std::uint64_t>>> per_thread(
76+
n_threads,
77+
std::vector<std::unordered_map<OtherT, std::uint64_t>>(number_of_nodes)
78+
);
10279

10380
bioimage_cpp::detail::parallel_for_chunks(
10481
n_threads,
@@ -116,35 +93,41 @@ void accumulate_labels(
11693
}
11794
);
11895

119-
// Merge the per-thread histograms into one, then pick the majority `other`
120-
// label per node (smaller label wins ties; nodes with no pixels stay 0).
121-
// The argmax pass is single-threaded over distinct (node, other) pairs,
122-
// which is cheap relative to the parallel pixel scan above.
123-
auto &merged = per_thread[0];
124-
for (std::size_t thread_id = 1; thread_id < per_thread.size(); ++thread_id) {
125-
for (const auto &entry : per_thread[thread_id]) {
126-
merged[entry.first] += entry.second;
127-
}
128-
detail_label_accumulation::NodeOtherHistogram<OtherT>().swap(per_thread[thread_id]);
129-
}
130-
131-
for (std::size_t node = 0; node < number_of_nodes; ++node) {
132-
out.data[node] = OtherT{0};
133-
}
134-
std::vector<std::uint64_t> best_count(number_of_nodes, 0);
135-
std::vector<unsigned char> has_best(number_of_nodes, 0);
136-
for (const auto &entry : merged) {
137-
const auto node = static_cast<std::size_t>(entry.first.node);
138-
const auto other = entry.first.other;
139-
const auto count = entry.second;
140-
if (has_best[node] == 0 ||
141-
count > best_count[node] ||
142-
(count == best_count[node] && other < out.data[node])) {
143-
out.data[node] = other;
144-
best_count[node] = count;
145-
has_best[node] = 1;
96+
// Merge per-thread histograms and pick majority per node in one pass over
97+
// nodes. This is embarrassingly parallel across nodes. (A single combined
98+
// (node, other) hashmap per thread was tried and was 3-15x slower — a giant
99+
// map probed per pixel plus a serial argmax — so the per-node map-of-maps
100+
// with a parallel merge stays.)
101+
const auto node_threads = detail::normalize_thread_count(number_of_threads, number_of_nodes);
102+
bioimage_cpp::detail::parallel_for_chunks(
103+
node_threads,
104+
number_of_nodes,
105+
[&](const std::size_t, const std::size_t node_begin, const std::size_t node_end) {
106+
for (std::size_t node = node_begin; node < node_end; ++node) {
107+
std::unordered_map<OtherT, std::uint64_t> merged;
108+
for (auto &thread_histograms : per_thread) {
109+
auto &node_hist = thread_histograms[node];
110+
for (const auto &entry : node_hist) {
111+
merged[entry.first] += entry.second;
112+
}
113+
node_hist.clear();
114+
}
115+
OtherT best_label = OtherT{0};
116+
std::uint64_t best_count = 0;
117+
bool has_best = false;
118+
for (const auto &entry : merged) {
119+
if (!has_best ||
120+
entry.second > best_count ||
121+
(entry.second == best_count && entry.first < best_label)) {
122+
best_label = entry.first;
123+
best_count = entry.second;
124+
has_best = true;
125+
}
126+
}
127+
out.data[node] = has_best ? best_label : OtherT{0};
128+
}
146129
}
147-
}
130+
);
148131
}
149132

150133
} // namespace bioimage_cpp::graph

include/bioimage_cpp/segmentation/mutex_watershed.hxx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ void mutex_watershed_grid(
7878
));
7979
const auto spatial_strides = detail::c_order_strides(spatial_shape);
8080

81+
// Precondition: `valid_edges` must be 0 for every out-of-bounds / boundary-
82+
// wrapping edge (the Python wrapper guarantees this). Given that, the
83+
// neighbor flat index can be computed with a single precomputed stride add
84+
// per edge instead of a per-axis bounds check, which matters in this hot
85+
// loop (a per-axis valid_offset_target check measured ~17% slower on 3D).
86+
std::vector<std::ptrdiff_t> offset_strides(number_of_channels, 0);
87+
for (std::size_t channel = 0; channel < number_of_channels; ++channel) {
88+
for (std::size_t axis = 0; axis < spatial_ndim; ++axis) {
89+
offset_strides[channel] += offsets[channel][axis] * spatial_strides[axis];
90+
}
91+
}
92+
8193
const auto number_of_edges = number_of_nodes * number_of_channels;
8294
std::vector<WeightedGridEdge<T>> edge_order;
8395
edge_order.reserve(static_cast<std::size_t>(number_of_edges));
@@ -101,13 +113,8 @@ void mutex_watershed_grid(
101113
const auto channel = static_cast<std::size_t>(edge_id / number_of_nodes);
102114
const auto u = edge_id % number_of_nodes;
103115

104-
// Bounds-check the neighbor per axis rather than relying solely on the
105-
// valid_edges mask: this keeps the kernel memory-safe and rejects edges
106-
// that would wrap across a row/plane boundary.
107-
std::uint64_t v = 0;
108-
if (!detail::valid_offset_target(u, offsets[channel], spatial_shape, spatial_strides, v)) {
109-
continue;
110-
}
116+
const auto v_signed = static_cast<std::int64_t>(u) + static_cast<std::int64_t>(offset_strides[channel]);
117+
const auto v = static_cast<std::uint64_t>(v_signed);
111118
std::uint64_t root_u = sets.find(u);
112119
std::uint64_t root_v = sets.find(v);
113120
if (root_u == root_v) {

include/bioimage_cpp/segmentation/semantic_mutex_watershed.hxx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ void semantic_mutex_watershed_grid(
105105
));
106106
const auto spatial_strides = detail::c_order_strides(spatial_shape);
107107

108+
// Precondition: `valid_edges` is 0 for every out-of-bounds / boundary-
109+
// wrapping spatial edge (guaranteed by the Python wrapper), so the neighbor
110+
// flat index uses a single precomputed stride add per edge rather than a
111+
// per-axis bounds check in this hot loop.
112+
std::vector<std::ptrdiff_t> offset_strides(number_of_offsets, 0);
113+
for (std::size_t channel = 0; channel < number_of_offsets; ++channel) {
114+
for (std::size_t axis = 0; axis < spatial_ndim; ++axis) {
115+
offset_strides[channel] += offsets[channel][axis] * spatial_strides[axis];
116+
}
117+
}
118+
108119
struct WeightedGridEdge {
109120
T weight;
110121
std::uint64_t id;
@@ -148,12 +159,9 @@ void semantic_mutex_watershed_grid(
148159
continue;
149160
}
150161

151-
// Bounds-check the neighbor per axis (see mutex_watershed_grid): keeps
152-
// the kernel memory-safe and rejects edges that wrap across a boundary.
153-
std::uint64_t v = 0;
154-
if (!detail::valid_offset_target(u, offsets[channel], spatial_shape, spatial_strides, v)) {
155-
continue;
156-
}
162+
const auto v_signed = static_cast<std::int64_t>(u) +
163+
static_cast<std::int64_t>(offset_strides[channel]);
164+
const auto v = static_cast<std::uint64_t>(v_signed);
157165
const auto root_u = sets.find(u);
158166
const auto root_v = sets.find(v);
159167
if (root_u == root_v) {

src/bindings/util.cxx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
#include <nanobind/ndarray.h>
66

7+
#include <algorithm>
78
#include <cstddef>
89
#include <cstdint>
910
#include <stdexcept>
@@ -30,6 +31,21 @@ void check_node(const util::UnionFind &uf, const std::uint64_t node, const char
3031
}
3132
}
3233

34+
// Bulk variant: a single max-scan over the ids (vectorizable, one pass) instead
35+
// of a per-element branch, so validating a large id array stays cheap.
36+
void check_nodes(const util::UnionFind &uf, const std::uint64_t *data, std::size_t count, const char *name) {
37+
if (count == 0) {
38+
return;
39+
}
40+
const auto max_node = *std::max_element(data, data + count);
41+
if (max_node >= uf.size()) {
42+
throw std::invalid_argument(
43+
std::string(name) + " out of range: got " + name + "="
44+
+ std::to_string(max_node) + ", size=" + std::to_string(uf.size())
45+
);
46+
}
47+
}
48+
3349
void merge_edges(
3450
util::UnionFind &uf,
3551
EdgeArray edges
@@ -51,10 +67,7 @@ void merge_edges(
5167
const auto n_edges = edges.shape(0);
5268
const auto *data = edges.data();
5369

54-
for (std::size_t i = 0; i < n_edges; ++i) {
55-
check_node(uf, data[2 * i], "edge endpoint");
56-
check_node(uf, data[2 * i + 1], "edge endpoint");
57-
}
70+
check_nodes(uf, data, 2 * n_edges, "edge endpoint");
5871

5972
{
6073
nb::gil_scoped_release release;
@@ -67,9 +80,7 @@ void merge_edges(
6780
OutputArray find_nodes(util::UnionFind &uf, NodeArray nodes) {
6881
const auto n = nodes.shape(0);
6982
const auto *input = nodes.data();
70-
for (std::size_t i = 0; i < n; ++i) {
71-
check_node(uf, input[i], "node");
72-
}
83+
check_nodes(uf, input, n, "node");
7384

7485
auto *out = new std::uint64_t[n]();
7586
nb::capsule owner(out, [](void *p) noexcept { delete[] static_cast<std::uint64_t *>(p); });

0 commit comments

Comments
 (0)