Files
dtourolle 1b8d0e740f Say the benchmark's second open no longer pays the backfill
benchmarks.md and catalog_open.rs both said schema::backfill runs on
every Catalog::open. Since ffdd640 it runs on the first open of a path
in a process and is skipped while the stamp matches, so in dr-bench
catalog_open_ms still includes it and catalog_open_warm_ms, the second
open in the same process, no longer does. That is what a library
reopened in one session costs, and both now say which figure is which.
The comment keeps its line count, so no tag below it moves.
2026-09-26 14:58:20 -04:00

179 lines
7.6 KiB
Rust

//! TRACES: NFR-P1
//! Opening a fifty-thousand-image catalog, and what that actually involves.
//!
//! NFR-P1 says under two seconds on the reference desktop. Until this file
//! existed the number had never been measured, which made it a wish — and
//! `dr-catalog`'s `lib.rs` has carried an `NFR-P1` tag the whole time on code
//! that describes the target rather than checking it. This is the check.
//!
//! # What counts as "open"
//!
//! Not `Catalog::open` alone. That call returns before anything is on screen,
//! and a user's "the catalog opened" is the moment the grid has cells in it.
//! So the measured span is the four things the library view cannot paint
//! without:
//!
//! 1. [`Catalog::open`] — connect, migrate if needed, and **backfill**. The
//! backfill is the interesting one: O(library) passes over the images table
//! on a path whose budget is in absolute seconds. Since 0.17.0 it runs on the
//! first open in a process and is skipped while nothing changed (`backfilled`).
//! 2. [`Catalog::count`] — the total, which is what sizes the scrollbar.
//! 3. [`Catalog::window`] — the first screenful of rows.
//! 4. [`Catalog::timeline`] — the scrubber's buckets, drawn beside the grid
//! from the first frame.
//!
//! `open` alone is reported separately anyway, because if the two ever diverge
//! sharply the fix is in a different place.
//!
//! # What this does *not* measure, said out loud
//!
//! `ui/dr-ui/src/library.rs` does not call [`Catalog::count`] or
//! [`Catalog::window`]. It issues its own SQL — `total_images_scoped`,
//! `total_images_filtered` and friends — against the same tables, with a
//! `VISIBLE` predicate and a burst-folding clause that this crate cannot see
//! without depending on the UI, which would drag Slint into a benchmark job
//! that has no display. So the number here is the **catalog crate's** open
//! path, and the application's is that plus whatever those queries cost.
//!
//! That gap is a real limit on what this file can certify, and it has a
//! falsifiable end: when the grid's queries move down into `dr-catalog` —
//! which is where SQL over catalog tables belongs — this measurement becomes
//! the whole of the application's open, and the caveat can be deleted rather
//! than argued about.
//!
//! # Cold and warm
//!
//! Both are reported. The first open in a process pays for SQLite's page cache
//! being empty and for the schema being read; the second pays for neither, and
//! is what a user gets when they close and reopen a library in the same
//! session. §4.1 asks the question directly for NFR-P8 and it is worth having
//! the answer here too. Neither figure is a genuinely cold *disk*: the fixture
//! was written by this same suite or by an earlier run of it, so the file is
//! in the OS page cache. On the reference desktop's NVMe a truly cold read of
//! a ~14 MB file is a few tens of milliseconds; on a spinning disk it is not.
use std::path::Path;
use std::time::Instant;
use anyhow::Result;
use dr_catalog::{Catalog, Granularity, Query};
use dr_types::Selector;
use crate::stats::{ms, Percentiles, Rng};
/// Rows fetched for the first screenful.
///
/// A dense grid on a 4K display is around three hundred cells; four hundred is
/// that plus the prefetch margin R2 asks for. Not the whole library, because
/// FR-CAT-4 is explicit that memory must not scale with it — a benchmark that
/// asked for 50,000 rows would be measuring the requirement's violation.
const WINDOW: usize = 400;
/// The clock the query compiler is handed.
///
/// Fixed rather than read from the system, so a rolling date filter would
/// compile to the same SQL on every run. The unfiltered query does not consult
/// it at all; this is here so that adding a dated row later does not silently
/// make the suite time-dependent.
const NOW: i64 = 2_000_000_000;
/// What one measured open produced.
pub struct Open {
/// Open, count, first window, timeline — the whole span, first time.
pub cold_ms: f64,
/// The same four calls on a second connection in the same process.
pub warm_ms: f64,
/// [`Catalog::open`] on its own, out of the cold span.
pub open_only_ms: f64,
/// How many images the count found. Reported so a fixture that failed to
/// populate cannot masquerade as a very fast open.
pub images: usize,
/// Timeline buckets at monthly granularity.
pub buckets: usize,
/// One window fetched at a random offset — the scroll, minus the drawing.
pub window_ms: Percentiles,
/// Count plus first window under a rating filter, which compiles to a
/// correlated subquery over `versions` (see `query::default_version_scalar`).
pub filtered_ms: f64,
}
/// Measure an open of the catalog at `path`, then `windows` random windows.
pub fn measure(path: &Path, windows: usize) -> Result<Open> {
let q = Query::default();
let started = Instant::now();
let catalog = open(path)?;
let open_only_ms = ms(started.elapsed());
let images = catalog.count(&q, NOW)?;
let rows = catalog.window(&q, 0..WINDOW, NOW)?;
let buckets = catalog.timeline(&q, Granularity::Month, NOW)?.len();
let cold_ms = ms(started.elapsed());
// A catalog that returned nothing would post an excellent time. Checked
// rather than trusted, because the failure mode is a *fast* wrong answer.
anyhow::ensure!(
!rows.is_empty() && images > 0 && buckets > 0,
"the fixture catalog answered with {images} images, {} rows and {buckets} buckets — \
the measurement below would be meaningless",
rows.len()
);
drop(catalog);
let started = Instant::now();
let catalog = open(path)?;
let _ = catalog.count(&q, NOW)?;
let _ = catalog.window(&q, 0..WINDOW, NOW)?;
let _ = catalog.timeline(&q, Granularity::Month, NOW)?;
let warm_ms = ms(started.elapsed());
// The scroll. Offsets are drawn from a fixed seed rather than swept in
// order, because a sequential sweep would be answered increasingly out of
// SQLite's own cache and would flatter the deep end of the library — which
// is exactly the end a person reaches by dragging the scrollbar.
let mut rng = Rng::new(0x5C_20_11);
let span = images.saturating_sub(WINDOW).max(1) as u64;
// Discarded: the first window of a new connection compiles the statement
// and faults in the b-tree's upper levels, and neither recurs while
// scrolling.
for _ in 0..4 {
let start = rng.below(span) as usize;
let _ = catalog.window(&q, start..start + WINDOW, NOW)?;
}
let mut samples = Vec::with_capacity(windows);
for _ in 0..windows {
let start = rng.below(span) as usize;
let t = Instant::now();
let rows = catalog.window(&q, start..start + WINDOW, NOW)?;
samples.push(ms(t.elapsed()));
debug_assert!(!rows.is_empty());
}
// A filter that has to reach the default version for every candidate row.
// Cheap to add and the one query shape in the grid that is not a scan of
// `images` alone, so a regression in it would otherwise show up first as a
// user complaint.
let rated = Query {
filter: Selector::Rating { min: 2 },
..Query::default()
};
let t = Instant::now();
let _ = catalog.count(&rated, NOW)?;
let _ = catalog.window(&rated, 0..WINDOW, NOW)?;
let filtered_ms = ms(t.elapsed());
Ok(Open {
cold_ms,
warm_ms,
open_only_ms,
images,
buckets,
window_ms: Percentiles::of(samples),
filtered_ms,
})
}
fn open(path: &Path) -> Result<Catalog> {
Catalog::open(path)
.map_err(|e| anyhow::anyhow!("opening the fixture catalog at {}: {e}", path.display()))
}