1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-26 04:25:14 +02:00

refactor(gh): CI用スクリプトをpackageとして整理 (#17727)

* refactor(gh): CI用スクリプトをpackageとして整理

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* remove old scripts

* migrate

* refactor 1

* refactor 2

* fix comment

* fix

* fix

* fix

* fix

* remove vite-node from changelog-checker

* fix lint

* fix

* refactor

* update deps

* fix

* spec: rename packages
This commit is contained in:
かっこかり
2026-07-20 20:09:22 +09:00
committed by GitHub
parent 7157f37011
commit ab369784fb
101 changed files with 8111 additions and 6707 deletions

View File

@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
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 readBooleanEnv(name: string, defaultValue: boolean) {
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`);
}
/**
* 必須の環境変数を読む。未設定・空文字ならエラーにする。
*/
export function readRequiredEnv(name: string) {
const rawValue = process.env[name]?.trim();
if (rawValue == null || rawValue === '') throw new Error(`${name} must be set`);
return rawValue;
}
/**
* 任意の環境変数を読む。未設定・空文字なら null を返す。
*/
export function readOptionalEnv(name: string) {
const rawValue = process.env[name]?.trim();
if (rawValue == null || rawValue === '') return null;
return rawValue;
}

View File

@@ -0,0 +1,116 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
const numberFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 1,
});
export function escapeLatex(text: string) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
export function escapeMdTableCell(value: string) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
export function escapeHtml(value: unknown) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&#39;');
}
export function formatNumber(value: number) {
return numberFormatter.format(value);
}
export function formatBytes(value: number) {
if (value === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let size = value;
while (size >= 1000 && unitIndex < units.length - 1) {
size /= 1000;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
/**
* KiB単位の値をMB表記にする。/proc 由来の値の表示に使う。
*/
export function formatKiBAsMb(valueKiB: number | null | undefined) {
if (valueKiB == null || !Number.isFinite(valueKiB)) return '-';
return `${formatNumber(valueKiB / 1000)} MB`;
}
export function formatPercent(value: number) {
return `${formatNumber(value)}%`;
}
export function formatMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '-';
if (value >= 1_000) return `${formatNumber(value / 1_000)} s`;
return `${formatNumber(value)} ms`;
}
export function formatSecondsAsMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '-';
return formatMs(value * 1_000);
}
/**
* 差分値を符号付きで整形する。`colorThreshold` 以上の変化があるときだけ色を付ける。
*/
export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) {
if (delta === 0) return text(0);
const sign = delta > 0 ? '+' : '-';
if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`;
const color = delta > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`;
}
export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) {
return formatColoredDelta(deltaBytes, formatBytes, colorThreshold);
}
export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) {
return formatColoredDelta(deltaPercent, formatPercent, colorThreshold);
}
/**
* Markdownのテーブルセル内に置く差分パーセント。
* LaTeX由来の `\%` がMarkdownのエスケープで食われるため二重エスケープする。
*/
export function formatDeltaPercentInMdTable(deltaPercent: number, colorThreshold = 0) {
return formatDeltaPercent(deltaPercent, colorThreshold).replaceAll('\\%', '\\\\%');
}
export function calcAndFormatDeltaNumber(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) {
if (before == null || after == null) return '-';
return formatColoredDelta(after - before, formatNumber, colorThreshold);
}
export function calcAndFormatDeltaBytes(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) {
if (before == null || after == null) return '-';
return formatDeltaBytes(after - before, colorThreshold);
}
export function calcAndFormatDeltaPercent(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) {
if (before == null || before === 0 || after == null) return '-';
return formatDeltaPercent((after - before) / before * 100, colorThreshold);
}
export function calcAndFormatDeltaPercentInMdTable(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) {
return calcAndFormatDeltaPercent(before, after, colorThreshold).replaceAll('\\%', '\\\\%');
}

View File

@@ -0,0 +1,198 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { classifyHeapSnapshotBreakdown, collapseHeapSnapshotBreakdowns } from './breakdown';
import {
createEmptyHeapSnapshotData,
heapSnapshotBreakdownCategories,
type HeapSnapshotCategory,
type HeapSnapshotData,
} from './categories';
/**
* `.heapsnapshot` のJSONを、フィールドオフセットを解決した状態で扱うためのビュー。
*/
type HeapSnapshotView = {
nodes: number[];
edges: number[];
strings: string[];
nodeFieldCount: number;
edgeFieldCount: number;
nodeTypeNames: string[];
edgeTypeNames: string[];
typeOffset: number;
nameOffset: number;
selfSizeOffset: number;
edgeCountOffset: number;
edgeTypeOffset: number;
edgeNameOffset: number;
edgeToNodeOffset: number;
extraNativeBytes: number;
};
function requireOffsets(fields: string[], names: string[], what: string) {
const offsets = names.map(name => fields.indexOf(name));
if (offsets.some(offset => offset < 0)) throw new Error(`Heap snapshot is missing required ${what} fields`);
return offsets;
}
function parseHeapSnapshot(snapshot: any): HeapSnapshotView {
const meta = snapshot?.snapshot?.meta;
const { nodes, edges, strings } = snapshot ?? {};
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !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 edgeFields = meta.edge_fields;
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
const [typeOffset, nameOffset, selfSizeOffset, edgeCountOffset] = requireOffsets(nodeFields, ['type', 'name', 'self_size', 'edge_count'], 'node');
const [edgeTypeOffset, edgeNameOffset, edgeToNodeOffset] = requireOffsets(edgeFields, ['type', 'name_or_index', 'to_node'], 'edge');
const nodeTypeNames = meta.node_types?.[typeOffset];
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
return {
nodes,
edges,
strings,
nodeFieldCount: nodeFields.length,
edgeFieldCount: edgeFields.length,
nodeTypeNames,
edgeTypeNames,
typeOffset,
nameOffset,
selfSizeOffset,
edgeCountOffset,
edgeTypeOffset,
edgeNameOffset,
edgeToNodeOffset,
extraNativeBytes: Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0,
};
}
/**
* ードごとのedge開始位置と、各ードが何本のedgeから参照されているかを求める。
* JS配列のelementsストアが専有されているか判定するために使う。
*/
function indexEdges(view: HeapSnapshotView) {
const edgeStartIndexes = new Map<number, number>();
const retainerCounts = new Map<number, number>();
let edgeIndex = 0;
for (let nodeIndex = 0; nodeIndex < view.nodes.length; nodeIndex += view.nodeFieldCount) {
edgeStartIndexes.set(nodeIndex, edgeIndex);
const edgeCount = view.nodes[nodeIndex + view.edgeCountOffset] ?? 0;
for (let i = 0; i < edgeCount; i++, edgeIndex += view.edgeFieldCount) {
const toNodeIndex = view.edges[edgeIndex + view.edgeToNodeOffset];
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
}
}
return { edgeStartIndexes, retainerCounts };
}
export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData {
const view = parseHeapSnapshot(snapshot);
const { nodes, edges, strings, nodeFieldCount, edgeFieldCount, nodeTypeNames, edgeTypeNames } = view;
const nativeType = nodeTypeNames.indexOf('native');
const codeType = nodeTypeNames.indexOf('code');
const hiddenType = nodeTypeNames.indexOf('hidden');
const stringTypes = new Set([
nodeTypeNames.indexOf('string'),
nodeTypeNames.indexOf('concatenated string'),
nodeTypeNames.indexOf('sliced string'),
]);
const internalEdgeType = edgeTypeNames.indexOf('internal');
const { categories, nodeCounts } = createEmptyHeapSnapshotData();
const breakdowns = {} as Record<HeapSnapshotCategory, Record<string, number>>;
for (const category of heapSnapshotBreakdownCategories) {
breakdowns[category] = {};
}
const { edgeStartIndexes, retainerCounts } = indexEdges(view);
const jsArrayElementNodeIndexes = new Set<number>();
function addCategoryValue(category: HeapSnapshotCategory, value: number, type: string, name: string, counted = true) {
if (value <= 0) return;
categories[category] += value;
const label = classifyHeapSnapshotBreakdown(category, type, name);
breakdowns[category][label] = (breakdowns[category][label] ?? 0) + value;
if (counted) nodeCounts[category]++;
}
/**
* 配列オブジェクト自身のself_sizeには要素ストアが含まれないため、
* その配列だけが参照しているelementsードの分を JS arrays に加算する。
*/
function addJsArrayElementSize(nodeIndex: number) {
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
const edgeCount = nodes[nodeIndex + view.edgeCountOffset] ?? 0;
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
if (edges[currentEdgeIndex + view.edgeTypeOffset] !== internalEdgeType) continue;
if (strings[edges[currentEdgeIndex + view.edgeNameOffset]] !== 'elements') continue;
const elementsNodeIndex = edges[currentEdgeIndex + view.edgeToNodeOffset];
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
addCategoryValue('jsArrays', nodes[elementsNodeIndex + view.selfSizeOffset] ?? 0, 'array elements', 'Array elements');
jsArrayElementNodeIndexes.add(elementsNodeIndex);
}
break;
}
}
if (view.extraNativeBytes > 0) {
addCategoryValue('otherNonJsObjects', view.extraNativeBytes, 'extra native bytes', 'extra native bytes', false);
}
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
const typeId = nodes[nodeIndex + view.typeOffset];
const type = nodeTypeNames[typeId] ?? 'unknown';
const name = strings[nodes[nodeIndex + view.nameOffset]] ?? '';
const selfSize = nodes[nodeIndex + view.selfSizeOffset] ?? 0;
categories.total += selfSize;
nodeCounts.total++;
if (typeId === hiddenType) {
addCategoryValue('systemObjects', selfSize, type, name);
} else if (typeId === nativeType) {
addCategoryValue(name === 'system / JSArrayBufferData' ? 'typedArrays' : 'otherNonJsObjects', selfSize, type, name);
} else if (typeId === codeType) {
addCategoryValue('code', selfSize, type, name);
} else if (stringTypes.has(typeId)) {
addCategoryValue('strings', selfSize, type, name);
} else if (name === 'Array') {
addCategoryValue('jsArrays', selfSize, type, name);
addJsArrayElementSize(nodeIndex);
}
}
categories.total += view.extraNativeBytes;
// 上のループで JS arrays に計上したelementsードが確定してから、残りを Other JS objects に振り分ける
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
const typeId = nodes[nodeIndex + view.typeOffset];
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
const name = strings[nodes[nodeIndex + view.nameOffset]] ?? '';
if (name === 'Array') continue;
addCategoryValue('otherJsObjects', nodes[nodeIndex + view.selfSizeOffset] ?? 0, nodeTypeNames[typeId] ?? 'unknown', name);
}
return {
categories,
nodeCounts,
breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN),
};
}

View File

@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
defaultHeapSnapshotBreakdownTopN,
heapSnapshotBreakdownCategories,
type HeapSnapshotCategory,
type HeapSnapshotData,
} from './categories';
function sanitizeLabel(value: unknown, fallback = 'unknown') {
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
if (label === '') return fallback;
if (label.length <= 80) return label;
return `${label.slice(0, 77)}...`;
}
/**
* ノードの type / name から、内訳テーブルに出す粒度のラベルを決める。
*/
export function classifyHeapSnapshotBreakdown(category: HeapSnapshotCategory, type: string, name: string) {
switch (category) {
case 'strings':
return type;
case 'jsArrays':
if (type === 'array elements') return 'Array elements';
if (type === 'object' && name === 'Array') return 'Array objects';
return sanitizeLabel(`${type}: ${name}`);
case 'typedArrays':
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
return sanitizeLabel(`${type}: ${name}`);
case 'systemObjects':
if (name.startsWith('system /') || name.startsWith('(system ')) return sanitizeLabel(name);
return sanitizeLabel(`${type}: ${name}`, type);
case 'otherJsObjects':
if (type === 'object') return sanitizeLabel(`object: ${name}`, 'object: unknown');
return type;
case 'otherNonJsObjects':
if (type === 'extra native bytes') return 'Extra native bytes';
if (type === 'native') return sanitizeLabel(`native: ${name}`, 'native: unknown');
return sanitizeLabel(`${type}: ${name}`, type);
case 'code': {
const lowerName = name.toLowerCase();
if (lowerName.includes('bytecode')) return 'bytecode';
if (lowerName.includes('builtin')) return 'builtins';
if (lowerName.includes('regexp')) return 'regexp code';
if (lowerName.includes('stub')) return 'stubs';
return sanitizeLabel(`code: ${name}`, 'code: unknown');
}
default:
return sanitizeLabel(`${type}: ${name}`, type);
}
}
/**
* 内訳を上位N件に丸め、残りを `Other` にまとめる。
*/
export function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>, topN = defaultHeapSnapshotBreakdownTopN) {
const entries = Object.entries(breakdown)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);
const collapsed = Object.fromEntries(entries.slice(0, topN));
const otherValue = entries.slice(topN).reduce((sum, [, value]) => sum + value, 0);
if (otherValue > 0) collapsed.Other = otherValue;
return collapsed;
}
export function collapseHeapSnapshotBreakdowns(
breakdowns: Partial<Record<HeapSnapshotCategory, Record<string, number>>>,
topN = defaultHeapSnapshotBreakdownTopN,
) {
const collapsed: NonNullable<HeapSnapshotData['breakdowns']> = {};
for (const category of heapSnapshotBreakdownCategories) {
const categoryBreakdown = breakdowns[category];
if (categoryBreakdown == null) continue;
const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN);
if (Object.keys(collapsedCategory).length > 0) {
collapsed[category] = collapsedCategory;
}
}
return collapsed;
}

View File

@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// Chrome DevTools の heap snapshot Statistics ビューと同じ分類になるように保つこと。
export const heapSnapshotCategory = {
total: { label: 'Total', color: 'gray', colorHex: '#888888' },
code: { label: 'Code', color: 'orange', colorHex: '#f28e2c' },
strings: { label: 'Strings', color: 'red', colorHex: '#e15759' },
jsArrays: { label: 'JS arrays', color: 'cyan', colorHex: '#76b7b2' },
typedArrays: { label: 'Typed arrays', color: 'green', colorHex: '#59a14f' },
systemObjects: { label: 'System objects', color: 'yellow', colorHex: '#edc949' },
otherJsObjects: { label: 'Other JS objs', color: 'violet', colorHex: '#af7aa1' },
otherNonJsObjects: { label: 'Other non-JS objs', color: 'pink', colorHex: '#ff9da7' },
} as const satisfies Record<string, { label: string; color: string; colorHex: string }>;
export type HeapSnapshotCategory = keyof typeof heapSnapshotCategory;
export const heapSnapshotCategories = Object.keys(heapSnapshotCategory) as HeapSnapshotCategory[];
/** `total` は他カテゴリの合算ではなく全体量なので、内訳を扱うときは除外する */
export const heapSnapshotBreakdownCategories = heapSnapshotCategories.filter(category => category !== 'total');
export type HeapSnapshotData = {
categories: Record<HeapSnapshotCategory, number>;
nodeCounts: Record<HeapSnapshotCategory, number>;
/** 内訳が空でないカテゴリだけが入る (`total` は内訳を持たない) */
breakdowns?: Partial<Record<HeapSnapshotCategory, Record<string, number>>>;
};
export type HeapSnapshotReport = {
summary: HeapSnapshotData;
samples: {
round: number;
data: HeapSnapshotData;
}[];
};
export const defaultHeapSnapshotBreakdownTopN = 6;
export function createEmptyHeapSnapshotData(): HeapSnapshotData {
const categories = {} as HeapSnapshotData['categories'];
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
for (const category of heapSnapshotCategories) {
categories[category] = 0;
nodeCounts[category] = 0;
}
return {
categories,
nodeCounts,
breakdowns: {},
};
}

View File

@@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export * from './analyze';
export * from './breakdown';
export * from './categories';
export * from './render';
export * from './summarize';

View File

@@ -0,0 +1,177 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
formatBytes,
formatDeltaBytes,
formatDeltaPercentInMdTable,
formatPercent,
} from '../format';
import { mad, pairedDeltaSummary } from '../stats';
import {
heapSnapshotCategories,
heapSnapshotCategory,
type HeapSnapshotCategory,
type HeapSnapshotReport,
} from './categories';
/** これ未満のバイト差分には色を付けない (0.1 MB) */
const byteColorThreshold = 100_000;
function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategory) {
return report.summary.categories[category];
}
function categorySampleSpread(report: HeapSnapshotReport, category: HeapSnapshotCategory) {
const values = report.samples
.map(sample => sample.data.categories[category])
.filter(value => Number.isFinite(value));
if (values.length < 2) throw new Error(`Not enough samples for category ${category}`);
return mad(values);
}
function swatch(category: HeapSnapshotCategory) {
return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`;
}
/**
* base / head のheap snapshotをカテゴリ別に比較するMarkdownテーブルを描画する。
*/
export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) {
const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
const baseTotal = categoryValue(base, 'total');
const headTotal = categoryValue(head, 'total');
for (const category of heapSnapshotCategories) {
const baseValue = categoryValue(base, category);
const headValue = categoryValue(head, category);
const summary = pairedDeltaSummary(base.samples, head.samples, sample => sample.data.categories[category]);
const deltaColumns = `${formatBytes(summary.mad)} | ${formatDeltaBytes(summary.min, byteColorThreshold)} | ${formatDeltaBytes(summary.max, byteColorThreshold)}`;
if (category === 'total') {
// Totalだけはばらつきと変化率も併記する
const percent = summary.median * 100 / baseValue;
const deltaMedian = `${formatDeltaBytes(summary.median, byteColorThreshold)}<br>${formatDeltaPercentInMdTable(percent, 0.1)}`;
const baseText = `${formatBytes(baseValue)} <br> ± ${formatBytes(categorySampleSpread(base, category))}`;
const headText = `${formatBytes(headValue)} <br> ± ${formatBytes(categorySampleSpread(head, category))}`;
lines.push(`| ${swatch(category)} | ${baseText} | ${headText} | ${deltaMedian} | ${deltaColumns} |`);
lines.push('| | | | | | | |');
} else {
// 各カテゴリはTotalに占める割合の推移をdetailsで見せる
const basePercent = formatPercent((baseValue * 100) / baseTotal);
const headPercent = formatPercent((headValue * 100) / headTotal);
const metricText = `<details><summary>${swatch(category)}</summary>${basePercent}${headPercent}</details>`;
lines.push(`| ${metricText} | ${formatBytes(baseValue)} | ${formatBytes(headValue)} | ${formatDeltaBytes(summary.median, byteColorThreshold)} | ${deltaColumns} |`);
}
}
if (lines.length === 2) return null;
return lines.join('\n');
}
const sankeyChildMinRatio = 0.3;
const sankeyParentMinPercent = 10;
function escapeCsvValue(value: string) {
return `"${String(value).replaceAll('"', '""')}"`;
}
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(/\.$/, '');
}
/**
* heap snapshotの構成比をmermaidのsankey図として描画する。
* 全体に占める割合が小さいカテゴリ・内訳は `Other` にまとめる。
*/
export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) {
const total = categoryValue(report, 'total');
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (total == null || total <= 0) return null;
const categories = heapSnapshotCategories
.filter(category => category !== 'total')
.map(category => {
const value = categoryValue(report, category);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (value == null || value <= 0) return null;
const breakdownEntries = Object.entries(report.summary.breakdowns?.[category] ?? {})
.filter(([, childValue]) => Number.isFinite(childValue) && childValue > 0)
.toSorted((a, b) => b[1] - a[1]);
const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0);
const percent = (value * 100) / total;
const childEntries: [string, number][] = [];
let otherPercent = 0;
if (breakdownTotal > 0 && percent > sankeyParentMinPercent) {
for (const [childName, childValue] of breakdownEntries) {
const childRatio = childValue / breakdownTotal;
if (childRatio >= sankeyChildMinRatio) {
childEntries.push([childName.replace(/^[^:]+:\s*/, ''), percent * childRatio]);
} else {
otherPercent += percent * childRatio;
}
}
if (childEntries.length > 0 && otherPercent > 0) {
childEntries.push(['Other', otherPercent]);
}
}
return { category, percent, childEntries };
})
.filter(value => value != null);
if (categories.length === 0) return null;
const nodeColors: Record<string, string> = {
[title]: heapSnapshotCategory.total.colorHex,
Other: '#888888',
};
for (const { category, childEntries } of categories) {
nodeColors[category] = heapSnapshotCategory[category].colorHex;
for (const [childName] of childEntries) {
nodeColors[childName] = heapSnapshotCategory[category].colorHex;
}
}
const lines = [
`<details><summary>${title} heap snapshot composition</summary>`,
'',
'```mermaid',
`%%{init: ${JSON.stringify({
sankey: {
showValues: false,
linkColor: 'target',
labelStyle: 'outlined',
nodeAlignment: 'center',
nodePadding: 10,
nodeColors,
},
})}}%%`,
'sankey-beta',
];
for (const { category, percent, childEntries } of categories) {
const categoryLabel = heapSnapshotCategory[category].label;
lines.push(`${escapeCsvValue(title)},${escapeCsvValue(categoryLabel)},${formatSankeyPercentValue(percent)}`);
for (const [childName, childPercent] of childEntries) {
lines.push(`${escapeCsvValue(categoryLabel)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`);
}
}
lines.push('```', '', '</details>');
return lines.join('\n');
}

View File

@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { finiteMedian } from '../stats';
import { collapseHeapSnapshotBreakdown } from './breakdown';
import {
heapSnapshotBreakdownCategories,
heapSnapshotCategories,
type HeapSnapshotCategory,
type HeapSnapshotData,
} from './categories';
function isComplete(values: Partial<Record<HeapSnapshotCategory, number>>): values is Record<HeapSnapshotCategory, number> {
return heapSnapshotCategories.every(category => values[category] != null);
}
/**
* 複数ラウンド分のheap snapshotを、カテゴリ・内訳ごとの中央値にまとめる。
* 全カテゴリ分の値が揃わなければ null を返す。
*/
export function summarizeHeapSnapshotDataSamples<T>(
samples: T[],
getData: (sample: T) => HeapSnapshotData | null | undefined,
options: { breakdownTopN?: number } = {},
) {
const data = samples.map(getData);
const categories: Partial<HeapSnapshotData['categories']> = {};
const nodeCounts: Partial<HeapSnapshotData['nodeCounts']> = {};
for (const category of heapSnapshotCategories) {
const categoryValue = finiteMedian(data.map(snapshot => snapshot?.categories?.[category]));
if (categoryValue != null) categories[category] = categoryValue;
const nodeCountValue = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category]));
if (nodeCountValue != null) nodeCounts[category] = nodeCountValue;
}
// 一部のカテゴリだけ欠けた状態で返すと、呼び出し側が完全な値として扱って
// undefined を描画してしまう。全カテゴリ揃っていなければサマリ自体を無しとする
if (!isComplete(categories) || !isComplete(nodeCounts)) return null;
const breakdowns: NonNullable<HeapSnapshotData['breakdowns']> = {};
for (const category of heapSnapshotBreakdownCategories) {
const childKeys = new Set(data.flatMap(snapshot => Object.keys(snapshot?.breakdowns?.[category] ?? {})));
const categoryBreakdown = {} as Record<string, number>;
for (const childKey of childKeys) {
const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey]));
if (value != null) categoryBreakdown[childKey] = value;
}
const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN);
if (Object.keys(collapsed).length > 0) breakdowns[category] = collapsed;
}
return {
categories,
nodeCounts,
...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}),
} satisfies HeapSnapshotData;
}

View File

@@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { escapeHtml } from './format';
/**
* エスケープ済み、あるいは意図的にエスケープしない生のHTML断片。
*
* ただの文字列と型で区別するためだけの存在ではなく、`html` が実行時に
* 「この値はもうエスケープしなくてよい」と判定するためのマーカーでもある。
* 型だけのブランドにすると実行時に判定できず、結局エスケープ漏れを防げない。
*/
export class Raw {
constructor(private readonly value: string) {}
toString() {
return this.value;
}
}
/**
* 文字列をエスケープせずそのまま埋め込む。
* 呼び出しが差分に残るので、レビューで「なぜ生で入れてよいのか」を確認できる。
*/
export function raw(value: string) {
return new Raw(value);
}
function interpolate(value: unknown): string {
if (value instanceof Raw) return value.toString();
// 配列をそのまま文字列化するとカンマ区切りで潰れるので、要素ごとに処理する
if (Array.isArray(value)) return value.map(interpolate).join('');
if (value == null) return '';
return escapeHtml(value);
}
/**
* HTMLを組み立てるタグ付きテンプレート。補間値は既定でエスケープされる。
* エスケープしたくない場合は `raw()` で包む必要があるため、
* 「うっかり生のまま埋め込む」ことが起きない。
*/
export function html(strings: TemplateStringsArray, ...values: unknown[]) {
let result = strings[0];
for (let i = 0; i < values.length; i++) {
result += interpolate(values[i]) + strings[i + 1];
}
return new Raw(result);
}
/**
* 断片を指定の区切り文字で連結する。
* `html` の配列補間は区切り無しで繋ぐので、改行などを挟みたいときはこちらを使う。
*/
export function joinHtml(parts: readonly Raw[], separator: string) {
return new Raw(parts.map(part => part.toString()).join(separator));
}

View File

@@ -0,0 +1,84 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
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) throw new Error('Not enough samples to calculate MAD');
const center = median(values);
return median(values.map(value => Math.abs(value - center)));
}
/**
* 有限値のみを対象に中央値を求める。有限値が1つも無い場合は `defaultValue` を返す。
*/
export function finiteMedian(values: (number | null | undefined)[]): number | null;
export function finiteMedian(values: (number | null | undefined)[], defaultValue: number): number;
export function finiteMedian(values: (number | null | undefined)[], defaultValue: number | null = null) {
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
if (finiteValues.length === 0) return defaultValue;
return median(finiteValues);
}
/**
* サンプルのばらつき (MAD) を求める。サンプルが2つ未満で求められない場合は null を返す。
*/
export function sampleSpread(values: (number | null | undefined)[]) {
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
if (finiteValues.length < 2) return null;
return mad(finiteValues);
}
type RoundedSample = { round: number };
function indexByRound<T extends RoundedSample>(samples: T[]) {
const samplesByRound = new Map<number, T>();
for (const sample of samples) {
// 負のroundはwarmupを表すため対象外
if (sample.round <= 0) continue;
samplesByRound.set(sample.round, sample);
}
return samplesByRound;
}
/**
* base / head を同じroundどうしで突き合わせ、その差分の分布を要約する。
* 実行順による揺らぎの影響を抑えるため、単純な集計値どうしの引き算ではなくペア差分を使う。
*/
export function pairedDeltaSummary<T extends RoundedSample>(baseSamples: T[], headSamples: T[], getValue: (sample: T) => number | null | undefined) {
const baseSamplesByRound = indexByRound(baseSamples);
const headSamplesByRound = indexByRound(headSamples);
const values: number[] = [];
for (const [round, baseSample] of baseSamplesByRound) {
const headSample = headSamplesByRound.get(round);
if (headSample == null) continue;
const baseValue = getValue(baseSample);
const headValue = getValue(headSample);
if (baseValue == null || headValue == null) continue;
values.push(headValue - baseValue);
}
// 対応するroundが1つも無いと中央値も最小/最大も定義できない。
// 静かにNaNやInfinityをレポートに載せるより、比較が成立していないと分かる形で落とす
if (values.length === 0) throw new Error('No paired samples to compare: base and head have no rounds in common');
return {
median: median(values),
// 1サンプルでは中央値からの偏差が常に0になる (mad() は統計として無意味なので拒否する)
mad: values.length < 2 ? 0 : mad(values),
min: Math.min(...values),
max: Math.max(...values),
samples: values.length,
};
}