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
+18 -22
View File
@@ -78,6 +78,7 @@
import { formatBytes } from '$lib/utils/format';
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
import { gridColumns } from '$lib/utils/grid';
import { ResourceSectionsBuilder } from '$lib/utils/resourceSections';
import { fileThumbnailUrl, thumbSizeForView } from '$lib/api/endpoints/files';
import {
canThumbnailClientSide,
@@ -369,29 +370,24 @@
/**
* Partition the visible items into grouped sections when a `bucketOf` is
* active. Server order is preserved within and across buckets (first-seen).
*
* `ResourceSectionsBuilder` re-buckets only the freshly-appended page rather
* than the whole accumulated list, and hands `VirtualList` the same rows
* array reference for every untouched bucket so it skips re-rendering it. An
* infinite-scroll drain of a grouped listing (trash / recent / favorites /
* shared-with-me) collapses from Σ O(N²/page) to O(N) bucketing work
* (benches/ROUND15.md §F1). Held off the reactive graph — a plain
* accumulator keyed by the append cursor, not $state; `sync` is idempotent,
* so if the derive re-fires without an actual append it safely full-rebuilds
* to the same output the pure `buildResourceSections` reference produces.
*/
const sections = $derived.by(
(): Array<{ key: string; label: string; rows: Array<FileItem | FolderItem> }> => {
const bucketOf = activeGroup?.bucketOf;
if (!bucketOf) return [{ key: '', label: '', rows: visibleItems }];
const order: string[] = [];
// Transient bucketing map computed inside $derived.by — not reactive state.
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<string, Array<FileItem | FolderItem>>();
for (const item of visibleItems) {
const k = bucketOf(item, ctxOf(item.id)) ?? '∅';
if (!map.has(k)) {
map.set(k, []);
order.push(k);
}
map.get(k)!.push(item);
}
return order.map((k) => ({
key: k,
label: activeGroup?.labelOf?.(k) ?? k,
rows: map.get(k)!
}));
}
const sectionsBuilder = new ResourceSectionsBuilder<FileItem | FolderItem, ItemContext>();
const sections = $derived.by(() =>
sectionsBuilder.sync(visibleItems, {
bucketOf: activeGroup?.bucketOf,
labelOf: activeGroup?.labelOf,
ctxOf: (item) => ctxOf(item.id)
})
);
const grouped = $derived(!!activeGroup?.bucketOf);