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.
105 lines
4.9 KiB
JavaScript
105 lines
4.9 KiB
JavaScript
// Compares alternated stress runs of two images and reports the medians side by side.
|
|
//
|
|
// node scripts/benchmarks/compare-stress-runs.mjs .bench/stress "u-before" "u-after"
|
|
//
|
|
// Runs are alternated (before, after, before, after) so machine drift lands on both sides,
|
|
// and the median of each side is reported rather than a single run: the harness's own
|
|
// spread between identical runs has been as wide as 50 MiB at peak, so a one-shot
|
|
// comparison cannot distinguish a real change from noise.
|
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const root = process.argv[2] ?? ".bench/stress";
|
|
const beforePrefix = process.argv[3] ?? "u-before";
|
|
const afterPrefix = process.argv[4] ?? "u-after";
|
|
|
|
const loadSide = (prefix) =>
|
|
readdirSync(root)
|
|
.filter((name) => name.startsWith(prefix))
|
|
.map((name) => path.join(root, name))
|
|
.filter((dir) => existsSync(path.join(dir, "summary.json")))
|
|
.map((dir) => ({ dir, summary: JSON.parse(readFileSync(path.join(dir, "summary.json"), "utf8")) }));
|
|
|
|
const median = (values) => {
|
|
const sorted = values.filter((value) => typeof value === "number" && Number.isFinite(value)).sort((a, b) => a - b);
|
|
if (!sorted.length) return null;
|
|
const middle = Math.floor(sorted.length / 2);
|
|
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
};
|
|
|
|
/** Peak arrayBuffers across a run's stages: the transient spike never shows in a summary total. */
|
|
const peakArrayBuffers = (dir) => {
|
|
let peak = 0;
|
|
for (const name of readdirSync(dir).filter((file) => file.endsWith(".probe.json"))) {
|
|
try {
|
|
const stage = JSON.parse(readFileSync(path.join(dir, name), "utf8"));
|
|
peak = Math.max(peak, (stage.usage?.arrayBuffers ?? 0) / 1048576);
|
|
} catch {
|
|
// A stage capture can be missing if the probe was not wired up; skip it.
|
|
}
|
|
}
|
|
return peak || null;
|
|
};
|
|
|
|
/** Largest single tracked Buffer allocation, which is what the umami paging change targets. */
|
|
const largestAllocation = (dir) => {
|
|
let largest = 0;
|
|
for (const name of readdirSync(dir).filter((file) => file.endsWith(".buffers.json"))) {
|
|
try {
|
|
const capture = JSON.parse(readFileSync(path.join(dir, name), "utf8"));
|
|
for (const site of capture.sites ?? []) largest = Math.max(largest, (site.max ?? 0) / 1048576);
|
|
} catch {
|
|
// Same: buffer tracking is opt-in.
|
|
}
|
|
}
|
|
return largest || null;
|
|
};
|
|
|
|
/** Stage samples are keyed by stage name, so the peak is the max across them. */
|
|
const peakStage = (stages) => {
|
|
const values = Object.values(stages ?? {}).filter((value) => typeof value === "number");
|
|
return values.length ? Math.max(...values) : null;
|
|
};
|
|
|
|
const METRICS = [
|
|
["peak container MiB", (run) => peakStage(run.summary.containerMiB)],
|
|
["peak node RSS MiB", (run) => peakStage(run.summary.nodeRssMiB)],
|
|
["peak node anon MiB", (run) => peakStage(run.summary.nodeAnonMiB)],
|
|
["multi-tab container MiB", (run) => run.summary.containerMiB?.["07-multi-tab"]],
|
|
["final (post-soak) MiB", (run) => run.summary.containerMiB?.["06-final"]],
|
|
["boot idle MiB", (run) => run.summary.containerMiB?.["01-boot-idle"]],
|
|
["peak arrayBuffers MiB", (run) => peakArrayBuffers(run.dir)],
|
|
["largest single alloc MiB", (run) => largestAllocation(run.dir)],
|
|
["client JS heap MiB", (run) => run.summary.client?.jsHeapUsedMiB],
|
|
["median board load ms", (run) => run.summary.latency?.medianBoardLoadMs],
|
|
["CPU seconds", (run) => run.summary.cpuSeconds],
|
|
];
|
|
|
|
const before = loadSide(beforePrefix);
|
|
const after = loadSide(afterPrefix);
|
|
console.log(`before "${beforePrefix}": ${before.length} runs after "${afterPrefix}": ${after.length} runs`);
|
|
if (!before.length || !after.length) throw new Error("need at least one run on each side");
|
|
|
|
console.log(`\n${"metric".padEnd(26)} ${"before".padStart(10)} ${"after".padStart(10)} ${"change".padStart(12)}`);
|
|
console.log("-".repeat(62));
|
|
for (const [name, get] of METRICS) {
|
|
const beforeValue = median(before.map(get));
|
|
const afterValue = median(after.map(get));
|
|
if (beforeValue === null || afterValue === null) continue;
|
|
const delta = afterValue - beforeValue;
|
|
const percent = beforeValue === 0 ? "" : ` (${delta >= 0 ? "+" : ""}${((delta / beforeValue) * 100).toFixed(0)}%)`;
|
|
console.log(
|
|
`${name.padEnd(26)} ${beforeValue.toFixed(1).padStart(10)} ${afterValue.toFixed(1).padStart(10)} ${((delta >= 0 ? "+" : "") + delta.toFixed(1) + percent).padStart(12)}`,
|
|
);
|
|
}
|
|
|
|
console.log(`\nper-run values (to judge spread):`);
|
|
for (const [name, get] of METRICS) {
|
|
const beforeValues = before.map(get).filter((value) => typeof value === "number");
|
|
const afterValues = after.map(get).filter((value) => typeof value === "number");
|
|
if (!beforeValues.length || !afterValues.length) continue;
|
|
console.log(
|
|
` ${name.padEnd(26)} before [${beforeValues.map((value) => value.toFixed(1)).join(", ")}] after [${afterValues.map((value) => value.toFixed(1)).join(", ")}]`,
|
|
);
|
|
}
|