//! What a search moves through the allocator. //! //! [`search_perf`](search_perf.rs) answers "how long does a keystroke take"; //! this answers "how much does it allocate to get there", which is a different //! question with a different answer and, until this existed, no way to measure //! it from the repository at all. `db/repo.rs` already carries one finding of //! exactly this kind — that `zstd::decode_all` was "27 of the 30 GiB a fuzzy //! search moved through the allocator" — and that number came from tooling //! nobody could re-run. //! //! # What is counted, and what is not //! //! This installs a `#[global_allocator]` that wraps [`System`] and counts every //! Rust-side allocation. A global allocator is **per binary**, so this affects //! only this test executable — the shipped `quicksearch` binary is untouched, //! which is what makes an always-available accounting harness safe to keep. //! //! It deliberately does **not** see SQLite. The bundled SQLCipher amalgamation //! calls libc `malloc` directly rather than going through Rust's `GlobalAlloc`, //! so its page cache (32 MiB under [`PRAGMAS_SEARCH`]) and its record buffers //! are invisible here. That is a feature: what is left is precisely the //! cascade's own churn — the per-row `String`s, the document folds, the snippet //! buffers — which is the part the code can do something about. //! //! # Reading the numbers //! //! - **allocs** is the count. It is the number to watch for per-row work: a //! pass that scans the whole table and allocates once per row shows up here //! as a figure proportional to the row count, and a fix shows up as a figure //! proportional to the *hit* count. //! - **bytes** is total traffic — allocation churn. High traffic with a low //! peak means buffers being built and dropped in a loop. //! - **peak live** is the high-water mark of outstanding bytes during the //! query, and the only one of the three that speaks to footprint rather than //! to churn. //! //! Timings are printed for orientation only. The counting allocator adds a //! thread-local read-modify-write to every allocation, so this binary is //! **not** where wall-clock is decided; `search_perf` is. //! //! # What it has measured so far //! //! 50,000 rows, 5,000 stored documents of ~2 KB, 32 MiB index. Baseline is the //! cascade as it stood before this round of work; each column after it is //! cumulative. //! //! | case | allocs | → after | bytes | → after | time | → after | //! |---|---:|---:|---:|---:|---:|---:| //! | no match | 14 | 14 | 0.0 | 0.0 | 2.5 ms | 2.5 ms | //! | literal, rare | 226 | 226 | 0.0 | 0.0 | 2.5 ms | 2.6 ms | //! | wildcard, rare | 50,324 | **348** | 1.2 MiB | 0.0 | 11.5 ms | **2.6 ms** | //! | fuzzy, rare | 150,134 | **242** | 4.0 MiB | 0.0 | 62.1 ms | **17.4 ms** | //! | regex, literal | 50,221 | **283** | 1.3 MiB | 0.1 MiB | 22.4 ms | **2.7 ms** | //! | regex, no literal | — | 46 | — | 0.0 | — | 25.0 ms | //! | common (capped) | 4,056 | 4,056 | 0.6 MiB | 0.6 MiB | 1.4 ms | 1.5 ms | //! //! (`content, many` and `regex, no literal` were added after the baseline was //! taken — the first to give the full-text pass real verification work, the //! second to keep the regex prefilter's *limit* as visible as its win. Neither //! has a "before" column. The 25 ms in the last row is not a regression: it is //! what a `regex:` query cost before this work and still costs when the pattern //! offers no literal to filter on, which is the honest shape of the feature.) //! //! What the table is the reason for keeping: //! //! * borrowing the row's name instead of allocating one per scanned row — the //! 50,324 and 150,134 figures were exactly one and three allocations per row; //! * folding inside the bitap mask table rather than folding each haystack, //! which took the fuzzy pass's remaining two-per-row to nothing; //! * a `LIKE` prefilter for multi-segment wildcards, which had been scanning //! with no SQL filter at all — the whole of the wildcard row's time saving; //! * the pigeonhole trigram prefilter on the fuzzy full-text pass; //! * a non-cryptographic hasher for the emitted-id set, worth ~5% of a fuzzy //! search (see `cascade::IdHasher`); //! * required-literal prefilters on both `regex:` passes, which had been //! running the user's pattern over every name, every path and every stored //! document — the same `Required` machinery the fuzzy pass uses, fed by the //! literal analysis the regex engine already does for its own prefilter. //! //! And what it argued *against*, each recorded as a losing arm in //! `benches/search.rs`: a `memchr2` candidate scan for the case-insensitive //! filename probe, and hoisting `memmem::Finder` construction out of the //! full-text pass. Both are obvious-looking and neither is measurable. //! //! Two things this table is worth reading carefully for. **Allocation churn and //! wall-clock are not the same axis**: the first two changes removed 99.8% of //! the allocations and moved the clock barely at all, because the fuzzy pass's //! cost was zstd and bitap rather than malloc. And **the two big wins are both //! the same idea**: work out what must be present for a row to match, and let //! the database reject the rest. That took wildcards from 11.5 ms to 2.6, //! fuzzy from 62 to 17, and `regex:` from 22 to 2.7 — where the per-row //! micro-optimisations, measured honestly, were worth nothing at all. //! //! Take timings on a quiet machine. The allocation columns are deterministic //! and reproduce bit-for-bit under any load; the time column does not, and a //! busy box inflates it by 2-3x across the board. //! //! Gated by `QSB_SEARCH_ALLOC` so the harness doesn't pay the seed cost on //! every `cargo test`. To run it: //! //! ```text //! QSB_SEARCH_ALLOC=1 cargo test --release -p quicksearch-core \ //! --test search_alloc -- --nocapture //! ``` //! //! [`PRAGMAS_SEARCH`]: quicksearch_core::db::schema::PRAGMAS_SEARCH //! [`System`]: std::alloc::System use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; use std::sync::atomic::AtomicU64; use std::time::{Duration, Instant}; use quicksearch_core::query::split::split_for_cascade; use quicksearch_core::search::{cascade, SearchHit, SearchOptions}; mod common; use common::{scratch_db, seed_index, SeedSpec}; // --------------------------------------------------------------------------- // The counting allocator // --------------------------------------------------------------------------- // Counters are **per thread**, not global, and that is load-bearing rather // than an optimization. // // libtest runs `#[test]` functions on concurrent threads in one process, and // there is only one allocator. With global counters, another test's harness // setup — libtest allocates an output-capture buffer per test, before the test // body can take any lock of ours — lands inside whatever region is open here // and is charged to it. That is not hypothetical: it is what the self-test // below caught, twice, and no mutex in this file can fix it because the // offending allocation happens before the other test body runs at all. // // Per-thread counting is immune to that, and is also the more precise // question: `cascade::run` is synchronous and does its work on the calling // thread, so "what did this thread allocate" *is* "what did the cascade // allocate". // // `const`-initialized `Cell`s, deliberately. A `thread_local!` with a lazy // initializer would allocate on first touch — from inside the allocator — and // one with a destructor can panic when touched during thread teardown, which // is exactly when the last deallocations happen. `Cell` has no `Drop`, so // neither hazard exists. // `LIVE` and `PEAK` are **signed**, and that is not fussiness. // // Per-thread accounting is inherently asymmetric: a buffer allocated on one // thread and freed on another decrements a counter that never incremented, so // a thread's live total legitimately goes negative. libtest does exactly this // — a test thread starts life having freed more than it allocated. Held as // `u64` that reads as ~1.8e19, and `PEAK.max(live)` then latches onto it and // never moves again, so every peak in the table would be reported as 8 bytes. // That was not a hypothetical either; it is what the self-test caught on the // third attempt, and it is why the counts are `u64` (they only ever rise) and // the balances are `i64`. thread_local! { static ALLOCS: Cell = const { Cell::new(0) }; static REALLOCS: Cell = const { Cell::new(0) }; static BYTES: Cell = const { Cell::new(0) }; static LIVE: Cell = const { Cell::new(0) }; static PEAK: Cell = const { Cell::new(0) }; } /// Read one monotonic counter, tolerating a thread whose locals are gone. #[inline] fn get(counter: &'static std::thread::LocalKey>) -> u64 { counter.try_with(Cell::get).unwrap_or(0) } /// Add to one monotonic counter, skipping a thread whose locals are gone. #[inline] fn bump(counter: &'static std::thread::LocalKey>, by: u64) -> u64 { counter .try_with(|c| { let v = c.get().wrapping_add(by); c.set(v); v }) .unwrap_or(0) } /// Read one signed balance. #[inline] fn get_live(counter: &'static std::thread::LocalKey>) -> i64 { counter.try_with(Cell::get).unwrap_or(0) } /// Move the live balance and return the new value. #[inline] fn bump_live(by: i64) -> i64 { LIVE.try_with(|c| { let v = c.get().wrapping_add(by); c.set(v); v }) .unwrap_or(0) } /// Raise the high-water mark to `live` if it is higher. #[inline] fn note_peak(live: i64) { PEAK.try_with(|p| p.set(p.get().max(live))).ok(); } /// `System`, with counters. Every hook delegates and then accounts; a failed /// allocation is not counted, so the totals describe memory that really /// existed. struct Counting; #[inline] fn note_alloc(size: usize) { bump(&ALLOCS, 1); bump(&BYTES, size as u64); note_peak(bump_live(size as i64)); } unsafe impl GlobalAlloc for Counting { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { let p = unsafe { System.alloc(layout) }; if !p.is_null() { note_alloc(layout.size()); } p } unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { let p = unsafe { System.alloc_zeroed(layout) }; if !p.is_null() { note_alloc(layout.size()); } p } /// Freeing on a thread that did not allocate drives this thread's balance /// negative; see the note on the `thread_local!` block for why that is /// ordinary and why the balance is signed. unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { bump_live(-(layout.size() as i64)); unsafe { System.dealloc(ptr, layout) } } /// Counted as a resize rather than as a fresh allocation: a `Vec` doubling /// its way up to a document's length is one buffer, not twelve, and calling /// it twelve allocations would hide the difference between a reused buffer /// and a per-row one — which is exactly what this harness exists to show. /// Only the *growth* is added to traffic. unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { let p = unsafe { System.realloc(ptr, layout, new_size) }; if !p.is_null() { bump(&REALLOCS, 1); let (old, new) = (layout.size() as u64, new_size as u64); bump(&BYTES, new.saturating_sub(old)); note_peak(bump_live(new as i64 - old as i64)); } p } } #[global_allocator] static ALLOCATOR: Counting = Counting; /// Counter values at one instant. #[derive(Clone, Copy)] struct Counters { allocs: u64, reallocs: u64, bytes: u64, /// Signed: a thread's balance is legitimately negative. See the /// `thread_local!` block. live: i64, } /// What happened between two instants. #[derive(Clone, Copy)] struct Usage { allocs: u64, reallocs: u64, bytes: u64, peak: u64, } impl Counters { /// Snapshot, and re-arm the peak tracker at the current live figure so the /// high-water mark that follows belongs to the region being measured /// rather than to whatever the process did before it. fn start() -> Counters { let live = get_live(&LIVE); PEAK.with(|p| p.set(live)); Counters { allocs: get(&ALLOCS), reallocs: get(&REALLOCS), bytes: get(&BYTES), live, } } fn since(&self) -> Usage { Usage { allocs: get(&ALLOCS).wrapping_sub(self.allocs), reallocs: get(&REALLOCS).wrapping_sub(self.reallocs), bytes: get(&BYTES).wrapping_sub(self.bytes), // Above the live figure the region started from, so a query that // holds nothing reads as zero rather than as the process floor. peak: (get_live(&PEAK) - self.live).max(0) as u64, } } } // --------------------------------------------------------------------------- // The measurement // --------------------------------------------------------------------------- /// Rows to seed. Smaller than `search_perf`'s 200k on purpose: allocation /// counts are deterministic and scale linearly with rows scanned, so they need /// no statistical settling — only enough rows that a per-row allocation is /// unmistakable next to a per-hit one. const NUM_FILES: usize = 50_000; // No serializing mutex here, deliberately: the counters are per thread, so // concurrent tests in this binary cannot reach each other's regions. An // earlier revision did lock, and it did not work — see the comment on the // `thread_local!` block. fn enabled() -> bool { std::env::var("QSB_SEARCH_ALLOC").is_ok() } /// One query, described so the table says which cascade passes it exercises. struct Case { label: &'static str, query: &'static str, fuzzy: bool, /// The passes this case is here to measure, for the table's last column. passes: &'static str, } /// The query set. /// /// Every case but the last is **rare on purpose**. A query that fills the /// display limit makes `cascade::run` break out between passes, so a common /// term measures how quickly the cascade gives up rather than what a scan /// costs — and the passes this work targets never run at all. The rare cases /// are also the ones a user actually waits on. /// /// Spread across the passes deliberately: a change that helps one pass and /// hurts another must not be able to hide inside a single total. const CASES: &[Case] = &[ Case { label: "no match", query: "zzzznomatch", fuzzy: false, passes: "A, whole table, nothing accepted", }, Case { label: "literal, rare", query: "quartzite", fuzzy: false, passes: "A whole table + B verifies FTS hits", }, Case { label: "wildcard, rare", query: "quar*zite", fuzzy: false, passes: "A whole table, no prefilter today (S3)", }, Case { label: "fuzzy, rare", // One edit from the planted needle, so the exact passes miss and the // fuzzy ones have to do the work. query: "quartzyte", fuzzy: true, passes: "A + B + C + D, C and D whole-table (S1/S2/S4)", }, Case { // The full-text pass with real work to do: the name `LIKE` finds // nothing, so every hit is a document the trigram index returned and // pass B had to decompress, fold and verify. label: "content, many", query: "chalcedony", fuzzy: false, passes: "A finds nothing + B verifies ~500 docs (S5/S7)", }, Case { label: "regex, literal", query: r"regex:quartz\w+", fuzzy: false, passes: "regex name + content, both prefiltered on \"quartz\"", }, Case { // No literal to extract, so both regex passes still read everything. // Kept beside the case above so the prefilter's *limit* is as visible // as its win. label: "regex, no literal", query: r"regex:[0-9]{6}[a-y]{6}", fuzzy: false, passes: "regex name + content, no prefilter possible", }, Case { label: "common (capped)", query: "content", fuzzy: false, passes: "A, stops at the display limit", }, ]; /// Run one query to completion on a held connection, reporting what it moved /// through the allocator. /// /// Hits are counted, not kept: holding thousands of `SearchHit`s would measure /// the harness's own `Vec` rather than the cascade's. The count is reported so /// that a change which quietly alters the result set shows up here as well as /// in `tests/cascade.rs`. fn measure(conn: &rusqlite::Connection, case: &Case) -> (Usage, usize, Duration) { let split = split_for_cascade(case.query).expect("the query set parses"); let latest = AtomicU64::new(1); let options = SearchOptions { fuzzy: case.fuzzy, limit: 1000, ..SearchOptions::default() }; let mut count = 0usize; let mut sink = |hits: Vec| count += hits.len(); // Everything the query needs is built above; the snapshot brackets the // cascade and nothing else. let start = Counters::start(); let clock = Instant::now(); cascade::run(conn, &split, &options, 1, &latest, &mut sink).expect("the cascade runs"); let elapsed = clock.elapsed(); (start.since(), count, elapsed) } fn mib(bytes: u64) -> String { format!("{:.1}", bytes as f64 / (1024.0 * 1024.0)) } /// Printed rather than asserted, for the reason `search_perf` gives: a /// threshold on a shared machine gets muted rather than fixed. Allocation /// counts are far more stable than timings, so a *ratchet* becomes reasonable /// once the search work has landed and the numbers have settled — until then /// this exists to be read when a change claims to have reduced churn. #[test] fn allocation_traffic_per_query() { if !enabled() { eprintln!("skipping: set QSB_SEARCH_ALLOC=1 to run"); return; } let db = scratch_db("searchalloc"); let seeded = Instant::now(); seed_index( &db, &SeedSpec { files: NUM_FILES, ..SeedSpec::default() }, ); println!( "seeded {} rows in {:.1?} ({} MiB on disk)\n", NUM_FILES, seeded.elapsed(), std::fs::metadata(&db).map(|m| m.len()).unwrap_or(0) / (1024 * 1024) ); // One connection for the whole run, as the search worker holds one across // a typing session. Opened through the real entry point so the pragma // profile is the production one. let conn = quicksearch_core::db::open::open_search_reader(&db.to_string_lossy()) .expect("open the seeded index"); println!( "{:<16} {:>12} {:>10} {:>12} {:>12} {:>7} {:>9} {}", "case", "allocs", "reallocs", "bytes (MiB)", "peak (MiB)", "hits", "time", "passes" ); for case in CASES { // Once to warm the page cache and the statement cache, then measured: // a cold first query would report SQLite's one-off setup as the // cascade's churn. let _ = measure(&conn, case); let (usage, hits, elapsed) = measure(&conn, case); println!( "{:<16} {:>12} {:>10} {:>12} {:>12} {:>7} {:>9.1?} {}", case.label, usage.allocs, usage.reallocs, mib(usage.bytes), mib(usage.peak), hits, elapsed, case.passes, ); } println!( "\n{} rows scanned per whole-table pass. An `allocs` figure at or above \ that is per-row work;\nafter S1/S2 the scan passes should sit near their \ hit counts instead.", NUM_FILES ); } /// The accounting itself, so a number printed above is a number that means /// something. A harness that silently stopped counting would read as a /// spectacular optimization. #[test] fn the_counters_track_real_allocations() { let start = Counters::start(); // A `Vec` that grows by doubling is one buffer: one alloc, then reallocs. let mut v: Vec = Vec::new(); for _ in 0..64 * 1024 { v.push(0); } let grown = start.since(); assert_eq!(grown.allocs, 1, "a doubling Vec is one allocation"); assert!(grown.reallocs > 0, "and several resizes"); assert!( grown.peak >= 64 * 1024, "peak {} should cover the grown buffer", grown.peak ); // Dropping it returns the bytes: live falls back, so a later region's peak // is not inflated by this one. let before_drop = get_live(&LIVE); drop(v); assert!( get_live(&LIVE) < before_drop, "dealloc must decrement live bytes" ); // The signedness the whole scheme turns on. Sink the balance below zero, as // a thread that frees what another thread allocated really does, and check // that a peak is still reported: held unsigned, that negative balance reads // as ~1.8e19, `max` latches onto it, and every peak in the table is // reported as a handful of bytes forever. bump_live(-(1 << 20)); let negative = Counters::start(); assert!(negative.live < 0, "the balance is genuinely negative"); let mut grow: Vec = Vec::with_capacity(32 * 1024); grow.push(1); let seen = negative.since().peak; drop(grow); assert!( seen >= 32 * 1024, "a negative live balance swallowed the peak: {}", seen ); bump_live(1 << 20); // put back what was sunk, so later regions start clean // The property that makes the whole harness trustworthy under libtest's // thread-per-test: another thread allocating hard does not touch this // thread's counters. // // The thread is spawned and joined *outside* the region on purpose. // `spawn` boxes the closure and allocates the `JoinHandle` on the calling // thread, and `join` frees them there — so bracketing the spawn would // measure this thread's own bookkeeping and report it as leakage. A // barrier hands control across without allocating. let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); let child = { let barrier = barrier.clone(); std::thread::spawn(move || { barrier.wait(); // the region is open let noisy: Vec = (0..10_000).map(|i| format!("allocation {}", i)).collect(); std::hint::black_box(noisy.len()); barrier.wait(); // the noise is done }) }; let quiet_across_threads = Counters::start(); barrier.wait(); barrier.wait(); let leaked = quiet_across_threads.since(); child.join().expect("the noisy thread finishes"); assert_eq!( (leaked.allocs, leaked.bytes), (0, 0), "another thread's allocations must not be charged to this region" ); // A region that allocates nothing reports nothing, which is what makes // "this pass no longer allocates per row" a statement the table can make. let quiet = Counters::start(); std::hint::black_box(1u64 + 1); let idle = quiet.since(); assert_eq!((idle.allocs, idle.bytes, idle.peak), (0, 0, 0)); }