Skip to content

Commit 1946cb4

Browse files
Performance: drop redundant work in greedy-additive, BFS, label accumulation
I1: GreedyAdditiveWorkspace::reset called heap.reset_capacity, and so did detail::initialize_dynamic_graph which every caller runs immediately after. The second call re-wiped the heap locator vector (O(E)) on every solve — hot under fusion-move (per fuse x slot x iteration). Drop it from reset() in both the multicut and lifted-multicut workspaces; initialize_dynamic_graph owns it. I3: BfsWorkspace::reset cleared the visited/distance buffers (O(N) over the whole graph) on every call, making the "k-hop neighborhood from every node" pattern O(N^2) of memset. Replace the boolean visited buffer with a uint32 generation stamp: reset just increments the generation (O(1)), clearing only on the rare wraparound. distance_ is sized but written before read. I4: accumulate_labels used an n_threads x n_nodes map-of-maps plus a per-node merge map. Switch to one combined-key (node, other) histogram per thread, merged once, with a single-pass per-node argmax. Far fewer container allocations; the argmax/tie-break/empty-node semantics are unchanged (covered by the existing accumulate_labels tests, incl. parallel-vs-single and nifty cross-checks). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 19a1942 commit 1946cb4

4 files changed

Lines changed: 93 additions & 48 deletions

File tree

include/bioimage_cpp/graph/breadth_first_search.hxx

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

33
#include "bioimage_cpp/graph/undirected_graph.hxx"
44

5+
#include <algorithm>
56
#include <cstddef>
67
#include <cstdint>
78
#include <limits>
@@ -19,23 +20,44 @@ struct BfsEntry {
1920
};
2021

2122
// Reusable scratch state for `breadth_first_search`. Reset via `reset(graph)`
22-
// before each call so the visited-flag and distance buffers grow once and stay
23+
// before each call so the visited and distance buffers grow once and stay
2324
// allocated across many BFS invocations on the same graph.
25+
//
26+
// Visited tracking uses a per-call generation stamp rather than a boolean
27+
// buffer cleared each call: `reset` just increments the generation (O(1)),
28+
// doing a full clear only on the rare 32-bit wraparound. This matters for the
29+
// "k-hop neighborhood from every node" pattern, where an O(N)-per-call clear
30+
// would otherwise make the whole sweep O(N^2). `distance_` is sized but not
31+
// cleared; it is written before being read for every visited node.
2432
class BfsWorkspace {
2533
public:
2634
BfsWorkspace() = default;
2735

2836
void reset(const UndirectedGraph &graph) {
2937
const auto n_nodes = static_cast<std::size_t>(graph.number_of_nodes());
30-
visited_.assign(n_nodes, 0);
31-
distance_.assign(n_nodes, 0);
38+
if (visited_.size() != n_nodes) {
39+
visited_.assign(n_nodes, 0);
40+
generation_ = 0;
41+
}
42+
distance_.resize(n_nodes);
43+
if (generation_ == std::numeric_limits<std::uint32_t>::max()) {
44+
std::fill(visited_.begin(), visited_.end(), std::uint32_t{0});
45+
generation_ = 0;
46+
}
47+
++generation_;
3248
}
3349

34-
[[nodiscard]] std::vector<unsigned char> &visited() { return visited_; }
50+
[[nodiscard]] bool is_visited(const std::uint64_t node) const {
51+
return visited_[static_cast<std::size_t>(node)] == generation_;
52+
}
53+
void mark_visited(const std::uint64_t node) {
54+
visited_[static_cast<std::size_t>(node)] = generation_;
55+
}
3556
[[nodiscard]] std::vector<std::uint64_t> &distance() { return distance_; }
3657

3758
private:
38-
std::vector<unsigned char> visited_;
59+
std::vector<std::uint32_t> visited_;
60+
std::uint32_t generation_ = 0;
3961
std::vector<std::uint64_t> distance_;
4062
};
4163

@@ -71,13 +93,12 @@ inline std::vector<BfsEntry> breadth_first_search(
7193
);
7294
}
7395
workspace.reset(graph);
74-
auto &visited = workspace.visited();
7596
auto &distance = workspace.distance();
7697

7798
std::vector<BfsEntry> result;
7899
std::queue<std::uint64_t> queue;
79100
queue.push(source);
80-
visited[static_cast<std::size_t>(source)] = 1;
101+
workspace.mark_visited(source);
81102
distance[static_cast<std::size_t>(source)] = 0;
82103
if (include_source) {
83104
result.push_back({source, 0});
@@ -93,10 +114,10 @@ inline std::vector<BfsEntry> breadth_first_search(
93114
const auto next_distance = node_distance + 1;
94115
for (const auto adjacency : graph.node_adjacency(node)) {
95116
const auto neighbor = adjacency.node;
96-
if (visited[static_cast<std::size_t>(neighbor)] != 0) {
117+
if (workspace.is_visited(neighbor)) {
97118
continue;
98119
}
99-
visited[static_cast<std::size_t>(neighbor)] = 1;
120+
workspace.mark_visited(neighbor);
100121
distance[static_cast<std::size_t>(neighbor)] = next_distance;
101122
result.push_back({neighbor, next_distance});
102123
queue.push(neighbor);

include/bioimage_cpp/graph/label_accumulation.hxx

Lines changed: 57 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,32 @@ inline std::size_t number_of_pixels(const std::vector<std::ptrdiff_t> &shape) {
2626
));
2727
}
2828

29+
// Combined (node, other) histogram key. A single map per thread keyed by this
30+
// pair avoids the n_threads * n_nodes map-of-maps (one std::unordered_map per
31+
// node per thread), which dominates allocation for RAGs with many nodes.
32+
template <class OtherT>
33+
struct NodeOther {
34+
std::uint64_t node;
35+
OtherT other;
36+
bool operator==(const NodeOther &rhs) const noexcept {
37+
return node == rhs.node && other == rhs.other;
38+
}
39+
};
40+
41+
template <class OtherT>
42+
struct NodeOtherHash {
43+
std::size_t operator()(const NodeOther<OtherT> &key) const noexcept {
44+
std::uint64_t h = key.node * 0x9E3779B97F4A7C15ULL;
45+
h ^= static_cast<std::uint64_t>(key.other) + 0x9E3779B97F4A7C15ULL +
46+
(h << 6) + (h >> 2);
47+
return static_cast<std::size_t>(h);
48+
}
49+
};
50+
51+
template <class OtherT>
52+
using NodeOtherHistogram =
53+
std::unordered_map<NodeOther<OtherT>, std::uint64_t, NodeOtherHash<OtherT>>;
54+
2955
template <class LabelT, class OtherT>
3056
void scan_chunk(
3157
const LabelT *labels,
@@ -34,15 +60,15 @@ void scan_chunk(
3460
const std::size_t pixel_end,
3561
const bool has_ignore_value,
3662
const OtherT ignore_value,
37-
std::vector<std::unordered_map<OtherT, std::uint64_t>> &histograms
63+
NodeOtherHistogram<OtherT> &histogram
3864
) {
3965
for (std::size_t index = pixel_begin; index < pixel_end; ++index) {
4066
const auto other = other_labels[index];
4167
if (has_ignore_value && other == ignore_value) {
4268
continue;
4369
}
4470
const auto node = detail::checked_label_to_node(labels[index]);
45-
++histograms[static_cast<std::size_t>(node)][other];
71+
++histogram[NodeOther<OtherT>{static_cast<std::uint64_t>(node), other}];
4672
}
4773
}
4874

@@ -81,10 +107,7 @@ void accumulate_labels(
81107
const auto n_pixels = detail_label_accumulation::number_of_pixels(labels.shape);
82108
const auto n_threads = detail::normalize_thread_count(number_of_threads, n_pixels);
83109

84-
std::vector<std::vector<std::unordered_map<OtherT, std::uint64_t>>> per_thread(
85-
n_threads,
86-
std::vector<std::unordered_map<OtherT, std::uint64_t>>(number_of_nodes)
87-
);
110+
std::vector<detail_label_accumulation::NodeOtherHistogram<OtherT>> per_thread(n_threads);
88111

89112
bioimage_cpp::detail::parallel_for_chunks(
90113
n_threads,
@@ -102,38 +125,35 @@ void accumulate_labels(
102125
}
103126
);
104127

105-
// Merge per-thread histograms and pick majority per node in one pass over
106-
// nodes. This is embarrassingly parallel across nodes.
107-
const auto node_threads = detail::normalize_thread_count(number_of_threads, number_of_nodes);
108-
bioimage_cpp::detail::parallel_for_chunks(
109-
node_threads,
110-
number_of_nodes,
111-
[&](const std::size_t, const std::size_t node_begin, const std::size_t node_end) {
112-
for (std::size_t node = node_begin; node < node_end; ++node) {
113-
std::unordered_map<OtherT, std::uint64_t> merged;
114-
for (auto &thread_histograms : per_thread) {
115-
auto &node_hist = thread_histograms[node];
116-
for (const auto &entry : node_hist) {
117-
merged[entry.first] += entry.second;
118-
}
119-
node_hist.clear();
120-
}
121-
OtherT best_label = OtherT{0};
122-
std::uint64_t best_count = 0;
123-
bool has_best = false;
124-
for (const auto &entry : merged) {
125-
if (!has_best ||
126-
entry.second > best_count ||
127-
(entry.second == best_count && entry.first < best_label)) {
128-
best_label = entry.first;
129-
best_count = entry.second;
130-
has_best = true;
131-
}
132-
}
133-
out.data[node] = has_best ? best_label : OtherT{0};
134-
}
128+
// Merge the per-thread histograms into one, then pick the majority `other`
129+
// label per node (smaller label wins ties; nodes with no pixels stay 0).
130+
// The argmax pass is single-threaded over distinct (node, other) pairs,
131+
// which is cheap relative to the parallel pixel scan above.
132+
auto &merged = per_thread[0];
133+
for (std::size_t thread_id = 1; thread_id < per_thread.size(); ++thread_id) {
134+
for (const auto &entry : per_thread[thread_id]) {
135+
merged[entry.first] += entry.second;
135136
}
136-
);
137+
detail_label_accumulation::NodeOtherHistogram<OtherT>().swap(per_thread[thread_id]);
138+
}
139+
140+
for (std::size_t node = 0; node < number_of_nodes; ++node) {
141+
out.data[node] = OtherT{0};
142+
}
143+
std::vector<std::uint64_t> best_count(number_of_nodes, 0);
144+
std::vector<unsigned char> has_best(number_of_nodes, 0);
145+
for (const auto &entry : merged) {
146+
const auto node = static_cast<std::size_t>(entry.first.node);
147+
const auto other = entry.first.other;
148+
const auto count = entry.second;
149+
if (has_best[node] == 0 ||
150+
count > best_count[node] ||
151+
(count == best_count[node] && other < out.data[node])) {
152+
out.data[node] = other;
153+
best_count[node] = count;
154+
has_best[node] = 1;
155+
}
156+
}
137157
}
138158

139159
} // namespace bioimage_cpp::graph

include/bioimage_cpp/graph/lifted_multicut/greedy_additive.hxx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ struct GreedyAdditiveWorkspace {
1919
void reset(const UndirectedGraph &lifted_graph) {
2020
dynamic_graph.reset(lifted_graph);
2121
union_find.reset(static_cast<std::size_t>(lifted_graph.number_of_nodes()));
22-
heap.reset_capacity(static_cast<std::size_t>(lifted_graph.number_of_edges()));
22+
// The heap is sized by detail::initialize_dynamic_graph, which every
23+
// caller runs immediately after reset(); resizing it here too would
24+
// wipe the locator vector twice.
2325
}
2426
};
2527

include/bioimage_cpp/graph/multicut/greedy_additive.hxx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ struct GreedyAdditiveWorkspace {
2121
void reset(const UndirectedGraph &graph) {
2222
dynamic_graph.reset(graph);
2323
union_find.reset(static_cast<std::size_t>(graph.number_of_nodes()));
24-
heap.reset_capacity(static_cast<std::size_t>(graph.number_of_edges()));
24+
// The heap is sized by detail::initialize_dynamic_graph, which every
25+
// caller runs immediately after reset(); resizing it here too would
26+
// wipe the locator vector twice.
2527
}
2628
};
2729

0 commit comments

Comments
 (0)