Files
homarr/scripts/benchmarks/analyze-heap-dominators.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

277 lines
9.7 KiB
JavaScript

// Computes real retained sizes from a .heapsnapshot and reports the biggest retainers.
//
// node --max-old-space-size=8192 scripts/benchmarks/analyze-heap-dominators.mjs <file>
//
// Shallow-size histograms answer "what kind of objects exist" and are misleading for
// "what is costing memory": 4 MiB of `Object` spread over 95k instances says nothing about
// which subsystem is holding them. Retained size — everything that would be freed if a node
// were released — is the number that maps onto a cause, and it needs the dominator tree.
//
// Same algorithm Chrome DevTools uses: postorder the reachable graph, solve dominators by
// iterated predecessor intersection, then accumulate self sizes up the dominator tree.
import { readFileSync } from "node:fs";
const file = process.argv[2];
if (!file) throw new Error("usage: analyze-heap-dominators.mjs <file.heapsnapshot> [topN]");
const topN = Number(process.argv[3] ?? 40);
const snap = JSON.parse(readFileSync(file, "utf8"));
const meta = snap.snapshot.meta;
const nf = meta.node_fields;
const nStride = nf.length;
const nType = nf.indexOf("type");
const nName = nf.indexOf("name");
const nSelf = nf.indexOf("self_size");
const nEdgeCount = nf.indexOf("edge_count");
const nodeTypes = meta.node_types[0];
const ef = meta.edge_fields;
const eStride = ef.length;
const eType = ef.indexOf("type");
const eName = ef.indexOf("name_or_index");
const eTo = ef.indexOf("to_node");
const edgeTypes = meta.edge_types[0];
const nodes = snap.nodes;
const edges = snap.edges;
const strings = snap.strings;
const N = snap.snapshot.node_count;
const E = snap.snapshot.edge_count;
const nodeName = (o) => strings[nodes[o * nStride + nName]] ?? "";
const nodeType = (o) => nodeTypes[nodes[o * nStride + nType]] ?? "?";
const selfSize = (o) => nodes[o * nStride + nSelf];
console.log(`file: ${file}`);
console.log(`nodes: ${N.toLocaleString()} edges: ${E.toLocaleString()}`);
// firstEdge[o] = index of node o's first edge.
const firstEdge = new Uint32Array(N + 1);
for (let o = 0, acc = 0; o < N; o++) {
firstEdge[o] = acc;
acc += nodes[o * nStride + nEdgeCount];
}
firstEdge[N] = E;
/**
* Weak edges do not keep their target alive, so counting them would let a WeakMap or a
* weak handle claim retention it does not actually have.
*/
const isWeak = (e) => edgeTypes[edges[e * eStride + eType]] === "weak";
// ---------------------------------------------------------------- postorder from the root
const postOrder = new Int32Array(N).fill(-1); // node ordinal -> postorder index
const postOrderNodes = new Int32Array(N);
{
const visited = new Uint8Array(N);
// Explicit stack: the graph is far too deep for recursion.
const stackNode = new Int32Array(N + 1);
const stackEdge = new Uint32Array(N + 1);
let post = 0;
let top = 0;
stackNode[0] = 0;
stackEdge[0] = firstEdge[0];
visited[0] = 1;
while (top >= 0) {
const node = stackNode[top];
let e = stackEdge[top];
let descended = false;
while (e < firstEdge[node + 1]) {
if (isWeak(e)) {
e++;
continue;
}
const child = edges[e * eStride + eTo] / nStride;
e++;
if (!visited[child]) {
visited[child] = 1;
stackEdge[top] = e;
top++;
stackNode[top] = child;
stackEdge[top] = firstEdge[child];
descended = true;
break;
}
}
if (descended) continue;
stackEdge[top] = e;
if (e >= firstEdge[node + 1]) {
postOrder[node] = post;
postOrderNodes[post] = node;
post++;
top--;
}
}
console.log(`reachable from root: ${post.toLocaleString()} nodes`);
// Unreachable nodes keep postOrder -1 and are excluded from the dominator solve.
var reachableCount = post;
}
// ------------------------------------------------- reverse edges, restricted to reachable
const inDeg = new Uint32Array(N);
for (let src = 0; src < N; src++) {
if (postOrder[src] === -1) continue;
for (let e = firstEdge[src]; e < firstEdge[src + 1]; e++) {
if (isWeak(e)) continue;
const dst = edges[e * eStride + eTo] / nStride;
if (postOrder[dst] !== -1) inDeg[dst]++;
}
}
const revStart = new Uint32Array(N + 1);
{
let acc = 0;
for (let o = 0; o < N; o++) {
revStart[o] = acc;
acc += inDeg[o];
}
revStart[N] = acc;
}
const revTo = new Uint32Array(revStart[N]);
{
const cursor = Uint32Array.from(revStart.subarray(0, N));
for (let src = 0; src < N; src++) {
if (postOrder[src] === -1) continue;
for (let e = firstEdge[src]; e < firstEdge[src + 1]; e++) {
if (isWeak(e)) continue;
const dst = edges[e * eStride + eTo] / nStride;
if (postOrder[dst] !== -1) revTo[cursor[dst]++] = src;
}
}
}
// -------------------------------------------------------------------- dominators
const rootPost = postOrder[0];
const dom = new Int32Array(reachableCount).fill(-1);
dom[rootPost] = rootPost;
{
const intersect = (a, b) => {
// Higher postorder index is closer to the root, so walking the smaller one upward
// converges on the nearest common dominator.
while (a !== b) {
if (a < b) a = dom[a];
else b = dom[b];
if (a === -1 || b === -1) return -1;
}
return a;
};
let changed = true;
let sweeps = 0;
while (changed) {
changed = false;
sweeps++;
for (let i = reachableCount - 1; i >= 0; i--) {
if (i === rootPost) continue;
const node = postOrderNodes[i];
let newDom = -1;
for (let r = revStart[node]; r < revStart[node + 1]; r++) {
const predPost = postOrder[revTo[r]];
if (predPost === -1 || dom[predPost] === -1) continue;
newDom = newDom === -1 ? predPost : intersect(newDom, predPost);
if (newDom === -1) break;
}
if (newDom !== -1 && dom[i] !== newDom) {
dom[i] = newDom;
changed = true;
}
}
}
console.log(`dominator solve converged in ${sweeps} sweeps`);
}
// ---------------------------------------------------------------- retained sizes
const retained = new Float64Array(reachableCount);
for (let i = 0; i < reachableCount; i++) retained[i] = selfSize(postOrderNodes[i]);
for (let i = 0; i < reachableCount; i++) {
if (i === rootPost) continue;
const d = dom[i];
if (d !== -1 && d !== i) retained[d] += retained[i];
}
const MiB = (bytes) => (bytes / 1048576).toFixed(2);
// A node whose dominator is the root is a top-level owner: its retained size is memory that
// only it holds. Those are the entries worth naming.
const label = (o) => {
const type = nodeType(o);
const name = nodeName(o) || "(anonymous)";
return `${type}:${name}`;
};
/** The property name a parent reaches a child through — that is what names the contents. */
const edgeLabelBetween = (from, to) => {
for (let e = firstEdge[from]; e < firstEdge[from + 1]; e++) {
if (edges[e * eStride + eTo] / nStride !== to) continue;
const type = edgeTypes[edges[e * eStride + eType]];
const raw = edges[e * eStride + eName];
return type === "element" || type === "hidden" ? `[${raw}]` : (strings[raw] ?? String(raw));
}
return "(indirect)";
};
const candidates = [];
for (let i = 0; i < reachableCount; i++) {
if (i === rootPost) continue;
if (retained[i] < 262144) continue;
candidates.push(i);
}
candidates.sort((a, b) => retained[b] - retained[a]);
console.log(`\n=== largest retainers (retained size = freed if this went away) ===`);
console.log(`${"retained".padStart(10)} ${"self".padStart(9)} what`);
const shown = [];
for (const i of candidates) {
const node = postOrderNodes[i];
// Skip a node whose dominator is already listed: its bytes are counted in that ancestor,
// so printing both would double-report the same memory.
let ancestor = dom[i];
let covered = false;
for (let guard = 0; guard < 200 && ancestor !== -1 && ancestor !== rootPost; guard++) {
if (shown.includes(ancestor)) {
covered = true;
break;
}
ancestor = dom[ancestor];
}
if (covered) continue;
shown.push(i);
console.log(`${MiB(retained[i]).padStart(10)} ${MiB(selfSize(node)).padStart(9)} ${label(node).slice(0, 96)}`);
if (shown.length >= topN) break;
}
// Expanding the single largest retainer is usually the whole point: "a Context retains 16 MiB"
// is not actionable until its contents are named. Only the dominator tree can do this — a
// plain graph walk escapes through __proto__ into the entire heap and reports nonsense.
if (shown.length) {
const top = shown[0];
const childrenOfTop = [];
for (let i = 0; i < reachableCount; i++) {
if (i !== top && dom[i] === top) childrenOfTop.push(i);
}
childrenOfTop.sort((a, b) => retained[b] - retained[a]);
const topNode = postOrderNodes[top];
console.log(`\n=== inside the largest retainer (${label(topNode)}, ${MiB(retained[top])} MiB) ===`);
console.log(` it dominates ${childrenOfTop.length} direct children; heaviest:`);
for (const child of childrenOfTop.slice(0, 20)) {
const node = postOrderNodes[child];
const edgeName = edgeLabelBetween(topNode, node);
console.log(` ${MiB(retained[child]).padStart(8)} MiB ${edgeName} -> ${label(node).slice(0, 78)}`);
}
}
// Grouping by constructor over retained size shows which *kind* of thing dominates, which
// is what a shallow histogram cannot express.
const byName = new Map();
for (let i = 0; i < reachableCount; i++) {
const node = postOrderNodes[i];
const key = label(node);
const acc = byName.get(key) ?? { retained: 0, self: 0, count: 0 };
acc.self += selfSize(node);
acc.count++;
byName.set(key, acc);
}
console.log(`\n=== self size by constructor, top 15 (for cross-checking the above) ===`);
for (const [name, acc] of [...byName.entries()].sort((a, b) => b[1].self - a[1].self).slice(0, 15)) {
console.log(`${MiB(acc.self).padStart(10)} MiB ${String(acc.count).padStart(8)}x ${name.slice(0, 80)}`);
}