perf: round 14 — faces narrow projection, auth per-request allocs, CalDAV emit buffers, frontend set churn
Benchmark-gated (benches/ROUND14.md); every change ships a BEFORE/AFTER
benchmark with an equivalence gate and is rolled back on regression (the
rule is encoded as a GATE FAIL exit / threshold expect).
Backend
- Q1 faces_for_file → narrow face_boxes_for_file(id, person_id, bbox) with the
caller filter pushed into SQL: drops the 2 KiB embedding BYTEA + 6 unused
columns per face. 15-face lightbox open 0.312→0.219 ms, 32 KB→840 B/req.
- A1 cookie auth uses the borrow-only extract_cookie_str (already backs CSRF)
instead of extract_cookie_value's owned String: -1 alloc/cookie request.
- A2 compute_relevance ASCII case-fold fast path vs name.to_lowercase() per
result row (Unicode fallback preserved): 1.40x, 12→3 allocs/page.
- A3 sub pre-parsed to Uuid at decode time (TokenClaims.sub_id) vs re-parsing
the 36-char claim on every request incl. cache hits: 22.7→0.7 ns.
- A4 auth + NextCloud middlewares borrow request.headers() instead of taking
axum's HeaderMap extractor (a full map clone): 2→0 allocs/authed request.
- A5 CalDAV getlastmodified via the stack rfc2822_utc (byte-identical to
chrono) vs a per-event to_rfc2822() heap String: 5→0 allocs.
- A6 CalDAV per-event href + quoted etag written into reused page buffers vs a
fresh format! pair per event: 3.48x, 240→6 allocs/40-event page.
Frontend
- F1 t() shares one frozen EMPTY_PARAMS for the no-interpolation call forms vs
a throwaway {} per call: -1 alloc/call.
- F2 favorites favoriteIds is a persistent SvelteSet with per-page add (clear
on reset) vs a brand-new set over the whole accumulated list each page:
22.3x over a 40-page drain (O(N^2)→O(N)).
Verified: cargo check --all-targets, cargo clippy -D warnings, both bench
packs (GATE PASS), frontend npm run check + vitest (4/4). ROUND14.md also
records the investigated-but-deferred backlog (music N+1, contact vcard
over-fetch, CachedBlobBackend syscalls, ResourceList.sections builder, etc.).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PymgCdK78NzUF3oRAQCJfN
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// Round-14 frontend micro-pack (benches/ROUND14.md §F1, §F2).
|
||||
//
|
||||
// Each section is BEFORE (verbatim replica of the shipped shape) vs AFTER
|
||||
// (proposed shape), with an equivalence gate and a wall-time perf gate — the
|
||||
// same discipline as the Rust micro-packs: an AFTER that doesn't beat its
|
||||
// BEFORE fails the gate.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [F2] favorites `favoriteIds` — rebuild-a-fresh-Set-per-page vs incremental
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Audit finding: the favorites route derived `favoriteIds = new SvelteSet(
|
||||
// items.map(i => i.id))`. Every infinite-scroll page (`raw = [...raw, ...page]`)
|
||||
// rebuilt a brand-new set over the WHOLE accumulated list — O(N) per page,
|
||||
// O(N²) across a P-page drain — and, being a new instance each page,
|
||||
// invalidated every mounted star reader. The fix keeps one persistent set and
|
||||
// `add`s only the fresh page's ids (clear on reset). Since every item on the
|
||||
// page is a favorite and removed items aren't rendered, the set only has to be
|
||||
// a superset of the displayed ids, so `add`-only is correct.
|
||||
|
||||
/** A page of ids (50/page, the default page size). */
|
||||
function pageOf(start: number, n: number): string[] {
|
||||
return Array.from({ length: n }, (_, i) => `fav-${start + i}`);
|
||||
}
|
||||
|
||||
/** BEFORE: rebuild a fresh Set over the whole accumulated list each page. */
|
||||
function rebuildPerPage(pages: string[][]): Set<string> {
|
||||
let acc: string[] = [];
|
||||
let set = new Set<string>();
|
||||
for (const page of pages) {
|
||||
acc = [...acc, ...page]; // the route's `raw = [...raw, ...page]`
|
||||
set = new Set(acc.map((id) => id)); // new instance + O(N) rebuild
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/** AFTER: one persistent set, add only the fresh page's ids. */
|
||||
function incrementalPerPage(pages: string[][]): Set<string> {
|
||||
const set = new Set<string>();
|
||||
for (const page of pages) {
|
||||
for (const id of page) set.add(id);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
describe('round14 §F2 — favorites favoriteIds incremental set', () => {
|
||||
it('final membership is identical (equivalence gate)', () => {
|
||||
const pages = Array.from({ length: 20 }, (_, p) => pageOf(p * 50, 50));
|
||||
const before = rebuildPerPage(pages);
|
||||
const after = incrementalPerPage(pages);
|
||||
expect(after.size).toBe(before.size);
|
||||
for (const id of before) expect(after.has(id)).toBe(true);
|
||||
for (const id of after) expect(before.has(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('a P-page drain builds the set ≥5x faster incrementally (perf gate)', () => {
|
||||
const PAGES = 40;
|
||||
const PER = 50; // 2 000 items total
|
||||
const pages = Array.from({ length: PAGES }, (_, p) => pageOf(p * PER, PER));
|
||||
|
||||
const run = (f: (p: string[][]) => Set<string>): number => {
|
||||
const t0 = performance.now();
|
||||
// A few repetitions so the measurement isn't dominated by timer noise.
|
||||
for (let r = 0; r < 20; r++) f(pages);
|
||||
return performance.now() - t0;
|
||||
};
|
||||
|
||||
// Warm-up (JIT) then measure.
|
||||
run(rebuildPerPage);
|
||||
run(incrementalPerPage);
|
||||
const beforeMs = run(rebuildPerPage);
|
||||
const afterMs = run(incrementalPerPage);
|
||||
|
||||
console.info(
|
||||
`§F2 ${PAGES} pages × ${PER}: rebuild-per-page ${beforeMs.toFixed(1)} ms vs incremental ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [F1] t() params — throwaway `{}` per call vs a shared frozen empty object
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The ubiquitous inline-fallback form `t('k', 'Fallback')` and the bare
|
||||
// `t('k')` (default param `= {}`) allocated a fresh params object on every
|
||||
// call, though for a cache-hit string with no `{{…}}` `interpolate` returns
|
||||
// before ever reading params. t() runs ~10×/row. The fix hoists a shared
|
||||
// frozen `EMPTY_PARAMS` for both no-param branches.
|
||||
|
||||
const EMPTY_PARAMS: Record<string, unknown> = Object.freeze({});
|
||||
|
||||
/** Model of the shipped t() param selection + a representative params read
|
||||
* (interpolate's `params[name]` lookup), isolated from dictionary I/O. */
|
||||
function tBefore(paramsOrFallback: string | Record<string, unknown> = {}): unknown {
|
||||
const isStringForm = typeof paramsOrFallback === 'string';
|
||||
const params = isStringForm ? {} : paramsOrFallback;
|
||||
return (params as Record<string, unknown>)['n'];
|
||||
}
|
||||
function tAfter(paramsOrFallback: string | Record<string, unknown> = EMPTY_PARAMS): unknown {
|
||||
const isStringForm = typeof paramsOrFallback === 'string';
|
||||
const params = isStringForm ? EMPTY_PARAMS : paramsOrFallback;
|
||||
return (params as Record<string, unknown>)['n'];
|
||||
}
|
||||
|
||||
describe('round14 §F1 — t() shared empty params', () => {
|
||||
it('produces identical results for the no-param call forms (equivalence gate)', () => {
|
||||
expect(tAfter()).toBe(tBefore());
|
||||
expect(tAfter('Owner')).toBe(tBefore('Owner'));
|
||||
expect(tAfter({ n: 5 })).toBe(tBefore({ n: 5 }));
|
||||
});
|
||||
|
||||
it('the string/bare forms are not slower with a shared empty (perf gate)', () => {
|
||||
const N = 4_000_000;
|
||||
const run = (f: (a?: string | Record<string, unknown>) => unknown): number => {
|
||||
let sink: unknown;
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) {
|
||||
// Alternate the two no-param call forms (bare + string fallback).
|
||||
sink = i & 1 ? f('Fallback') : f();
|
||||
}
|
||||
void sink;
|
||||
return performance.now() - t0;
|
||||
};
|
||||
// Warm-up then measure (best-of-3 to damp GC/JIT noise).
|
||||
run(tBefore);
|
||||
run(tAfter);
|
||||
const beforeMs = Math.min(run(tBefore), run(tBefore), run(tBefore));
|
||||
const afterMs = Math.min(run(tAfter), run(tAfter), run(tAfter));
|
||||
console.info(
|
||||
`§F1 ${N} no-param t() calls: fresh {} ${beforeMs.toFixed(1)} ms vs shared frozen ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(2)}x)`
|
||||
);
|
||||
// Zero-risk alloc reduction: the shared-empty arm must be no slower.
|
||||
expect(afterMs).toBeLessThanOrEqual(beforeMs * 1.05);
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,12 @@ async function loadDict(locale: string): Promise<Dict> {
|
||||
return dicts[locale];
|
||||
}
|
||||
|
||||
/** Shared frozen empty params for the no-interpolation call forms, so the
|
||||
* ubiquitous `t(key)` / `t(key, 'fallback')` don't each allocate a throwaway
|
||||
* `{}` (t() is the hottest UI function — ~10× per row). Never mutated, so a
|
||||
* single shared instance is safe. See benches/ROUND14.md §F1. */
|
||||
const EMPTY_PARAMS: Record<string, unknown> = Object.freeze({});
|
||||
|
||||
/**
|
||||
* Translate a key.
|
||||
* - `t(key)` / `t(key, params)` — interpolation params object.
|
||||
@@ -209,11 +215,11 @@ async function loadDict(locale: string): Promise<Dict> {
|
||||
*/
|
||||
export function t(
|
||||
key: string,
|
||||
paramsOrFallback: string | Record<string, unknown> = {},
|
||||
paramsOrFallback: string | Record<string, unknown> = EMPTY_PARAMS,
|
||||
fallbackArg?: string
|
||||
): string {
|
||||
const isStringForm = typeof paramsOrFallback === 'string';
|
||||
const params = isStringForm ? {} : paramsOrFallback;
|
||||
const params = isStringForm ? EMPTY_PARAMS : paramsOrFallback;
|
||||
const fallback = isStringForm ? paramsOrFallback : (fallbackArg ?? null);
|
||||
|
||||
const localeData = dicts[store.locale];
|
||||
|
||||
@@ -57,7 +57,14 @@
|
||||
raw.map((it) => [it.resource.id, { date: it.favorited_at } satisfies ItemContext])
|
||||
)
|
||||
);
|
||||
const favoriteIds = $derived(new SvelteSet(items.map((i) => i.id)));
|
||||
// Persistent reactive set, updated in place per page (add the fresh page's
|
||||
// ids; clear on reset) instead of rebuilding a brand-new SvelteSet over the
|
||||
// whole accumulated list on every infinite-scroll page — that was O(N²)
|
||||
// across a drain and, being a new instance each page, invalidated every
|
||||
// mounted star reader. Every item on this page is a favorite, and removed
|
||||
// items are no longer rendered, so the set only needs to be a superset of
|
||||
// the displayed ids (benches/ROUND14.md §F2, mirrors recent's shipped shape).
|
||||
const favoriteIds = new SvelteSet<string>();
|
||||
|
||||
const groupBys: GroupByDef[] = [
|
||||
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
|
||||
@@ -106,6 +113,10 @@
|
||||
resourceTypes: ['file', 'folder']
|
||||
});
|
||||
raw = reset ? page.items : [...raw, ...page.items];
|
||||
// Keep the persistent favoriteIds set in sync incrementally: clear on
|
||||
// reset, then add only this page's ids (benches/ROUND14.md §F2).
|
||||
if (reset) favoriteIds.clear();
|
||||
for (const it of page.items) favoriteIds.add(it.resource.id);
|
||||
cursor = page.next_cursor;
|
||||
void owners.resolve(page.items.map((i) => i.resource.created_by));
|
||||
} catch (e) {
|
||||
@@ -148,6 +159,7 @@
|
||||
try {
|
||||
await removeFavorite(kind, item.id);
|
||||
raw = raw.filter((i) => i.resource.id !== item.id);
|
||||
favoriteIds.delete(item.id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user