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
+25 -12
View File
@@ -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