Skip to content

Commit cb5198e

Browse files
More optimization
1 parent 750272d commit cb5198e

6 files changed

Lines changed: 323 additions & 47 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ Important differences:
107107
- Bulk methods accept array-like inputs and return NumPy arrays.
108108
- Python-style names are preferred. A few nifty-style aliases are still present
109109
on `UndirectedGraph` for convenience, but new code should use snake_case.
110+
- `graph.clone()` returns an independent deep copy. The C++ class is
111+
move-only (it owns a CSR adjacency buffer), so prefer this over
112+
reassignment-by-value.
113+
- `graph.freeze()` eagerly builds the internal adjacency. Call it after a
114+
batch of `insert_edge` calls if you intend to hand the graph to multiple
115+
reader threads, or if you want to ensure subsequent `node_adjacency`
116+
reads carry no first-call rebuild cost.
110117

111118
Common method/property mapping:
112119

@@ -155,6 +162,16 @@ Important differences:
155162
`grid_affinity_features_with_lifted(...)`, which returns local graph weights
156163
plus explicit long-range `uv_ids` and weights for lifted multicut or mutex
157164
watershed style workflows.
165+
- The three grid feature functions preserve `float32` and `float64` input
166+
dtype end-to-end (no internal copy to `float64`); other dtypes are
167+
promoted to `float64`. Output weight arrays match the input dtype.
168+
- Grid graph construction does not materialize the per-node adjacency
169+
list. If you only need `uv_ids()` and edge features (the common case)
170+
you pay nothing for adjacency. The first call to `node_adjacency`,
171+
`connected_components`, `breadth_first_search`, or
172+
`extract_subgraph_from_nodes` on a grid graph triggers a one-shot
173+
rebuild; call `graph.freeze()` on the construction thread before
174+
fan-out if you intend to use those from multiple threads.
158175
- Affogato-style masks and seed edges are not part of the public grid feature
159176
API yet; the implementation is structured so these filters/extra edges can
160177
be added later.

include/bioimage_cpp/graph/grid_graph.hxx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -314,10 +314,21 @@ private:
314314
}
315315

316316
void build_edges() {
317-
// Phase 1: emit every edge into `edges_` in canonical order
318-
// (axis-major, C-order within each axis). Sequential `emplace_back`
319-
// into a single pre-reserved vector is cache-friendly. `edges_` was
317+
// Emit every edge into `edges_` in canonical order (axis-major,
318+
// C-order within each axis). Sequential `emplace_back` into a
319+
// single pre-reserved vector is cache-friendly. `edges_` was
320320
// already reserved by the `UndirectedGraph` ctor.
321+
//
322+
// We deliberately do NOT call `rebuild_adjacency_from_edges()`
323+
// here. The dominant grid-graph workflow (build graph → compute
324+
// edge features via `uv_ids()`) never touches adjacency, so the
325+
// ~400 ms rebuild on a 12 M-edge 3D grid is pure waste in that
326+
// case. Callers that need adjacency (BFS, connected components,
327+
// `extract_subgraph_from_nodes`) trigger a lazy rebuild via the
328+
// base-class `node_adjacency` path on first use, paying the cost
329+
// exactly once. Multi-threaded readers should call `freeze()` on
330+
// the construction thread before fan-out — the same convention
331+
// any insert-built `UndirectedGraph` already follows.
321332
auto &edges = access_edges();
322333
for (std::size_t axis = 0; axis < D; ++axis) {
323334
auto pivot_shape = shape_;
@@ -330,10 +341,6 @@ private:
330341
}
331342
);
332343
}
333-
// Phase 2: fill adjacency in bulk — exact-size reserve per node,
334-
// no per-edge geometric-growth reallocs across millions of small
335-
// adjacency vectors. This is the dominant win on large grids.
336-
rebuild_adjacency_from_edges();
337344
}
338345

339346
Coordinate shape_{};

include/bioimage_cpp/graph/undirected_graph.hxx

Lines changed: 164 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
#include <algorithm>
66
#include <cstddef>
77
#include <cstdint>
8+
#include <memory>
9+
#include <span>
810
#include <stdexcept>
911
#include <string>
12+
#include <type_traits>
1013
#include <unordered_map>
1114
#include <unordered_set>
1215
#include <utility>
@@ -19,12 +22,37 @@ struct Adjacency {
1922
std::uint64_t edge;
2023
};
2124

25+
// Undirected graph storing adjacency in CSR-style (compressed sparse row)
26+
// layout: a single contiguous `adjacency_data_` array of `2 * E` `Adjacency`
27+
// entries, plus an `adjacency_offsets_` array of length `N + 1` giving the
28+
// start of each node's adjacency slice. This replaces the previous
29+
// `std::vector<std::vector<Adjacency>>` representation, which paid for one
30+
// allocator call per node and scattered the per-node buffers throughout the
31+
// heap — the dominant cost in `GridGraph` construction on large 3D problems.
32+
//
33+
// CSR is rebuilt lazily: incremental `insert_edge*` only appends to `edges_`
34+
// (and `edge_lookup_`) and marks the adjacency dirty; the first subsequent
35+
// `node_adjacency` read triggers a single bulk rebuild. Bulk construction
36+
// paths (`from_sorted_unique_edges`, subclass `build_edges` overrides) call
37+
// `rebuild_adjacency_from_edges()` explicitly, which keeps reads cheap and
38+
// thread-safe.
39+
//
40+
// Thread safety: as long as a graph is "frozen" before being shared with
41+
// reader threads (no concurrent inserts, no first-read-from-dirty-state
42+
// race), reads of `node_adjacency` are safe to share across threads. The
43+
// lazy rebuild is not internally synchronized — call
44+
// `rebuild_adjacency_from_edges()` once on the construction thread before
45+
// fan-out if you built the graph via `insert_edge*`.
2246
class UndirectedGraph {
2347
public:
2448
using NodeId = std::uint64_t;
2549
using EdgeId = std::uint64_t;
2650
using Edge = detail::Edge;
27-
using AdjacencyList = std::vector<Adjacency>;
51+
// Non-owning view over a node's adjacency slice in the CSR buffer.
52+
// Backward-compatible with the previous `std::vector<Adjacency>` type
53+
// for the uses we have today (range-for and `.size()`); not assignable
54+
// and not extendable.
55+
using AdjacencyList = std::span<const Adjacency>;
2856

2957
explicit UndirectedGraph(
3058
const NodeId number_of_nodes = 0,
@@ -35,17 +63,31 @@ public:
3563

3664
virtual ~UndirectedGraph() = default;
3765

66+
// CSR data lives in a `unique_ptr<Adjacency[]>`, so the type is move-only.
67+
// The user-declared destructor above suppresses implicit move generation,
68+
// so we re-default the moves explicitly. Copies are deleted on purpose:
69+
// the previous vector-of-vectors layout was implicitly copyable but every
70+
// such copy paid for a deep clone of millions of small adjacency vectors,
71+
// and no caller in this codebase actually needs that. Add an explicit
72+
// deep-copy method if one becomes necessary.
73+
UndirectedGraph(const UndirectedGraph &) = delete;
74+
UndirectedGraph &operator=(const UndirectedGraph &) = delete;
75+
UndirectedGraph(UndirectedGraph &&) noexcept = default;
76+
UndirectedGraph &operator=(UndirectedGraph &&) noexcept = default;
77+
3878
void assign(
3979
const NodeId number_of_nodes = 0,
4080
const EdgeId reserve_number_of_edges = 0
4181
) {
4282
number_of_nodes_ = number_of_nodes;
4383
edges_.clear();
44-
edge_lookup_.clear();
45-
adjacency_.clear();
46-
adjacency_.resize(static_cast<std::size_t>(number_of_nodes));
4784
edges_.reserve(static_cast<std::size_t>(reserve_number_of_edges));
85+
edge_lookup_.clear();
4886
edge_lookup_.reserve(static_cast<std::size_t>(reserve_number_of_edges));
87+
adjacency_offsets_.clear();
88+
adjacency_data_.reset();
89+
adjacency_data_size_ = 0;
90+
adjacency_dirty_ = true;
4991
}
5092

5193
[[nodiscard]] NodeId number_of_nodes() const {
@@ -97,9 +139,15 @@ public:
97139
return edges_;
98140
}
99141

100-
[[nodiscard]] const AdjacencyList &node_adjacency(const NodeId node) const {
142+
[[nodiscard]] AdjacencyList node_adjacency(const NodeId node) const {
101143
validate_node(node);
102-
return adjacency_[static_cast<std::size_t>(node)];
144+
ensure_adjacency_built();
145+
const auto begin = adjacency_offsets_[static_cast<std::size_t>(node)];
146+
const auto end = adjacency_offsets_[static_cast<std::size_t>(node) + 1];
147+
return AdjacencyList(
148+
adjacency_data_.get() + begin,
149+
static_cast<std::size_t>(end - begin)
150+
);
103151
}
104152

105153
virtual EdgeId insert_edge(const NodeId u, const NodeId v) {
@@ -136,6 +184,47 @@ public:
136184
return 2 + 2 * number_of_edges();
137185
}
138186

187+
// Force the CSR adjacency to be rebuilt if it's currently stale. Use this
188+
// after a batch of `insert_edge*` calls, before handing the graph to
189+
// multiple reader threads or before holding a span returned from
190+
// `node_adjacency` across other graph operations. Lazy rebuild from a
191+
// dirty state happens via `mutable` writes inside a `const` method and is
192+
// not internally synchronized; once a graph is "frozen" via `freeze`
193+
// (or via an explicit `rebuild_adjacency_from_edges` from a subclass
194+
// constructor) it is safe to share by `const&` across threads.
195+
void freeze() const {
196+
ensure_adjacency_built();
197+
}
198+
199+
// Explicit deep copy. The previous vector-of-vectors layout made
200+
// `UndirectedGraph` implicitly copyable; switching to CSR with a
201+
// `unique_ptr` buffer made the class move-only, so callers that need
202+
// a clone use this method. If the source graph's CSR is already built
203+
// we copy the buffer so the clone doesn't pay another rebuild on
204+
// first read.
205+
[[nodiscard]] UndirectedGraph clone() const {
206+
UndirectedGraph copy(
207+
number_of_nodes_,
208+
static_cast<EdgeId>(edges_.size()),
209+
edge_lookup_.empty() ? 0 : static_cast<EdgeId>(edges_.size())
210+
);
211+
copy.edges_ = edges_;
212+
copy.edge_lookup_ = edge_lookup_;
213+
if (adjacency_dirty_) {
214+
copy.adjacency_dirty_ = true;
215+
} else {
216+
copy.adjacency_offsets_ = adjacency_offsets_;
217+
copy.adjacency_data_size_ = adjacency_data_size_;
218+
copy.adjacency_data_ =
219+
std::make_unique_for_overwrite<Adjacency[]>(adjacency_data_size_);
220+
std::copy_n(
221+
adjacency_data_.get(), adjacency_data_size_, copy.adjacency_data_.get()
222+
);
223+
copy.adjacency_dirty_ = false;
224+
}
225+
return copy;
226+
}
227+
139228
// Fast construction from a pre-sorted, deduplicated edge list.
140229
//
141230
// Precondition: `edges` is sorted ascending by `(u, v)` with `u < v` in
@@ -210,8 +299,7 @@ protected:
210299
const EdgeId reserve_edges,
211300
const EdgeId reserve_lookup
212301
)
213-
: number_of_nodes_(number_of_nodes),
214-
adjacency_(static_cast<std::size_t>(number_of_nodes)) {
302+
: number_of_nodes_(number_of_nodes) {
215303
edges_.reserve(static_cast<std::size_t>(reserve_edges));
216304
edge_lookup_.reserve(static_cast<std::size_t>(reserve_lookup));
217305
}
@@ -220,61 +308,77 @@ protected:
220308
const auto edge = static_cast<EdgeId>(edges_.size());
221309
edges_.emplace_back(u, v);
222310
edge_lookup_.emplace(Edge{u, v}, edge);
223-
adjacency_[static_cast<std::size_t>(u)].push_back(Adjacency{v, edge});
224-
adjacency_[static_cast<std::size_t>(v)].push_back(Adjacency{u, edge});
311+
adjacency_dirty_ = true;
225312
return edge;
226313
}
227314

228315
EdgeId insert_new_edge_without_lookup(const NodeId u, const NodeId v) {
229316
const auto edge = static_cast<EdgeId>(edges_.size());
230317
edges_.emplace_back(u, v);
231-
adjacency_[static_cast<std::size_t>(u)].push_back(Adjacency{v, edge});
232-
adjacency_[static_cast<std::size_t>(v)].push_back(Adjacency{u, edge});
318+
adjacency_dirty_ = true;
233319
return edge;
234320
}
235321

236322
// Mutable access to the underlying edge list for subclasses that emit
237-
// edges in bulk. The caller is responsible for re-establishing the
238-
// invariants between `edges_` and `adjacency_` (see
239-
// `rebuild_adjacency_from_edges`) before exposing the graph to clients.
323+
// edges in bulk. Marks adjacency dirty; the next `node_adjacency` read
324+
// (or an explicit `rebuild_adjacency_from_edges` call) will rebuild
325+
// the CSR buffers.
240326
std::vector<Edge> &access_edges() {
327+
adjacency_dirty_ = true;
241328
return edges_;
242329
}
243330

244-
// Bulk-populate `adjacency_` from `edges_`. Assumes `edges_` already
245-
// contains every edge in the order the subclass wants its `EdgeId`s
246-
// assigned (the index into `edges_` becomes the edge id).
331+
// Build the CSR adjacency from `edges_`. After this call,
332+
// `adjacency_offsets_` has length `N + 1`, `adjacency_data_` has length
333+
// `2 * E`, and the slice `adjacency_data_[offsets[u]:offsets[u+1]]` is
334+
// exactly the adjacency of node `u`.
247335
//
248-
// Computes the exact degree of each node in one pass, reserves that
249-
// capacity per `adjacency_[u]`, then fills in a second pass. With
250-
// exact capacity reserved up front each `adjacency_[u]` gets a single
251-
// allocation of optimal size — no geometric-growth reallocs, no
252-
// fragmentation from intermediate buffers. For grid-shaped topologies
253-
// where one push_back per axis would otherwise hammer many small
254-
// vectors, this is dramatically faster than `insert_new_edge_*`
255-
// accumulating both `edges_` and `adjacency_` in lockstep.
336+
// Builds in three sequential passes:
337+
// 1. Count degree per node (one pass over `edges_`).
338+
// 2. Prefix-sum into `adjacency_offsets_` (one pass over nodes).
339+
// 3. Place each edge's two adjacency entries via a fill cursor copied
340+
// from the offsets (one pass over `edges_`).
256341
//
257-
// Existing contents of `adjacency_` are discarded.
342+
// All writes in pass (3) land in a single contiguous buffer, so cache
343+
// behavior is good; there are no per-node allocator calls. This is
344+
// typically 4-5x faster than the previous vector-of-vectors fill on
345+
// large grids.
258346
void rebuild_adjacency_from_edges() {
259-
std::vector<std::size_t> degree(static_cast<std::size_t>(number_of_nodes_), 0);
347+
const auto n_nodes = static_cast<std::size_t>(number_of_nodes_);
348+
const auto data_size = 2 * edges_.size();
349+
// Pass 1: count per-node degree into the offsets buffer (shifted by 1).
350+
adjacency_offsets_.assign(n_nodes + 1, 0);
260351
for (const auto &edge : edges_) {
261-
++degree[static_cast<std::size_t>(edge.first)];
262-
++degree[static_cast<std::size_t>(edge.second)];
352+
++adjacency_offsets_[static_cast<std::size_t>(edge.first) + 1];
353+
++adjacency_offsets_[static_cast<std::size_t>(edge.second) + 1];
263354
}
264-
for (std::size_t node = 0; node < adjacency_.size(); ++node) {
265-
adjacency_[node].clear();
266-
adjacency_[node].reserve(degree[node]);
355+
// Pass 2: inclusive prefix sum turns degree-counts into slice starts.
356+
for (std::size_t node = 1; node <= n_nodes; ++node) {
357+
adjacency_offsets_[node] += adjacency_offsets_[node - 1];
267358
}
359+
// Pass 3: allocate the contiguous adjacency buffer.
360+
// `make_unique_for_overwrite` leaves trivially-default-constructible
361+
// `Adjacency` elements UNINITIALIZED — every slot is overwritten in
362+
// the fill pass below, so the zero-init that `std::vector::resize`
363+
// would do is pure overhead. On a 12M-edge grid this skips ~125 ms
364+
// of memset, though the underlying page-fault cost shifts to the
365+
// fill pass; net win is modest but real.
366+
static_assert(std::is_trivially_default_constructible_v<Adjacency>);
367+
adjacency_data_ = std::make_unique_for_overwrite<Adjacency[]>(data_size);
368+
adjacency_data_size_ = data_size;
369+
// Pass 4: `cursor[u]` is the next free slot in node `u`'s adjacency
370+
// range. Initialized from the slice starts and incremented per write.
371+
std::vector<std::uint64_t> cursor(adjacency_offsets_);
372+
Adjacency *const data = adjacency_data_.get();
268373
for (std::size_t index = 0; index < edges_.size(); ++index) {
269374
const auto &edge = edges_[index];
270375
const auto edge_id = static_cast<EdgeId>(index);
271-
adjacency_[static_cast<std::size_t>(edge.first)].push_back(
272-
Adjacency{edge.second, edge_id}
273-
);
274-
adjacency_[static_cast<std::size_t>(edge.second)].push_back(
275-
Adjacency{edge.first, edge_id}
276-
);
376+
data[cursor[static_cast<std::size_t>(edge.first)]++] =
377+
Adjacency{edge.second, edge_id};
378+
data[cursor[static_cast<std::size_t>(edge.second)]++] =
379+
Adjacency{edge.first, edge_id};
277380
}
381+
adjacency_dirty_ = false;
278382
}
279383

280384
void validate_node(const NodeId node) const {
@@ -298,9 +402,29 @@ protected:
298402
}
299403

300404
private:
405+
void ensure_adjacency_built() const {
406+
if (adjacency_dirty_) {
407+
const_cast<UndirectedGraph *>(this)->rebuild_adjacency_from_edges();
408+
}
409+
}
410+
301411
NodeId number_of_nodes_;
302412
std::vector<Edge> edges_;
303-
std::vector<AdjacencyList> adjacency_;
413+
// CSR adjacency. `adjacency_offsets_` has length `N + 1`;
414+
// `adjacency_data_` has length `2 * number_of_edges()` after a rebuild.
415+
// The `mutable` qualifiers allow the lazy rebuild from a const reader
416+
// path — see `ensure_adjacency_built`. Writers (insert / bulk-edit
417+
// helpers) set `adjacency_dirty_ = true` and the rebuild happens on the
418+
// next read.
419+
mutable std::vector<std::uint64_t> adjacency_offsets_;
420+
// Heap-allocated CSR data buffer. Using `unique_ptr<T[]>` instead of
421+
// `std::vector<T>` lets `make_unique_for_overwrite` skip the
422+
// zero-initialization that `vector::resize` would force — a substantial
423+
// saving for 12 M-edge graphs where the buffer is ~384 MB and every
424+
// slot is overwritten in the fill pass anyway.
425+
mutable std::unique_ptr<Adjacency[]> adjacency_data_;
426+
mutable std::size_t adjacency_data_size_ = 0;
427+
mutable bool adjacency_dirty_ = true;
304428
std::unordered_map<Edge, EdgeId, detail::EdgeHash> edge_lookup_;
305429
};
306430

src/bindings/graph.cxx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,8 @@ void bind_graph(nb::module_ &m) {
11201120
nb::arg("nodes")
11211121
)
11221122
.def("edges_from_node_list", &graph_edges_from_node_list, nb::arg("nodes"))
1123+
.def("freeze", &Graph::freeze)
1124+
.def("clone", &Graph::clone)
11231125
.def_static(
11241126
"from_edges",
11251127
&graph_from_edges,

0 commit comments

Comments
 (0)