From 8dfa900729145b24578e3d0d65b50a1ba1fdf1ff Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:45:58 +0900 Subject: [PATCH] (test) enhance(dev): improve backend-memory-report --- .github/scripts/backend-memory-report.mjs | 154 +++++++++++++++ .../measure-backend-memory-comparison.mjs | 46 ++++- .github/workflows/get-backend-memory.yml | 1 + packages/backend/scripts/measure-memory.mjs | 184 ++++++++++++++++++ packages/backend/src/boot/entry.ts | 16 ++ 5 files changed, 397 insertions(+), 4 deletions(-) diff --git a/.github/scripts/backend-memory-report.mjs b/.github/scripts/backend-memory-report.mjs index 6ea8a5b5af..ab26e92233 100644 --- a/.github/scripts/backend-memory-report.mjs +++ b/.github/scripts/backend-memory-report.mjs @@ -26,6 +26,17 @@ const metrics = [ 'External', ]; +const heapSnapshotCategories = [ + 'Code', + 'Strings', + 'JS arrays', + 'Typed arrays', + 'System objects', + 'Other JS objects', + 'Other non-JS objects', + 'Total', +]; + function formatNumber(value) { return numberFormatter.format(value); } @@ -273,6 +284,143 @@ function formatPlainDiffPercent(baseValue, headValue) { return `${sign}${formatPercent(Math.abs((diff * 100) / baseValue))}`; } +function getHeapSnapshotCategoryValue(report, phase, category) { + const value = report?.[phase]?.heapSnapshot?.categories?.[category]; + return Number.isFinite(value) ? value : null; +} + +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 formatDiffBytes(baseBytes, headBytes) { + const diff = headBytes - baseBytes; + if (diff === 0) return formatBytes(0); + + const sign = diff > 0 ? '+' : '-'; + return formatColoredDiff(`${sign}${formatBytes(Math.abs(diff))}`, diff); +} + +function formatDiffBytesPercent(baseBytes, headBytes) { + const diff = headBytes - baseBytes; + if (diff === 0) return '0%'; + if (baseBytes <= 0) return '-'; + + const sign = diff > 0 ? '+' : '-'; + return formatColoredDiff(`${sign}${formatPercent(Math.abs((diff * 100) / baseBytes))}`, diff); +} + +function getPairedHeapSnapshotDeltaValues(base, head, phase, category) { + const baseSamplesByRound = getSamplesByRound(base); + const headSamplesByRound = getSamplesByRound(head); + const values = []; + + 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); + 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), + min: Math.min(...values), + max: Math.max(...values), + samples: values.length, + }; +} + +function renderHeapSnapshotTable(base, head, phase) { + const lines = [ + '| Category | Base | Head | Δ | Δ (%) |', + '| --- | ---: | ---: | ---: | ---: |', + ]; + + for (const category of heapSnapshotCategories) { + const baseValue = getHeapSnapshotCategoryValue(base, phase, category); + const headValue = getHeapSnapshotCategoryValue(head, phase, category); + if (baseValue == null || headValue == null) continue; + + const baseSpread = getHeapSnapshotSampleSpread(base, phase, category); + const headSpread = getHeapSnapshotSampleSpread(head, phase, category); + + lines.push(`| ${category} | ${formatBytes(baseValue)}
± ${baseSpread == null ? '-' : formatBytes(baseSpread)} | ${formatBytes(headValue)}
± ${headSpread == null ? '-' : formatBytes(headSpread)} | ${formatDiffBytes(baseValue, headValue)} | ${formatDiffBytesPercent(baseValue, headValue)} |`); + } + + if (lines.length === 2) return null; + return lines.join('\n'); +} + +function renderHeapSnapshotPairedDeltaTable(base, head, phase) { + const lines = [ + '| Category | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: |', + ]; + + for (const category of heapSnapshotCategories) { + const summary = pairedHeapSnapshotDeltaSummary(base, head, phase, category); + if (summary == null) continue; + + lines.push(`| ${category} | ${formatDeltaBytes(summary.median)} | ${summary.mad == null ? '-' : formatBytes(summary.mad)} | ${formatDeltaBytes(summary.min)} | ${formatDeltaBytes(summary.max)} |`); + } + + if (lines.length === 2) return null; + return lines.join('\n'); +} + +function renderHeapSnapshotSection(base, head) { + const table = renderHeapSnapshotTable(base, head, 'afterRequest'); + if (table == null) return null; + + const lines = [ + '### V8 Heap Snapshot Statistics', + '', + table, + '', + ]; + + const pairedDeltaTable = renderHeapSnapshotPairedDeltaTable(base, head, 'afterRequest'); + if (pairedDeltaTable != null) { + lines.push('#### Paired Delta Summary'); + lines.push(''); + lines.push(pairedDeltaTable); + lines.push(''); + } + + return lines.join('\n'); +} + function getJsFootprintValue(report, phase, key) { const value = report?.[phase]?.totals?.[key]; return Number.isFinite(value) ? value : null; @@ -494,6 +642,12 @@ for (const phase of phases) { } } +const heapSnapshotSection = renderHeapSnapshotSection(base, head); +if (heapSnapshotSection != null) { + lines.push(heapSnapshotSection); + lines.push(''); +} + const jsFootprintSection = renderJsFootprintSection(baseJsFootprint, headJsFootprint); if (jsFootprintSection != null) { lines.push(jsFootprintSection); diff --git a/.github/scripts/measure-backend-memory-comparison.mjs b/.github/scripts/measure-backend-memory-comparison.mjs index da543570ac..25b8942572 100644 --- a/.github/scripts/measure-backend-memory-comparison.mjs +++ b/.github/scripts/measure-backend-memory-comparison.mjs @@ -9,6 +9,16 @@ 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); @@ -121,6 +131,31 @@ function summarizeSamples(samples) { 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) { + summary[phase].heapSnapshot = { + categories: heapSnapshotCategoryValues, + nodeCounts: heapSnapshotNodeCountValues, + }; + } } return summary; @@ -138,12 +173,15 @@ async function measureRepo(label, repoDir, round, orderIndex) { }); 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: { - ...process.env, - MK_MEMORY_SAMPLE_COUNT: '1', - }, + env: measureEnv, }); const report = JSON.parse(stdout); diff --git a/.github/workflows/get-backend-memory.yml b/.github/workflows/get-backend-memory.yml index 9f5af707c7..c21c516721 100644 --- a/.github/workflows/get-backend-memory.yml +++ b/.github/workflows/get-backend-memory.yml @@ -93,6 +93,7 @@ jobs: env: 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 - name: Measure backend loaded JS footprint run: | diff --git a/packages/backend/scripts/measure-memory.mjs b/packages/backend/scripts/measure-memory.mjs index beefcd7ed0..8d2c1e1e77 100644 --- a/packages/backend/scripts/measure-memory.mjs +++ b/packages/backend/scripts/measure-memory.mjs @@ -14,6 +14,7 @@ import { 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 fs from 'node:fs/promises'; @@ -30,11 +31,21 @@ function readIntegerEnv(name, defaultValue, min) { return value; } +function readBooleanEnv(name, defaultValue) { + const rawValue = process.env[name]; + if (rawValue == null || rawValue === '') return defaultValue; + if (rawValue === '1' || rawValue === 'true') return true; + if (rawValue === '0' || rawValue === 'false') return false; + throw new Error(`${name} must be one of: 1, 0, true, false`); +} + const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0); +const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false); +const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1); const procStatusKeys = { VmPeak: 0, @@ -74,6 +85,45 @@ const memoryKeys = { const phases = ['beforeGc', 'afterGc', 'afterRequest']; +const heapSnapshotCategories = [ + 'Code', + 'Strings', + 'JS arrays', + 'Typed arrays', + 'System objects', + 'Other JS objects', + 'Other non-JS objects', + 'Total', +]; + +const typedArrayNames = new Set([ + 'ArrayBuffer', + 'SharedArrayBuffer', + 'DataView', + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array', + 'Float16Array', + 'Float32Array', + 'Float64Array', + 'BigInt64Array', + 'BigUint64Array', + 'system / JSArrayBufferData', +]); + +const otherJsNodeTypes = new Set([ + 'object', + 'closure', + 'regexp', + 'number', + 'symbol', + 'bigint', +]); + function parseMemoryFile(content, keys, path, required) { const result = {}; for (const key of Object.keys(keys)) { @@ -91,6 +141,76 @@ function bytesToKiB(value) { return Math.round(value / 1024); } +function createEmptyHeapSnapshotCategoryMap() { + return Object.fromEntries(heapSnapshotCategories.map(category => [category, 0])); +} + +function isTypedArrayNode(type, name) { + return typedArrayNames.has(name) || + (type === 'native' && (name.includes('ArrayBuffer') || name.includes('TypedArray'))); +} + +function isSystemNode(type, name) { + return type === 'hidden' || + type === 'synthetic' || + type === 'object shape' || + name.startsWith('system /') || + name.startsWith('(system '); +} + +function classifyHeapSnapshotNode(type, name) { + if (type === 'code') return 'Code'; + if (type === 'string' || type === 'concatenated string' || type === 'sliced string') return 'Strings'; + if (isTypedArrayNode(type, name)) return 'Typed arrays'; + if (type === 'array' || (type === 'object' && name === 'Array')) return 'JS arrays'; + if (isSystemNode(type, name)) return 'System objects'; + if (otherJsNodeTypes.has(type)) return 'Other JS objects'; + return 'Other non-JS objects'; +} + +function analyzeHeapSnapshot(snapshot) { + const meta = snapshot?.snapshot?.meta; + const nodes = snapshot?.nodes; + const strings = snapshot?.strings; + if (meta == null || !Array.isArray(nodes) || !Array.isArray(strings)) { + throw new Error('Invalid heap snapshot format'); + } + + const nodeFields = meta.node_fields; + if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); + + const typeOffset = nodeFields.indexOf('type'); + const nameOffset = nodeFields.indexOf('name'); + const selfSizeOffset = nodeFields.indexOf('self_size'); + if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0) { + throw new Error('Heap snapshot is missing required node fields'); + } + + const nodeTypeNames = meta.node_types?.[typeOffset]; + if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); + + const fieldCount = nodeFields.length; + const categories = createEmptyHeapSnapshotCategoryMap(); + const nodeCounts = createEmptyHeapSnapshotCategoryMap(); + + for (let offset = 0; offset < nodes.length; offset += fieldCount) { + const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown'; + const name = strings[nodes[offset + nameOffset]] ?? ''; + const selfSize = nodes[offset + selfSizeOffset] ?? 0; + const category = classifyHeapSnapshotNode(type, name); + + categories[category] += selfSize; + categories.Total += selfSize; + nodeCounts[category]++; + nodeCounts.Total++; + } + + return { + categories, + nodeCounts, + }; +} + async function getMemoryUsage(pid) { const path = `/proc/${pid}/status`; const status = await fs.readFile(path, 'utf-8'); @@ -150,6 +270,39 @@ async function getRuntimeMemoryUsage(serverProcess) { }; } +async function getHeapSnapshotStatistics(serverProcess) { + if (!HEAP_SNAPSHOT) return null; + + const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`); + const response = waitForMessage( + serverProcess, + message => message != null && typeof message === 'object' && (message.type === 'heap snapshot' || message.type === 'heap snapshot error'), + 'heap snapshot', + HEAP_SNAPSHOT_TIMEOUT, + ); + + serverProcess.send({ + type: 'heap snapshot', + path: snapshotPath, + }); + + const message = await response; + if (message.type === 'heap snapshot error') { + throw new Error(`Failed to write heap snapshot: ${message.message}`); + } + + const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath; + + try { + const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8')); + return analyzeHeapSnapshot(snapshot); + } finally { + await fs.unlink(writtenPath).catch(err => { + process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`); + }); + } +} + async function getAllMemoryUsage(serverProcess) { const pid = serverProcess.pid; return { @@ -180,6 +333,31 @@ function summarizeResults(results) { 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) { + summary[phase].heapSnapshot = { + categories: heapSnapshotCategoryValues, + nodeCounts: heapSnapshotNodeCountValues, + }; + } } return summary; @@ -290,6 +468,8 @@ async function measureMemory() { await triggerGc(); const afterRequest = await getAllMemoryUsage(serverProcess); + const heapSnapshot = await getHeapSnapshotStatistics(serverProcess); + if (heapSnapshot != null) afterRequest.heapSnapshot = heapSnapshot; // Stop the server serverProcess.kill('SIGTERM'); @@ -339,6 +519,10 @@ async function main() { memorySettleTimeMs: MEMORY_SETTLE_TIME, ipcTimeoutMs: IPC_TIMEOUT, requestCount: REQUEST_COUNT, + heapSnapshot: { + enabled: HEAP_SNAPSHOT, + timeoutMs: HEAP_SNAPSHOT_TIMEOUT, + }, }, ...summary, samples: results, diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index b5f258e9ee..9fcb4d73ca 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -9,6 +9,7 @@ import cluster from 'node:cluster'; import { EventEmitter } from 'node:events'; +import { writeHeapSnapshot } from 'node:v8'; import chalk from 'chalk'; import Xev from 'xev'; import Logger from '@/logger.js'; @@ -106,6 +107,21 @@ process.on('message', msg => { value: process.memoryUsage(), }); } + } else if (msg != null && typeof msg === 'object' && 'type' in msg && msg.type === 'heap snapshot' && 'path' in msg && typeof msg.path === 'string') { + if (process.send != null) { + try { + const path = writeHeapSnapshot(msg.path); + process.send({ + type: 'heap snapshot', + path, + }); + } catch (err) { + process.send({ + type: 'heap snapshot error', + message: err instanceof Error ? err.message : String(err), + }); + } + } } });