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.
277 lines
9.7 KiB
JavaScript
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)}`);
|
|
}
|