390 lines
13 KiB
Rust
390 lines
13 KiB
Rust
|
|
//! What SQLCipher's per-page authenticator costs, so the build can decide
|
||
|
|
//! whether to keep one.
|
||
|
|
//!
|
||
|
|
//! ```text
|
||
|
|
//! TMPDIR=/media/shared/qs-scratch QSB_HMAC=1 \
|
||
|
|
//! cargo bench -p quicksearch-core --bench cipher_hmac
|
||
|
|
//! ```
|
||
|
|
//!
|
||
|
|
//! `TMPDIR` is not optional in spirit, for the reason
|
||
|
|
//! `benches/page_geometry.rs` gives at length: a tmpfs `/tmp` cannot produce a
|
||
|
|
//! page fetch that was not already in RAM, and the scale tier will not fit
|
||
|
|
//! besides. Point it at real storage.
|
||
|
|
//!
|
||
|
|
//! # The question
|
||
|
|
//!
|
||
|
|
//! The cipher is not a choice. SQLCipher 4 removed `PRAGMA cipher` and the
|
||
|
|
//! provider hard-codes AES-256-CBC, so the only lever the build has is the
|
||
|
|
//! HMAC — and that lever is worth pulling on because the index holds text read
|
||
|
|
//! out of files the same user can already read. Anything positioned to *tamper*
|
||
|
|
//! with the index could read the originals instead, so per-page authentication
|
||
|
|
//! defends very little while being paid on every page read and every page
|
||
|
|
//! write. An unprotected index has never had any, either.
|
||
|
|
//!
|
||
|
|
//! Three modes, and the reason the middle one is not obviously pointless:
|
||
|
|
//!
|
||
|
|
//! | mode | reserve | per page |
|
||
|
|
//! |---|---|---|
|
||
|
|
//! | `Sha512` | 80 | SQLCipher's default |
|
||
|
|
//! | `Sha256` | 48 | SHA-NI on Zen and Ice Lake+, and 32 bytes of page back |
|
||
|
|
//! | `Off` | 16 | no authenticator at all |
|
||
|
|
//!
|
||
|
|
//! The reserve matters twice: it is page space the rows do not get, and
|
||
|
|
//! `db::schema::fts_pgsz_for` derives FTS5's record size from it, so each arm
|
||
|
|
//! also gets a differently-shaped leaf.
|
||
|
|
//!
|
||
|
|
//! **The write path is the one to watch.** `sqlcipher_openssl_hmac` calls
|
||
|
|
//! `EVP_MAC_fetch(NULL, "HMAC", NULL)`, `EVP_MAC_CTX_new` and an
|
||
|
|
//! `EVP_MAC_init` that fetches the digest *by name* — two OpenSSL 3 provider
|
||
|
|
//! lookups per page, on top of the hash itself. That fixed cost is paid
|
||
|
|
//! whichever digest is selected, which is why `Sha256` may buy far less than
|
||
|
|
//! its digest speed suggests, and why `Off` may buy far more.
|
||
|
|
//!
|
||
|
|
//! # Reading it
|
||
|
|
//!
|
||
|
|
//! The plain arm is the noise floor, not a candidate: it is what the product
|
||
|
|
//! does with no password set. Rank the three keyed arms against each other and
|
||
|
|
//! against it.
|
||
|
|
|
||
|
|
use std::time::{Duration, Instant};
|
||
|
|
|
||
|
|
use quicksearch_core::db;
|
||
|
|
use quicksearch_core::db::schema::HmacMode;
|
||
|
|
use quicksearch_core::query::split::split_for_cascade;
|
||
|
|
use quicksearch_core::search::{cascade, find_duplicate_groups, SearchHit, SearchOptions};
|
||
|
|
use quicksearch_core::testutil::{cache_stats, Arm, SeedSpec, BODY_TERM, NEEDLE};
|
||
|
|
use rusqlite::Connection;
|
||
|
|
|
||
|
|
/// Newest-to-oldest is deliberate: `Off` is the candidate, `Sha512` the
|
||
|
|
/// incumbent, and reporting the candidate first makes the table read as a
|
||
|
|
/// comparison against what ships rather than a sweep with no thesis.
|
||
|
|
const MODES: [HmacMode; 3] = [HmacMode::Off, HmacMode::Sha256, HmacMode::Sha512];
|
||
|
|
|
||
|
|
/// The shape tier — all four arms, cheap enough to run every time.
|
||
|
|
const SHAPE_FILES: usize = 200_000;
|
||
|
|
/// The confirmation tier, where the working set stops fitting the OS cache and
|
||
|
|
/// real reads enter. `QSB_HMAC_SHAPE_ONLY=1` skips it.
|
||
|
|
const SCALE_FILES: usize = 1_000_000;
|
||
|
|
|
||
|
|
const CONTENT_EVERY: usize = 8;
|
||
|
|
|
||
|
|
/// Commit in slices, as a production run does: each commit flushes an FTS5
|
||
|
|
/// segment, so a single enormous transaction would not resemble one — and the
|
||
|
|
/// write path is half of what this bench is for.
|
||
|
|
const COMMIT_EVERY: usize = 5_000;
|
||
|
|
|
||
|
|
/// 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.
|
||
|
|
const RUNS: u32 = 5;
|
||
|
|
|
||
|
|
/// The workloads, in the order they are reported. One common word leads: its
|
||
|
|
/// posting lists are long, where the rare terms stop at the display limit
|
||
|
|
/// having touched very little.
|
||
|
|
const WORKLOADS: [(&str, &str, bool); 6] = [
|
||
|
|
("body (common)", "planning", false),
|
||
|
|
("body (rare)", BODY_TERM, false),
|
||
|
|
("name", NEEDLE, false),
|
||
|
|
("fuzzy", "quartzlte", true),
|
||
|
|
("wildcard", "quart*", false),
|
||
|
|
("regex", "regex:quart[sz]ite", false),
|
||
|
|
];
|
||
|
|
|
||
|
|
fn enabled() -> bool {
|
||
|
|
std::env::var("QSB_HMAC").is_ok()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn shape_only() -> bool {
|
||
|
|
std::env::var("QSB_HMAC_SHAPE_ONLY").is_ok()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn spec(files: usize, hmac: Option<HmacMode>) -> SeedSpec {
|
||
|
|
SeedSpec {
|
||
|
|
files,
|
||
|
|
content_every: CONTENT_EVERY,
|
||
|
|
dup_every: 5,
|
||
|
|
commit_every: COMMIT_EVERY,
|
||
|
|
hmac,
|
||
|
|
..SeedSpec::default()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn mib(bytes: u64) -> f64 {
|
||
|
|
bytes as f64 / (1024.0 * 1024.0)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// One arm's identity: `None` is the plain floor, `Some` a keyed mode.
|
||
|
|
fn arms(tag: &str) -> Vec<(String, String, bool, Option<HmacMode>)> {
|
||
|
|
let mut out = vec![(
|
||
|
|
"plain (no password)".to_string(),
|
||
|
|
format!("{}-plain", tag),
|
||
|
|
false,
|
||
|
|
None,
|
||
|
|
)];
|
||
|
|
for mode in MODES {
|
||
|
|
out.push((
|
||
|
|
format!("keyed, HMAC {}", mode.label()),
|
||
|
|
format!("{}-{}", tag, mode.label()),
|
||
|
|
true,
|
||
|
|
Some(mode),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
fn main() {
|
||
|
|
if !enabled() {
|
||
|
|
eprintln!("skipping: set QSB_HMAC=1 to run");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if std::env::var_os("TMPDIR").is_none() {
|
||
|
|
eprintln!(
|
||
|
|
"warning: TMPDIR unset — scratch goes to {}. If that is tmpfs, \
|
||
|
|
every 'cold' figure below is RAM and the scale tier may not fit.",
|
||
|
|
std::env::temp_dir().display()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
tier(SHAPE_FILES, "shape");
|
||
|
|
if shape_only() {
|
||
|
|
println!("\n(QSB_HMAC_SHAPE_ONLY set — skipping the scale tier)");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
tier(SCALE_FILES, "scale");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Every arm at one corpus size, seeded and dropped one at a time so only one
|
||
|
|
/// index is resident.
|
||
|
|
fn tier(files: usize, tag: &str) {
|
||
|
|
println!(
|
||
|
|
"\n######## {} tier: {} files, {} with content ########",
|
||
|
|
tag,
|
||
|
|
files,
|
||
|
|
files / CONTENT_EVERY
|
||
|
|
);
|
||
|
|
let mut summary: Vec<(String, f64, f64, f64, u64)> = Vec::new();
|
||
|
|
for (what, suffix, keyed, hmac) in arms(tag) {
|
||
|
|
let arm = Arm::seed(&what, &suffix, keyed, &spec(files, hmac));
|
||
|
|
let (warm_total, dup) = report(&arm);
|
||
|
|
summary.push((
|
||
|
|
what,
|
||
|
|
arm.seeded_in.as_secs_f64(),
|
||
|
|
warm_total,
|
||
|
|
dup.as_secs_f64(),
|
||
|
|
arm.size_bytes(),
|
||
|
|
));
|
||
|
|
arm.discard();
|
||
|
|
}
|
||
|
|
|
||
|
|
// The whole bench in one table, because the per-arm blocks above are too
|
||
|
|
// far apart on a terminal to compare by eye.
|
||
|
|
println!("\n---- {} tier summary ----", tag);
|
||
|
|
println!(
|
||
|
|
"{:<24}{:>12}{:>12}{:>12}{:>12}",
|
||
|
|
"arm", "seed", "warm total", "duplicates", "size"
|
||
|
|
);
|
||
|
|
let baseline = summary.first().map(|s| (s.1, s.2, s.3)).unwrap_or_default();
|
||
|
|
for (what, seeded, warm_total, dup, size) in &summary {
|
||
|
|
println!(
|
||
|
|
"{:<24}{:>12}{:>12}{:>12}{:>12}",
|
||
|
|
what,
|
||
|
|
format!("{:.1} s", seeded),
|
||
|
|
format!("{:.1} ms", warm_total * 1000.0),
|
||
|
|
format!("{:.0} ms", dup * 1000.0),
|
||
|
|
format!("{:.1} MiB", mib(*size)),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
println!(
|
||
|
|
"\n{:<24}{:>12}{:>12}{:>12}",
|
||
|
|
"over plain", "seed", "warm total", "duplicates"
|
||
|
|
);
|
||
|
|
for (what, seeded, warm_total, dup, _) in &summary {
|
||
|
|
println!(
|
||
|
|
"{:<24}{:>12}{:>12}{:>12}",
|
||
|
|
what,
|
||
|
|
format!("{:.2}x", seeded / baseline.0),
|
||
|
|
format!("{:.2}x", warm_total / baseline.1),
|
||
|
|
format!("{:.2}x", dup / baseline.2),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Everything measured about one arm. Returns `(warm query total, duplicate
|
||
|
|
/// scan)` — the two numbers the summary ranks on.
|
||
|
|
fn report(arm: &Arm) -> (f64, Duration) {
|
||
|
|
let (leaf, overflow) = arm.fts_pages();
|
||
|
|
println!(
|
||
|
|
"\n=== {} === {:.1} MiB on disk, files table {:.1} MiB, \
|
||
|
|
fts {} leaf / {} overflow, written in {:.1?} ({:.0} rows/s)",
|
||
|
|
arm.what,
|
||
|
|
mib(arm.size_bytes()),
|
||
|
|
mib(arm.table_bytes("files")),
|
||
|
|
leaf,
|
||
|
|
overflow,
|
||
|
|
arm.seeded_in,
|
||
|
|
seeded_rows(arm) as f64 / arm.seeded_in.as_secs_f64(),
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
overflow, 0,
|
||
|
|
"{}: FTS5 leaves overflowed, so this arm is measuring a broken \
|
||
|
|
derivation rather than its authenticator",
|
||
|
|
arm.what
|
||
|
|
);
|
||
|
|
|
||
|
|
attribution(arm);
|
||
|
|
|
||
|
|
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 (cold_time, misses, hits) = cold(arm, query, fuzzy);
|
||
|
|
let warm_time = warm(&conn, query, fuzzy);
|
||
|
|
warm_total += warm_time.as_secs_f64();
|
||
|
|
println!(
|
||
|
|
"{:<16}{:>12}{:>12}{:>12}{:>10}",
|
||
|
|
what,
|
||
|
|
format!("{:.2?}", cold_time),
|
||
|
|
format!("{:.2?}", warm_time),
|
||
|
|
misses,
|
||
|
|
hits
|
||
|
|
);
|
||
|
|
}
|
||
|
|
drop(conn);
|
||
|
|
|
||
|
|
(warm_total, duplicates(arm))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// `find_duplicate_groups` is the read shape with the most pages per unit of
|
||
|
|
/// answer — a full `idx_files_hash` scan — so it is where a per-page cost
|
||
|
|
/// shows up most plainly. It opens its own connection, so the process key and
|
||
|
|
/// profile have to be installed at *call* time.
|
||
|
|
fn duplicates(arm: &Arm) -> Duration {
|
||
|
|
let db_path = arm.path.to_string_lossy().into_owned();
|
||
|
|
arm.with_key(|| {
|
||
|
|
let mut best = Duration::MAX;
|
||
|
|
for _ in 0..RUNS {
|
||
|
|
let start = Instant::now();
|
||
|
|
let groups = find_duplicate_groups(&db_path, 200).expect("duplicate scan");
|
||
|
|
assert!(!groups.is_empty(), "the seed must contain duplicate groups");
|
||
|
|
best = best.min(start.elapsed());
|
||
|
|
}
|
||
|
|
best
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
fn seeded_rows(arm: &Arm) -> i64 {
|
||
|
|
let conn = arm.open_search();
|
||
|
|
conn.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
|
||
|
|
.unwrap_or(0)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// **Where the page fetches go**, so a difference between arms lands on the
|
||
|
|
/// table that caused it. The shapes are the ones `search/cascade/passes.rs`
|
||
|
|
/// issues; see `benches/page_geometry.rs`, which uses the same four.
|
||
|
|
fn attribution(arm: &Arm) {
|
||
|
|
let like = format!("%{}%", BODY_TERM);
|
||
|
|
let match_expr = format!("text: \"{}\"", BODY_TERM);
|
||
|
|
|
||
|
|
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)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Run one query, counting hits rather than keeping them — holding the
|
||
|
|
/// `SearchHit`s would measure the allocator instead of the scan.
|
||
|
|
fn run_query(conn: &Connection, query: &str, fuzzy: bool) -> (Duration, usize) {
|
||
|
|
let split = split_for_cascade(query).expect("query parses");
|
||
|
|
let options = SearchOptions {
|
||
|
|
fuzzy,
|
||
|
|
..SearchOptions::default()
|
||
|
|
};
|
||
|
|
let latest = std::sync::atomic::AtomicU64::new(1);
|
||
|
|
let mut hits = 0usize;
|
||
|
|
let mut sink = |batch: Vec<SearchHit>| hits += batch.len();
|
||
|
|
let start = Instant::now();
|
||
|
|
cascade::run(conn, &split, &options, 1, &latest, &mut sink).expect("cascade runs");
|
||
|
|
(start.elapsed(), hits)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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 a
|
||
|
|
/// per-page authenticator is paid rather than skipped.
|
||
|
|
fn cold(arm: &Arm, query: &str, fuzzy: bool) -> (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_query(&conn, query, fuzzy);
|
||
|
|
if elapsed < best {
|
||
|
|
best = elapsed;
|
||
|
|
misses = cache_stats(&conn).1 - before;
|
||
|
|
}
|
||
|
|
hits = n;
|
||
|
|
}
|
||
|
|
(best, misses, hits)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Best of `RUNS` on one connection after a priming run — the steady state of
|
||
|
|
/// a typing session, which is what almost every real search is.
|
||
|
|
fn warm(conn: &Connection, query: &str, fuzzy: bool) -> Duration {
|
||
|
|
run_query(conn, query, fuzzy);
|
||
|
|
let mut best = Duration::MAX;
|
||
|
|
for _ in 0..RUNS {
|
||
|
|
best = best.min(run_query(conn, query, fuzzy).0);
|
||
|
|
}
|
||
|
|
best
|
||
|
|
}
|