Skip to content

Commit 440dbf9

Browse files
Optimize greedy additive solver
1 parent 8ba78c9 commit 440dbf9

3 files changed

Lines changed: 136 additions & 29 deletions

File tree

include/bioimage_cpp/graph/lifted_multicut/greedy_additive.hxx

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#pragma once
22

3+
#include "bioimage_cpp/detail/profile.hxx"
34
#include "bioimage_cpp/graph/lifted_multicut/detail.hxx"
45
#include "bioimage_cpp/graph/lifted_multicut/objective.hxx"
56

@@ -40,28 +41,44 @@ inline std::vector<std::uint64_t> greedy_additive(
4041
const double sigma,
4142
GreedyAdditiveWorkspace &workspace
4243
) {
44+
BIOIMAGE_PROFILE_INIT(profile);
4345
validate_weights(lifted_graph, weights);
44-
workspace.reset(lifted_graph);
46+
{
47+
BIOIMAGE_PROFILE_SCOPE(profile, "workspace_reset");
48+
workspace.reset(lifted_graph);
49+
}
4550
auto &dynamic_graph = workspace.dynamic_graph;
4651
auto &sets = workspace.union_find;
4752
auto &heap = workspace.heap;
48-
detail::initialize_dynamic_graph(
49-
lifted_graph, weights, n_base_edges, dynamic_graph, heap, add_noise, seed, sigma
50-
);
53+
{
54+
BIOIMAGE_PROFILE_SCOPE(profile, "initialize");
55+
detail::initialize_dynamic_graph(
56+
lifted_graph, weights, n_base_edges, dynamic_graph, heap, add_noise, seed, sigma
57+
);
58+
}
5159

52-
while (!heap.empty() && dynamic_graph.alive_count > 1) {
53-
const auto top = heap.top();
54-
if (top.priority <= weight_stop) {
55-
break;
56-
}
57-
if (node_num_stop > 0.0
58-
&& dynamic_graph.alive_count <= detail::stop_node_count(lifted_graph, node_num_stop)) {
59-
break;
60+
{
61+
BIOIMAGE_PROFILE_SCOPE(profile, "contraction_loop");
62+
while (!heap.empty() && dynamic_graph.alive_count > 1) {
63+
const auto top = heap.top();
64+
if (top.priority <= weight_stop) {
65+
break;
66+
}
67+
if (node_num_stop > 0.0
68+
&& dynamic_graph.alive_count <= detail::stop_node_count(lifted_graph, node_num_stop)) {
69+
break;
70+
}
71+
const auto &edge = dynamic_graph.edges[top.key];
72+
detail::merge_dynamic_nodes(dynamic_graph, sets, heap, edge.u, edge.v);
6073
}
61-
const auto &edge = dynamic_graph.edges[top.key];
62-
detail::merge_dynamic_nodes(dynamic_graph, sets, heap, edge.u, edge.v);
6374
}
64-
return detail::labels_from_sets(sets, lifted_graph);
75+
std::vector<std::uint64_t> labels;
76+
{
77+
BIOIMAGE_PROFILE_SCOPE(profile, "labels_from_sets");
78+
labels = detail::labels_from_sets(sets, lifted_graph);
79+
}
80+
BIOIMAGE_PROFILE_REPORT(profile);
81+
return labels;
6582
}
6683

6784
inline std::vector<std::uint64_t> greedy_additive(

src/bindings/graph.cxx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,30 @@ Graph graph_from_edges(const std::uint64_t number_of_nodes, ConstUInt64Array uvs
219219
return graph;
220220
}
221221

222+
// Bulk-construct a graph from a pre-deduplicated (u, v) array, bypassing the
223+
// per-edge hash dedup that ``insert_edge`` performs. The caller asserts that
224+
// no (u, v) pair appears twice in ``uvs`` and that ``u != v`` in every row.
225+
// Edges receive ids matching their position in ``uvs``. This is the fast path
226+
// for copying an existing graph (its ``uv_ids()`` are unique by construction).
227+
Graph graph_from_unique_edges(const std::uint64_t number_of_nodes, ConstUInt64Array uvs) {
228+
require_uv_array(uvs, "uvs");
229+
std::vector<Graph::Edge> edges;
230+
edges.reserve(static_cast<std::size_t>(uvs.shape(0)));
231+
const auto *in = uvs.data();
232+
for (std::size_t index = 0; index < uvs.shape(0); ++index) {
233+
const auto u = in[2 * index];
234+
const auto v = in[2 * index + 1];
235+
if (u == v) {
236+
throw std::invalid_argument("self edges are not supported");
237+
}
238+
if (u >= number_of_nodes || v >= number_of_nodes) {
239+
throw std::out_of_range("edge endpoint exceeds number_of_nodes");
240+
}
241+
edges.emplace_back(u, v);
242+
}
243+
return Graph::from_sorted_unique_edges(number_of_nodes, std::move(edges));
244+
}
245+
222246
Graph graph_deserialize(ConstUInt64Array serialization) {
223247
if (serialization.ndim() != 1) {
224248
throw std::invalid_argument("serialization must be a 1D uint64 array");
@@ -855,6 +879,12 @@ void bind_graph(nb::module_ &m) {
855879
nb::arg("number_of_nodes"),
856880
nb::arg("uvs")
857881
)
882+
.def_static(
883+
"from_unique_edges",
884+
&graph_from_unique_edges,
885+
nb::arg("number_of_nodes"),
886+
nb::arg("uvs")
887+
)
858888
.def_static(
859889
"deserialize",
860890
&graph_deserialize,

src/bioimage_cpp/graph/__init__.py

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,17 @@ def _as_serialization_array(serialization) -> np.ndarray:
165165
return np.ascontiguousarray(array)
166166

167167

168-
def _copy_graph(graph: UndirectedGraph | RegionAdjacencyGraph) -> UndirectedGraph:
169-
copied = UndirectedGraph(int(graph.number_of_nodes), int(graph.number_of_edges))
170-
if graph.number_of_edges:
171-
copied.insert_edges(graph.uv_ids())
172-
return copied
168+
def _copy_graph(graph: UndirectedGraph | RegionAdjacencyGraph) -> _core.UndirectedGraph:
169+
# `uv_ids()` always returns a unique list (graphs deduplicate on insert),
170+
# so we can use the bulk constructor that skips per-edge hash dedup —
171+
# significantly faster than `insert_edges` for large graphs. The result
172+
# is a ``_core.UndirectedGraph``; downstream code (objectives, solvers,
173+
# validators) uses base-class methods that work identically.
174+
if graph.number_of_edges == 0:
175+
return _core.UndirectedGraph(int(graph.number_of_nodes))
176+
return _core.UndirectedGraph.from_unique_edges(
177+
int(graph.number_of_nodes), graph.uv_ids()
178+
)
173179

174180

175181
def _as_edge_costs(edge_costs, graph: UndirectedGraph | RegionAdjacencyGraph) -> np.ndarray:
@@ -787,14 +793,22 @@ def __init__(
787793
overwrite_existing: bool = False,
788794
initial_labels=None,
789795
):
790-
base_graph = _copy_graph(graph)
796+
# The objective holds a reference to the user's base graph (no
797+
# defensive copy — the C++ ``Objective`` already keeps a const
798+
# reference). The user is expected to treat the input graph as
799+
# immutable while the objective is alive; mutations are visible to
800+
# the objective and may produce undefined behaviour.
801+
base_graph = graph
791802
base_costs = _as_edge_costs(edge_costs, base_graph)
792803

793-
lifted_graph = UndirectedGraph(
794-
int(base_graph.number_of_nodes), int(base_graph.number_of_edges)
795-
)
804+
# Use the bulk constructor for the lifted graph's base portion to
805+
# bypass the per-edge hash dedup that ``insert_edges`` performs.
796806
if int(base_graph.number_of_edges) > 0:
797-
lifted_graph.insert_edges(base_graph.uv_ids())
807+
lifted_graph = _core.UndirectedGraph.from_unique_edges(
808+
int(base_graph.number_of_nodes), base_graph.uv_ids()
809+
)
810+
else:
811+
lifted_graph = _core.UndirectedGraph(int(base_graph.number_of_nodes))
798812

799813
weights_list = [base_costs.copy()]
800814

@@ -953,6 +967,55 @@ def _add_lifted_edges(
953967
# In-place updates require a single flat working buffer; coalesce first.
954968
if len(weights_list) > 1:
955969
weights_list[:] = [np.ascontiguousarray(np.concatenate(weights_list))]
970+
971+
# Fast path: bulk-insert and detect uniqueness from the row count delta.
972+
# For the typical case — ``lifted_uvs`` produced by
973+
# ``lifted_edges_from_affinities`` or by the BFS constructor — every row
974+
# is a brand-new edge, so the delta equals the input length and we can
975+
# append the weights array directly. No ``find_edges`` calls are needed.
976+
if not overwrite_existing:
977+
pre_count = int(lifted_graph.number_of_edges)
978+
lifted_graph.insert_edges(lifted_uvs)
979+
post_count = int(lifted_graph.number_of_edges)
980+
n_new = post_count - pre_count
981+
982+
if n_new == lifted_uvs.shape[0]:
983+
weights_list.append(
984+
np.ascontiguousarray(lifted_costs.astype(np.float64, copy=False))
985+
)
986+
return
987+
988+
# Some rows collided with existing edges or with each other. Use
989+
# find_edges to recover the per-row edge id (insertion is already
990+
# done; this is just a lookup).
991+
edge_ids = np.asarray(lifted_graph.find_edges(lifted_uvs))
992+
lifted_costs_f64 = lifted_costs.astype(np.float64, copy=False)
993+
working = weights_list[0]
994+
995+
collision_mask = edge_ids < pre_count
996+
if collision_mask.any():
997+
np.add.at(
998+
working,
999+
edge_ids[collision_mask].astype(np.intp, copy=False),
1000+
lifted_costs_f64[collision_mask],
1001+
)
1002+
1003+
new_mask = ~collision_mask
1004+
if new_mask.any():
1005+
slot = (edge_ids[new_mask] - pre_count).astype(np.int64, copy=False)
1006+
new_weights = np.bincount(
1007+
slot, weights=lifted_costs_f64[new_mask], minlength=n_new
1008+
).astype(np.float64, copy=False)
1009+
else:
1010+
new_weights = np.zeros(n_new, dtype=np.float64)
1011+
1012+
weights_list[0] = working
1013+
if n_new > 0:
1014+
weights_list.append(new_weights)
1015+
return
1016+
1017+
# Slow path: per-row Python loop for ``overwrite_existing=True`` (rare).
1018+
# Order-sensitive last-write-wins semantics on collisions.
9561019
working = weights_list[0]
9571020
new_costs: list[float] = []
9581021
for index in range(lifted_uvs.shape[0]):
@@ -964,10 +1027,7 @@ def _add_lifted_edges(
9641027
if int(lifted_graph.number_of_edges) > pre:
9651028
new_costs.append(weight)
9661029
else:
967-
if overwrite_existing:
968-
working[edge] = weight
969-
else:
970-
working[edge] = working[edge] + weight
1030+
working[edge] = weight
9711031
if new_costs:
9721032
weights_list[0] = working
9731033
weights_list.append(np.asarray(new_costs, dtype=np.float64))

0 commit comments

Comments
 (0)