refactor(dev): refactor of backend memory comparison workflow (#17619)

* refactor(dev): refactor of backend memory comparison workflow

* fix
This commit is contained in:
syuilo 2026-06-25 21:53:58 +09:00 committed by GitHub
commit 1c4bcd9b32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 827 additions and 967 deletions

View file

@ -13,21 +13,17 @@ import { gzipSync } from 'node:zlib';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs'; import * as fsSync from 'node:fs';
import * as http from 'node:http'; import * as http from 'node:http';
import * as util from './utility.mts';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const [repoDirArg, outputFileArg] = process.argv.slice(2); const [repoDirArg, outputFileArg] = process.argv.slice(2);
if (repoDirArg == null || outputFileArg == null) { const STARTUP_TIMEOUT = util.readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1);
console.error('Usage: node .github/scripts/backend-js-footprint.mjs <repo-dir> <output.json>'); const SETTLE_TIME = util.readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0);
process.exit(1); const REQUEST_COUNT = util.readIntegerEnv('MK_JS_FOOTPRINT_REQUEST_COUNT', 10, 0);
} const MAX_TABLE_ITEMS = util.readIntegerEnv('MK_JS_FOOTPRINT_MAX_ITEMS', 20, 1);
const STARTUP_TIMEOUT = readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1);
const SETTLE_TIME = readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0);
const REQUEST_COUNT = readIntegerEnv('MK_JS_FOOTPRINT_REQUEST_COUNT', 10, 0);
const MAX_TABLE_ITEMS = readIntegerEnv('MK_JS_FOOTPRINT_MAX_ITEMS', 20, 1);
const repoDir = resolve(repoDirArg); const repoDir = resolve(repoDirArg);
const outputFile = resolve(outputFileArg); const outputFile = resolve(outputFileArg);
@ -41,22 +37,6 @@ const fileMetricCache = new Map();
const packageInfoCache = new Map(); const packageInfoCache = new Map();
const nativePackageNames = new Set(); const nativePackageNames = new Set();
function readIntegerEnv(name, defaultValue, min) {
const rawValue = process.env[name];
if (rawValue == null || rawValue === '') return defaultValue;
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
return value;
}
function commandName(command) {
if (process.platform !== 'win32') return command;
if (command === 'pnpm') return 'pnpm.cmd';
return command;
}
function isInside(parent, child) { function isInside(parent, child) {
const rel = relative(parent, child); const rel = relative(parent, child);
return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`)); return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`));
@ -439,7 +419,6 @@ function summarizeRecords(records, phase) {
totals.nativeAddonPackageCount = externalPackages.filter(packageSummary => packageSummary.nativeAddon).length; totals.nativeAddonPackageCount = externalPackages.filter(packageSummary => packageSummary.nativeAddon).length;
return { return {
phase,
totals: { totals: {
...totals, ...totals,
loadedJsSourceKiB: bytesToKiB(totals.loadedJsSourceBytes), loadedJsSourceKiB: bytesToKiB(totals.loadedJsSourceBytes),
@ -499,7 +478,7 @@ async function measureFootprint() {
await waitForServerReady(serverProcess); await waitForServerReady(serverProcess);
await setTimeout(SETTLE_TIME); await setTimeout(SETTLE_TIME);
const startup = summarizeRecords(await readTraceRecords(), 'startup'); //const startup = summarizeRecords(await readTraceRecords(), 'startup');
await Promise.all( await Promise.all(
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()), Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
@ -517,8 +496,10 @@ async function measureFootprint() {
requestCount: REQUEST_COUNT, requestCount: REQUEST_COUNT,
cpus: cpus().length, cpus: cpus().length,
}, },
startup, phases: {
afterRequest, //startup,
afterRequest,
},
}; };
} finally { } finally {
await stopServer(serverProcess); await stopServer(serverProcess);

View file

@ -1,22 +1,47 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import * as util from './utility.mts';
import { type MemoryReport } from './measure-backend-memory-comparison.mts';
const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = process.argv.slice(2); const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = process.argv.slice(2);
if (baseFile == null || headFile == null || outputFile == null) { type RuntimeLoadedJsFootprintReport = {
console.error('Usage: node .github/scripts/backend-memory-report.mjs <base-memory.json> <head-memory.json> <report.md> [base-js-footprint.json head-js-footprint.json]'); phases: Record<'afterRequest', {
process.exit(1); totals: {
} loadedJsModules: number;
loadedJsSourceBytes: number;
loadedJsGzipBytes: number;
astNodeCount: number;
functionCount: number;
classCount: number;
stringLiteralBytes: number;
externalPackageCount: number;
nativeAddonPackageCount: number;
};
modules: {
path: string;
package: string;
category: string;
sourceBytes: number;
gzipBytes: number;
astNodeCount: number;
functionCount: number;
classCount: number;
stringLiteralBytes: number;
}[];
}>;
};
const numberFormatter = new Intl.NumberFormat('en-US', { const memoryReportPhases = [
maximumFractionDigits: 1,
});
const phases = [
{ {
key: 'afterGc', key: 'afterGc',
title: 'After GC', title: 'After GC',
}, },
]; ] as const;
const metrics = [ const metrics = [
'HeapUsed', 'HeapUsed',
@ -24,18 +49,7 @@ const metrics = [
'Private_Dirty', 'Private_Dirty',
'VmRSS', 'VmRSS',
'External', 'External',
]; ] as const;
const heapSnapshotCategories = [
'Total',
'Code',
'Strings',
'JS arrays',
'Typed arrays',
'System objects',
'Other JS objects',
'Other non-JS objects',
];
const heapSnapshotCategoriesColors = { const heapSnapshotCategoriesColors = {
'Total': 'gray', 'Total': 'gray',
@ -46,7 +60,7 @@ const heapSnapshotCategoriesColors = {
'System objects': 'yellow', 'System objects': 'yellow',
'Other JS objects': 'violet', 'Other JS objects': 'violet',
'Other non-JS objects': 'pink', 'Other non-JS objects': 'pink',
}; } as const;
const heapSnapshotCategoriesColorsHex = { const heapSnapshotCategoriesColorsHex = {
'Total': '#888888', 'Total': '#888888',
@ -57,96 +71,46 @@ const heapSnapshotCategoriesColorsHex = {
'System objects': '#edc949', 'System objects': '#edc949',
'Other JS objects': '#af7aa1', 'Other JS objects': '#af7aa1',
'Other non-JS objects': '#ff9da7', 'Other non-JS objects': '#ff9da7',
}; } as const;
function formatNumber(value) { function formatMemoryMb(valueKiB: number | null | undefined) {
return numberFormatter.format(value); if (valueKiB == null) return '-';
return `${util.formatNumber(valueKiB / 1024)} MB`;
} }
function formatMemory(valueKiB) { function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
return `${formatNumber(valueKiB / 1024)} MB`; return report.summary[phase].memoryUsage[metric];
} }
function formatBytes(value) { function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
if (!Number.isFinite(value)) return '-'; return sample.phases[phase].memoryUsage[metric];
if (value < 1024) return `${formatNumber(value)} B`;
if (value < 1024 * 1024) return `${formatNumber(value / 1024)} KiB`;
return `${formatNumber(value / 1024 / 1024)} MiB`;
} }
function formatPercent(value) { function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
return `${formatNumber(value)}%`; const values = report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
}
function formatDeltaPercent(diff, baseValue) {
if (diff === 0) return '0%';
if (baseValue <= 0) return '-';
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatPercent(Math.abs((diff * 100) / baseValue))}`, diff);
}
function formatMathText(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
function formatColoredDiff(text, diff) {
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text).replaceAll('\\%', '\\\\%')}}}$`;
}
function getMemoryValue(report, phase, metric) {
const value = report?.[phase]?.[metric];
return Number.isFinite(value) ? value : null;
}
function median(values) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[center];
return Math.round((sorted[center - 1] + sorted[center]) / 2);
}
function getSampleValues(report, phase, metric) {
if (!Array.isArray(report?.samples)) return [];
return report.samples
.map(sample => getMemoryValue(sample, phase, metric))
.filter(value => Number.isFinite(value));
}
function getSampleSpread(report, phase, metric) {
const values = getSampleValues(report, phase, metric);
if (values.length < 2) return null; if (values.length < 2) return null;
const center = median(values); const center = util.median(values);
return median(values.map(value => Math.abs(value - center))); return util.median(values.map(value => Math.abs(value - center)));
} }
function mad(values) { function getSamplesByRound(report: MemoryReport) {
if (values.length < 2) return null; const samplesByRound = new Map<number, MemoryReport['samples'][number]>();
if (!Array.isArray(report.samples)) return samplesByRound;
const center = median(values);
return median(values.map(value => Math.abs(value - center)));
}
function getSamplesByRound(report) {
const samplesByRound = new Map();
if (!Array.isArray(report?.samples)) return samplesByRound;
for (const sample of report.samples) { for (const sample of report.samples) {
if (!Number.isInteger(sample?.round) || sample.round <= 0) continue; if (sample.round <= 0) continue;
samplesByRound.set(sample.round, sample); samplesByRound.set(sample.round, sample);
} }
return samplesByRound; return samplesByRound;
} }
function getPairedDeltaValues(base, head, phase, metric) { function formatDeltaMemory(diffKiB: number) {
return util.formatColoredDelta(formatMemoryMb(Math.abs(diffKiB)), diffKiB);
}
function pairedDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
const baseSamplesByRound = getSamplesByRound(base); const baseSamplesByRound = getSamplesByRound(base);
const headSamplesByRound = getSamplesByRound(head); const headSamplesByRound = getSamplesByRound(head);
const values = []; const values = [];
@ -155,37 +119,23 @@ function getPairedDeltaValues(base, head, phase, metric) {
const headSample = headSamplesByRound.get(round); const headSample = headSamplesByRound.get(round);
if (headSample == null) continue; if (headSample == null) continue;
const baseValue = getMemoryValue(baseSample, phase, metric); const baseValue = getMemoryValueFromSample(baseSample, phase, metric);
const headValue = getMemoryValue(headSample, phase, metric); const headValue = getMemoryValueFromSample(headSample, phase, metric);
if (baseValue == null || headValue == null) continue; if (baseValue == null || headValue == null) continue;
values.push(headValue - baseValue); values.push(headValue - baseValue);
} }
return values;
}
function formatDeltaMemory(diffKiB) {
if (diffKiB === 0) return formatMemory(0);
const sign = diffKiB > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatMemory(Math.abs(diffKiB))}`, diffKiB);
}
function pairedDeltaSummary(base, head, phase, metric) {
const values = getPairedDeltaValues(base, head, phase, metric);
if (values.length === 0) return null;
return { return {
median: median(values), median: util.median(values),
mad: mad(values), mad: util.mad(values),
min: Math.min(...values), min: Math.min(...values),
max: Math.max(...values), max: Math.max(...values),
samples: values.length, samples: values.length,
}; };
} }
function renderTable(base, head, phase) { function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
const lines = [ const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |', '| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
@ -194,20 +144,20 @@ function renderTable(base, head, phase) {
for (const metric of metrics) { for (const metric of metrics) {
const baseValue = getMemoryValue(base, phase, metric); const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric); const headValue = getMemoryValue(head, phase, metric);
if (baseValue == null || headValue == null) continue;
const baseSpread = getSampleSpread(base, phase, metric); const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric); const headSpread = getSampleSpread(head, phase, metric);
const summary = pairedDeltaSummary(base, head, phase, metric); const summary = pairedDeltaSummary(base, head, phase, metric);
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${formatDeltaPercent(summary.median, baseValue)}`; const percent = summary.median * 100 / baseValue;
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent)}`;
lines.push(`| **${metric}** | ${formatMemory(baseValue)} <br> ± ${formatMemory(baseSpread)} | ${formatMemory(headValue)} <br> ± ${formatMemory(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemory(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`); lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemoryMb(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`);
} }
return lines.join('\n'); return lines.join('\n');
} }
function getDiffPercent(base, head, phase, metric) { function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
const baseValue = getMemoryValue(base, phase, metric); const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric); const headValue = getMemoryValue(head, phase, metric);
if (baseValue == null || headValue == null || baseValue <= 0) return null; if (baseValue == null || headValue == null || baseValue <= 0) return null;
@ -215,41 +165,7 @@ function getDiffPercent(base, head, phase, metric) {
return ((headValue - baseValue) * 100) / baseValue; return ((headValue - baseValue) * 100) / baseValue;
} }
function getWarningMetric(base, head) { /*
for (const metric of ['Pss', 'Private_Dirty', 'VmRSS']) {
if (getMemoryValue(base, 'afterGc', metric) != null && getMemoryValue(head, 'afterGc', metric) != null) {
return metric;
}
}
return null;
}
function isBeyondSampleNoise(base, head, phase, metric) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
if (baseValue == null || headValue == null) return false;
const diff = headValue - baseValue;
if (diff <= 0) return false;
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
if (baseSpread == null || headSpread == null) return true;
const combinedSpread = Math.hypot(baseSpread, headSpread);
return diff > combinedSpread * 3;
}
function workflowFooter() {
const repository = process.env.GITHUB_REPOSITORY;
const runId = process.env.GITHUB_RUN_ID;
if (repository == null || runId == null) {
return 'See workflow logs for details.';
}
return `[See workflow logs for details](https://github.com/${repository}/actions/runs/${runId})`;
}
function measurementSummary(base, head) { function measurementSummary(base, head) {
const baseCount = base?.sampleCount; const baseCount = base?.sampleCount;
const headCount = head?.sampleCount; const headCount = head?.sampleCount;
@ -264,66 +180,63 @@ function measurementSummary(base, head) {
return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`; return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`;
} }
*/
function formatPlainDiff(baseValue, headValue, formatter = formatNumber) { function formatPlainDelta(baseValue: number, headValue: number, formatter = util.formatNumber) {
const diff = headValue - baseValue; const delta = headValue - baseValue;
if (diff === 0) return formatter(0); if (delta === 0) return formatter(0);
const sign = diff > 0 ? '+' : '-'; const sign = delta > 0 ? '+' : '-';
return `${sign}${formatter(Math.abs(diff))}`; return `${sign}${formatter(Math.abs(delta))}`;
} }
function formatPlainDiffPercent(baseValue, headValue) { function getHeapSnapshotCategoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
const diff = headValue - baseValue; const value = report.summary[phase]?.heapSnapshot?.categories?.[category];
if (diff === 0) return '0%';
if (baseValue <= 0) return '-';
const sign = diff > 0 ? '+' : '-';
return `${sign}${formatPercent(Math.abs((diff * 100) / baseValue))}`;
}
function getHeapSnapshotCategoryValue(report, phase, category) {
const value = report?.[phase]?.heapSnapshot?.categories?.[category];
return Number.isFinite(value) ? value : null; return Number.isFinite(value) ? value : null;
} }
function getHeapSnapshotBreakdownEntries(report, phase, category) { function getHeapSnapshotCategoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
const breakdown = report?.[phase]?.heapSnapshot?.breakdowns?.[category]; const value = sample.phases[phase]?.heapSnapshot?.categories?.[category];
if (breakdown == null || typeof breakdown !== 'object') return []; return Number.isFinite(value) ? value : null;
return Object.entries(breakdown)
.filter(([, value]) => Number.isFinite(value) && value > 0)
.toSorted((a, b) => b[1] - a[1]);
} }
const heapSnapshotSankeyChildMinRatio = 0.3; const heapSnapshotSankeyChildMinRatio = 0.3;
const heapSnapshotSankeyParentMinPercent = 10; const heapSnapshotSankeyParentMinPercent = 10;
function escapeCsvValue(value) { function escapeCsvValue(value: string) {
return `"${String(value).replaceAll('"', '""')}"`; return `"${String(value).replaceAll('"', '""')}"`;
} }
function formatSankeyPercentValue(value) { function formatSankeyPercentValue(value: number) {
const rounded = Math.round(value * 100) / 100; const rounded = Math.round(value * 100) / 100;
if (rounded === 0 && value > 0) return '0.01'; if (rounded === 0 && value > 0) return '0.01';
if (Number.isInteger(rounded)) return String(rounded); if (Number.isInteger(rounded)) return String(rounded);
return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
} }
function formatHeapSnapshotSankeyChildLabel(label) { function formatHeapSnapshotSankeyChildLabel(label: string) {
return String(label).replace(/^[^:]+:\s*/, ''); return String(label).replace(/^[^:]+:\s*/, '');
} }
function renderHeapSnapshotSankey(report, phase, title) { function renderHeapSnapshotSankey(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], title: string) {
const total = getHeapSnapshotCategoryValue(report, phase, 'Total'); const total = getHeapSnapshotCategoryValue(report, phase, 'Total');
if (total == null || total <= 0) return null; if (total == null || total <= 0) return null;
const categories = heapSnapshotCategories function getHeapSnapshotBreakdownEntries(category: typeof util.heapSnapshotCategories[number]) {
const breakdown = report.summary[phase].heapSnapshot?.breakdowns?.[category];
if (breakdown == null || typeof breakdown !== 'object') return [];
return Object.entries(breakdown)
.filter(([, value]) => Number.isFinite(value) && value > 0)
.toSorted((a, b) => b[1] - a[1]);
}
const categories = util.heapSnapshotCategories
.filter(category => category !== 'Total') .filter(category => category !== 'Total')
.map(category => { .map(category => {
const value = getHeapSnapshotCategoryValue(report, phase, category); const value = getHeapSnapshotCategoryValue(report, phase, category);
if (value == null || value <= 0) return null; if (value == null || value <= 0) return null;
const breakdownEntries = getHeapSnapshotBreakdownEntries(report, phase, category); const breakdownEntries = getHeapSnapshotBreakdownEntries(category);
const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0); const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0);
const percent = (value * 100) / total; const percent = (value * 100) / total;
const childEntries = []; const childEntries = [];
@ -357,7 +270,7 @@ function renderHeapSnapshotSankey(report, phase, title) {
const nodeColors = { const nodeColors = {
[title]: heapSnapshotCategoriesColorsHex.Total, [title]: heapSnapshotCategoriesColorsHex.Total,
}; } as Record<string, string>;
for (const { category, childEntries } of categories) { for (const { category, childEntries } of categories) {
const categoryColor = heapSnapshotCategoriesColorsHex[category] ?? heapSnapshotCategoriesColorsHex.Total; const categoryColor = heapSnapshotCategoriesColorsHex[category] ?? heapSnapshotCategoriesColorsHex.Total;
nodeColors[category] = categoryColor; nodeColors[category] = categoryColor;
@ -402,70 +315,32 @@ function renderHeapSnapshotSankey(report, phase, title) {
return lines.join('\n'); return lines.join('\n');
} }
function formatHeapSnapshotCategoryLabel(category, baseValue, headValue, baseTotal, headTotal) { function pairedHeapSnapshotDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
if (category === 'Total' || baseTotal == null || headTotal == null || baseTotal <= 0 || headTotal <= 0) return `**${category}**`;
const basePercent = formatPercent((baseValue * 100) / baseTotal);
const headPercent = formatPercent((headValue * 100) / headTotal);
return `**${category}**<br>${basePercent}${headPercent}`;
}
function getHeapSnapshotSampleValues(report, phase, category) {
if (!Array.isArray(report?.samples)) return [];
return report.samples
.map(sample => getHeapSnapshotCategoryValue(sample, phase, category))
.filter(value => Number.isFinite(value));
}
function getHeapSnapshotSampleSpread(report, phase, category) {
const values = getHeapSnapshotSampleValues(report, phase, category);
if (values.length < 2) return null;
const center = median(values);
return median(values.map(value => Math.abs(value - center)));
}
function getPairedHeapSnapshotDeltaValues(base, head, phase, category) {
const baseSamplesByRound = getSamplesByRound(base); const baseSamplesByRound = getSamplesByRound(base);
const headSamplesByRound = getSamplesByRound(head); const headSamplesByRound = getSamplesByRound(head);
const values = []; const values = [] as number[];
for (const [round, baseSample] of baseSamplesByRound) { for (const [round, baseSample] of baseSamplesByRound) {
const headSample = headSamplesByRound.get(round); const headSample = headSamplesByRound.get(round);
if (headSample == null) continue; if (headSample == null) continue;
const baseValue = getHeapSnapshotCategoryValue(baseSample, phase, category); const baseValue = getHeapSnapshotCategoryValueFromSample(baseSample, phase, category);
const headValue = getHeapSnapshotCategoryValue(headSample, phase, category); const headValue = getHeapSnapshotCategoryValueFromSample(headSample, phase, category);
if (baseValue == null || headValue == null) continue; if (baseValue == null || headValue == null) continue;
values.push(headValue - baseValue); values.push(headValue - baseValue);
} }
return values;
}
function formatDeltaBytes(diffBytes) {
if (diffBytes === 0) return formatBytes(0);
const sign = diffBytes > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatBytes(Math.abs(diffBytes))}`, diffBytes);
}
function pairedHeapSnapshotDeltaSummary(base, head, phase, category) {
const values = getPairedHeapSnapshotDeltaValues(base, head, phase, category);
if (values.length === 0) return null;
return { return {
median: median(values), median: util.median(values),
mad: mad(values), mad: util.mad(values),
min: Math.min(...values), min: Math.min(...values),
max: Math.max(...values), max: Math.max(...values),
samples: values.length, samples: values.length,
}; };
} }
function renderHeapSnapshotTable(base, head, phase) { function renderHeapSnapshotTable(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
const lines = [ const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |', '| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
@ -473,7 +348,25 @@ function renderHeapSnapshotTable(base, head, phase) {
const baseTotal = getHeapSnapshotCategoryValue(base, phase, 'Total'); const baseTotal = getHeapSnapshotCategoryValue(base, phase, 'Total');
const headTotal = getHeapSnapshotCategoryValue(head, phase, 'Total'); const headTotal = getHeapSnapshotCategoryValue(head, phase, 'Total');
for (const category of heapSnapshotCategories) { function formatHeapSnapshotCategoryLabel(category: typeof heapSnapshotCategories[number], baseValue: number, headValue: number, baseTotal: number, headTotal: number) {
if (category === 'Total' || baseTotal == null || headTotal == null || baseTotal <= 0 || headTotal <= 0) return `**${category}**`;
const basePercent = util.formatPercent((baseValue * 100) / baseTotal);
const headPercent = util.formatPercent((headValue * 100) / headTotal);
return `**${category}**<br>${basePercent}${headPercent}`;
}
function getHeapSnapshotSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
const values = report.samples
.map(sample => getHeapSnapshotCategoryValueFromSample(sample, phase, category))
.filter(value => Number.isFinite(value)) as number[];
if (values.length < 2) return null;
const center = util.median(values);
return util.median(values.map(value => Math.abs(value - center)));
}
for (const category of util.heapSnapshotCategories) {
const baseValue = getHeapSnapshotCategoryValue(base, phase, category); const baseValue = getHeapSnapshotCategoryValue(base, phase, category);
const headValue = getHeapSnapshotCategoryValue(head, phase, category); const headValue = getHeapSnapshotCategoryValue(head, phase, category);
if (baseValue == null || headValue == null) continue; if (baseValue == null || headValue == null) continue;
@ -481,10 +374,11 @@ function renderHeapSnapshotTable(base, head, phase) {
const baseSpread = getHeapSnapshotSampleSpread(base, phase, category); const baseSpread = getHeapSnapshotSampleSpread(base, phase, category);
const headSpread = getHeapSnapshotSampleSpread(head, phase, category); const headSpread = getHeapSnapshotSampleSpread(head, phase, category);
const summary = pairedHeapSnapshotDeltaSummary(base, head, phase, category); const summary = pairedHeapSnapshotDeltaSummary(base, head, phase, category);
const deltaMedian = summary == null ? '-' : `${formatDeltaBytes(summary.median)}<br>${formatDeltaPercent(summary.median, baseValue)}`; const percent = summary.median * 100 / baseValue;
const deltaMedian = summary == null ? '-' : `${util.formatDeltaBytes(summary.median)}<br>${util.formatDeltaPercent(percent)}`;
const categoryLabel = formatHeapSnapshotCategoryLabel(category, baseValue, headValue, baseTotal, headTotal); const categoryLabel = formatHeapSnapshotCategoryLabel(category, baseValue, headValue, baseTotal, headTotal);
lines.push(`| $\\color{${heapSnapshotCategoriesColors[category]}}{\\rule{8pt}{8pt}}$ ${categoryLabel} | ${formatBytes(baseValue)} <br> ± ${baseSpread == null ? '-' : formatBytes(baseSpread)} | ${formatBytes(headValue)} <br> ± ${headSpread == null ? '-' : formatBytes(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatBytes(summary.mad)} | ${summary == null ? '-' : formatDeltaBytes(summary.min)} | ${summary == null ? '-' : formatDeltaBytes(summary.max)} |`); lines.push(`| $\\color{${heapSnapshotCategoriesColors[category]}}{\\rule{8pt}{8pt}}$ ${categoryLabel} | ${util.formatBytes(baseValue)} <br> ± ${baseSpread == null ? '-' : util.formatBytes(baseSpread)} | ${util.formatBytes(headValue)} <br> ± ${headSpread == null ? '-' : util.formatBytes(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : util.formatBytes(summary.mad)} | ${summary == null ? '-' : util.formatDeltaBytes(summary.min)} | ${summary == null ? '-' : util.formatDeltaBytes(summary.max)} |`);
if (category === 'Total') { if (category === 'Total') {
lines.push('| | | | | | | |'); lines.push('| | | | | | | |');
} }
@ -494,8 +388,8 @@ function renderHeapSnapshotTable(base, head, phase) {
return lines.join('\n'); return lines.join('\n');
} }
function renderHeapSnapshotSection(base, head) { function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
const table = renderHeapSnapshotTable(base, head, 'afterRequest'); const table = renderHeapSnapshotTable(base, head, 'afterGc');
if (table == null) return null; if (table == null) return null;
const lines = [ const lines = [
@ -506,8 +400,8 @@ function renderHeapSnapshotSection(base, head) {
]; ];
for (const graph of [ for (const graph of [
renderHeapSnapshotSankey(base, 'afterRequest', 'Base'), renderHeapSnapshotSankey(base, 'afterGc', 'Base'),
renderHeapSnapshotSankey(head, 'afterRequest', 'Head'), renderHeapSnapshotSankey(head, 'afterGc', 'Head'),
]) { ]) {
if (graph == null) continue; if (graph == null) continue;
lines.push(graph); lines.push(graph);
@ -517,23 +411,23 @@ function renderHeapSnapshotSection(base, head) {
return lines.join('\n'); return lines.join('\n');
} }
function getJsFootprintValue(report, phase, key) { function getJsFootprintValue(report: RuntimeLoadedJsFootprintReport, phase: 'afterRequest', key: keyof RuntimeLoadedJsFootprintReport['phases'][typeof phase]['totals']) {
const value = report?.[phase]?.totals?.[key]; const value = report.phases[phase].totals[key];
return Number.isFinite(value) ? value : null; return Number.isFinite(value) ? value : null;
} }
function renderJsFootprintMetricTable(base, head) { function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
const metricRows = [ const metricRows = [
['Loaded JS modules', 'loadedJsModules', formatNumber], ['Loaded JS modules', 'loadedJsModules', util.formatNumber],
['Loaded JS source', 'loadedJsSourceBytes', formatBytes], ['Loaded JS source', 'loadedJsSourceBytes', util.formatBytes],
//['Loaded JS gzip estimate', 'loadedJsGzipBytes', formatBytes], //['Loaded JS gzip estimate', 'loadedJsGzipBytes', util.formatBytes],
//['AST nodes', 'astNodeCount', formatNumber], //['AST nodes', 'astNodeCount', util.formatNumber],
//['Functions', 'functionCount', formatNumber], //['Functions', 'functionCount', util.formatNumber],
//['Classes', 'classCount', formatNumber], //['Classes', 'classCount', util.formatNumber],
//['String literals', 'stringLiteralBytes', formatBytes], //['String literals', 'stringLiteralBytes', util.formatBytes],
['External packages loaded', 'externalPackageCount', formatNumber], ['External packages loaded', 'externalPackageCount', util.formatNumber],
['Native addon packages', 'nativeAddonPackageCount', formatNumber], ['Native addon packages', 'nativeAddonPackageCount', util.formatNumber],
]; ] as const;
const lines = [ const lines = [
'| Metric | Base | Head | Δ | Δ (%) |', '| Metric | Base | Head | Δ | Δ (%) |',
@ -545,12 +439,13 @@ function renderJsFootprintMetricTable(base, head) {
const headValue = getJsFootprintValue(head, 'afterRequest', key); const headValue = getJsFootprintValue(head, 'afterRequest', key);
if (baseValue == null || headValue == null) continue; if (baseValue == null || headValue == null) continue;
lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${formatPlainDiff(baseValue, headValue, formatter)} | ${formatPlainDiffPercent(baseValue, headValue)} |`); lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${formatPlainDelta(baseValue, headValue, formatter)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue)} |`);
} }
return lines.join('\n'); return lines.join('\n');
} }
/*
function renderJsFootprintPhaseTable(base, head) { function renderJsFootprintPhaseTable(base, head) {
const lines = [ const lines = [
'| Phase | Base modules | Head modules | Δ modules | Base source | Head source | Δ source |', '| Phase | Base modules | Head modules | Δ modules | Base source | Head source | Δ source |',
@ -564,27 +459,28 @@ function renderJsFootprintPhaseTable(base, head) {
const headSource = getJsFootprintValue(head, phase, 'loadedJsSourceBytes'); const headSource = getJsFootprintValue(head, phase, 'loadedJsSourceBytes');
if (baseModules == null || headModules == null || baseSource == null || headSource == null) continue; if (baseModules == null || headModules == null || baseSource == null || headSource == null) continue;
lines.push(`| ${title} | ${formatNumber(baseModules)} | ${formatNumber(headModules)} | ${formatPlainDiff(baseModules, headModules)} | ${formatBytes(baseSource)} | ${formatBytes(headSource)} | ${formatPlainDiff(baseSource, headSource, formatBytes)} |`); lines.push(`| ${title} | ${util.formatNumber(baseModules)} | ${util.formatNumber(headModules)} | ${formatPlainDelta(baseModules, headModules)} | ${util.formatBytes(baseSource)} | ${util.formatBytes(headSource)} | ${formatPlainDelta(baseSource, headSource, util.formatBytes)} |`);
} }
return lines.join('\n'); return lines.join('\n');
} }
*/
function packageMap(report) { function packageMap(report: RuntimeLoadedJsFootprintReport) {
const map = new Map(); const map = new Map();
for (const packageSummary of report?.afterRequest?.packages ?? []) { for (const packageSummary of report.phases.afterRequest.packages) {
if (packageSummary?.category !== 'external' || typeof packageSummary.name !== 'string') continue; if (packageSummary?.category !== 'external' || typeof packageSummary.name !== 'string') continue;
map.set(packageSummary.name, packageSummary); map.set(packageSummary.name, packageSummary);
} }
return map; return map;
} }
function packageDisplayName(packageSummary) { function packageDisplayName(packageSummary: { name: string; version?: string | null }) {
if (packageSummary.version == null) return packageSummary.name; if (packageSummary.version == null) return packageSummary.name;
return `${packageSummary.name} ${packageSummary.version}`; return `${packageSummary.name} ${packageSummary.version}`;
} }
function renderNewExternalPackages(base, head) { function renderNewExternalPackages(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
const basePackages = packageMap(base); const basePackages = packageMap(base);
const headPackages = packageMap(head); const headPackages = packageMap(head);
const newPackages = [...headPackages.values()] const newPackages = [...headPackages.values()]
@ -602,13 +498,13 @@ function renderNewExternalPackages(base, head) {
]; ];
for (const packageSummary of newPackages) { for (const packageSummary of newPackages) {
lines.push(`| ${packageDisplayName(packageSummary)} | ${formatBytes(packageSummary.sourceBytes)} | ${formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`); lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`);
} }
return lines.join('\n'); return lines.join('\n');
} }
function renderLargestPackageIncreases(base, head) { function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
const basePackages = packageMap(base); const basePackages = packageMap(base);
const headPackages = packageMap(head); const headPackages = packageMap(head);
const increases = [...headPackages.values()] const increases = [...headPackages.values()]
@ -638,22 +534,22 @@ function renderLargestPackageIncreases(base, head) {
]; ];
for (const packageSummary of increases) { for (const packageSummary of increases) {
lines.push(`| ${packageDisplayName(packageSummary)} | ${formatBytes(packageSummary.baseSourceBytes)} | ${formatBytes(packageSummary.sourceBytes)} | ${formatPlainDiff(packageSummary.baseSourceBytes, packageSummary.sourceBytes, formatBytes)} | ${formatPlainDiff(packageSummary.baseModules, packageSummary.modules)} |`); lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${formatPlainDelta(packageSummary.baseSourceBytes, packageSummary.sourceBytes, util.formatBytes)} | ${formatPlainDelta(packageSummary.baseModules, packageSummary.modules)} |`);
} }
return lines.join('\n'); return lines.join('\n');
} }
function moduleMap(report) { function renderNewLoadedModules(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
const map = new Map(); function moduleMap(report: RuntimeLoadedJsFootprintReport) {
for (const moduleSummary of report?.afterRequest?.modules ?? []) { const map = new Map();
if (typeof moduleSummary.path !== 'string') continue; for (const moduleSummary of report.phases.afterRequest.modules) {
map.set(moduleSummary.path, moduleSummary); if (typeof moduleSummary.path !== 'string') continue;
map.set(moduleSummary.path, moduleSummary);
}
return map;
} }
return map;
}
function renderNewLoadedModules(base, head) {
const baseModules = moduleMap(base); const baseModules = moduleMap(base);
const headModules = moduleMap(head); const headModules = moduleMap(head);
const newModules = [...headModules.values()] const newModules = [...headModules.values()]
@ -671,15 +567,13 @@ function renderNewLoadedModules(base, head) {
]; ];
for (const moduleSummary of newModules) { for (const moduleSummary of newModules) {
lines.push(`| \`${moduleSummary.path}\` | ${moduleSummary.package} | ${formatBytes(moduleSummary.sourceBytes)} |`); lines.push(`| \`${moduleSummary.path}\` | ${moduleSummary.package} | ${util.formatBytes(moduleSummary.sourceBytes)} |`);
} }
return lines.join('\n'); return lines.join('\n');
} }
function renderJsFootprintSection(base, head) { function renderJsFootprintSection(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
if (base == null || head == null) return null;
const lines = [ const lines = [
'### Runtime Loaded JS Footprint', '### Runtime Loaded JS Footprint',
'', '',
@ -709,10 +603,10 @@ function renderJsFootprintSection(base, head) {
return lines.join('\n'); return lines.join('\n');
} }
const base = JSON.parse(await readFile(baseFile, 'utf8')); const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport;
const head = JSON.parse(await readFile(headFile, 'utf8')); const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport;
const baseJsFootprint = baseJsFootprintFile == null ? null : JSON.parse(await readFile(baseJsFootprintFile, 'utf8')); const baseJsFootprint = JSON.parse(await readFile(baseJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
const headJsFootprint = headJsFootprintFile == null ? null : JSON.parse(await readFile(headJsFootprintFile, 'utf8')); const headJsFootprint = JSON.parse(await readFile(headJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
const lines = [ const lines = [
'## ⚙️ Backend Memory Usage Report', '## ⚙️ Backend Memory Usage Report',
'', '',
@ -724,9 +618,9 @@ const lines = [
// lines.push(''); // lines.push('');
//} //}
for (const phase of phases) { for (const phase of memoryReportPhases) {
lines.push(`### ${phase.title}`); lines.push(`### ${phase.title}`);
lines.push(renderTable(base, head, phase.key)); lines.push(renderMainTableForPhase(base, head, phase.key));
lines.push(''); lines.push('');
} }
@ -742,6 +636,31 @@ if (jsFootprintSection != null) {
lines.push(''); lines.push('');
} }
function getWarningMetric(base: MemoryReport, head: MemoryReport) {
for (const metric of ['Pss', 'Private_Dirty', 'VmRSS'] as const) {
if (getMemoryValue(base, 'afterGc', metric) != null && getMemoryValue(head, 'afterGc', metric) != null) {
return metric;
}
}
return null;
}
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
if (baseValue == null || headValue == null) return false;
const diff = headValue - baseValue;
if (diff <= 0) return false;
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
if (baseSpread == null || headSpread == null) return true;
const combinedSpread = Math.hypot(baseSpread, headSpread);
return diff > combinedSpread * 3;
}
const warningMetric = getWarningMetric(base, head); const warningMetric = getWarningMetric(base, head);
const warningDiffPercent = warningMetric == null ? null : getDiffPercent(base, head, 'afterGc', warningMetric); const warningDiffPercent = warningMetric == null ? null : getDiffPercent(base, head, 'afterGc', warningMetric);
if (warningMetric != null && warningDiffPercent != null && warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) { if (warningMetric != null && warningDiffPercent != null && warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
@ -749,6 +668,6 @@ if (warningMetric != null && warningDiffPercent != null && warningDiffPercent >
lines.push(''); lines.push('');
} }
lines.push(workflowFooter()); lines.push(`[See workflow logs for details](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`);
await writeFile(outputFile, `${lines.join('\n')}\n`); await writeFile(outputFile, `${lines.join('\n')}\n`);

View file

@ -5,129 +5,54 @@
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import * as util from './utility.mts';
const marker = '<!-- misskey-frontend-js-size -->'; const marker = '<!-- misskey-frontend-js-size -->';
const locale = process.env.FRONTEND_JS_SIZE_LOCALE || 'ja-JP';
const byteFormatter = new Intl.NumberFormat('en-US');
const numberFormatter = new Intl.NumberFormat('en-US');
function normalizePath(filePath) { const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP';
return filePath.split(path.sep).join('/');
}
async function exists(filePath) { //function sharePercent(value, total) {
try { // if (total === 0) return '0%';
await fs.access(filePath); // return Math.round((value / total) * 100) + '%';
return true; //}
} catch {
return false;
}
}
async function fileSize(filePath) { function escapeCell(value: string) {
const stat = await fs.stat(filePath);
return stat.size;
}
async function* walk(dir) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(fullPath);
} else if (entry.isFile()) {
yield fullPath;
}
}
}
function formatNumber(value) {
return numberFormatter.format(value);
}
function formatBytes(value) {
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
function escapeLatex(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
function formatColoredDiff(text, diff) {
if (diff === 0) return text;
const color = diff > 0 ? 'orange' : 'green';
const sign = diff > 0 ? '+' : '-';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
}
function formatNumberDiff(before, after) {
if (before == null || after == null) return '-';
const diff = after - before;
return formatColoredDiff(formatNumber(Math.abs(diff)), diff);
}
function formatBytesDiff(before, after) {
if (before == null || after == null) return '-';
const diff = after - before;
if (diff === 0) return '0 B';
return formatColoredDiff(formatBytes(Math.abs(diff)), diff);
}
function formatDiffPercent(before, after) {
if (before == null || before === 0 || after == null || after === 0) return '-';
const diff = after - before;
if (diff === 0) return `0%`;
const percent = Math.abs(Math.round(diff / before * 100));
return formatColoredDiff(`${percent}%`, diff);
}
function sharePercent(value, total) {
if (total === 0) return '0%';
return Math.round((value / total) * 100) + '%';
}
function escapeCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>'); return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
} }
function tableCell(value) { //function tableCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' '); // return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
} //}
function code(value) { //function code(value) {
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' '); // const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
const backtickRuns = sanitized.match(/`+/g) ?? []; // const backtickRuns = sanitized.match(/`+/g) ?? [];
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1)); // const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
const fence = '`'.repeat(fenceLength); // const fence = '`'.repeat(fenceLength);
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : ''; // const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
//
// return `${fence}${padding}${sanitized}${padding}${fence}`;
//}
return `${fence}${padding}${sanitized}${padding}${fence}`; //function tableCode(value) {
} // return tableCell(code(value));
//}
function tableCode(value) { type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
return tableCell(code(value));
}
function entryDisplayName(entry) { type FileEntry = {
key: string;
displayName: string;
file: string;
size: number;
};
function entryDisplayName(entry: FileEntry) {
if (!entry) return ''; if (!entry) return '';
return entry.displayName || entry.file; return entry.displayName || entry.file;
} }
function findEntryKey(manifest) { function findEntryKey(manifest: Manifest) {
const entries = Object.entries(manifest); const entries = Object.entries(manifest);
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0] return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0] ?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
@ -135,16 +60,16 @@ function findEntryKey(manifest) {
?? null; ?? null;
} }
function stableChunkKey(manifestKey, chunk) { function stableChunkKey(manifestKey: string, chunk: Manifest[string]) {
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey); return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
} }
function collectStartupKeys(manifest) { function collectStartupKeys(manifest: Manifest) {
const entryKey = findEntryKey(manifest); const entryKey = findEntryKey(manifest);
const keys = new Set(); const keys = new Set<string>();
if (entryKey == null) return keys; if (entryKey == null) return keys;
function visit(key) { function visit(key: string) {
if (keys.has(key)) return; if (keys.has(key)) return;
const chunk = manifest[key]; const chunk = manifest[key];
if (!chunk || !chunk.file?.endsWith('.js')) return; if (!chunk || !chunk.file?.endsWith('.js')) return;
@ -158,11 +83,11 @@ function collectStartupKeys(manifest) {
return keys; return keys;
} }
async function resolveBuiltFile(outDir, file) { async function resolveBuiltFile(outDir: string, file: string) {
if (file.startsWith('scripts/')) { if (file.startsWith('scripts/')) {
const localizedFile = file.slice('scripts/'.length); const localizedFile = file.slice('scripts/'.length);
const localizedPath = path.join(outDir, locale, localizedFile); const localizedPath = path.join(outDir, locale, localizedFile);
if (await exists(localizedPath)) { if (await util.fileExists(localizedPath)) {
return { return {
absolutePath: localizedPath, absolutePath: localizedPath,
relativePath: `${locale}/${localizedFile}`, relativePath: `${locale}/${localizedFile}`,
@ -177,17 +102,17 @@ async function resolveBuiltFile(outDir, file) {
}; };
} }
async function collectReport(repoDir) { async function collectReport(repoDir: string) {
const outDir = path.join(repoDir, 'built/_frontend_vite_'); const outDir = path.join(repoDir, 'built/_frontend_vite_');
const manifestPath = path.join(outDir, 'manifest.json'); const manifestPath = path.join(outDir, 'manifest.json');
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest;
const byKey = new Map(); const byKey = new Map<string, FileEntry>();
const byFile = new Set(); const byFile = new Set<string>();
for (const [key, chunk] of Object.entries(manifest)) { for (const [key, chunk] of Object.entries(manifest)) {
if (!chunk.file?.endsWith('.js')) continue; if (!chunk.file?.endsWith('.js')) continue;
const builtFile = await resolveBuiltFile(outDir, chunk.file); const builtFile = await resolveBuiltFile(outDir, chunk.file);
const size = await fileSize(builtFile.absolutePath); const size = await util.fileSize(builtFile.absolutePath);
const stableKey = stableChunkKey(key, chunk); const stableKey = stableChunkKey(key, chunk);
const displayName = chunk.src ?? chunk.name ?? key; const displayName = chunk.src ?? chunk.name ?? key;
byKey.set(stableKey, { byKey.set(stableKey, {
@ -200,12 +125,12 @@ async function collectReport(repoDir) {
} }
const localeDir = path.join(outDir, locale); const localeDir = path.join(outDir, locale);
if (await exists(localeDir)) { if (await util.fileExists(localeDir)) {
for await (const fullPath of walk(localeDir)) { for await (const fullPath of util.traverseDirectory(localeDir)) {
if (!fullPath.endsWith('.js')) continue; if (!fullPath.endsWith('.js')) continue;
const relativePath = normalizePath(path.relative(outDir, fullPath)); const relativePath = util.normalizePath(path.relative(outDir, fullPath));
if (byFile.has(relativePath)) continue; if (byFile.has(relativePath)) continue;
const size = await fileSize(fullPath); const size = await util.fileSize(fullPath);
byKey.set(relativePath, { byKey.set(relativePath, {
key: relativePath, key: relativePath,
displayName: relativePath, displayName: relativePath,
@ -222,7 +147,28 @@ async function collectReport(repoDir) {
}; };
} }
function collectVisualizerReport(data) { type VisualizerReport = {
nodeParts?: Record<string, {
renderedLength: number;
gzipLength: number;
brotliLength: number;
}>;
nodeMetas?: Record<string, {
id: string;
isEntry?: boolean;
isExternal?: boolean;
importedBy?: string[];
imported?: { id: string; dynamic?: boolean }[];
moduleParts?: Record<string, string>;
renderedLength: number;
gzipLength: number;
brotliLength: number;
}>;
options?: Record<string, unknown>;
};
function collectVisualizerReport(data: VisualizerReport) {
const nodeParts = data.nodeParts ?? {}; const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {}); const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = []; const moduleRows = [];
@ -304,7 +250,7 @@ function collectVisualizerReport(data) {
}; };
} }
function renderVisualizerSummaryTable(before, after) { function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
const summary = [ const summary = [
'bundles', 'bundles',
'modules', 'modules',
@ -312,13 +258,13 @@ function renderVisualizerSummaryTable(before, after) {
//'externals', //'externals',
'staticImports', 'staticImports',
'dynamicImports', 'dynamicImports',
]; ] as const;
const metrics = [ const metrics = [
'renderedLength', 'renderedLength',
'gzipLength', 'gzipLength',
'brotliLength', 'brotliLength',
]; ] as const;
return [ return [
`<table>`, `<table>`,
@ -342,31 +288,31 @@ function renderVisualizerSummaryTable(before, after) {
`<tbody>`, `<tbody>`,
`<tr>`, `<tr>`,
`<th><b>Before</b></th>`, `<th><b>Before</b></th>`,
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`), ...summary.map((key) => `<td>${util.formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`), ...metrics.map((key) => `<td>${util.formatBytes(before.metrics[key])}</td>`),
`</tr>`, `</tr>`,
`<tr>`, `<tr>`,
`<th><b>After</b></th>`, `<th><b>After</b></th>`,
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`), ...summary.map((key) => `<td>${util.formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`), ...metrics.map((key) => `<td>${util.formatBytes(after.metrics[key])}</td>`),
`</tr>`, `</tr>`,
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`, `<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
`<tr>`, `<tr>`,
`<th><b>Δ</b></th>`, `<th><b>Δ</b></th>`,
...summary.map((key) => `<td>${formatNumberDiff(before.summary[key], after.summary[key])}</td>`), ...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytesDiff(before.metrics[key], after.metrics[key])}</td>`), ...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`, `</tr>`,
`<tr>`, `<tr>`,
`<th><b>Δ (%)</b></th>`, `<th><b>Δ (%)</b></th>`,
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`), ...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`), ...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`, `</tr>`,
`</tbody>`, `</tbody>`,
`</table>`, `</table>`,
]; ];
} }
function getChunkComparisonRows(keys, before, after) { function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
return keys.map((key) => { return keys.map((key) => {
const beforeEntry = before.chunks[key]; const beforeEntry = before.chunks[key];
const afterEntry = after.chunks[key]; const afterEntry = after.chunks[key];
@ -384,7 +330,7 @@ function getChunkComparisonRows(keys, before, after) {
}); });
} }
function summarizeChunkChanges(rows) { function summarizeChunkChanges(rows: ReturnType<typeof getChunkComparisonRows>) {
return { return {
updated: rows.filter((row) => row.changeType === 'updated').length, updated: rows.filter((row) => row.changeType === 'updated').length,
added: rows.filter((row) => row.changeType === 'added').length, added: rows.filter((row) => row.changeType === 'added').length,
@ -392,18 +338,18 @@ function summarizeChunkChanges(rows) {
}; };
} }
function formatChunkChangeSummary(label, summary) { function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summarizeChunkChanges>) {
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`; return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
} }
function compareChunkComparisonRows(a, b) { function compareChunkComparisonRows(a: ReturnType<typeof getChunkComparisonRows>[number], b: ReturnType<typeof getChunkComparisonRows>[number]) {
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize) return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize) || (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize || b.sortSize - a.sortSize
|| a.name.localeCompare(b.name); || a.name.localeCompare(b.name);
} }
function chunkMarkdownTable(rows, total) { function chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, total?: { beforeSize: number; afterSize: number }) {
if (rows.length === 0) return '_No data_'; if (rows.length === 0) return '_No data_';
const lines = [ const lines = [
@ -411,22 +357,22 @@ function chunkMarkdownTable(rows, total) {
'| --- | ---: | ---: | ---: | ---: |', '| --- | ---: | ---: | ---: | ---: |',
]; ];
if (total != null) { if (total != null) {
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatBytesDiff(total.beforeSize, total.afterSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize).replaceAll('\\%', '\\\\%')} |`); lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize).replaceAll('\\%', '\\\\%')} |`);
lines.push('| | | | | |'); lines.push('| | | | | |');
} }
for (const row of rows) { for (const row of rows) {
if (row.changeType === 'added') { if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`); lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
} else if (row.changeType === 'removed') { } else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`); lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
} else { } else {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`); lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
} }
} }
return lines.join('\n'); return lines.join('\n');
} }
function renderFrontendChunkReport(before, after) { function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null); const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null);
const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null); const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null);
const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null); const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null);
@ -489,7 +435,7 @@ function renderFrontendChunkReport(before, after) {
].join('\n'); ].join('\n');
} }
function renderFrontendBundleReport(before, after) { function renderFrontendBundleReport(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
const lines = [ const lines = [
...renderVisualizerSummaryTable(before, after), ...renderVisualizerSummaryTable(before, after),
'', '',
@ -532,7 +478,7 @@ function renderFrontendBundleReport(before, after) {
const visualizerTreemapLimit = 50; const visualizerTreemapLimit = 50;
function mermaidTreemapLabel(value) { function mermaidTreemapLabel(value: string) {
const label = String(value) const label = String(value)
.replaceAll('\\', '/') .replaceAll('\\', '/')
.replaceAll('"', "'") .replaceAll('"', "'")
@ -543,14 +489,14 @@ function mermaidTreemapLabel(value) {
return label === '' ? '(unknown)' : label; return label === '' ? '(unknown)' : label;
} }
function mermaidTreemapModuleLabel(id) { function mermaidTreemapModuleLabel(id: string) {
const normalizedId = String(id).replaceAll('\\', '/'); const normalizedId = String(id).replaceAll('\\', '/');
const filePath = normalizedId.split(/[?#]/, 1)[0]; const filePath = normalizedId.split(/[?#]/, 1)[0];
const fileName = path.posix.basename(filePath); const fileName = path.posix.basename(filePath);
return mermaidTreemapLabel(fileName || normalizedId); return mermaidTreemapLabel(fileName || normalizedId);
} }
function renderVisualizerTreemap(label, report) { function renderVisualizerTreemap(label: string, report: ReturnType<typeof collectVisualizerReport>) {
const rows = report.hotModules const rows = report.hotModules
.filter((row) => row.renderedLength > 0) .filter((row) => row.renderedLength > 0)
.slice(0, visualizerTreemapLimit); .slice(0, visualizerTreemapLimit);
@ -580,7 +526,7 @@ function renderVisualizerTreemap(label, report) {
return lines.join('\n'); return lines.join('\n');
} }
function renderVisualizerTreemapDetails(label, report, open = false) { function renderVisualizerTreemapDetails(label: string, report: ReturnType<typeof collectVisualizerReport>, open = false) {
return [ return [
`<details${open ? ' open' : ''}>`, `<details${open ? ' open' : ''}>`,
`<summary>${label} rendered size treemap (top ${visualizerTreemapLimit} + Other)</summary>`, `<summary>${label} rendered size treemap (top ${visualizerTreemapLimit} + Other)</summary>`,
@ -595,8 +541,8 @@ const args = process.argv.slice(2);
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args; const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
const before = await collectReport(beforeDir); const before = await collectReport(beforeDir);
const after = await collectReport(afterDir); const after = await collectReport(afterDir);
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')); const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')); const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
const beforeVisualizerReport = collectVisualizerReport(beforeStats); const beforeVisualizerReport = collectVisualizerReport(beforeStats);
const afterVisualizerReport = collectVisualizerReport(afterStats); const afterVisualizerReport = collectVisualizerReport(afterStats);
const visualizerArtifactLink = `[Open detailed HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`; const visualizerArtifactLink = `[Open detailed HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;

View file

@ -1,312 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
const phases = ['beforeGc', 'afterGc', 'afterRequest'];
const heapSnapshotCategories = [
'Code',
'Strings',
'JS arrays',
'Typed arrays',
'System objects',
'Other JS objects',
'Other non-JS objects',
'Total',
];
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) {
console.error('Usage: node .github/scripts/measure-backend-memory-comparison.mjs <base-dir> <head-dir> <base-output.json> <head-output.json>');
process.exit(1);
}
function readIntegerEnv(name, defaultValue, min) {
const rawValue = process.env[name];
if (rawValue == null || rawValue === '') return defaultValue;
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
return value;
}
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
function commandName(command) {
if (process.platform !== 'win32') return command;
if (command === 'pnpm') return 'pnpm.cmd';
return command;
}
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn(commandName(command), args, {
cwd: options.cwd,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
stdout += data;
if (options.logStdout) process.stderr.write(data);
});
child.stderr.on('data', data => {
stderr += data;
process.stderr.write(data);
});
child.on('error', reject);
child.on('close', code => {
if (code === 0) {
resolvePromise(stdout);
} else {
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
}
});
});
}
async function resetState(repoDir) {
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
const pg = require('pg');
const Redis = require('ioredis');
const postgres = new pg.Client({
host: '127.0.0.1',
port: 54312,
database: 'postgres',
user: 'postgres',
});
await postgres.connect();
try {
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
await postgres.query('CREATE DATABASE "test-misskey"');
} finally {
await postgres.end();
}
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
try {
await redis.flushall();
} finally {
redis.disconnect();
}
}
function median(values) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[center];
return Math.round((sorted[center - 1] + sorted[center]) / 2);
}
function summarizeHeapSnapshotBreakdowns(samples, phase) {
const breakdowns = {};
for (const category of heapSnapshotCategories) {
if (category === 'Total') continue;
const childKeys = new Set();
for (const sample of samples) {
for (const childKey of Object.keys(sample[phase]?.heapSnapshot?.breakdowns?.[category] ?? {})) {
childKeys.add(childKey);
}
}
const categoryBreakdown = {};
for (const childKey of childKeys) {
const values = samples
.map(sample => sample[phase]?.heapSnapshot?.breakdowns?.[category]?.[childKey])
.filter(value => Number.isFinite(value));
if (values.length > 0) categoryBreakdown[childKey] = median(values);
}
if (Object.keys(categoryBreakdown).length > 0) {
breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown);
}
}
return breakdowns;
}
function collapseHeapSnapshotBreakdown(breakdown) {
const entries = Object.entries(breakdown)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
const otherValue = entries
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
.reduce((sum, [, value]) => sum + value, 0);
const collapsed = Object.fromEntries(topEntries);
if (otherValue > 0) collapsed.Other = otherValue;
return collapsed;
}
function summarizeSamples(samples) {
const summary = {};
for (const phase of phases) {
summary[phase] = {};
const metricKeys = new Set();
for (const sample of samples) {
for (const key of Object.keys(sample[phase] ?? {})) {
metricKeys.add(key);
}
}
for (const key of metricKeys) {
const values = samples
.map(sample => sample[phase]?.[key])
.filter(value => Number.isFinite(value));
if (values.length > 0) summary[phase][key] = median(values);
}
const heapSnapshotCategoryValues = {};
for (const category of heapSnapshotCategories) {
const values = samples
.map(sample => sample[phase]?.heapSnapshot?.categories?.[category])
.filter(value => Number.isFinite(value));
if (values.length > 0) heapSnapshotCategoryValues[category] = median(values);
}
const heapSnapshotNodeCountValues = {};
for (const category of heapSnapshotCategories) {
const values = samples
.map(sample => sample[phase]?.heapSnapshot?.nodeCounts?.[category])
.filter(value => Number.isFinite(value));
if (values.length > 0) heapSnapshotNodeCountValues[category] = median(values);
}
if (Object.keys(heapSnapshotCategoryValues).length > 0) {
const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase);
summary[phase].heapSnapshot = {
categories: heapSnapshotCategoryValues,
nodeCounts: heapSnapshotNodeCountValues,
...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}),
};
}
}
return summary;
}
async function measureRepo(label, repoDir, round, orderIndex) {
process.stderr.write(`[${label}] Resetting database and Redis\n`);
await resetState(repoDir);
process.stderr.write(`[${label}] Running migrations\n`);
await run('pnpm', ['--filter', 'backend', 'migrate'], {
cwd: repoDir,
env: process.env,
logStdout: true,
});
process.stderr.write(`[${label}] Measuring memory\n`);
const measureEnv = {
...process.env,
MK_MEMORY_SAMPLE_COUNT: '1',
};
if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0';
const stdout = await run('node', ['packages/backend/scripts/measure-memory.mjs'], {
cwd: repoDir,
env: measureEnv,
});
const report = JSON.parse(stdout);
const sample = report.samples?.[0] ?? {
timestamp: report.timestamp,
beforeGc: report.beforeGc,
afterGc: report.afterGc,
afterRequest: report.afterRequest,
};
return {
...sample,
label,
round,
orderIndex,
};
}
async function main() {
const baseDir = resolve(baseDirArg);
const headDir = resolve(headDirArg);
const baseOutput = resolve(baseOutputArg);
const headOutput = resolve(headOutputArg);
const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
const startedAt = new Date().toISOString();
const repos = {
base: {
dir: baseDir,
samples: [],
},
head: {
dir: headDir,
samples: [],
},
};
for (let round = 1; round <= warmupRounds; round++) {
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
for (const label of ['base', 'head']) {
await measureRepo(label, repos[label].dir, -round, 0);
}
}
for (let round = 1; round <= rounds; round++) {
const order = round % 2 === 1 ? ['base', 'head'] : ['head', 'base'];
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
for (const [orderIndex, label] of order.entries()) {
const sample = await measureRepo(label, repos[label].dir, round, orderIndex);
repos[label].samples.push(sample);
}
}
for (const label of ['base', 'head']) {
const report = {
timestamp: new Date().toISOString(),
sampleCount: repos[label].samples.length,
aggregation: 'median',
comparison: {
strategy: 'interleaved-pairs',
rounds,
warmupRounds,
startedAt,
},
...summarizeSamples(repos[label].samples),
samples: repos[label].samples,
};
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,260 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createRequire } from 'node:module';
import { writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import * as util from './utility.mts';
import type { MemoryReportRaw } from '../../packages/backend/scripts/measure-memory.mts';
const phases = ['afterGc'] as const;
export type MemoryReport = {
timestamp: string;
sampleCount: any;
aggregation: string;
measurement: {
startupTimeoutMs: any;
memorySettleTimeMs: any;
ipcTimeoutMs: any;
requestCount: any;
heapSnapshot: {
enabled: any;
timeoutMs: any;
breakdownTopN: any;
};
};
summary: Record<typeof phases[number], {
memoryUsage: Record<string, number>;
heapSnapshot?: {
categories: Record<typeof util.heapSnapshotCategories[number], number>;
nodeCounts: Record<typeof util.heapSnapshotCategories[number], number>;
breakdowns?: Record<typeof util.heapSnapshotCategories[number], Record<string, number>>;
};
}>;
samples: (MemoryReportRaw['samples'][number] & {
round: number;
})[];
};
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
async function resetState(repoDir: string) {
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
const pg = require('pg');
const Redis = require('ioredis');
const postgres = new pg.Client({
host: '127.0.0.1',
port: 54312,
database: 'postgres',
user: 'postgres',
});
await postgres.connect();
try {
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
await postgres.query('CREATE DATABASE "test-misskey"');
} finally {
await postgres.end();
}
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
try {
await redis.flushall();
} finally {
redis.disconnect();
}
}
function summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) {
const breakdowns = {} as Record<typeof util.heapSnapshotCategories[number], Record<string, number>>;
for (const category of util.heapSnapshotCategories) {
if (category === 'Total') continue;
const childKeys = new Set<string>();
for (const sample of samples) {
for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) {
childKeys.add(childKey);
}
}
const categoryBreakdown = {} as Record<string, number>;
for (const childKey of childKeys) {
const values = samples
.map(sample => sample.phases[phase].heapSnapshot?.breakdowns?.[category]?.[childKey])
.filter(value => Number.isFinite(value));
if (values.length > 0) categoryBreakdown[childKey] = util.median(values);
}
if (Object.keys(categoryBreakdown).length > 0) {
breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown);
}
}
return breakdowns;
}
function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>) {
const entries = Object.entries(breakdown)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
const otherValue = entries
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
.reduce((sum, [, value]) => sum + value, 0);
const collapsed = Object.fromEntries(topEntries);
if (otherValue > 0) collapsed.Other = otherValue;
return collapsed;
}
function summarizeSamples(samples: MemoryReport['samples']) {
const summary = {} as MemoryReport['summary'];
for (const phase of phases) {
summary[phase] = {} as typeof summary[typeof phase];
const metricKeys = new Set<string>();
for (const sample of samples) {
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
metricKeys.add(key);
}
}
for (const key of metricKeys) {
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
summary[phase].memoryUsage[key] = util.median(values);
}
const heapSnapshotCategoryValues = {} as Record<typeof util.heapSnapshotCategories[number], number>;
for (const category of util.heapSnapshotCategories) {
const values = samples
.map(sample => sample.phases[phase].heapSnapshot?.categories?.[category])
.filter(value => Number.isFinite(value)) as number[];
if (values.length > 0) heapSnapshotCategoryValues[category] = util.median(values);
}
const heapSnapshotNodeCountValues = {} as Record<typeof util.heapSnapshotCategories[number], number>;
for (const category of util.heapSnapshotCategories) {
const values = samples
.map(sample => sample.phases[phase].heapSnapshot?.nodeCounts?.[category])
.filter(value => Number.isFinite(value)) as number[];
if (values.length > 0) heapSnapshotNodeCountValues[category] = util.median(values);
}
if (Object.keys(heapSnapshotCategoryValues).length > 0) {
const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase);
summary[phase].heapSnapshot = {
categories: heapSnapshotCategoryValues,
nodeCounts: heapSnapshotNodeCountValues,
...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}),
};
}
}
return summary;
}
async function measureRepo(label: string, repoDir: string, round: number) {
process.stderr.write(`[${label}] Resetting database and Redis\n`);
await resetState(repoDir);
process.stderr.write(`[${label}] Running migrations\n`);
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
cwd: repoDir,
env: process.env,
logStdout: true,
});
process.stderr.write(`[${label}] Measuring memory\n`);
const measureEnv = {
...process.env,
MK_MEMORY_SAMPLE_COUNT: '1',
} as NodeJS.ProcessEnv;
if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0';
const stdout = await util.run('node', ['packages/backend/scripts/measure-memory.mts'], {
cwd: repoDir,
env: measureEnv,
});
const report = JSON.parse(stdout) as MemoryReportRaw;
const sample = report.samples[0];
return sample;
}
async function main() {
const baseDir = resolve(baseDirArg);
const headDir = resolve(headDirArg);
const baseOutput = resolve(baseOutputArg);
const headOutput = resolve(headOutputArg);
const rounds = util.readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
const startedAt = new Date().toISOString();
const reports = {
base: {
dir: baseDir,
samples: [] as MemoryReport['samples'],
},
head: {
dir: headDir,
samples: [] as MemoryReport['samples'],
},
};
for (let round = 1; round <= warmupRounds; round++) {
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
for (const label of ['base', 'head'] as const) {
await measureRepo(label, reports[label].dir, -round);
}
}
for (let round = 1; round <= rounds; round++) {
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
for (const [orderIndex, label] of order.entries()) {
const sample = await measureRepo(label, reports[label].dir, round);
reports[label].samples.push({
...sample,
round,
});
}
}
for (const label of ['base', 'head'] as const) {
const report = {
timestamp: new Date().toISOString(),
sampleCount: reports[label].samples.length,
aggregation: 'median',
comparison: {
strategy: 'interleaved-pairs',
rounds,
warmupRounds,
startedAt,
},
summary: summarizeSamples(reports[label].samples),
samples: reports[label].samples,
};
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});

179
.github/scripts/utility.mts vendored Normal file
View file

@ -0,0 +1,179 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { spawn } from 'node:child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
export const heapSnapshotCategories = [
'Total',
'Code',
'Strings',
'JS arrays',
'Typed arrays',
'System objects',
'Other JS objects',
'Other non-JS objects',
] as const;
export function median(values: number[]) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[center];
return Math.round((sorted[center - 1] + sorted[center]) / 2);
}
export function mad(values: number[]) {
if (values.length < 2) return null;
const center = median(values);
return median(values.map(value => Math.abs(value - center)));
}
export function normalizePath(filePath: string) {
return filePath.split(path.sep).join('/');
}
export async function fileExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export async function fileSize(filePath: string) {
const stat = await fs.stat(filePath);
return stat.size;
}
export async function* traverseDirectory(dir: string): AsyncGenerator<string> {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* traverseDirectory(fullPath);
} else if (entry.isFile()) {
yield fullPath;
}
}
}
export function escapeLatex(text: string) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
export function formatColoredDelta(text: string, delta: number) {
if (delta === 0) return text;
const color = delta > 0 ? 'orange' : 'green';
const sign = delta > 0 ? '+' : '-';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
}
const numberFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 1,
});
export function formatNumber(value: number) {
return numberFormatter.format(value);
}
export function formatBytes(value: number) {
if (value === 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
export function calcAndFormatDeltaNumber(before: number, after: number) {
if (before == null || after == null) return '-';
const delta = after - before;
return formatColoredDelta(formatNumber(Math.abs(delta)), delta);
}
export function formatDeltaBytes(deltaBytes: number) {
return formatColoredDelta(formatBytes(Math.abs(deltaBytes)), deltaBytes);
}
export function calcAndFormatDeltaBytes(before: number, after: number) {
if (before == null || after == null) return '-';
const delta = after - before;
return formatDeltaBytes(delta);
}
export function formatPercent(value: number) {
return `${formatNumber(value)}%`;
}
export function formatDeltaPercent(deltaPercent: number) {
if (deltaPercent === 0) return '0%';
return formatColoredDelta(formatPercent(Math.abs(deltaPercent)), deltaPercent);
}
export function calcAndFormatDeltaPercent(before: number, after: number) {
if (before == null || before === 0 || after == null || after === 0) return '-';
const delta = after - before;
return formatDeltaPercent(delta / before * 100);
}
export function commandName(command: string) {
if (process.platform !== 'win32') return command;
if (command === 'pnpm') return 'pnpm.cmd';
return command;
}
export function readIntegerEnv(name: string, defaultValue: number, min: number) {
const rawValue = process.env[name];
if (rawValue == null || rawValue === '') return defaultValue;
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
return value;
}
export function run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; logStdout?: boolean } = {}) {
return new Promise<string>((resolvePromise, reject) => {
const child = spawn(commandName(command), args, {
cwd: options.cwd,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
stdout += data;
if (options.logStdout) process.stderr.write(data);
});
child.stderr.on('data', data => {
stderr += data;
process.stderr.write(data);
});
child.on('error', reject);
child.on('close', code => {
if (code === 0) {
resolvePromise(stdout);
} else {
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
}
});
});
}

View file

@ -25,7 +25,8 @@ on:
- pnpm-lock.yaml - pnpm-lock.yaml
- pnpm-workspace.yaml - pnpm-workspace.yaml
- .node-version - .node-version
- .github/scripts/frontend-js-size.mjs - .github/scripts/utility.mts
- .github/scripts/frontend-js-size.mts
- .github/workflows/frontend-bundle-report.yml - .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml - .github/workflows/frontend-bundle-report-comment.yml

View file

@ -20,7 +20,8 @@ on:
- pnpm-lock.yaml - pnpm-lock.yaml
- pnpm-workspace.yaml - pnpm-workspace.yaml
- .node-version - .node-version
- .github/scripts/frontend-js-size.mjs - .github/scripts/utility.mts
- .github/scripts/frontend-js-size.mts
- .github/workflows/frontend-bundle-report.yml - .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml - .github/workflows/frontend-bundle-report-comment.yml
@ -144,7 +145,7 @@ jobs:
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }} FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
run: | run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report" REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md" node after/.github/scripts/frontend-js-size.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt" printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt" printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt" printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"

View file

@ -9,8 +9,9 @@ on:
paths: paths:
- packages/backend/** - packages/backend/**
- packages/misskey-js/** - packages/misskey-js/**
- .github/scripts/backend-memory-report.mjs - .github/scripts/utility.mts
- .github/scripts/measure-backend-memory-comparison.mjs - .github/scripts/backend-memory-report.mts
- .github/scripts/measure-backend-memory-comparison.mts
- .github/scripts/backend-js-footprint.mjs - .github/scripts/backend-js-footprint.mjs
- .github/scripts/backend-js-footprint-loader.mjs - .github/scripts/backend-js-footprint-loader.mjs
- .github/scripts/backend-js-footprint-require.cjs - .github/scripts/backend-js-footprint-require.cjs
@ -94,7 +95,7 @@ jobs:
MK_MEMORY_COMPARE_ROUNDS: 5 MK_MEMORY_COMPARE_ROUNDS: 5
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1 MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
MK_MEMORY_HEAP_SNAPSHOT: 1 MK_MEMORY_HEAP_SNAPSHOT: 1
run: node head/.github/scripts/measure-backend-memory-comparison.mjs base head memory-base.json memory-head.json run: node head/.github/scripts/measure-backend-memory-comparison.mts base head memory-base.json memory-head.json
- name: Measure backend loaded JS footprint - name: Measure backend loaded JS footprint
run: | run: |
node head/.github/scripts/backend-js-footprint.mjs base js-footprint-base.json node head/.github/scripts/backend-js-footprint.mjs base js-footprint-base.json

View file

@ -59,7 +59,7 @@ jobs:
run: cat ./artifacts/js-footprint-head.json run: cat ./artifacts/js-footprint-head.json
- id: build-comment - id: build-comment
name: Build memory comment name: Build memory comment
run: node .github/scripts/backend-memory-report.mjs ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md ./artifacts/js-footprint-base.json ./artifacts/js-footprint-head.json run: node .github/scripts/backend-memory-report.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md ./artifacts/js-footprint-base.json ./artifacts/js-footprint-head.json
- uses: thollander/actions-comment-pull-request@v3 - uses: thollander/actions-comment-pull-request@v3
with: with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }} pr-number: ${{ steps.load-pr-num.outputs.pr-number }}

View file

@ -1 +1 @@
22.15.0 22.18.0

View file

@ -3,19 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
/** import { ChildProcess, fork } from 'node:child_process';
* This script starts the Misskey backend server, waits for it to be ready,
* measures memory usage, and outputs the result as JSON.
*
* Usage: node scripts/measure-memory.mjs
*/
import { fork } from 'node:child_process';
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import * as http from 'node:http'; //import * as http from 'node:http';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -48,43 +41,8 @@ const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1); const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1);
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1); const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
const procStatusKeys = { const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
VmPeak: 0, const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
VmSize: 0,
VmHWM: 0,
VmRSS: 0,
VmData: 0,
VmStk: 0,
VmExe: 0,
VmLib: 0,
VmPTE: 0,
VmSwap: 0,
};
const smapsRollupKeys = {
Pss: 0,
Shared_Clean: 0,
Shared_Dirty: 0,
Private_Clean: 0,
Private_Dirty: 0,
Swap: 0,
SwapPss: 0,
};
const runtimeKeys = {
HeapTotal: 0,
HeapUsed: 0,
External: 0,
ArrayBuffers: 0,
};
const memoryKeys = {
...procStatusKeys,
...smapsRollupKeys,
...runtimeKeys,
};
const phases = ['beforeGc', 'afterGc', 'afterRequest'];
const heapSnapshotCategories = [ const heapSnapshotCategories = [
'Code', 'Code',
@ -125,9 +83,10 @@ const otherJsNodeTypes = new Set([
'bigint', 'bigint',
]); ]);
function parseMemoryFile(content, keys, path, required) { function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string, required: boolean): Record<KS[number], number> {
const result = {}; const result = {} as Record<KS[number], number>;
for (const key of Object.keys(keys)) { for (const _key of keys) {
const key = _key as KS[number];
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`)); const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
if (match) { if (match) {
result[key] = parseInt(match[1], 10); result[key] = parseInt(match[1], 10);
@ -138,7 +97,7 @@ function parseMemoryFile(content, keys, path, required) {
return result; return result;
} }
function bytesToKiB(value) { function bytesToKiB(value: number) {
return Math.round(value / 1024); return Math.round(value / 1024);
} }
@ -169,10 +128,6 @@ function classifyHeapSnapshotNode(type, name) {
return 'Other non-JS objects'; return 'Other non-JS objects';
} }
function addValue(map, key, value) {
map[key] = (map[key] ?? 0) + value;
}
function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') { function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
const label = String(value ?? '').replace(/\s+/g, ' ').trim(); const label = String(value ?? '').replace(/\s+/g, ' ').trim();
if (label === '') return fallback; if (label === '') return fallback;
@ -277,6 +232,10 @@ function analyzeHeapSnapshot(snapshot) {
.map(category => [category, {}]), .map(category => [category, {}]),
); );
function addValue(map: Record<string, number>, key: string, value: number) {
map[key] = (map[key] ?? 0) + value;
}
for (let offset = 0; offset < nodes.length; offset += fieldCount) { for (let offset = 0; offset < nodes.length; offset += fieldCount) {
const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown'; const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown';
const name = strings[nodes[offset + nameOffset]] ?? ''; const name = strings[nodes[offset + nameOffset]] ?? '';
@ -297,25 +256,16 @@ function analyzeHeapSnapshot(snapshot) {
}; };
} }
async function getMemoryUsage(pid) { async function getMemoryUsage(pid: number) {
const path = `/proc/${pid}/status`; const path = `/proc/${pid}/status`;
const status = await fs.readFile(path, 'utf-8'); const status = await fs.readFile(path, 'utf-8');
return parseMemoryFile(status, procStatusKeys, path, true); return parseMemoryFile(status, procStatusKeys, path, true);
} }
async function getSmapsRollupMemoryUsage(pid) { async function getSmapsRollupMemoryUsage(pid: number) {
const path = `/proc/${pid}/smaps_rollup`; const path = `/proc/${pid}/smaps_rollup`;
try { const smapsRollup = await fs.readFile(path, 'utf-8');
const smapsRollup = await fs.readFile(path, 'utf-8'); return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'EACCES') {
process.stderr.write(`Failed to read ${path}: ${err.message}\n`);
return {};
}
throw err;
}
} }
function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) { function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) {
@ -336,7 +286,7 @@ function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIM
}); });
} }
async function getRuntimeMemoryUsage(serverProcess) { async function getRuntimeMemoryUsage(serverProcess: ChildProcess) {
const response = waitForMessage( const response = waitForMessage(
serverProcess, serverProcess,
message => message != null && typeof message === 'object' && message.type === 'memory usage', message => message != null && typeof message === 'object' && message.type === 'memory usage',
@ -356,7 +306,7 @@ async function getRuntimeMemoryUsage(serverProcess) {
}; };
} }
async function getHeapSnapshotStatistics(serverProcess) { async function getHeapSnapshotStatistics(serverProcess: ChildProcess) {
if (!HEAP_SNAPSHOT) return null; if (!HEAP_SNAPSHOT) return null;
const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`); const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
@ -389,8 +339,8 @@ async function getHeapSnapshotStatistics(serverProcess) {
} }
} }
async function getAllMemoryUsage(serverProcess) { async function getAllMemoryUsage(serverProcess: ChildProcess) {
const pid = serverProcess.pid; const pid = serverProcess.pid!;
return { return {
...await getMemoryUsage(pid), ...await getMemoryUsage(pid),
...await getSmapsRollupMemoryUsage(pid), ...await getSmapsRollupMemoryUsage(pid),
@ -398,90 +348,6 @@ async function getAllMemoryUsage(serverProcess) {
}; };
} }
function median(values) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[center];
return Math.round((sorted[center - 1] + sorted[center]) / 2);
}
function summarizeHeapSnapshotBreakdowns(results, phase) {
const breakdowns = {};
for (const category of heapSnapshotCategories) {
if (category === 'Total') continue;
const childKeys = new Set();
for (const result of results) {
for (const childKey of Object.keys(result[phase]?.heapSnapshot?.breakdowns?.[category] ?? {})) {
childKeys.add(childKey);
}
}
const categoryBreakdown = {};
for (const childKey of childKeys) {
const values = results
.map(result => result[phase]?.heapSnapshot?.breakdowns?.[category]?.[childKey])
.filter(value => Number.isFinite(value));
if (values.length > 0) categoryBreakdown[childKey] = median(values);
}
if (Object.keys(categoryBreakdown).length > 0) {
breakdowns[category] = collapseHeapSnapshotBreakdown({ [category]: categoryBreakdown })[category] ?? categoryBreakdown;
}
}
return breakdowns;
}
function summarizeResults(results) {
const summary = {};
for (const phase of phases) {
summary[phase] = {};
for (const key of Object.keys(memoryKeys)) {
const values = results
.map(result => result[phase][key])
.filter(value => Number.isFinite(value));
if (values.length > 0) {
summary[phase][key] = median(values);
}
}
const heapSnapshotCategoryValues = {};
for (const category of heapSnapshotCategories) {
const values = results
.map(result => result[phase]?.heapSnapshot?.categories?.[category])
.filter(value => Number.isFinite(value));
if (values.length > 0) heapSnapshotCategoryValues[category] = median(values);
}
const heapSnapshotNodeCountValues = {};
for (const category of heapSnapshotCategories) {
const values = results
.map(result => result[phase]?.heapSnapshot?.nodeCounts?.[category])
.filter(value => Number.isFinite(value));
if (values.length > 0) heapSnapshotNodeCountValues[category] = median(values);
}
if (Object.keys(heapSnapshotCategoryValues).length > 0) {
const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(results, phase);
summary[phase].heapSnapshot = {
categories: heapSnapshotCategoryValues,
nodeCounts: heapSnapshotNodeCountValues,
...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}),
};
}
}
return summary;
}
async function measureMemory() { async function measureMemory() {
// Start the Misskey backend server using fork to enable IPC // Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/entry.js'), [], { const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
@ -537,25 +403,25 @@ async function measureMemory() {
await setTimeout(1000); await setTimeout(1000);
} }
function createRequest() { //function createRequest() {
return new Promise((resolve, reject) => { // return new Promise((resolve, reject) => {
const req = http.request({ // const req = http.request({
host: 'localhost', // host: 'localhost',
port: 61812, // port: 61812,
path: '/api/meta', // path: '/api/meta',
method: 'POST', // method: 'POST',
}, (res) => { // }, (res) => {
res.on('data', () => { }); // res.on('data', () => { });
res.on('end', () => { // res.on('end', () => {
resolve(); // resolve();
}); // });
}); // });
req.on('error', (err) => { // req.on('error', (err) => {
reject(err); // reject(err);
}); // });
req.end(); // req.end();
}); // });
} //}
// Wait for server to be ready or timeout // Wait for server to be ready or timeout
const startupStartTime = Date.now(); const startupStartTime = Date.now();
@ -573,22 +439,22 @@ async function measureMemory() {
// Wait for memory to settle // Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME); await setTimeout(MEMORY_SETTLE_TIME);
const beforeGc = await getAllMemoryUsage(serverProcess); //const beforeGc = await getAllMemoryUsage(serverProcess);
await triggerGc(); await triggerGc();
const afterGc = await getAllMemoryUsage(serverProcess); const memoryUsageAfterGC = await getAllMemoryUsage(serverProcess);
// create some http requests to simulate load //// create some http requests to simulate load
await Promise.all( //await Promise.all(
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()), // Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
); //);
await triggerGc(); //await triggerGc();
const afterRequest = await getAllMemoryUsage(serverProcess); //const afterRequest = await getAllMemoryUsage(serverProcess);
const heapSnapshot = await getHeapSnapshotStatistics(serverProcess);
if (heapSnapshot != null) afterRequest.heapSnapshot = heapSnapshot; const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess);
// Stop the server // Stop the server
serverProcess.kill('SIGTERM'); serverProcess.kill('SIGTERM');
@ -611,14 +477,36 @@ async function measureMemory() {
const result = { const result = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
beforeGc, phases: {
afterGc, //beforeGc,
afterRequest, afterGc: {
memoryUsage: memoryUsageAfterGC,
heapSnapshot: heapSnapshotAfterGc,
},
//afterRequest,
},
}; };
return result; return result;
} }
export type MemoryReportRaw = {
timestamp: string;
sampleCount: number;
measurement: {
startupTimeoutMs: number;
memorySettleTimeMs: number;
ipcTimeoutMs: number;
requestCount: number;
heapSnapshot: {
enabled: boolean;
timeoutMs: number;
breakdownTopN: number;
};
};
samples: Awaited<ReturnType<typeof measureMemory>>[];
};
async function main() { async function main() {
const results = []; const results = [];
for (let i = 0; i < SAMPLE_COUNT; i++) { for (let i = 0; i < SAMPLE_COUNT; i++) {
@ -627,12 +515,9 @@ async function main() {
results.push(res); results.push(res);
} }
const summary = summarizeResults(results); const result: MemoryReportRaw = {
const result = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
sampleCount: SAMPLE_COUNT, sampleCount: SAMPLE_COUNT,
aggregation: 'median',
measurement: { measurement: {
startupTimeoutMs: STARTUP_TIMEOUT, startupTimeoutMs: STARTUP_TIMEOUT,
memorySettleTimeMs: MEMORY_SETTLE_TIME, memorySettleTimeMs: MEMORY_SETTLE_TIME,
@ -644,7 +529,6 @@ async function main() {
breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N, breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N,
}, },
}, },
...summary,
samples: results, samples: results,
}; };