From ae0281fedde79087f96145f00d9b3b426f1c23e3 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 26 Sep 2026 13:13:40 -0400 Subject: [PATCH] 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. --- core/dr-catalog/src/merge.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/core/dr-catalog/src/merge.rs b/core/dr-catalog/src/merge.rs index fcc4947..2ecdb58 100644 --- a/core/dr-catalog/src/merge.rs +++ b/core/dr-catalog/src/merge.rs @@ -1081,6 +1081,8 @@ fn match_faces(tx: &Connection) -> Result, 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> = std::collections::HashMap::new(); @@ -1153,6 +1155,30 @@ fn match_faces(tx: &Connection) -> Result, 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);