2026-08-25 01:04:36 -04:00
|
|
|
|
//! What the index's page geometry costs, on disk and in query time.
|
|
|
|
|
|
//!
|
2026-09-25 14:58:51 -04:00
|
|
|
|
//! Two levers, swept together because they are coupled: the database page
|
|
|
|
|
|
//! size (`db::schema::PAGE_SIZE`) and FTS5's record size
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//! (`db::schema::fts_pgsz_for`). The second is derived from the first, so
|
2026-09-25 14:58:51 -04:00
|
|
|
|
//! neither can move alone: at a page size of 8192, an FTS5 record built for
|
|
|
|
|
|
//! 4096 leaves half of every page empty.
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//!
|
|
|
|
|
|
//! ```text
|
|
|
|
|
|
//! TMPDIR=/media/shared/qs-scratch QSB_PGSZ=1 \
|
|
|
|
|
|
//! cargo bench -p quicksearch-core --bench page_geometry
|
|
|
|
|
|
//! ```
|
|
|
|
|
|
//!
|
2026-09-25 14:58:51 -04:00
|
|
|
|
//! Point `TMPDIR` at real storage; see `arms::warn_if_tmpdir_unset`. The
|
2026-10-11 20:32:00 -04:00
|
|
|
|
//! matrix wanted ~2 GB of scratch at a time, since each arm is dropped once
|
|
|
|
|
|
//! measured; that predates the seed's uncapped WAL (`testutil::seed_index`),
|
|
|
|
|
|
//! which needs room on top.
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//!
|
|
|
|
|
|
//! # 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
|
2026-09-25 14:58:51 -04:00
|
|
|
|
//! record of 4050 fits a plain 4096-byte page; under an 80-byte HMAC-SHA512
|
|
|
|
|
|
//! reserve it missed by 71 bytes and sent every full leaf to an overflow page.
|
|
|
|
|
|
//! `fts_pgsz_for` derives the record from the page size and reserve, which
|
|
|
|
|
|
//! keeps the miss at zero at every page size. Measured at 120k files before
|
|
|
|
|
|
//! the derivation existed; keyed figures predate `HMAC_MODE = Off` (see
|
|
|
|
|
|
//! `db::schema::HMAC_MODE`):
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//!
|
|
|
|
|
|
//! | | 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.
|
2026-10-11 20:32:00 -04:00
|
|
|
|
//! `tests/encrypted_size.rs` gates the size half, so this bench does not
|
2026-09-25 14:58:51 -04:00
|
|
|
|
//! re-measure it.
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//!
|
2026-09-25 14:58:51 -04:00
|
|
|
|
//! # Settled: database page size
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//!
|
2026-09-25 14:58:51 -04:00
|
|
|
|
//! 8192; `db::schema::PAGE_SIZE` holds the sweep and the reasoning.
|
2026-08-25 01:04:36 -04:00
|
|
|
|
//!
|
|
|
|
|
|
//! # Reading it
|
|
|
|
|
|
//!
|
2026-10-11 20:32:00 -04:00
|
|
|
|
//! The plain arm is the noise floor. Cold is SQLite-cold: a fresh
|
|
|
|
|
|
//! connection, so its cache starts empty. Nothing evicts the OS cache, so no
|
|
|
|
|
|
//! tier is storage-cold. Warm runs at the cache ceiling each arm ships with;
|
|
|
|
|
|
//! the sweep on `db::schema::PAGE_SIZE` predates that and ran at the 16 MiB
|
|
|
|
|
|
//! floor, below the `files` table, so its warm ranking prices per-page work,
|
|
|
|
|
|
//! not a warm keystroke at the shipped ceiling. Report the tiers separately;
|
|
|
|
|
|
//! do not average them.
|
2026-08-25 01:04:36 -04:00
|
|
|
|
|
2026-09-25 14:58:51 -04:00
|
|
|
|
mod arms;
|
2026-08-25 01:04:36 -04:00
|
|
|
|
|
|
|
|
|
|
use quicksearch_core::db;
|
2026-09-25 14:58:51 -04:00
|
|
|
|
use quicksearch_core::testutil::{Arm, SeedSpec};
|
2026-08-25 01:04:36 -04:00
|
|
|
|
|
2026-09-25 14:58:51 -04:00
|
|
|
|
/// Page sizes to sweep, up to `SQLITE_MAX_PAGE_SIZE`. 512 is excluded: under
|
|
|
|
|
|
/// HMAC-SHA512's 80-byte reserve its usable size falls below 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.
|
2026-08-25 01:04:36 -04:00
|
|
|
|
const SWEPT: [i64; 7] = [1024, 2048, 4096, 8192, 16384, 32768, 65536];
|
|
|
|
|
|
|
2026-09-25 14:58:51 -04:00
|
|
|
|
/// `QSB_PGSZ_SIZES=8192,16384` narrows the sweep. A full run takes ~45
|
|
|
|
|
|
/// minutes, nearly all of it seeding, so re-asking one question should not
|
|
|
|
|
|
/// re-ask all of them.
|
2026-08-25 01:04:36 -04:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-25 14:58:51 -04:00
|
|
|
|
/// The shape tier: every page size, cheap enough to run them all.
|
2026-08-25 01:04:36 -04:00
|
|
|
|
const SHAPE_FILES: usize = 200_000;
|
|
|
|
|
|
/// The confirmation tiers, run only for the baseline and the shape tier's
|
2026-10-11 20:32:00 -04:00
|
|
|
|
/// winner.
|
2026-08-25 01:04:36 -04:00
|
|
|
|
const SCALE_FILES: [usize; 2] = [600_000, 1_000_000];
|
|
|
|
|
|
|
|
|
|
|
|
fn enabled() -> bool {
|
|
|
|
|
|
std::env::var("QSB_PGSZ").is_ok()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn spec(files: usize, page_size: i64) -> SeedSpec {
|
|
|
|
|
|
SeedSpec {
|
|
|
|
|
|
page_size: Some(page_size),
|
2026-09-25 14:58:51 -04:00
|
|
|
|
..arms::spec(files)
|
2026-08-25 01:04:36 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn main() {
|
|
|
|
|
|
if !enabled() {
|
|
|
|
|
|
eprintln!("skipping: set QSB_PGSZ=1 to run");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-11 20:32:00 -04:00
|
|
|
|
arms::warn_if_tmpdir_unset("the large tiers may not fit");
|
2026-08-25 01:04:36 -04:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-25 14:58:51 -04:00
|
|
|
|
/// size with the lowest total warm time, the metric that matters because a
|
2026-08-25 01:04:36 -04:00
|
|
|
|
/// session re-queries on every keystroke.
|
|
|
|
|
|
fn shape_tier() -> i64 {
|
2026-09-25 14:58:51 -04:00
|
|
|
|
arms::banner("shape", SHAPE_FILES);
|
2026-08-25 01:04:36 -04:00
|
|
|
|
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,
|
2026-09-25 14:58:51 -04:00
|
|
|
|
arms::WORKLOADS.len()
|
2026-08-25 01:04:36 -04:00
|
|
|
|
);
|
|
|
|
|
|
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 {
|
2026-09-25 14:58:51 -04:00
|
|
|
|
arms::banner("scale", files);
|
2026-08-25 01:04:36 -04:00
|
|
|
|
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 {
|
2026-09-25 14:58:51 -04:00
|
|
|
|
arms::header(arm);
|
|
|
|
|
|
arms::attribution(arm);
|
|
|
|
|
|
arms::workloads(arm)
|
2026-08-25 01:04:36 -04:00
|
|
|
|
}
|