Match synced faces from an index of their boxes, not from their rows

`merge::match_faces` reads every local face's box and model to pair the
other device's faces with ours. It took 54 ms of a steady-state merge on the
reference library (19,000 faces).

A `faces` row is eight kilobytes -- the embedding, the crop, the dense
landmarks -- and `model_id` sits past the embedding, so reading it opened
each row's overflow pages:

  SCAN f
  SEARCH r USING INTEGER PRIMARY KEY (rowid=?)

`faces_box (image_id, model_id, x, y, w, h)` holds every column the scan
asks for:

  SCAN f USING COVERING INDEX faces_box
  SEARCH r USING INTEGER PRIMARY KEY (rowid=?)

The local scan went from 38 ms to 8 ms (sqlite3 on a copy, aggregated so
output formatting is not timed), and `match_faces` from 54 ms to 30-37 ms;
what remains is the other device's half. That is read from its snapshot,
which has whatever indexes its build made -- this one will carry
`faces_box` in its uploads -- and whose rows have had their crops stripped.
The bench merges a full copy with crops, so it overstates that half.

Created on first use in `match_faces`, with CREATE INDEX IF NOT EXISTS,
rather than by a migration, for the reason `keywords::ensure_term_index`
gives: a schema version bump makes older builds refuse the snapshot, and an
extra index is invisible to them. The first merge after the upgrade builds
it (about a second, once). Its prefix duplicates `faces_image_model`, which
is left alone; the planner takes either for an (image_id, model_id) probe.
Tables checksum the same after the bench run as after the old build's.
This commit is contained in:
2026-09-26 13:28:50 -04:00
parent 981022ab1d
commit ae0281fedd
+26
View File
@@ -1081,6 +1081,8 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
type Boxed = (i64, f32, f32, f32, f32);
ensure_face_box_index(tx);
// Local faces, grouped by the photograph's cross-device id.
let mut local: std::collections::HashMap<(i64, String), Vec<Boxed>> =
std::collections::HashMap::new();
@@ -1153,6 +1155,30 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
Ok(map)
}
/// The index the local half of [`match_faces`] is read from: every column
/// it asks of a face, so the walk touches no `faces` row.
///
/// A face row is eight kilobytes and `model_id` sits past the embedding, so
/// reading it opened the row's overflow pages: 38 ms of every sync pass on
/// the reference library, to read 19,000 boxes. From this index, 8 ms. The
/// other device's half has no such index to use -- it is a snapshot made by
/// whatever build that device runs -- but its rows have had their crops
/// stripped, which is most of their width.
///
/// Created on first use rather than by a migration, like
/// `keywords::ensure_term_index` and for the reason given there: a new
/// schema version makes older builds refuse this catalog's snapshot, and an
/// extra index is invisible to them. The first merge after an upgrade pays
/// for building it, once. A failure is logged and the merge goes on reading
/// rows, as it did before.
fn ensure_face_box_index(tx: &Connection) {
if let Err(e) = tx.execute_batch(
"CREATE INDEX IF NOT EXISTS main.faces_box ON faces(image_id, model_id, x, y, w, h);",
) {
log::warn!("merge: could not create faces_box: {e}");
}
}
/// Intersection over union of two `(x, y, w, h)` boxes.
fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
let x0 = a.0.max(b.0);