Skip to content

Commit 796e53a

Browse files
Implement parallelization for fusion movs
1 parent cba92f7 commit 796e53a

8 files changed

Lines changed: 408 additions & 110 deletions

File tree

AGENTS.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,57 @@ Before introducing union-finds, priority queues, edge hashing, stride math, thre
3333

3434
If a needed helper does not exist but is generally useful, add it to `detail/` as a header-only utility with a focused API rather than inlining it into one algorithm. Keep the contract small and well-documented; over time `detail/` is what lets multiple modules stay small and consistent.
3535

36+
## Reusable algorithmic infrastructure
37+
38+
Some larger pieces of infrastructure sit above `detail/` but are still
39+
intended to be reused across objective types. Check these before starting a
40+
new fusion-move / proposal-based / contraction-based solver:
41+
42+
- `include/bioimage_cpp/graph/detail/fusion_contract.hxx` — objective-agnostic
43+
agreement-projection primitive. `contract_by_agreement(graph, proposals,
44+
n_proposals, ...)` returns `{contracted_graph, contracted_edge_of_original,
45+
root_of_node}`. Already supports N ≥ 1 proposals; reused by both pairwise
46+
and joint multi-proposal fuses.
47+
- `include/bioimage_cpp/graph/proposal_generator.hxx``ProposalGeneratorBase`
48+
abstract class. Concrete generators (Watershed, GreedyAdditiveMulticut) live
49+
in `proposal_generators/` and depend only on `(graph, edge_costs)` plus an
50+
RNG seed. They emit `std::vector<std::uint64_t>` node labelings and are
51+
therefore reusable across multicut, lifted multicut, mincut, etc.
52+
- `include/bioimage_cpp/graph/multicut/greedy_additive.hxx` — exposes
53+
`GreedyAdditiveWorkspace` so multiple invocations on different graphs
54+
share scratch buffers. Use this pattern (workspace + `reset(graph)`) when
55+
a fusion-move driver calls a sub-solver inside its iteration loop.
56+
- `UndirectedGraph::from_sorted_unique_edges(N, edges, populate_lookup=false)`
57+
— bulk graph construction without the per-edge hash insertion in
58+
`insert_edge`. Pair with `populate_lookup=false` when the consumer only
59+
walks edges / adjacency (the multicut sub-solvers do).
60+
- `detail/threading.hxx::parallel_for_chunks` — the only threading primitive
61+
we use. New parallel solvers should not introduce alternatives.
62+
63+
When porting fusion moves to a new objective (e.g. lifted multicut):
64+
65+
1. The driver loop in `multicut/fusion_move.hxx::FusionMoveSolver::optimize`
66+
is short and dense. Duplicate it for the new objective rather than
67+
abstracting it via a template/CRTP base — the moving parts (cost
68+
aggregation, energy evaluator, sub-solver type) are objective-specific
69+
and template gymnastics buy little.
70+
2. Reuse `contract_by_agreement` unchanged; it operates on the *base* graph
71+
only.
72+
3. Write a new `fuse_multi(...)` that aggregates *both* base and lifted (or
73+
other auxiliary) weights through `contraction.contracted_edge_of_original`
74+
and `contraction.root_of_node`, calls the new objective's sub-solver, and
75+
lifts labels back via `root_of_node`.
76+
4. Reuse the existing `WatershedProposalGenerator` and
77+
`GreedyAdditiveMulticutProposalGenerator` verbatim; they only depend on
78+
the base graph + base costs and emit node labelings. Add objective-specific
79+
generators (e.g. `GreedyAdditiveLiftedMulticutProposalGenerator`) only if a
80+
meaningful new proposal strategy emerges.
81+
5. Reuse the per-thread parallel pattern from `optimize`: stage-1 parallel
82+
proposal generation + parallel pairwise fuse, stage-2 sequential joint
83+
multi-fuse on leftover candidates. Per-thread `GreedyAdditiveWorkspace`
84+
becomes per-thread `<NewObjective>Workspace` if the new sub-solver follows
85+
the same pattern.
86+
3687
## Dependencies
3788

3889
**Allowed**: C++20 stdlib, `nanobind`, `numpy`, `scikit-build-core`, `cmake`, `pytest`, `cibuildwheel` (CI only).

MIGRATION_GUIDE.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -466,12 +466,19 @@ Intentional differences vs. nifty:
466466
proposal-generator object is built lazily when `optimize` is called.
467467
- The driver warm-starts from the trivial singleton labeling by running the
468468
default greedy-additive sub-solver once before the proposal loop.
469-
- A best-of-three safety net (current, proposal, fused) keeps the running
470-
energy monotonically non-increasing across iterations.
471-
- The current implementation is single-threaded and uses pairwise
472-
(proposal, current) fuses. `number_of_threads` and
473-
`number_of_parallel_proposals` are kept on the API for forward compatibility
474-
but must be left at their defaults (`1` and `2` respectively).
469+
- A best-of safety net keeps the running energy monotonically non-increasing
470+
across iterations (compared against current, proposals, fused, and the
471+
stage-2 joint fuse).
472+
- Parallel proposal generation and a multi-proposal joint fuse are supported:
473+
`number_of_threads=T` runs `number_of_parallel_proposals=P` proposal
474+
generators in parallel within each iteration. By default `P=2` when `T=1`
475+
and `P=T` when `T>1`; pass an explicit `number_of_parallel_proposals` to
476+
override. Each parallel slot uses an independent proposal generator with
477+
seed `proposal_generator.seed + slot_index` so the result is deterministic
478+
for a given `(seed, T, P)`. When at least two parallel pairwise fuses fail
479+
to improve on the current best, a joint multi-proposal fuse runs over the
480+
surviving fused candidates (matches nifty's `ccFusionMoveBased` stage-2
481+
behaviour).
475482

476483
Notes:
477484

development/graph/multicut/check_fusion_move.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,23 @@
77

88
def main() -> None:
99
args = parser(
10-
"Compare bioimage-cpp and nifty fusion-move multicut at default settings."
10+
"Compare bioimage-cpp and nifty fusion-move multicut at matched settings."
1111
).parse_args()
12-
# bioimage-cpp warm-starts from the trivial singleton labeling with a
13-
# greedy-additive pass before the proposal loop. Nifty's ccFusionMoveBased
14-
# exposes the same behaviour via the `warmStartGreedy=True` flag (which
15-
# internally chains a greedyAdditiveFactory in front of the fusion-move
16-
# factory). Both sides therefore enter the proposal loop from the same
17-
# starting point.
12+
# Match settings on both sides:
13+
# - threads / parallel-proposals = `--threads N` (default 1). We set
14+
# P = T explicitly on both sides so the comparison is apples-to-apples
15+
# regardless of either library's API default for P.
16+
# - greedy-additive warm-start (we do it automatically when initial
17+
# labels are the trivial singleton; nifty exposes it as
18+
# `warmStartGreedy=True`).
19+
# - greedy-additive sub-solver.
20+
threads = int(args.threads)
1821
run_comparison(
1922
"fusion_move",
2023
lambda: bic.graph.FusionMoveMulticut(
2124
proposal_generator=bic.graph.WatershedProposalGenerator(),
25+
number_of_threads=threads,
26+
number_of_parallel_proposals=threads,
2227
),
2328
lambda objective: objective.ccFusionMoveBasedFactory(
2429
proposalGenerator=objective.watershedCcProposals(),
@@ -27,7 +32,7 @@ def main() -> None:
2732
),
2833
numberOfIterations=10,
2934
stopIfNoImprovement=4,
30-
numberOfThreads=1,
35+
numberOfThreads=threads,
3136
warmStartGreedy=True,
3237
),
3338
args,

include/bioimage_cpp/detail/profile.hxx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
// and adds no overhead. Use it in development/profile-mode builds to find
66
// hotspots; do not enable it in production wheels.
77

8+
// `NullProfiler` is always available; helper templates can request "no
9+
// profiling here" via the same type regardless of build mode.
10+
namespace bioimage_cpp::detail {
11+
struct NullProfiler {};
12+
} // namespace bioimage_cpp::detail
13+
814
#ifdef BIOIMAGE_PROFILE
915
#include <chrono>
1016
#include <cstdio>
@@ -63,18 +69,31 @@ private:
6369
Profiler::Clock::time_point start_;
6470
};
6571

72+
// Overload that accepts a NullProfiler and does nothing. Lets the same
73+
// macros expand cleanly when a profiled translation unit calls into a helper
74+
// that explicitly does not want to participate in profiling (e.g. work inside
75+
// parallel workers, where we measure wall-clock at the dispatch level).
76+
class ProfileTimerNull {
77+
public:
78+
ProfileTimerNull(NullProfiler &, const char *) {}
79+
};
80+
81+
inline ProfileTimer make_profile_timer(Profiler &profiler, const char *name) {
82+
return ProfileTimer(profiler, name);
83+
}
84+
85+
inline ProfileTimerNull make_profile_timer(NullProfiler &profiler, const char *name) {
86+
return ProfileTimerNull(profiler, name);
87+
}
88+
6689
} // namespace bioimage_cpp::detail
6790

6891
#define BIOIMAGE_PROFILE_INIT(var) ::bioimage_cpp::detail::Profiler var;
69-
#define BIOIMAGE_PROFILE_SCOPE(var, name) ::bioimage_cpp::detail::ProfileTimer _bp_##__LINE__(var, name);
92+
#define BIOIMAGE_PROFILE_SCOPE(var, name) auto _bp_##__LINE__ = ::bioimage_cpp::detail::make_profile_timer(var, name);
7093
#define BIOIMAGE_PROFILE_REPORT(var) (var).report();
7194

7295
#else
7396

74-
namespace bioimage_cpp::detail {
75-
struct NullProfiler {};
76-
} // namespace bioimage_cpp::detail
77-
7897
#define BIOIMAGE_PROFILE_INIT(var) ::bioimage_cpp::detail::NullProfiler var;
7998
#define BIOIMAGE_PROFILE_SCOPE(var, name) (void)var;
8099
#define BIOIMAGE_PROFILE_REPORT(var) (void)var;

0 commit comments

Comments
 (0)