FACES: a person's look-alikes are found on every core, with vector arithmetic

A person's page (and every face confirmed there) compares each unnamed
face with each of the person's named faces on the UI thread, on one core:
92 000 unnamed faces against 4 100 named ones is some 400 million
512-long dot products, about 2 min 40 s on an Apple M1 Pro with the
window frozen.

- matching::cosine sums in eight lanes, which the compiler vectorizes
  (about 4x faster on 512-long embeddings; same result to the last bits).
- matching::person_score: one person's score as rank gives it, with no
  names to group by and nothing allocated (same score, tested).
- similar() scores the candidates with rayon: about 3 s in the same case
  (benchmark at that scale: 163 s before, 3 s after).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tabasko78
2026-10-29 00:02:31 +01:00
co-authored by Claude Opus 5.5
parent 77c0a6adfa
commit e4f8d74da6
3 changed files with 86 additions and 4 deletions
+6 -3
View File
@@ -280,6 +280,7 @@ mod imp {
use lightcraft_faces::runtime::Embedder;
use lightcraft_geom::Rect;
use lightcraft_meta::{Region, RegionKind};
use rayon::prelude::*;
use serde_json::{Value, json};
use super::super::bad;
@@ -731,11 +732,13 @@ mod imp {
return ready;
}
let bar = embedder.manifest().thresholds.match_cosine.unwrap_or(DEFAULT_THRESHOLD) * 0.8;
let refs: Vec<(&str, &[f32])> = gallery.iter().map(|e| ("person", &e[..])).collect();
let refs: Vec<&[f32]> = gallery.iter().map(|e| &e[..]).collect();
// every unnamed face against every face of theirs: some 400 million comparisons for a person with 4 000 faces
// in a library of 90 000, so on every core (minutes on one)
let mut scored: Vec<(f32, u64, usize, Rect)> = candidates
.into_iter()
.into_par_iter()
.filter_map(|(photo, index, rect, e)| {
let score = matching::rank(&e, &refs).first()?.score;
let score = matching::person_score(&e, &refs, matching::TOP_FACES)?;
(score >= bar).then_some((score, photo, index, rect))
})
.collect();
+71 -1
View File
@@ -32,10 +32,24 @@ pub fn cosine(a: &[f32], b: &[f32]) -> Option<f32> {
if a.len() != b.len() || a.is_empty() {
return None;
}
let d: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let d = dot(a, b);
d.is_finite().then_some(d)
}
/// The dot product of two vectors of the same length, summed in eight lanes so the compiler can use the processor's
/// vector instructions (one running sum can't be: each addition waits for the one before). Some four times faster on
/// 512-long embeddings; the result differs from a plain running sum only in the last bits.
fn dot(a: &[f32], b: &[f32]) -> f32 {
let mut lanes = [0f32; 8];
let ((ca, ra), (cb, rb)) = (a.as_chunks::<8>(), b.as_chunks::<8>());
for (x, y) in ca.iter().zip(cb) {
for ((l, x), y) in lanes.iter_mut().zip(x).zip(y) {
*l += x * y;
}
}
lanes.iter().sum::<f32>() + ra.iter().zip(rb).map(|(x, y)| x * y).sum::<f32>()
}
/// Rank the people in `gallery` (a name and one face's embedding per entry; a person appears once per face),
/// best first. Names are compared case-insensitively and shown as first seen.
pub fn rank(query: &[f32], gallery: &[(&str, &[f32])]) -> Vec<Ranked> {
@@ -60,6 +74,31 @@ pub fn rank(query: &[f32], gallery: &[(&str, &[f32])]) -> Vec<Ranked> {
out
}
/// One person's score for `query`, as [`rank`] gives it, when all of `gallery` is that person: the mean of the `top`
/// (1 to 8) faces most like it, or of all of them when they have fewer. `None` when no face compares. Made for scoring
/// many faces against one person: no names to group by and nothing allocated, so it can run on every core.
pub fn person_score(query: &[f32], gallery: &[&[f32]], top: usize) -> Option<f32> {
const MOST: usize = 8;
let top = top.clamp(1, MOST);
// the best similarities so far, highest first
let mut best = [f32::NEG_INFINITY; MOST];
let mut seen = 0usize;
for face in gallery {
let Some(c) = cosine(query, face) else { continue };
seen += 1;
if c > best[top - 1] {
let mut i = top - 1;
while i > 0 && best[i - 1] < c {
best[i] = best[i - 1];
i -= 1;
}
best[i] = c;
}
}
let n = seen.min(top);
(n > 0).then(|| best[..n].iter().sum::<f32>() / n as f32)
}
/// The best person if they pass both bars: a score of at least `threshold`, and at least `margin` ahead of the
/// next person (when there is one).
pub fn suggest(ranked: &[Ranked], threshold: f32, margin: f32) -> Option<Suggestion> {
@@ -95,6 +134,37 @@ mod tests {
assert_eq!(r2[0].name, "Di", "{r2:?}");
}
#[test]
fn a_persons_score_is_the_one_ranking_gives_them() {
// pseudo-random unit vectors, the same on every run
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 40) as f32 / (1u64 << 24) as f32 - 0.5
};
let mut face = || unit(&(0..16).map(|_| next()).collect::<Vec<_>>());
let gallery: Vec<Vec<f32>> = (0..40).map(|_| face()).collect();
let refs: Vec<&[f32]> = gallery.iter().map(Vec::as_slice).collect();
let named: Vec<(&str, &[f32])> = refs.iter().map(|e| ("Ann", *e)).collect();
for _ in 0..50 {
let q = face();
let ranked = rank(&q, &named);
let score = person_score(&q, &refs, TOP_FACES).unwrap();
assert!((score - ranked[0].score).abs() < 1e-6, "{score} against {}", ranked[0].score);
}
// fewer faces than asked, bad embeddings skipped, nothing to compare, and any count
let q = face();
assert!((person_score(&q, &refs[..1], 4).unwrap() - cosine(&q, refs[0]).unwrap()).abs() < 1e-6);
let nan = [f32::NAN; 16];
assert_eq!(person_score(&q, &[&nan[..], refs[0]], 2), person_score(&q, &refs[..1], 2));
assert_eq!(person_score(&q, &[], 2), None);
assert_eq!(person_score(&q, &[&nan[..]], 2), None);
assert_eq!(person_score(&q, &refs, 0), person_score(&q, &refs, 1));
assert_eq!(person_score(&q, &refs, 1000), person_score(&q, &refs, 8));
}
#[test]
fn a_suggestion_needs_a_strong_match_clearly_ahead() {
let r = |name: &str, score: f32| Ranked { name: name.into(), score, faces: 1 };
+9
View File
@@ -1,5 +1,14 @@
# What's new in LightCraft
## 2026-10-29
### A person's page opens in seconds in a large library
- Finding the unnamed faces that look like a person (a person's page, and again after each face confirmed there) now
runs on every processor core, and each comparison of two faces uses the processor's vector instructions. In a
library of 92 000 unnamed faces, a person with 4 100 named ones took about 2 minutes 40 seconds on one core of an
Apple M1 Pro, with the window frozen; it now takes about 3 seconds. Name suggestions and the order of the unnamed
faces get the faster comparison too.
## 2026-10-24
### Relative-date filters and smart albums follow the clock