-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathundirected_graph.hxx
More file actions
445 lines (405 loc) · 18 KB
/
Copy pathundirected_graph.hxx
File metadata and controls
445 lines (405 loc) · 18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
#pragma once
#include "bioimage_cpp/detail/edge_hash.hxx"
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <span>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
namespace bioimage_cpp::graph {
struct Adjacency {
std::uint64_t node;
std::uint64_t edge;
};
// Undirected graph storing adjacency in CSR-style (compressed sparse row)
// layout: a single contiguous `adjacency_data_` array of `2 * E` `Adjacency`
// entries, plus an `adjacency_offsets_` array of length `N + 1` giving the
// start of each node's adjacency slice. This replaces the previous
// `std::vector<std::vector<Adjacency>>` representation, which paid for one
// allocator call per node and scattered the per-node buffers throughout the
// heap — the dominant cost in `GridGraph` construction on large 3D problems.
//
// CSR is rebuilt lazily: incremental `insert_edge*` only appends to `edges_`
// (and `edge_lookup_`) and marks the adjacency dirty; the first subsequent
// `node_adjacency` read triggers a single bulk rebuild. Bulk construction
// paths (`from_sorted_unique_edges`, subclass `build_edges` overrides) call
// `rebuild_adjacency_from_edges()` explicitly, which keeps reads cheap and
// thread-safe.
//
// Thread safety: the lazy rebuild is not internally synchronized. If two
// threads each take the first `node_adjacency` read on a still-dirty graph
// they race on the rebuild — concurrently reallocating `adjacency_data_` and
// overwriting `adjacency_offsets_` — which corrupts the CSR (garbage neighbor
// ids, out-of-bounds reads) and intermittently segfaults. The rule:
//
// Any algorithm that reads `node_adjacency` (directly, or via
// `breadth_first_search`, `extract_subgraph_from_nodes`, or a sub-solver
// such as `multicut::greedy_additive`'s `DynamicGraph::reset`) from
// `parallel_for_chunks` or other threads MUST `freeze()` the graph on the
// calling thread *before* the fan-out.
//
// Once frozen (or built via `from_sorted_unique_edges`, which rebuilds the CSR
// eagerly), the graph has no mutable read path and is safe to share by
// `const&` across reader threads. Graphs built incrementally via `insert_edge*`
// (including the `from_edges` binding and `region_adjacency_graph`) start dirty.
class UndirectedGraph {
public:
using NodeId = std::uint64_t;
using EdgeId = std::uint64_t;
using Edge = detail::Edge;
// Non-owning view over a node's adjacency slice in the CSR buffer.
// Backward-compatible with the previous `std::vector<Adjacency>` type
// for the uses we have today (range-for and `.size()`); not assignable
// and not extendable.
using AdjacencyList = std::span<const Adjacency>;
explicit UndirectedGraph(
const NodeId number_of_nodes = 0,
const EdgeId reserve_number_of_edges = 0
)
: UndirectedGraph(number_of_nodes, reserve_number_of_edges, reserve_number_of_edges) {
}
virtual ~UndirectedGraph() = default;
// CSR data lives in a `unique_ptr<Adjacency[]>`, so the type is move-only.
// The user-declared destructor above suppresses implicit move generation,
// so we re-default the moves explicitly. Copies are deleted on purpose:
// the previous vector-of-vectors layout was implicitly copyable but every
// such copy paid for a deep clone of millions of small adjacency vectors,
// and no caller in this codebase actually needs that. Add an explicit
// deep-copy method if one becomes necessary.
UndirectedGraph(const UndirectedGraph &) = delete;
UndirectedGraph &operator=(const UndirectedGraph &) = delete;
UndirectedGraph(UndirectedGraph &&) noexcept = default;
UndirectedGraph &operator=(UndirectedGraph &&) noexcept = default;
void assign(
const NodeId number_of_nodes = 0,
const EdgeId reserve_number_of_edges = 0
) {
number_of_nodes_ = number_of_nodes;
edges_.clear();
edges_.reserve(static_cast<std::size_t>(reserve_number_of_edges));
edge_lookup_.clear();
edge_lookup_.reserve(static_cast<std::size_t>(reserve_number_of_edges));
adjacency_offsets_.clear();
adjacency_data_.reset();
adjacency_data_size_ = 0;
adjacency_dirty_ = true;
}
[[nodiscard]] NodeId number_of_nodes() const {
return number_of_nodes_;
}
[[nodiscard]] EdgeId number_of_edges() const {
return static_cast<EdgeId>(edges_.size());
}
[[nodiscard]] NodeId node_id_upper_bound() const {
return number_of_nodes_ == 0 ? 0 : number_of_nodes_ - 1;
}
[[nodiscard]] EdgeId edge_id_upper_bound() const {
return edges_.empty() ? 0 : static_cast<EdgeId>(edges_.size() - 1);
}
[[nodiscard]] std::vector<NodeId> nodes() const {
std::vector<NodeId> result(static_cast<std::size_t>(number_of_nodes_));
for (NodeId node = 0; node < number_of_nodes_; ++node) {
result[static_cast<std::size_t>(node)] = node;
}
return result;
}
[[nodiscard]] std::vector<EdgeId> edges() const {
std::vector<EdgeId> result(edges_.size());
for (EdgeId edge = 0; edge < edges_.size(); ++edge) {
result[static_cast<std::size_t>(edge)] = edge;
}
return result;
}
[[nodiscard]] Edge uv(const EdgeId edge) const {
validate_edge(edge);
return edges_[static_cast<std::size_t>(edge)];
}
[[nodiscard]] NodeId u(const EdgeId edge) const {
return uv(edge).first;
}
[[nodiscard]] NodeId v(const EdgeId edge) const {
return uv(edge).second;
}
[[nodiscard]] const std::vector<Edge> &uv_ids() const {
return edges_;
}
// Adjacency slice of `node`. The first call on a dirty graph triggers a
// non-thread-safe lazy CSR rebuild (mutable write through this `const`
// method); call `freeze()` on the construction thread before sharing the
// graph with concurrent readers. See the class-level thread-safety note.
[[nodiscard]] AdjacencyList node_adjacency(const NodeId node) const {
validate_node(node);
ensure_adjacency_built();
const auto begin = adjacency_offsets_[static_cast<std::size_t>(node)];
const auto end = adjacency_offsets_[static_cast<std::size_t>(node) + 1];
return AdjacencyList(
adjacency_data_.get() + begin,
static_cast<std::size_t>(end - begin)
);
}
virtual EdgeId insert_edge(const NodeId u, const NodeId v) {
validate_node(u);
validate_node(v);
if (u == v) {
throw std::invalid_argument("self edges are not supported");
}
const auto key = detail::edge_key(u, v);
const auto found = edge_lookup_.find(key);
if (found != edge_lookup_.end()) {
return found->second;
}
return insert_new_edge(key.first, key.second);
}
[[nodiscard]] virtual std::int64_t find_edge(const NodeId u, const NodeId v) const {
validate_node(u);
validate_node(v);
if (u == v) {
return -1;
}
const auto key = detail::edge_key(u, v);
const auto found = edge_lookup_.find(key);
if (found == edge_lookup_.end()) {
return -1;
}
return static_cast<std::int64_t>(found->second);
}
[[nodiscard]] std::uint64_t serialization_size() const {
return 2 + 2 * number_of_edges();
}
// Force the CSR adjacency to be rebuilt if it's currently stale. Use this
// after a batch of `insert_edge*` calls, before handing the graph to
// multiple reader threads or before holding a span returned from
// `node_adjacency` across other graph operations. Lazy rebuild from a
// dirty state happens via `mutable` writes inside a `const` method and is
// not internally synchronized; once a graph is "frozen" via `freeze`
// (or via an explicit `rebuild_adjacency_from_edges` from a subclass
// constructor) it is safe to share by `const&` across threads.
void freeze() const {
ensure_adjacency_built();
}
// Explicit deep copy. The previous vector-of-vectors layout made
// `UndirectedGraph` implicitly copyable; switching to CSR with a
// `unique_ptr` buffer made the class move-only, so callers that need
// a clone use this method. If the source graph's CSR is already built
// we copy the buffer so the clone doesn't pay another rebuild on
// first read.
[[nodiscard]] UndirectedGraph clone() const {
UndirectedGraph copy(
number_of_nodes_,
static_cast<EdgeId>(edges_.size()),
edge_lookup_.empty() ? 0 : static_cast<EdgeId>(edges_.size())
);
copy.edges_ = edges_;
copy.edge_lookup_ = edge_lookup_;
if (adjacency_dirty_) {
copy.adjacency_dirty_ = true;
} else {
copy.adjacency_offsets_ = adjacency_offsets_;
copy.adjacency_data_size_ = adjacency_data_size_;
copy.adjacency_data_ =
std::make_unique_for_overwrite<Adjacency[]>(adjacency_data_size_);
std::copy_n(
adjacency_data_.get(), adjacency_data_size_, copy.adjacency_data_.get()
);
copy.adjacency_dirty_ = false;
}
return copy;
}
// Fast construction from a pre-sorted, deduplicated edge list.
//
// Precondition: `edges` is sorted ascending by `(u, v)` with `u < v` in
// every entry, and contains no duplicates. No node id may equal or exceed
// `number_of_nodes`. The call takes ownership of `edges` and uses it as
// the graph's edge storage, bypassing the per-edge hash dedup that
// `insert_edge` performs — useful when bulk-building a contracted graph
// whose unique edges are already known.
//
// When `populate_lookup` is false, the edge-lookup hash map is left empty
// and `find_edge`/`insert_edge` are not available on the returned graph.
// Used by the fusion-move contraction primitive, whose sub-solver only
// walks edges and adjacency lists.
static UndirectedGraph from_sorted_unique_edges(
const NodeId number_of_nodes,
std::vector<Edge> edges,
const bool populate_lookup = true
) {
const auto n_edges = static_cast<EdgeId>(edges.size());
UndirectedGraph graph(
number_of_nodes,
n_edges,
populate_lookup ? n_edges : 0
);
graph.edges_ = std::move(edges);
graph.rebuild_adjacency_from_edges();
if (populate_lookup) {
for (std::size_t index = 0; index < graph.edges_.size(); ++index) {
graph.edge_lookup_.emplace(
graph.edges_[index], static_cast<EdgeId>(index)
);
}
}
return graph;
}
[[nodiscard]] std::pair<std::vector<EdgeId>, std::vector<EdgeId>>
extract_subgraph_from_nodes(const std::vector<NodeId> &nodes) const {
std::unordered_set<NodeId> node_set;
node_set.reserve(nodes.size());
for (const auto node : nodes) {
validate_node(node);
node_set.insert(node);
}
std::vector<EdgeId> inner_edges;
std::vector<EdgeId> outer_edges;
for (const auto u : nodes) {
for (const auto adjacency : node_adjacency(u)) {
const auto v = adjacency.node;
const auto edge = adjacency.edge;
if (node_set.find(v) != node_set.end()) {
if (u < v) {
inner_edges.push_back(edge);
}
} else {
outer_edges.push_back(edge);
}
}
}
return {inner_edges, outer_edges};
}
protected:
// Internal constructor that lets subclasses opt out of pre-reserving the
// `(u, v) -> edge_id` hash map. Subclasses (e.g. `GridGraph`) that build
// their edges analytically and never populate `edge_lookup_` for their
// intrinsic edges should pass `reserve_lookup = 0` so we don't allocate
// millions of empty hash buckets that are never written to.
UndirectedGraph(
const NodeId number_of_nodes,
const EdgeId reserve_edges,
const EdgeId reserve_lookup
)
: number_of_nodes_(number_of_nodes) {
edges_.reserve(static_cast<std::size_t>(reserve_edges));
edge_lookup_.reserve(static_cast<std::size_t>(reserve_lookup));
}
EdgeId insert_new_edge(const NodeId u, const NodeId v) {
const auto edge = static_cast<EdgeId>(edges_.size());
edges_.emplace_back(u, v);
edge_lookup_.emplace(Edge{u, v}, edge);
adjacency_dirty_ = true;
return edge;
}
EdgeId insert_new_edge_without_lookup(const NodeId u, const NodeId v) {
const auto edge = static_cast<EdgeId>(edges_.size());
edges_.emplace_back(u, v);
adjacency_dirty_ = true;
return edge;
}
// Mutable access to the underlying edge list for subclasses that emit
// edges in bulk. Marks adjacency dirty; the next `node_adjacency` read
// (or an explicit `rebuild_adjacency_from_edges` call) will rebuild
// the CSR buffers.
std::vector<Edge> &access_edges() {
adjacency_dirty_ = true;
return edges_;
}
// Build the CSR adjacency from `edges_`. After this call,
// `adjacency_offsets_` has length `N + 1`, `adjacency_data_` has length
// `2 * E`, and the slice `adjacency_data_[offsets[u]:offsets[u+1]]` is
// exactly the adjacency of node `u`.
//
// Builds in three sequential passes:
// 1. Count degree per node (one pass over `edges_`).
// 2. Prefix-sum into `adjacency_offsets_` (one pass over nodes).
// 3. Place each edge's two adjacency entries via a fill cursor copied
// from the offsets (one pass over `edges_`).
//
// All writes in pass (3) land in a single contiguous buffer, so cache
// behavior is good; there are no per-node allocator calls. This is
// typically 4-5x faster than the previous vector-of-vectors fill on
// large grids.
void rebuild_adjacency_from_edges() {
const auto n_nodes = static_cast<std::size_t>(number_of_nodes_);
const auto data_size = 2 * edges_.size();
// Pass 1: count per-node degree into the offsets buffer (shifted by 1).
adjacency_offsets_.assign(n_nodes + 1, 0);
for (const auto &edge : edges_) {
++adjacency_offsets_[static_cast<std::size_t>(edge.first) + 1];
++adjacency_offsets_[static_cast<std::size_t>(edge.second) + 1];
}
// Pass 2: inclusive prefix sum turns degree-counts into slice starts.
for (std::size_t node = 1; node <= n_nodes; ++node) {
adjacency_offsets_[node] += adjacency_offsets_[node - 1];
}
// Pass 3: allocate the contiguous adjacency buffer.
// `make_unique_for_overwrite` leaves trivially-default-constructible
// `Adjacency` elements UNINITIALIZED — every slot is overwritten in
// the fill pass below, so the zero-init that `std::vector::resize`
// would do is pure overhead. On a 12M-edge grid this skips ~125 ms
// of memset, though the underlying page-fault cost shifts to the
// fill pass; net win is modest but real.
static_assert(std::is_trivially_default_constructible_v<Adjacency>);
adjacency_data_ = std::make_unique_for_overwrite<Adjacency[]>(data_size);
adjacency_data_size_ = data_size;
// Pass 4: `cursor[u]` is the next free slot in node `u`'s adjacency
// range. Initialized from the slice starts and incremented per write.
std::vector<std::uint64_t> cursor(adjacency_offsets_);
Adjacency *const data = adjacency_data_.get();
for (std::size_t index = 0; index < edges_.size(); ++index) {
const auto &edge = edges_[index];
const auto edge_id = static_cast<EdgeId>(index);
data[cursor[static_cast<std::size_t>(edge.first)]++] =
Adjacency{edge.second, edge_id};
data[cursor[static_cast<std::size_t>(edge.second)]++] =
Adjacency{edge.first, edge_id};
}
adjacency_dirty_ = false;
}
void validate_node(const NodeId node) const {
if (node >= number_of_nodes_) {
throw std::out_of_range(
"node id must be < number_of_nodes, got node id=" +
std::to_string(node) + ", number_of_nodes=" +
std::to_string(number_of_nodes_)
);
}
}
void validate_edge(const EdgeId edge) const {
if (edge >= edges_.size()) {
throw std::out_of_range(
"edge id must be < number_of_edges, got edge id=" +
std::to_string(edge) + ", number_of_edges=" +
std::to_string(edges_.size())
);
}
}
private:
void ensure_adjacency_built() const {
if (adjacency_dirty_) {
const_cast<UndirectedGraph *>(this)->rebuild_adjacency_from_edges();
}
}
NodeId number_of_nodes_;
std::vector<Edge> edges_;
// CSR adjacency. `adjacency_offsets_` has length `N + 1`;
// `adjacency_data_` has length `2 * number_of_edges()` after a rebuild.
// The `mutable` qualifiers allow the lazy rebuild from a const reader
// path — see `ensure_adjacency_built`. Writers (insert / bulk-edit
// helpers) set `adjacency_dirty_ = true` and the rebuild happens on the
// next read.
mutable std::vector<std::uint64_t> adjacency_offsets_;
// Heap-allocated CSR data buffer. Using `unique_ptr<T[]>` instead of
// `std::vector<T>` lets `make_unique_for_overwrite` skip the
// zero-initialization that `vector::resize` would force — a substantial
// saving for 12 M-edge graphs where the buffer is ~384 MB and every
// slot is overwritten in the fill pass anyway.
mutable std::unique_ptr<Adjacency[]> adjacency_data_;
mutable std::size_t adjacency_data_size_ = 0;
mutable bool adjacency_dirty_ = true;
std::unordered_map<Edge, EdgeId, detail::EdgeHash> edge_lookup_;
};
} // namespace bioimage_cpp::graph