File each pair under its component once, not once per component

Seeding the merge heaps was 3.06s of a 5.93s regroup on the reference
18,143-face library — more than half the pass, spent before a single
merge was considered.

Every component scanned the whole pair list looking for the pairs that
were its own: 475 components against 804,499 pairs, 382 million set
lookups to place 804,499 of them. A pair can only ever join two faces of
one component, since that is what a component is, so the union-find that
finds the components can file the pairs at the same time and hand each
agglomeration the list it needs.

The membership set inside agglomerate goes with it — it existed only to
run that filter — and the heap can be sized up front now that the pair
count is known.

Ordering is preserved deliberately: pairs are filed in the order they
arrive, which is the global (i, j) order, so the seeded heap breaks its
ties exactly as before and the merge order is unchanged. Same 2,518
groups holding the same 16,246 faces on the reference library, at 3.5s
rather than 5.9s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 11:57:29 +02:00
co-authored by Claude Opus 5
parent e596eb0657
commit b4d39ba33a
+44 -9
View File
@@ -265,8 +265,9 @@ fn build(
pairs: &[neighbours::Pair], pairs: &[neighbours::Pair],
) -> Vec<Cluster> { ) -> Vec<Cluster> {
let mut engine = Engine::new(faces, cal, min_probability); let mut engine = Engine::new(faces, cal, min_probability);
for component in components(faces.len(), pairs) { let parts = components(faces.len(), pairs);
engine.agglomerate(&component, pairs); for (component, edges) in parts.members.iter().zip(&parts.edges) {
engine.agglomerate(component, edges);
} }
engine.finish() engine.finish()
} }
@@ -405,10 +406,9 @@ impl<'a> Engine<'a> {
if component.len() < 2 { if component.len() < 2 {
return; return;
} }
let members: HashSet<usize> = component.iter().copied().collect();
let mut heap = BinaryHeap::new(); let mut heap = BinaryHeap::with_capacity(pairs.len());
for p in pairs.iter().filter(|p| members.contains(&p.i)) { for p in pairs {
self.links.insert( self.links.insert(
key(p.i, p.j), key(p.i, p.j),
Link { Link {
@@ -632,7 +632,7 @@ fn key(a: usize, b: usize) -> (usize, usize) {
/// separate and much smaller agglomeration. Returned with the members of each /// separate and much smaller agglomeration. Returned with the members of each
/// component ascending, and the components themselves in order of their lowest /// component ascending, and the components themselves in order of their lowest
/// member — the determinism the merge order inherits. /// member — the determinism the merge order inherits.
fn components(n: usize, pairs: &[neighbours::Pair]) -> Vec<Vec<usize>> { fn components(n: usize, pairs: &[neighbours::Pair]) -> Components {
let mut parent: Vec<usize> = (0..n).collect(); let mut parent: Vec<usize> = (0..n).collect();
fn find(parent: &mut [usize], mut x: usize) -> usize { fn find(parent: &mut [usize], mut x: usize) -> usize {
@@ -659,9 +659,44 @@ fn components(n: usize, pairs: &[neighbours::Pair]) -> Vec<Vec<usize>> {
let r = find(&mut parent, i); let r = find(&mut parent, i);
by_root.entry(r).or_default().push(i); by_root.entry(r).or_default().push(i);
} }
let mut out: Vec<Vec<usize>> = by_root.into_values().filter(|c| c.len() > 1).collect(); let mut members: Vec<Vec<usize>> = by_root.into_values().filter(|c| c.len() > 1).collect();
out.sort_unstable_by_key(|c| c[0]); members.sort_unstable_by_key(|c| c[0]);
out
// Each pair filed under its component, in one pass.
//
// **This is not bookkeeping, it is the cost of the whole stage.** Each
// component used to scan the entire pair list for the ones that were its
// own: 475 components against 804,499 pairs on the reference library, 382
// million set lookups, and 3.06 s of a 5.93 s regroup spent before a single
// merge was considered. A pair can only ever join two faces of one
// component — that is what a component *is* — so one pass over the list
// places every pair exactly where it is needed.
let mut slot_of = vec![usize::MAX; n];
for (slot, c) in members.iter().enumerate() {
for &m in c {
slot_of[m] = slot;
}
}
let mut edges: Vec<Vec<neighbours::Pair>> = vec![Vec::new(); members.len()];
for p in pairs {
// Ordering within a component follows the global order, which is what
// the seeded heap's tiebreak — and so the determinism promise — rests
// on.
if let Some(slot) = slot_of.get(p.i).copied().filter(|&s| s != usize::MAX) {
edges[slot].push(*p);
}
}
Components { members, edges }
}
/// The connected components of the pair graph, each with the pairs inside it.
///
/// The two halves are parallel: `members[k]` and `edges[k]` describe the same
/// component.
struct Components {
members: Vec<Vec<usize>>,
edges: Vec<Vec<neighbours::Pair>>,
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {