Files
homarr/scripts/benchmarks/attribute-memory.mts
T
Thomas Camlong cb3a2a360e perf(umami): stop buffering 100k raw events per widget refresh
Profiling the container end to end found one allocation site responsible for
most of the peak: a single 46.3 MiB JSON body, assembled from 3,946 socket
chunks, inside undici's fullyReadBody. That function holds the chunk list and
the joined copy at the same instant, so a body of size N costs about 2N.

The source was two Umami calls asking for pageSize=100000, with
getMultiEventTimeSeriesAsync running them for every tracked event name
concurrently via Promise.all. getEventNamesAsync was downloading every raw
event of the last 30 days purely to collect a handful of distinct name strings.

  - getEventNamesAsync now reads the aggregated /metrics?type=event endpoint.
  - getWebsiteEventTimeSeriesAsync pages 5,000 records at a time, folding each
    page into the bucket map before requesting the next.
  - getMultiEventTimeSeriesAsync runs series sequentially.

Instances that return aggregated {x,y} buckets still answer in one request, an
instance that accepts pageSize but ignores page is detected rather than
double-counted, and hitting the page ceiling logs instead of silently
undercounting.

Alternated runs, 2 per side, on a restored real backup:

  peak container       498.9 -> 398.5 MiB  (-20%)
  peak node RSS        452.4 -> 353.3 MiB  (-22%)
  peak during 4 tabs   498.5 -> 356.3 MiB  (-29%)
  peak arrayBuffers     95.8 ->   6.6 MiB  (-93%)
  largest allocation    46.4 ->   2.4 MiB  (-95%)

Costs +4% CPU from the extra round trips; board load latency unchanged.
Post-soak steady state and boot idle are unchanged within run-to-run spread.

Adds the attribution tooling this needed: attribute-memory.mts reconciles
cgroup -> processes -> address-space mappings -> V8 spaces -> loaded code,
memory-probe.cjs is an injectable diagnostics preload (never shipped) that
tracks buffer and fetch allocation sites, analyze-heap-dominators.mjs computes
real retained sizes, and compare-stress-runs.mjs reports alternated runs with
their spread.

Also corrects an earlier claim in PHASE2-FINDINGS.md: the reported "7.86 MiB of
duplicate chunk sources" was an artefact of V8 truncating string contents to
~1 KiB in heap snapshots. There are zero byte-identical duplicates among all
3,055 server JS files. The real doubling is character width - one codepoint
above U+00FF forces a whole source string to two bytes per character, which
costs 9.45 MiB across 80 loaded files.

Adds 6 tests; this integration had none.
2026-08-09 21:35:00 +02:00

452 lines
21 KiB
TypeScript

/**
* Full RAM attribution for a running Homarr container, top-down:
*
* cgroup -> processes -> address-space mappings -> V8 spaces -> JS objects
*
* Every layer is measured, and each layer's total is reconciled against the one
* above it so nothing hides in a rounding gap. The point is to answer "what takes
* what" with numbers that add up, rather than quoting one aggregate (`memory.current`,
* `heapUsed`) that silently omits most of the footprint.
*
* node --experimental-strip-types scripts/benchmarks/attribute-memory.mts <container>
*
* Notes on the layers, because each one measures something different:
* - cgroup `anon` is the number that counts against a container limit and cannot be
* reclaimed. `memory.current` also includes page cache, which swings by tens of MiB
* for reasons unrelated to the app.
* - `smaps` is the only place the split between JS heap, JIT code, native malloc and
* mapped shared libraries is visible. V8's own numbers cover just its heap.
* - `heapUsed` is live JS objects. `heapTotal` is what V8 has committed, including
* free space it is holding for reuse. The gap between them is real RSS.
*/
import { execFile, spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const container = process.argv[2];
if (!container) throw new Error("usage: attribute-memory.mts <container> [--snapshot <file>] [--label <name>]");
const snapshotIndex = process.argv.indexOf("--snapshot");
const snapshotPath = snapshotIndex === -1 ? undefined : process.argv[snapshotIndex + 1];
const labelIndex = process.argv.indexOf("--label");
const label = labelIndex === -1 ? "current" : (process.argv[labelIndex + 1] ?? "current");
const MiB = (bytes: number) => Math.round((bytes / 1048576) * 10) / 10;
const pad = (value: string | number, width: number) => String(value).padStart(width);
const execIn = async (script: string) => {
const { stdout } = await execFileAsync("docker", ["exec", container, "sh", "-c", script], {
maxBuffer: 64 * 1024 * 1024,
});
return stdout;
};
/**
* Piping to stdin avoids the escaping problems of `node -e "..."` through two shells.
* NODE_OPTIONS is cleared because the container may carry a `--require` preload meant only
* for the long-lived server; inheriting it into every helper is wasted work at best.
*/
const execNodeIn = (script: string) =>
new Promise<string>((resolve, reject) => {
const proc = spawn("docker", ["exec", "-e", "NODE_OPTIONS=", "-i", container, "node"], {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk) => (stdout += chunk));
proc.stderr.on("data", (chunk) => (stderr += chunk));
proc.on("error", reject);
proc.on("close", (code) => (code === 0 ? resolve(stdout) : reject(new Error(stderr.trim() || `node exited ${code}`))));
proc.stdin.end(script);
});
/** comm is truncated to 15 chars and Next retitles itself, so match by prefix. */
const findServerPidAsync = async () => {
const out = await execIn(
`for p in /proc/[0-9]*; do n=$(cat "$p/comm" 2>/dev/null);` +
` case "$n" in node*|next-server*|bun*) echo "$(basename "$p") $n";; esac; done`,
);
const line = out.trim().split("\n").filter(Boolean)[0];
if (!line) throw new Error("no node/next-server/bun process found in the container");
const [pid, comm] = line.split(/\s+/);
return { pid: Number(pid), comm: comm ?? "?" };
};
const { pid, comm } = await findServerPidAsync();
// ---------------------------------------------------------------- layer 1: cgroup
const cgroupRaw = await execIn(
`cat /sys/fs/cgroup/memory.current; echo ---; cat /sys/fs/cgroup/memory.peak 2>/dev/null || echo 0; echo ---; cat /sys/fs/cgroup/memory.stat`,
);
const [currentRaw, peakRaw, statRaw] = cgroupRaw.split("---");
const cgroupStat = new Map<string, number>();
for (const line of (statRaw ?? "").trim().split("\n")) {
const [key, value] = line.trim().split(/\s+/);
if (key && value) cgroupStat.set(key, Number(value));
}
const cgroupCurrent = Number((currentRaw ?? "0").trim());
const cgroupPeak = Number((peakRaw ?? "0").trim());
// ---------------------------------------------------------- layer 2: per-process RSS
const perProcessRaw = await execIn(
`for p in /proc/[0-9]*/status; do awk '/^Name:/{n=$2} /^VmRSS:/{print n" "$2}' "$p" 2>/dev/null; done`,
);
const processes = perProcessRaw
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [name, kib] = line.trim().split(/\s+/);
return { name: name ?? "?", bytes: Number(kib) * 1024 };
})
.sort((a, b) => b.bytes - a.bytes);
// ------------------------------------------------- layer 3: address space via smaps
/**
* Buckets every mapping of the server process. The categories are chosen so each maps
* onto a distinct cause a developer can act on:
* - `.so` / node binary text: the runtime and native addons, mostly shared and
* file-backed, so cheap and not something app code controls.
* - JIT code: anonymous executable pages, i.e. what V8 compiled at runtime. Grows
* with how much JavaScript actually ran, not with how much shipped.
* - large anonymous: V8's heap reservations and big malloc'd blocks.
* - small anonymous: malloc arenas and thread stacks.
*/
const smapsRaw = await execIn(`cat /proc/${pid}/smaps`);
type Mapping = { start: number; end: number; size: number; rss: number; anonymous: number; perms: string; path: string };
const mappings: Mapping[] = [];
{
let current: Mapping | undefined;
for (const line of smapsRaw.split("\n")) {
const header = /^([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)$/.exec(line);
if (header) {
if (current) mappings.push(current);
const start = Number.parseInt(header[1]!, 16);
const end = Number.parseInt(header[2]!, 16);
current = { start, end, size: end - start, rss: 0, anonymous: 0, perms: header[3]!, path: (header[4] ?? "").trim() };
continue;
}
if (!current) continue;
const rss = /^Rss:\s+(\d+) kB/.exec(line);
if (rss) current.rss = Number(rss[1]) * 1024;
const anon = /^Anonymous:\s+(\d+) kB/.exec(line);
if (anon) current.anonymous = Number(anon[1]) * 1024;
}
if (current) mappings.push(current);
}
/**
* Anonymous mappings cannot be told apart by size alone: V8 carves its heap out of one
* huge reservation in ~256 KiB pages, so /proc shows hundreds of small `rw-p` mappings
* that look exactly like malloc arenas. Grouping address-adjacent anonymous mappings
* back into the reservation they came from restores the distinction — a region reserving
* gigabytes of address space is V8's cage, and committed RSS inside it is the JS heap.
* Everything anonymous outside those regions is native: malloc, Buffers, thread stacks.
*/
const anonymousMappings = mappings.filter((mapping) => !mapping.path).sort((a, b) => a.start - b.start);
type Region = { start: number; end: number; reserved: number; rss: number; exec: boolean; maps: number };
const regions: Region[] = [];
for (const mapping of anonymousMappings) {
const last = regions.at(-1);
if (last && mapping.start === last.end) {
last.end = mapping.end;
last.reserved += mapping.size;
last.rss += mapping.rss;
last.exec ||= mapping.perms.includes("x");
last.maps++;
continue;
}
regions.push({
start: mapping.start,
end: mapping.end,
reserved: mapping.size,
rss: mapping.rss,
exec: mapping.perms.includes("x"),
maps: 1,
});
}
/** Reserving >=512 MiB of address space is only ever V8 claiming its cage up front. */
const V8_RESERVATION_BYTES = 512 * 1048576;
const v8Regions = regions.filter((region) => region.reserved >= V8_RESERVATION_BYTES);
const inV8Region = (address: number) =>
v8Regions.some((region) => address >= region.start && address < region.end);
/**
* V8 allocates its heap in 256 KiB pages (`kRegularPageSize`), which is the single most
* recognisable signature in the map: a 256 KiB anonymous `rw-p` mapping is a JS heap page
* and nothing else in the process allocates at exactly that granularity in bulk. Thread
* stacks are the other unmistakable shape — pthread reserves 8 MiB + a guard page, so they
* show up as ~8196 KiB reservations that are almost entirely untouched.
*/
const V8_PAGE_BYTES = 262144;
const THREAD_STACK_BYTES = 8196 * 1024;
const categorise = (mapping: Mapping) => {
const { path, perms } = mapping;
const executable = perms.includes("x");
if (path === "[stack]") return "main thread stack";
if (path === "[heap]") return "malloc brk heap";
if (path.startsWith("[")) return `kernel ${path}`;
if (path) {
if (/\.node$/.test(path)) return `native addon ${path.split("/").pop()}`;
if (/\/node$|\/bun$/.test(path)) return executable ? "node binary (code)" : "node binary (data)";
if (/\.so/.test(path)) return executable ? "shared library (code)" : "shared library (data)";
return `file-backed ${path.split("/").pop()}`;
}
if (executable) return "JIT code (anonymous executable)";
if (mapping.size === V8_PAGE_BYTES) return "V8 heap pages (256 KiB regular pages)";
if (mapping.size === THREAD_STACK_BYTES) return "thread stacks (8 MiB reserved each)";
if (inV8Region(mapping.start)) return "V8 reserved range (code range / trusted space)";
if (mapping.size >= 8 * 1048576) return "native: large anonymous mmap (>=8 MiB)";
return "native: malloc arenas, Buffers, small V8 spaces";
};
const byCategory = new Map<string, { rss: number; size: number; count: number }>();
let smapsRssTotal = 0;
let smapsAnonTotal = 0;
for (const mapping of mappings) {
smapsRssTotal += mapping.rss;
smapsAnonTotal += mapping.anonymous;
const key = categorise(mapping);
const acc = byCategory.get(key) ?? { rss: 0, size: 0, count: 0 };
acc.rss += mapping.rss;
acc.size += mapping.size;
acc.count++;
byCategory.set(key, acc);
}
// ------------------------------------------------------------- layer 4: V8 internals
/**
* Preferred path: the preloaded probe's localhost endpoint. It needs no signal, no CDP
* handshake and no GC pause, so it can be sampled at every stage of a stress run.
*/
const probeHttp = await execNodeIn(`
fetch("http://127.0.0.1:${process.env.HOMARR_PROBE_PORT ?? 9333}/")
.then((response) => response.text())
.then((text) => console.log(text))
.catch((error) => console.log(JSON.stringify({ probeUnavailable: String(error && error.message) })));
`)
.then((raw) => {
try {
return JSON.parse(raw.trim());
} catch {
return null;
}
})
.catch(() => null);
const haveProbeHttp = probeHttp !== null && !probeHttp.probeUnavailable;
/** The inspector is still needed for a heap snapshot, and as the fallback when no probe is loaded. */
const needInspector = Boolean(snapshotPath) || !haveProbeHttp;
let webSocketUrl: string | undefined;
if (needInspector) {
await execIn(`kill -USR1 ${pid}`).catch(() => undefined);
await new Promise((resolve) => setTimeout(resolve, 1_500));
const listRaw = await execNodeIn(`
const http = require("node:http");
http.get({ host: "127.0.0.1", port: 9229, path: "/json/list" }, (response) => {
let body = "";
response.on("data", (chunk) => (body += chunk));
response.on("end", () => console.log(body));
}).on("error", (error) => console.log("ERR " + error.message));
`).catch((error: Error) => `ERR ${error.message}`);
webSocketUrl = /"webSocketDebuggerUrl":\s*"([^"]+)"/.exec(listRaw)?.[1];
}
const evaluateInServerAsync = async (expression: string) => {
if (!webSocketUrl) return null;
const out = await execNodeIn(`
const ws = new WebSocket(${JSON.stringify(webSocketUrl)});
let nextId = 0;
const pending = new Map();
const send = (method, params = {}) =>
new Promise((resolve) => { const id = ++nextId; pending.set(id, resolve); ws.send(JSON.stringify({ id, method, params })); });
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) { pending.get(message.id)(message.result); pending.delete(message.id); }
});
ws.addEventListener("error", () => { console.error("inspector websocket error"); process.exit(1); });
ws.addEventListener("open", async () => {
await send("Runtime.enable");
const response = await send("Runtime.evaluate", { expression: ${JSON.stringify(expression)}, returnByValue: true, awaitPromise: true });
if (response?.exceptionDetails) { console.error(JSON.stringify(response.exceptionDetails).slice(0, 800)); process.exit(1); }
console.log(JSON.stringify(response?.result?.value ?? null));
ws.close();
process.exit(0);
});
`).catch((error: Error) => `null /* ${error.message} */`);
try {
return JSON.parse(out.trim());
} catch {
return null;
}
};
/**
* Fallback when no probe is mounted. The inspected context has no `require`, so the only
* route to the module system is `process.mainModule` — which the Next standalone entry
* does not set, so per-space statistics are usually unavailable this way. Kept because it
* still works on containers started by hand, and reports `process.memoryUsage()` regardless.
*/
const v8Detail = haveProbeHttp
? { ...probeHttp, probe: true }
: await evaluateInServerAsync(`(() => {
const usage = process.memoryUsage();
const out = { usage, spaces: null, heap: null, modules: null, largestModules: null, probe: false,
versions: process.versions, execArgv: process.execArgv, uptime: process.uptime() };
const probe = globalThis.__homarrProbe;
try {
if (probe) {
out.probe = true;
out.spaces = probe.spaces();
out.heap = probe.heap();
out.modules = probe.modules();
out.largestModules = probe.largestModules(25);
} else if (process.mainModule) {
const req = process.mainModule.require.bind(process.mainModule);
const v8 = req("v8");
out.spaces = v8.getHeapSpaceStatistics();
out.heap = v8.getHeapStatistics();
}
} catch (error) { out.error = String(error && error.message); }
return out;
})()`);
// --------------------------------------------------------------------- report
const line = (name: string, bytes: number, of: number, note = "") =>
`${pad(MiB(bytes).toFixed(1), 9)} MiB ${pad(((bytes / of) * 100).toFixed(1), 5)}% ${name}${note ? ` ${note}` : ""}`;
console.log(`\n############ RAM attribution: ${label} ############`);
console.log(`container ${container} server pid ${pid} (${comm}) uptime ${Math.round(v8Detail?.uptime ?? 0)}s`);
if (v8Detail?.execArgv?.length) console.log(`node flags: ${v8Detail.execArgv.join(" ")}`);
console.log(`\n=== LAYER 1: cgroup (what the container limit sees) ===`);
const anon = cgroupStat.get("anon") ?? 0;
console.log(line("anon — non-reclaimable, the real cost", anon, cgroupCurrent));
console.log(line("file — page cache, reclaimable under pressure", cgroupStat.get("file") ?? 0, cgroupCurrent));
for (const key of ["slab", "kernel_stack", "pagetables", "sock", "shmem"]) {
const value = cgroupStat.get(key) ?? 0;
if (value > 0) console.log(line(`${key} — kernel bookkeeping`, value, cgroupCurrent));
}
console.log(`${pad(MiB(cgroupCurrent).toFixed(1), 9)} MiB memory.current (sum of the above)`);
console.log(`${pad(MiB(cgroupPeak).toFixed(1), 9)} MiB memory.peak (high-water since boot)`);
console.log(`\n=== LAYER 2: processes inside the container ===`);
const processTotal = processes.reduce((sum, item) => sum + item.bytes, 0);
for (const item of processes) console.log(line(item.name, item.bytes, processTotal));
console.log(`${pad(MiB(processTotal).toFixed(1), 9)} MiB sum of process RSS`);
console.log(`\n=== LAYER 3: address space of pid ${pid} (smaps, ${mappings.length} mappings) ===`);
for (const [name, acc] of [...byCategory.entries()].sort((a, b) => b[1].rss - a[1].rss)) {
if (acc.rss < 65536) continue;
console.log(line(name, acc.rss, smapsRssTotal, `(${acc.count} maps, ${MiB(acc.size).toFixed(0)} MiB reserved)`));
}
console.log(`${pad(MiB(smapsRssTotal).toFixed(1), 9)} MiB total RSS`);
console.log(`${pad(MiB(smapsAnonTotal).toFixed(1), 9)} MiB of which anonymous (not backed by a file)`);
console.log(
`\n address-space reservations >=512 MiB (V8 claims its heap up front; only committed pages cost RAM):`,
);
for (const region of v8Regions.sort((a, b) => b.reserved - a.reserved)) {
console.log(
` ${pad((region.reserved / 1073741824).toFixed(1), 6)} GiB reserved -> ${pad(MiB(region.rss).toFixed(1), 7)} MiB committed (${region.maps} mappings)`,
);
}
if (v8Detail?.usage) {
const usage = v8Detail.usage;
console.log(`\n=== LAYER 4: inside V8 ===`);
console.log(line("heapTotal — committed to the JS heap", usage.heapTotal, usage.rss));
console.log(line(" heapUsed — live JS objects in it", usage.heapUsed, usage.rss));
console.log(line(" heap free — committed but unused", usage.heapTotal - usage.heapUsed, usage.rss));
console.log(line("external — Buffers/ArrayBuffers off-heap", usage.external, usage.rss));
console.log(line(" arrayBuffers — of which ArrayBuffer", usage.arrayBuffers, usage.rss));
const rest = usage.rss - usage.heapTotal - usage.external;
console.log(line("rest — native malloc, JIT code, .so, stacks", rest, usage.rss));
console.log(`${pad(MiB(usage.rss).toFixed(1), 9)} MiB process.memoryUsage().rss`);
if (v8Detail.spaces) {
console.log(`\n--- V8 heap spaces (which part of the JS heap) ---`);
const spaceTotal = v8Detail.spaces.reduce((sum: number, s: { space_size: number }) => sum + s.space_size, 0);
for (const space of [...v8Detail.spaces].sort((a, b) => b.space_size - a.space_size)) {
if (space.space_size < 65536) continue;
console.log(
line(space.space_name, space.space_size, spaceTotal, `(${MiB(space.space_used_size).toFixed(1)} MiB used)`),
);
}
}
if (v8Detail.heap) {
console.log(`\n--- V8 heap statistics ---`);
console.log(` heap_size_limit ${MiB(v8Detail.heap.heap_size_limit).toFixed(0)} MiB (--max-old-space-size)`);
console.log(` malloced_memory ${MiB(v8Detail.heap.malloced_memory).toFixed(1)} MiB`);
console.log(` peak_malloced_memory ${MiB(v8Detail.heap.peak_malloced_memory).toFixed(1)} MiB`);
console.log(` number_of_native_contexts ${v8Detail.heap.number_of_native_contexts}`);
console.log(` number_of_detached_contexts ${v8Detail.heap.number_of_detached_contexts}`);
}
if (v8Detail.modules) {
const modules = v8Detail.modules;
console.log(
`\n--- LAYER 5: loaded code, by owner (${modules.fileCount} files, ${MiB(modules.totalBytes).toFixed(1)} MiB of source on disk) ---`,
);
console.log(
` V8 keeps a script's source string alive while any function in it is still lazily`,
);
console.log(` compilable, so this is a floor on what merely *loading* the code costs.`);
for (const group of modules.groups) {
if (group.bytes < 262144) continue;
console.log(line(group.name, group.bytes, modules.totalBytes, `(${group.files} files)`));
}
console.log(`${pad(MiB(modules.totalBytes).toFixed(1), 9)} MiB total source loaded`);
}
if (v8Detail.largestModules) {
console.log(`\n--- largest individual loaded files ---`);
for (const item of v8Detail.largestModules.slice(0, 15)) {
const short = item.file.replace(/^.*\/(\.next|node_modules)\//, "$1/");
console.log(` ${pad(MiB(item.bytes).toFixed(2), 8)} MiB ${short}`);
}
}
if (!v8Detail.probe) {
console.log(
`\n (no probe loaded: per-space and per-module attribution unavailable. Start the container with`,
);
console.log(` NODE_OPTIONS="--require /probe/memory-probe.cjs" and the probe dir mounted.)`);
}
if (v8Detail.error) console.log(` (v8 detail unavailable: ${v8Detail.error})`);
} else {
console.log(`\n=== LAYER 4: inside V8 === unavailable (inspector not reachable)`);
}
if (snapshotPath) {
const inContainerPath = "/tmp/attribute.heapsnapshot";
console.log(`\n=== taking heap snapshot (after a forced GC, so it shows retained not garbage) ===`);
console.log(
(
await execNodeIn(`
const fs = require("node:fs");
const out = fs.createWriteStream(${JSON.stringify(inContainerPath)});
const ws = new WebSocket(${JSON.stringify(webSocketUrl)});
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.method === "HeapProfiler.addHeapSnapshotChunk") out.write(message.params.chunk);
else if (message.id === 3) out.end(() => { console.log("written"); process.exit(0); });
});
ws.addEventListener("error", () => { console.error("inspector websocket error"); process.exit(1); });
ws.addEventListener("open", () => {
ws.send(JSON.stringify({ id: 1, method: "HeapProfiler.enable" }));
ws.send(JSON.stringify({ id: 2, method: "HeapProfiler.collectGarbage" }));
ws.send(JSON.stringify({ id: 3, method: "HeapProfiler.takeHeapSnapshot", params: { reportProgress: false } }));
});
`)
).trim(),
);
await new Promise<void>((resolve, reject) => {
const out = createWriteStream(snapshotPath);
const proc = spawn("docker", ["exec", container, "cat", inContainerPath]);
proc.stdout.pipe(out);
proc.on("error", reject);
proc.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`docker exec cat exited ${code}`))));
});
await execIn(`rm -f ${inContainerPath}`).catch(() => undefined);
console.log(`saved ${snapshotPath}`);
}