2026-08-05 19:17:11 -04:00
|
|
|
//! What a warm page cache is worth to search, and how big it has to be.
|
|
|
|
|
//!
|
|
|
|
|
//! The search worker holds one connection across requests (see
|
|
|
|
|
//! [`quicksearch_core::search`]) precisely so that a typing session runs
|
|
|
|
|
//! against a cache that is already warm. This measures the two things that
|
|
|
|
|
//! claim rests on:
|
|
|
|
|
//!
|
|
|
|
|
//! 1. **Warm beats cold**, by enough to justify holding the connection at all.
|
|
|
|
|
//! 2. **8 MiB is enough** — the size [`PRAGMAS_SEARCH`] settles on. The hot set
|
|
|
|
|
//! across queries is the `files` b-tree interior nodes and the tips of the
|
|
|
|
|
//! FTS5 segments, not the table, so past some point a larger ceiling buys
|
|
|
|
|
//! nothing and only raises what an idle process is holding.
|
|
|
|
|
//!
|
|
|
|
|
//! Queries are run as a *sequence* — `q`, `qu`, `qui`, `quic` — because that is
|
|
|
|
|
//! what a search-per-keystroke frontend actually does. The first is the
|
|
|
|
|
//! outlier; the second and later ones are the number that matters.
|
|
|
|
|
//!
|
|
|
|
|
//! The encrypted column is the one to watch. Under SQLCipher a page cache miss
|
|
|
|
|
//! costs an AES-CBC decrypt plus an HMAC-SHA512 verify per 4 KiB page rather
|
|
|
|
|
//! than a `memcpy`, so if a smaller cache is going to hurt anywhere it is here.
|
|
|
|
|
//!
|
|
|
|
|
//! Gated by `QSB_SEARCH_PERF` so the harness doesn't pay the seed cost on every
|
|
|
|
|
//! `cargo test`. To run it:
|
|
|
|
|
//!
|
|
|
|
|
//! ```text
|
|
|
|
|
//! QSB_SEARCH_PERF=1 cargo test --release -p quicksearch-core \
|
|
|
|
|
//! --test search_perf -- --nocapture
|
|
|
|
|
//! ```
|
|
|
|
|
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
use quicksearch_core::db::repo::{insert_file, set_content_done, NewFile};
|
|
|
|
|
use quicksearch_core::db::{open_or_recreate, set_process_key};
|
|
|
|
|
use quicksearch_core::mime::FileType;
|
|
|
|
|
use quicksearch_core::query::split::split_for_cascade;
|
|
|
|
|
use quicksearch_core::search::{cascade, SearchHit, SearchOptions};
|
|
|
|
|
use quicksearch_core::security::IndexKey;
|
|
|
|
|
use rusqlite::Connection;
|
|
|
|
|
|
|
|
|
|
mod common;
|
|
|
|
|
use common::scratch_db;
|
|
|
|
|
|
|
|
|
|
/// Rows to seed. Large enough that the `files` b-tree has real interior levels
|
|
|
|
|
/// and the FTS index has more than one segment — below that everything fits in
|
|
|
|
|
/// any cache and the comparison says nothing.
|
|
|
|
|
const NUM_FILES: usize = 200_000;
|
|
|
|
|
|
|
|
|
|
/// Cache ceilings to compare, as `PRAGMA cache_size` values in KiB.
|
|
|
|
|
///
|
|
|
|
|
/// `-40960` is what every read connection used to take, `-8192` is
|
|
|
|
|
/// `PRAGMAS_SEARCH`, and `-1024` is deliberately too small — it is there to
|
|
|
|
|
/// show the curve has a floor worth being above, so that "8 MiB is enough" is
|
|
|
|
|
/// a measurement rather than an assumption.
|
|
|
|
|
const CACHE_SIZES: [i64; 6] = [-40960, -32768, -16384, -8192, -4096, -1024];
|
|
|
|
|
|
|
|
|
|
/// The prefixes of one word, typed one character at a time.
|
|
|
|
|
const SEQUENCE: [&str; 4] = ["quar", "quart", "quartz", "quartzi"];
|
|
|
|
|
|
|
|
|
|
fn enabled() -> bool {
|
|
|
|
|
std::env::var("QSB_SEARCH_PERF").is_ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Deterministic pseudo-random word picker. Same LCG as `indexprobe`, for the
|
|
|
|
|
/// same reason: a fixed seed makes two runs comparable.
|
|
|
|
|
struct Lcg(u64);
|
|
|
|
|
|
|
|
|
|
impl Lcg {
|
|
|
|
|
fn next(&mut self) -> u64 {
|
|
|
|
|
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
|
|
|
|
|
self.0 >> 33
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const WORDS: &[&str] = &[
|
2026-08-09 02:58:13 -04:00
|
|
|
"alpha",
|
|
|
|
|
"beta",
|
|
|
|
|
"gamma",
|
|
|
|
|
"delta",
|
|
|
|
|
"epsilon",
|
|
|
|
|
"zeta",
|
|
|
|
|
"eta",
|
|
|
|
|
"theta",
|
|
|
|
|
"iota",
|
|
|
|
|
"kappa",
|
|
|
|
|
"lambda",
|
|
|
|
|
"quartz",
|
|
|
|
|
"quartzite",
|
|
|
|
|
"quarry",
|
|
|
|
|
"quarter",
|
|
|
|
|
"quantum",
|
|
|
|
|
"brown",
|
|
|
|
|
"fox",
|
|
|
|
|
"jumps",
|
|
|
|
|
"lazy",
|
|
|
|
|
"index",
|
|
|
|
|
"search",
|
|
|
|
|
"cascade",
|
|
|
|
|
"snippet",
|
|
|
|
|
"document",
|
|
|
|
|
"content",
|
|
|
|
|
"extract",
|
2026-08-05 19:17:11 -04:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/// Seed an index with `NUM_FILES` rows, a tenth of them content-indexed.
|
|
|
|
|
///
|
|
|
|
|
/// Only a tenth so the FTS index stays smaller than the table, which is the
|
|
|
|
|
/// real shape — most files in a tree are not text.
|
|
|
|
|
fn seed(path: &std::path::Path) {
|
|
|
|
|
let mut conn = open_or_recreate(path.to_str().unwrap(), "trigram").unwrap();
|
|
|
|
|
let mut rng = Lcg(0x5eed);
|
|
|
|
|
let tx = conn.transaction().unwrap();
|
|
|
|
|
for i in 0..NUM_FILES {
|
|
|
|
|
let w1 = WORDS[(rng.next() as usize) % WORDS.len()];
|
|
|
|
|
let w2 = WORDS[(rng.next() as usize) % WORDS.len()];
|
|
|
|
|
let name = format!("{}-{}-{:07}.txt", w1, w2, i);
|
|
|
|
|
let dir = format!("/seed/{:03}", i % 500);
|
|
|
|
|
let full = format!("{}/{}", dir, name);
|
|
|
|
|
let id = insert_file(
|
|
|
|
|
&tx,
|
|
|
|
|
&NewFile {
|
|
|
|
|
name: &name,
|
|
|
|
|
path: &full,
|
|
|
|
|
parent: &dir,
|
|
|
|
|
size: 4096,
|
|
|
|
|
mtime: 1_700_000_000 + i as u64,
|
|
|
|
|
inode: None,
|
|
|
|
|
device_id: None,
|
|
|
|
|
mime: Some("text/plain"),
|
|
|
|
|
ftype: FileType::TEXT,
|
|
|
|
|
hash: None,
|
|
|
|
|
needs_content: i % 10 == 0,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.expect("unique path");
|
|
|
|
|
if i % 10 == 0 {
|
|
|
|
|
let body: Vec<&str> = (0..60)
|
|
|
|
|
.map(|_| WORDS[(rng.next() as usize) % WORDS.len()])
|
|
|
|
|
.collect();
|
|
|
|
|
set_content_done(&tx, id, &name, &body.join(" "), &[], true).unwrap();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
tx.commit().unwrap();
|
|
|
|
|
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").ok();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run one query to completion, returning how long it took and how many hits
|
|
|
|
|
/// it produced. Hits are counted, not kept — the cost being measured is the
|
|
|
|
|
/// scan, and holding 200k `SearchHit`s would measure the allocator instead.
|
|
|
|
|
fn time_query(conn: &Connection, query: &str) -> (Duration, usize) {
|
|
|
|
|
let split = split_for_cascade(query).unwrap();
|
|
|
|
|
let latest = std::sync::atomic::AtomicU64::new(1);
|
|
|
|
|
let mut count = 0usize;
|
|
|
|
|
let mut sink = |hits: Vec<SearchHit>| count += hits.len();
|
|
|
|
|
let options = SearchOptions {
|
|
|
|
|
limit: 1000,
|
|
|
|
|
..SearchOptions::default()
|
|
|
|
|
};
|
|
|
|
|
let start = Instant::now();
|
|
|
|
|
cascade::run(conn, &split, &options, 1, &latest, &mut sink).unwrap();
|
|
|
|
|
(start.elapsed(), count)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Open a connection at an explicit cache ceiling.
|
|
|
|
|
///
|
|
|
|
|
/// Spelled out rather than going through `db::open::open_search_reader`
|
|
|
|
|
/// because the whole point is to compare ceilings, which that function
|
|
|
|
|
/// deliberately does not expose.
|
|
|
|
|
fn open_at(path: &std::path::Path, cache_size: i64) -> Connection {
|
|
|
|
|
let conn = Connection::open(path).unwrap();
|
|
|
|
|
conn.execute_batch(&format!(
|
|
|
|
|
"PRAGMA busy_timeout = 5000;
|
|
|
|
|
PRAGMA cache_size = {};
|
|
|
|
|
PRAGMA temp_store = MEMORY;
|
|
|
|
|
PRAGMA foreign_keys = ON;",
|
|
|
|
|
cache_size
|
|
|
|
|
))
|
|
|
|
|
.unwrap();
|
|
|
|
|
conn
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_matrix(label: &str, path: &std::path::Path) {
|
|
|
|
|
println!("\n=== {} ===", label);
|
|
|
|
|
println!(
|
|
|
|
|
"{:>12} {:>10} {:>10} {:>10} {:>8}",
|
|
|
|
|
"cache_size", "cold", "warm avg", "warm best", "hits"
|
|
|
|
|
);
|
|
|
|
|
for cache_size in CACHE_SIZES {
|
|
|
|
|
// A connection per ceiling, held for the whole sequence — the same
|
|
|
|
|
// lifetime the search worker gives it.
|
|
|
|
|
let conn = open_at(path, cache_size);
|
|
|
|
|
let (cold, hits) = time_query(&conn, SEQUENCE[0]);
|
|
|
|
|
let mut warm = Vec::new();
|
|
|
|
|
for query in &SEQUENCE[1..] {
|
|
|
|
|
warm.push(time_query(&conn, query).0);
|
|
|
|
|
}
|
|
|
|
|
let avg = warm.iter().sum::<Duration>() / warm.len() as u32;
|
|
|
|
|
let best = warm.iter().min().copied().unwrap_or_default();
|
|
|
|
|
println!(
|
|
|
|
|
"{:>12} {:>9.1?} {:>9.1?} {:>9.1?} {:>8}",
|
|
|
|
|
cache_size, cold, avg, best, hits
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The headline comparison, printed rather than asserted.
|
|
|
|
|
///
|
|
|
|
|
/// Deliberately not a pass/fail threshold: timings on a shared CI box are not
|
|
|
|
|
/// stable enough for one, and a flaky perf gate gets muted rather than fixed.
|
|
|
|
|
/// This exists to be *read* when the number in [`PRAGMAS_SEARCH`] is being
|
|
|
|
|
/// chosen or questioned.
|
|
|
|
|
#[test]
|
|
|
|
|
fn cache_size_against_search_latency() {
|
|
|
|
|
if !enabled() {
|
|
|
|
|
eprintln!("skipping: set QSB_SEARCH_PERF=1 to run");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let plain = scratch_db("searchperf-plain");
|
|
|
|
|
let seeded = Instant::now();
|
|
|
|
|
seed(&plain);
|
|
|
|
|
println!(
|
|
|
|
|
"seeded {} rows in {:.1?} ({} MiB on disk)",
|
|
|
|
|
NUM_FILES,
|
|
|
|
|
seeded.elapsed(),
|
|
|
|
|
std::fs::metadata(&plain).map(|m| m.len()).unwrap_or(0) / (1024 * 1024)
|
|
|
|
|
);
|
|
|
|
|
run_matrix("unencrypted", &plain);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The same matrix against an encrypted index.
|
|
|
|
|
///
|
|
|
|
|
/// Separate test, and separate process-wide key, because
|
|
|
|
|
/// [`set_process_key`] is global: running both in one test would have the
|
|
|
|
|
/// plain index opened with a key set.
|
|
|
|
|
#[test]
|
|
|
|
|
fn cache_size_against_search_latency_encrypted() {
|
|
|
|
|
if !enabled() {
|
|
|
|
|
eprintln!("skipping: set QSB_SEARCH_PERF=1 to run");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set_process_key(Some(
|
|
|
|
|
IndexKey::from_hex(&"42".repeat(32)).expect("valid 32-byte key"),
|
|
|
|
|
));
|
|
|
|
|
let enc = scratch_db("searchperf-enc");
|
|
|
|
|
seed(&enc);
|
|
|
|
|
|
|
|
|
|
println!("\n(encrypted: every cache miss costs an AES-CBC + HMAC-SHA512 per page)");
|
|
|
|
|
// Both orders. The ceilings are tried largest-first and then
|
|
|
|
|
// smallest-first because the OS page cache warms as the run proceeds, and
|
|
|
|
|
// a difference that survives reversing the order is a property of the
|
|
|
|
|
// ceiling rather than of when it was measured.
|
|
|
|
|
let mut order: Vec<i64> = CACHE_SIZES.to_vec();
|
|
|
|
|
order.extend(CACHE_SIZES.iter().rev());
|
|
|
|
|
// Opened through the keyed path — a raw `Connection::open` cannot read it.
|
|
|
|
|
for cache_size in order {
|
|
|
|
|
let conn = quicksearch_core::db::open_existing(&enc.to_string_lossy(), false).unwrap();
|
|
|
|
|
conn.execute_batch(&format!("PRAGMA cache_size = {};", cache_size))
|
|
|
|
|
.unwrap();
|
|
|
|
|
let (cold, hits) = time_query(&conn, SEQUENCE[0]);
|
|
|
|
|
let mut warm = Vec::new();
|
|
|
|
|
for query in &SEQUENCE[1..] {
|
|
|
|
|
warm.push(time_query(&conn, query).0);
|
|
|
|
|
}
|
|
|
|
|
let avg = warm.iter().sum::<Duration>() / warm.len() as u32;
|
|
|
|
|
println!(
|
|
|
|
|
"{:>12} cold {:>9.1?} warm avg {:>9.1?} hits {}",
|
|
|
|
|
cache_size, cold, avg, hits
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
set_process_key(None);
|
|
|
|
|
}
|