2026-08-25 01:04:36 -04:00
|
|
|
|
//! How big the search connection's page cache has to be, and what it is worth
|
|
|
|
|
|
//! — the two claims `db::schema::PRAGMAS_SEARCH` and
|
|
|
|
|
|
//! `search::IDLE_RELEASE` rest on.
|
2026-08-23 00:33:53 -04:00
|
|
|
|
//!
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//! Queries run as a keystroke sequence (`quar`, `quart`, `quartz`,
|
|
|
|
|
|
//! `quartzi`), because that is what a real session is: the user types, and
|
|
|
|
|
|
//! every keystroke re-runs the search. **The warm column is the one that
|
|
|
|
|
|
//! decides the constant.** A cold query happens once, on the first keystroke
|
|
|
|
|
|
//! after `IDLE_RELEASE` drops the connection; a warm one happens on every
|
|
|
|
|
|
//! keystroke after it.
|
2026-08-23 00:33:53 -04:00
|
|
|
|
//!
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//! What has to stay resident is the **`files` table**, 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, no FTS
|
2026-09-06 19:43:57 -04:00
|
|
|
|
//! at all — 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 with document volume, and the corpus dimension below
|
|
|
|
|
|
//! is what makes that visible.
|
2026-08-23 00:33:53 -04:00
|
|
|
|
//!
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//! The keyed rows are where an undersized cache hurts first: a page-cache miss
|
|
|
|
|
|
//! costs an AES-CBC decrypt, not a `memcpy`.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! **Every keyed figure recorded below was taken while
|
|
|
|
|
|
//! `db::schema::HMAC_MODE` was HMAC-SHA512**, so a miss then also cost a
|
|
|
|
|
|
//! per-page verify. It no longer does, and `benches/cipher_hmac.rs` measured
|
|
|
|
|
|
//! that as 1.78x on warm search — so the knees found here are deeper than the
|
|
|
|
|
|
//! ones a re-sweep would find. `schema::SEARCH_CACHE_BYTES_PER_FILE` says the
|
|
|
|
|
|
//! same thing from the other side: it is now conservative, and re-running this
|
|
|
|
|
|
//! bench is what would tighten 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.
|
2026-08-23 00:33:53 -04:00
|
|
|
|
//!
|
|
|
|
|
|
//! ```text
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//! TMPDIR=/media/shared/qs-scratch QSB_SEARCH_PERF=1 \
|
|
|
|
|
|
//! cargo bench -p quicksearch-core --bench search_perf
|
2026-08-23 00:33:53 -04:00
|
|
|
|
//! ```
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//!
|
|
|
|
|
|
//! `TMPDIR` wants real storage: the 1M-file arms are ~580 MB each and a tmpfs
|
|
|
|
|
|
//! `/tmp` would turn every miss into a RAM copy. Budget ~20 minutes, nearly
|
|
|
|
|
|
//! all of it seeding.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! # What it found
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! Warm search, best of the settled session, `schema::PAGE_SIZE` = 8192, rows
|
|
|
|
|
|
//! at a realistic width (139 B — see [`spec`]):
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! | corpus | `files` table | keyed, under knee | keyed, at knee | knee | ratio |
|
|
|
|
|
|
//! |---|---|---|---|---|---|
|
|
|
|
|
|
//! | 200k | 26.5 MiB | 41.4 ms (≤24 MiB) | **10.7 ms** | 32 MiB | 1.21x |
|
|
|
|
|
|
//! | 600k | 79.5 MiB | 121.2 ms (≤64 MiB) | **33.7 ms** | 96 MiB | 1.21x |
|
|
|
|
|
|
//! | 1M | 132.4 MiB | 199.6 ms (≤128 MiB) | **58.3 ms** | 256 MiB | ≤1.93x |
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! 1. **The knee is 1.21x the `files` table, at every corpus.** Not
|
|
|
|
|
|
//! approximately: 26.5→32 and 79.5→96 both land on it, and 1M's true knee
|
|
|
|
|
|
//! is somewhere in (128, 256] where 1.21x predicts 160. It tracks *file
|
|
|
|
|
|
//! count*, not document volume, because what every keystroke rescans is
|
|
|
|
|
|
//! `files` (see the pass-A note above), never the FTS index. That product —
|
|
|
|
|
|
//! 139 B/row × 1.21 — is `schema::SEARCH_CACHE_BYTES_PER_FILE`.
|
|
|
|
|
|
//! 2. **Below the knee an encrypted index is 3.4–3.9x slower**, and the step
|
|
|
|
|
|
//! is a cliff, not a slope: 600k measured 121–130 ms at every ceiling from
|
|
|
|
|
|
//! 1 to 64 MiB and 33.7 ms at 96.
|
|
|
|
|
|
//! 3. **Plain has no knee.** Its widest spread was 1.35x and it is not even
|
|
|
|
|
|
//! monotonic — 32 and 48 MiB measured slower than 1 MiB at 600k — which is
|
|
|
|
|
|
//! run-to-run noise, not a curve. A miss it takes is a `memcpy` from the OS
|
|
|
|
|
|
//! cache; a miss the keyed arm takes is an AES-CBC decrypt (plus, when
|
|
|
|
|
|
//! these were measured, an HMAC-SHA512 verify). Hence
|
|
|
|
|
|
//! `schema::SEARCH_CACHE_PLAIN_MIB`, flat.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! **Row width is half the answer and was nearly missed.** The narrow rows the
|
|
|
|
|
|
//! search harnesses used to seed — `hash` NULL, a 16-character parent — are
|
|
|
|
|
|
//! 69.5 B, exactly half of a realistic 139 B. Calibrating against those would
|
|
|
|
|
|
//! have under-sized every cache by two and put every user back under the knee.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! **The 128 MiB automatic cap binds at ~800k files.** At 1M the derived value
|
|
|
|
|
|
//! is capped at 128 while the index wants ~160: 205 ms per keystroke against
|
|
|
|
|
|
//! the 58 ms available. That is the memory-versus-speed trade
|
|
|
|
|
|
//! `schema::SEARCH_CACHE_MAX_MIB` documents, and
|
|
|
|
|
|
//! `[search] cache_size_mib` is how a user takes the other side of it.
|
2026-08-23 00:33:53 -04:00
|
|
|
|
|
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
|
|
use quicksearch_core::query::split::split_for_cascade;
|
|
|
|
|
|
use quicksearch_core::search::{cascade, SearchHit, SearchOptions};
|
2026-08-25 01:04:36 -04:00
|
|
|
|
use quicksearch_core::testutil::{Arm, SeedSpec};
|
2026-08-23 00:33:53 -04:00
|
|
|
|
use rusqlite::Connection;
|
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
|
/// Corpora chosen to bracket `PRAGMAS_SEARCH`: at 200k the `files` table is
|
|
|
|
|
|
/// ~19 MiB and fits inside 32 MiB, at 1M it is ~98 MiB and cannot. If the
|
|
|
|
|
|
/// working set really is `files`, the knee moves between these two.
|
|
|
|
|
|
const CORPORA: [usize; 3] = [200_000, 600_000, 1_000_000];
|
|
|
|
|
|
|
|
|
|
|
|
/// Cache ceilings in KiB (negative is KiB; positive would be a page count).
|
|
|
|
|
|
/// 1 MiB is deliberately far too small — the curve needs a visible floor for
|
|
|
|
|
|
/// any "enough" to be a measurement — and 256 MiB is past anything shippable,
|
|
|
|
|
|
/// so a knee inside the range is a knee and not the edge of the sweep.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 24, 48 and 96 MiB break the powers of two. Without them the knee can only
|
|
|
|
|
|
/// be located to the next power up, which pins the cache-over-`files` ratio no
|
|
|
|
|
|
/// tighter than (1.0, 1.93] — too loose to derive a constant from.
|
|
|
|
|
|
const CACHE_SIZES: [i64; 12] = [
|
|
|
|
|
|
-1024, -2048, -4096, -8192, -16384, -24576, -32768, -49152, -65536, -98304, -131072, -262144,
|
|
|
|
|
|
];
|
2026-08-23 00:33:53 -04:00
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
|
/// What `PRAGMAS_SEARCH` ships with, marked in the output so the curve can be
|
|
|
|
|
|
/// read against it without counting columns.
|
|
|
|
|
|
const SHIPPED_CACHE: i64 = -32768;
|
2026-08-23 00:33:53 -04:00
|
|
|
|
|
|
|
|
|
|
const SEQUENCE: [&str; 4] = ["quar", "quart", "quartz", "quartzi"];
|
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
|
/// A cache is "enough" once warm is within this of the best warm on the same
|
|
|
|
|
|
/// arm — used to *locate* a knee, once there is one to locate.
|
|
|
|
|
|
const KNEE_TOLERANCE: f64 = 1.10;
|
|
|
|
|
|
|
|
|
|
|
|
/// How much worse the worst ceiling must be than the best before the curve is
|
|
|
|
|
|
/// called a knee at all.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// A real one is unmistakable: keyed at 200k ran 41.4 ms flat below 32 MiB and
|
|
|
|
|
|
/// 10.7 ms at or above it, monotonically, a 4x step. A plain arm at the same
|
|
|
|
|
|
/// corpus wanders over about 1.3x and is not even monotonic — 32 and 48 MiB
|
|
|
|
|
|
/// measured *slower* than 1 MiB — which is run-to-run noise wearing the shape
|
|
|
|
|
|
/// of a curve. At 1.10 the locator happily reports a knee in that noise, so
|
|
|
|
|
|
/// the gate to being a knee is set well above it.
|
|
|
|
|
|
const KNEE_MIN_SPREAD: f64 = 1.5;
|
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
|
fn enabled() -> bool {
|
|
|
|
|
|
std::env::var("QSB_SEARCH_PERF").is_ok()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
|
/// A **realistically wide** `files` row, which is the whole calibration.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The default seed stores `hash` NULL and a 16-character `/seed/NNN/` parent;
|
|
|
|
|
|
/// a real row carries a 32-byte content hash and a parent nested several
|
|
|
|
|
|
/// directories deep, and `parent` is stored per row. Since the working set
|
|
|
|
|
|
/// *is* the `files` table, calibrating a cache constant against the narrow
|
|
|
|
|
|
/// shape would under-size it by roughly the ratio between them — the bytes per
|
|
|
|
|
|
/// row are printed per arm so that ratio stays visible rather than assumed.
|
|
|
|
|
|
fn spec(files: usize) -> SeedSpec {
|
|
|
|
|
|
SeedSpec {
|
|
|
|
|
|
files,
|
|
|
|
|
|
// ~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.
|
|
|
|
|
|
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, which
|
|
|
|
|
|
// is an ordinary depth for a document tree.
|
|
|
|
|
|
dir_depth: 6,
|
|
|
|
|
|
..SeedSpec::default()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn mib(bytes: u64) -> f64 {
|
|
|
|
|
|
bytes as f64 / (1024.0 * 1024.0)
|
2026-08-23 00:33:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Run one query; hits are counted, not kept — holding 200k `SearchHit`s
|
|
|
|
|
|
/// would measure the allocator instead of the scan.
|
2026-09-06 19:43:57 -04:00
|
|
|
|
fn time_query(conn: &Connection, query: &str, options: &SearchOptions) -> (Duration, usize) {
|
2026-08-23 00:33:53 -04:00
|
|
|
|
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 start = Instant::now();
|
2026-09-06 19:43:57 -04:00
|
|
|
|
cascade::run(conn, &split, options, 1, &latest, &mut sink).unwrap();
|
2026-08-23 00:33:53 -04:00
|
|
|
|
(start.elapsed(), count)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 19:43:57 -04:00
|
|
|
|
/// The options every measurement here runs under: the sweep's, with `fuzzy`
|
|
|
|
|
|
/// the variable. Both `SearchOptions::default()` and the GUI default
|
|
|
|
|
|
/// (`[search] fuzzy_default`) have fuzzy **off** — the fuzzy rows below 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, per-term rather than averaged:
|
|
|
|
|
|
/// under the default `fuzzy_max_edits` of 2, `pigeonhole_chunks` needs
|
|
|
|
|
|
/// `3 × (k + 1)` characters, so terms under 9 characters keep the full scan
|
|
|
|
|
|
/// (`edit_budget` gives k=1 at 3–5 and k=2 from 6) and only the 9+ ones can
|
|
|
|
|
|
/// be prefiltered. Both shapes are in the sequence on purpose: 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 engaged 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 — the
|
|
|
|
|
|
/// gate for the fuzzy filename pass's shape (`passes::pass_fuzzy_filename`).
|
|
|
|
|
|
/// The off column is the control: a pass-C change has no business moving it.
|
|
|
|
|
|
fn run_fuzzy_pairing(arm: &Arm) {
|
|
|
|
|
|
let conn = arm.open_search();
|
|
|
|
|
|
conn.execute_batch(&format!("PRAGMA cache_size = {};", SHIPPED_CACHE))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
// 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 {
|
|
|
|
|
|
time_query(&conn, query, &opts);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"{:>12} {:>10} {:>10} {:>8} (fuzzy pairing, shipped cache, warm)",
|
|
|
|
|
|
"term", "off", "on", "hits"
|
|
|
|
|
|
);
|
|
|
|
|
|
for query in FUZZY_SEQUENCE {
|
|
|
|
|
|
let (off, _) = time_query(&conn, query, &options(false));
|
|
|
|
|
|
let (on, hits) = time_query(&conn, query, &options(true));
|
|
|
|
|
|
println!("{:>12} {:>9.1?} {:>9.1?} {:>8}", query, off, on, hits);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
|
/// 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 and
|
|
|
|
|
|
/// nothing else — the old version opened the plain arm with a raw
|
|
|
|
|
|
/// `Connection::open`, which skipped the key path entirely and made the two
|
|
|
|
|
|
/// columns incomparable.
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
2026-09-06 19:43:57 -04:00
|
|
|
|
let opts = options(false);
|
|
|
|
|
|
let (cold, hits) = time_query(&conn, SEQUENCE[0], &opts);
|
2026-08-25 01:04:36 -04:00
|
|
|
|
// A priming pass, so "warm" is a settled session rather than the three
|
|
|
|
|
|
// keystrokes after the first.
|
|
|
|
|
|
for query in SEQUENCE {
|
2026-09-06 19:43:57 -04:00
|
|
|
|
time_query(&conn, query, &opts);
|
2026-08-25 01:04:36 -04:00
|
|
|
|
}
|
2026-09-06 19:43:57 -04:00
|
|
|
|
let total: Duration = SEQUENCE
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|q| time_query(&conn, q, &opts).0)
|
|
|
|
|
|
.sum();
|
2026-08-25 01:04:36 -04:00
|
|
|
|
(cold, total / SEQUENCE.len() as u32, hits)
|
2026-08-23 00:33:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
|
fn run_matrix(arm: &Arm, files: usize) {
|
|
|
|
|
|
let files_bytes = arm.table_bytes("files");
|
|
|
|
|
|
// Bytes per row is the constant `schema::recommended_search_cache_mib` is
|
|
|
|
|
|
// built on, so it is printed rather than left to be inferred from the
|
|
|
|
|
|
// table size and the corpus.
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"\n=== {} === {:.1} MiB on disk, files table {:.1} MiB ({:.0} B/row), \
|
|
|
|
|
|
seeded in {:.1?}",
|
|
|
|
|
|
arm.what,
|
|
|
|
|
|
mib(arm.size_bytes()),
|
|
|
|
|
|
mib(files_bytes),
|
|
|
|
|
|
files_bytes as f64 / files as f64,
|
|
|
|
|
|
arm.seeded_in
|
|
|
|
|
|
);
|
2026-08-23 00:33:53 -04:00
|
|
|
|
println!(
|
|
|
|
|
|
"{:>12} {:>10} {:>10} {:>10} {:>8}",
|
2026-08-25 01:04:36 -04:00
|
|
|
|
"cache", "cold", "warm", "vs best", "hits"
|
2026-08-23 00:33:53 -04:00
|
|
|
|
);
|
2026-08-25 01:04:36 -04:00
|
|
|
|
|
|
|
|
|
|
let rows: Vec<(i64, Duration, Duration, usize)> = CACHE_SIZES
|
|
|
|
|
|
.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();
|
2026-08-23 00:33:53 -04:00
|
|
|
|
println!(
|
2026-08-25 01:04:36 -04:00
|
|
|
|
"{:>9} MiB{} {:>9.1?} {:>9.1?} {:>9.2}x {:>8}",
|
|
|
|
|
|
-cache_size / 1024,
|
|
|
|
|
|
if *cache_size == SHIPPED_CACHE {
|
|
|
|
|
|
" *"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
" "
|
|
|
|
|
|
},
|
|
|
|
|
|
cold,
|
|
|
|
|
|
warm,
|
|
|
|
|
|
ratio,
|
|
|
|
|
|
hits
|
2026-08-23 00:33:53 -04:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
|
// 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,
|
|
|
|
|
|
mib(files_bytes)
|
|
|
|
|
|
);
|
2026-08-23 00:33:53 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
|
// Otherwise the knee is the smallest ceiling still within tolerance of the
|
|
|
|
|
|
// best. Reported against the `files` table because that is the quantity it
|
|
|
|
|
|
// should track — if the two move together across corpora, the constant
|
|
|
|
|
|
// should be written in terms of file count. `max` on a negative KiB
|
|
|
|
|
|
// ceiling is the *smallest* cache.
|
|
|
|
|
|
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_CACHE);
|
|
|
|
|
|
// The ratio is the number the constant is derived from: how much cache one
|
|
|
|
|
|
// byte of `files` needs. Reported per arm so the derivation can be checked
|
|
|
|
|
|
// against every corpus rather than fitted to one.
|
2026-08-23 00:33:53 -04:00
|
|
|
|
println!(
|
2026-08-25 01:04:36 -04:00
|
|
|
|
"knee at {} MiB (within {:.0}% of best warm); files table is {:.1} MiB; \
|
|
|
|
|
|
ratio {:.2}x; shipped ceiling is {} MiB{}",
|
|
|
|
|
|
-knee / 1024,
|
|
|
|
|
|
(KNEE_TOLERANCE - 1.0) * 100.0,
|
|
|
|
|
|
mib(files_bytes),
|
|
|
|
|
|
(-knee * 1024) as f64 / files_bytes as f64,
|
|
|
|
|
|
-SHIPPED_CACHE / 1024,
|
|
|
|
|
|
if knee < SHIPPED_CACHE {
|
|
|
|
|
|
" — TOO SMALL for this corpus"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
""
|
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
|
fn main() {
|
|
|
|
|
|
if !enabled() {
|
|
|
|
|
|
eprintln!("skipping: set QSB_SEARCH_PERF=1 to run");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if std::env::var_os("TMPDIR").is_none() {
|
|
|
|
|
|
eprintln!(
|
|
|
|
|
|
"warning: TMPDIR unset — scratch goes to {}. If that is tmpfs the \
|
|
|
|
|
|
large arms will not fit and every miss is a RAM copy.",
|
|
|
|
|
|
std::env::temp_dir().display()
|
2026-08-23 00:33:53 -04:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-06 19:43:57 -04:00
|
|
|
|
// `QSB_SEARCH_PERF=fuzzy` runs only the fuzzy pairing — the cache sweep
|
|
|
|
|
|
// is minutes per arm and a pass-C gate does not need it. `QSB_CORPORA`
|
|
|
|
|
|
// trims the corpus list the same way (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 {
|
2026-08-25 01:04:36 -04:00
|
|
|
|
for keyed in [false, true] {
|
|
|
|
|
|
let arm = Arm::seed(
|
|
|
|
|
|
format!(
|
|
|
|
|
|
"{} {}k files",
|
|
|
|
|
|
if keyed { "keyed" } else { "plain" },
|
|
|
|
|
|
files / 1000
|
|
|
|
|
|
),
|
|
|
|
|
|
&format!("searchperf-{}-{}", files, keyed),
|
|
|
|
|
|
keyed,
|
2026-09-06 19:43:57 -04:00
|
|
|
|
&if fuzzy_only {
|
|
|
|
|
|
light_spec(files)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
spec(files)
|
|
|
|
|
|
},
|
2026-08-25 01:04:36 -04:00
|
|
|
|
);
|
2026-09-06 19:43:57 -04:00
|
|
|
|
if fuzzy_only {
|
|
|
|
|
|
println!("\n=== {} === seeded in {:.1?}", arm.what, arm.seeded_in);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
run_matrix(&arm, files);
|
|
|
|
|
|
}
|
|
|
|
|
|
run_fuzzy_pairing(&arm);
|
2026-08-25 01:04:36 -04:00
|
|
|
|
arm.discard();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
|
}
|
2026-09-06 19:43:57 -04:00
|
|
|
|
|
|
|
|
|
|
/// 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 to the number under test. One row in `usize::MAX` still
|
|
|
|
|
|
/// gets content, which keeps pass D exercised without costing anything.
|
|
|
|
|
|
fn light_spec(files: usize) -> SeedSpec {
|
|
|
|
|
|
SeedSpec {
|
|
|
|
|
|
content_every: usize::MAX,
|
|
|
|
|
|
..spec(files)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|