diff --git a/.github/scripts/backend-js-footprint.mjs b/.github/scripts/backend-js-footprint.mjs index b3dee97089..f48ccd973a 100644 --- a/.github/scripts/backend-js-footprint.mjs +++ b/.github/scripts/backend-js-footprint.mjs @@ -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 '); - 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); diff --git a/.github/scripts/backend-memory-report.mjs b/.github/scripts/backend-memory-report.mts similarity index 55% rename from .github/scripts/backend-memory-report.mjs rename to .github/scripts/backend-memory-report.mts index 2aeaf24733..b564e9e6d6 100644 --- a/.github/scripts/backend-memory-report.mjs +++ b/.github/scripts/backend-memory-report.mts @@ -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-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(); + 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)}
${formatDeltaPercent(summary.median, baseValue)}`; + const percent = summary.median * 100 / baseValue; + const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent)}`; - lines.push(`| **${metric}** | ${formatMemory(baseValue)}
± ${formatMemory(baseSpread)} | ${formatMemory(headValue)}
± ${formatMemory(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemory(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`); + lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${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; 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}**
${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}**
${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)}
${formatDeltaPercent(summary.median, baseValue)}`; + const percent = summary.median * 100 / baseValue; + const deltaMedian = summary == null ? '-' : `${util.formatDeltaBytes(summary.median)}
${util.formatDeltaPercent(percent)}`; const categoryLabel = formatHeapSnapshotCategoryLabel(category, baseValue, headValue, baseTotal, headTotal); - lines.push(`| $\\color{${heapSnapshotCategoriesColors[category]}}{\\rule{8pt}{8pt}}$ ${categoryLabel} | ${formatBytes(baseValue)}
± ${baseSpread == null ? '-' : formatBytes(baseSpread)} | ${formatBytes(headValue)}
± ${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)}
± ${baseSpread == null ? '-' : util.formatBytes(baseSpread)} | ${util.formatBytes(headValue)}
± ${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`); diff --git a/.github/scripts/frontend-js-size.mjs b/.github/scripts/frontend-js-size.mts similarity index 68% rename from .github/scripts/frontend-js-size.mjs rename to .github/scripts/frontend-js-size.mts index 39a4fd3993..1be657f9ef 100644 --- a/.github/scripts/frontend-js-size.mjs +++ b/.github/scripts/frontend-js-size.mts @@ -5,129 +5,54 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; +import * as util from './utility.mts'; const marker = ''; -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', '
'); } -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; -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(); 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(); + const byFile = new Set(); 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; + nodeMetas?: Record; + renderedLength: number; + gzipLength: number; + brotliLength: number; + }>; + options?: Record; +}; + + +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, after: ReturnType) { 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 [ ``, @@ -342,31 +288,31 @@ function renderVisualizerSummaryTable(before, after) { ``, ``, ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), + ...summary.map((key) => ``), + ...metrics.map((key) => ``), ``, ``, ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), + ...summary.map((key) => ``), + ...metrics.map((key) => ``), ``, ``, ``, ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), + ...summary.map((key) => ``), + ...metrics.map((key) => ``), ``, ``, ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), + ...summary.map((key) => ``), + ...metrics.map((key) => ``), ``, ``, `
Before${formatNumber(before.summary[key])}${formatBytes(before.metrics[key])}${util.formatNumber(before.summary[key])}${util.formatBytes(before.metrics[key])}
After${formatNumber(after.summary[key])}${formatBytes(after.metrics[key])}${util.formatNumber(after.summary[key])}${util.formatBytes(after.metrics[key])}
Δ${formatNumberDiff(before.summary[key], after.summary[key])}${formatBytesDiff(before.metrics[key], after.metrics[key])}${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key])}${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key])}
Δ (%)${formatDiffPercent(before.summary[key], after.summary[key])}${formatDiffPercent(before.metrics[key], after.metrics[key])}${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key])}${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key])}
`, ]; } -function getChunkComparisonRows(keys, before, after) { +function getChunkComparisonRows(keys: string[], before: Awaited>, after: Awaited>) { 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) { 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) { return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`; } -function compareChunkComparisonRows(a, b) { +function compareChunkComparisonRows(a: ReturnType[number], b: ReturnType[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, 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(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`); } else if (row.changeType === 'removed') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`); } else { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${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>, after: Awaited>) { 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, after: ReturnType) { 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) { 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, open = false) { return [ ``, `${label} rendered size treemap (top ${visualizerTreemapLimit} + Other)`, @@ -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})`; diff --git a/.github/scripts/measure-backend-memory-comparison.mjs b/.github/scripts/measure-backend-memory-comparison.mjs deleted file mode 100644 index 3857499bdd..0000000000 --- a/.github/scripts/measure-backend-memory-comparison.mjs +++ /dev/null @@ -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 '); - 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); -}); diff --git a/.github/scripts/measure-backend-memory-comparison.mts b/.github/scripts/measure-backend-memory-comparison.mts new file mode 100644 index 0000000000..24ca67f13e --- /dev/null +++ b/.github/scripts/measure-backend-memory-comparison.mts @@ -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; + heapSnapshot?: { + categories: Record; + nodeCounts: Record; + breakdowns?: Record>; + }; + }>; + 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>; + + for (const category of util.heapSnapshotCategories) { + if (category === 'Total') continue; + + const childKeys = new Set(); + for (const sample of samples) { + for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) { + childKeys.add(childKey); + } + } + + const categoryBreakdown = {} as Record; + 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) { + 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(); + 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; + 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; + 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); +}); diff --git a/.github/scripts/utility.mts b/.github/scripts/utility.mts new file mode 100644 index 0000000000..a0c2f35e1c --- /dev/null +++ b/.github/scripts/utility.mts @@ -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 { + 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((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}`)); + } + }); + }); +} diff --git a/.github/workflows/frontend-bundle-report-comment.yml b/.github/workflows/frontend-bundle-report-comment.yml index d1535c7710..b93f8a320f 100644 --- a/.github/workflows/frontend-bundle-report-comment.yml +++ b/.github/workflows/frontend-bundle-report-comment.yml @@ -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 diff --git a/.github/workflows/frontend-bundle-report.yml b/.github/workflows/frontend-bundle-report.yml index a8f4ecde05..7625f4696d 100644 --- a/.github/workflows/frontend-bundle-report.yml +++ b/.github/workflows/frontend-bundle-report.yml @@ -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" diff --git a/.github/workflows/get-backend-memory.yml b/.github/workflows/get-backend-memory.yml index c21c516721..d25e3db1d9 100644 --- a/.github/workflows/get-backend-memory.yml +++ b/.github/workflows/get-backend-memory.yml @@ -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 diff --git a/.github/workflows/report-backend-memory.yml b/.github/workflows/report-backend-memory.yml index f5a9b62306..157ad3b68f 100644 --- a/.github/workflows/report-backend-memory.yml +++ b/.github/workflows/report-backend-memory.yml @@ -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 }} diff --git a/.node-version b/.node-version index b8ffd70759..91d5f6ff8e 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.15.0 +22.18.0 diff --git a/packages/backend/scripts/measure-memory.mjs b/packages/backend/scripts/measure-memory.mts similarity index 73% rename from packages/backend/scripts/measure-memory.mjs rename to packages/backend/scripts/measure-memory.mts index b3a7056e1a..f814ac27ac 100644 --- a/packages/backend/scripts/measure-memory.mjs +++ b/packages/backend/scripts/measure-memory.mts @@ -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(content: string, keys: KS, path: string, required: boolean): Record { + const result = {} as Record; + 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, 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>[]; +}; + 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, };