mirror of https://github.com/sveltejs/svelte
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
347 lines
10 KiB
347 lines
10 KiB
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = path.resolve(fileURLToPath(import.meta.url), '../..');
|
|
const compiler_path = 'packages/svelte/src/compiler/';
|
|
|
|
/**
|
|
* Merge V8 profiles from a Vitest run into a compiler-only flame graph and hotspot summary.
|
|
* Samples retain callees outside the compiler once execution has entered compiler code.
|
|
* @param {string} run_dir
|
|
*/
|
|
export function analyze_compiler_profiles(run_dir) {
|
|
const raw_dir = path.join(run_dir, 'raw');
|
|
const profile_files = fs
|
|
.readdirSync(raw_dir)
|
|
.filter((file) => file.endsWith('.cpuprofile'))
|
|
.sort();
|
|
|
|
if (profile_files.length === 0) {
|
|
throw new Error(`No CPU profiles found in ${raw_dir}`);
|
|
}
|
|
|
|
/** @type {Array<{ name: string, file?: string, line?: number, col?: number }>} */
|
|
const frames = [];
|
|
/** @type {Map<string, number>} */
|
|
const frame_indices = new Map();
|
|
/** @type {Map<string, { stack: number[], weight: number }>} */
|
|
const stacks = new Map();
|
|
/** @type {Map<string, Hotspot>} */
|
|
const hotspots = new Map();
|
|
/** @type {Map<string, Hotspot>} */
|
|
const files = new Map();
|
|
let total_time = 0;
|
|
let compiler_time = 0;
|
|
let garbage_collection_time = 0;
|
|
let total_samples = 0;
|
|
let compiler_samples = 0;
|
|
|
|
for (const profile_file of profile_files) {
|
|
let profile;
|
|
try {
|
|
profile = JSON.parse(fs.readFileSync(path.join(raw_dir, profile_file), 'utf8'));
|
|
} catch (error) {
|
|
throw new Error(`Could not parse CPU profile ${profile_file}`, { cause: error });
|
|
}
|
|
const nodes = Array.isArray(profile.nodes) ? profile.nodes : [];
|
|
const samples = Array.isArray(profile.samples) ? profile.samples : [];
|
|
const time_deltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : [];
|
|
const nodes_by_id = new Map(nodes.map((node) => [node.id, node]));
|
|
const parents = new Map();
|
|
|
|
for (const node of nodes) {
|
|
for (const child of node.children || []) {
|
|
parents.set(child, node.id);
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < samples.length; i += 1) {
|
|
const weight = typeof time_deltas[i] === 'number' ? time_deltas[i] : 1;
|
|
const stack = get_stack(samples[i], nodes_by_id, parents);
|
|
total_time += weight;
|
|
total_samples += 1;
|
|
|
|
if (stack.at(-1)?.function_name === '(garbage collector)') {
|
|
garbage_collection_time += weight;
|
|
}
|
|
|
|
const compiler_index = stack.findIndex((frame) => frame.url.includes(compiler_path));
|
|
if (compiler_index === -1) continue;
|
|
|
|
const compiler_stack = stack.slice(compiler_index);
|
|
const stack_indices = compiler_stack.map(get_frame_index);
|
|
const stack_key = stack_indices.join(',');
|
|
const existing_stack = stacks.get(stack_key);
|
|
|
|
if (existing_stack) {
|
|
existing_stack.weight += weight;
|
|
} else {
|
|
stacks.set(stack_key, { stack: stack_indices, weight });
|
|
}
|
|
|
|
compiler_time += weight;
|
|
compiler_samples += 1;
|
|
|
|
const leaf = compiler_stack.at(-1);
|
|
if (leaf) {
|
|
get_hotspot(hotspots, frame_key(leaf), leaf).self += weight;
|
|
get_hotspot(files, leaf.url || '(native)', {
|
|
...leaf,
|
|
function_name: leaf.url || '(native)'
|
|
}).self += weight;
|
|
}
|
|
|
|
const seen_frames = new Set();
|
|
const seen_files = new Set();
|
|
|
|
for (const frame of compiler_stack) {
|
|
const key = frame_key(frame);
|
|
if (!seen_frames.has(key)) {
|
|
get_hotspot(hotspots, key, frame).inclusive += weight;
|
|
seen_frames.add(key);
|
|
}
|
|
|
|
const file = frame.url || '(native)';
|
|
if (!seen_files.has(file)) {
|
|
get_hotspot(files, file, { ...frame, function_name: file }).inclusive += weight;
|
|
seen_files.add(file);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (compiler_time === 0) {
|
|
throw new Error('The CPU profiles contain no samples from packages/svelte/src/compiler');
|
|
}
|
|
|
|
const hotspot_rows = rank(hotspots, compiler_time);
|
|
const file_rows = rank(files, compiler_time);
|
|
const compact_stacks = [...stacks.values()];
|
|
const summary = {
|
|
profile_files: profile_files.length,
|
|
total_samples,
|
|
compiler_samples,
|
|
total_time_microseconds: total_time,
|
|
compiler_time_microseconds: compiler_time,
|
|
garbage_collection_time_microseconds: garbage_collection_time,
|
|
compiler_share_percent: (compiler_time * 100) / total_time,
|
|
hotspots: hotspot_rows,
|
|
files: file_rows
|
|
};
|
|
const speedscope = {
|
|
$schema: 'https://www.speedscope.app/file-format-schema.json',
|
|
name: `Svelte compiler: ${path.basename(run_dir)}`,
|
|
exporter: 'Svelte compiler test profiler',
|
|
activeProfileIndex: 0,
|
|
shared: { frames },
|
|
profiles: [
|
|
{
|
|
type: 'sampled',
|
|
name: 'Compiler-active samples from pnpm test',
|
|
unit: 'microseconds',
|
|
startValue: 0,
|
|
endValue: compiler_time,
|
|
samples: compact_stacks.map((entry) => entry.stack),
|
|
weights: compact_stacks.map((entry) => entry.weight)
|
|
}
|
|
]
|
|
};
|
|
|
|
fs.writeFileSync(
|
|
path.join(run_dir, 'flamegraph.speedscope.json'),
|
|
`${JSON.stringify(speedscope)}\n`
|
|
);
|
|
fs.writeFileSync(path.join(run_dir, 'summary.json'), `${JSON.stringify(summary, null, '\t')}\n`);
|
|
fs.writeFileSync(path.join(run_dir, 'summary.md'), render_markdown(summary));
|
|
|
|
return summary;
|
|
|
|
/** @param {Frame} frame */
|
|
function get_frame_index(frame) {
|
|
const key = frame_key(frame);
|
|
const existing = frame_indices.get(key);
|
|
if (existing !== undefined) return existing;
|
|
|
|
const index = frames.length;
|
|
const location = frame.url ? ` (${frame.url}:${frame.line})` : '';
|
|
frames.push({
|
|
name: `${frame.function_name}${location}`,
|
|
...(frame.url ? { file: frame.url, line: frame.line, col: frame.column } : {})
|
|
});
|
|
frame_indices.set(key, index);
|
|
return index;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {number} leaf_id
|
|
* @param {Map<number, any>} nodes_by_id
|
|
* @param {Map<number, number>} parents
|
|
* @returns {Frame[]}
|
|
*/
|
|
function get_stack(leaf_id, nodes_by_id, parents) {
|
|
/** @type {Frame[]} */
|
|
const stack = [];
|
|
const seen = new Set();
|
|
let id = leaf_id;
|
|
|
|
while (typeof id === 'number' && !seen.has(id)) {
|
|
seen.add(id);
|
|
const node = nodes_by_id.get(id);
|
|
if (!node) break;
|
|
|
|
const call_frame = node.callFrame || {};
|
|
stack.push({
|
|
function_name: call_frame.functionName || '(anonymous)',
|
|
url: normalize_url(call_frame.url || ''),
|
|
line: typeof call_frame.lineNumber === 'number' ? call_frame.lineNumber + 1 : 0,
|
|
column: typeof call_frame.columnNumber === 'number' ? call_frame.columnNumber + 1 : 0
|
|
});
|
|
id = parents.get(id);
|
|
}
|
|
|
|
return stack.reverse();
|
|
}
|
|
|
|
/** @param {string} url */
|
|
function normalize_url(url) {
|
|
if (!url) return '';
|
|
|
|
let pathname = url.replace(/^\/\@fs\//, '/').replace(/[?#].*$/, '');
|
|
if (pathname.startsWith('file://')) {
|
|
try {
|
|
pathname = fileURLToPath(pathname);
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
if (path.isAbsolute(pathname)) {
|
|
const relative = path.relative(root, pathname);
|
|
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
pathname = relative;
|
|
}
|
|
}
|
|
|
|
return pathname.replaceAll(path.sep, '/');
|
|
}
|
|
|
|
/** @param {Frame} frame */
|
|
function frame_key(frame) {
|
|
return `${frame.function_name}\0${frame.url}\0${frame.line}\0${frame.column}`;
|
|
}
|
|
|
|
/**
|
|
* @param {Map<string, Hotspot>} collection
|
|
* @param {string} key
|
|
* @param {Frame} frame
|
|
*/
|
|
function get_hotspot(collection, key, frame) {
|
|
let hotspot = collection.get(key);
|
|
if (!hotspot) {
|
|
hotspot = { ...frame, self: 0, inclusive: 0 };
|
|
collection.set(key, hotspot);
|
|
}
|
|
return hotspot;
|
|
}
|
|
|
|
/**
|
|
* @param {Map<string, Hotspot>} collection
|
|
* @param {number} total
|
|
*/
|
|
function rank(collection, total) {
|
|
return [...collection.values()]
|
|
.map((hotspot) => ({
|
|
function: hotspot.function_name,
|
|
url: hotspot.url,
|
|
line: hotspot.line,
|
|
column: hotspot.column,
|
|
self_microseconds: hotspot.self,
|
|
self_percent: (hotspot.self * 100) / total,
|
|
inclusive_microseconds: hotspot.inclusive,
|
|
inclusive_percent: (hotspot.inclusive * 100) / total
|
|
}))
|
|
.sort(
|
|
(a, b) =>
|
|
b.self_microseconds - a.self_microseconds ||
|
|
b.inclusive_microseconds - a.inclusive_microseconds
|
|
);
|
|
}
|
|
|
|
/** @param {ReturnType<typeof analyze_compiler_profiles>} summary */
|
|
function render_markdown(summary) {
|
|
const lines = [
|
|
'# Svelte compiler CPU profile',
|
|
'',
|
|
'Open `flamegraph.speedscope.json` in [Speedscope](https://www.speedscope.app/) for the interactive flame graph.',
|
|
'',
|
|
'## Coverage',
|
|
'',
|
|
'| Metric | Value |',
|
|
'| --- | ---: |',
|
|
`| Raw profiles | ${summary.profile_files} |`,
|
|
`| All profiled CPU time | ${format_time(summary.total_time_microseconds)} |`,
|
|
`| Compiler-active CPU time | ${format_time(summary.compiler_time_microseconds)} |`,
|
|
`| Compiler share | ${summary.compiler_share_percent.toFixed(2)}% |`,
|
|
`| All-process garbage collection | ${format_time(summary.garbage_collection_time_microseconds)} |`,
|
|
'',
|
|
'Compiler-active time includes external callees while a compiler frame is on the stack. Garbage collection is reported for context but cannot be attributed to compiler stacks by V8.',
|
|
'',
|
|
'## Top Self Hotspots',
|
|
'',
|
|
'| Rank | Function | Location | Self | Self % | Inclusive | Inclusive % |',
|
|
'| ---: | --- | --- | ---: | ---: | ---: | ---: |'
|
|
];
|
|
|
|
for (const [index, row] of summary.hotspots.slice(0, 50).entries()) {
|
|
lines.push(render_row(index, row));
|
|
}
|
|
|
|
lines.push(
|
|
'',
|
|
'## Top Files',
|
|
'',
|
|
'| Rank | File | Self | Self % | Inclusive | Inclusive % |',
|
|
'| ---: | --- | ---: | ---: | ---: | ---: |'
|
|
);
|
|
|
|
for (const [index, row] of summary.files.slice(0, 50).entries()) {
|
|
lines.push(
|
|
`| ${index + 1} | ${escape_cell(row.function)} | ${format_time(row.self_microseconds)} | ${row.self_percent.toFixed(2)}% | ${format_time(row.inclusive_microseconds)} | ${row.inclusive_percent.toFixed(2)}% |`
|
|
);
|
|
}
|
|
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
/** @param {number} index @param {ReturnType<typeof rank>[number]} row */
|
|
function render_row(index, row) {
|
|
const location = row.url ? `${row.url}:${row.line}:${row.column}` : '(native)';
|
|
return `| ${index + 1} | ${escape_cell(row.function)} | ${escape_cell(location)} | ${format_time(row.self_microseconds)} | ${row.self_percent.toFixed(2)}% | ${format_time(row.inclusive_microseconds)} | ${row.inclusive_percent.toFixed(2)}% |`;
|
|
}
|
|
|
|
/** @param {number} microseconds */
|
|
function format_time(microseconds) {
|
|
return `${(microseconds / 1000).toFixed(1)} ms`;
|
|
}
|
|
|
|
/** @param {string} value */
|
|
function escape_cell(value) {
|
|
return value.replaceAll('|', '\\|');
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const run_dir = process.argv[2];
|
|
if (!run_dir) {
|
|
console.error('Usage: node benchmarking/analyze-compiler-profile.js <profile-run-directory>');
|
|
process.exit(1);
|
|
}
|
|
analyze_compiler_profiles(path.resolve(run_dir));
|
|
}
|
|
|
|
/**
|
|
* @typedef {{ function_name: string, url: string, line: number, column: number }} Frame
|
|
* @typedef {Frame & { self: number, inclusive: number }} Hotspot
|
|
*/
|