Measure what opening the catalog, a sync pass and a scan cost on a real library

Two benches for reading side by side before and after a change, against a
copy of a real catalog, in the manner of identity_bench:

- `dr-catalog --example catalog_bench CATALOG [FACES_DIR]` times
  `Catalog::open` and the backfill inside it step by step, the upload
  snapshot, a merge of the catalog with a copy of itself, and the face
  shard export and import in the steady state where nothing is new.

- `persist_bench`, an ignored test in dr-ui's scan module because
  `persist` and `apply_judgement` are private to it, replays the
  catalog's own rows through `persist` (the largest folder, and the whole
  library) and looks up every `.drsc` sidecar the catalog has read. It
  works on a scratch copy and prints a fingerprint of what `persist` left,
  so two builds can be shown to agree.

Both print best, median and CPU time; the CPU figure is the one to compare
while other builds share the machine.
This commit is contained in:
2026-09-25 22:06:58 -04:00
parent c559d31dad
commit 454375243c
3 changed files with 336 additions and 23 deletions
+196
View File
@@ -1051,4 +1051,200 @@ mod tests {
0
);
}
/// What a scan pass and a sidecar pull cost on a real library.
///
/// DR_BENCH_CATALOG=/path/to/copy.sqlite cargo test --release -p dr-ui \
/// --lib persist_bench -- --ignored --nocapture --test-threads=1
///
/// Replays the catalog's own `remote` rows through [`persist`] — the
/// largest folder, which is what a pass relists after one sidecar write
/// there, and then the whole library, which is a first scan — and looks
/// up every `.drsc` sidecar the catalog has read, as [`apply_judgement`]
/// does on a pull (with no judgement, so nothing is written). Works on a
/// scratch copy beside the catalog it is given, and prints a fingerprint
/// of what `persist` left so two builds can be shown to agree.
#[test]
#[ignore]
fn persist_bench() {
use std::time::{Duration, Instant};
let Ok(source) = std::env::var("DR_BENCH_CATALOG") else {
eprintln!("DR_BENCH_CATALOG not set; nothing to measure");
return;
};
let scratch = format!("{source}.persist-bench");
let _ = std::fs::remove_file(&scratch);
{
let from = rusqlite::Connection::open(&source).unwrap();
from.execute("VACUUM INTO ?1", [&scratch]).unwrap();
}
let catalog = Catalog::open(std::path::Path::new(&scratch)).unwrap();
let conn = catalog.connection();
let root: String = conn
.query_row(
"SELECT label FROM roots WHERE kind = 'remote' LIMIT 1",
[],
|r| r.get(0),
)
.unwrap();
let entries = |filter: &str| -> dr_sync::ScanResult {
let mut q = conn
.prepare(&format!(
"SELECT i.source_ref, r.file_id, r.etag, coalesce(i.file_size, 0)
FROM remote r JOIN images i ON i.id = r.image_id
WHERE 1 {filter}
ORDER BY i.source_ref"
))
.unwrap();
let images: Vec<dr_sync::RemoteEntry> = q
.query_map([], |r| {
Ok(dr_sync::RemoteEntry {
id: RemoteId::Stable(r.get::<_, i64>(1)? as u64),
path: RemotePath::new(r.get::<_, String>(0)?),
kind: dr_sync::EntryKind::File,
validator: dr_sync::Validator::new(
r.get::<_, Option<String>>(2)?.unwrap_or_default(),
),
size: r.get::<_, i64>(3)? as u64,
modified: None,
has_preview: false,
materialised: true,
})
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
let mut dirs: Vec<String> = images
.iter()
.filter_map(|e| e.path.parent().map(|p| p.as_str().to_string()))
.collect();
dirs.sort();
dirs.dedup();
let directories = dirs
.into_iter()
.map(|d| {
let etag: Option<String> = conn
.query_row("SELECT etag FROM folders WHERE path = ?1", [&d], |r| {
r.get(0)
})
.ok()
.flatten();
(
RemotePath::new(d),
dr_sync::Validator::new(etag.unwrap_or_default()),
)
})
.collect();
dr_sync::ScanResult {
images,
directories,
progress: Default::default(),
sidecars: Vec::new(),
}
};
let biggest: i64 = conn
.query_row(
"SELECT folder_id FROM images GROUP BY folder_id ORDER BY count(*) DESC LIMIT 1",
[],
|r| r.get(0),
)
.unwrap();
let folder = entries(&format!("AND i.folder_id = {biggest}"));
let all = entries("");
let cpu_now = || {
// The test's own thread: the harness runs it off the main one.
std::fs::read_to_string("/proc/thread-self/schedstat")
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<u64>().ok())
.map(Duration::from_nanos)
.unwrap_or_default()
};
let time = |label: &str, runs: usize, f: &mut dyn FnMut()| {
let mut wall = Vec::new();
let mut cpu = Vec::new();
for _ in 0..runs {
let (c, t) = (cpu_now(), Instant::now());
f();
wall.push(t.elapsed());
cpu.push(cpu_now().saturating_sub(c));
}
wall.sort();
cpu.sort();
println!(
"{label:44} best {:9.2} ms median {:9.2} ms cpu {:9.2} ms",
wall[0].as_secs_f64() * 1e3,
wall[runs / 2].as_secs_f64() * 1e3,
cpu[0].as_secs_f64() * 1e3
);
};
time(
&format!("persist, largest folder ({} images)", folder.images.len()),
5,
&mut || persist(&catalog, &root, &folder).unwrap(),
);
time(
&format!("persist, whole library ({} images)", all.images.len()),
3,
&mut || persist(&catalog, &root, &all).unwrap(),
);
let root_id: i64 = conn
.query_row("SELECT id FROM roots WHERE label = ?1", [&root], |r| {
r.get(0)
})
.unwrap();
let sidecars: Vec<String> = {
let mut q = conn
.prepare("SELECT path FROM sidecars WHERE path LIKE '%.drsc' ORDER BY path")
.unwrap();
q.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap()
};
let mut matched = 0usize;
time(
&format!("apply_judgement lookup x{}", sidecars.len()),
3,
&mut || {
for s in &sidecars {
matched += apply_judgement(conn, root_id, s, 0, 0, 0).unwrap();
}
},
);
let fingerprint: (i64, i64, i64, i64) = conn
.query_row(
"SELECT (SELECT count(*) || ':' || total(id * 31 + coalesce(folder_id, 0) * 7
+ file_size % 1000003 + availability * 3
+ metadata_state + length(source_ref))
FROM images),
(SELECT total(image_id * 13 + file_id % 1000003 + length(etag)
+ length(remote_path)) FROM remote),
(SELECT count(*) || ':' || total(kind * 17 + coalesce(subject_id, 0) * 5
+ priority * 3 + state + attempts) FROM jobs),
(SELECT count(*) || ':' || total(length(path) + length(etag)) FROM folders)",
[],
|r| {
let h = |s: String| {
s.bytes()
.fold(0i64, |a, b| a.wrapping_mul(131).wrapping_add(b as i64))
};
Ok((
h(r.get::<_, String>(0)?),
h(r.get::<_, f64>(1)?.to_string()),
h(r.get::<_, String>(2)?),
h(r.get::<_, String>(3)?),
))
},
)
.unwrap();
println!("fingerprint after persist: {fingerprint:?}, judgements matched {matched}");
drop(catalog);
let _ = std::fs::remove_file(&scratch);
let _ = std::fs::remove_file(format!("{scratch}-wal"));
let _ = std::fs::remove_file(format!("{scratch}-shm"));
}
}