From b4d39ba33a7947dc0a305c5eef2583100abc0a64 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 11:57:29 +0200 Subject: [PATCH] File each pair under its component once, not once per component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/dr-face/src/cluster.rs | 53 ++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/core/dr-face/src/cluster.rs b/core/dr-face/src/cluster.rs index 4a3137f..8f578e3 100644 --- a/core/dr-face/src/cluster.rs +++ b/core/dr-face/src/cluster.rs @@ -265,8 +265,9 @@ fn build( pairs: &[neighbours::Pair], ) -> Vec { let mut engine = Engine::new(faces, cal, min_probability); - for component in components(faces.len(), pairs) { - engine.agglomerate(&component, pairs); + let parts = components(faces.len(), pairs); + for (component, edges) in parts.members.iter().zip(&parts.edges) { + engine.agglomerate(component, edges); } engine.finish() } @@ -405,10 +406,9 @@ impl<'a> Engine<'a> { if component.len() < 2 { return; } - let members: HashSet = component.iter().copied().collect(); - let mut heap = BinaryHeap::new(); - for p in pairs.iter().filter(|p| members.contains(&p.i)) { + let mut heap = BinaryHeap::with_capacity(pairs.len()); + for p in pairs { self.links.insert( key(p.i, p.j), Link { @@ -632,7 +632,7 @@ fn key(a: usize, b: usize) -> (usize, usize) { /// separate and much smaller agglomeration. Returned with the members of each /// component ascending, and the components themselves in order of their lowest /// member — the determinism the merge order inherits. -fn components(n: usize, pairs: &[neighbours::Pair]) -> Vec> { +fn components(n: usize, pairs: &[neighbours::Pair]) -> Components { let mut parent: Vec = (0..n).collect(); fn find(parent: &mut [usize], mut x: usize) -> usize { @@ -659,9 +659,44 @@ fn components(n: usize, pairs: &[neighbours::Pair]) -> Vec> { let r = find(&mut parent, i); by_root.entry(r).or_default().push(i); } - let mut out: Vec> = by_root.into_values().filter(|c| c.len() > 1).collect(); - out.sort_unstable_by_key(|c| c[0]); - out + let mut members: Vec> = by_root.into_values().filter(|c| c.len() > 1).collect(); + members.sort_unstable_by_key(|c| c[0]); + + // 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![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>, + edges: Vec>, } #[cfg(test)] mod tests {