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:
@@ -61,23 +61,13 @@ impl ExifService {
|
||||
|
||||
// ── Camera info ──
|
||||
if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) {
|
||||
let val = field
|
||||
.display_value()
|
||||
.to_string()
|
||||
.trim_matches('"')
|
||||
.trim()
|
||||
.to_string();
|
||||
let val = display_value_trimmed(field);
|
||||
if !val.is_empty() {
|
||||
meta.camera_make = Some(val);
|
||||
}
|
||||
}
|
||||
if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) {
|
||||
let val = field
|
||||
.display_value()
|
||||
.to_string()
|
||||
.trim_matches('"')
|
||||
.trim()
|
||||
.to_string();
|
||||
let val = display_value_trimmed(field);
|
||||
if !val.is_empty() {
|
||||
meta.camera_model = Some(val);
|
||||
}
|
||||
@@ -115,6 +105,29 @@ impl ExifService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render an EXIF field's display value, then strip surrounding quotes and
|
||||
/// whitespace (the shape `Make`/`Model` want) in a SINGLE allocation.
|
||||
///
|
||||
/// `display_value().to_string()` is the one unavoidable allocation — the field
|
||||
/// value is materialized to text. The old `…to_string().trim_matches('"')
|
||||
/// .trim().to_string()` chain then threw that `String` away and allocated a
|
||||
/// second time for the trimmed copy. Here the same two-stage trim is applied
|
||||
/// in place on the already-owned buffer (`drain` drops the prefix, `truncate`
|
||||
/// the suffix — both reuse the allocation), so a quoted `"Canon"` costs one
|
||||
/// allocation instead of two.
|
||||
fn display_value_trimmed(field: &exif::Field) -> String {
|
||||
let mut s = field.display_value().to_string();
|
||||
// Same order the old chain used: strip `"` first, then whitespace. The
|
||||
// result is a contiguous subslice of `s`; capture its byte range before
|
||||
// mutating the owned buffer (the borrow ends at these two reads).
|
||||
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
|
||||
}
|
||||
|
||||
/// Parse EXIF datetime string "YYYY:MM:DD HH:MM:SS" into DateTime<Utc>.
|
||||
fn parse_exif_datetime(s: &str) -> Option<DateTime<Utc>> {
|
||||
// EXIF dates use ":" as separator for date parts
|
||||
|
||||
@@ -264,13 +264,21 @@ impl ContentIndexWorker {
|
||||
let found: HashSet<Uuid> = files.iter().map(|f| f.0).collect();
|
||||
deletes.extend(upsert_candidates.iter().filter(|id| !found.contains(id)));
|
||||
|
||||
// `supports` lowercases the MIME (and, on a generic MIME, the extension)
|
||||
// — 1–2 allocations per call. Classify each file ONCE here and thread the
|
||||
// flag through both the wanted-hashes filter and the per-file records
|
||||
// loop below, where it used to be re-derived a second time per file.
|
||||
let supported: Vec<bool> = files
|
||||
.iter()
|
||||
.map(|(_, _, name, _, mime, _)| text_extractor::supports(name, mime))
|
||||
.collect();
|
||||
|
||||
// Per-blob text: batch-read the extraction cache, extract misses.
|
||||
let wanted_hashes: Vec<String> = files
|
||||
.iter()
|
||||
.filter(|(_, _, name, _, mime, size)| {
|
||||
text_extractor::supports(name, mime) && *size as u64 <= self.max_extract_file_bytes
|
||||
})
|
||||
.map(|f| f.3.clone())
|
||||
.zip(&supported)
|
||||
.filter(|&(f, sup)| *sup && f.5 as u64 <= self.max_extract_file_bytes)
|
||||
.map(|(f, _)| f.3.clone())
|
||||
.collect();
|
||||
let mut text_by_hash: HashMap<String, Option<String>> = HashMap::new();
|
||||
if !wanted_hashes.is_empty() {
|
||||
@@ -287,8 +295,9 @@ impl ContentIndexWorker {
|
||||
}
|
||||
|
||||
let mut records = Vec::with_capacity(files.len());
|
||||
for (file_id, drive_id, name, blob_hash, mime, size) in files {
|
||||
let supported = text_extractor::supports(&name, &mime);
|
||||
for ((file_id, drive_id, name, blob_hash, mime, size), supported) in
|
||||
files.into_iter().zip(supported)
|
||||
{
|
||||
let content = if !supported {
|
||||
None
|
||||
} else if let Some(cached) = text_by_hash.get(&blob_hash) {
|
||||
|
||||
@@ -349,6 +349,14 @@ impl TantivyContentIndex {
|
||||
.search(&query, &TopDocs::with_limit(limit.max(1)).order_by_score())
|
||||
.map_err(|e| DomainError::internal_error("ContentIndex", format!("search: {e}")))?;
|
||||
|
||||
// No hits → no documents to highlight. `SnippetGenerator::create`
|
||||
// compiles the query against the index (term lookups + weight build);
|
||||
// for a query that matched nothing that is pure waste on the search
|
||||
// request path, and the per-hit loop below never runs. Return early.
|
||||
if top_docs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Snippets highlight CONTENT matches; an empty fragment means the hit
|
||||
// came from the name (or a fuzzy variant) — no snippet then.
|
||||
let snippet_generator = SnippetGenerator::create(&searcher, &*query, fields.content)
|
||||
|
||||
Reference in New Issue
Block a user