2026-09-25 14:58:51 -04:00
|
|
|
//! What `cipher_hmac` and `page_geometry` share: the seed shape, the
|
|
|
|
|
//! workloads and the per-arm report. `search_perf` uses [`mib`] and
|
|
|
|
|
//! [`warn_if_tmpdir_unset`].
|
|
|
|
|
|
|
|
|
|
// Each bench binary uses only part of it.
|
|
|
|
|
#![allow(dead_code)]
|
|
|
|
|
|
2026-10-11 20:32:00 -04:00
|
|
|
// Timed on the allocator the shipped binaries install; see
|
|
|
|
|
// `platform::Allocator`.
|
|
|
|
|
#[global_allocator]
|
|
|
|
|
static GLOBAL: quicksearch_core::platform::Allocator = quicksearch_core::platform::Allocator;
|
|
|
|
|
|
2026-09-25 14:58:51 -04:00
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
use quicksearch_core::db;
|
|
|
|
|
use quicksearch_core::search::SearchOptions;
|
|
|
|
|
use quicksearch_core::testutil::{cache_stats, run_cascade, Arm, SeedSpec, BODY_TERM, NEEDLE};
|
|
|
|
|
use rusqlite::Connection;
|
|
|
|
|
|
|
|
|
|
/// One file in this many carries a document body.
|
|
|
|
|
pub const CONTENT_EVERY: usize = 8;
|
|
|
|
|
|
|
|
|
|
/// 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.
|
|
|
|
|
pub const RUNS: u32 = 5;
|
|
|
|
|
|
2026-10-11 20:32:00 -04:00
|
|
|
/// The workloads, in report order. A phrase of two common words from
|
|
|
|
|
/// `testutil::WORDS` leads: no file name holds it (names join words with
|
|
|
|
|
/// `-`), so the cascade reaches the full-text pass, where its posting lists
|
|
|
|
|
/// are long and the rare terms stop at the display limit having touched very
|
|
|
|
|
/// little.
|
2026-09-25 14:58:51 -04:00
|
|
|
pub const WORKLOADS: [(&str, &str, bool); 6] = [
|
2026-10-11 20:32:00 -04:00
|
|
|
("body (phrase)", "planning review", false),
|
2026-09-25 14:58:51 -04:00
|
|
|
("body (rare)", BODY_TERM, false),
|
|
|
|
|
("name", NEEDLE, false),
|
|
|
|
|
("fuzzy", "quartzlte", true),
|
|
|
|
|
("wildcard", "quart*", false),
|
|
|
|
|
("regex", "regex:quart[sz]ite", false),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/// The corpus every arm is seeded with; the caller sets the variable under
|
|
|
|
|
/// test. It commits in slices, as a production run does: each commit
|
|
|
|
|
/// flushes an FTS5 segment, so one enormous transaction would not resemble a
|
|
|
|
|
/// run, and the write path is half of what these benches measure.
|
|
|
|
|
pub fn spec(files: usize) -> SeedSpec {
|
|
|
|
|
SeedSpec {
|
|
|
|
|
files,
|
|
|
|
|
content_every: CONTENT_EVERY,
|
|
|
|
|
dup_every: 5,
|
|
|
|
|
commit_every: 5_000,
|
|
|
|
|
..SeedSpec::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn mib(bytes: u64) -> f64 {
|
|
|
|
|
bytes as f64 / (1024.0 * 1024.0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Warn when `TMPDIR` is unset, finishing the sentence with `consequence`.
|
2026-10-11 20:32:00 -04:00
|
|
|
/// Scratch then goes to `std::env::temp_dir()`, often a tmpfs too small for
|
|
|
|
|
/// the large arms.
|
2026-09-25 14:58:51 -04:00
|
|
|
pub fn warn_if_tmpdir_unset(consequence: &str) {
|
|
|
|
|
if std::env::var_os("TMPDIR").is_none() {
|
|
|
|
|
eprintln!(
|
|
|
|
|
"warning: TMPDIR unset — scratch goes to {}. If that is tmpfs, {}.",
|
|
|
|
|
std::env::temp_dir().display(),
|
|
|
|
|
consequence
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Print the banner that opens a corpus tier.
|
|
|
|
|
pub fn banner(tier: &str, files: usize) {
|
|
|
|
|
println!(
|
|
|
|
|
"\n######## {} tier: {} files, {} with content ########",
|
|
|
|
|
tier,
|
|
|
|
|
files,
|
|
|
|
|
files / CONTENT_EVERY
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 20:32:00 -04:00
|
|
|
/// Print an arm's size, FTS page counts and seed time; returns its FTS
|
2026-09-25 14:58:51 -04:00
|
|
|
/// overflow page count.
|
|
|
|
|
pub fn header(arm: &Arm) -> i64 {
|
|
|
|
|
let (leaf, overflow) = arm.fts_pages();
|
|
|
|
|
println!(
|
|
|
|
|
"\n=== {} === {:.1} MiB on disk, files table {:.1} MiB, \
|
2026-10-11 20:32:00 -04:00
|
|
|
fts {} leaf / {} overflow, written in {:.1?}",
|
2026-09-25 14:58:51 -04:00
|
|
|
arm.what,
|
|
|
|
|
mib(arm.size_bytes()),
|
|
|
|
|
mib(arm.table_bytes("files")),
|
|
|
|
|
leaf,
|
|
|
|
|
overflow,
|
|
|
|
|
arm.seeded_in,
|
|
|
|
|
);
|
|
|
|
|
overflow
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Print 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` issues: pass A is a
|
|
|
|
|
/// `files` scan with no FTS in it, and pass B's FTS MATCH is joined 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.
|
|
|
|
|
pub fn attribution(arm: &Arm) {
|
|
|
|
|
let like = format!("%{}%", BODY_TERM);
|
2026-10-12 00:14:10 -04:00
|
|
|
// Built the way the cascade builds it: a quoted phrase is refused by the
|
|
|
|
|
// default `detail=none` index, so it is an AND of trigrams there.
|
|
|
|
|
let match_expr = quicksearch_core::search::prefilter::fts_literal(
|
|
|
|
|
BODY_TERM,
|
|
|
|
|
db::repo::fts_positions(&arm.open_search()),
|
|
|
|
|
)
|
|
|
|
|
.expect("BODY_TERM clears the trigram floor");
|
2026-09-25 14:58:51 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Print the cold and warm time of every [`WORKLOADS`] entry; returns the
|
|
|
|
|
/// total warm time in seconds.
|
|
|
|
|
pub fn workloads(arm: &Arm) -> f64 {
|
|
|
|
|
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 options = SearchOptions {
|
|
|
|
|
fuzzy,
|
|
|
|
|
..SearchOptions::default()
|
|
|
|
|
};
|
|
|
|
|
let (cold_time, misses, hits) = cold(arm, query, &options);
|
|
|
|
|
let warm_time = warm(&conn, query, &options);
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Best of [`RUNS`], each on a fresh connection, so SQLite's page cache
|
|
|
|
|
/// starts empty and every page the query wants is a miss: the regime where
|
|
|
|
|
/// per-page work is paid rather than skipped. Returns the best time with
|
|
|
|
|
/// that run's miss count, and the hit count.
|
|
|
|
|
fn cold(arm: &Arm, query: &str, options: &SearchOptions) -> (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_cascade(&conn, query, options);
|
|
|
|
|
if elapsed < best {
|
|
|
|
|
best = elapsed;
|
|
|
|
|
misses = cache_stats(&conn).1 - before;
|
|
|
|
|
}
|
|
|
|
|
hits = n;
|
|
|
|
|
}
|
|
|
|
|
(best, misses, hits)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 20:32:00 -04:00
|
|
|
/// Best of [`RUNS`] on one connection after a priming run, at the cache
|
|
|
|
|
/// ceiling the arm ships with: the steady state of a typing session, which
|
|
|
|
|
/// almost every real search is. A plain arm's flat 16 MiB can sit below its
|
|
|
|
|
/// `files` table; its warm runs then re-read pages from the OS cache, as a
|
|
|
|
|
/// plain index in use does.
|
2026-09-25 14:58:51 -04:00
|
|
|
fn warm(conn: &Connection, query: &str, options: &SearchOptions) -> Duration {
|
|
|
|
|
run_cascade(conn, query, options);
|
|
|
|
|
let mut best = Duration::MAX;
|
|
|
|
|
for _ in 0..RUNS {
|
|
|
|
|
best = best.min(run_cascade(conn, query, options).0);
|
|
|
|
|
}
|
|
|
|
|
best
|
|
|
|
|
}
|