//! Search-path microbenchmarks. //! //! Every group pairs two ways of doing the same work in one run, so the //! delta is a measurement rather than an estimate; a losing arm records //! that an obvious-looking idea was measured and did not pay. //! //! Run with: //! //! ```text //! cargo bench -p quicksearch-core --bench search //! ``` //! //! Sizes come from `corpus::SIZES`. mod corpus; use divan::Bencher; use quicksearch_core::query::pattern::{TermPart, TermPattern}; use quicksearch_core::search::fuzzy::Bitap; use quicksearch_core::snippet; fn main() { divan::main(); } fn literal(term: &str) -> TermPattern { TermPattern::build(&[TermPart { text: term.to_string(), glob: false, }]) .expect("literal patterns always compile") } /// Decompressing the stored body, the first thing every full-text row does. /// Reusing one context and pre-sizing the output is 5.1x faster at 1 KiB, the /// size most documents are; `DocDecoder` exists for that. mod zstd_decode { use super::*; #[divan::bench(args = corpus::SIZES)] fn decode_all(bencher: Bencher, size: usize) { let blob = corpus::blob(size, 4); bencher.bench(|| zstd::decode_all(divan::black_box(blob)).unwrap()); } #[divan::bench(args = corpus::SIZES)] fn bulk_reused(bencher: Bencher, size: usize) { let blob = corpus::blob(size, 4); let capacity = corpus::text(size, 4).len(); let mut dec = zstd::bulk::Decompressor::new().unwrap(); bencher.bench_local(move || dec.decompress(divan::black_box(blob), capacity).unwrap()); } } /// Turning decompressed bytes into a `&str`: `from_utf8_lossy` copies and /// scan-validates; `String::from_utf8` vector-validates and *moves*. A /// 14-16x gap; `DocDecoder` borrows instead. mod utf8 { use super::*; #[divan::bench(args = corpus::SIZES)] fn lossy_into_owned(bencher: Bencher, size: usize) { let raw = corpus::text(size, 4).as_bytes(); bencher.bench(|| String::from_utf8_lossy(divan::black_box(raw)).into_owned()); } #[divan::bench(args = corpus::SIZES)] fn lossy_borrowed(bencher: Bencher, size: usize) { let raw = corpus::text(size, 4).as_bytes(); bencher.bench(|| { let cow = String::from_utf8_lossy(divan::black_box(raw)); cow.len() }); } /// The decompressed `Vec` is unshared, so `from_utf8` can move it; /// the lossy fallback keeps corrupt-row behaviour unchanged. #[divan::bench(args = corpus::SIZES)] fn from_utf8_move(bencher: Bencher, size: usize) { let raw = corpus::text(size, 4).as_bytes(); bencher .with_inputs(|| raw.to_vec()) .bench_values(|owned| match String::from_utf8(owned) { Ok(s) => s, Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(), }); } } /// ASCII-folding the document. Folding into a reused buffer is not faster; /// `fold_into` is chosen for what it does to the allocator, not the clock, so /// do not "optimize" it back. mod fold { use super::*; #[divan::bench(args = corpus::SIZES)] fn to_ascii_lowercase(bencher: Bencher, size: usize) { let text = corpus::text_mixed(size, 4); bencher.bench(|| divan::black_box(text).to_ascii_lowercase()); } #[divan::bench(args = corpus::SIZES)] fn into_reused_buffer(bencher: Bencher, size: usize) { let text = corpus::text_mixed(size, 4); let mut buf = String::new(); bencher.bench_local(move || { buf.clear(); buf.push_str(divan::black_box(text)); // `make_ascii_lowercase` preserves byte length, the invariant // the cascade's folded offsets rely on. buf.make_ascii_lowercase(); buf.len() }); } } /// Substring search: std's Two-Way against `memmem`'s SIMD prefilter. The /// miss case matters most (a pass verifies far more rows than it accepts): /// 92 µs against 4.7 µs at 256 KiB (20x). `match_indices` stays as the /// guard: if the two converge, the SIMD path has stopped being selected. mod substring { use super::*; #[divan::bench(args = corpus::SIZES)] fn match_indices_miss(bencher: Bencher, size: usize) { let text = corpus::text(size, 0); bencher.bench(|| divan::black_box(text).match_indices(corpus::NEEDLE).count()); } #[divan::bench(args = corpus::SIZES)] fn memmem_miss(bencher: Bencher, size: usize) { let text = corpus::text(size, 0).as_bytes(); let finder = memchr::memmem::Finder::new(corpus::NEEDLE); bencher.bench(|| finder.find_iter(divan::black_box(text)).count()); } /// A `Finder` built per call: +14 ns against a hoisted one at 1 KiB and /// indistinguishable from 16 KiB up, since the precompute is O(needle) /// and a term is a few bytes. #[divan::bench(args = corpus::SIZES)] fn memmem_per_call_miss(bencher: Bencher, size: usize) { let text = corpus::text(size, 0).as_bytes(); bencher.bench(|| { memchr::memmem::find_iter(divan::black_box(text), corpus::NEEDLE.as_bytes()).count() }); } #[divan::bench(args = corpus::SIZES)] fn memmem_per_call_hits(bencher: Bencher, size: usize) { let text = corpus::text(size, 64).as_bytes(); bencher.bench(|| { memchr::memmem::find_iter(divan::black_box(text), corpus::NEEDLE.as_bytes()).count() }); } #[divan::bench(args = corpus::SIZES)] fn match_indices_hits(bencher: Bencher, size: usize) { let text = corpus::text(size, 64); bencher.bench(|| divan::black_box(text).match_indices(corpus::NEEDLE).count()); } #[divan::bench(args = corpus::SIZES)] fn memmem_hits(bencher: Bencher, size: usize) { let text = corpus::text(size, 64).as_bytes(); let finder = memchr::memmem::Finder::new(corpus::NEEDLE); bencher.bench(|| finder.find_iter(divan::black_box(text)).count()); } /// What `pass_fulltext` runs per row on the literal path: one folded /// extraction yielding the window and both counts. #[divan::bench(args = corpus::SIZES)] fn cascade_row_sweeps(bencher: Bencher, size: usize) { let text = corpus::text_mixed(size, 4); let folded = corpus::text_folded(size, 4); let term = corpus::NEEDLE.to_ascii_lowercase(); let budget = 600; bencher.bench(|| { snippet::extract_folded( divan::black_box(text), divan::black_box(folded), &term, corpus::NEEDLE, budget, ) .map(|f| (f.count, f.exact, f.snippet.ranges.len())) }); } /// The replaced shape: a separate case-sensitive count sweeps the /// document a second time for a number the extraction now has. #[divan::bench(args = corpus::SIZES)] fn cascade_row_sweeps_separate_count(bencher: Bencher, size: usize) { let text = corpus::text_mixed(size, 4); let folded = corpus::text_folded(size, 4); let term = corpus::NEEDLE.to_ascii_lowercase(); let budget = 600; bencher.bench(|| { let exact = memchr::memmem::find_iter(divan::black_box(text).as_bytes(), corpus::NEEDLE) .count(); let f = snippet::extract_folded(text, folded, &term, corpus::NEEDLE, budget); (exact, f.map(|f| (f.count, f.snippet.ranges.len()))) }); } } /// Snippet extraction against a pre-folded haystack: the only folded sweep /// of a row, and the source of its count. mod snippet_extract { use super::*; #[divan::bench(args = corpus::SIZES)] fn extract_folded(bencher: Bencher, size: usize) { let text = corpus::text_mixed(size, 64); let folded = corpus::text_folded(size, 64); let term = corpus::NEEDLE.to_ascii_lowercase(); let budget = 600; bencher.bench(|| { snippet::extract_folded( divan::black_box(text), divan::black_box(folded), &term, corpus::NEEDLE, budget, ) }); } } /// The filename pass's per-row ladder over 2000 realistic rows. `current` is /// the shipped `find_first`, which jumps between spellings of the term's /// first byte with `memchr2`. Adopting that took it from 53.4 µs to 45.4 µs, /// 1.18x (2026-10-11, mimalloc, fastest sample, median of 3 runs), with the /// other arms within 4%. Folding first does not catch up: the two obvious /// fixes each measure *worse* alone (reused fold buffer, prebuilt `Finder`), /// and together they only tie it. Short strings do not behave like document /// bodies. /// /// The other arms are local copies, which skip `find_first`'s per-call cost /// (about 1.5 ns, 6 µs a run). The `memchr2` copy that prompted the change /// measured 1.39x where the shipped call gained 1.18x, so gate a change on /// `current` before and after it, not on a copy. mod filename_ladder { use super::*; #[divan::bench] fn find_first_ci_current(bencher: Bencher) { let pattern = literal("quartzite"); let rows = corpus::rows(); bencher.bench(|| { let mut found = 0usize; for row in divan::black_box(rows) { if pattern.find_first(&row.name, true).is_some() || pattern.find_first(&row.path, true).is_some() { found += 1; } } found }); } #[divan::bench] fn find_first_ci_scratch(bencher: Bencher) { let rows = corpus::rows(); let mut scratch = String::new(); bencher.bench_local(move || { let mut found = 0usize; for row in divan::black_box(rows) { scratch.clear(); scratch.push_str(&row.name); scratch.make_ascii_lowercase(); if memchr::memmem::find(scratch.as_bytes(), b"quartzite").is_some() { found += 1; continue; } scratch.clear(); scratch.push_str(&row.path); scratch.make_ascii_lowercase(); if memchr::memmem::find(scratch.as_bytes(), b"quartzite").is_some() { found += 1; } } found }); } /// Isolates the searcher from the allocation. #[divan::bench] fn find_first_ci_memmem(bencher: Bencher) { let finder = memchr::memmem::Finder::new("quartzite"); let rows = corpus::rows(); bencher.bench(|| { let mut found = 0usize; for row in divan::black_box(rows) { if finder .find(row.name.to_ascii_lowercase().as_bytes()) .is_some() || finder .find(row.path.to_ascii_lowercase().as_bytes()) .is_some() { found += 1; } } found }); } /// Both at once: one reused fold buffer and a prebuilt `Finder`. #[divan::bench] fn find_first_ci_scratch_memmem(bencher: Bencher) { let finder = memchr::memmem::Finder::new("quartzite"); let rows = corpus::rows(); let mut scratch = String::new(); bencher.bench_local(move || { let mut found = 0usize; for row in divan::black_box(rows) { scratch.clear(); scratch.push_str(&row.name); scratch.make_ascii_lowercase(); if finder.find(scratch.as_bytes()).is_some() { found += 1; continue; } scratch.clear(); scratch.push_str(&row.path); scratch.make_ascii_lowercase(); if finder.find(scratch.as_bytes()).is_some() { found += 1; } } found }); } /// The per-byte scan `find_first` ran until 2026-10-11: every start /// tested, `needle` already lowercased. The `memchr2` scan that replaced /// it was first rejected under glibc as not worth it on short haystacks; /// re-measured under mimalloc, its copy here ran 38.5 µs against /// `current`'s 53.4 µs. This copy runs 47.9 µs. #[divan::bench] fn find_first_ci_bytewise(bencher: Bencher) { let rows = corpus::rows(); let needle = corpus::NEEDLE.as_bytes(); fn find(hay: &[u8], needle: &[u8]) -> Option { let first = needle[0]; let last = hay.len().checked_sub(needle.len())?; (0..=last).find(|&i| { hay[i].to_ascii_lowercase() == first && hay[i..i + needle.len()].eq_ignore_ascii_case(needle) }) } bencher.bench(|| { let mut found = 0usize; for row in divan::black_box(rows) { if find(row.name.as_bytes(), needle).is_some() || find(row.path.as_bytes(), needle).is_some() { found += 1; } } found }); } } /// Bitap, the fuzzy passes' inner loop, run over every candidate row when /// fuzzy is on. `step` takes `&mut [u64]`, so indices are bounds-checked and /// the trip count is opaque to the optimizer. mod bitap { use super::*; #[divan::bench(args = corpus::SIZES)] fn count_and_first_k2(bencher: Bencher, size: usize) { let bitap = Bitap::new(corpus::NEEDLE.as_bytes(), 2).unwrap(); let hay = corpus::text(size, 4).as_bytes(); bencher.bench(|| bitap.count_and_first(divan::black_box(hay))); } /// The filename pass's shape: many short haystacks, the register memset /// amortized over very little work. #[divan::bench] fn best_distance_over_names_k2(bencher: Bencher) { let bitap = Bitap::new(b"quartzite", 2).unwrap(); let rows = corpus::rows(); bencher.bench(|| { let mut hits = 0usize; for row in divan::black_box(rows) { if bitap.best_distance_and_first(row.name.as_bytes()).is_some() { hits += 1; } } hits }); } }