Files
homarr/scripts/benchmarks/analyze-allocations.mjs
T
Thomas Camlong 3cc6ff33df perf(docker): stop loading ssh2, which allocates a 16 MiB WASM heap nobody uses
Profiling at the memory high-water mark - not at idle, which is what every earlier
round measured - found the largest single retainer in every snapshot was one V8
Context holding a 16.00 MiB ArrayBuffer. Expanding it through the dominator tree
named it: ssh2's crypto module, whose POLY1305_WASM_MODULE instantiates a
WebAssembly memory for ChaCha20-Poly1305 as soon as it is required.

Homarr has no SSH feature and does not depend on ssh2. The chain is
dockerode -> docker-modem -> ssh2, and docker-modem/lib/modem.js requires './ssh'
at module scope while using it in exactly one place, already guarded by
`protocol === 'ssh'`. A pnpm patch moves the require inside that branch. SSH-based
Docker hosts still work - require() is cached, so they pay it once - and every
socket-based install, which is everyone else, never loads it.

Measured over four alternated runs on a restored real backup, external memory:

                     before        after
  board loaded    25.4, 25.1    8.3, 11.2
  nav-01          28.5, 25.4   10.1,  8.6
  multi-tab       31.1, 35.8   13.0, 10.0
  post-soak       21.4, 21.5    5.5,  7.2

Consistently -15 to -18 MiB, ranges non-overlapping, and 30 fewer files loaded
(1470 -> 1440).

Peak RSS does NOT improve, and that is worth stating plainly: V8 sizes its heap
against available headroom, so freeing native memory lets it grow further before
collecting. What changes is the composition - a fixed 16 MiB of unusable WASM
buffer becomes headroom the app can actually use, which matters under a container
memory limit but does not move the peak number.

Also adds the instruments this needed. A heap snapshot can never explain a peak,
because V8 runs a full GC before serialising one and reports only survivors:

  - memory-probe.cjs gains in-process allocation sampling, on-demand snapshots via
    v8.writeHeapSnapshot, an smaps endpoint, and require-graph recording.
  - analyze-allocations.mjs diffs two stages to isolate what one stage allocated.
  - analyze-smaps-stages.mjs splits each stage's RSS into JS heap, JIT, native and
    file-backed pages.
  - analyze-heap-dominators.mjs can now expand the largest retainer, which is what
    identified ssh2. A plain graph walk escapes through __proto__ and is useless.

One trap recorded in the findings: taking the peak snapshot over CDP delivers it as
JS string chunks and added ~134 MiB of ArrayBuffers to the process being measured,
producing a fake 693 MiB reading. v8.writeHeapSnapshot streams from C++ instead, and
the probe now reports its own observerCostMiB so the cost of looking is visible.
2026-08-09 22:17:25 +02:00

100 lines
4.4 KiB
JavaScript

// Reports what allocated memory during a stress run, by site and by owner — and, given two
// stages, what a single stage allocated.
//
// node scripts/benchmarks/analyze-allocations.mjs <dir> [fromStage] [toStage]
// node scripts/benchmarks/analyze-allocations.mjs .bench/stress/peak nav-03 07-multi-tab
//
// This is the counterpart to a heap snapshot and answers the opposite question. A snapshot is
// taken after a forced GC, so it shows only what *survived*; the churn that produces a memory
// peak is by definition gone by then. The sampling heap profiler records an allocation stack
// every N bytes regardless of survival, so a peak driven by transient garbage — which is what
// this container's peak turned out to be — shows up here and nowhere else.
//
// Sampling totals are cumulative since boot, so a single stage is only meaningful as the
// difference between two captures. Without stage arguments this reports the cumulative
// profile, which is dominated by boot-time module loading.
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
const [, , directory, fromStage, toStage] = process.argv;
if (!directory) throw new Error("usage: analyze-allocations.mjs <dir> [fromStage] [toStage]");
const load = (stage) => {
const file = path.join(directory, `${stage}.allocations.json`);
if (!existsSync(file)) throw new Error(`missing ${file}`);
const parsed = JSON.parse(readFileSync(file, "utf8"));
if (parsed.disabled) throw new Error("sampling was not enabled (HOMARR_PROBE_SAMPLE_ALLOCATIONS)");
return parsed;
};
const MiB = (bytes) => (bytes / 1048576).toFixed(2);
/** Groups a frame to something a developer could act on: a package, a chunk, or node itself. */
const ownerOf = (key) => {
const url = key.slice(key.indexOf("|") + 1);
if (!url || url.startsWith("undefined")) return "unattributed (no script)";
if (/^node:/.test(url)) {
if (/cjs\/loader|modules\//.test(url)) return "node: CommonJS module loader (reading source)";
return "node internals";
}
const dependency = /\/app\/node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(url);
if (dependency) return `node_modules: ${dependency[1]}`;
if (url.includes("/.next/server/chunks/")) {
const chunk = /chunks\/(?:ssr\/)?([^/:]+)/.exec(url);
return `chunk ${chunk ? chunk[1] : "?"}`;
}
if (url.includes("/.next/server/app/")) return "route bundle";
if (url.includes("/probe/")) return "the probe itself (diagnostics)";
return "other";
};
const shorten = (key) =>
key.replace("|", " <- ").replace("/app/apps/nextjs/.next/server/", "").replace("/app/node_modules/", "");
let label;
let total;
let sites;
if (fromStage && toStage) {
const before = load(fromStage);
const after = load(toStage);
const previous = new Map(before.sites.map((site) => [site.key, site.bytes]));
label = `allocated between "${fromStage}" and "${toStage}"`;
total = after.total - before.total;
sites = after.sites
.map((site) => ({ ...site, bytes: site.bytes - (previous.get(site.key) ?? 0) }))
.filter((site) => site.bytes > 0)
.sort((a, b) => b.bytes - a.bytes);
} else {
const profile = load(fromStage ?? "06-final");
label = `cumulative since boot (${fromStage ?? "06-final"})`;
total = profile.total;
sites = profile.sites;
}
const covered = sites.reduce((sum, site) => sum + site.bytes, 0);
console.log(`${label}: ${MiB(total)} MiB total, ${MiB(covered)} MiB attributed across ${sites.length} sites\n`);
const byOwner = new Map();
for (const site of sites) {
const owner = ownerOf(site.key);
const acc = byOwner.get(owner) ?? { bytes: 0, sites: 0 };
acc.bytes += site.bytes;
acc.sites++;
byOwner.set(owner, acc);
}
console.log("=== allocation volume by owner ===");
console.log(`${"MiB".padStart(9)} ${"share".padStart(7)} ${"sites".padStart(6)} owner`);
for (const [owner, acc] of [...byOwner.entries()].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 18)) {
const share = covered === 0 ? "-" : `${((acc.bytes / covered) * 100).toFixed(1)}%`;
console.log(`${MiB(acc.bytes).padStart(9)} ${share.padStart(7)} ${String(acc.sites).padStart(6)} ${owner}`);
}
console.log(`\n=== top individual allocation sites ===`);
for (const site of sites.slice(0, 16)) {
const share = covered === 0 ? "-" : `${((site.bytes / covered) * 100).toFixed(1)}%`;
console.log(`\n ${MiB(site.bytes)} MiB (${share}) ${shorten(site.key).slice(0, 110)}`);
for (const frame of (site.stack ?? []).slice(1, 4)) console.log(` from ${shorten(frame).slice(0, 106)}`);
}