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

enhance(gh): 複数サンプルの値の表示を改善するなど (#17759)

* docs: design backend diagnostics memory noise handling

* chore(gh): tweak workflow

* docs: clarify shared heap snapshot reporting

* docs: plan backend diagnostics memory noise handling

* chore: ignore local worktrees

* feat(diagnostics): add independent delta summary

* feat(diagnostics): make heap snapshot deltas noise-aware

* docs: correct heap snapshot threshold plan

* fix(diagnostics): suppress backend memory noise

* ci: refine backend diagnostics sampling

* test(diagnostics): address final review

* wip

* Update heap-snapshot-sampling.ts

* wip

* wip

* docs: design frontend metric noise reporting

* docs: plan frontend metric noise reporting

* fix(diagnostics): suppress frontend metric noise

* docs: remove temporary frontend metric plans

* test(diagnostics): cover frontend metric thresholds

* docs: design shared diagnostics metric table

* docs: plan shared diagnostics metric table

* refactor(diagnostics): simplify independent delta statistics

* feat(diagnostics): add shared metric table renderer

* refactor(diagnostics): use shared heap snapshot table

* refactor(diagnostics): use shared backend metric table

* refactor(diagnostics): use shared frontend metric table

* docs: remove temporary metric table plans

* tweak

* docs: design metric table no-data output

* fix(diagnostics): show marker for empty metric tables

* chore: remove temporary diagnostics design
This commit is contained in:
syuilo
2026-07-21 22:43:22 +09:00
committed by GitHub
parent 41e730a763
commit 98ae62c432
18 changed files with 1108 additions and 273 deletions

View File

@@ -89,9 +89,10 @@ jobs:
- name: Measure backend memory usage
working-directory: head
env:
MK_MEMORY_COMPARE_ROUNDS: 10
MK_MEMORY_COMPARE_ROUNDS: 15
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
MK_MEMORY_HEAP_SNAPSHOT: 1
MK_MEMORY_HEAP_SNAPSHOT_ROUNDS: 3
# 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す
MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }}
run: >-

View File

@@ -6,10 +6,15 @@
import { copyFile, rm, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { execa } from 'execa';
import { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env';
import { readBooleanEnv, readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env';
import { median } from 'diagnostics-shared/stats';
import { summarizeHeapSnapshotDataSamples, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot';
import { resetState } from './db';
import {
clampHeapSnapshotRounds,
selectRepresentativeHeapSnapshotRound,
shouldCollectHeapSnapshot,
} from './heap-snapshot-sampling';
import { measureBackendMemory } from './measure';
import { memoryPhases, type MemoryReport } from './types';
@@ -67,7 +72,12 @@ export function summarizeSamples(samples: MemoryReport['samples']) {
return summary;
}
async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) {
type GenSampleOptions = {
collectHeapSnapshot?: boolean;
heapSnapshotSavePath?: string;
};
async function genSample(label: string, repoDir: string, round: number, options: GenSampleOptions = {}) {
process.stderr.write(`[${label}] Resetting database and Redis\n`);
await resetState();
@@ -82,7 +92,7 @@ async function genSample(label: string, repoDir: string, round: number, options:
process.stderr.write(`[${label}] Measuring memory\n`);
return await measureBackendMemory(resolve(repoDir, 'packages/backend'), {
// warmupラウンド (round <= 0) は捨てるので、重いheap snapshotは取らない
...(round <= 0 ? { heapSnapshot: false } : {}),
...(round <= 0 || options.collectHeapSnapshot === false ? { heapSnapshot: false } : {}),
heapSnapshotSavePath: options.heapSnapshotSavePath ?? null,
});
}
@@ -91,32 +101,12 @@ function heapSnapshotPath(label: HeapSnapshotLabel, round: number) {
return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`);
}
/**
* 中央値に最も近いラウンドを代表として選ぶ。外れ値のスナップショットを成果物にしないため。
*/
function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
const medianTotal = summary.afterGc.heapSnapshot?.categories.total;
if (medianTotal == null || !Number.isFinite(medianTotal)) return null;
let selected: { round: number; distance: number } | null = null;
for (const sample of samples) {
const total = sample.phases.afterGc.heapSnapshot?.categories.total;
if (total == null || !Number.isFinite(total)) continue;
const distance = Math.abs(total - medianTotal);
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) {
selected = {
round: sample.round,
distance,
};
}
}
return selected?.round ?? null;
}
async function saveRepresentativeHeapSnapshot(label: HeapSnapshotLabel, samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
const round = selectRepresentativeHeapSnapshotRound(samples, summary);
const round = selectRepresentativeHeapSnapshotRound(
samples,
summary.afterGc.heapSnapshot?.categories.total,
sample => sample.phases.afterGc.heapSnapshot?.categories.total,
);
if (round == null) return;
await copyFile(heapSnapshotPath(label, round), HEAP_SNAPSHOT_OUTPUT_PATHS[label]);
@@ -131,6 +121,11 @@ async function saveRepresentativeHeapSnapshot(label: HeapSnapshotLabel, samples:
export async function compareBackendMemory(options: CompareOptions) {
const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
const heapSnapshotsEnabled = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
const requestedHeapSnapshotRounds = heapSnapshotsEnabled
? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_ROUNDS', rounds, 1)
: 0;
const heapSnapshotRounds = clampHeapSnapshotRounds(rounds, requestedHeapSnapshotRounds);
const startedAt = new Date().toISOString();
for (const label of heapSnapshotLabels) {
@@ -161,7 +156,11 @@ export async function compareBackendMemory(options: CompareOptions) {
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
for (const label of order) {
const sample = await genSample(label, reports[label].dir, round, { heapSnapshotSavePath: heapSnapshotPath(label, round) });
const collectHeapSnapshot = shouldCollectHeapSnapshot(round, rounds, heapSnapshotRounds);
const sample = await genSample(label, reports[label].dir, round, {
collectHeapSnapshot,
...(collectHeapSnapshot ? { heapSnapshotSavePath: heapSnapshotPath(label, round) } : {}),
});
reports[label].samples.push({
...sample,
round,
@@ -186,6 +185,7 @@ export async function compareBackendMemory(options: CompareOptions) {
strategy: 'interleaved-pairs',
rounds,
warmupRounds,
heapSnapshotRounds,
startedAt,
},
summary: summaries[label],

View File

@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function clampHeapSnapshotRounds(totalRounds: number, requestedRounds: number) {
return Math.min(totalRounds, requestedRounds);
}
export function shouldCollectHeapSnapshot(round: number, totalRounds: number, requestedRounds: number) {
const snapshotRounds = clampHeapSnapshotRounds(totalRounds, requestedRounds);
return round > totalRounds - snapshotRounds;
}
/**
* 中央値に最も近いラウンドを代表として選ぶ。外れ値のスナップショットを成果物にしないため。
*/
export function selectRepresentativeHeapSnapshotRound<T extends { round: number }>(
samples: T[],
medianTotal: number | null | undefined,
getTotal: (sample: T) => number | null | undefined,
) {
if (medianTotal == null || !Number.isFinite(medianTotal)) return null;
let selected: { round: number; distance: number } | null = null;
for (const sample of samples) {
const total = getTotal(sample);
if (total == null || !Number.isFinite(total)) continue;
const distance = Math.abs(total - medianTotal);
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) {
selected = { round: sample.round, distance };
}
}
return selected?.round ?? null;
}

View File

@@ -3,9 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { formatColoredDelta, formatDeltaPercentInMdTable, formatKiBAsMb } from 'diagnostics-shared/format';
import { median, pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats';
import { renderHeapSnapshotTable } from 'diagnostics-shared/heap-snapshot';
import { formatKiBAsMb } from 'diagnostics-shared/format';
import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot';
import { renderMetricComparisonTable } from 'diagnostics-shared/metric-table';
import {
independentDeltaSummary,
isOutsideObservedNoise,
type IndependentDeltaSummary,
} from 'diagnostics-shared/stats';
import type { MemoryPhase, MemoryReport } from '../types';
export type RenderMemoryReportOptions = {
@@ -29,6 +34,8 @@ const memoryMetrics = [
type MemoryMetric = typeof memoryMetrics[number];
const memoryColorThresholdKiB = 100;
function formatMemoryMetricName(metric: MemoryMetric) {
return metric === 'Pss' ? 'PSS' : metric;
}
@@ -40,64 +47,51 @@ function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase
return memoryUsage.Private_Clean + memoryUsage.Private_Dirty;
}
function getSampleValues(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
function summarizeMemoryMetric(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
return independentDeltaSummary(
base.samples,
head.samples,
sample => getMemoryValueFromSample(sample, phase, metric),
);
}
function getMemoryValue(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
if (metric !== 'USS') return report.summary[phase].memoryUsage[metric];
return median(getSampleValues(report, phase, metric));
}
function getSampleSpread(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
return sampleSpread(getSampleValues(report, phase, metric));
function getDeltaPercent(summary: IndependentDeltaSummary) {
if (summary.baseMedian === 0) return null;
return summary.delta * 100 / summary.baseMedian;
}
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: MemoryPhase) {
const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
return renderMetricComparisonTable(
base.samples,
head.samples,
memoryMetrics.map(metric => ({
label: `**${formatMemoryMetricName(metric)}**`,
getValue: sample => getMemoryValueFromSample(sample, phase, metric),
formatValue: formatKiBAsMb,
absoluteThreshold: memoryColorThresholdKiB,
})),
);
}
function formatDeltaMemory(deltaKiB: number) {
return formatColoredDelta(deltaKiB, v => formatKiBAsMb(v), 100); // 0.1 MB threshold
}
function toHeapSnapshotReport(report: MemoryReport): HeapSnapshotReport | null {
const summary = report.summary.afterGc.heapSnapshot;
if (summary == null) return null;
for (const metric of memoryMetrics) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
const summary = pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric));
const percent = summary.median * 100 / baseValue;
const deltaMedian = `${formatDeltaMemory(summary.median)}<br>${formatDeltaPercentInMdTable(percent, 0.1)}`;
lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatKiBAsMb(baseValue)} <br> ± ${formatKiBAsMb(baseSpread)} | ${formatKiBAsMb(headValue)} <br> ± ${formatKiBAsMb(headSpread)} | ${deltaMedian} | ${formatKiBAsMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`);
}
return lines.join('\n');
return {
summary,
samples: report.samples.flatMap(sample => {
const data = sample.phases.afterGc.heapSnapshot;
return data == null ? [] : [{ round: sample.round, data }];
}),
};
}
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
const baseHeapSnapshotReport = {
summary: base.summary.afterGc.heapSnapshot!,
samples: base.samples.map(sample => ({
round: sample.round,
data: sample.phases.afterGc.heapSnapshot!,
})),
};
const headHeapSnapshotReport = {
summary: head.summary.afterGc.heapSnapshot!,
samples: head.samples.map(sample => ({
round: sample.round,
data: sample.phases.afterGc.heapSnapshot!,
})),
};
const baseHeapSnapshotReport = toHeapSnapshotReport(base);
const headHeapSnapshotReport = toHeapSnapshotReport(head);
if (baseHeapSnapshotReport == null || headHeapSnapshotReport == null) return null;
const table = renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport);
if (table == null) return null;
const lines = [
'### V8 Heap Snapshot Statistics',
@@ -119,29 +113,11 @@ function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
return lines.join('\n');
}
function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
return ((headValue - baseValue) * 100) / baseValue;
}
/**
* 増加分がサンプルのばらつきを明確に超えているかを見る。
* 測定イズで警告が出続けるのを避けるため、合成ばらつきの3倍を閾値にする。
*/
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
const delta = headValue - baseValue;
if (delta <= 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 delta > combinedSpread * 3;
function countNonConvergedMemorySamples(base: MemoryReport, head: MemoryReport) {
return [base, head]
.flatMap(report => report.samples)
.filter(sample => memoryReportPhases.some(phase => !sample.phases[phase.key].memoryStability.converged))
.length;
}
export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryReport, options: RenderMemoryReportOptions) {
@@ -162,6 +138,16 @@ export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryRepor
lines.push('');
}
lines.push(`_Values are median ± MAD (${base.samples.length} base / ${head.samples.length} head samples). Delta is Head - Base. Deltas are highlighted when their absolute value reaches the metric threshold and exceeds 3 × MAD._`);
lines.push('');
const nonConvergedSamples = countNonConvergedMemorySamples(base, head);
if (nonConvergedSamples > 0) {
const noun = nonConvergedSamples === 1 ? 'sample' : 'samples';
lines.push(`⚠️ **Measurement warning**: ${nonConvergedSamples} memory ${noun} did not converge.`);
lines.push('');
}
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
if (heapSnapshotSection != null) {
lines.push(heapSnapshotSection);
@@ -172,8 +158,14 @@ export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryRepor
lines.push('');
const warningMetric = 'Pss';
const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric);
if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
const warningSummary = summarizeMemoryMetric(base, head, 'afterGc', warningMetric);
const warningDiffPercent = getDeltaPercent(warningSummary);
if (
warningSummary.delta > 0 &&
warningDiffPercent != null &&
warningDiffPercent > 5 &&
isOutsideObservedNoise(warningSummary)
) {
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('');
}

View File

@@ -37,6 +37,7 @@ export type MemoryReport = {
strategy: string;
rounds: number;
warmupRounds: number;
heapSnapshotRounds?: number;
startedAt: string;
};
summary: Record<MemoryPhase, {

View File

@@ -1,26 +1,28 @@
## ⚙️ Backend Diagnostics Report
### Memory: After GC
| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **HeapUsed** | 152 MB <br> ± 1 MB | 168 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+10.5\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ |
| **PSS** | 202 MB <br> ± 1 MB | 218 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ |
| **USS** | 184 MB <br> ± 1 MB | 200 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+8.7\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ |
| **External** | 8.2 MB <br> ± 0.1 MB | 8.2 MB <br> ± 0.1 MB | 0 MB<br>0% | 0 MB | 0 MB | 0 MB |
| Metric | @ Base | @ Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| **HeapUsed** | 152 MB <br> ± 1 MB | 168 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+10.5\\%}}$ | 1.4 MB |
| **PSS** | 202 MB <br> ± 1 MB | 218 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 1.4 MB |
| **USS** | 184 MB <br> ± 1 MB | 200 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+8.7\\%}}$ | 1.4 MB |
| **External** | 8.2 MB <br> ± 0.1 MB | 8.2 MB <br> ± 0.1 MB | 0 MB<br>0% | 0.1 MB |
_Values are median ± MAD (3 base / 3 head samples). Delta is Head - Base. Deltas are highlighted when their absolute value reaches the metric threshold and exceeds 3 × MAD._
### V8 Heap Snapshot Statistics
| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB <br> ± 200 KB | 44 MB <br> ± 200 KB | $\color{orange}{\text{+3.2 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 0 B | $\color{orange}{\text{+3.2 MB}}$ | $\color{orange}{\text{+3.2 MB}}$ |
| | | | | | | |
| <details><summary>$\color{orange}{\rule{8pt}{8pt}}$ **Code**</summary>11.8% → 11.8%</details> | 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 0 B | $\color{orange}{\text{+376 KB}}$ | $\color{orange}{\text{+376 KB}}$ |
| <details><summary>$\color{red}{\rule{8pt}{8pt}}$ **Strings**</summary>11% → 11%</details> | 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 0 B | $\color{orange}{\text{+352 KB}}$ | $\color{orange}{\text{+352 KB}}$ |
| <details><summary>$\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays**</summary>10.3% → 10.3%</details> | 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 0 B | $\color{orange}{\text{+328 KB}}$ | $\color{orange}{\text{+328 KB}}$ |
| <details><summary>$\color{green}{\rule{8pt}{8pt}}$ **Typed arrays**</summary>9.5% → 9.5%</details> | 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 0 B | $\color{orange}{\text{+304 KB}}$ | $\color{orange}{\text{+304 KB}}$ |
| <details><summary>$\color{yellow}{\rule{8pt}{8pt}}$ **System objects**</summary>8.8% → 8.8%</details> | 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 0 B | $\color{orange}{\text{+280 KB}}$ | $\color{orange}{\text{+280 KB}}$ |
| <details><summary>$\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs**</summary>8% → 8%</details> | 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 0 B | $\color{orange}{\text{+256 KB}}$ | $\color{orange}{\text{+256 KB}}$ |
| <details><summary>$\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs**</summary>7.3% → 7.3%</details> | 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 0 B | $\color{orange}{\text{+232 KB}}$ | $\color{orange}{\text{+232 KB}}$ |
| Metric | @ Base | @ Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB <br> ± 200 KB | 44 MB <br> ± 200 KB | $\color{orange}{\text{+3.2 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 283 KB |
| | | | | |
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 33 KB |
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 31 KB |
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 29 KB |
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 27 KB |
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 25 KB |
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 23 KB |
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 21 KB |
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)

View File

@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from 'vitest';
import {
clampHeapSnapshotRounds,
selectRepresentativeHeapSnapshotRound,
shouldCollectHeapSnapshot,
} from '../src/heap-snapshot-sampling';
describe('heap snapshot sampling', () => {
test('collects only the final requested rounds', () => {
const selected = Array.from({ length: 15 }, (_, index) => index + 1)
.filter(round => shouldCollectHeapSnapshot(round, 15, 3));
expect(selected).toStrictEqual([13, 14, 15]);
});
test('clamps the requested count to the measured round count', () => {
expect(clampHeapSnapshotRounds(5, 20)).toBe(5);
expect(Array.from({ length: 5 }, (_, index) => index + 1)
.filter(round => shouldCollectHeapSnapshot(round, 5, 20)))
.toStrictEqual([1, 2, 3, 4, 5]);
});
test('collects no snapshots when the effective count is zero', () => {
expect(Array.from({ length: 5 }, (_, index) => index + 1)
.filter(round => shouldCollectHeapSnapshot(round, 5, 0)))
.toStrictEqual([]);
});
test('selects the snapshot nearest the median and ignores missing rounds', () => {
const samples = [
{ round: 12, total: null },
{ round: 13, total: 100 },
{ round: 14, total: 110 },
{ round: 15, total: 130 },
];
expect(selectRepresentativeHeapSnapshotRound(samples, 110, sample => sample.total)).toBe(14);
});
});

View File

@@ -6,6 +6,7 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { pairedDeltaSummary } from 'diagnostics-shared/stats';
import { renderMemoryReportMarkdown } from '../src/report/markdown';
import type { MemoryReport } from '../src/types';
@@ -15,6 +16,26 @@ async function loadFixture(name: string) {
return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as MemoryReport;
}
function replacePssSamples(report: MemoryReport, values: number[]) {
const templates = structuredClone(report.samples);
report.samples = values.map((Pss, index) => {
const sample = structuredClone(templates[index % templates.length]);
sample.round = index + 1;
sample.phases.afterGc.memoryUsage.Pss = Pss;
const privateClean = sample.phases.afterGc.memoryUsage.Private_Clean;
sample.phases.afterGc.memoryUsage.Private_Dirty = Pss - privateClean;
return sample;
});
report.sampleCount = report.samples.length;
return report;
}
function findMetricRow(markdown: string, metric: string) {
const row = markdown.split('\n').find(line => line.startsWith(`| **${metric}**`));
if (row === undefined) throw new Error(`expected memory report to contain a ${metric} row`);
return row;
}
/**
* 出力をゴールデンファイルで固定する。
* 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。
@@ -27,3 +48,89 @@ test('renders the backend memory report', async () => {
await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md');
});
test('throws when filtering leaves fewer than two heap snapshots per side', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
for (const report of [base, head]) {
for (const sample of report.samples.slice(0, -1)) {
sample.phases.afterGc.heapSnapshot = null;
}
}
expect(() => renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
})).toThrow('At least two samples per side are required');
});
test('reports the difference of medians and leaves a paired-looking PSS delta uncoloured', async () => {
const base = replacePssSamples(await loadFixture('base'), [290_000, 292_900, 295_800, 298_700, 301_600]);
const head = replacePssSamples(await loadFixture('head'), [292_900, 296_300, 298_700, 301_600, 290_000]);
expect(pairedDeltaSummary(
base.samples,
head.samples,
sample => sample.phases.afterGc.memoryUsage.Pss,
).median).toBe(2_900);
const markdown = renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
const row = findMetricRow(markdown, 'PSS');
expect(row).toContain('295.8 MB <br> ± 2.9 MB');
expect(row).toContain('296.3 MB <br> ± 3.4 MB');
expect(row).toContain('$\\text{+0.5 MB}$');
expect(row).toContain('4.5 MB');
expect(row.split('|')).toHaveLength(7);
expect(row).not.toMatch(/within noise|increase|decrease|inconclusive/);
expect(row).not.toContain('\\color{orange}');
expect(findMetricRow(markdown, 'USS')).not.toContain('\\color{orange}');
});
test('keeps the convergence warning without suppressing table colour or the PSS warning', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
base.samples[0].phases.afterGc.memoryStability.converged = false;
const markdown = renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
const memorySection = markdown.slice(0, markdown.indexOf('### V8 Heap Snapshot Statistics'));
expect(memorySection).not.toContain('inconclusive');
expect(findMetricRow(markdown, 'PSS')).toContain('\\color{orange}');
expect(markdown).toContain('⚠️ **Measurement warning**: 1 memory sample did not converge.');
expect(markdown).not.toContain('results are marked inconclusive');
expect(markdown).toContain('⚠️ **Warning**: Memory usage (PSS)');
});
test('throws for an undersampled memory comparison', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
base.samples = base.samples.slice(0, 1);
head.samples = head.samples.slice(0, 1);
expect(() => renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
})).toThrow('At least two samples per side are required');
});
test('renders an unavailable percentage when the base median is zero', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
for (const sample of base.samples) sample.phases.afterGc.memoryUsage.External = 0;
const markdown = renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
expect(findMetricRow(markdown, 'External')).toContain('<br>-');
expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |');
});

View File

@@ -5,11 +5,11 @@
import { formatBytes, formatColoredDelta, formatNumber } from 'diagnostics-shared/format';
import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot';
import { pairedDeltaSummary } from 'diagnostics-shared/stats';
import { renderMetricComparisonTable } from 'diagnostics-shared/metric-table';
import { renderFrontendChunkReport } from './bundle/chunk-report';
import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './bundle/visualizer';
import type { CollectedBundleReport } from './bundle/manifest';
import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from './browser/types';
import type { BrowserMeasurementSample, BrowserMetricsReport } from './browser/types';
export type FrontendDiagnosticsMarkdownInput = {
bundle: {
@@ -29,86 +29,132 @@ export type FrontendDiagnosticsMarkdownInput = {
};
};
function renderMetricRow(
label: string,
base: BrowserMetricsReport,
head: BrowserMetricsReport,
getSummaryValue: (summary: BrowserMeasurement) => number,
getSampleValue: (sample: BrowserMeasurementSample) => number,
formatter: (value: number) => string,
significantThreshold = 0,
skipIfNotSignificant = true,
) {
const baseValue = getSummaryValue(base.summary);
const headValue = getSummaryValue(head.summary);
if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null;
const summary = pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample));
// 有意な閾値に満たない場合はそもそもrowとして出力しない
if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null;
const deltaMedian = formatColoredDelta(summary.median, formatter, significantThreshold);
return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${formatter(summary.mad)} | ${formatColoredDelta(summary.min, formatter, significantThreshold)} | ${formatColoredDelta(summary.max, formatter, significantThreshold)} |`;
}
function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) {
return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0);
}
function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) {
return resourceTypeBytes(sample, resourceTypes);
return resourceTypes.reduce((sum, resourceType) => sum + (sample.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0);
}
function renderBrowserSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
//function getMetric(report: BrowserMeasurement, key: string) {
// return report.performance.cdpMetrics[key];
//}
const rows = [
//renderMetricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs),
renderMetricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, formatNumber, 1, !all),
//renderMetricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, formatNumber),
renderMetricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, formatBytes, 10000, !all),
renderMetricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, formatBytes, 10000, !all),
renderMetricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, formatBytes, 10000, !all),
renderMetricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, formatBytes, 10000, !all),
renderMetricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), formatBytes, 10000, !all),
renderMetricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), formatBytes, 10000, !all),
renderMetricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), formatBytes, 10000, !all),
renderMetricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), formatBytes, 10000, !all),
renderMetricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), formatBytes, 10000, !all),
//renderMetricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs),
//renderMetricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs),
//renderMetricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, formatNumber),
//renderMetricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs),
//renderMetricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs),
//renderMetricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), formatBytes),
//renderMetricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), formatBytes),
//renderMetricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, formatBytes, 10000),
//renderMetricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, formatNumber),
//renderMetricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), formatNumber),
//renderMetricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), formatNumber),
//renderMetricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), formatNumber),
//renderMetricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), formatNumber),
//renderMetricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs),
//renderMetricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs),
renderMetricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, formatNumber, 1, !all),
renderMetricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, formatBytes, 10000, !all),
renderMetricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, formatBytes, 10000, !all),
renderMetricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, formatNumber, 1, !all),
renderMetricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, formatNumber, 1, !all),
renderMetricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, formatNumber, 1, !all),
renderMetricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, formatNumber, 1, !all),
renderMetricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, formatNumber, 1, !all),
renderMetricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, formatBytes, 10000, !all),
].filter(row => row != null);
return [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
...rows,
].join('\n');
function renderBrowserSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
return renderMetricComparisonTable(
base.samples,
head.samples,
[
{
label: '**Requests**',
getValue: sample => sample.network.requestCount,
formatValue: formatNumber,
absoluteThreshold: 1,
},
{
label: '**Encoded network**',
getValue: sample => sample.network.totalEncodedBytes,
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**Decoded body**',
getValue: sample => sample.network.totalDecodedBodyBytes,
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**Same-origin encoded**',
getValue: sample => sample.network.sameOriginEncodedBytes,
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**Third-party encoded**',
getValue: sample => sample.network.thirdPartyEncodedBytes,
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**Script encoded**',
getValue: sample => resourceTypeSampleBytes(sample, ['Script']),
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**Stylesheet encoded**',
getValue: sample => resourceTypeSampleBytes(sample, ['Stylesheet']),
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**Fetch/XHR encoded**',
getValue: sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']),
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**Image encoded**',
getValue: sample => resourceTypeSampleBytes(sample, ['Image']),
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**Font encoded**',
getValue: sample => resourceTypeSampleBytes(sample, ['Font']),
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**WebSocket connections**',
getValue: sample => sample.network.webSocketConnectionCount,
formatValue: formatNumber,
absoluteThreshold: 1,
},
{
label: '**WebSocket sent**',
getValue: sample => sample.network.webSocketSentBytes,
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**WebSocket received**',
getValue: sample => sample.network.webSocketReceivedBytes,
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
{
label: '**Page errors**',
getValue: sample => sample.diagnostics.pageErrorCount,
formatValue: formatNumber,
absoluteThreshold: 1,
},
{
label: '**Console log**',
getValue: sample => sample.diagnostics.console.log,
formatValue: formatNumber,
absoluteThreshold: 1,
},
{
label: '**Console warnings**',
getValue: sample => sample.diagnostics.console.warning,
formatValue: formatNumber,
absoluteThreshold: 1,
},
{
label: '**Console errors**',
getValue: sample => sample.diagnostics.console.error,
formatValue: formatNumber,
absoluteThreshold: 1,
},
{
label: '**Console info**',
getValue: sample => sample.diagnostics.console.info,
formatValue: formatNumber,
absoluteThreshold: 1,
},
{
label: '**Page-attributed memory**',
getValue: sample => sample.performance.tabMemory.totalBytes,
formatValue: formatBytes,
absoluteThreshold: 10_000,
},
],
{ onlySignificantChanges: true },
);
}
function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
@@ -199,7 +245,7 @@ export function renderFrontendDiagnosticsMarkdown(input: FrontendDiagnosticsMark
`Download representative heap snapshot: [base](${browser.baseHeapSnapshotUrl}) / [head](${browser.headHeapSnapshotUrl})`,
'</details>',
'',
'### 📦 Bundle Stats',
'## 📦 Bundle Stats',
'',
renderFrontendChunkReport(bundle.base, bundle.head),
'',

View File

@@ -1,12 +1,12 @@
## 🖥 Frontend Diagnostics Report
| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **Encoded network** | 1 MB | 1.1 MB | $\color{orange}{\text{+83 KB}}$ | 816 B | $\color{orange}{\text{+82 KB}}$ | $\color{orange}{\text{+84 KB}}$ |
| **Decoded body** | 3.5 MB | 3.7 MB | $\color{orange}{\text{+277 KB}}$ | 2.7 KB | $\color{orange}{\text{+274 KB}}$ | $\color{orange}{\text{+279 KB}}$ |
| **Same-origin encoded** | 1 MB | 1.1 MB | $\color{orange}{\text{+82 KB}}$ | 800 B | $\color{orange}{\text{+81 KB}}$ | $\color{orange}{\text{+82 KB}}$ |
| **Script encoded** | 918 KB | 991 KB | $\color{orange}{\text{+73 KB}}$ | 720 B | $\color{orange}{\text{+73 KB}}$ | $\color{orange}{\text{+74 KB}}$ |
| **Page-attributed memory** | 92 MB | 99 MB | $\color{orange}{\text{+7.3 MB}}$ | 72 KB | $\color{orange}{\text{+7.3 MB}}$ | $\color{orange}{\text{+7.4 MB}}$ |
| Metric | @ Base | @ Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| **Encoded network** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+83 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
| **Decoded body** | 3.5 MB <br> ± 34 KB | 3.7 MB <br> ± 37 KB | $\color{orange}{\text{+277 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 50 KB |
| **Same-origin encoded** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+82 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
| **Script encoded** | 918 KB <br> ± 9 KB | 991 KB <br> ± 9.7 KB | $\color{orange}{\text{+73 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 13 KB |
| **Page-attributed memory** | 92 MB <br> ± 900 KB | 99 MB <br> ± 972 KB | $\color{orange}{\text{+7.3 MB}}$<br>$\color{orange}{\text{+8\\%}}$ | 1.3 MB |
<i>Only metrics showing significant changes are displayed.</i>
@@ -67,22 +67,22 @@
<details>
<summary>V8 heap snapshot statistics</summary>
| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\text{+82 KB}$<br>$\color{orange}{\text{+8\\%}}$ | 800 B | $\text{+81 KB}$ | $\text{+82 KB}$ |
| | | | | | | |
| <details><summary>$\color{orange}{\rule{8pt}{8pt}}$ **Code**</summary>200% → 200%</details> | 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 1.6 KB | $\color{orange}{\text{+162 KB}}$ | $\color{orange}{\text{+165 KB}}$ |
| <details><summary>$\color{red}{\rule{8pt}{8pt}}$ **Strings**</summary>300% → 300%</details> | 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 2.4 KB | $\color{orange}{\text{+242 KB}}$ | $\color{orange}{\text{+247 KB}}$ |
| <details><summary>$\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays**</summary>400% → 400%</details> | 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 3.2 KB | $\color{orange}{\text{+323 KB}}$ | $\color{orange}{\text{+330 KB}}$ |
| <details><summary>$\color{green}{\rule{8pt}{8pt}}$ **Typed arrays**</summary>500% → 500%</details> | 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 4 KB | $\color{orange}{\text{+404 KB}}$ | $\color{orange}{\text{+412 KB}}$ |
| <details><summary>$\color{yellow}{\rule{8pt}{8pt}}$ **System objects**</summary>600% → 600%</details> | 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 4.8 KB | $\color{orange}{\text{+485 KB}}$ | $\color{orange}{\text{+494 KB}}$ |
| <details><summary>$\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs**</summary>700% → 700%</details> | 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 5.6 KB | $\color{orange}{\text{+566 KB}}$ | $\color{orange}{\text{+577 KB}}$ |
| <details><summary>$\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs**</summary>800% → 800%</details> | 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 6.4 KB | $\color{orange}{\text{+646 KB}}$ | $\color{orange}{\text{+659 KB}}$ |
| Metric | @ Base | @ Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\text{+82 KB}$<br>$\text{+8\\%}$ | 15 KB |
| | | | | |
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 29 KB |
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 44 KB |
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 59 KB |
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 74 KB |
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 88 KB |
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 103 KB |
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 118 KB |
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
</details>
### 📦 Bundle Stats
## 📦 Bundle Stats
<details>
<summary>Chunk size diff (2 updated, 0 added, 0 removed)</summary>

View File

@@ -9,7 +9,7 @@ import { dirname, join } from 'node:path';
import { afterAll, beforeAll, expect, test } from 'vitest';
import { collectBundleReport } from '../src/bundle/manifest';
import { renderFrontendDiagnosticsMarkdown } from '../src/report';
import type { BrowserMetricsReport } from '../src/browser/types';
import type { BrowserMeasurementSample, BrowserMetricsReport } from '../src/browser/types';
import type { VisualizerReport } from '../src/bundle/visualizer';
const bundleFixturesDir = join(import.meta.dirname, 'bundle/fixtures');
@@ -90,7 +90,15 @@ async function loadBrowserReport(name: 'base' | 'head') {
return JSON.parse(await readFile(join(browserFixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport;
}
async function renderReport(detailedHtmlUrl: string | null) {
type BrowserReports = {
base: BrowserMetricsReport;
head: BrowserMetricsReport;
};
async function renderReport(detailedHtmlUrl: string | null, reports?: BrowserReports) {
const base = reports?.base ?? await loadBrowserReport('base');
const head = reports?.head ?? await loadBrowserReport('head');
return renderFrontendDiagnosticsMarkdown({
bundle: {
base: await collectBundleReport(repoDirs.base),
@@ -100,8 +108,8 @@ async function renderReport(detailedHtmlUrl: string | null) {
visualizerArtifactUrl: 'https://example.invalid/treemap',
},
browser: {
base: await loadBrowserReport('base'),
head: await loadBrowserReport('head'),
base,
head,
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
detailedHtmlUrl,
@@ -109,6 +117,32 @@ async function renderReport(detailedHtmlUrl: string | null) {
});
}
function withMetricSamples(
report: BrowserMetricsReport,
values: number[],
setValue: (sample: BrowserMeasurementSample, value: number) => void,
): BrowserMetricsReport {
const cloned = structuredClone(report);
const samples = values.map((value, index) => {
const sample = structuredClone(cloned.samples[index % cloned.samples.length]);
sample.round = index + 1;
setValue(sample, value);
return sample;
});
return {
...cloned,
sampleCount: samples.length,
samples,
};
}
function requireMetricRow(markdown: string, label: string) {
const row = markdown.split('\n').find(line => line.startsWith(`| **${label}** |`));
if (row == null) throw new Error(`Metric row not found: ${label}`);
return row;
}
/**
* 出力をゴールデンファイルで固定する。
* 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。
@@ -117,6 +151,10 @@ test('renders one frontend diagnostics markdown report from bundle and browser d
const markdown = await renderReport('https://example.invalid/html');
await expect(markdown).toMatchFileSnapshot('./__snapshots__/report.md');
expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |');
expect(markdown).toContain('<summary>Requests by resource type</summary>');
expect(markdown).toContain('## 📦 Bundle Stats');
});
test('omits the browser details link when no detailed html artifact was uploaded', async () => {
@@ -124,3 +162,148 @@ test('omits the browser details link when no detailed html artifact was uploaded
expect(markdown).not.toContain('View details');
});
test('renders the difference of independent medians instead of the paired median delta', async () => {
const base = withMetricSamples(
await loadBrowserReport('base'),
[100, 100, 100, 1_000, 1_000],
(sample, value) => { sample.network.requestCount = value; },
);
const head = withMetricSamples(
await loadBrowserReport('head'),
[0, 0, 200, 200, 200],
(sample, value) => { sample.network.requestCount = value; },
);
const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests');
expect(row).toContain('100 <br> ± 0');
expect(row).toContain('200 <br> ± 0');
expect(row).toContain('$\\color{orange}{\\text{+100}}$<br>$\\color{orange}{\\text{+100\\\\%}}$');
expect(row).toContain('| 0 |');
expect(row.split('|')).toHaveLength(7);
expect(row).not.toMatch(/increase|decrease|within noise|inconclusive/);
expect(row).not.toContain('\\color{green}');
});
test('hides a threshold-sized change that remains within observed noise', async () => {
const base = withMetricSamples(
await loadBrowserReport('base'),
[100, 110, 120],
(sample, value) => { sample.network.requestCount = value; },
);
const head = withMetricSamples(
await loadBrowserReport('head'),
[110, 120, 130],
(sample, value) => { sample.network.requestCount = value; },
);
const markdown = await renderReport(null, { base, head });
expect(markdown).not.toContain('| **Requests** |');
});
test('renders a directional request count delta at the absolute threshold', async () => {
const base = withMetricSamples(
await loadBrowserReport('base'),
[100, 100, 100],
(sample, value) => { sample.network.requestCount = value; },
);
const head = withMetricSamples(
await loadBrowserReport('head'),
[101, 101, 101],
(sample, value) => { sample.network.requestCount = value; },
);
const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests');
expect(row).toContain('$\\color{orange}{\\text{+1}}$');
});
test('hides a directional byte change below the existing absolute threshold', async () => {
const base = withMetricSamples(
await loadBrowserReport('base'),
[1_000_000, 1_000_000, 1_000_000],
(sample, value) => { sample.network.totalEncodedBytes = value; },
);
const head = withMetricSamples(
await loadBrowserReport('head'),
[1_005_000, 1_005_000, 1_005_000],
(sample, value) => { sample.network.totalEncodedBytes = value; },
);
const markdown = await renderReport(null, { base, head });
expect(markdown).not.toContain('| **Encoded network** |');
});
test('renders a directional encoded byte delta at the absolute threshold', async () => {
const base = withMetricSamples(
await loadBrowserReport('base'),
[1_000_000, 1_000_000, 1_000_000],
(sample, value) => { sample.network.totalEncodedBytes = value; },
);
const head = withMetricSamples(
await loadBrowserReport('head'),
[1_010_000, 1_010_000, 1_010_000],
(sample, value) => { sample.network.totalEncodedBytes = value; },
);
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$');
});
test('colours absolute and relative deltas together when the row is significant', async () => {
const base = withMetricSamples(
await loadBrowserReport('base'),
[100_000_000, 100_000_000, 100_000_000],
(sample, value) => { sample.network.totalEncodedBytes = value; },
);
const head = withMetricSamples(
await loadBrowserReport('head'),
[100_020_000, 100_020_000, 100_020_000],
(sample, value) => { sample.network.totalEncodedBytes = value; },
);
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('100 MB <br> ± 0 B');
expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
expect(row).toContain('| 0 B |');
expect(row.split('|')).toHaveLength(7);
});
test('renders an unavailable relative delta when the base median is zero', async () => {
const base = withMetricSamples(
await loadBrowserReport('base'),
[0, 0, 0],
(sample, value) => { sample.network.requestCount = value; },
);
const head = withMetricSamples(
await loadBrowserReport('head'),
[2, 2, 2],
(sample, value) => { sample.network.requestCount = value; },
);
const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests');
expect(row).toContain('$\\color{orange}{\\text{+2}}$<br>-');
expect(row).not.toContain('increase');
});
test('throws for a metric with fewer than two samples on one side', async () => {
const base = withMetricSamples(
await loadBrowserReport('base'),
[100],
(sample, value) => { sample.network.requestCount = value; },
);
const head = withMetricSamples(
await loadBrowserReport('head'),
[102, 102],
(sample, value) => { sample.network.requestCount = value; },
);
await expect(renderReport(null, { base, head }))
.rejects.toThrow('At least two samples per side are required');
});

View File

@@ -7,6 +7,7 @@
"./format": "./src/format.ts",
"./html": "./src/html.ts",
"./stats": "./src/stats.ts",
"./metric-table": "./src/metric-table.ts",
"./heap-snapshot": "./src/heap-snapshot/index.ts"
},
"scripts": {

View File

@@ -3,13 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
formatBytes,
formatDeltaBytes,
formatDeltaPercentInMdTable,
formatPercent,
} from '../format';
import { mad, pairedDeltaSummary } from '../stats';
import { formatBytes } from '../format';
import { renderMetricComparisonTable } from '../metric-table';
import {
heapSnapshotCategories,
heapSnapshotCategory,
@@ -24,15 +19,6 @@ function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategor
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}**`;
}
@@ -41,38 +27,19 @@ function swatch(category: HeapSnapshotCategory) {
* 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');
return renderMetricComparisonTable(
base.samples,
head.samples,
heapSnapshotCategories.map(category => ({
label: swatch(category),
getValue: sample => sample.data.categories[category],
formatValue: formatBytes,
absoluteThreshold: byteColorThreshold,
showMedianMad: category === 'total',
showDeltaPercentage: category === 'total',
separatorAfter: category === 'total',
})),
);
}
const sankeyChildMinRatio = 0.3;

View File

@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { formatColoredDelta, formatDeltaPercentInMdTable } from './format';
import {
independentDeltaSummary,
isOutsideObservedNoise,
type IndependentDeltaSummary,
} from './stats';
export type MetricComparisonRow<T> = {
label: string;
getValue: (sample: T) => number;
formatValue: (value: number) => string;
absoluteThreshold: number;
showMedianMad?: boolean;
showDeltaPercentage?: boolean;
separatorAfter?: boolean;
};
export type MetricComparisonTableOptions = {
onlySignificantChanges?: boolean;
};
function isSignificant(summary: IndependentDeltaSummary, absoluteThreshold: number) {
return isOutsideObservedNoise(summary) && Math.abs(summary.delta) >= absoluteThreshold;
}
function formatMedian<T>(
value: number,
spread: number,
row: MetricComparisonRow<T>,
) {
const formatted = row.formatValue(value);
if (row.showMedianMad === false) return formatted;
return `${formatted} <br> ± ${row.formatValue(spread)}`;
}
function formatDelta<T>(
summary: IndependentDeltaSummary,
row: MetricComparisonRow<T>,
significant: boolean,
) {
const colorThreshold = significant ? 0 : Number.POSITIVE_INFINITY;
const absolute = formatColoredDelta(summary.delta, row.formatValue, colorThreshold);
if (row.showDeltaPercentage === false) return absolute;
const percentage = summary.baseMedian === 0
? '-'
: formatDeltaPercentInMdTable(summary.delta * 100 / summary.baseMedian, colorThreshold);
return `${absolute}<br>${percentage}`;
}
export function renderMetricComparisonTable<T>(
baseSamples: T[],
headSamples: T[],
rows: MetricComparisonRow<T>[],
options: MetricComparisonTableOptions = {},
): string {
const lines: string[] = [];
for (const row of rows) {
const summary = independentDeltaSummary(baseSamples, headSamples, row.getValue);
const significant = isSignificant(summary, row.absoluteThreshold);
if (options.onlySignificantChanges === true && !significant) continue;
lines.push(`| ${row.label} | ${formatMedian(summary.baseMedian, summary.baseMad, row)} | ${formatMedian(summary.headMedian, summary.headMad, row)} | ${formatDelta(summary, row, significant)} | ${row.formatValue(summary.combinedMad)} |`);
if (row.separatorAfter === true) lines.push('| | | | | |');
}
if (lines.length === 0) return '**(No data)**';
return [
'| Metric | @ Base | @ Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
...lines,
].join('\n');
}

View File

@@ -37,6 +37,49 @@ export function sampleSpread(values: (number | null | undefined)[]) {
return mad(finiteValues);
}
export type IndependentDeltaSummary = {
baseMedian: number;
headMedian: number;
delta: number;
baseMad: number;
headMad: number;
combinedMad: number;
baseSamples: number;
headSamples: number;
};
export function independentDeltaSummary<T>(
baseSamples: T[],
headSamples: T[],
getValue: (sample: T) => number,
): IndependentDeltaSummary {
const baseValues = baseSamples.map(getValue);
const headValues = headSamples.map(getValue);
if (baseValues.length < 2 || headValues.length < 2) {
throw new Error('At least two samples per side are required');
}
const baseMedian = median(baseValues);
const headMedian = median(headValues);
const baseMad = mad(baseValues);
const headMad = mad(headValues);
return {
baseMedian,
headMedian,
delta: headMedian - baseMedian,
baseMad,
headMad,
combinedMad: Math.hypot(baseMad, headMad),
baseSamples: baseValues.length,
headSamples: headValues.length,
};
}
export function isOutsideObservedNoise(summary: IndependentDeltaSummary) {
return Math.abs(summary.delta) > summary.combinedMad * 3;
}
type RoundedSample = { round: number };
function indexByRound<T extends RoundedSample>(samples: T[]) {

View File

@@ -0,0 +1,103 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from 'vitest';
import {
createEmptyHeapSnapshotData,
renderHeapSnapshotTable,
summarizeHeapSnapshotDataSamples,
type HeapSnapshotData,
type HeapSnapshotReport,
} from '../src/heap-snapshot';
function snapshot(total: number): HeapSnapshotData {
const data = createEmptyHeapSnapshotData();
data.categories.total = total;
return data;
}
function report(totals: number[]): HeapSnapshotReport {
const samples = totals.map((total, index) => ({
round: index + 1,
data: snapshot(total),
}));
const summary = summarizeHeapSnapshotDataSamples(samples, sample => sample.data);
if (summary == null) throw new Error('expected heap snapshot samples to produce a summary');
return {
summary,
samples,
};
}
function totalRow(markdown: string) {
const row = markdown.split('\n').find(line => line.includes('**Total**'));
if (row === undefined) throw new Error('expected heap snapshot table to contain a Total row');
return row;
}
describe('renderHeapSnapshotTable', () => {
test('leaves a threshold-sized delta uncoloured when it is within observed noise', () => {
const row = totalRow(renderHeapSnapshotTable(
report([1_000_000, 1_100_000, 1_200_000]),
report([1_100_000, 1_200_000, 1_300_000]),
));
expect(row).toContain('$\\text{+100 KB}$');
expect(row).not.toContain('within noise');
expect(row).not.toContain('\\color{orange}');
});
test('colours both delta lines for a clear increase at or above the absolute threshold', () => {
const row = totalRow(renderHeapSnapshotTable(
report([1_000_000, 1_000_000, 1_000_000]),
report([1_200_000, 1_200_000, 1_200_000]),
));
expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+20\\\\%}}$');
expect(row).not.toContain('increase');
});
test('leaves both delta lines uncoloured below the absolute threshold', () => {
const row = totalRow(renderHeapSnapshotTable(
report([1_000_000, 1_000_000, 1_000_000]),
report([1_050_000, 1_050_000, 1_050_000]),
));
expect(row).toContain('$\\text{+50 KB}$<br>$\\text{+5\\\\%}$');
expect(row.split('|')[4]).not.toContain('\\color{');
});
test('throws when only one snapshot per side reaches the renderer', () => {
expect(() => renderHeapSnapshotTable(
report([1_000_000]),
report([1_200_000]),
)).toThrow('At least two samples per side are required');
});
test('uses two-line formatting only for Total and puts a five-column separator after it', () => {
const table = renderHeapSnapshotTable(
report([1_000_000, 1_000_000, 1_000_000]),
report([1_200_000, 1_200_000, 1_200_000]),
);
const lines = table.split('\n');
const totalIndex = lines.findIndex(line => line.includes('**Total**'));
const categoryRow = lines.find(line => line.includes('**Code**'));
expect(lines.slice(0, 2)).toStrictEqual([
'| Metric | @ Base | @ Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
]);
expect(table).not.toContain('Result');
expect(totalIndex).toBeGreaterThan(1);
expect(lines[totalIndex].match(/<br>/g)).toHaveLength(3);
expect(lines[totalIndex + 1]).toBe('| | | | | |');
expect(categoryRow).toBeDefined();
expect(categoryRow).not.toContain('<br>');
expect(categoryRow).not.toContain('<details>');
expect(categoryRow).not.toContain('→');
});
});

View File

@@ -0,0 +1,145 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from 'vitest';
import {
renderMetricComparisonTable,
type MetricComparisonRow,
} from '../src/metric-table';
type Sample = { value: number };
const defaultRow: MetricComparisonRow<Sample> = {
label: '<strong>Metric</strong>',
getValue: sample => sample.value,
formatValue: value => `${value} units`,
absoluteThreshold: 10,
};
function samples(...values: number[]): Sample[] {
return values.map(value => ({ value }));
}
describe('renderMetricComparisonTable', () => {
test('renders the fixed five-column layout, raw label, MAD, and percentage by default', () => {
const table = renderMetricComparisonTable(
samples(100, 100, 100),
samples(120, 120, 120),
[defaultRow],
);
expect(table.split('\n')).toStrictEqual([
'| Metric | @ Base | @ Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
'| <strong>Metric</strong> | 100 units <br> ± 0 units | 120 units <br> ± 0 units | $\\color{orange}{\\text{+20 units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
]);
});
test('can hide second lines and insert a five-column separator row', () => {
const table = renderMetricComparisonTable(
samples(100, 100, 100),
samples(120, 120, 120),
[{
...defaultRow,
showMedianMad: false,
showDeltaPercentage: false,
separatorAfter: true,
}],
);
expect(table.split('\n')).toStrictEqual([
'| Metric | @ Base | @ Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
'| <strong>Metric</strong> | 100 units | 120 units | $\\color{orange}{\\text{+20 units}}$ | 0 units |',
'| | | | | |',
]);
});
test('leaves both delta lines uncolored below the absolute threshold and can filter the row', () => {
const base = samples(100, 100, 100);
const head = samples(109, 109, 109);
const table = renderMetricComparisonTable(base, head, [defaultRow]);
expect(table).toContain('$\\text{+9 units}$<br>$\\text{+9\\\\%}$');
expect(table).not.toContain('\\color{');
expect(renderMetricComparisonTable(base, head, [defaultRow], {
onlySignificantChanges: true,
})).toBe('**(No data)**');
});
test('renders a no-data marker when no rows are configured', () => {
expect(renderMetricComparisonTable(
samples(100, 100, 100),
samples(120, 120, 120),
[],
)).toBe('**(No data)**');
});
test('treats the absolute threshold itself as significant', () => {
const table = renderMetricComparisonTable(
samples(100, 100, 100),
samples(110, 110, 110),
[defaultRow],
{ onlySignificantChanges: true },
);
expect(table).toContain('$\\color{orange}{\\text{+10 units}}$');
expect(table).toContain('$\\color{orange}{\\text{+10\\\\%}}$');
});
test('requires the delta to be strictly outside three combined MADs', () => {
const inside = renderMetricComparisonTable(
samples(100, 110, 120),
samples(109, 119, 129),
[{ ...defaultRow, absoluteThreshold: 1 }],
);
const boundary = renderMetricComparisonTable(
samples(100, 100, 100),
samples(120, 130, 140),
[defaultRow],
);
const outside = renderMetricComparisonTable(
samples(100, 100, 100),
samples(121, 131, 141),
[defaultRow],
);
expect(inside).toContain('$\\text{+9 units}$');
expect(inside).not.toContain('\\color{');
expect(boundary).toContain('$\\text{+30 units}$');
expect(boundary).not.toContain('\\color{');
expect(outside).toContain('$\\color{orange}{\\text{+31 units}}$');
expect(outside).toContain('$\\color{orange}{\\text{+31\\\\%}}$');
});
test('colours a significant decrease green on both delta lines', () => {
const table = renderMetricComparisonTable(
samples(120, 120, 120),
samples(100, 100, 100),
[defaultRow],
);
expect(table).toContain('$\\color{green}{\\text{-20 units}}$');
expect(table).toContain('$\\color{green}{\\text{-16.7\\\\%}}$');
});
test('shows a dash for percentage when the base median is zero', () => {
const table = renderMetricComparisonTable(
samples(0, 0, 0),
samples(20, 20, 20),
[defaultRow],
);
expect(table).toContain('$\\color{orange}{\\text{+20 units}}$<br>-');
});
test('propagates the minimum sample contract', () => {
expect(() => renderMetricComparisonTable(
samples(100),
samples(120, 120),
[defaultRow],
)).toThrow('At least two samples per side are required');
});
});

View File

@@ -4,7 +4,16 @@
*/
import { describe, expect, test } from 'vitest';
import { finiteMedian, mad, median, pairedDeltaSummary, sampleSpread } from '../src/stats';
import {
finiteMedian,
independentDeltaSummary,
isOutsideObservedNoise,
mad,
median,
pairedDeltaSummary,
sampleSpread,
type IndependentDeltaSummary,
} from '../src/stats';
describe('median', () => {
test('takes the middle value of an odd-length sample', () => {
@@ -107,3 +116,79 @@ describe('pairedDeltaSummary', () => {
expect(pairedDeltaSummary(warmupBase, warmupHead, sample => sample.value).samples).toBe(3);
});
});
describe('independentDeltaSummary', () => {
const base = [290_000, 292_900, 295_800, 298_700, 301_600]
.map((value, index) => ({ round: index + 1, value }));
const head = [292_900, 296_300, 298_700, 301_600, 290_000]
.map((value, index) => ({ round: index + 1, value }));
test('uses the difference of independent medians instead of the paired median', () => {
expect(pairedDeltaSummary(base, head, sample => sample.value).median).toBe(2_900);
const summary = independentDeltaSummary(base, head, sample => sample.value);
expect(summary).toMatchObject({
baseMedian: 295_800,
headMedian: 296_300,
delta: 500,
baseMad: 2_900,
headMad: 3_400,
baseSamples: 5,
headSamples: 5,
});
expect(summary.combinedMad).toBeCloseTo(Math.hypot(2_900, 3_400));
});
test('allows unequal sample counts', () => {
const summary = independentDeltaSummary(
[{ value: 10 }, { value: 20 }],
[{ value: 20 }, { value: 30 }, { value: 40 }],
sample => sample.value,
);
expect(summary).toStrictEqual({
baseMedian: 15,
headMedian: 30,
delta: 15,
baseMad: 5,
headMad: 10,
combinedMad: Math.hypot(5, 10),
baseSamples: 2,
headSamples: 3,
});
});
test('throws when either side has fewer than two samples', () => {
expect(() => independentDeltaSummary(
[{ value: 10 }],
[{ value: 20 }, { value: 20 }],
sample => sample.value,
)).toThrow('At least two samples per side are required');
expect(() => independentDeltaSummary(
[{ value: 10 }, { value: 10 }],
[{ value: 20 }],
sample => sample.value,
)).toThrow('At least two samples per side are required');
});
test('treats exactly three combined MADs as noise and only larger deltas as outside noise', () => {
function summary(delta: number, combinedMad: number): IndependentDeltaSummary {
return {
baseMedian: 100,
headMedian: 100 + delta,
delta,
baseMad: 0,
headMad: combinedMad,
combinedMad,
baseSamples: 3,
headSamples: 3,
};
}
expect(isOutsideObservedNoise(summary(29, 10))).toBe(false);
expect(isOutsideObservedNoise(summary(30, 10))).toBe(false);
expect(isOutsideObservedNoise(summary(31, 10))).toBe(true);
expect(isOutsideObservedNoise(summary(-31, 10))).toBe(true);
});
});