quick_search/crates/quicksearch-core/benches/search_perf.rs
Jeremy Karst be30ddc9af
All checks were successful
CI / linux (push) Successful in 16m22s
CI / windows-cross (push) Successful in 5m9s
CI / release (push) Successful in 15s
Tail-check duplicates, re-measure benchmarks and fix CI's release-only test failures
- Duplicates tab: groups are confirmed by each file's last 8 KiB and
  keyed by head+tail; unchecked files are never shown. False-positive
  duplicates drop from 3.4% to 0.1%, and indexing is unchanged.
- Encrypted search cache cap raised to 192 MiB at 150 B per file: a
  keystroke at 1M files takes 59 ms, down from 99 ms.
- Case-insensitive filename matching uses a memchr2 scan (1.18x).
- Benches run again on the compact index, seed with the indexer's 2 GiB
  WAL cap (peak scratch 6.2 GB -> 2.45 GB), and every recorded figure
  is re-measured.
- A fresh index no longer logs a rebuild warning.
- CI: the sandbox memory test's allocation survives release builds, and
  --toggle retries an interrupted read.
2026-10-12 00:14:10 -04:00

373 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! How big the search connection's page cache has to be, and what it is
//! worth: the sweep behind `db::schema::recommended_search_cache_mib` and
//! `search::IDLE_RELEASE`.
//!
//! Queries run as a keystroke sequence (`quar`, `quart`, `quartz`,
//! `quartzi`), as in a real session, where every keystroke re-runs the
//! search. The warm column decides the constant: a cold query happens once,
//! on the first keystroke after `IDLE_RELEASE` drops the connection, and a
//! warm one on every keystroke after it.
//!
//! The `files` table has to stay resident, not the FTS index.
//! `search/cascade/passes.rs` answers filename queries (ranks 1–4, 9–10) with
//! `SELECT … FROM files f WHERE f.name LIKE '%…%'`, a full table scan with no
//! FTS, and the fuzzy pass scans it again (`LIKE`-narrowed for terms of 9+
//! characters, unpredicated below that). So the working set scales with file
//! count, not document volume, and the corpus dimension below shows it.
//!
//! Printed rather than asserted: shared-box timings are not stable enough for
//! a pass/fail gate, and a flaky perf gate gets muted rather than fixed.
//!
//! ```text
//! TMPDIR=/media/shared/qs-scratch QSB_SEARCH_PERF=1 \
//! cargo bench -p quicksearch-core --bench search_perf
//! ```
//!
//! Point `TMPDIR` at real storage (see `arms::warn_if_tmpdir_unset`). One arm
//! is held at a time, and a 1M-file arm peaks at about 2.5 GB of scratch: its
//! 344 MiB database plus a WAL that `testutil::seed_index` checkpoints as a
//! default-config run does, at `maximum_wal_size` (2 GiB; less on a volume
//! with under ~8 GiB free). Budget about 2 minutes, most of it seeding.
//!
//! # What it found
//!
//! Warm search, the mean keystroke of one settled pass, medians of 3 runs,
//! `schema::PAGE_SIZE` = 8192, `HMAC_MODE = Off`, rows at a realistic width
//! (137 B; see [`spec`]), on [`cache_sizes`]'s grid (2026-10-11):
//!
//! | corpus | `files` table | keyed, under knee | keyed, at knee | knee | ratio |
//! |---|---|---|---|---|---|
//! | 200k | 26.1 MiB | 18.1–18.4 ms (≤1.0x) | **12.1 ms** | 27.4 MiB | 1.05x |
//! | 600k | 78.3 MiB | 57.8–61.3 ms (≤1.0x) | **35.6 ms** | 82.2 MiB | 1.05x |
//! | 1M | 130.4 MiB | 85.3–99.0 ms (≤1.0x) | **60.0 ms** | 136.9 MiB | 1.05x |
//!
//! 1. The knee is at 1.05x the `files` table on every corpus: the table
//! itself is under it. Hence 137 B/row × 1.05 = 144 B, plus about 4% of
//! margin: 150 B, `schema::SEARCH_CACHE_BYTES_PER_FILE`.
//! 2. Below the knee an encrypted index is 1.5–1.7x slower (3.4–3.9x under
//! HMAC-SHA512), and the step is a cliff, not a slope: 600k keyed ran
//! 58–61 ms at every ceiling up to the table and 35.6 ms at 1.05x.
//! 3. Plain has no cliff, but it runs 10–25% faster once the cache holds the
//! `files` table: 13.0 → 10.7–12.1 ms at 200k, 43.0 → 34.3–36.6 ms at
//! 600k, 63.6 → 57.5–59.2 ms at 1M, from its flat 16 MiB to 1.05x the
//! table and up. A miss on a plain index is a `memcpy` from the OS cache;
//! on a keyed one it is an AES-CBC decrypt. `schema::SEARCH_CACHE_PLAIN_MIB`
//! stays flat by choice, for memory.
//!
//! Row width is half the answer. The default seed's narrow rows (`hash` NULL,
//! a 16-character `/seed/NNN/` parent) measure 69.5 B, half of a realistic
//! 139 B; calibrating on them would under-size every cache by two and put
//! every user back under the knee.
//!
//! The 192 MiB automatic cap (`schema::SEARCH_CACHE_MAX_MIB`) binds at
//! ~1.34M files, and the knee passes it at ~1.4M. The cap was 128 MiB, under
//! 1M's knee: 99.0 ms per keystroke there, against 58.9 ms at the 143 MiB it
//! now derives. Past ~1.4M, `[search] cache_size_mib` is how a user trades
//! memory for that speed.
mod arms;
use std::time::Duration;
use quicksearch_core::db::schema::recommended_search_cache_mib;
use quicksearch_core::search::SearchOptions;
use quicksearch_core::testutil::{run_cascade, Arm, SeedSpec};
/// Three corpora under the automatic cap (`schema::SEARCH_CACHE_MAX_MIB`
/// binds at ~1.34M files). If the working set is `files`, the knee moves with
/// the corpus.
const CORPORA: [usize; 3] = [200_000, 600_000, 1_000_000];
/// Cache ceilings to sweep, as multiples of the arm's own `files` table. The
/// knee sits at the first ceiling that holds the table, which the earlier
/// fixed grid could only place at ≤1.21x, too loose to tell how much of
/// `schema::SEARCH_CACHE_BYTES_PER_FILE`'s headroom is needed.
const TABLE_MULTIPLES: [f64; 5] = [1.0, 1.05, 1.1, 1.2, 1.5];
/// The sweep's ends. 1 MiB is far too small, because the curve needs a
/// visible floor for any "enough" to be a measurement, and 256 MiB is past
/// the automatic cap, so a knee inside the range is a knee and not the edge
/// of the sweep.
const FLOOR_MIB: i64 = 1;
const CEILING_MIB: i64 = 256;
/// The ceilings for an arm whose `files` table is `files_bytes`, as
/// `PRAGMA cache_size` values, smallest cache first: negative is KiB
/// (positive would be a page count). The ends, [`TABLE_MULTIPLES`], and
/// `shipped`, so the curve can be read against the ceiling a user gets.
fn cache_sizes(files_bytes: u64, shipped: i64) -> Vec<i64> {
let mut kib: Vec<i64> = TABLE_MULTIPLES
.iter()
.map(|m| (files_bytes as f64 * m / 1024.0).ceil() as i64)
.collect();
kib.extend([FLOOR_MIB * 1024, CEILING_MIB * 1024, -shipped]);
kib.sort_unstable();
kib.dedup();
kib.into_iter().map(|k| -k).collect()
}
/// The ceiling `open_search_reader` derives for this arm, as a
/// `PRAGMA cache_size` value. The output marks it, so the curve can be read
/// against it without counting rows.
fn shipped_cache(arm: &Arm, files: usize) -> i64 {
-recommended_search_cache_mib(files as i64, arm.keyed) * 1024
}
const SEQUENCE: [&str; 4] = ["quar", "quart", "quartz", "quartzi"];
/// A cache is "enough" once warm is within this of the best warm on the same
/// arm. Locates a knee once [`KNEE_MIN_SPREAD`] says there is one.
const KNEE_TOLERANCE: f64 = 1.10;
/// How much worse the worst ceiling must be than the best before the curve
/// counts as a knee. A real one was a 3.4–3.9x step under HMAC-SHA512 and is
/// 1.5–1.7x with `HMAC_MODE = Off` (keyed at 200k: 18.1 ms at the table,
/// 12.1 ms at 1.05x), right at this gate. A plain arm's spread, its 10–25%
/// gain once the table fits plus noise, measured 1.13–1.65x, so the two
/// overlap: across the 2026-10-11 runs one keyed arm missed its knee at 1.50x
/// and two plain arms reported one. Read a knee off several runs, not one.
/// [`KNEE_TOLERANCE`] alone would find a knee in every plain arm.
const KNEE_MIN_SPREAD: f64 = 1.5;
fn enabled() -> bool {
std::env::var("QSB_SEARCH_PERF").is_ok()
}
/// A realistically wide `files` row: a content hash and a parent several
/// directories deep, where the default seed has `hash` NULL and a
/// 16-character `/seed/NNN/` parent. The row width sets the calibration (see
/// the module doc), so bytes per row are printed per arm.
fn spec(files: usize) -> SeedSpec {
SeedSpec {
files,
commit_every: 5_000,
// A hash on every row, as a real index has once hashing has run.
dup_every: 2,
// `/seed/NNN/word/word/word/word/word/`: about 50 characters, an
// ordinary depth for a document tree.
dir_depth: 6,
// ~2 KB documents, the default: a corpus of tiny ones would make the
// full-text pass look free when it is the cascade's most expensive.
..SeedSpec::default()
}
}
/// The options every measurement here runs under, with `fuzzy` the variable.
/// Fuzzy is off in both `SearchOptions::default()` and the GUI default
/// (`[search] fuzzy_default`), so the fuzzy rows are what a user opts into,
/// not what a default install pays per keystroke.
fn options(fuzzy: bool) -> SearchOptions {
SearchOptions {
limit: 1000,
fuzzy,
..SearchOptions::default()
}
}
/// The fuzzy pairing's keystroke sequence, reported per term rather than
/// averaged. Under the default `fuzzy_max_edits` of 2, `pigeonhole_chunks`
/// needs `3 × (k + 1)` characters (`edit_budget` gives k=1 at 3–5 and k=2
/// from 6), so only terms of 9+ characters are prefiltered. The short terms
/// are the control a pass-C narrowing must not move; the long ones are what
/// it narrows. `quartzite` is `testutil::NEEDLE`, present in seeded names, so
/// the prefiltered rows return real hits.
const FUZZY_SEQUENCE: [&str; 4] = ["quar", "quartz", "quartzite", "quartzites"];
/// Warm per-term timings, fuzzy off against on, at the shipped cache, which
/// the counts `seed_index` records give the connection: the gate for the
/// fuzzy filename pass (`passes::pass_fuzzy_filename`). The off column is the
/// control, which a pass-C change must not move.
fn run_fuzzy_pairing(arm: &Arm) {
let conn = arm.open_search();
// One settling pass per mode so every printed number is warm.
for fuzzy in [false, true] {
let opts = options(fuzzy);
for query in FUZZY_SEQUENCE {
run_cascade(&conn, query, &opts);
}
}
println!(
"{:>12} {:>10} {:>10} {:>8} (fuzzy pairing, shipped cache, warm)",
"term", "off", "on", "hits"
);
for query in FUZZY_SEQUENCE {
let (off, _) = run_cascade(&conn, query, &options(false));
let (on, hits) = run_cascade(&conn, query, &options(true));
println!("{:>12} {:>9.1?} {:>9.1?} {:>8}", query, off, on, hits);
}
}
/// One arm at one cache ceiling: the first keystroke on a fresh connection,
/// then the steady state after a priming pass. The connection comes from the
/// production `open_search_reader` with only `cache_size` overridden, so the
/// two key states differ by the key alone.
fn measure(arm: &Arm, cache_size: i64) -> (Duration, Duration, usize) {
let conn = arm.open_search();
conn.execute_batch(&format!("PRAGMA cache_size = {};", cache_size))
.unwrap();
let opts = options(false);
let (cold, hits) = run_cascade(&conn, SEQUENCE[0], &opts);
// A priming pass, so "warm" is a settled session rather than the three
// keystrokes after the first.
for query in SEQUENCE {
run_cascade(&conn, query, &opts);
}
let total: Duration = SEQUENCE
.iter()
.map(|q| run_cascade(&conn, q, &opts).0)
.sum();
(cold, total / SEQUENCE.len() as u32, hits)
}
fn run_matrix(arm: &Arm, files: usize) {
let files_bytes = arm.table_bytes("files");
let shipped = shipped_cache(arm, files);
// `schema::SEARCH_CACHE_BYTES_PER_FILE` is built on bytes per row, so it
// is printed rather than left to be inferred.
println!(
"\n=== {} === {:.1} MiB on disk, files table {:.1} MiB ({:.0} B/row), \
seeded in {:.1?}",
arm.what,
arms::mib(arm.size_bytes()),
arms::mib(files_bytes),
files_bytes as f64 / files as f64,
arm.seeded_in
);
println!(
"{:>13} {:>7} {:>10} {:>10} {:>10} {:>8}",
"cache", "/files", "cold", "warm", "vs best", "hits"
);
let rows: Vec<(i64, Duration, Duration, usize)> = cache_sizes(files_bytes, shipped)
.into_iter()
.map(|cache_size| {
let (cold, warm, hits) = measure(arm, cache_size);
(cache_size, cold, warm, hits)
})
.collect();
let best = rows
.iter()
.map(|(_, _, warm, _)| *warm)
.min()
.unwrap_or_default();
for (cache_size, cold, warm, hits) in &rows {
let ratio = warm.as_secs_f64() / best.as_secs_f64();
println!(
"{:>7.1} MiB{} {:>6.2}x {:>9.1?} {:>9.1?} {:>9.2}x {:>8}",
-*cache_size as f64 / 1024.0,
if *cache_size == shipped { " *" } else { " " },
(-cache_size * 1024) as f64 / files_bytes as f64,
cold,
warm,
ratio,
hits
);
}
// A curve without a real step is the normal shape for a *plain* arm, and
// naming the low point of its noise a knee would invent a result.
let worst = rows
.iter()
.map(|(_, _, warm, _)| *warm)
.max()
.unwrap_or_default();
let spread = worst.as_secs_f64() / best.as_secs_f64();
if spread < KNEE_MIN_SPREAD {
println!(
"no knee — warm spans only {:.2}x across the whole sweep, under \
the {:.1}x a step has to clear; files table is {:.1} MiB",
spread,
KNEE_MIN_SPREAD,
arms::mib(files_bytes)
);
return;
}
// Otherwise the knee is the smallest ceiling within tolerance of the best
// (`max` on a negative KiB ceiling is the smallest cache), reported
// against the `files` table, the quantity it should track across corpora.
let knee = rows
.iter()
.filter(|(_, _, warm, _)| warm.as_secs_f64() <= best.as_secs_f64() * KNEE_TOLERANCE)
.map(|(cache_size, _, _, _)| *cache_size)
.max()
.unwrap_or(shipped);
// The constant is derived from the ratio, cache per byte of `files`;
// printing it per arm checks the derivation against every corpus rather
// than fitting it to one.
println!(
"knee at {:.1} MiB (within {:.0}% of best warm); files table is {:.1} MiB; \
ratio {:.2}x; shipped ceiling is {} MiB{}",
-knee as f64 / 1024.0,
(KNEE_TOLERANCE - 1.0) * 100.0,
arms::mib(files_bytes),
(-knee * 1024) as f64 / files_bytes as f64,
-shipped / 1024,
if knee < shipped {
" — TOO SMALL for this corpus"
} else {
""
}
);
}
fn main() {
if !enabled() {
eprintln!("skipping: set QSB_SEARCH_PERF=1 to run");
return;
}
arms::warn_if_tmpdir_unset("the large arms may not fit");
// `QSB_SEARCH_PERF=fuzzy` runs only the fuzzy pairing: the cache sweep
// takes minutes per arm and a pass-C gate does not need it. `QSB_CORPORA`
// trims the corpus list (comma-separated file counts).
let fuzzy_only = std::env::var("QSB_SEARCH_PERF").as_deref() == Ok("fuzzy");
let corpora: Vec<usize> = std::env::var("QSB_CORPORA")
.ok()
.map(|v| {
v.split(',')
.filter_map(|n| n.trim().parse().ok())
.collect()
})
.filter(|v: &Vec<usize>| !v.is_empty())
.unwrap_or_else(|| CORPORA.to_vec());
for files in corpora {
for keyed in [false, true] {
let arm = Arm::seed(
format!(
"{} {}k files",
if keyed { "keyed" } else { "plain" },
files / 1000
),
&format!("searchperf-{}-{}", files, keyed),
keyed,
&if fuzzy_only {
light_spec(files)
} else {
spec(files)
},
);
if fuzzy_only {
println!("\n=== {} === seeded in {:.1?}", arm.what, arm.seeded_in);
} else {
run_matrix(&arm, files);
}
run_fuzzy_pairing(&arm);
arm.discard();
}
}
}
/// The fuzzy-only corpus: `files` rows and nothing else. Pass C never reads a
/// document (its whole cost is the `files` scan plus the bitap), and 2 KB
/// bodies turn a minutes-long seed into hours on slow storage while adding
/// only pass-D noise. One row in `usize::MAX` still gets content, which keeps
/// pass D exercised at no cost.
fn light_spec(files: usize) -> SeedSpec {
SeedSpec {
content_every: usize::MAX,
..spec(files)
}
}