mirror of
https://github.com/SukkaW/Surge.git
synced 2026-09-13 02:54:38 +08:00
Chore: better stat tracking
This commit is contained in:
@@ -1,40 +1,36 @@
|
||||
import { isCI } from 'ci-info';
|
||||
import { noop } from 'foxts/noop';
|
||||
import { basename, extname } from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import process from 'node:process';
|
||||
import picocolors from 'picocolors';
|
||||
import { threadId } from 'node:worker_threads';
|
||||
import { mergeExternalDownloadStats, takeExternalDownloadStats } from '../lib/download-stats';
|
||||
import type { ExternalDownloadStatsSnapshot } from '../lib/download-stats';
|
||||
import { SPAN_STATUS_END, SPAN_STATUS_START, SpanCategory } from './types';
|
||||
import type { RawSpan, TraceResult } from './types';
|
||||
import { adjustTraceTimestamps, printBuildReport } from './report';
|
||||
|
||||
export const SPAN_STATUS_START = 0;
|
||||
export const SPAN_STATUS_END = 1;
|
||||
export { SPAN_STATUS_START, SPAN_STATUS_END, SpanCategory, UNCATEGORIZED } from './types';
|
||||
export type { RawSpan, TraceResult, ReportedSpanCategory, SpanEventLoopUtilization } from './types';
|
||||
export { printTraceResult, printStats, printBuildReport, analyzeTraces } from './report';
|
||||
export type { BuildResourceUsage, TraceAnalysis } from './report';
|
||||
|
||||
const spanTag = Symbol('span');
|
||||
|
||||
export interface TraceResult {
|
||||
name: string,
|
||||
start: number,
|
||||
end: number,
|
||||
children: TraceResult[]
|
||||
}
|
||||
|
||||
/** Pure data object — safe to transfer across Worker Thread boundaries. */
|
||||
export interface RawSpan {
|
||||
traceResult: TraceResult,
|
||||
status: typeof SPAN_STATUS_START | typeof SPAN_STATUS_END
|
||||
}
|
||||
|
||||
export interface Span {
|
||||
[spanTag]: true,
|
||||
readonly rawSpan: RawSpan,
|
||||
readonly stop: (time?: number) => void,
|
||||
readonly traceChild: (name: string) => Span,
|
||||
/** Tag (or re-tag) what this span's self time is spent on */
|
||||
readonly setCategory: (category: SpanCategory) => Span,
|
||||
readonly traceChild: (name: string, category?: SpanCategory) => Span,
|
||||
readonly traceSyncFn: <T>(fn: (span: Span) => T) => T,
|
||||
readonly traceAsyncFn: <T>(fn: (span: Span) => T | Promise<T>) => Promise<T>,
|
||||
readonly tracePromise: <T>(promise: Promise<T>) => Promise<T>,
|
||||
readonly traceChildSync: <T>(name: string, fn: (span: Span) => T) => T,
|
||||
readonly traceChildAsync: <T>(name: string, fn: (span: Span) => Promise<T>) => Promise<T>,
|
||||
readonly traceChildPromise: <T>(name: string, promise: Promise<T>) => Promise<T>,
|
||||
readonly traceChildSync: <T>(name: string, fn: (span: Span) => T, category?: SpanCategory) => T,
|
||||
readonly traceChildAsync: <T>(name: string, fn: (span: Span) => T | Promise<T>, category?: SpanCategory) => Promise<T>,
|
||||
readonly traceChildPromise: <T>(name: string, promise: Promise<T>, category?: SpanCategory) => Promise<T>,
|
||||
/** Always tagged {@link SpanCategory.Worker}: the self time is the IPC + waiting on the worker */
|
||||
readonly traceWorkerChild: <T>(name: string, factory: (rawSpan: RawSpan) => Promise<WorkerJobResult<T>>) => Promise<T>,
|
||||
readonly traceResult: TraceResult
|
||||
}
|
||||
@@ -52,17 +48,26 @@ export function makeSpan(rawSpan: RawSpan): Span {
|
||||
throw new Error(`span already stopped: ${traceResult.name}`);
|
||||
}
|
||||
traceResult.end = time ?? performance.now();
|
||||
if (rawSpan.eluStart) {
|
||||
const elu = performance.eventLoopUtilization(rawSpan.eluStart);
|
||||
traceResult.elu = { idle: elu.idle, active: elu.active };
|
||||
}
|
||||
rawSpan.status = SPAN_STATUS_END;
|
||||
};
|
||||
|
||||
const traceChild = (name: string) => createSpan(name, traceResult);
|
||||
const traceChild = (name: string, category?: SpanCategory) => createSpan(name, traceResult, category);
|
||||
|
||||
const span: Span = {
|
||||
[spanTag]: true,
|
||||
rawSpan,
|
||||
stop,
|
||||
setCategory(category) {
|
||||
traceResult.category = category;
|
||||
return span;
|
||||
},
|
||||
traceChild,
|
||||
traceSyncFn<T>(fn: (span: Span) => T) {
|
||||
traceResult.sync = true;
|
||||
const res = fn(span);
|
||||
span.stop();
|
||||
return res;
|
||||
@@ -78,12 +83,12 @@ export function makeSpan(rawSpan: RawSpan): Span {
|
||||
span.stop();
|
||||
return res;
|
||||
},
|
||||
traceChildSync: <T>(name: string, fn: (span: Span) => T): T => traceChild(name).traceSyncFn(fn),
|
||||
traceChildAsync: <T>(name: string, fn: (span: Span) => T | Promise<T>): Promise<T> => traceChild(name).traceAsyncFn(fn),
|
||||
traceChildPromise: <T>(name: string, promise: Promise<T>): Promise<T> => traceChild(name).tracePromise(promise),
|
||||
traceChildSync: <T>(name: string, fn: (span: Span) => T, category?: SpanCategory): T => traceChild(name, category).traceSyncFn(fn),
|
||||
traceChildAsync: <T>(name: string, fn: (span: Span) => T | Promise<T>, category?: SpanCategory): Promise<T> => traceChild(name, category).traceAsyncFn(fn),
|
||||
traceChildPromise: <T>(name: string, promise: Promise<T>, category?: SpanCategory): Promise<T> => traceChild(name, category).tracePromise(promise),
|
||||
|
||||
async traceWorkerChild<T>(name: string, factory: (rawSpan: RawSpan) => Promise<WorkerJobResult<T>>): Promise<T> {
|
||||
const childSpan = traceChild(name);
|
||||
const childSpan = traceChild(name, SpanCategory.Worker);
|
||||
const { result, traceResult, workerTimeOrigin, externalDownloadStats } = await factory(childSpan.rawSpan);
|
||||
mergeWorkerTrace(childSpan, traceResult, workerTimeOrigin);
|
||||
mergeExternalDownloadStats(externalDownloadStats);
|
||||
@@ -96,18 +101,25 @@ export function makeSpan(rawSpan: RawSpan): Span {
|
||||
return span;
|
||||
}
|
||||
|
||||
export function createSpan(name: string, parentTraceResult?: TraceResult): Span {
|
||||
export function createSpan(name: string, parentTraceResult?: TraceResult, category?: SpanCategory): Span {
|
||||
const traceResult: TraceResult = {
|
||||
name,
|
||||
start: performance.now(),
|
||||
end: 0,
|
||||
thread: threadId,
|
||||
children: []
|
||||
};
|
||||
if (category !== undefined) {
|
||||
traceResult.category = category;
|
||||
}
|
||||
|
||||
const rawSpan: RawSpan = {
|
||||
traceResult: {
|
||||
name,
|
||||
start: performance.now(),
|
||||
end: 0,
|
||||
children: []
|
||||
},
|
||||
status: SPAN_STATUS_START
|
||||
traceResult,
|
||||
status: SPAN_STATUS_START,
|
||||
eluStart: performance.eventLoopUtilization()
|
||||
};
|
||||
|
||||
parentTraceResult?.children.push(rawSpan.traceResult);
|
||||
parentTraceResult?.children.push(traceResult);
|
||||
|
||||
return makeSpan(rawSpan);
|
||||
}
|
||||
@@ -123,6 +135,8 @@ export function task(importMetaMain: boolean, importMetaPath: string) {
|
||||
};
|
||||
|
||||
if (importMetaMain) {
|
||||
const eluAtStart = performance.eventLoopUtilization();
|
||||
const cpuAtStart = process.cpuUsage();
|
||||
const innerSpan = createSpan(taskName);
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
@@ -136,7 +150,11 @@ export function task(importMetaMain: boolean, importMetaPath: string) {
|
||||
|
||||
innerSpan.traceChildAsync('dummy', (childSpan) => fn(childSpan, onCleanup)).finally(() => {
|
||||
innerSpan.stop();
|
||||
printTraceResult(innerSpan.traceResult);
|
||||
innerSpan.traceResult.timeOrigin = performance.timeOrigin;
|
||||
printBuildReport([innerSpan.traceResult], {
|
||||
elu: performance.eventLoopUtilization(eluAtStart),
|
||||
cpu: process.cpuUsage(cpuAtStart)
|
||||
});
|
||||
process.nextTick(whyIsNodeRunning);
|
||||
process.nextTick(() => process.exit(0));
|
||||
});
|
||||
@@ -157,6 +175,9 @@ export function task(importMetaMain: boolean, importMetaPath: string) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
// A task may run on a worker thread (via jest-worker) and hand its trace
|
||||
// back to the main thread, which then needs this to re-align the clock.
|
||||
runSpan.traceResult.timeOrigin = performance.timeOrigin;
|
||||
return runSpan.traceResult;
|
||||
}
|
||||
|
||||
@@ -186,15 +207,6 @@ export async function whyIsNodeRunning() {
|
||||
// };
|
||||
// };
|
||||
|
||||
function adjustTraceTimestamps(trace: TraceResult, offset: number): TraceResult {
|
||||
return {
|
||||
name: trace.name,
|
||||
start: trace.start + offset,
|
||||
end: trace.end + offset,
|
||||
children: trace.children.map(child => adjustTraceTimestamps(child, offset))
|
||||
};
|
||||
}
|
||||
|
||||
function mergeWorkerTrace(
|
||||
parentSpan: Span,
|
||||
workerTraceResult: TraceResult,
|
||||
@@ -240,67 +252,3 @@ export async function workerJob<T>(
|
||||
externalDownloadStats: takeExternalDownloadStats()
|
||||
};
|
||||
}
|
||||
|
||||
export function printTraceResult(traceResult: TraceResult) {
|
||||
printTree(
|
||||
traceResult,
|
||||
node => {
|
||||
if (node.end - node.start < 0) {
|
||||
return node.name;
|
||||
}
|
||||
return `${node.name} ${picocolors.bold(`${(node.end - node.start).toFixed(3)}ms`)}`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function printTree(initialTree: TraceResult, printNode: (node: TraceResult, branch: string) => string) {
|
||||
function printBranch(tree: TraceResult, branch: string, isGraphHead: boolean, isChildOfLastBranch: boolean) {
|
||||
const children = tree.children;
|
||||
|
||||
let branchHead = '';
|
||||
|
||||
if (!isGraphHead) {
|
||||
branchHead = children.length > 0 ? '┬ ' : '─ ';
|
||||
}
|
||||
|
||||
const toPrint = printNode(tree, `${branch}${branchHead}`);
|
||||
|
||||
if (typeof toPrint === 'string') {
|
||||
console.log(`${branch}${branchHead}${toPrint}`);
|
||||
}
|
||||
|
||||
let baseBranch = branch;
|
||||
|
||||
if (!isGraphHead) {
|
||||
baseBranch = branch.slice(0, -2) + (isChildOfLastBranch ? ' ' : '│ ');
|
||||
}
|
||||
|
||||
const nextBranch = `${baseBranch}├─`;
|
||||
const lastBranch = `${baseBranch}└─`;
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const last = children.length - 1 === index;
|
||||
printBranch(child, last ? lastBranch : nextBranch, false, last);
|
||||
});
|
||||
}
|
||||
|
||||
printBranch(initialTree, '', true, false);
|
||||
}
|
||||
|
||||
export function printStats(stats: TraceResult[]): void {
|
||||
const longestTaskName = Math.max(...stats.map(i => i.name.length));
|
||||
const realStart = Math.min(...stats.map(i => i.start));
|
||||
const realEnd = Math.max(...stats.map(i => i.end));
|
||||
|
||||
const statsStep = ((realEnd - realStart) / 120) | 0;
|
||||
|
||||
stats
|
||||
.sort((a, b) => a.start - b.start)
|
||||
.forEach(stat => {
|
||||
console.log(
|
||||
`[${stat.name}]${' '.repeat(longestTaskName - stat.name.length)}`,
|
||||
' '.repeat(((stat.start - realStart) / statsStep) | 0),
|
||||
'='.repeat(Math.max(((stat.end - stat.start) / statsStep) | 0, 1))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
102
Build/trace/report.test.ts
Normal file
102
Build/trace/report.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it } from 'mocha';
|
||||
import { expect } from 'earl';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { analyzeTraces } from './report';
|
||||
import { SpanCategory, UNCATEGORIZED } from './types';
|
||||
import type { TraceResult } from './types';
|
||||
import type { CategoryStat, TraceAnalysis } from './report';
|
||||
|
||||
function span(
|
||||
name: string,
|
||||
start: number,
|
||||
end: number,
|
||||
extra: Partial<Pick<TraceResult, 'category' | 'thread' | 'sync' | 'timeOrigin'>> = {},
|
||||
children: TraceResult[] = []
|
||||
): TraceResult {
|
||||
return { name, start, end, thread: 0, children, ...extra };
|
||||
}
|
||||
|
||||
function indexByCategory(analysis: TraceAnalysis) {
|
||||
return analysis.categories.reduce<Record<string, CategoryStat>>((acc, c) => {
|
||||
acc[c.category] = c;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
describe('trace report analysis', () => {
|
||||
it('attributes self time as duration minus the union of (overlapping) children', () => {
|
||||
const task = span('task', 0, 100, {}, [
|
||||
// two async children that overlap: 10-50 and 30-70 cover 60ms, not 80ms
|
||||
span('a', 10, 50, { category: SpanCategory.Network }),
|
||||
span('b', 30, 70, { category: SpanCategory.Network }),
|
||||
span('c', 80, 90, { category: SpanCategory.Compute, sync: true })
|
||||
]);
|
||||
|
||||
const analysis = analyzeTraces([task]);
|
||||
const byCategory = indexByCategory(analysis);
|
||||
|
||||
expect(analysis.wall).toEqual(100);
|
||||
expect(byCategory[UNCATEGORIZED].selfTotal).toEqual(100 - 60 - 10);
|
||||
// self of leaves is their full duration, summed across the concurrent pair
|
||||
expect(byCategory[SpanCategory.Network].selfTotal).toEqual(40 + 40);
|
||||
// coverage de-duplicates the overlap
|
||||
expect(byCategory[SpanCategory.Network].coverage).toEqual(60);
|
||||
expect(byCategory[SpanCategory.Compute].selfTotal).toEqual(10);
|
||||
expect(byCategory[SpanCategory.Compute].selfSync).toEqual(10);
|
||||
expect(byCategory[SpanCategory.Network].selfSync).toEqual(0);
|
||||
|
||||
expect(analysis.tasks[0].byCategory[SpanCategory.Network]).toEqual(80);
|
||||
expect(analysis.tasks[0].byCategory[UNCATEGORIZED]).toEqual(30);
|
||||
});
|
||||
|
||||
it('clips children to the parent window and never reports negative self time', () => {
|
||||
// fire-and-forget child that outlives its parent
|
||||
const task = span('task', 0, 50, {}, [span('late', 40, 90, { category: SpanCategory.FsWrite })]);
|
||||
|
||||
const analysis = analyzeTraces([task]);
|
||||
const byCategory = indexByCategory(analysis);
|
||||
|
||||
expect(byCategory[UNCATEGORIZED].selfTotal).toEqual(40);
|
||||
expect(byCategory[SpanCategory.FsWrite].selfTotal).toEqual(50);
|
||||
expect(analysis.wall).toEqual(90);
|
||||
});
|
||||
|
||||
it('skips unfinished spans but counts them', () => {
|
||||
const task = span('task', 0, 10, {}, [span('never stopped', 5, 0)]);
|
||||
|
||||
const analysis = analyzeTraces([task]);
|
||||
|
||||
expect(analysis.unfinishedSpans).toEqual(1);
|
||||
// the unfinished child does not eat into the parent's self time
|
||||
expect(analysis.categories.find(c => c.category === UNCATEGORIZED)!.selfTotal).toEqual(10);
|
||||
});
|
||||
|
||||
it('splits self time between the main thread and workers', () => {
|
||||
const task = span('task', 0, 100, {}, [
|
||||
span('offload', 0, 100, { category: SpanCategory.Worker }, [
|
||||
span('crunch', 20, 60, { category: SpanCategory.Compute, thread: 7, sync: true })
|
||||
])
|
||||
]);
|
||||
|
||||
const analysis = analyzeTraces([task]);
|
||||
const compute = analysis.categories.find(c => c.category === SpanCategory.Compute)!;
|
||||
const worker = analysis.categories.find(c => c.category === SpanCategory.Worker)!;
|
||||
|
||||
expect(compute.selfOnWorkers).toEqual(40);
|
||||
expect(compute.selfOnMainThread).toEqual(0);
|
||||
expect(worker.selfOnMainThread).toEqual(60);
|
||||
});
|
||||
|
||||
it('shifts a trace produced on another thread onto the local clock', () => {
|
||||
// A worker whose clock started 1000ms after ours reports everything 1000ms early
|
||||
const workerTask = span('worker task', 0, 10, { timeOrigin: performance.timeOrigin + 1000 });
|
||||
const mainTask = span('main task', 1000, 1010);
|
||||
|
||||
const analysis = analyzeTraces([workerTask, mainTask]);
|
||||
|
||||
expect(analysis.wallStart).toEqual(1000);
|
||||
expect(analysis.wallEnd).toEqual(1010);
|
||||
expect(analysis.tasks[0].node.start).toEqual(1000);
|
||||
});
|
||||
});
|
||||
596
Build/trace/report.ts
Normal file
596
Build/trace/report.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
import picocolors from 'picocolors';
|
||||
import { SpanCategory, UNCATEGORIZED } from './types';
|
||||
import type { ReportedSpanCategory, SpanEventLoopUtilization, TraceResult } from './types';
|
||||
|
||||
/** Fixed display order: I/O flavours first, then compute, then the "waiting on something else" buckets */
|
||||
const CATEGORY_ORDER: ReportedSpanCategory[] = [
|
||||
SpanCategory.Network,
|
||||
SpanCategory.FsRead,
|
||||
SpanCategory.FsWrite,
|
||||
SpanCategory.Compute,
|
||||
SpanCategory.Worker,
|
||||
SpanCategory.Wait,
|
||||
UNCATEGORIZED
|
||||
];
|
||||
|
||||
const CATEGORY_COLOR: Record<ReportedSpanCategory, (s: string) => string> = {
|
||||
[SpanCategory.Network]: picocolors.cyan,
|
||||
[SpanCategory.FsRead]: picocolors.green,
|
||||
[SpanCategory.FsWrite]: picocolors.yellow,
|
||||
[SpanCategory.Compute]: picocolors.magenta,
|
||||
[SpanCategory.Worker]: picocolors.blue,
|
||||
[SpanCategory.Wait]: picocolors.gray,
|
||||
[UNCATEGORIZED]: picocolors.gray
|
||||
};
|
||||
|
||||
const TOP_SPANS_PER_CATEGORY = 5;
|
||||
const TOP_SPANS_OVERALL = 15;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clock alignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function adjustTraceTimestamps(trace: TraceResult, offset: number): TraceResult {
|
||||
const adjusted: TraceResult = {
|
||||
...trace,
|
||||
start: trace.start + offset,
|
||||
end: trace.end + offset,
|
||||
children: trace.children.map(child => adjustTraceTimestamps(child, offset))
|
||||
};
|
||||
// the clock is now the main thread's, so the marker no longer applies
|
||||
delete adjusted.timeOrigin;
|
||||
return adjusted;
|
||||
}
|
||||
|
||||
/**
|
||||
* A task that ran on a worker thread reports `performance.now()` values relative
|
||||
* to that thread's own `timeOrigin`. Shift them onto the current thread's clock
|
||||
* so tasks can be laid out on a shared timeline.
|
||||
*/
|
||||
export function normalizeTraceClock(trace: TraceResult): TraceResult {
|
||||
if (trace.timeOrigin == null || trace.timeOrigin === performance.timeOrigin) {
|
||||
return trace;
|
||||
}
|
||||
return adjustTraceTimestamps(trace, trace.timeOrigin - performance.timeOrigin);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AnalyzedSpan {
|
||||
node: TraceResult,
|
||||
/** Ancestor names, root task first, this span last */
|
||||
path: string[],
|
||||
category: ReportedSpanCategory,
|
||||
duration: number,
|
||||
/** Duration not covered by any traced child */
|
||||
self: number
|
||||
}
|
||||
|
||||
export interface CategoryStat {
|
||||
category: ReportedSpanCategory,
|
||||
/** Sum of self time of all spans with this category (across all threads, concurrency included) */
|
||||
selfTotal: number,
|
||||
/** Wall-clock during which at least one span of this category was in flight */
|
||||
coverage: number,
|
||||
spans: number,
|
||||
selfOnMainThread: number,
|
||||
selfOnWorkers: number,
|
||||
/** Self time of synchronous spans: guaranteed CPU, no event-loop queueing inside */
|
||||
selfSync: number,
|
||||
top: AnalyzedSpan[]
|
||||
}
|
||||
|
||||
export interface TaskStat {
|
||||
node: TraceResult,
|
||||
duration: number,
|
||||
/** Self time of all descendants (and the task itself) bucketed by category */
|
||||
byCategory: Record<ReportedSpanCategory, number>
|
||||
}
|
||||
|
||||
export interface TraceAnalysis {
|
||||
wallStart: number,
|
||||
wallEnd: number,
|
||||
wall: number,
|
||||
categories: CategoryStat[],
|
||||
tasks: TaskStat[],
|
||||
topSpans: AnalyzedSpan[],
|
||||
unfinishedSpans: number
|
||||
}
|
||||
|
||||
type Interval = [start: number, end: number];
|
||||
|
||||
function isFinished(node: TraceResult) {
|
||||
return node.end >= node.start;
|
||||
}
|
||||
|
||||
/** Total length covered by the union of the intervals (handles overlap, which async children routinely do) */
|
||||
function unionLength(intervals: Interval[]): number {
|
||||
if (intervals.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
intervals.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
let total = 0;
|
||||
let [curStart, curEnd] = intervals[0];
|
||||
for (let i = 1, len = intervals.length; i < len; i++) {
|
||||
const [start, end] = intervals[i];
|
||||
if (start <= curEnd) {
|
||||
if (end > curEnd) {
|
||||
curEnd = end;
|
||||
}
|
||||
} else {
|
||||
total += curEnd - curStart;
|
||||
curStart = start;
|
||||
curEnd = end;
|
||||
}
|
||||
}
|
||||
return total + (curEnd - curStart);
|
||||
}
|
||||
|
||||
function selfTime(node: TraceResult): number {
|
||||
const duration = node.end - node.start;
|
||||
if (node.children.length === 0) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
const covered: Interval[] = [];
|
||||
for (let i = 0, len = node.children.length; i < len; i++) {
|
||||
const child = node.children[i];
|
||||
if (!isFinished(child)) {
|
||||
continue;
|
||||
}
|
||||
// clip to the parent's own window: a child stopped after its parent
|
||||
// (fire-and-forget) must not produce negative self time
|
||||
const start = Math.max(child.start, node.start);
|
||||
const end = Math.min(child.end, node.end);
|
||||
if (end > start) {
|
||||
covered.push([start, end]);
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, duration - unionLength(covered));
|
||||
}
|
||||
|
||||
function emptyByCategory(): Record<ReportedSpanCategory, number> {
|
||||
return {
|
||||
[SpanCategory.Network]: 0,
|
||||
[SpanCategory.FsRead]: 0,
|
||||
[SpanCategory.FsWrite]: 0,
|
||||
[SpanCategory.Compute]: 0,
|
||||
[SpanCategory.Worker]: 0,
|
||||
[SpanCategory.Wait]: 0,
|
||||
[UNCATEGORIZED]: 0
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeTraces(rawTraces: TraceResult[]): TraceAnalysis {
|
||||
const traces = rawTraces.map(normalizeTraceClock);
|
||||
|
||||
const spans: AnalyzedSpan[] = [];
|
||||
const coverageIntervals = new Map<ReportedSpanCategory, Interval[]>();
|
||||
const categoryStats = new Map<ReportedSpanCategory, CategoryStat>();
|
||||
for (let i = 0, len = CATEGORY_ORDER.length; i < len; i++) {
|
||||
const category = CATEGORY_ORDER[i];
|
||||
coverageIntervals.set(category, []);
|
||||
categoryStats.set(category, {
|
||||
category,
|
||||
selfTotal: 0,
|
||||
coverage: 0,
|
||||
spans: 0,
|
||||
selfOnMainThread: 0,
|
||||
selfOnWorkers: 0,
|
||||
selfSync: 0,
|
||||
top: []
|
||||
});
|
||||
}
|
||||
|
||||
let unfinishedSpans = 0;
|
||||
let wallStart = Infinity;
|
||||
let wallEnd = -Infinity;
|
||||
|
||||
const walk = (node: TraceResult, path: string[], task: TaskStat) => {
|
||||
const ownPath = path.concat(node.name);
|
||||
|
||||
if (isFinished(node)) {
|
||||
const category = node.category ?? UNCATEGORIZED;
|
||||
const self = selfTime(node);
|
||||
spans.push({ node, path: ownPath, category, duration: node.end - node.start, self });
|
||||
|
||||
const stat = categoryStats.get(category)!;
|
||||
stat.selfTotal += self;
|
||||
stat.spans++;
|
||||
if (node.thread === 0) {
|
||||
stat.selfOnMainThread += self;
|
||||
} else {
|
||||
stat.selfOnWorkers += self;
|
||||
}
|
||||
if (node.sync) {
|
||||
stat.selfSync += self;
|
||||
}
|
||||
coverageIntervals.get(category)!.push([node.start, node.end]);
|
||||
task.byCategory[category] += self;
|
||||
|
||||
if (node.start < wallStart) wallStart = node.start;
|
||||
if (node.end > wallEnd) wallEnd = node.end;
|
||||
} else {
|
||||
unfinishedSpans++;
|
||||
}
|
||||
|
||||
for (let i = 0, len = node.children.length; i < len; i++) {
|
||||
walk(node.children[i], ownPath, task);
|
||||
}
|
||||
};
|
||||
|
||||
const tasks: TaskStat[] = traces.map((trace) => {
|
||||
const task: TaskStat = {
|
||||
node: trace,
|
||||
duration: isFinished(trace) ? trace.end - trace.start : 0,
|
||||
byCategory: emptyByCategory()
|
||||
};
|
||||
walk(trace, [], task);
|
||||
return task;
|
||||
});
|
||||
|
||||
spans.sort((a, b) => b.self - a.self);
|
||||
|
||||
const categories = CATEGORY_ORDER.map((category) => {
|
||||
const stat = categoryStats.get(category)!;
|
||||
stat.coverage = unionLength(coverageIntervals.get(category)!);
|
||||
stat.top = spans.filter(s => s.category === category && s.self > 0).slice(0, TOP_SPANS_PER_CATEGORY);
|
||||
return stat;
|
||||
});
|
||||
|
||||
return {
|
||||
wallStart,
|
||||
wallEnd,
|
||||
wall: wallEnd > wallStart ? wallEnd - wallStart : 0,
|
||||
categories,
|
||||
tasks,
|
||||
topSpans: spans.filter(s => s.self > 0).slice(0, TOP_SPANS_OVERALL),
|
||||
unfinishedSpans
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtMs(ms: number): string {
|
||||
if (ms >= 10000) {
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
if (ms >= 1000) {
|
||||
return `${(ms / 1000).toFixed(3)}s`;
|
||||
}
|
||||
return `${ms.toFixed(1)}ms`;
|
||||
}
|
||||
|
||||
function fmtPercent(part: number, whole: number): string {
|
||||
if (whole <= 0) {
|
||||
return 'n/a';
|
||||
}
|
||||
return `${(part / whole * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function categoryTag(category: ReportedSpanCategory): string {
|
||||
return CATEGORY_COLOR[category](`[${category}]`);
|
||||
}
|
||||
|
||||
function loopBusy(node: TraceResult): string | null {
|
||||
const { elu } = node;
|
||||
// a sync span never yields to the loop, so the number would be a trivial 100%
|
||||
if (!elu || node.sync) {
|
||||
return null;
|
||||
}
|
||||
const total = elu.idle + elu.active;
|
||||
if (total < 1) {
|
||||
return null;
|
||||
}
|
||||
return `loop-busy=${fmtPercent(elu.active, total)}`;
|
||||
}
|
||||
|
||||
function threadLabel(thread: number): string {
|
||||
return thread === 0 ? 'main' : `worker#${thread}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the middle of a breadcrumb so both ends survive: the task it belongs
|
||||
* to and the span itself (plus as many of its nearest ancestors as fit).
|
||||
*/
|
||||
function fmtPath(path: string[], maxLength: number): string {
|
||||
if (path.length < 3) {
|
||||
return path.join(' › ');
|
||||
}
|
||||
const head = path[0];
|
||||
let out = path.at(-1)!;
|
||||
for (let i = path.length - 2; i >= 1; i--) {
|
||||
const candidate = `${path[i]} › ${out}`;
|
||||
if (head.length + candidate.length + 8 > maxLength) {
|
||||
return `${head} › … › ${out}`;
|
||||
}
|
||||
out = candidate;
|
||||
}
|
||||
return `${head} › ${out}`;
|
||||
}
|
||||
|
||||
/** Printable width of a string that may carry picocolors escapes */
|
||||
function visibleLength(s: string): number {
|
||||
return stripVTControlCharacters(s).length;
|
||||
}
|
||||
|
||||
function pad(s: string, width: number, alignRight: boolean): string {
|
||||
const fill = ' '.repeat(Math.max(0, width - visibleLength(s)));
|
||||
return alignRight ? fill + s : s + fill;
|
||||
}
|
||||
|
||||
function table(header: string[], rows: string[][], rightAlignFrom = 1): string[] {
|
||||
const widths = header.map((h, col) => Math.max(visibleLength(h), ...rows.map(r => visibleLength(r[col]))));
|
||||
const fmtRow = (row: string[]) => row
|
||||
.map((cell, col) => pad(cell, widths[col], col >= rightAlignFrom))
|
||||
.join(' ');
|
||||
return [
|
||||
picocolors.bold(fmtRow(header)),
|
||||
picocolors.dim(widths.map(w => '─'.repeat(w)).join(' ')),
|
||||
...rows.map(fmtRow)
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Printing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function printTraceResult(traceResult: TraceResult) {
|
||||
printTree(
|
||||
normalizeTraceClock(traceResult),
|
||||
(node, parentThread) => {
|
||||
const parts: string[] = [node.name];
|
||||
|
||||
if (node.category) {
|
||||
parts.push(categoryTag(node.category));
|
||||
}
|
||||
|
||||
if (!isFinished(node)) {
|
||||
parts.push(picocolors.red('(unfinished)'));
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
parts.push(picocolors.bold(fmtMs(node.end - node.start)));
|
||||
|
||||
if (node.children.length > 0) {
|
||||
parts.push(picocolors.dim(`self=${fmtMs(selfTime(node))}`));
|
||||
}
|
||||
|
||||
const busy = loopBusy(node);
|
||||
if (busy) {
|
||||
parts.push(picocolors.dim(busy));
|
||||
}
|
||||
|
||||
if (node.thread !== parentThread) {
|
||||
parts.push(picocolors.dim(`@${threadLabel(node.thread)}`));
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function printTree(initialTree: TraceResult, printNode: (node: TraceResult, parentThread: number) => string) {
|
||||
function printBranch(tree: TraceResult, branch: string, isGraphHead: boolean, isChildOfLastBranch: boolean, parentThread: number) {
|
||||
const children = tree.children;
|
||||
|
||||
let branchHead = '';
|
||||
|
||||
if (!isGraphHead) {
|
||||
branchHead = children.length > 0 ? '┬ ' : '─ ';
|
||||
}
|
||||
|
||||
console.log(`${branch}${branchHead}${printNode(tree, parentThread)}`);
|
||||
|
||||
let baseBranch = branch;
|
||||
|
||||
if (!isGraphHead) {
|
||||
baseBranch = branch.slice(0, -2) + (isChildOfLastBranch ? ' ' : '│ ');
|
||||
}
|
||||
|
||||
const nextBranch = `${baseBranch}├─`;
|
||||
const lastBranch = `${baseBranch}└─`;
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const last = children.length - 1 === index;
|
||||
printBranch(child, last ? lastBranch : nextBranch, false, last, tree.thread);
|
||||
});
|
||||
}
|
||||
|
||||
printBranch(initialTree, '', true, false, initialTree.thread);
|
||||
}
|
||||
|
||||
/** Gantt-style overview of the top-level tasks on a shared timeline */
|
||||
export function printStats(rawStats: TraceResult[]): void {
|
||||
const stats = rawStats.reduce<TraceResult[]>((acc, trace) => {
|
||||
const normalized = normalizeTraceClock(trace);
|
||||
if (isFinished(normalized)) {
|
||||
acc.push(normalized);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
if (stats.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const longestTaskName = Math.max(...stats.map(i => i.name.length));
|
||||
const realStart = Math.min(...stats.map(i => i.start));
|
||||
const realEnd = Math.max(...stats.map(i => i.end));
|
||||
|
||||
const width = 100;
|
||||
const statsStep = Math.max((realEnd - realStart) / width, 1);
|
||||
|
||||
console.log(picocolors.bold('[timeline]'), `${fmtMs(realEnd - realStart)} wall, one column ≈ ${fmtMs(statsStep)}`);
|
||||
|
||||
stats
|
||||
.sort((a, b) => a.start - b.start)
|
||||
.forEach((stat) => {
|
||||
const offset = ((stat.start - realStart) / statsStep) | 0;
|
||||
const length = Math.max(((stat.end - stat.start) / statsStep) | 0, 1);
|
||||
console.log(
|
||||
`[${stat.name}]${' '.repeat(longestTaskName - stat.name.length)}`,
|
||||
' '.repeat(offset) + '='.repeat(length),
|
||||
picocolors.dim(fmtMs(stat.end - stat.start) + (stat.thread === 0 ? '' : ` @${threadLabel(stat.thread)}`))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface BuildResourceUsage {
|
||||
/** Main thread event loop utilization delta over the whole build */
|
||||
elu?: SpanEventLoopUtilization,
|
||||
/** `process.cpuUsage()` delta over the whole build, in microseconds. Covers every thread */
|
||||
cpu?: NodeJS.CpuUsage
|
||||
}
|
||||
|
||||
function printOverview(analysis: TraceAnalysis, usage: BuildResourceUsage | undefined) {
|
||||
const parts = [`wall=${fmtMs(analysis.wall)}`];
|
||||
|
||||
if (usage?.elu) {
|
||||
const { active, idle } = usage.elu;
|
||||
parts.push(`main-loop-busy=${fmtMs(active)} (${fmtPercent(active, active + idle)})`, `main-loop-idle=${fmtMs(idle)}`);
|
||||
}
|
||||
if (usage?.cpu) {
|
||||
const user = usage.cpu.user / 1000;
|
||||
const system = usage.cpu.system / 1000;
|
||||
const total = user + system;
|
||||
parts.push(
|
||||
`process-cpu=${fmtMs(total)} (user ${fmtMs(user)} / sys ${fmtMs(system)})`,
|
||||
analysis.wall > 0 ? `cpu/wall=${(total / analysis.wall).toFixed(2)}x` : ''
|
||||
);
|
||||
}
|
||||
if (analysis.unfinishedSpans > 0) {
|
||||
parts.push(picocolors.red(`unfinished-spans=${analysis.unfinishedSpans}`));
|
||||
}
|
||||
|
||||
console.log(picocolors.bold('[build]'), parts.filter(Boolean).join(' '));
|
||||
}
|
||||
|
||||
function printCategoryBreakdown(analysis: TraceAnalysis) {
|
||||
const grandTotal = analysis.categories.reduce((acc, c) => acc + c.selfTotal, 0);
|
||||
|
||||
console.log();
|
||||
console.log(picocolors.bold('[time by category]'));
|
||||
console.log(picocolors.dim(
|
||||
' self = span time not covered by traced children, summed across concurrent spans (so it exceeds wall;\n'
|
||||
+ ' an async span on a busy event loop also includes time queued behind other work);\n'
|
||||
+ ' coverage = wall-clock during which at least one span of that category was in flight;\n'
|
||||
+ ' sync = the part of self that came from synchronous spans, i.e. guaranteed CPU on that thread'
|
||||
));
|
||||
|
||||
const rows = analysis.categories.reduce<string[][]>((acc, c) => {
|
||||
if (c.spans > 0) {
|
||||
acc.push([
|
||||
CATEGORY_COLOR[c.category](c.category),
|
||||
fmtMs(c.selfTotal),
|
||||
fmtPercent(c.selfTotal, grandTotal),
|
||||
fmtMs(c.coverage),
|
||||
fmtPercent(c.coverage, analysis.wall),
|
||||
String(c.spans),
|
||||
fmtMs(c.selfOnMainThread),
|
||||
fmtMs(c.selfOnWorkers),
|
||||
fmtMs(c.selfSync)
|
||||
]);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
table(['category', 'self', 'share', 'coverage', 'of wall', 'spans', 'main', 'workers', 'sync'], rows)
|
||||
.forEach(line => console.log(' ' + line));
|
||||
}
|
||||
|
||||
function printTopSpans(analysis: TraceAnalysis) {
|
||||
console.log();
|
||||
console.log(picocolors.bold('[top spans by self time, per category]'));
|
||||
for (let i = 0, len = analysis.categories.length; i < len; i++) {
|
||||
const stat = analysis.categories[i];
|
||||
if (stat.top.length === 0) {
|
||||
continue;
|
||||
}
|
||||
console.log(' ' + categoryTag(stat.category));
|
||||
for (let j = 0, jlen = stat.top.length; j < jlen; j++) {
|
||||
const span = stat.top[j];
|
||||
const extra: string[] = [];
|
||||
if (span.node.children.length > 0) {
|
||||
extra.push(`wall=${fmtMs(span.duration)}`);
|
||||
}
|
||||
const busy = loopBusy(span.node);
|
||||
if (busy) {
|
||||
extra.push(busy);
|
||||
}
|
||||
if (span.node.thread !== 0) {
|
||||
extra.push(`@${threadLabel(span.node.thread)}`);
|
||||
}
|
||||
console.log(
|
||||
' ',
|
||||
picocolors.bold(fmtMs(span.self).padStart(9)),
|
||||
fmtPath(span.path, 110),
|
||||
extra.length ? picocolors.dim(extra.join(' ')) : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(picocolors.bold('[top spans by self time, overall]'));
|
||||
for (let i = 0, len = analysis.topSpans.length; i < len; i++) {
|
||||
const span = analysis.topSpans[i];
|
||||
console.log(
|
||||
' ',
|
||||
picocolors.bold(fmtMs(span.self).padStart(9)),
|
||||
categoryTag(span.category).padEnd(16),
|
||||
fmtPath(span.path, 110)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printTaskMatrix(analysis: TraceAnalysis) {
|
||||
if (analysis.tasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(picocolors.bold('[time by task × category]'), picocolors.dim('(self time of the task and all its descendants)'));
|
||||
|
||||
const usedCategories = CATEGORY_ORDER.filter(c => analysis.tasks.some(t => t.byCategory[c] > 0));
|
||||
|
||||
const rows = analysis.tasks
|
||||
.toSorted((a, b) => b.duration - a.duration)
|
||||
.map((task) => {
|
||||
const busy = task.node.elu ? fmtPercent(task.node.elu.active, task.node.elu.active + task.node.elu.idle) : 'n/a';
|
||||
return [
|
||||
task.node.name + (task.node.thread === 0 ? '' : picocolors.dim(` @${threadLabel(task.node.thread)}`)),
|
||||
fmtMs(task.duration),
|
||||
busy,
|
||||
...usedCategories.map(c => (task.byCategory[c] > 0 ? fmtMs(task.byCategory[c]) : picocolors.dim('·')))
|
||||
];
|
||||
});
|
||||
|
||||
table(['task', 'wall', 'loop-busy', ...usedCategories], rows)
|
||||
.forEach(line => console.log(' ' + line));
|
||||
}
|
||||
|
||||
/**
|
||||
* The full post-build report: every task's span tree, then the aggregate views
|
||||
* (where does time go by category, which spans dominate, per-task breakdown and
|
||||
* the shared timeline).
|
||||
*/
|
||||
export function printBuildReport(traces: TraceResult[], usage?: BuildResourceUsage) {
|
||||
traces.forEach(printTraceResult);
|
||||
|
||||
const analysis = analyzeTraces(traces);
|
||||
|
||||
console.log();
|
||||
printOverview(analysis, usage);
|
||||
printCategoryBreakdown(analysis);
|
||||
printTopSpans(analysis);
|
||||
printTaskMatrix(analysis);
|
||||
console.log();
|
||||
printStats(traces);
|
||||
}
|
||||
68
Build/trace/types.ts
Normal file
68
Build/trace/types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { EventLoopUtilization } from 'node:perf_hooks';
|
||||
|
||||
export const SPAN_STATUS_START = 0;
|
||||
export const SPAN_STATUS_END = 1;
|
||||
|
||||
/**
|
||||
* What a span spends its *self* time on (its own duration minus whatever its
|
||||
* traced children cover). Unmarked spans are reported as "uncategorized" rather
|
||||
* than inheriting from their parent, so gaps in instrumentation stay visible.
|
||||
*/
|
||||
export enum SpanCategory {
|
||||
/** CPU-bound work on the current thread (parsing, trie ops, hashing, formatting) */
|
||||
Compute = 'compute',
|
||||
/** Reading from the local filesystem */
|
||||
FsRead = 'fs-read',
|
||||
/** Writing to the local filesystem */
|
||||
FsWrite = 'fs-write',
|
||||
/** Fetching from the network (download, DNS, HTTP HEAD, ...) */
|
||||
Network = 'network',
|
||||
/** Handing work to / waiting on a worker thread. Set automatically by traceWorkerChild */
|
||||
Worker = 'worker',
|
||||
/** Waiting on another in-flight promise that is traced (or untraced) elsewhere */
|
||||
Wait = 'wait'
|
||||
}
|
||||
|
||||
export const UNCATEGORIZED = 'uncategorized';
|
||||
export type ReportedSpanCategory = SpanCategory | typeof UNCATEGORIZED;
|
||||
|
||||
/** Delta of `performance.eventLoopUtilization()` across the span, in milliseconds */
|
||||
export interface SpanEventLoopUtilization {
|
||||
idle: number,
|
||||
active: number
|
||||
}
|
||||
|
||||
export interface TraceResult {
|
||||
name: string,
|
||||
category?: SpanCategory,
|
||||
start: number,
|
||||
end: number,
|
||||
/**
|
||||
* Event loop utilization of the thread that ran this span, over the span's
|
||||
* lifetime. `active` is an upper bound of this span's own CPU time: on a shared
|
||||
* event loop it also includes work done by concurrently running spans.
|
||||
*/
|
||||
elu?: SpanEventLoopUtilization,
|
||||
/**
|
||||
* Set when the span wrapped a synchronous function: its whole self time is
|
||||
* CPU time on its thread, never queueing behind other work on the event loop.
|
||||
*/
|
||||
sync?: true,
|
||||
/** `worker_threads.threadId` of the thread that created the span (0 = main thread) */
|
||||
thread: number,
|
||||
/**
|
||||
* `performance.timeOrigin` of the thread that produced this trace. Only set on
|
||||
* the root returned by a task so traces produced on a worker thread can be
|
||||
* shifted onto the main thread's clock before being compared with others.
|
||||
*/
|
||||
timeOrigin?: number,
|
||||
children: TraceResult[]
|
||||
}
|
||||
|
||||
/** Pure data object — safe to transfer across Worker Thread boundaries. */
|
||||
export interface RawSpan {
|
||||
traceResult: TraceResult,
|
||||
status: typeof SPAN_STATUS_START | typeof SPAN_STATUS_END,
|
||||
/** `performance.eventLoopUtilization()` sampled when the span started */
|
||||
eluStart?: EventLoopUtilization
|
||||
}
|
||||
Reference in New Issue
Block a user