3be85fa9f0
Benchmark-gated, same rule as rounds 2–14: every change ships with a BEFORE/AFTER benchmark and an equivalence/safety gate; an AFTER that doesn't beat its BEFORE is rolled back. The rule is encoded per harness (GATE FAIL non-zero exit in the Rust examples, threshold expect() in vitest). F1 — Grouped listings (trash / recent / favorites / shared-with-me) re-bucketed the WHOLE accumulated list on every infinite-scroll page. ResourceSectionsBuilder (new, off the reactive graph) re-buckets only the fresh page and hands VirtualList the same rows array reference for untouched buckets. 50×50 (2 500-item) drain: 63 750 → 2 500 bucketOf calls (25.5×), 12.5 → 1.3 ms wall (9.9×); O(N²/page) → O(N). Deep-equal to the full-rebuild reference at every page for both a contiguous (date) and a non-contiguous (trash-by-drive) group-by; reference-stability + fallback gated. B1 — exif Make/Model: the display String was thrown away to allocate the trimmed copy; display_value_trimmed trims in place (drain + truncate), 2 → 1 alloc per field (8 → 4 allocs/op, 1.26×). B2 — content-index worker: text_extractor::supports (lowercases MIME + extension) was called twice per file per drain batch; classify once into a Vec<bool> and thread it through both uses. 256-file batch: 704 → 353 allocs, 34.5 → 16.7 µs (2.07×). B3 — tantivy: skip SnippetGenerator::create on a zero-hit content search (return Ok(vec![]) once top_docs.is_empty()); the per-hit loop was empty. 400-doc index: 1 575.6 → 1 237.2 ns (1.27×), widens with index size. Harnesses: examples/bench_round15_micro.rs, examples/bench_round15_tantivy.rs, frontend resourceSections.bench.test.ts; writeup in benches/ROUND15.md. Also normalizes two round14 bench examples that were committed unformatted (cargo fmt --all). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012o47jSrtL7xuNGTHXmtiYL
225 lines
8.4 KiB
Rust
225 lines
8.4 KiB
Rust
//! Round-15 tantivy zero-hit snippet skip (no Postgres).
|
|
//!
|
|
//! `TantivyContentIndex::search_blocking` builds a `SnippetGenerator` from the
|
|
//! query right after the `TopDocs` search — but a `SnippetGenerator::create`
|
|
//! compiles the query against the index (term lookups + weight build), and when
|
|
//! the query matched NO documents that generator is never used (the per-hit
|
|
//! loop is empty). The shipped fix returns `Ok(Vec::new())` as soon as
|
|
//! `top_docs.is_empty()`, before the create.
|
|
//!
|
|
//! This bench reproduces the exact skipped operation on a RAM index built with
|
|
//! the public tantivy API (same crate + version the service uses):
|
|
//! BEFORE = search (→ 0 hits) + `SnippetGenerator::create` (+ `set_max_num_chars`)
|
|
//! AFTER = search (→ 0 hits) + `top_docs.is_empty()` early return
|
|
//! The delta is the wasted create the fix removes from every no-hit content
|
|
//! search. A sanity arm confirms a term that DOES hit still yields a snippet, so
|
|
//! the skip only ever triggers on a genuine zero-hit query.
|
|
//!
|
|
//! Run:
|
|
//! cargo run --release --features bench --example bench_round15_tantivy
|
|
//! Tunables (env): BENCH_ITERS (50000), BENCH_DOCS (400)
|
|
|
|
use std::alloc::{GlobalAlloc, Layout, System};
|
|
use std::env;
|
|
use std::hint::black_box;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::Instant;
|
|
|
|
use tantivy::collector::TopDocs;
|
|
use tantivy::query::QueryParser;
|
|
use tantivy::schema::{STORED, STRING, Schema, TEXT, Value as _};
|
|
use tantivy::snippet::SnippetGenerator;
|
|
use tantivy::{Index, TantivyDocument, doc};
|
|
|
|
static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0);
|
|
|
|
struct CountingAlloc;
|
|
|
|
unsafe impl GlobalAlloc for CountingAlloc {
|
|
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
|
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
|
unsafe { System.alloc(layout) }
|
|
}
|
|
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
|
unsafe { System.dealloc(ptr, layout) }
|
|
}
|
|
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
|
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
|
unsafe { System.realloc(ptr, layout, new_size) }
|
|
}
|
|
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
|
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
|
unsafe { System.alloc_zeroed(layout) }
|
|
}
|
|
}
|
|
|
|
#[global_allocator]
|
|
static GLOBAL: CountingAlloc = CountingAlloc;
|
|
|
|
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
|
env::var(key)
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(default)
|
|
}
|
|
|
|
const SNIPPET_MAX_CHARS: usize = 200;
|
|
|
|
fn main() {
|
|
let iters: usize = env_or("BENCH_ITERS", 50_000);
|
|
let docs: usize = env_or("BENCH_DOCS", 400);
|
|
|
|
println!("#################################################################");
|
|
println!("# Round-15 tantivy zero-hit snippet skip");
|
|
println!("#################################################################");
|
|
|
|
// ── Build a RAM index: a stored content field + a name field, the shape
|
|
// the service indexes. Fill it with realistic prose so create() has real
|
|
// terms to weigh. ────────────────────────────────────────────────────
|
|
let mut schema_builder = Schema::builder();
|
|
let name = schema_builder.add_text_field("name", STRING | STORED);
|
|
let content = schema_builder.add_text_field("content", TEXT | STORED);
|
|
let schema = schema_builder.build();
|
|
let index = Index::create_in_ram(schema);
|
|
|
|
const WORDS: &[&str] = &[
|
|
"informe",
|
|
"trimestral",
|
|
"ventas",
|
|
"region",
|
|
"norte",
|
|
"presupuesto",
|
|
"reunion",
|
|
"proyecto",
|
|
"cliente",
|
|
"factura",
|
|
"contrato",
|
|
"entrega",
|
|
"calendario",
|
|
"documento",
|
|
"resumen",
|
|
"analisis",
|
|
"resultados",
|
|
"equipo",
|
|
];
|
|
{
|
|
let mut writer = index.writer(15_000_000).expect("writer");
|
|
for i in 0..docs {
|
|
let body: String = (0..40)
|
|
.map(|j| WORDS[(i * 7 + j * 13) % WORDS.len()])
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
writer
|
|
.add_document(doc!(
|
|
name => format!("doc-{i}.txt"),
|
|
content => body,
|
|
))
|
|
.expect("add");
|
|
}
|
|
writer.commit().expect("commit");
|
|
}
|
|
let reader = index.reader().expect("reader");
|
|
let searcher = reader.searcher();
|
|
let parser = QueryParser::for_index(&index, vec![content]);
|
|
|
|
// A multi-term query of words that appear in NO document → zero hits, but
|
|
// valid tokens (so the real code reaches the search, not the empty-token
|
|
// guard). These are plausible-but-absent search terms.
|
|
let miss_query = parser
|
|
.parse_query("zzznonexistent quuxfoobar wibblewobble")
|
|
.expect("parse");
|
|
// A query that DOES hit — the sanity arm.
|
|
let hit_query = parser.parse_query("informe ventas").expect("parse");
|
|
|
|
// ── Correctness gates ──────────────────────────────────────────────────
|
|
let miss_hits = searcher
|
|
.search(&miss_query, &TopDocs::with_limit(32).order_by_score())
|
|
.expect("search");
|
|
assert!(
|
|
miss_hits.is_empty(),
|
|
"miss query must return zero hits (got {})",
|
|
miss_hits.len()
|
|
);
|
|
|
|
let hit_hits = searcher
|
|
.search(&hit_query, &TopDocs::with_limit(32).order_by_score())
|
|
.expect("search");
|
|
assert!(!hit_hits.is_empty(), "hit query must return hits");
|
|
// The generator the fix keeps for real hits still produces a fragment.
|
|
let generator = SnippetGenerator::create(&searcher, &*hit_query, content).expect("gen");
|
|
let (_, addr) = hit_hits[0];
|
|
let d: TantivyDocument = searcher.doc(addr).expect("doc");
|
|
let preview = d
|
|
.get_first(content)
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or_default()
|
|
.to_owned();
|
|
assert!(
|
|
!generator.snippet(&preview).fragment().is_empty(),
|
|
"a real hit must still yield a snippet fragment"
|
|
);
|
|
|
|
// ── BEFORE: search + build the snippet generator even on zero hits. ──────
|
|
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
|
|
let t = Instant::now();
|
|
for _ in 0..iters {
|
|
let top = searcher
|
|
.search(&miss_query, &TopDocs::with_limit(32).order_by_score())
|
|
.expect("search");
|
|
let sg = SnippetGenerator::create(&searcher, &*miss_query, content).map(|mut g| {
|
|
g.set_max_num_chars(SNIPPET_MAX_CHARS);
|
|
g
|
|
});
|
|
black_box((top.len(), sg.is_ok()));
|
|
}
|
|
let before_ns = t.elapsed().as_nanos() as f64 / iters as f64;
|
|
let before_allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a0) as f64 / iters as f64;
|
|
|
|
// ── AFTER: search + the shipped early return on an empty result. ─────────
|
|
let a1 = ALLOC_CALLS.load(Ordering::Relaxed);
|
|
let t = Instant::now();
|
|
for _ in 0..iters {
|
|
let top = searcher
|
|
.search(&miss_query, &TopDocs::with_limit(32).order_by_score())
|
|
.expect("search");
|
|
if top.is_empty() {
|
|
black_box(top.len());
|
|
continue;
|
|
}
|
|
// Unreached for the miss query; present so the arm is structurally the
|
|
// shipped code, not a stripped one.
|
|
let sg = SnippetGenerator::create(&searcher, &*miss_query, content).map(|mut g| {
|
|
g.set_max_num_chars(SNIPPET_MAX_CHARS);
|
|
g
|
|
});
|
|
black_box(sg.is_ok());
|
|
}
|
|
let after_ns = t.elapsed().as_nanos() as f64 / iters as f64;
|
|
let after_allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a1) as f64 / iters as f64;
|
|
|
|
println!("\n## zero-hit content search ({docs} docs indexed)");
|
|
println!("| arm | ns/op | allocs/op |");
|
|
println!(
|
|
"| {:<40} | {:>12.1} | {:>10.2} |",
|
|
"BEFORE search + snippet create", before_ns, before_allocs
|
|
);
|
|
println!(
|
|
"| {:<40} | {:>12.1} | {:>10.2} |",
|
|
"AFTER search + is_empty skip", after_ns, after_allocs
|
|
);
|
|
println!(
|
|
"# {:.2}x wall, {:.2} fewer allocs/op",
|
|
before_ns / after_ns,
|
|
before_allocs - after_allocs
|
|
);
|
|
|
|
if after_ns >= before_ns || after_allocs >= before_allocs {
|
|
eprintln!(
|
|
"GATE FAIL [B3]: zero-hit skip did not beat building the snippet generator — rollback"
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
println!("\nGATE PASS");
|
|
}
|