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
This commit is contained in:
Claude
2026-07-19 11:36:47 +00:00
parent 76b9113c96
commit 3be85fa9f0
12 changed files with 1244 additions and 67 deletions
+86 -23
View File
@@ -64,7 +64,10 @@ unsafe impl GlobalAlloc for CountingAlloc {
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)
env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
struct Measured {
@@ -80,11 +83,17 @@ fn measure<F: FnMut()>(iters: usize, mut f: F) -> Measured {
}
let wall = t.elapsed().as_nanos() as f64 / iters as f64;
let allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a0) as f64 / iters as f64;
Measured { wall_ns_per_op: wall, allocs_per_op: allocs }
Measured {
wall_ns_per_op: wall,
allocs_per_op: allocs,
}
}
fn print_row(label: &str, m: &Measured) {
println!("| {:<40} | {:>12.1} | {:>10.2} |", label, m.wall_ns_per_op, m.allocs_per_op);
println!(
"| {:<40} | {:>12.1} | {:>10.2} |",
label, m.wall_ns_per_op, m.allocs_per_op
);
}
fn header_footer(name: &str, before: &Measured, after: &Measured) {
@@ -107,12 +116,15 @@ fn section_cookie() {
let iters: usize = env_or("BENCH_ITERS", 200_000);
let name = "oxicloud_access";
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMTIzNDU2Nzg5YWJjZGVmIn0.c2lnbmF0dXJlLXBsYWNlaG9sZGVy";
let jwt =
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMTIzNDU2Nzg5YWJjZGVmIn0.c2lnbmF0dXJlLXBsYWNlaG9sZGVy";
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
HeaderValue::from_str(&format!("{name}={jwt}; oxicloud_csrf=3f2504e0-4f89-41d3-9a0c-0305e82c3301"))
.unwrap(),
HeaderValue::from_str(&format!(
"{name}={jwt}; oxicloud_csrf=3f2504e0-4f89-41d3-9a0c-0305e82c3301"
))
.unwrap(),
);
// Gate: byte-identical value.
@@ -221,7 +233,10 @@ fn section_relevance() {
"relevance differs for ({name:?}, {q:?})"
);
}
println!("# [A2] gate: ASCII fast path matches Unicode lowercase across {} cases — OK", corpus.len());
println!(
"# [A2] gate: ASCII fast path matches Unicode lowercase across {} cases — OK",
corpus.len()
);
let m_before = measure(iters, || {
for (name, q) in corpus {
@@ -234,7 +249,10 @@ fn section_relevance() {
}
});
println!("\n## [A2] compute_relevance over a {}-row result page (per search / keystroke)", corpus.len());
println!(
"\n## [A2] compute_relevance over a {}-row result page (per search / keystroke)",
corpus.len()
);
header_footer("relevance whole corpus", &m_before, &m_after);
if m_after.wall_ns_per_op >= m_before.wall_ns_per_op {
eprintln!("GATE FAIL [A2]: ASCII fast path not faster — rollback");
@@ -252,7 +270,11 @@ fn section_sub_parse() {
let pre_parsed = Uuid::parse_str(&sub).unwrap();
// Gate: the pre-parsed uuid equals a fresh parse.
assert_eq!(Uuid::parse_str(&sub).unwrap(), pre_parsed, "uuid parse differs");
assert_eq!(
Uuid::parse_str(&sub).unwrap(),
pre_parsed,
"uuid parse differs"
);
println!("# [A3] gate: pre-parsed sub_id equals per-request parse — OK");
let m_before = measure(iters, || {
@@ -279,18 +301,41 @@ fn section_sub_parse() {
fn build_request_headers() -> HeaderMap {
// A representative authed browser request.
let mut h = HeaderMap::new();
h.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"));
h.insert(
header::AUTHORIZATION,
HeaderValue::from_static("Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"),
);
h.insert(
header::COOKIE,
HeaderValue::from_static("oxicloud_access=eyJ.payload.sig; oxicloud_csrf=3f2504e0-4f89-41d3-9a0c-0305e82c3301"),
HeaderValue::from_static(
"oxicloud_access=eyJ.payload.sig; oxicloud_csrf=3f2504e0-4f89-41d3-9a0c-0305e82c3301",
),
);
h.insert(header::HOST, HeaderValue::from_static("cloud.example.com"));
h.insert(header::USER_AGENT, HeaderValue::from_static("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"));
h.insert(header::ACCEPT, HeaderValue::from_static("application/json, text/plain, */*"));
h.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip, deflate, br"));
h.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9"));
h.insert(header::REFERER, HeaderValue::from_static("https://cloud.example.com/files"));
h.insert("x-csrf-token", HeaderValue::from_static("3f2504e0-4f89-41d3-9a0c-0305e82c3301"));
h.insert(
header::USER_AGENT,
HeaderValue::from_static("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"),
);
h.insert(
header::ACCEPT,
HeaderValue::from_static("application/json, text/plain, */*"),
);
h.insert(
header::ACCEPT_ENCODING,
HeaderValue::from_static("gzip, deflate, br"),
);
h.insert(
header::ACCEPT_LANGUAGE,
HeaderValue::from_static("en-US,en;q=0.9"),
);
h.insert(
header::REFERER,
HeaderValue::from_static("https://cloud.example.com/files"),
);
h.insert(
"x-csrf-token",
HeaderValue::from_static("3f2504e0-4f89-41d3-9a0c-0305e82c3301"),
);
h.insert(header::CONNECTION, HeaderValue::from_static("keep-alive"));
h
}
@@ -302,9 +347,13 @@ fn section_headermap_clone() {
// Gate: the token extracted from a cloned map equals that from the borrowed map.
let from_clone = {
let c = headers.clone();
c.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()).map(str::to_string)
c.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
let from_borrow = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok());
let from_borrow = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
assert_eq!(from_clone.as_deref(), from_borrow, "authorization differs");
println!("# [A4] gate: token from cloned map == token from borrowed map — OK");
@@ -358,7 +407,10 @@ fn section_caldav_rfc2822() {
}
println!("# [A5] gate: stack rfc2822_utc byte-identical to chrono to_rfc2822 — OK");
let dts: Vec<DateTime<Utc>> = secs.iter().map(|&s| DateTime::<Utc>::from_timestamp(s, 0).unwrap()).collect();
let dts: Vec<DateTime<Utc>> = secs
.iter()
.map(|&s| DateTime::<Utc>::from_timestamp(s, 0).unwrap())
.collect();
let m_before = measure(iters, || {
for dt in &dts {
@@ -372,7 +424,10 @@ fn section_caldav_rfc2822() {
}
});
println!("\n## [A5] CalDAV getlastmodified render ({} events, per REPORT/PROPFIND)", secs.len());
println!(
"\n## [A5] CalDAV getlastmodified render ({} events, per REPORT/PROPFIND)",
secs.len()
);
header_footer("rfc2822 chrono/stack", &m_before, &m_after);
if m_after.allocs_per_op >= m_before.allocs_per_op {
eprintln!("GATE FAIL [A5]: stack render did not remove allocations — rollback");
@@ -389,7 +444,12 @@ fn section_caldav_href_etag() {
let base_href = "/caldav/alice/personal/";
// A page of events (uid, id) like write_report_page iterates.
let events: Vec<(String, Uuid)> = (0..40)
.map(|i| (format!("event-uid-{i:04}-abcdef@oxicloud"), Uuid::from_u128(0x1000 + i as u128)))
.map(|i| {
(
format!("event-uid-{i:04}-abcdef@oxicloud"),
Uuid::from_u128(0x1000 + i as u128),
)
})
.collect();
// Gate: reused-buffer output identical to the per-event format! pair.
@@ -426,7 +486,10 @@ fn section_caldav_href_etag() {
}
});
println!("\n## [A6] CalDAV per-event href + etag ({} events/page, per REPORT/PROPFIND)", events.len());
println!(
"\n## [A6] CalDAV per-event href + etag ({} events/page, per REPORT/PROPFIND)",
events.len()
);
header_footer("href+etag per page", &m_before, &m_after);
if m_after.allocs_per_op >= m_before.allocs_per_op {
eprintln!("GATE FAIL [A6]: reused buffer did not reduce allocations — rollback");
+16 -4
View File
@@ -57,7 +57,11 @@ fn bytes_to_embedding(b: &[u8]) -> Vec<f32> {
/// hydrate the full 10-column row (decoding the 2 KiB embedding like
/// `row_to_face`), then filter `user_id == caller` in Rust and keep only
/// `(id, person_id, bbox)`.
async fn boxes_before(pool: &PgPool, file_id: Uuid, caller: Uuid) -> Vec<(Uuid, Option<Uuid>, Vec<f32>)> {
async fn boxes_before(
pool: &PgPool,
file_id: Uuid,
caller: Uuid,
) -> Vec<(Uuid, Option<Uuid>, Vec<f32>)> {
let rows = sqlx::query(
"SELECT id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash, created_at
FROM faces.faces WHERE file_id = $1",
@@ -83,7 +87,11 @@ async fn boxes_before(pool: &PgPool, file_id: Uuid, caller: Uuid) -> Vec<(Uuid,
}
/// AFTER: narrow projection, caller filter in SQL.
async fn boxes_after(pool: &PgPool, file_id: Uuid, caller: Uuid) -> Vec<(Uuid, Option<Uuid>, Vec<f32>)> {
async fn boxes_after(
pool: &PgPool,
file_id: Uuid,
caller: Uuid,
) -> Vec<(Uuid, Option<Uuid>, Vec<f32>)> {
let rows = sqlx::query(
"SELECT id, person_id, bbox FROM faces.faces WHERE file_id = $1 AND user_id = $2",
)
@@ -200,8 +208,12 @@ async fn section_face_boxes(pool: &PgPool) {
let wire_after = n * (16 + 16 + 16 + 8);
println!("\n## [Q1] Lightbox face boxes — group photo, {n} faces");
println!("| arm | mean ms | p50 ms | p95 ms | ~bytes/req |");
println!("| BEFORE wide row (incl. embedding) | {wm:>7.3} | {wp50:>6.3} | {wp95:>6.3} | {wire_before:>9} |");
println!("| AFTER narrow (id,person,bbox) | {nm:>7.3} | {np50:>6.3} | {np95:>6.3} | {wire_after:>9} |");
println!(
"| BEFORE wide row (incl. embedding) | {wm:>7.3} | {wp50:>6.3} | {wp95:>6.3} | {wire_before:>9} |"
);
println!(
"| AFTER narrow (id,person,bbox) | {nm:>7.3} | {np50:>6.3} | {np95:>6.3} | {wire_after:>9} |"
);
println!(
"# {:.2}x faster; ~{} KiB embedding/columns off the wire per lightbox open (scales with face count)",
wm / nm,
+268
View File
@@ -0,0 +1,268 @@
//! Round-15 CPU/alloc micro-pack (no Postgres).
//!
//! Each section is BEFORE (verbatim replica of the shipped-before shape) vs
//! AFTER (the shipped-after shape, or the shipped function itself), with an
//! equivalence gate and a `GATE FAIL … rollback` check that exits non-zero if
//! the AFTER arm fails to beat its BEFORE — the round's roll-back rule encoded
//! into the benchmark.
//!
//! [B1] exif Make/Model — `display_value().to_string().trim_matches('"')
//! .trim().to_string()` allocates the display String, then throws it away
//! to allocate the trimmed copy (2 allocs). The shipped
//! `exif_service::display_value_trimmed` trims in place on the owned
//! buffer (`drain` + `truncate`) — 1 alloc. Per ingested photo.
//! [B2] content-index worker `supports()` — `text_extractor::supports`
//! (lowercases the MIME + extension, 1–2 allocs) was called TWICE per
//! file per drain batch: once in the wanted-hashes filter, once in the
//! records loop. The shipped code classifies each file once into a
//! `Vec<bool>` and threads it through both. Per reseed batch (every
//! file in the library).
//!
//! Run:
//! cargo run --release --features bench --example bench_round15_micro
//! Tunables (env): BENCH_ITERS (200000), BENCH_BATCH (256)
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 oxicloud::infrastructure::services::search_index::text_extractor;
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)
}
struct Measured {
wall_ns_per_op: f64,
allocs_per_op: f64,
}
fn measure<F: FnMut()>(iters: usize, mut f: F) -> Measured {
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
for _ in 0..iters {
f();
}
let wall = t.elapsed().as_nanos() as f64 / iters as f64;
let allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a0) as f64 / iters as f64;
Measured {
wall_ns_per_op: wall,
allocs_per_op: allocs,
}
}
fn print_row(label: &str, m: &Measured) {
println!(
"| {:<42} | {:>12.1} | {:>10.2} |",
label, m.wall_ns_per_op, m.allocs_per_op
);
}
fn header_footer(name: &str, before: &Measured, after: &Measured) {
println!("| arm | ns/op | allocs/op |");
print_row(&format!("BEFORE {name}"), before);
print_row(&format!("AFTER {name}"), after);
println!(
"# {:.2}x wall, {:.2} fewer allocs/op",
before.wall_ns_per_op / after.wall_ns_per_op,
before.allocs_per_op - after.allocs_per_op
);
}
// ────────────────────────────────────────────────────────────────────────────
// [B1] exif Make/Model trim — 2 allocs (throwaway display String) vs 1 (in place)
// ────────────────────────────────────────────────────────────────────────────
/// BEFORE: the shipped-before chain. `raw` stands in for the field's rendered
/// display value; `to_string()` mirrors `display_value().to_string()` (the one
/// unavoidable alloc), then `.trim_matches('"').trim().to_string()` allocates a
/// second time for the trimmed copy.
fn trim_before(raw: &str) -> String {
raw.to_string().trim_matches('"').trim().to_string()
}
/// AFTER: verbatim replica of `exif_service::display_value_trimmed` — trims in
/// place on the already-owned buffer, so only the display String is allocated.
fn trim_after(raw: &str) -> String {
let mut s = raw.to_string();
let trimmed = s.trim_matches('"').trim();
let start = trimmed.as_ptr().addr() - s.as_ptr().addr();
let len = trimmed.len();
s.drain(..start);
s.truncate(len);
s
}
fn section_exif_trim() {
let iters: usize = env_or("BENCH_ITERS", 200_000);
// Representative EXIF Make/Model display values: the widely-seen quoted
// form, plus a padded one and an already-clean one.
let samples = ["\"Canon\"", "\"NIKON CORPORATION\"", " Apple ", "SONY"];
// Gate: byte-identical output to the old chain across every shape.
for s in samples {
assert_eq!(trim_before(s), trim_after(s), "trim differs for {s:?}");
}
let before = measure(iters, || {
for s in samples {
black_box(trim_before(black_box(s)));
}
});
let after = measure(iters, || {
for s in samples {
black_box(trim_after(black_box(s)));
}
});
println!("\n## [B1] exif Make/Model trim (4 sample values/op)");
header_footer("exif trim", &before, &after);
if after.allocs_per_op >= before.allocs_per_op {
eprintln!("GATE FAIL [B1]: in-place trim did not reduce allocations — rollback");
std::process::exit(1);
}
}
// ────────────────────────────────────────────────────────────────────────────
// [B2] content-index worker supports() — 2× per file vs 1× (memoized)
// ────────────────────────────────────────────────────────────────────────────
/// One drained file row: (name, mime, size). Mirrors the worker's
/// `FileIndexRow` projection (only the fields `supports` + the size gate read).
struct FileRow {
name: &'static str,
mime: &'static str,
size: i64,
}
fn corpus(n: usize) -> Vec<FileRow> {
// A realistic reseed mix: text/markdown/pdf/office (supported) interleaved
// with images/video/binaries (unsupported — the fast reject).
const MIX: &[(&str, &str, i64)] = &[
("notes.txt", "text/plain", 4_000),
("readme.md", "text/markdown", 8_000),
("report.pdf", "application/pdf", 250_000),
("photo.jpg", "image/jpeg", 3_000_000),
(
"sheet.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
120_000,
),
("clip.mp4", "video/mp4", 40_000_000),
("data.bin", "application/octet-stream", 1_000),
("page.html", "text/html; charset=utf-8", 20_000),
];
(0..n)
.map(|i| {
let (name, mime, size) = MIX[i % MIX.len()];
FileRow { name, mime, size }
})
.collect()
}
fn section_supports() {
let iters: usize = env_or("BENCH_ITERS", 200_000) / 20; // heavier op
let batch: usize = env_or("BENCH_BATCH", 256);
let max_bytes: u64 = 10 * 1024 * 1024;
let files = corpus(batch);
// BEFORE: `supports` is evaluated in the wanted-hashes filter AND again per
// file in the records loop — twice per file.
let run_before = |files: &[FileRow]| -> (usize, usize) {
let wanted = files
.iter()
.filter(|f| text_extractor::supports(f.name, f.mime) && f.size as u64 <= max_bytes)
.count();
let mut supported_files = 0;
for f in files {
if text_extractor::supports(f.name, f.mime) {
supported_files += 1;
}
}
(wanted, supported_files)
};
// AFTER: classify each file once into a `Vec<bool>`; both the filter and the
// records loop read the flag.
let run_after = |files: &[FileRow]| -> (usize, usize) {
let supported: Vec<bool> = files
.iter()
.map(|f| text_extractor::supports(f.name, f.mime))
.collect();
let wanted = files
.iter()
.zip(&supported)
.filter(|&(f, s)| *s && f.size as u64 <= max_bytes)
.count();
let mut supported_files = 0;
for (_, &s) in files.iter().zip(&supported) {
if s {
supported_files += 1;
}
}
(wanted, supported_files)
};
// Gate: identical (wanted, supported) tallies.
assert_eq!(
run_before(&files),
run_after(&files),
"supports tally differs"
);
let before = measure(iters, || {
black_box(run_before(black_box(&files)));
});
let after = measure(iters, || {
black_box(run_after(black_box(&files)));
});
println!("\n## [B2] content-index supports() ({batch} files/batch)");
header_footer("supports/batch", &before, &after);
if after.allocs_per_op >= before.allocs_per_op || after.wall_ns_per_op >= before.wall_ns_per_op
{
eprintln!("GATE FAIL [B2]: single-classify did not beat the double call — rollback");
std::process::exit(1);
}
}
fn main() {
println!("#################################################################");
println!("# Round-15 CPU/alloc micro-pack");
println!("#################################################################");
section_exif_trim();
section_supports();
println!("\nGATE PASS (all sections)");
}
+224
View File
@@ -0,0 +1,224 @@
//! 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");
}