perf(library): load a collection when a view needs it, not at startup
All four collections were fetched at DOMContentLoaded and refetched on every invalidation, whichever view was showing. On a 26 138-track library the track list was 20.5 MB of that, and encoding it cost the backend ~170 MB of transient allocation — paid by someone looking at Home, which draws none of it. - The store warms only albums, artists and genres, on idle after first paint: 1.6 MB together, and what made those views instant. - A nav item prefetches on hover and on keyboard focus, which is the ~100 ms before the click that a cold open would otherwise wait. - An invalidation refetches what something had loaded, and nothing else — a scan no longer loads the track list of a library whose Tracks view nobody has opened. - `index.html`'s first-paint `<track-list>` is `view-hidden`, and `index.ts` no longer activates it: it is markup, not a decision about which view the launch lands on, and activating it was what fetched the whole list for a landing on Home. The track list itself loads on view activation, which the shell drives. Measured on 50 000 tracks: backend RSS at rest 543 → 296 MB, peak 571 → 296 MB, Go heap held 361 → 125 MB, JS heap 31.8 → 18 MB, binding bytes at rest 35.9 → 12.1 MB, heap after a browse 36.5 → 22.7 MB. Tracks first open 26 ms; slowest view open 57 ms. Closes #280
This commit is contained in:
1 parent
3d9828b847
commit
620151aa41
11 files changed
+538
-73
No files matched your search
+162
-5
@@ -42,6 +42,13 @@
|
||||
* browse number below could not see this, because it
|
||||
* visits Explore without ever typing in it.
|
||||
* heap JS heap after a scripted browse, post-GC. m3.
|
||||
* memory what the app holds at rest after launch, before any
|
||||
* view but the landing one is opened: the backend
|
||||
* process's RSS and peak, its Go heap, the page's JS
|
||||
* heap, and the bytes each binding returned on the way.
|
||||
* Then the same after the first open of Tracks, and how
|
||||
* long that open took to its first *row* — the number
|
||||
* eager loading was buying (#280/#281).
|
||||
*
|
||||
* Usage:
|
||||
* node e2e/perf/measure.mjs --label before
|
||||
@@ -51,7 +58,7 @@
|
||||
* Requires a running app: `make dev-headless SEED=bulk`.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from '@playwright/test';
|
||||
@@ -190,6 +197,135 @@ const SEARCH_DEBOUNCE_MS = 150;
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The backend process's memory, from /proc and its own pprof endpoint.
|
||||
*
|
||||
* Linux and a dev build only (pprof is mounted by `-tags dev`); either
|
||||
* missing is a null, not a failure — this is a measurement harness, and
|
||||
* a number it cannot take is reported as absent rather than as zero.
|
||||
*/
|
||||
async function backendMemory() {
|
||||
const out = { rssMB: null, peakMB: null, anonMB: null, goHeapInuseMB: null, goHeapHeldMB: null };
|
||||
const pid = findBackendPid();
|
||||
|
||||
if (pid) {
|
||||
try {
|
||||
const status = readFileSync(`/proc/${pid}/status`, 'utf8');
|
||||
const kb = (k) => Number(status.match(new RegExp(`^${k}:\\s+(\\d+)`, 'm'))?.[1] ?? NaN);
|
||||
|
||||
out.rssMB = round(kb('VmRSS') / 1024, 1);
|
||||
out.peakMB = round(kb('VmHWM') / 1024, 1);
|
||||
out.anonMB = round(kb('RssAnon') / 1024, 1);
|
||||
} catch {
|
||||
// The process went away between finding it and reading it.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await (await fetch('http://localhost:6060/debug/pprof/heap?debug=1')).text();
|
||||
const field = (k) => Number(text.match(new RegExp(`^# ${k} = (\\d+)`, 'm'))?.[1] ?? NaN);
|
||||
|
||||
out.goHeapInuseMB = round(field('HeapInuse') / 1048576, 1);
|
||||
// What the runtime holds from the OS: the RSS the heap accounts for.
|
||||
out.goHeapHeldMB = round((field('HeapSys') - field('HeapReleased')) / 1048576, 1);
|
||||
} catch {
|
||||
// Not a dev build, or pprof is not listening.
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The pid of the running `yj-dev` binary, found by name under /proc. */
|
||||
function findBackendPid() {
|
||||
try {
|
||||
for (const entry of readdirSync('/proc')) {
|
||||
if (!/^\d+$/.test(entry)) continue;
|
||||
|
||||
try {
|
||||
if (readFileSync(`/proc/${entry}/comm`, 'utf8').trim() === 'yj-dev') return entry;
|
||||
} catch {
|
||||
// Raced with an exiting process.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No /proc: not Linux.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Bytes returned per binding name, largest first. */
|
||||
function bytesByBinding(calls) {
|
||||
const by = {};
|
||||
|
||||
for (const c of calls) by[c.path] = (by[c.path] ?? 0) + (c.bytes ?? 0);
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(by)
|
||||
.filter(([, b]) => b > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, b]) => [k, round(b / 1048576, 2)]),
|
||||
);
|
||||
}
|
||||
|
||||
async function pageMemory(page, client) {
|
||||
await client.send('HeapProfiler.collectGarbage');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const heap = await client.send('Runtime.getHeapUsage');
|
||||
const dom = await client.send('Memory.getDOMCounters');
|
||||
|
||||
return { jsHeapMB: round(heap.usedSize / 1048576, 1), domNodes: dom.nodes };
|
||||
}
|
||||
|
||||
async function measureMemory(page, client) {
|
||||
// Settled: the landing view has loaded and any idle warming has run.
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const startupCalls = await page.evaluate(() => window.__yjPerf.calls);
|
||||
const atRest = {
|
||||
backend: await backendMemory(),
|
||||
page: await pageMemory(page, client),
|
||||
bytesByBindingMB: bytesByBinding(startupCalls),
|
||||
totalBindingMB: round(startupCalls.reduce((n, c) => n + (c.bytes ?? 0), 0) / 1048576, 2),
|
||||
};
|
||||
|
||||
// First open of Tracks, to its first row: what a user waits for when
|
||||
// nothing was loaded ahead of them.
|
||||
const since = await page.evaluate(() => performance.now());
|
||||
const firstRowMs = await page.evaluate(async () => {
|
||||
const t0 = performance.now();
|
||||
|
||||
document.dispatchEvent(new CustomEvent('navigate', { detail: { view: 'tracks' } }));
|
||||
|
||||
for (;;) {
|
||||
const list = document.querySelector('#main-content > track-list:not(.view-hidden)');
|
||||
|
||||
if (list?.shadowRoot?.querySelector('[data-testid="track-row"]')) {
|
||||
return Math.round(performance.now() - t0);
|
||||
}
|
||||
|
||||
if (performance.now() - t0 > 60000) return null;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const tracksCalls = await page.evaluate((t) => window.__yjPerf.since(t), since);
|
||||
|
||||
return {
|
||||
atRest,
|
||||
tracksOpen: {
|
||||
firstRowMs,
|
||||
bytesByBindingMB: bytesByBinding(tracksCalls),
|
||||
backend: await backendMemory(),
|
||||
page: await pageMemory(page, client),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
async function measureStartup(page) {
|
||||
const nav = await page.evaluate(() => {
|
||||
const n = performance.getEntriesByType('navigation')[0];
|
||||
@@ -234,16 +370,23 @@ async function measureStartup(page) {
|
||||
};
|
||||
});
|
||||
|
||||
// "First row on screen" is the number a user experiences as startup;
|
||||
// FCP fires on the chrome around an empty list.
|
||||
// "First row on screen" is the number a user experiences as startup
|
||||
// *when the app lands on Tracks*; FCP fires on the chrome around an
|
||||
// empty list.
|
||||
//
|
||||
// The deadline is short because since #280 a landing on any other
|
||||
// view leaves the track list unloaded, and this would otherwise
|
||||
// spend a minute waiting for a row that is not coming. The number
|
||||
// that means something either way is `memory.tracksOpen.firstRowMs`,
|
||||
// which opens Tracks and waits for its first row deliberately.
|
||||
const firstRowMs = await page.evaluate(async () => {
|
||||
const t0 = performance.now();
|
||||
const deadline = t0 + 60000;
|
||||
const deadline = t0 + 2000;
|
||||
|
||||
for (;;) {
|
||||
const list = document.querySelector('track-list');
|
||||
const row = list?.shadowRoot?.querySelector('[role="row"], .track-row');
|
||||
if (row) return Math.round(performance.now() - t0 + (performance.timeOrigin ? 0 : 0));
|
||||
if (row) return Math.round(performance.now() - t0);
|
||||
if (performance.now() > deadline) return null;
|
||||
await new Promise((r) => setTimeout(r, 16));
|
||||
}
|
||||
@@ -1845,6 +1988,12 @@ async function run(label) {
|
||||
loadWallMs: Date.now() - t0,
|
||||
};
|
||||
|
||||
// First of all: at rest means before any measurement opens a view.
|
||||
console.log(' memory at rest, then Tracks first open…');
|
||||
report.memory = await measureMemory(page, client);
|
||||
await page.evaluate(() => document.dispatchEvent(
|
||||
new CustomEvent('navigate', { detail: { view: 'home' } }),
|
||||
));
|
||||
console.log(' startup…');
|
||||
report.startup = await measureStartup(page);
|
||||
// Before anything else navigates: every view's first open has to be
|
||||
@@ -1907,6 +2056,14 @@ async function run(label) {
|
||||
|
||||
const ROWS = [
|
||||
['First contentful paint', (r) => fmt(r.startup.firstContentfulPaintMs, 'ms')],
|
||||
['At rest: backend RSS', (r) => fmt(r.memory?.atRest.backend.rssMB, 'MB')],
|
||||
['At rest: backend peak RSS', (r) => fmt(r.memory?.atRest.backend.peakMB, 'MB')],
|
||||
['At rest: Go heap held', (r) => fmt(r.memory?.atRest.backend.goHeapHeldMB, 'MB')],
|
||||
['At rest: JS heap', (r) => fmt(r.memory?.atRest.page.jsHeapMB, 'MB')],
|
||||
['At rest: binding bytes', (r) => fmt(r.memory?.atRest.totalBindingMB, 'MB')],
|
||||
['Tracks first open: first row', (r) => fmt(r.memory?.tracksOpen.firstRowMs, 'ms')],
|
||||
['Tracks open: backend peak RSS', (r) => fmt(r.memory?.tracksOpen.backend.peakMB, 'MB')],
|
||||
['Tracks open: JS heap', (r) => fmt(r.memory?.tracksOpen.page.jsHeapMB, 'MB')],
|
||||
['First track row', (r) => fmt(r.startup.firstRowAfterLoadMs, 'ms')],
|
||||
['JS transferred', (r) => fmt(round(r.startup.scriptBytes / 1024), 'kB')],
|
||||
['JS evaluated before first paint', (r) => fmt(round((r.startup.scriptBytesBeforePaint ?? 0) / 1024), 'kB')],
|
||||
|
||||
Reference in new issue
Block a user