From 12be1fe5b5002d195e4817c888ee052cbc5a1ea7 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:23:01 +0900 Subject: [PATCH] [skip ci] chore(dev): tweak backend-memory-report --- .github/scripts/backend-memory-report.mts | 277 ++-------------------- 1 file changed, 16 insertions(+), 261 deletions(-) diff --git a/.github/scripts/backend-memory-report.mts b/.github/scripts/backend-memory-report.mts index 4a701655ec..85630f87b2 100644 --- a/.github/scripts/backend-memory-report.mts +++ b/.github/scripts/backend-memory-report.mts @@ -8,34 +8,7 @@ import * as util from './utility.mts'; import * as heapSnapshotUtil from './heap-snapshot-util.mts'; import type { MemoryReport } from './measure-backend-memory-comparison.mts'; -const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = process.argv.slice(2); - -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 [baseFile, headFile, outputFile] = process.argv.slice(2); const memoryReportPhases = [ { @@ -44,7 +17,7 @@ const memoryReportPhases = [ }, ] as const; -const metrics = [ +const memoryMetrics = [ 'HeapUsed', 'Pss', 'USS', @@ -56,26 +29,26 @@ function formatMemoryMb(valueKiB: number | null | undefined) { return `${util.formatNumber(valueKiB / 1000)} MB`; } -function formatMetricName(metric: typeof metrics[number]) { +function formatMemoryMetricName(metric: typeof memoryMetrics[number]) { return metric === 'Pss' ? 'PSS' : metric; } -function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { +function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { const memoryUsage = sample.phases[phase].memoryUsage; if (metric !== 'USS') return memoryUsage[metric]; return memoryUsage.Private_Clean + memoryUsage.Private_Dirty; } -function getSampleValues(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { +function getSampleValues(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric)); } -function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { +function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { if (metric !== 'USS') return report.summary[phase].memoryUsage[metric]; return util.median(getSampleValues(report, phase, metric)); } -function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { +function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { const values = getSampleValues(report, phase, metric); if (values.length < 2) return null; @@ -93,7 +66,7 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold } - for (const metric of metrics) { + for (const metric of memoryMetrics) { const baseValue = getMemoryValue(base, phase, metric); const headValue = getMemoryValue(head, phase, metric); @@ -103,29 +76,12 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: const percent = summary.median * 100 / baseValue; const deltaMedian = `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - lines.push(`| **${formatMetricName(metric)}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); + lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); } return lines.join('\n'); } -/* -function measurementSummary(base, head) { - const baseCount = base?.sampleCount; - const headCount = head?.sampleCount; - const strategy = base?.comparison?.strategy; - if (baseCount == null || headCount == null) return null; - - if (strategy === 'interleaved-pairs') { - const rounds = base?.comparison?.rounds ?? baseCount; - const warmupRounds = base?.comparison?.warmupRounds ?? 0; - return `_Measured as ${rounds} interleaved base/head pairs after ${warmupRounds} warmup pair(s). Values are medians; ± is median absolute deviation._`; - } - - return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`; -} -*/ - function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { const baseHeapSnapshotReport = { summary: base.summary.afterGc.heapSnapshot!, @@ -165,204 +121,11 @@ function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { return lines.join('\n'); } -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: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const metricRows = [ - ['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 | Δ | Δ (%) |', - '| --- | ---: | ---: | ---: | ---: |', - ]; - - for (const [title, key, formatter] of metricRows) { - const baseValue = getJsFootprintValue(base, 'afterRequest', key); - const headValue = getJsFootprintValue(head, 'afterRequest', key); - if (baseValue == null || headValue == null) continue; - - lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${util.formatColoredDelta(headValue - baseValue, v => formatter(v))} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`); - } - - return lines.join('\n'); -} - -/* -function renderJsFootprintPhaseTable(base, head) { - const lines = [ - '| Phase | Base modules | Head modules | Δ modules | Base source | Head source | Δ source |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ]; - - for (const [phase, title] of [['startup', 'Startup'], ['afterRequest', 'After warmup requests']]) { - const baseModules = getJsFootprintValue(base, phase, 'loadedJsModules'); - const headModules = getJsFootprintValue(head, phase, 'loadedJsModules'); - const baseSource = getJsFootprintValue(base, phase, 'loadedJsSourceBytes'); - const headSource = getJsFootprintValue(head, phase, 'loadedJsSourceBytes'); - if (baseModules == null || headModules == null || baseSource == null || headSource == null) continue; - - 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: RuntimeLoadedJsFootprintReport) { - const map = new Map(); - 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: { name: string; version?: string | null }) { - if (packageSummary.version == null) return packageSummary.name; - return `${packageSummary.name} ${packageSummary.version}`; -} - -function renderNewExternalPackages(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const basePackages = packageMap(base); - const headPackages = packageMap(head); - const newPackages = [...headPackages.values()] - .filter(packageSummary => !basePackages.has(packageSummary.name)) - .toSorted((a, b) => b.sourceBytes - a.sourceBytes) - .slice(0, 10); - - if (newPackages.length === 0) return null; - - const lines = [ - '#### Newly Loaded External Packages', - '', - '| Package | Loaded JS | Modules | Notes |', - '| --- | ---: | ---: | --- |', - ]; - - for (const packageSummary of newPackages) { - lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`); - } - - return lines.join('\n'); -} - -function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const basePackages = packageMap(base); - const headPackages = packageMap(head); - const increases = [...headPackages.values()] - .map(headPackage => { - const basePackage = basePackages.get(headPackage.name); - const baseSourceBytes = basePackage?.sourceBytes ?? 0; - const baseModules = basePackage?.modules ?? 0; - return { - ...headPackage, - baseSourceBytes, - baseModules, - sourceDiff: headPackage.sourceBytes - baseSourceBytes, - moduleDiff: headPackage.modules - baseModules, - }; - }) - .filter(packageSummary => packageSummary.sourceDiff > 0) - .toSorted((a, b) => b.sourceDiff - a.sourceDiff) - .slice(0, 10); - - if (increases.length === 0) return null; - - const lines = [ - '#### Largest Package Increases', - '', - '| Package | Base | Head | Δ | Modules Δ |', - '| --- | ---: | ---: | ---: | ---: |', - ]; - - for (const packageSummary of increases) { - lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatColoredDelta(packageSummary.sourceBytes - packageSummary.baseSourceBytes, v => util.formatBytes(v))} | ${util.formatColoredDelta(packageSummary.modules - packageSummary.baseModules, v => util.formatNumber(v))} |`); - } - - return lines.join('\n'); -} - -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; - } - - const baseModules = moduleMap(base); - const headModules = moduleMap(head); - const newModules = [...headModules.values()] - .filter(moduleSummary => !baseModules.has(moduleSummary.path)) - .toSorted((a, b) => b.sourceBytes - a.sourceBytes) - .slice(0, 10); - - if (newModules.length === 0) return null; - - const lines = [ - '#### Largest Newly Loaded Modules', - '', - '| Module | Package | Loaded JS |', - '| --- | --- | ---: |', - ]; - - for (const moduleSummary of newModules) { - lines.push(`| \`${moduleSummary.path}\` | ${moduleSummary.package} | ${util.formatBytes(moduleSummary.sourceBytes)} |`); - } - - return lines.join('\n'); -} - -function renderJsFootprintSection(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) { - const lines = [ - '### Runtime Loaded JS Footprint', - '', - '
Click to show', - '', - renderJsFootprintMetricTable(base, head), - '', - //'#### Load Phase Breakdown', - //'', - //renderJsFootprintPhaseTable(base, head), - //'', - ]; - - for (const block of [ - renderNewExternalPackages(base, head), - renderLargestPackageIncreases(base, head), - renderNewLoadedModules(base, head), - ]) { - if (block == null) continue; - lines.push(block); - lines.push(''); - } - - lines.push('
'); - lines.push(''); - - return lines.join('\n'); -} - 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', + '## ⚙️ Backend Diagnostics Report', '', ]; @@ -373,7 +136,7 @@ const lines = [ //} for (const phase of memoryReportPhases) { - lines.push(`### ${phase.title}`); + lines.push(`### Memory: ${phase.title}`); lines.push(renderMainTableForPhase(base, head, phase.key)); lines.push(''); } @@ -386,22 +149,16 @@ if (heapSnapshotSection != null) { const baseHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE!.trim(); const headHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim(); -lines.push(`Download representative V8 heap snapshot [base](${baseHeapSnapshotArtifactUrl}) / [head](${headHeapSnapshotArtifactUrl})`); +lines.push(`You can download the representative heap snapshot: [base](${baseHeapSnapshotArtifactUrl}) / [head](${headHeapSnapshotArtifactUrl})`); lines.push(''); -const jsFootprintSection = renderJsFootprintSection(baseJsFootprint, headJsFootprint); -if (jsFootprintSection != null) { - lines.push(jsFootprintSection); - lines.push(''); -} - -function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { +function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { const baseValue = getMemoryValue(base, phase, metric); const headValue = getMemoryValue(head, phase, metric); return ((headValue - baseValue) * 100) / baseValue; } -function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { +function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { const baseValue = getMemoryValue(base, phase, metric); const headValue = getMemoryValue(head, phase, metric); @@ -419,10 +176,8 @@ function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: type const warningMetric = 'Pss'; const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric); if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) { - lines.push(`⚠️ **Warning**: Memory usage (${formatMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`); + lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`); lines.push(''); } -//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`);