Files
Oxicloud/examples/bench_round15_tantivy.rs
T
Claude 3be85fa9f0 perf: round 15 — grouped-listing O(N²) rebucket, exif/reseed allocs, tantivy zero-hit snippet skip
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
2026-07-19 11:36:47 +00:00

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");
}