forked from mirrors/misskey
refactor(dev): refactor of backend memory comparison workflow (#17619)
* refactor(dev): refactor of backend memory comparison workflow * fix
This commit is contained in:
parent
e90ef7eba2
commit
1c4bcd9b32
12 changed files with 827 additions and 967 deletions
39
.github/scripts/backend-js-footprint.mjs
vendored
39
.github/scripts/backend-js-footprint.mjs
vendored
|
|
@ -13,21 +13,17 @@ import { gzipSync } from 'node:zlib';
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const [repoDirArg, outputFileArg] = process.argv.slice(2);
|
||||
|
||||
if (repoDirArg == null || outputFileArg == null) {
|
||||
console.error('Usage: node .github/scripts/backend-js-footprint.mjs <repo-dir> <output.json>');
|
||||
process.exit(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 STARTUP_TIMEOUT = util.readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1);
|
||||
const SETTLE_TIME = util.readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0);
|
||||
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 repoDir = resolve(repoDirArg);
|
||||
const outputFile = resolve(outputFileArg);
|
||||
|
|
@ -41,22 +37,6 @@ const fileMetricCache = new Map();
|
|||
const packageInfoCache = new Map();
|
||||
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) {
|
||||
const rel = relative(parent, child);
|
||||
return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`));
|
||||
|
|
@ -439,7 +419,6 @@ function summarizeRecords(records, phase) {
|
|||
totals.nativeAddonPackageCount = externalPackages.filter(packageSummary => packageSummary.nativeAddon).length;
|
||||
|
||||
return {
|
||||
phase,
|
||||
totals: {
|
||||
...totals,
|
||||
loadedJsSourceKiB: bytesToKiB(totals.loadedJsSourceBytes),
|
||||
|
|
@ -499,7 +478,7 @@ async function measureFootprint() {
|
|||
await waitForServerReady(serverProcess);
|
||||
await setTimeout(SETTLE_TIME);
|
||||
|
||||
const startup = summarizeRecords(await readTraceRecords(), 'startup');
|
||||
//const startup = summarizeRecords(await readTraceRecords(), 'startup');
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||
|
|
@ -517,8 +496,10 @@ async function measureFootprint() {
|
|||
requestCount: REQUEST_COUNT,
|
||||
cpus: cpus().length,
|
||||
},
|
||||
startup,
|
||||
afterRequest,
|
||||
phases: {
|
||||
//startup,
|
||||
afterRequest,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await stopServer(serverProcess);
|
||||
|
|
|
|||
|
|
@ -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 * as util from './utility.mts';
|
||||
import { type MemoryReport } from './measure-backend-memory-comparison.mts';
|
||||
|
||||
const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = process.argv.slice(2);
|
||||
|
||||
if (baseFile == null || headFile == null || outputFile == null) {
|
||||
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]');
|
||||
process.exit(1);
|
||||
}
|
||||
type RuntimeLoadedJsFootprintReport = {
|
||||
phases: Record<'afterRequest', {
|
||||
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', {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
const phases = [
|
||||
const memoryReportPhases = [
|
||||
{
|
||||
key: 'afterGc',
|
||||
title: 'After GC',
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
const metrics = [
|
||||
'HeapUsed',
|
||||
|
|
@ -24,18 +49,7 @@ const metrics = [
|
|||
'Private_Dirty',
|
||||
'VmRSS',
|
||||
'External',
|
||||
];
|
||||
|
||||
const heapSnapshotCategories = [
|
||||
'Total',
|
||||
'Code',
|
||||
'Strings',
|
||||
'JS arrays',
|
||||
'Typed arrays',
|
||||
'System objects',
|
||||
'Other JS objects',
|
||||
'Other non-JS objects',
|
||||
];
|
||||
] as const;
|
||||
|
||||
const heapSnapshotCategoriesColors = {
|
||||
'Total': 'gray',
|
||||
|
|
@ -46,7 +60,7 @@ const heapSnapshotCategoriesColors = {
|
|||
'System objects': 'yellow',
|
||||
'Other JS objects': 'violet',
|
||||
'Other non-JS objects': 'pink',
|
||||
};
|
||||
} as const;
|
||||
|
||||
const heapSnapshotCategoriesColorsHex = {
|
||||
'Total': '#888888',
|
||||
|
|
@ -57,96 +71,46 @@ const heapSnapshotCategoriesColorsHex = {
|
|||
'System objects': '#edc949',
|
||||
'Other JS objects': '#af7aa1',
|
||||
'Other non-JS objects': '#ff9da7',
|
||||
};
|
||||
} as const;
|
||||
|
||||
function formatNumber(value) {
|
||||
return numberFormatter.format(value);
|
||||
function formatMemoryMb(valueKiB: number | null | undefined) {
|
||||
if (valueKiB == null) return '-';
|
||||
return `${util.formatNumber(valueKiB / 1024)} MB`;
|
||||
}
|
||||
|
||||
function formatMemory(valueKiB) {
|
||||
return `${formatNumber(valueKiB / 1024)} MB`;
|
||||
function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
return report.summary[phase].memoryUsage[metric];
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
if (!Number.isFinite(value)) return '-';
|
||||
if (value < 1024) return `${formatNumber(value)} B`;
|
||||
if (value < 1024 * 1024) return `${formatNumber(value / 1024)} KiB`;
|
||||
return `${formatNumber(value / 1024 / 1024)} MiB`;
|
||||
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
return sample.phases[phase].memoryUsage[metric];
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${formatNumber(value)}%`;
|
||||
}
|
||||
|
||||
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);
|
||||
function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
const values = report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = median(values);
|
||||
return median(values.map(value => Math.abs(value - center)));
|
||||
const center = util.median(values);
|
||||
return util.median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
function mad(values) {
|
||||
if (values.length < 2) return null;
|
||||
|
||||
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;
|
||||
function getSamplesByRound(report: MemoryReport) {
|
||||
const samplesByRound = new Map<number, MemoryReport['samples'][number]>();
|
||||
if (!Array.isArray(report.samples)) return samplesByRound;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 headSamplesByRound = getSamplesByRound(head);
|
||||
const values = [];
|
||||
|
|
@ -155,37 +119,23 @@ function getPairedDeltaValues(base, head, phase, metric) {
|
|||
const headSample = headSamplesByRound.get(round);
|
||||
if (headSample == null) continue;
|
||||
|
||||
const baseValue = getMemoryValue(baseSample, phase, metric);
|
||||
const headValue = getMemoryValue(headSample, phase, metric);
|
||||
const baseValue = getMemoryValueFromSample(baseSample, phase, metric);
|
||||
const headValue = getMemoryValueFromSample(headSample, phase, metric);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
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 {
|
||||
median: median(values),
|
||||
mad: mad(values),
|
||||
median: util.median(values),
|
||||
mad: util.mad(values),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
samples: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderTable(base, head, phase) {
|
||||
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
|
|
@ -194,20 +144,20 @@ function renderTable(base, head, phase) {
|
|||
for (const metric of metrics) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(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');
|
||||
}
|
||||
|
||||
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 headValue = getMemoryValue(head, phase, metric);
|
||||
if (baseValue == null || headValue == null || baseValue <= 0) return null;
|
||||
|
|
@ -215,41 +165,7 @@ function getDiffPercent(base, head, phase, metric) {
|
|||
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) {
|
||||
const baseCount = base?.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._`;
|
||||
}
|
||||
*/
|
||||
|
||||
function formatPlainDiff(baseValue, headValue, formatter = formatNumber) {
|
||||
const diff = headValue - baseValue;
|
||||
if (diff === 0) return formatter(0);
|
||||
function formatPlainDelta(baseValue: number, headValue: number, formatter = util.formatNumber) {
|
||||
const delta = headValue - baseValue;
|
||||
if (delta === 0) return formatter(0);
|
||||
|
||||
const sign = diff > 0 ? '+' : '-';
|
||||
return `${sign}${formatter(Math.abs(diff))}`;
|
||||
const sign = delta > 0 ? '+' : '-';
|
||||
return `${sign}${formatter(Math.abs(delta))}`;
|
||||
}
|
||||
|
||||
function formatPlainDiffPercent(baseValue, headValue) {
|
||||
const diff = headValue - baseValue;
|
||||
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];
|
||||
function getHeapSnapshotCategoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const value = report.summary[phase]?.heapSnapshot?.categories?.[category];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function getHeapSnapshotBreakdownEntries(report, phase, category) {
|
||||
const breakdown = report?.[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]);
|
||||
function getHeapSnapshotCategoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const value = sample.phases[phase]?.heapSnapshot?.categories?.[category];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
const heapSnapshotSankeyChildMinRatio = 0.3;
|
||||
const heapSnapshotSankeyParentMinPercent = 10;
|
||||
|
||||
function escapeCsvValue(value) {
|
||||
function escapeCsvValue(value: string) {
|
||||
return `"${String(value).replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function formatSankeyPercentValue(value) {
|
||||
function formatSankeyPercentValue(value: number) {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
if (rounded === 0 && value > 0) return '0.01';
|
||||
if (Number.isInteger(rounded)) return String(rounded);
|
||||
return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function formatHeapSnapshotSankeyChildLabel(label) {
|
||||
function formatHeapSnapshotSankeyChildLabel(label: string) {
|
||||
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');
|
||||
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')
|
||||
.map(category => {
|
||||
const value = getHeapSnapshotCategoryValue(report, phase, category);
|
||||
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 percent = (value * 100) / total;
|
||||
const childEntries = [];
|
||||
|
|
@ -357,7 +270,7 @@ function renderHeapSnapshotSankey(report, phase, title) {
|
|||
|
||||
const nodeColors = {
|
||||
[title]: heapSnapshotCategoriesColorsHex.Total,
|
||||
};
|
||||
} as Record<string, string>;
|
||||
for (const { category, childEntries } of categories) {
|
||||
const categoryColor = heapSnapshotCategoriesColorsHex[category] ?? heapSnapshotCategoriesColorsHex.Total;
|
||||
nodeColors[category] = categoryColor;
|
||||
|
|
@ -402,70 +315,32 @@ function renderHeapSnapshotSankey(report, phase, title) {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatHeapSnapshotCategoryLabel(category, baseValue, headValue, baseTotal, headTotal) {
|
||||
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) {
|
||||
function pairedHeapSnapshotDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const baseSamplesByRound = getSamplesByRound(base);
|
||||
const headSamplesByRound = getSamplesByRound(head);
|
||||
const values = [];
|
||||
const values = [] as number[];
|
||||
|
||||
for (const [round, baseSample] of baseSamplesByRound) {
|
||||
const headSample = headSamplesByRound.get(round);
|
||||
if (headSample == null) continue;
|
||||
|
||||
const baseValue = getHeapSnapshotCategoryValue(baseSample, phase, category);
|
||||
const headValue = getHeapSnapshotCategoryValue(headSample, phase, category);
|
||||
const baseValue = getHeapSnapshotCategoryValueFromSample(baseSample, phase, category);
|
||||
const headValue = getHeapSnapshotCategoryValueFromSample(headSample, phase, category);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
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 {
|
||||
median: median(values),
|
||||
mad: mad(values),
|
||||
median: util.median(values),
|
||||
mad: util.mad(values),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
samples: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHeapSnapshotTable(base, head, phase) {
|
||||
function renderHeapSnapshotTable(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
|
|
@ -473,7 +348,25 @@ function renderHeapSnapshotTable(base, head, phase) {
|
|||
const baseTotal = getHeapSnapshotCategoryValue(base, 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 headValue = getHeapSnapshotCategoryValue(head, phase, category);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
|
@ -481,10 +374,11 @@ function renderHeapSnapshotTable(base, head, phase) {
|
|||
const baseSpread = getHeapSnapshotSampleSpread(base, phase, category);
|
||||
const headSpread = getHeapSnapshotSampleSpread(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);
|
||||
|
||||
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') {
|
||||
lines.push('| | | | | | | |');
|
||||
}
|
||||
|
|
@ -494,8 +388,8 @@ function renderHeapSnapshotTable(base, head, phase) {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderHeapSnapshotSection(base, head) {
|
||||
const table = renderHeapSnapshotTable(base, head, 'afterRequest');
|
||||
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
|
||||
const table = renderHeapSnapshotTable(base, head, 'afterGc');
|
||||
if (table == null) return null;
|
||||
|
||||
const lines = [
|
||||
|
|
@ -506,8 +400,8 @@ function renderHeapSnapshotSection(base, head) {
|
|||
];
|
||||
|
||||
for (const graph of [
|
||||
renderHeapSnapshotSankey(base, 'afterRequest', 'Base'),
|
||||
renderHeapSnapshotSankey(head, 'afterRequest', 'Head'),
|
||||
renderHeapSnapshotSankey(base, 'afterGc', 'Base'),
|
||||
renderHeapSnapshotSankey(head, 'afterGc', 'Head'),
|
||||
]) {
|
||||
if (graph == null) continue;
|
||||
lines.push(graph);
|
||||
|
|
@ -517,23 +411,23 @@ function renderHeapSnapshotSection(base, head) {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getJsFootprintValue(report, phase, key) {
|
||||
const value = report?.[phase]?.totals?.[key];
|
||||
function getJsFootprintValue(report: RuntimeLoadedJsFootprintReport, phase: 'afterRequest', key: keyof RuntimeLoadedJsFootprintReport['phases'][typeof phase]['totals']) {
|
||||
const value = report.phases[phase].totals[key];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function renderJsFootprintMetricTable(base, head) {
|
||||
function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const metricRows = [
|
||||
['Loaded JS modules', 'loadedJsModules', formatNumber],
|
||||
['Loaded JS source', 'loadedJsSourceBytes', formatBytes],
|
||||
//['Loaded JS gzip estimate', 'loadedJsGzipBytes', formatBytes],
|
||||
//['AST nodes', 'astNodeCount', formatNumber],
|
||||
//['Functions', 'functionCount', formatNumber],
|
||||
//['Classes', 'classCount', formatNumber],
|
||||
//['String literals', 'stringLiteralBytes', formatBytes],
|
||||
['External packages loaded', 'externalPackageCount', formatNumber],
|
||||
['Native addon packages', 'nativeAddonPackageCount', formatNumber],
|
||||
];
|
||||
['Loaded JS modules', 'loadedJsModules', util.formatNumber],
|
||||
['Loaded JS source', 'loadedJsSourceBytes', util.formatBytes],
|
||||
//['Loaded JS gzip estimate', 'loadedJsGzipBytes', util.formatBytes],
|
||||
//['AST nodes', 'astNodeCount', util.formatNumber],
|
||||
//['Functions', 'functionCount', util.formatNumber],
|
||||
//['Classes', 'classCount', util.formatNumber],
|
||||
//['String literals', 'stringLiteralBytes', util.formatBytes],
|
||||
['External packages loaded', 'externalPackageCount', util.formatNumber],
|
||||
['Native addon packages', 'nativeAddonPackageCount', util.formatNumber],
|
||||
] as const;
|
||||
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ | Δ (%) |',
|
||||
|
|
@ -545,12 +439,13 @@ function renderJsFootprintMetricTable(base, head) {
|
|||
const headValue = getJsFootprintValue(head, 'afterRequest', key);
|
||||
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');
|
||||
}
|
||||
|
||||
/*
|
||||
function renderJsFootprintPhaseTable(base, head) {
|
||||
const lines = [
|
||||
'| 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');
|
||||
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');
|
||||
}
|
||||
*/
|
||||
|
||||
function packageMap(report) {
|
||||
function packageMap(report: RuntimeLoadedJsFootprintReport) {
|
||||
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;
|
||||
map.set(packageSummary.name, packageSummary);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function packageDisplayName(packageSummary) {
|
||||
function packageDisplayName(packageSummary: { name: string; version?: string | null }) {
|
||||
if (packageSummary.version == null) return packageSummary.name;
|
||||
return `${packageSummary.name} ${packageSummary.version}`;
|
||||
}
|
||||
|
||||
function renderNewExternalPackages(base, head) {
|
||||
function renderNewExternalPackages(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const basePackages = packageMap(base);
|
||||
const headPackages = packageMap(head);
|
||||
const newPackages = [...headPackages.values()]
|
||||
|
|
@ -602,13 +498,13 @@ function renderNewExternalPackages(base, head) {
|
|||
];
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
function renderLargestPackageIncreases(base, head) {
|
||||
function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const basePackages = packageMap(base);
|
||||
const headPackages = packageMap(head);
|
||||
const increases = [...headPackages.values()]
|
||||
|
|
@ -638,22 +534,22 @@ function renderLargestPackageIncreases(base, head) {
|
|||
];
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
function moduleMap(report) {
|
||||
const map = new Map();
|
||||
for (const moduleSummary of report?.afterRequest?.modules ?? []) {
|
||||
if (typeof moduleSummary.path !== 'string') continue;
|
||||
map.set(moduleSummary.path, moduleSummary);
|
||||
function renderNewLoadedModules(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
function moduleMap(report: RuntimeLoadedJsFootprintReport) {
|
||||
const map = new Map();
|
||||
for (const moduleSummary of report.phases.afterRequest.modules) {
|
||||
if (typeof moduleSummary.path !== 'string') continue;
|
||||
map.set(moduleSummary.path, moduleSummary);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function renderNewLoadedModules(base, head) {
|
||||
const baseModules = moduleMap(base);
|
||||
const headModules = moduleMap(head);
|
||||
const newModules = [...headModules.values()]
|
||||
|
|
@ -671,15 +567,13 @@ function renderNewLoadedModules(base, head) {
|
|||
];
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
function renderJsFootprintSection(base, head) {
|
||||
if (base == null || head == null) return null;
|
||||
|
||||
function renderJsFootprintSection(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const lines = [
|
||||
'### Runtime Loaded JS Footprint',
|
||||
'',
|
||||
|
|
@ -709,10 +603,10 @@ function renderJsFootprintSection(base, head) {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8'));
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8'));
|
||||
const baseJsFootprint = baseJsFootprintFile == null ? null : JSON.parse(await readFile(baseJsFootprintFile, 'utf8'));
|
||||
const headJsFootprint = headJsFootprintFile == null ? null : JSON.parse(await readFile(headJsFootprintFile, 'utf8'));
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport;
|
||||
const baseJsFootprint = JSON.parse(await readFile(baseJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
|
||||
const headJsFootprint = JSON.parse(await readFile(headJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
|
||||
const lines = [
|
||||
'## ⚙️ Backend Memory Usage Report',
|
||||
'',
|
||||
|
|
@ -724,9 +618,9 @@ const lines = [
|
|||
// lines.push('');
|
||||
//}
|
||||
|
||||
for (const phase of phases) {
|
||||
for (const phase of memoryReportPhases) {
|
||||
lines.push(`### ${phase.title}`);
|
||||
lines.push(renderTable(base, head, phase.key));
|
||||
lines.push(renderMainTableForPhase(base, head, phase.key));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
|
|
@ -742,6 +636,31 @@ if (jsFootprintSection != null) {
|
|||
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 warningDiffPercent = warningMetric == null ? null : getDiffPercent(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(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`);
|
||||
|
|
@ -5,129 +5,54 @@
|
|||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
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) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP';
|
||||
|
||||
async function exists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
//function sharePercent(value, total) {
|
||||
// if (total === 0) return '0%';
|
||||
// return Math.round((value / total) * 100) + '%';
|
||||
//}
|
||||
|
||||
async function fileSize(filePath) {
|
||||
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) {
|
||||
function escapeCell(value: string) {
|
||||
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
||||
}
|
||||
|
||||
function tableCell(value) {
|
||||
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
}
|
||||
//function tableCell(value) {
|
||||
// return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
//}
|
||||
|
||||
function code(value) {
|
||||
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
const backtickRuns = sanitized.match(/`+/g) ?? [];
|
||||
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
|
||||
const fence = '`'.repeat(fenceLength);
|
||||
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
|
||||
//function code(value) {
|
||||
// const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
// const backtickRuns = sanitized.match(/`+/g) ?? [];
|
||||
// const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
|
||||
// const fence = '`'.repeat(fenceLength);
|
||||
// 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) {
|
||||
return tableCell(code(value));
|
||||
}
|
||||
type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
|
||||
|
||||
function entryDisplayName(entry) {
|
||||
type FileEntry = {
|
||||
key: string;
|
||||
displayName: string;
|
||||
file: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
function entryDisplayName(entry: FileEntry) {
|
||||
if (!entry) return '';
|
||||
return entry.displayName || entry.file;
|
||||
}
|
||||
|
||||
function findEntryKey(manifest) {
|
||||
function findEntryKey(manifest: Manifest) {
|
||||
const entries = Object.entries(manifest);
|
||||
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
|
||||
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
|
||||
|
|
@ -135,16 +60,16 @@ function findEntryKey(manifest) {
|
|||
?? null;
|
||||
}
|
||||
|
||||
function stableChunkKey(manifestKey, chunk) {
|
||||
function stableChunkKey(manifestKey: string, chunk: Manifest[string]) {
|
||||
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
|
||||
}
|
||||
|
||||
function collectStartupKeys(manifest) {
|
||||
function collectStartupKeys(manifest: Manifest) {
|
||||
const entryKey = findEntryKey(manifest);
|
||||
const keys = new Set();
|
||||
const keys = new Set<string>();
|
||||
if (entryKey == null) return keys;
|
||||
|
||||
function visit(key) {
|
||||
function visit(key: string) {
|
||||
if (keys.has(key)) return;
|
||||
const chunk = manifest[key];
|
||||
if (!chunk || !chunk.file?.endsWith('.js')) return;
|
||||
|
|
@ -158,11 +83,11 @@ function collectStartupKeys(manifest) {
|
|||
return keys;
|
||||
}
|
||||
|
||||
async function resolveBuiltFile(outDir, file) {
|
||||
async function resolveBuiltFile(outDir: string, file: string) {
|
||||
if (file.startsWith('scripts/')) {
|
||||
const localizedFile = file.slice('scripts/'.length);
|
||||
const localizedPath = path.join(outDir, locale, localizedFile);
|
||||
if (await exists(localizedPath)) {
|
||||
if (await util.fileExists(localizedPath)) {
|
||||
return {
|
||||
absolutePath: localizedPath,
|
||||
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 manifestPath = path.join(outDir, 'manifest.json');
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
||||
const byKey = new Map();
|
||||
const byFile = new Set();
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest;
|
||||
const byKey = new Map<string, FileEntry>();
|
||||
const byFile = new Set<string>();
|
||||
|
||||
for (const [key, chunk] of Object.entries(manifest)) {
|
||||
if (!chunk.file?.endsWith('.js')) continue;
|
||||
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 displayName = chunk.src ?? chunk.name ?? key;
|
||||
byKey.set(stableKey, {
|
||||
|
|
@ -200,12 +125,12 @@ async function collectReport(repoDir) {
|
|||
}
|
||||
|
||||
const localeDir = path.join(outDir, locale);
|
||||
if (await exists(localeDir)) {
|
||||
for await (const fullPath of walk(localeDir)) {
|
||||
if (await util.fileExists(localeDir)) {
|
||||
for await (const fullPath of util.traverseDirectory(localeDir)) {
|
||||
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;
|
||||
const size = await fileSize(fullPath);
|
||||
const size = await util.fileSize(fullPath);
|
||||
byKey.set(relativePath, {
|
||||
key: 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 nodeMetas = Object.values(data.nodeMetas ?? {});
|
||||
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 = [
|
||||
'bundles',
|
||||
'modules',
|
||||
|
|
@ -312,13 +258,13 @@ function renderVisualizerSummaryTable(before, after) {
|
|||
//'externals',
|
||||
'staticImports',
|
||||
'dynamicImports',
|
||||
];
|
||||
] as const;
|
||||
|
||||
const metrics = [
|
||||
'renderedLength',
|
||||
'gzipLength',
|
||||
'brotliLength',
|
||||
];
|
||||
] as const;
|
||||
|
||||
return [
|
||||
`<table>`,
|
||||
|
|
@ -342,31 +288,31 @@ function renderVisualizerSummaryTable(before, after) {
|
|||
`<tbody>`,
|
||||
`<tr>`,
|
||||
`<th><b>Before</b></th>`,
|
||||
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
|
||||
...summary.map((key) => `<td>${util.formatNumber(before.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.formatBytes(before.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>After</b></th>`,
|
||||
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
|
||||
...summary.map((key) => `<td>${util.formatNumber(after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.formatBytes(after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ</b></th>`,
|
||||
...summary.map((key) => `<td>${formatNumberDiff(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatBytesDiff(before.metrics[key], after.metrics[key])}</td>`),
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ (%)</b></th>`,
|
||||
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`</tbody>`,
|
||||
`</table>`,
|
||||
];
|
||||
}
|
||||
|
||||
function getChunkComparisonRows(keys, before, after) {
|
||||
function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||
return keys.map((key) => {
|
||||
const beforeEntry = before.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 {
|
||||
updated: rows.filter((row) => row.changeType === 'updated').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)`;
|
||||
}
|
||||
|
||||
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)
|
||||
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|
||||
|| b.sortSize - a.sortSize
|
||||
|| 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_';
|
||||
|
||||
const lines = [
|
||||
|
|
@ -411,22 +357,22 @@ function chunkMarkdownTable(rows, total) {
|
|||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
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('| | | | | |');
|
||||
}
|
||||
for (const row of rows) {
|
||||
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') {
|
||||
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 {
|
||||
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');
|
||||
}
|
||||
|
||||
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 addedChunkKeys = Object.keys(after.chunks).filter((key) => before.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');
|
||||
}
|
||||
|
||||
function renderFrontendBundleReport(before, after) {
|
||||
function renderFrontendBundleReport(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
|
||||
const lines = [
|
||||
...renderVisualizerSummaryTable(before, after),
|
||||
'',
|
||||
|
|
@ -532,7 +478,7 @@ function renderFrontendBundleReport(before, after) {
|
|||
|
||||
const visualizerTreemapLimit = 50;
|
||||
|
||||
function mermaidTreemapLabel(value) {
|
||||
function mermaidTreemapLabel(value: string) {
|
||||
const label = String(value)
|
||||
.replaceAll('\\', '/')
|
||||
.replaceAll('"', "'")
|
||||
|
|
@ -543,14 +489,14 @@ function mermaidTreemapLabel(value) {
|
|||
return label === '' ? '(unknown)' : label;
|
||||
}
|
||||
|
||||
function mermaidTreemapModuleLabel(id) {
|
||||
function mermaidTreemapModuleLabel(id: string) {
|
||||
const normalizedId = String(id).replaceAll('\\', '/');
|
||||
const filePath = normalizedId.split(/[?#]/, 1)[0];
|
||||
const fileName = path.posix.basename(filePath);
|
||||
return mermaidTreemapLabel(fileName || normalizedId);
|
||||
}
|
||||
|
||||
function renderVisualizerTreemap(label, report) {
|
||||
function renderVisualizerTreemap(label: string, report: ReturnType<typeof collectVisualizerReport>) {
|
||||
const rows = report.hotModules
|
||||
.filter((row) => row.renderedLength > 0)
|
||||
.slice(0, visualizerTreemapLimit);
|
||||
|
|
@ -580,7 +526,7 @@ function renderVisualizerTreemap(label, report) {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderVisualizerTreemapDetails(label, report, open = false) {
|
||||
function renderVisualizerTreemapDetails(label: string, report: ReturnType<typeof collectVisualizerReport>, open = false) {
|
||||
return [
|
||||
`<details${open ? ' open' : ''}>`,
|
||||
`<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 before = await collectReport(beforeDir);
|
||||
const after = await collectReport(afterDir);
|
||||
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8'));
|
||||
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8'));
|
||||
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
|
||||
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
|
||||
const beforeVisualizerReport = collectVisualizerReport(beforeStats);
|
||||
const afterVisualizerReport = collectVisualizerReport(afterStats);
|
||||
const visualizerArtifactLink = `[Open detailed HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
|
||||
|
|
@ -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);
|
||||
});
|
||||
260
.github/scripts/measure-backend-memory-comparison.mts
vendored
Normal file
260
.github/scripts/measure-backend-memory-comparison.mts
vendored
Normal 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
179
.github/scripts/utility.mts
vendored
Normal 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}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -25,7 +25,8 @@ on:
|
|||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .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-comment.yml
|
||||
|
||||
|
|
|
|||
5
.github/workflows/frontend-bundle-report.yml
vendored
5
.github/workflows/frontend-bundle-report.yml
vendored
|
|
@ -20,7 +20,8 @@ on:
|
|||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .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-comment.yml
|
||||
|
||||
|
|
@ -144,7 +145,7 @@ jobs:
|
|||
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
|
||||
run: |
|
||||
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' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
|
|
|
|||
7
.github/workflows/get-backend-memory.yml
vendored
7
.github/workflows/get-backend-memory.yml
vendored
|
|
@ -9,8 +9,9 @@ on:
|
|||
paths:
|
||||
- packages/backend/**
|
||||
- packages/misskey-js/**
|
||||
- .github/scripts/backend-memory-report.mjs
|
||||
- .github/scripts/measure-backend-memory-comparison.mjs
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/backend-memory-report.mts
|
||||
- .github/scripts/measure-backend-memory-comparison.mts
|
||||
- .github/scripts/backend-js-footprint.mjs
|
||||
- .github/scripts/backend-js-footprint-loader.mjs
|
||||
- .github/scripts/backend-js-footprint-require.cjs
|
||||
|
|
@ -94,7 +95,7 @@ jobs:
|
|||
MK_MEMORY_COMPARE_ROUNDS: 5
|
||||
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 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
|
||||
run: |
|
||||
node head/.github/scripts/backend-js-footprint.mjs base js-footprint-base.json
|
||||
|
|
|
|||
2
.github/workflows/report-backend-memory.yml
vendored
2
.github/workflows/report-backend-memory.yml
vendored
|
|
@ -59,7 +59,7 @@ jobs:
|
|||
run: cat ./artifacts/js-footprint-head.json
|
||||
- id: build-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
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
22.15.0
|
||||
22.18.0
|
||||
|
|
|
|||
|
|
@ -3,19 +3,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 { ChildProcess, fork } from 'node:child_process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import * as http from 'node:http';
|
||||
//import * as http from 'node:http';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
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_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
|
||||
|
||||
const procStatusKeys = {
|
||||
VmPeak: 0,
|
||||
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 procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
|
||||
const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
|
||||
|
||||
const heapSnapshotCategories = [
|
||||
'Code',
|
||||
|
|
@ -125,9 +83,10 @@ const otherJsNodeTypes = new Set([
|
|||
'bigint',
|
||||
]);
|
||||
|
||||
function parseMemoryFile(content, keys, path, required) {
|
||||
const result = {};
|
||||
for (const key of Object.keys(keys)) {
|
||||
function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string, required: boolean): Record<KS[number], number> {
|
||||
const result = {} as Record<KS[number], number>;
|
||||
for (const _key of keys) {
|
||||
const key = _key as KS[number];
|
||||
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
|
||||
if (match) {
|
||||
result[key] = parseInt(match[1], 10);
|
||||
|
|
@ -138,7 +97,7 @@ function parseMemoryFile(content, keys, path, required) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function bytesToKiB(value) {
|
||||
function bytesToKiB(value: number) {
|
||||
return Math.round(value / 1024);
|
||||
}
|
||||
|
||||
|
|
@ -169,10 +128,6 @@ function classifyHeapSnapshotNode(type, name) {
|
|||
return 'Other non-JS objects';
|
||||
}
|
||||
|
||||
function addValue(map, key, value) {
|
||||
map[key] = (map[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
|
||||
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (label === '') return fallback;
|
||||
|
|
@ -277,6 +232,10 @@ function analyzeHeapSnapshot(snapshot) {
|
|||
.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) {
|
||||
const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown';
|
||||
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 status = await fs.readFile(path, 'utf-8');
|
||||
|
||||
return parseMemoryFile(status, procStatusKeys, path, true);
|
||||
}
|
||||
|
||||
async function getSmapsRollupMemoryUsage(pid) {
|
||||
async function getSmapsRollupMemoryUsage(pid: number) {
|
||||
const path = `/proc/${pid}/smaps_rollup`;
|
||||
try {
|
||||
const smapsRollup = await fs.readFile(path, 'utf-8');
|
||||
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;
|
||||
}
|
||||
const smapsRollup = await fs.readFile(path, 'utf-8');
|
||||
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
|
||||
}
|
||||
|
||||
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(
|
||||
serverProcess,
|
||||
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;
|
||||
|
||||
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) {
|
||||
const pid = serverProcess.pid;
|
||||
async function getAllMemoryUsage(serverProcess: ChildProcess) {
|
||||
const pid = serverProcess.pid!;
|
||||
return {
|
||||
...await getMemoryUsage(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() {
|
||||
// Start the Misskey backend server using fork to enable IPC
|
||||
const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
|
||||
|
|
@ -537,25 +403,25 @@ async function measureMemory() {
|
|||
await setTimeout(1000);
|
||||
}
|
||||
|
||||
function createRequest() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
host: 'localhost',
|
||||
port: 61812,
|
||||
path: '/api/meta',
|
||||
method: 'POST',
|
||||
}, (res) => {
|
||||
res.on('data', () => { });
|
||||
res.on('end', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
//function createRequest() {
|
||||
// return new Promise((resolve, reject) => {
|
||||
// const req = http.request({
|
||||
// host: 'localhost',
|
||||
// port: 61812,
|
||||
// path: '/api/meta',
|
||||
// method: 'POST',
|
||||
// }, (res) => {
|
||||
// res.on('data', () => { });
|
||||
// res.on('end', () => {
|
||||
// resolve();
|
||||
// });
|
||||
// });
|
||||
// req.on('error', (err) => {
|
||||
// reject(err);
|
||||
// });
|
||||
// req.end();
|
||||
// });
|
||||
//}
|
||||
|
||||
// Wait for server to be ready or timeout
|
||||
const startupStartTime = Date.now();
|
||||
|
|
@ -573,22 +439,22 @@ async function measureMemory() {
|
|||
// Wait for memory to settle
|
||||
await setTimeout(MEMORY_SETTLE_TIME);
|
||||
|
||||
const beforeGc = await getAllMemoryUsage(serverProcess);
|
||||
//const beforeGc = await getAllMemoryUsage(serverProcess);
|
||||
|
||||
await triggerGc();
|
||||
|
||||
const afterGc = await getAllMemoryUsage(serverProcess);
|
||||
const memoryUsageAfterGC = await getAllMemoryUsage(serverProcess);
|
||||
|
||||
// create some http requests to simulate load
|
||||
await Promise.all(
|
||||
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||
);
|
||||
//// create some http requests to simulate load
|
||||
//await Promise.all(
|
||||
// Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||
//);
|
||||
|
||||
await triggerGc();
|
||||
//await triggerGc();
|
||||
|
||||
const afterRequest = await getAllMemoryUsage(serverProcess);
|
||||
const heapSnapshot = await getHeapSnapshotStatistics(serverProcess);
|
||||
if (heapSnapshot != null) afterRequest.heapSnapshot = heapSnapshot;
|
||||
//const afterRequest = await getAllMemoryUsage(serverProcess);
|
||||
|
||||
const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess);
|
||||
|
||||
// Stop the server
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
|
@ -611,14 +477,36 @@ async function measureMemory() {
|
|||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
beforeGc,
|
||||
afterGc,
|
||||
afterRequest,
|
||||
phases: {
|
||||
//beforeGc,
|
||||
afterGc: {
|
||||
memoryUsage: memoryUsageAfterGC,
|
||||
heapSnapshot: heapSnapshotAfterGc,
|
||||
},
|
||||
//afterRequest,
|
||||
},
|
||||
};
|
||||
|
||||
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() {
|
||||
const results = [];
|
||||
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
||||
|
|
@ -627,12 +515,9 @@ async function main() {
|
|||
results.push(res);
|
||||
}
|
||||
|
||||
const summary = summarizeResults(results);
|
||||
|
||||
const result = {
|
||||
const result: MemoryReportRaw = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: SAMPLE_COUNT,
|
||||
aggregation: 'median',
|
||||
measurement: {
|
||||
startupTimeoutMs: STARTUP_TIMEOUT,
|
||||
memorySettleTimeMs: MEMORY_SETTLE_TIME,
|
||||
|
|
@ -644,7 +529,6 @@ async function main() {
|
|||
breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N,
|
||||
},
|
||||
},
|
||||
...summary,
|
||||
samples: results,
|
||||
};
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue