390 lines
14 KiB
Rust
390 lines
14 KiB
Rust
//! What the index's page geometry costs, on disk and in query time.
|
||
//!
|
||
//! Two levers, swept together because they are coupled: the **database page
|
||
//! size** (`db::schema::PAGE_SIZE`) and FTS5's **record size**
|
||
//! (`db::schema::fts_pgsz_for`). The second is derived from the first, so
|
||
//! neither can be moved alone — at a page size of 8192 an FTS5 record built
|
||
//! for 4096 leaves half of every page empty.
|
||
//!
|
||
//! ```text
|
||
//! TMPDIR=/media/shared/qs-scratch QSB_PGSZ=1 \
|
||
//! cargo bench -p quicksearch-core --bench page_geometry
|
||
//! ```
|
||
//!
|
||
//! `TMPDIR` is not optional in spirit. `testutil::scratch_dir` builds on
|
||
//! `std::env::temp_dir()`, and a `/tmp` that is tmpfs cannot produce a page
|
||
//! fetch that was not already in RAM — it would price the one regime this is
|
||
//! not trying to characterise, and a 1M-file arm would not fit besides. Point
|
||
//! it at real storage. The matrix wants ~2 GB at a time (each arm is dropped
|
||
//! once measured) and around ten minutes, most of it seeding.
|
||
//!
|
||
//! # Settled: FTS5 record size
|
||
//!
|
||
//! SQLCipher reserves part of every page for its IV and any authenticator, so
|
||
//! a keyed page holds `page − reserve − 35` bytes inline. FTS5's default
|
||
//! record of 4050 was chosen for a *plain* 4096 page and missed that by 71
|
||
//! bytes under the 80-byte reserve of the day, which sent every full leaf to
|
||
//! an overflow page. Measured at 120k files before `fts_pgsz_for` existed, and
|
||
//! while `db::schema::HMAC_MODE` was still HMAC-SHA512 — at today's 16-byte
|
||
//! reserve the miss is smaller, but the derivation is what makes it zero at
|
||
//! *every* page size:
|
||
//!
|
||
//! | | plain 4050 | plain shipped | keyed 4050 | keyed shipped |
|
||
//! |---|---|---|---|---|
|
||
//! | bulk write | 3.87 s | 4.04 s | 7.34 s | 7.13 s |
|
||
//! | size | 156.4 MiB | 156.4 MiB | **170.2 MiB** | **159.1 MiB** |
|
||
//! | fts overflow pages | 0 | 0 | **27381** | 0 |
|
||
//! | cold `chalcedony` | 11.35 ms | 11.37 ms | 29.91 ms | 29.57 ms |
|
||
//! | warm `chalcedony` | 9.29 ms | 9.24 ms | 9.41 ms | 9.27 ms |
|
||
//!
|
||
//! A disk-space fix (1.089x → 1.011x encrypted-over-plain), not a speed fix:
|
||
//! both indexing and search moved less than the harness's own noise floor.
|
||
//! `tests/encrypted_perf.rs` gates the size half of that and is the reason
|
||
//! this bench does not re-measure it.
|
||
//!
|
||
//! # Open: database page size
|
||
//!
|
||
//! A keyed index decrypts a whole page to read one row out of it. If the
|
||
//! expensive fetches are *scattered* single rows, a smaller page cuts that
|
||
//! work in proportion, and — `cache_size` being a byte ceiling — lets the same
|
||
//! 32 MiB hold four times as many distinct rows. Pulling the other way, the
|
||
//! `files` scan behind every filename query is sequential and wants large
|
||
//! pages, and the reserve costs proportionally more of a small page: at the
|
||
//! 80 bytes of the HMAC-SHA512 era that was 2% of a 4096-byte page against
|
||
//! 7.8% of a 1024-byte one, and at today's 16 it is 0.4% against 1.6%.
|
||
//!
|
||
//! [`attribution`] settles which of those a query actually does, by counting
|
||
//! page-cache misses per query shape rather than inferring them from timings.
|
||
//!
|
||
//! # Reading it
|
||
//!
|
||
//! **The plain arm is the noise floor**, and at the 200k tier a just-seeded
|
||
//! index is small enough that the OS page cache serves nearly all of it — so
|
||
//! those figures price decrypt work with little I/O in them. The 1M tier
|
||
//! exceeds what stays cached, and is where real reads enter: storage reads in
|
||
//! ≥4 KiB blocks whatever the page size, so a sub-4K page cuts decryption but
|
||
//! not I/O. The two tiers are reported separately for that reason; do not
|
||
//! average them.
|
||
|
||
use std::time::{Duration, Instant};
|
||
|
||
use quicksearch_core::db;
|
||
use quicksearch_core::query::split::split_for_cascade;
|
||
use quicksearch_core::search::{cascade, SearchHit, SearchOptions};
|
||
use quicksearch_core::testutil::{cache_stats, Arm, SeedSpec, BODY_TERM, NEEDLE};
|
||
use rusqlite::Connection;
|
||
|
||
/// Page sizes to sweep, up to `SQLITE_MAX_PAGE_SIZE`. 512 is excluded: with
|
||
/// SQLCipher's 80-byte reserve its usable size falls under SQLite's 480-byte
|
||
/// floor. FTS5 caps its own record size at 64 KiB and rejects anything larger,
|
||
/// so 65536 is the last size where `fts_pgsz_for` still has room.
|
||
const SWEPT: [i64; 7] = [1024, 2048, 4096, 8192, 16384, 32768, 65536];
|
||
|
||
/// `QSB_PGSZ_SIZES=8192,16384` narrows the sweep; a full run is ~45 minutes,
|
||
/// nearly all of it seeding, so re-asking one question should not re-ask all
|
||
/// of them.
|
||
fn swept() -> Vec<i64> {
|
||
match std::env::var("QSB_PGSZ_SIZES") {
|
||
Ok(list) => list
|
||
.split(',')
|
||
.map(|s| s.trim().parse().expect("QSB_PGSZ_SIZES wants integers"))
|
||
.collect(),
|
||
Err(_) => SWEPT.to_vec(),
|
||
}
|
||
}
|
||
|
||
/// `QSB_PGSZ_SHAPE_ONLY=1` skips the large corpora.
|
||
fn shape_only() -> bool {
|
||
std::env::var("QSB_PGSZ_SHAPE_ONLY").is_ok()
|
||
}
|
||
|
||
/// The shape tier — every page size, cheap enough to run them all.
|
||
const SHAPE_FILES: usize = 200_000;
|
||
/// The confirmation tiers, run only for the baseline and the shape tier's
|
||
/// winner. 1M is where the working set stops fitting in the OS cache.
|
||
const SCALE_FILES: [usize; 2] = [600_000, 1_000_000];
|
||
|
||
const CONTENT_EVERY: usize = 8;
|
||
|
||
/// Commit in slices, as a production run does: each commit flushes an FTS5
|
||
/// segment, so a single enormous transaction would not resemble one.
|
||
const COMMIT_EVERY: usize = 5_000;
|
||
|
||
/// Best-of-N. The minimum is the run least disturbed by whatever else is on
|
||
/// the box, which is the honest figure for a comparison.
|
||
const RUNS: u32 = 5;
|
||
|
||
/// The workloads, in the order they are reported. One word from
|
||
/// `testutil::WORDS` leads: its posting lists are long, where the rare terms
|
||
/// stop at the display limit having touched very little.
|
||
const WORKLOADS: [(&str, &str, bool); 6] = [
|
||
("body (common)", "planning", false),
|
||
("body (rare)", BODY_TERM, false),
|
||
("name", NEEDLE, false),
|
||
("fuzzy", "quartzlte", true),
|
||
("wildcard", "quart*", false),
|
||
("regex", "regex:quart[sz]ite", false),
|
||
];
|
||
|
||
fn enabled() -> bool {
|
||
std::env::var("QSB_PGSZ").is_ok()
|
||
}
|
||
|
||
fn spec(files: usize, page_size: i64) -> SeedSpec {
|
||
SeedSpec {
|
||
files,
|
||
content_every: CONTENT_EVERY,
|
||
dup_every: 5,
|
||
commit_every: COMMIT_EVERY,
|
||
page_size: Some(page_size),
|
||
..SeedSpec::default()
|
||
}
|
||
}
|
||
|
||
fn mib(bytes: u64) -> f64 {
|
||
bytes as f64 / (1024.0 * 1024.0)
|
||
}
|
||
|
||
fn main() {
|
||
if !enabled() {
|
||
eprintln!("skipping: set QSB_PGSZ=1 to run");
|
||
return;
|
||
}
|
||
if std::env::var_os("TMPDIR").is_none() {
|
||
eprintln!(
|
||
"warning: TMPDIR unset — scratch goes to {}. If that is tmpfs, \
|
||
every 'cold' figure below is RAM and the large tiers may not fit.",
|
||
std::env::temp_dir().display()
|
||
);
|
||
}
|
||
|
||
let best = shape_tier();
|
||
if shape_only() {
|
||
println!("\n(QSB_PGSZ_SHAPE_ONLY set — skipping the large corpora)");
|
||
return;
|
||
}
|
||
scale_tier(best);
|
||
}
|
||
|
||
/// Every page size at [`SHAPE_FILES`], plain and keyed. Returns the keyed page
|
||
/// size with the lowest total warm time — the metric that matters, because a
|
||
/// session re-queries on every keystroke.
|
||
fn shape_tier() -> i64 {
|
||
println!(
|
||
"\n######## shape tier: {} files, {} with content ########",
|
||
SHAPE_FILES,
|
||
SHAPE_FILES / CONTENT_EVERY
|
||
);
|
||
let mut best = (db::schema::PAGE_SIZE, f64::MAX);
|
||
for page_size in swept() {
|
||
for keyed in [false, true] {
|
||
let arm = Arm::seed(
|
||
format!("{} {}", if keyed { "keyed" } else { "plain" }, page_size),
|
||
&format!("pgsz-{}-{}", page_size, keyed),
|
||
keyed,
|
||
&spec(SHAPE_FILES, page_size),
|
||
);
|
||
let warm_total = report(&arm);
|
||
if keyed && warm_total < best.1 {
|
||
best = (page_size, warm_total);
|
||
}
|
||
arm.discard();
|
||
}
|
||
}
|
||
println!(
|
||
"\n>>> lowest keyed warm total at page_size {} ({:.1} ms across {} workloads)",
|
||
best.0,
|
||
best.1 * 1000.0,
|
||
WORKLOADS.len()
|
||
);
|
||
best.0
|
||
}
|
||
|
||
/// The baseline and the winner only, at the larger corpora.
|
||
fn scale_tier(best: i64) {
|
||
let mut sizes = vec![db::schema::PAGE_SIZE];
|
||
if best != db::schema::PAGE_SIZE {
|
||
sizes.push(best);
|
||
}
|
||
for files in SCALE_FILES {
|
||
println!(
|
||
"\n######## scale tier: {} files, {} with content ########",
|
||
files,
|
||
files / CONTENT_EVERY
|
||
);
|
||
for page_size in &sizes {
|
||
for keyed in [false, true] {
|
||
let arm = Arm::seed(
|
||
format!("{} {}", if keyed { "keyed" } else { "plain" }, page_size),
|
||
&format!("pgsz-{}-{}-{}", files, page_size, keyed),
|
||
keyed,
|
||
&spec(files, *page_size),
|
||
);
|
||
report(&arm);
|
||
arm.discard();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Everything measured about one arm. Returns its total warm query time in
|
||
/// seconds, the metric [`shape_tier`] ranks on.
|
||
fn report(arm: &Arm) -> f64 {
|
||
let (leaf, overflow) = arm.fts_pages();
|
||
let files_bytes = arm.table_bytes("files");
|
||
println!(
|
||
"\n=== {} === {:.1} MiB on disk, files table {:.1} MiB, \
|
||
fts {} leaf / {} overflow, written in {:.1?} ({:.0} rows/s)",
|
||
arm.what,
|
||
mib(arm.size_bytes()),
|
||
mib(files_bytes),
|
||
leaf,
|
||
overflow,
|
||
arm.seeded_in,
|
||
seeded_rows(arm) as f64 / arm.seeded_in.as_secs_f64(),
|
||
);
|
||
|
||
attribution(arm);
|
||
|
||
println!(
|
||
"{:<16}{:>12}{:>12}{:>12}{:>10}",
|
||
"workload", "cold", "warm", "cold miss", "hits"
|
||
);
|
||
let conn = arm.open_search();
|
||
let mut warm_total = 0.0;
|
||
for (what, query, fuzzy) in WORKLOADS {
|
||
let (cold_time, misses, hits) = cold(arm, query, fuzzy);
|
||
let warm_time = warm(&conn, query, fuzzy);
|
||
warm_total += warm_time.as_secs_f64();
|
||
println!(
|
||
"{:<16}{:>12}{:>12}{:>12}{:>10}",
|
||
what,
|
||
format!("{:.2?}", cold_time),
|
||
format!("{:.2?}", warm_time),
|
||
misses,
|
||
hits
|
||
);
|
||
}
|
||
warm_total
|
||
}
|
||
|
||
fn seeded_rows(arm: &Arm) -> i64 {
|
||
let conn = arm.open_search();
|
||
conn.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// **Where the page fetches go.** Each shape runs on its own fresh connection
|
||
/// and reports the misses it caused, so the cost lands on the table that
|
||
/// caused it rather than on whichever query happened to be slow.
|
||
///
|
||
/// The shapes are the ones `search/cascade/passes.rs` actually issues: pass A
|
||
/// is a `files` scan with no FTS in it at all, and pass B's FTS MATCH is
|
||
/// joined straight back to `files` by rowid and to `documents_text` for the
|
||
/// body — so each posting costs a random row seek and a blob read on top of
|
||
/// the posting list that produced it. The middle two rows separate those.
|
||
fn attribution(arm: &Arm) {
|
||
let term = BODY_TERM;
|
||
let like = format!("%{}%", term);
|
||
let match_expr = format!("text: \"{}\"", term);
|
||
|
||
let shapes: [(&str, &str, &str); 4] = [
|
||
(
|
||
"pass A: files scan",
|
||
"SELECT COUNT(*) FROM files f WHERE f.name LIKE ?1 ESCAPE '\\'",
|
||
"like",
|
||
),
|
||
(
|
||
" FTS postings only",
|
||
"SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH ?1",
|
||
"match",
|
||
),
|
||
(
|
||
" + files rowid join",
|
||
"SELECT COUNT(*) FROM searchabletext \
|
||
JOIN files f ON f.id = searchabletext.rowid \
|
||
WHERE searchabletext MATCH ?1",
|
||
"match",
|
||
),
|
||
(
|
||
"pass B: + the bodies",
|
||
"SELECT SUM(LENGTH(dt.text_zstd)) FROM searchabletext \
|
||
JOIN files f ON f.id = searchabletext.rowid \
|
||
LEFT JOIN documents_text dt ON dt.file_id = f.id \
|
||
WHERE searchabletext MATCH ?1",
|
||
"match",
|
||
),
|
||
];
|
||
|
||
println!(
|
||
"{:<24}{:>12}{:>12}{:>14}",
|
||
"cold page misses", "misses", "time", "MiB decrypted"
|
||
);
|
||
for (what, sql, param) in shapes {
|
||
// A fresh connection per shape: the miss count is only meaningful
|
||
// from an empty cache.
|
||
let conn = arm.open_search();
|
||
let bound: &str = if param == "like" { &like } else { &match_expr };
|
||
let before = cache_stats(&conn).1;
|
||
let start = Instant::now();
|
||
conn.query_row(sql, [bound], |r| r.get::<_, Option<i64>>(0))
|
||
.expect("attribution shape runs");
|
||
let elapsed = start.elapsed();
|
||
let misses = cache_stats(&conn).1 - before;
|
||
let page = arm.page_size.unwrap_or(db::schema::PAGE_SIZE);
|
||
println!(
|
||
"{:<24}{:>12}{:>12}{:>14.1}",
|
||
what,
|
||
misses,
|
||
format!("{:.2?}", elapsed),
|
||
(misses * page) as f64 / (1024.0 * 1024.0)
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Run one query, counting hits rather than keeping them — holding the
|
||
/// `SearchHit`s would measure the allocator instead of the scan.
|
||
fn run_query(conn: &Connection, query: &str, fuzzy: bool) -> (Duration, usize) {
|
||
let split = split_for_cascade(query).expect("query parses");
|
||
let options = SearchOptions {
|
||
fuzzy,
|
||
..SearchOptions::default()
|
||
};
|
||
let latest = std::sync::atomic::AtomicU64::new(1);
|
||
let mut hits = 0usize;
|
||
let mut sink = |batch: Vec<SearchHit>| hits += batch.len();
|
||
let start = Instant::now();
|
||
cascade::run(conn, &split, &options, 1, &latest, &mut sink).expect("cascade runs");
|
||
(start.elapsed(), hits)
|
||
}
|
||
|
||
/// Best of `RUNS`, each on a **fresh** connection, so SQLite's page cache
|
||
/// starts empty and every page the query wants is a miss. Returns the miss
|
||
/// count alongside, which is what makes the timing interpretable.
|
||
fn cold(arm: &Arm, query: &str, fuzzy: bool) -> (Duration, i64, usize) {
|
||
let mut best = Duration::MAX;
|
||
let mut misses = 0;
|
||
let mut hits = 0;
|
||
for _ in 0..RUNS {
|
||
let conn = arm.open_search();
|
||
let before = cache_stats(&conn).1;
|
||
let (elapsed, n) = run_query(&conn, query, fuzzy);
|
||
if elapsed < best {
|
||
best = elapsed;
|
||
misses = cache_stats(&conn).1 - before;
|
||
}
|
||
hits = n;
|
||
}
|
||
(best, misses, hits)
|
||
}
|
||
|
||
/// Best of `RUNS` on one connection after a priming run — the steady state of
|
||
/// a typing session, which is what almost every real search is.
|
||
fn warm(conn: &Connection, query: &str, fuzzy: bool) -> Duration {
|
||
run_query(conn, query, fuzzy);
|
||
let mut best = Duration::MAX;
|
||
for _ in 0..RUNS {
|
||
best = best.min(run_query(conn, query, fuzzy).0);
|
||
}
|
||
best
|
||
}
|