- Duplicates tab: groups are confirmed by each file's last 8 KiB and keyed by head+tail; unchecked files are never shown. False-positive duplicates drop from 3.4% to 0.1%, and indexing is unchanged. - Encrypted search cache cap raised to 192 MiB at 150 B per file: a keystroke at 1M files takes 59 ms, down from 99 ms. - Case-insensitive filename matching uses a memchr2 scan (1.18x). - Benches run again on the compact index, seed with the indexer's 2 GiB WAL cap (peak scratch 6.2 GB -> 2.45 GB), and every recorded figure is re-measured. - A fresh index no longer logs a rebuild warning. - CI: the sandbox memory test's allocation survives release builds, and --toggle retries an interrupted read.
397 lines
14 KiB
Rust
397 lines
14 KiB
Rust
//! 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<usize> {
|
|
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
|
|
});
|
|
}
|
|
}
|