mirror of
https://github.com/SukkaW/Surge.git
synced 2026-09-12 18:44:36 +08:00
Chore: event loop wall build time tracking
This commit is contained in:
@@ -49,8 +49,7 @@ export function makeSpan(rawSpan: RawSpan): Span {
|
|||||||
}
|
}
|
||||||
traceResult.end = time ?? performance.now();
|
traceResult.end = time ?? performance.now();
|
||||||
if (rawSpan.eluStart) {
|
if (rawSpan.eluStart) {
|
||||||
const elu = performance.eventLoopUtilization(rawSpan.eluStart);
|
traceResult.loopIdle = { atStart: rawSpan.eluStart.idle, atEnd: performance.eventLoopUtilization().idle };
|
||||||
traceResult.elu = { idle: elu.idle, active: elu.active };
|
|
||||||
}
|
}
|
||||||
rawSpan.status = SPAN_STATUS_END;
|
rawSpan.status = SPAN_STATUS_END;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,18 +3,26 @@ import { expect } from 'earl';
|
|||||||
import { performance } from 'node:perf_hooks';
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|
||||||
import { analyzeTraces } from './report';
|
import { analyzeTraces } from './report';
|
||||||
|
import type { CategoryStat, TraceAnalysis } from './report';
|
||||||
import { SpanCategory, UNCATEGORIZED } from './types';
|
import { SpanCategory, UNCATEGORIZED } from './types';
|
||||||
import type { TraceResult } from './types';
|
import type { TraceResult } from './types';
|
||||||
import type { CategoryStat, TraceAnalysis } from './report';
|
|
||||||
|
|
||||||
function span(
|
interface SpanExtra extends Partial<Pick<TraceResult, 'category' | 'thread' | 'sync' | 'timeOrigin'>> {
|
||||||
name: string,
|
/** cumulative loop idle (ms) at start and end; defaults to "loop never idle" */
|
||||||
start: number,
|
idle?: [atStart: number, atEnd: number]
|
||||||
end: number,
|
}
|
||||||
extra: Partial<Pick<TraceResult, 'category' | 'thread' | 'sync' | 'timeOrigin'>> = {},
|
|
||||||
children: TraceResult[] = []
|
function span(name: string, start: number, end: number, extra: SpanExtra = {}, children: TraceResult[] = []): TraceResult {
|
||||||
): TraceResult {
|
const { idle, ...rest } = extra;
|
||||||
return { name, start, end, thread: 0, children, ...extra };
|
return {
|
||||||
|
name,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
thread: 0,
|
||||||
|
children,
|
||||||
|
loopIdle: { atStart: idle?.[0] ?? 0, atEnd: idle?.[1] ?? 0 },
|
||||||
|
...rest
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function indexByCategory(analysis: TraceAnalysis) {
|
function indexByCategory(analysis: TraceAnalysis) {
|
||||||
@@ -24,42 +32,65 @@ function indexByCategory(analysis: TraceAnalysis) {
|
|||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mainThread(analysis: TraceAnalysis) {
|
||||||
|
return analysis.threads.find(t => t.thread === 0)!;
|
||||||
|
}
|
||||||
|
|
||||||
describe('trace report analysis', () => {
|
describe('trace report analysis', () => {
|
||||||
it('attributes self time as duration minus the union of (overlapping) children', () => {
|
it('hands every instant of a thread out exactly once, so category totals add up to wall', () => {
|
||||||
|
// Two concurrent downloads (10-50, 30-70) and a sync compute (80-90) under one
|
||||||
|
// task. The loop idled 40ms in total, all of it while the downloads were in flight.
|
||||||
|
const task = span('task', 0, 100, { idle: [0, 40] }, [
|
||||||
|
span('a', 10, 50, { category: SpanCategory.Network, idle: [0, 20] }),
|
||||||
|
span('b', 30, 70, { category: SpanCategory.Network, idle: [10, 40] }),
|
||||||
|
span('c', 80, 90, { category: SpanCategory.Compute, sync: true, idle: [40, 40] })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const analysis = analyzeTraces([task]);
|
||||||
|
const main = mainThread(analysis);
|
||||||
|
const byCategory = indexByCategory(analysis);
|
||||||
|
|
||||||
|
expect(analysis.wall).toEqual(100);
|
||||||
|
expect(main.attributed.cpu + main.attributed.wait).toEqual(100);
|
||||||
|
expect(main.attributed.wait).toEqual(40);
|
||||||
|
|
||||||
|
// downloads were innermost for 60ms of wall (not 80ms: the overlap is shared, not doubled)
|
||||||
|
const network = byCategory[SpanCategory.Network].main;
|
||||||
|
expect(network.cpu + network.wait).toEqual(60);
|
||||||
|
expect(network.wait).toEqual(40);
|
||||||
|
expect(byCategory[SpanCategory.Network].coverage).toEqual(60);
|
||||||
|
|
||||||
|
expect(byCategory[SpanCategory.Compute].main).toEqual({ cpu: 10, wait: 0 });
|
||||||
|
// the task itself is innermost for 0-10, 70-80 and 90-100
|
||||||
|
expect(byCategory[UNCATEGORIZED].main).toEqual({ cpu: 30, wait: 0 });
|
||||||
|
|
||||||
|
// the equal split: 30-50 is shared by a and b, 10-30 is a's alone, 50-70 is b's alone
|
||||||
|
expect(analysis.attribution.get(task.children[0])!.cpu + analysis.attribution.get(task.children[0])!.wait).toEqual(30);
|
||||||
|
expect(analysis.attribution.get(task.children[1])!.cpu + analysis.attribution.get(task.children[1])!.wait).toEqual(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a synchronous span the whole segment even when async spans are in flight', () => {
|
||||||
const task = span('task', 0, 100, {}, [
|
const task = span('task', 0, 100, {}, [
|
||||||
// two async children that overlap: 10-50 and 30-70 cover 60ms, not 80ms
|
span('download', 0, 100, { category: SpanCategory.Network }),
|
||||||
span('a', 10, 50, { category: SpanCategory.Network }),
|
span('parse', 40, 60, { category: SpanCategory.Compute, sync: true })
|
||||||
span('b', 30, 70, { category: SpanCategory.Network }),
|
|
||||||
span('c', 80, 90, { category: SpanCategory.Compute, sync: true })
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const analysis = analyzeTraces([task]);
|
const analysis = analyzeTraces([task]);
|
||||||
const byCategory = indexByCategory(analysis);
|
const byCategory = indexByCategory(analysis);
|
||||||
|
|
||||||
expect(analysis.wall).toEqual(100);
|
expect(byCategory[SpanCategory.Compute].main).toEqual({ cpu: 20, wait: 0 });
|
||||||
expect(byCategory[UNCATEGORIZED].selfTotal).toEqual(100 - 60 - 10);
|
expect(byCategory[SpanCategory.Network].main).toEqual({ cpu: 80, wait: 0 });
|
||||||
// self of leaves is their full duration, summed across the concurrent pair
|
expect(byCategory[UNCATEGORIZED].main).toEqual({ cpu: 0, wait: 0 });
|
||||||
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', () => {
|
it('reports time with no span in flight as untraced', () => {
|
||||||
// fire-and-forget child that outlives its parent
|
const analysis = analyzeTraces([
|
||||||
const task = span('task', 0, 50, {}, [span('late', 40, 90, { category: SpanCategory.FsWrite })]);
|
span('first', 0, 10),
|
||||||
|
span('second', 30, 40)
|
||||||
|
]);
|
||||||
|
|
||||||
const analysis = analyzeTraces([task]);
|
expect(mainThread(analysis).untraced).toEqual({ cpu: 20, wait: 0 });
|
||||||
const byCategory = indexByCategory(analysis);
|
expect(analysis.wall).toEqual(40);
|
||||||
|
|
||||||
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', () => {
|
it('skips unfinished spans but counts them', () => {
|
||||||
@@ -68,29 +99,30 @@ describe('trace report analysis', () => {
|
|||||||
const analysis = analyzeTraces([task]);
|
const analysis = analyzeTraces([task]);
|
||||||
|
|
||||||
expect(analysis.unfinishedSpans).toEqual(1);
|
expect(analysis.unfinishedSpans).toEqual(1);
|
||||||
// the unfinished child does not eat into the parent's self time
|
// the unfinished child does not steal the parent's time
|
||||||
expect(analysis.categories.find(c => c.category === UNCATEGORIZED)!.selfTotal).toEqual(10);
|
expect(indexByCategory(analysis)[UNCATEGORIZED].main).toEqual({ cpu: 10, wait: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('splits self time between the main thread and workers', () => {
|
it('sweeps each thread on its own and keeps the main thread waiting on the worker', () => {
|
||||||
const task = span('task', 0, 100, {}, [
|
const task = span('task', 0, 100, { idle: [0, 100] }, [
|
||||||
span('offload', 0, 100, { category: SpanCategory.Worker }, [
|
span('offload', 0, 100, { category: SpanCategory.Worker, idle: [0, 100] }, [
|
||||||
span('crunch', 20, 60, { category: SpanCategory.Compute, thread: 7, sync: true })
|
span('crunch', 20, 60, { category: SpanCategory.Compute, thread: 7, sync: true })
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const analysis = analyzeTraces([task]);
|
const analysis = analyzeTraces([task]);
|
||||||
const compute = analysis.categories.find(c => c.category === SpanCategory.Compute)!;
|
const byCategory = indexByCategory(analysis);
|
||||||
const worker = analysis.categories.find(c => c.category === SpanCategory.Worker)!;
|
|
||||||
|
|
||||||
expect(compute.selfOnWorkers).toEqual(40);
|
// the child runs on another thread, so `offload` stays innermost on main for its full 100ms
|
||||||
expect(compute.selfOnMainThread).toEqual(0);
|
expect(byCategory[SpanCategory.Worker].main).toEqual({ cpu: 0, wait: 100 });
|
||||||
expect(worker.selfOnMainThread).toEqual(60);
|
expect(byCategory[SpanCategory.Compute].workers).toEqual({ cpu: 40, wait: 0 });
|
||||||
|
expect(byCategory[SpanCategory.Compute].main).toEqual({ cpu: 0, wait: 0 });
|
||||||
|
expect(analysis.threads.map(t => t.thread)).toEqual([0, 7]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shifts a trace produced on another thread onto the local clock', () => {
|
it('shifts a trace produced on another thread onto the local clock', () => {
|
||||||
// A worker whose clock started 1000ms after ours reports everything 1000ms early
|
// A worker whose clock started 1000ms after ours reports everything 1000ms early
|
||||||
const workerTask = span('worker task', 0, 10, { timeOrigin: performance.timeOrigin + 1000 });
|
const workerTask = span('worker task', 0, 10, { thread: 7, timeOrigin: performance.timeOrigin + 1000 });
|
||||||
const mainTask = span('main task', 1000, 1010);
|
const mainTask = span('main task', 1000, 1010);
|
||||||
|
|
||||||
const analysis = analyzeTraces([workerTask, mainTask]);
|
const analysis = analyzeTraces([workerTask, mainTask]);
|
||||||
@@ -98,5 +130,7 @@ describe('trace report analysis', () => {
|
|||||||
expect(analysis.wallStart).toEqual(1000);
|
expect(analysis.wallStart).toEqual(1000);
|
||||||
expect(analysis.wallEnd).toEqual(1010);
|
expect(analysis.wallEnd).toEqual(1010);
|
||||||
expect(analysis.tasks[0].node.start).toEqual(1000);
|
expect(analysis.tasks[0].node.start).toEqual(1000);
|
||||||
|
// attribution is keyed by the shifted copy
|
||||||
|
expect(analysis.attribution.get(analysis.traces[0])).toEqual({ cpu: 10, wait: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,44 +60,66 @@ export function normalizeTraceClock(trace: TraceResult): TraceResult {
|
|||||||
// Analysis
|
// Analysis
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Wall-clock attributed to something, split by what the event loop was doing meanwhile */
|
||||||
|
export interface Attribution {
|
||||||
|
/** loop was busy: CPU on that thread */
|
||||||
|
cpu: number,
|
||||||
|
/** loop was idle: waiting on I/O, a timer, another thread... */
|
||||||
|
wait: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnalyzedSpan {
|
export interface AnalyzedSpan {
|
||||||
node: TraceResult,
|
node: TraceResult,
|
||||||
/** Ancestor names, root task first, this span last */
|
/** Ancestor names, root task first, this span last */
|
||||||
path: string[],
|
path: string[],
|
||||||
category: ReportedSpanCategory,
|
category: ReportedSpanCategory,
|
||||||
duration: number,
|
duration: number,
|
||||||
/** Duration not covered by any traced child */
|
/** Wall-clock attributed to this span (i.e. while it was innermost in flight on its thread) */
|
||||||
self: number
|
attributed: Attribution
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryStat {
|
export interface CategoryStat {
|
||||||
category: ReportedSpanCategory,
|
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,
|
spans: number,
|
||||||
selfOnMainThread: number,
|
/** Wall-clock during which at least one span of this category was in flight, on any thread */
|
||||||
selfOnWorkers: number,
|
coverage: number,
|
||||||
/** Self time of synchronous spans: guaranteed CPU, no event-loop queueing inside */
|
/** Attributed time on the main thread -- the one whose wall-clock is the build's */
|
||||||
selfSync: number,
|
main: Attribution,
|
||||||
|
/** Attributed time on worker threads, which run in parallel to the main thread */
|
||||||
|
workers: Attribution,
|
||||||
top: AnalyzedSpan[]
|
top: AnalyzedSpan[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskStat {
|
export interface TaskStat {
|
||||||
node: TraceResult,
|
node: TraceResult,
|
||||||
duration: number,
|
duration: number,
|
||||||
/** Self time of all descendants (and the task itself) bucketed by category */
|
attributed: Attribution,
|
||||||
byCategory: Record<ReportedSpanCategory, number>
|
byCategory: Record<ReportedSpanCategory, Attribution>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadStat {
|
||||||
|
thread: number,
|
||||||
|
/** wall-clock from the first to the last span event on this thread */
|
||||||
|
span: number,
|
||||||
|
attributed: Attribution,
|
||||||
|
/** time between span events with no span in flight on this thread */
|
||||||
|
untraced: Attribution,
|
||||||
|
byCategory: Record<ReportedSpanCategory, Attribution>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TraceAnalysis {
|
export interface TraceAnalysis {
|
||||||
|
/** the input traces, shifted onto the local clock; `attribution` is keyed by these nodes */
|
||||||
|
traces: TraceResult[],
|
||||||
wallStart: number,
|
wallStart: number,
|
||||||
wallEnd: number,
|
wallEnd: number,
|
||||||
wall: number,
|
wall: number,
|
||||||
|
/** thread 0 first, then workers by id */
|
||||||
|
threads: ThreadStat[],
|
||||||
categories: CategoryStat[],
|
categories: CategoryStat[],
|
||||||
tasks: TaskStat[],
|
tasks: TaskStat[],
|
||||||
topSpans: AnalyzedSpan[],
|
topSpans: AnalyzedSpan[],
|
||||||
|
/** per node, so the tree printer can annotate without recomputing */
|
||||||
|
attribution: Map<TraceResult, Attribution>,
|
||||||
unfinishedSpans: number
|
unfinishedSpans: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +129,26 @@ function isFinished(node: TraceResult) {
|
|||||||
return node.end >= node.start;
|
return node.end >= node.start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function zeroAttribution(): Attribution {
|
||||||
|
return { cpu: 0, wait: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function attributionTotal(a: Attribution) {
|
||||||
|
return a.cpu + a.wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyByCategory(): Record<ReportedSpanCategory, Attribution> {
|
||||||
|
return {
|
||||||
|
[SpanCategory.Network]: zeroAttribution(),
|
||||||
|
[SpanCategory.FsRead]: zeroAttribution(),
|
||||||
|
[SpanCategory.FsWrite]: zeroAttribution(),
|
||||||
|
[SpanCategory.Compute]: zeroAttribution(),
|
||||||
|
[SpanCategory.Worker]: zeroAttribution(),
|
||||||
|
[SpanCategory.Wait]: zeroAttribution(),
|
||||||
|
[UNCATEGORIZED]: zeroAttribution()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Total length covered by the union of the intervals (handles overlap, which async children routinely do) */
|
/** Total length covered by the union of the intervals (handles overlap, which async children routinely do) */
|
||||||
function unionLength(intervals: Interval[]): number {
|
function unionLength(intervals: Interval[]): number {
|
||||||
if (intervals.length === 0) {
|
if (intervals.length === 0) {
|
||||||
@@ -131,97 +173,174 @@ function unionLength(intervals: Interval[]): number {
|
|||||||
return total + (curEnd - curStart);
|
return total + (curEnd - curStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selfTime(node: TraceResult): number {
|
interface SpanRecord {
|
||||||
const duration = node.end - node.start;
|
node: TraceResult,
|
||||||
if (node.children.length === 0) {
|
parent: SpanRecord | null,
|
||||||
return duration;
|
task: TaskStat,
|
||||||
}
|
category: ReportedSpanCategory,
|
||||||
|
path: string[],
|
||||||
const covered: Interval[] = [];
|
/** number of in-flight children on the same thread; the span is "innermost" while this is 0 */
|
||||||
for (let i = 0, len = node.children.length; i < len; i++) {
|
activeChildren: number
|
||||||
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> {
|
interface SpanEvent {
|
||||||
return {
|
time: number,
|
||||||
[SpanCategory.Network]: 0,
|
/** cumulative loop idle of the thread at this moment */
|
||||||
[SpanCategory.FsRead]: 0,
|
idle: number,
|
||||||
[SpanCategory.FsWrite]: 0,
|
record: SpanRecord,
|
||||||
[SpanCategory.Compute]: 0,
|
isStart: boolean
|
||||||
[SpanCategory.Worker]: 0,
|
}
|
||||||
[SpanCategory.Wait]: 0,
|
|
||||||
[UNCATEGORIZED]: 0
|
/**
|
||||||
|
* Sweep one thread's span events and attribute every segment between two
|
||||||
|
* consecutive events to the spans that were innermost in flight at that time:
|
||||||
|
*
|
||||||
|
* - the loop-idle delta of the segment is time spent waiting, the rest is CPU;
|
||||||
|
* - if a synchronous span is innermost it owns the whole segment (nothing else
|
||||||
|
* can run on the thread meanwhile);
|
||||||
|
* - otherwise the segment is split equally among the innermost async spans;
|
||||||
|
* - a segment with nothing in flight is "untraced".
|
||||||
|
*
|
||||||
|
* Because every instant of the thread's timeline is handed out exactly once,
|
||||||
|
* the per-category totals add up to the thread's wall-clock -- unlike summing
|
||||||
|
* span durations, which counts a queued span's wait once per queued span.
|
||||||
|
*/
|
||||||
|
function sweepThread(events: SpanEvent[], threadStat: ThreadStat, attribution: Map<TraceResult, Attribution>) {
|
||||||
|
// ends before starts at equal timestamps, so a back-to-back sibling pair never overlaps
|
||||||
|
events.sort((a, b) => a.time - b.time || (a.isStart ? 1 : 0) - (b.isStart ? 1 : 0));
|
||||||
|
|
||||||
|
const inFlight = new Set<SpanRecord>();
|
||||||
|
const innermost: SpanRecord[] = [];
|
||||||
|
|
||||||
|
const attribute = (record: SpanRecord | null, cpu: number, wait: number) => {
|
||||||
|
threadStat.attributed.cpu += cpu;
|
||||||
|
threadStat.attributed.wait += wait;
|
||||||
|
|
||||||
|
if (record === null) {
|
||||||
|
threadStat.untraced.cpu += cpu;
|
||||||
|
threadStat.untraced.wait += wait;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const own = attribution.get(record.node);
|
||||||
|
if (own) {
|
||||||
|
own.cpu += cpu;
|
||||||
|
own.wait += wait;
|
||||||
|
} else {
|
||||||
|
attribution.set(record.node, { cpu, wait });
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskBucket = record.task.byCategory[record.category];
|
||||||
|
taskBucket.cpu += cpu;
|
||||||
|
taskBucket.wait += wait;
|
||||||
|
record.task.attributed.cpu += cpu;
|
||||||
|
record.task.attributed.wait += wait;
|
||||||
|
|
||||||
|
const threadBucket = threadStat.byCategory[record.category];
|
||||||
|
threadBucket.cpu += cpu;
|
||||||
|
threadBucket.wait += wait;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for (let i = 0, len = events.length; i < len; i++) {
|
||||||
|
const event = events[i];
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
const prev = events[i - 1];
|
||||||
|
const duration = event.time - prev.time;
|
||||||
|
if (duration > 0) {
|
||||||
|
// the idle counter is only advanced when the loop leaves poll, so a
|
||||||
|
// segment inside one synchronous tick correctly reads as all-cpu
|
||||||
|
const wait = Math.min(duration, Math.max(0, event.idle - prev.idle));
|
||||||
|
const cpu = duration - wait;
|
||||||
|
|
||||||
|
innermost.length = 0;
|
||||||
|
let sync: SpanRecord | null = null;
|
||||||
|
for (const record of inFlight) {
|
||||||
|
if (record.activeChildren === 0) {
|
||||||
|
innermost.push(record);
|
||||||
|
if (record.node.sync) {
|
||||||
|
sync = record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sync) {
|
||||||
|
attribute(sync, cpu, wait);
|
||||||
|
} else if (innermost.length === 0) {
|
||||||
|
attribute(null, cpu, wait);
|
||||||
|
} else {
|
||||||
|
const share = 1 / innermost.length;
|
||||||
|
for (let j = 0, jlen = innermost.length; j < jlen; j++) {
|
||||||
|
attribute(innermost[j], cpu * share, wait * share);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { record } = event;
|
||||||
|
const sameThreadParent = record.parent?.node.thread === record.node.thread ? record.parent : null;
|
||||||
|
if (event.isStart) {
|
||||||
|
inFlight.add(record);
|
||||||
|
if (sameThreadParent) {
|
||||||
|
sameThreadParent.activeChildren++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
inFlight.delete(record);
|
||||||
|
if (sameThreadParent) {
|
||||||
|
sameThreadParent.activeChildren--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
threadStat.span = events.at(-1)!.time - events[0].time;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function analyzeTraces(rawTraces: TraceResult[]): TraceAnalysis {
|
export function analyzeTraces(rawTraces: TraceResult[]): TraceAnalysis {
|
||||||
const traces = rawTraces.map(normalizeTraceClock);
|
const traces = rawTraces.map(normalizeTraceClock);
|
||||||
|
|
||||||
const spans: AnalyzedSpan[] = [];
|
const records: SpanRecord[] = [];
|
||||||
|
const eventsByThread = new Map<number, SpanEvent[]>();
|
||||||
const coverageIntervals = new Map<ReportedSpanCategory, Interval[]>();
|
const coverageIntervals = new Map<ReportedSpanCategory, Interval[]>();
|
||||||
const categoryStats = new Map<ReportedSpanCategory, CategoryStat>();
|
const spanCount = new Map<ReportedSpanCategory, number>();
|
||||||
for (let i = 0, len = CATEGORY_ORDER.length; i < len; i++) {
|
for (let i = 0, len = CATEGORY_ORDER.length; i < len; i++) {
|
||||||
const category = CATEGORY_ORDER[i];
|
coverageIntervals.set(CATEGORY_ORDER[i], []);
|
||||||
coverageIntervals.set(category, []);
|
spanCount.set(CATEGORY_ORDER[i], 0);
|
||||||
categoryStats.set(category, {
|
|
||||||
category,
|
|
||||||
selfTotal: 0,
|
|
||||||
coverage: 0,
|
|
||||||
spans: 0,
|
|
||||||
selfOnMainThread: 0,
|
|
||||||
selfOnWorkers: 0,
|
|
||||||
selfSync: 0,
|
|
||||||
top: []
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let unfinishedSpans = 0;
|
let unfinishedSpans = 0;
|
||||||
let wallStart = Infinity;
|
let wallStart = Infinity;
|
||||||
let wallEnd = -Infinity;
|
let wallEnd = -Infinity;
|
||||||
|
|
||||||
const walk = (node: TraceResult, path: string[], task: TaskStat) => {
|
const walk = (node: TraceResult, parent: SpanRecord | null, task: TaskStat) => {
|
||||||
const ownPath = path.concat(node.name);
|
const path = parent ? parent.path.concat(node.name) : [node.name];
|
||||||
|
const category = node.category ?? UNCATEGORIZED;
|
||||||
|
const record: SpanRecord = { node, parent, task, category, path, activeChildren: 0 };
|
||||||
|
|
||||||
if (isFinished(node)) {
|
if (isFinished(node)) {
|
||||||
const category = node.category ?? UNCATEGORIZED;
|
records.push(record);
|
||||||
const self = selfTime(node);
|
spanCount.set(category, spanCount.get(category)! + 1);
|
||||||
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]);
|
coverageIntervals.get(category)!.push([node.start, node.end]);
|
||||||
task.byCategory[category] += self;
|
|
||||||
|
|
||||||
if (node.start < wallStart) wallStart = node.start;
|
if (node.start < wallStart) wallStart = node.start;
|
||||||
if (node.end > wallEnd) wallEnd = node.end;
|
if (node.end > wallEnd) wallEnd = node.end;
|
||||||
|
|
||||||
|
let events = eventsByThread.get(node.thread);
|
||||||
|
if (!events) {
|
||||||
|
events = [];
|
||||||
|
eventsByThread.set(node.thread, events);
|
||||||
|
}
|
||||||
|
// a span without loop samples (a hand-built trace) reads as all-cpu
|
||||||
|
events.push(
|
||||||
|
{ time: node.start, idle: node.loopIdle?.atStart ?? 0, record, isStart: true },
|
||||||
|
{ time: node.end, idle: node.loopIdle?.atEnd ?? 0, record, isStart: false }
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
unfinishedSpans++;
|
unfinishedSpans++;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0, len = node.children.length; i < len; i++) {
|
for (let i = 0, len = node.children.length; i < len; i++) {
|
||||||
walk(node.children[i], ownPath, task);
|
walk(node.children[i], record, task);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,28 +348,66 @@ export function analyzeTraces(rawTraces: TraceResult[]): TraceAnalysis {
|
|||||||
const task: TaskStat = {
|
const task: TaskStat = {
|
||||||
node: trace,
|
node: trace,
|
||||||
duration: isFinished(trace) ? trace.end - trace.start : 0,
|
duration: isFinished(trace) ? trace.end - trace.start : 0,
|
||||||
|
attributed: zeroAttribution(),
|
||||||
byCategory: emptyByCategory()
|
byCategory: emptyByCategory()
|
||||||
};
|
};
|
||||||
walk(trace, [], task);
|
walk(trace, null, task);
|
||||||
return task;
|
return task;
|
||||||
});
|
});
|
||||||
|
|
||||||
spans.sort((a, b) => b.self - a.self);
|
const attribution = new Map<TraceResult, Attribution>();
|
||||||
|
const threads: ThreadStat[] = Array.from(eventsByThread.keys())
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((thread) => {
|
||||||
|
const threadStat: ThreadStat = {
|
||||||
|
thread,
|
||||||
|
span: 0,
|
||||||
|
attributed: zeroAttribution(),
|
||||||
|
untraced: zeroAttribution(),
|
||||||
|
byCategory: emptyByCategory()
|
||||||
|
};
|
||||||
|
sweepThread(eventsByThread.get(thread)!, threadStat, attribution);
|
||||||
|
return threadStat;
|
||||||
|
});
|
||||||
|
|
||||||
const categories = CATEGORY_ORDER.map((category) => {
|
const spans: AnalyzedSpan[] = records.map(record => ({
|
||||||
const stat = categoryStats.get(category)!;
|
node: record.node,
|
||||||
stat.coverage = unionLength(coverageIntervals.get(category)!);
|
path: record.path,
|
||||||
stat.top = spans.filter(s => s.category === category && s.self > 0).slice(0, TOP_SPANS_PER_CATEGORY);
|
category: record.category,
|
||||||
return stat;
|
duration: record.node.end - record.node.start,
|
||||||
|
attributed: attribution.get(record.node) ?? zeroAttribution()
|
||||||
|
}));
|
||||||
|
spans.sort((a, b) => attributionTotal(b.attributed) - attributionTotal(a.attributed));
|
||||||
|
|
||||||
|
const categories: CategoryStat[] = CATEGORY_ORDER.map((category) => {
|
||||||
|
const main = zeroAttribution();
|
||||||
|
const workers = zeroAttribution();
|
||||||
|
for (let i = 0, len = threads.length; i < len; i++) {
|
||||||
|
const source = threads[i].byCategory[category];
|
||||||
|
const target = threads[i].thread === 0 ? main : workers;
|
||||||
|
target.cpu += source.cpu;
|
||||||
|
target.wait += source.wait;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
spans: spanCount.get(category)!,
|
||||||
|
coverage: unionLength(coverageIntervals.get(category)!),
|
||||||
|
main,
|
||||||
|
workers,
|
||||||
|
top: spans.filter(s => s.category === category && attributionTotal(s.attributed) > 0).slice(0, TOP_SPANS_PER_CATEGORY)
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
traces,
|
||||||
wallStart,
|
wallStart,
|
||||||
wallEnd,
|
wallEnd,
|
||||||
wall: wallEnd > wallStart ? wallEnd - wallStart : 0,
|
wall: wallEnd > wallStart ? wallEnd - wallStart : 0,
|
||||||
|
threads,
|
||||||
categories,
|
categories,
|
||||||
tasks,
|
tasks,
|
||||||
topSpans: spans.filter(s => s.self > 0).slice(0, TOP_SPANS_OVERALL),
|
topSpans: spans.filter(s => attributionTotal(s.attributed) > 0).slice(0, TOP_SPANS_OVERALL),
|
||||||
|
attribution,
|
||||||
unfinishedSpans
|
unfinishedSpans
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -276,21 +433,12 @@ function fmtPercent(part: number, whole: number): string {
|
|||||||
return `${(part / whole * 100).toFixed(0)}%`;
|
return `${(part / whole * 100).toFixed(0)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryTag(category: ReportedSpanCategory): string {
|
function fmtAttribution(a: Attribution): string {
|
||||||
return CATEGORY_COLOR[category](`[${category}]`);
|
return `cpu=${fmtMs(a.cpu)} wait=${fmtMs(a.wait)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loopBusy(node: TraceResult): string | null {
|
function categoryTag(category: ReportedSpanCategory): string {
|
||||||
const { elu } = node;
|
return CATEGORY_COLOR[category](`[${category}]`);
|
||||||
// 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 {
|
function threadLabel(thread: number): string {
|
||||||
@@ -327,11 +475,13 @@ function pad(s: string, width: number, alignRight: boolean): string {
|
|||||||
return alignRight ? fill + s : s + fill;
|
return alignRight ? fill + s : s + fill;
|
||||||
}
|
}
|
||||||
|
|
||||||
function table(header: string[], rows: string[][], rightAlignFrom = 1): string[] {
|
/** Columns in [rightAlignFrom, rightAlignTo) are numeric and right-aligned, the rest left-aligned */
|
||||||
|
function table(header: string[], rows: string[][], rightAlignFrom = 1, rightAlignTo = Infinity): string[] {
|
||||||
const widths = header.map((h, col) => Math.max(visibleLength(h), ...rows.map(r => visibleLength(r[col]))));
|
const widths = header.map((h, col) => Math.max(visibleLength(h), ...rows.map(r => visibleLength(r[col]))));
|
||||||
const fmtRow = (row: string[]) => row
|
const fmtRow = (row: string[]) => row
|
||||||
.map((cell, col) => pad(cell, widths[col], col >= rightAlignFrom))
|
.map((cell, col) => pad(cell, widths[col], col >= rightAlignFrom && col < rightAlignTo))
|
||||||
.join(' ');
|
.join(' ')
|
||||||
|
.trimEnd();
|
||||||
return [
|
return [
|
||||||
picocolors.bold(fmtRow(header)),
|
picocolors.bold(fmtRow(header)),
|
||||||
picocolors.dim(widths.map(w => '─'.repeat(w)).join(' ')),
|
picocolors.dim(widths.map(w => '─'.repeat(w)).join(' ')),
|
||||||
@@ -339,13 +489,22 @@ function table(header: string[], rows: string[][], rightAlignFrom = 1): string[]
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dotIfZero(value: number): string {
|
||||||
|
return value > 0 ? fmtMs(value) : picocolors.dim('·');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCpuWait(a: Attribution): string {
|
||||||
|
return attributionTotal(a) > 0 ? `${fmtMs(a.cpu)}/${fmtMs(a.wait)}` : picocolors.dim('·');
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Printing
|
// Printing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function printTraceResult(traceResult: TraceResult) {
|
export function printTraceResult(traceResult: TraceResult, analysis: TraceAnalysis = analyzeTraces([traceResult])) {
|
||||||
|
const { attribution } = analysis;
|
||||||
printTree(
|
printTree(
|
||||||
normalizeTraceClock(traceResult),
|
analysis.traces.find(t => t === traceResult) ?? normalizeTraceClock(traceResult),
|
||||||
(node, parentThread) => {
|
(node, parentThread) => {
|
||||||
const parts: string[] = [node.name];
|
const parts: string[] = [node.name];
|
||||||
|
|
||||||
@@ -360,13 +519,10 @@ export function printTraceResult(traceResult: TraceResult) {
|
|||||||
|
|
||||||
parts.push(picocolors.bold(fmtMs(node.end - node.start)));
|
parts.push(picocolors.bold(fmtMs(node.end - node.start)));
|
||||||
|
|
||||||
if (node.children.length > 0) {
|
// a sync span is trivially all-cpu for its whole duration
|
||||||
parts.push(picocolors.dim(`self=${fmtMs(selfTime(node))}`));
|
const own = attribution.get(node);
|
||||||
}
|
if (own && !node.sync && attributionTotal(own) >= 0.1) {
|
||||||
|
parts.push(picocolors.dim(`self(${fmtAttribution(own)})`));
|
||||||
const busy = loopBusy(node);
|
|
||||||
if (busy) {
|
|
||||||
parts.push(picocolors.dim(busy));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.thread !== parentThread) {
|
if (node.thread !== parentThread) {
|
||||||
@@ -473,42 +629,102 @@ function printOverview(analysis: TraceAnalysis, usage: BuildResourceUsage | unde
|
|||||||
console.log(picocolors.bold('[build]'), parts.filter(Boolean).join(' '));
|
console.log(picocolors.bold('[build]'), parts.filter(Boolean).join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
function printCategoryBreakdown(analysis: TraceAnalysis) {
|
function printMainThreadBreakdown(analysis: TraceAnalysis) {
|
||||||
const grandTotal = analysis.categories.reduce((acc, c) => acc + c.selfTotal, 0);
|
const main = analysis.threads.find(t => t.thread === 0);
|
||||||
|
if (!main) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = attributionTotal(main.attributed);
|
||||||
|
|
||||||
console.log();
|
console.log();
|
||||||
console.log(picocolors.bold('[time by category]'));
|
console.log(
|
||||||
|
picocolors.bold('[main thread: where did the wall-clock go]'),
|
||||||
|
`${fmtMs(total)} = cpu ${fmtMs(main.attributed.cpu)} (${fmtPercent(main.attributed.cpu, total)}) + wait ${fmtMs(main.attributed.wait)} (${fmtPercent(main.attributed.wait, total)})`
|
||||||
|
);
|
||||||
console.log(picocolors.dim(
|
console.log(picocolors.dim(
|
||||||
' self = span time not covered by traced children, summed across concurrent spans (so it exceeds wall;\n'
|
' every instant is handed to the innermost spans in flight on the main thread (a synchronous span takes all\n'
|
||||||
+ ' an async span on a busy event loop also includes time queued behind other work);\n'
|
+ ' of it, async spans split it equally), and classified as cpu (event loop busy) or wait (event loop idle);\n'
|
||||||
+ ' coverage = wall-clock during which at least one span of that category was in flight;\n'
|
+ ' coverage = wall-clock during which at least one span of that category was in flight, on any thread'
|
||||||
+ ' 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) => {
|
const rows = analysis.categories.reduce<string[][]>((acc, c) => {
|
||||||
if (c.spans > 0) {
|
if (c.spans > 0) {
|
||||||
|
const t = attributionTotal(c.main);
|
||||||
acc.push([
|
acc.push([
|
||||||
CATEGORY_COLOR[c.category](c.category),
|
CATEGORY_COLOR[c.category](c.category),
|
||||||
fmtMs(c.selfTotal),
|
fmtMs(t),
|
||||||
fmtPercent(c.selfTotal, grandTotal),
|
fmtPercent(t, total),
|
||||||
|
dotIfZero(c.main.cpu),
|
||||||
|
dotIfZero(c.main.wait),
|
||||||
fmtMs(c.coverage),
|
fmtMs(c.coverage),
|
||||||
fmtPercent(c.coverage, analysis.wall),
|
String(c.spans)
|
||||||
String(c.spans),
|
|
||||||
fmtMs(c.selfOnMainThread),
|
|
||||||
fmtMs(c.selfOnWorkers),
|
|
||||||
fmtMs(c.selfSync)
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
const untraced = attributionTotal(main.untraced);
|
||||||
|
if (untraced > 0) {
|
||||||
|
rows.push([
|
||||||
|
picocolors.dim('(no span in flight)'),
|
||||||
|
fmtMs(untraced),
|
||||||
|
fmtPercent(untraced, total),
|
||||||
|
dotIfZero(main.untraced.cpu),
|
||||||
|
dotIfZero(main.untraced.wait),
|
||||||
|
picocolors.dim('·'),
|
||||||
|
picocolors.dim('·')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
table(['category', 'self', 'share', 'coverage', 'of wall', 'spans', 'main', 'workers', 'sync'], rows)
|
table(['category', 'total', 'share', 'cpu', 'wait', 'coverage', 'spans'], rows)
|
||||||
|
.forEach(line => console.log(' ' + line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function printWorkerThreads(analysis: TraceAnalysis) {
|
||||||
|
const workers = analysis.threads.filter(t => t.thread !== 0);
|
||||||
|
if (workers.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(picocolors.bold('[worker threads]'), picocolors.dim('(run in parallel to the main thread; same attribution, per thread; cpu / wait)'));
|
||||||
|
|
||||||
|
const rows = workers.map((t) => {
|
||||||
|
const byCategory = CATEGORY_ORDER.reduce<string[]>((acc, category) => {
|
||||||
|
const a = t.byCategory[category];
|
||||||
|
if (attributionTotal(a) > 0) {
|
||||||
|
acc.push(`${CATEGORY_COLOR[category](category)} ${fmtCpuWait(a)}`);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
return [
|
||||||
|
threadLabel(t.thread),
|
||||||
|
fmtMs(t.span),
|
||||||
|
fmtMs(t.attributed.cpu),
|
||||||
|
fmtMs(t.attributed.wait),
|
||||||
|
byCategory.join(', ')
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
table(['thread', 'active for', 'cpu', 'wait', 'by category'], rows, 1, 4)
|
||||||
.forEach(line => console.log(' ' + line));
|
.forEach(line => console.log(' ' + line));
|
||||||
}
|
}
|
||||||
|
|
||||||
function printTopSpans(analysis: TraceAnalysis) {
|
function printTopSpans(analysis: TraceAnalysis) {
|
||||||
|
const describe = (span: AnalyzedSpan) => {
|
||||||
|
const extra: string[] = [span.node.sync ? 'sync' : fmtAttribution(span.attributed)];
|
||||||
|
// only worth showing when it differs from what was attributed (children or concurrency)
|
||||||
|
if (attributionTotal(span.attributed) < span.duration * 0.98) {
|
||||||
|
extra.push(`wall=${fmtMs(span.duration)}`);
|
||||||
|
}
|
||||||
|
if (span.node.thread !== 0) {
|
||||||
|
extra.push(`@${threadLabel(span.node.thread)}`);
|
||||||
|
}
|
||||||
|
return picocolors.dim(extra.join(' '));
|
||||||
|
};
|
||||||
|
|
||||||
console.log();
|
console.log();
|
||||||
console.log(picocolors.bold('[top spans by self time, per category]'));
|
console.log(picocolors.bold('[top spans by attributed time, per category]'));
|
||||||
for (let i = 0, len = analysis.categories.length; i < len; i++) {
|
for (let i = 0, len = analysis.categories.length; i < len; i++) {
|
||||||
const stat = analysis.categories[i];
|
const stat = analysis.categories[i];
|
||||||
if (stat.top.length === 0) {
|
if (stat.top.length === 0) {
|
||||||
@@ -517,35 +733,25 @@ function printTopSpans(analysis: TraceAnalysis) {
|
|||||||
console.log(' ' + categoryTag(stat.category));
|
console.log(' ' + categoryTag(stat.category));
|
||||||
for (let j = 0, jlen = stat.top.length; j < jlen; j++) {
|
for (let j = 0, jlen = stat.top.length; j < jlen; j++) {
|
||||||
const span = stat.top[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(
|
console.log(
|
||||||
' ',
|
' ',
|
||||||
picocolors.bold(fmtMs(span.self).padStart(9)),
|
picocolors.bold(fmtMs(attributionTotal(span.attributed)).padStart(9)),
|
||||||
fmtPath(span.path, 110),
|
fmtPath(span.path, 110),
|
||||||
extra.length ? picocolors.dim(extra.join(' ')) : ''
|
describe(span)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log();
|
console.log();
|
||||||
console.log(picocolors.bold('[top spans by self time, overall]'));
|
console.log(picocolors.bold('[top spans by attributed time, overall]'));
|
||||||
for (let i = 0, len = analysis.topSpans.length; i < len; i++) {
|
for (let i = 0, len = analysis.topSpans.length; i < len; i++) {
|
||||||
const span = analysis.topSpans[i];
|
const span = analysis.topSpans[i];
|
||||||
console.log(
|
console.log(
|
||||||
' ',
|
' ',
|
||||||
picocolors.bold(fmtMs(span.self).padStart(9)),
|
picocolors.bold(fmtMs(attributionTotal(span.attributed)).padStart(9)),
|
||||||
categoryTag(span.category).padEnd(16),
|
pad(categoryTag(span.category), 15, false),
|
||||||
fmtPath(span.path, 110)
|
fmtPath(span.path, 100),
|
||||||
|
describe(span)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,23 +762,24 @@ function printTaskMatrix(analysis: TraceAnalysis) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log();
|
console.log();
|
||||||
console.log(picocolors.bold('[time by task × category]'), picocolors.dim('(self time of the task and all its descendants)'));
|
console.log(
|
||||||
|
picocolors.bold('[time by task × category]'),
|
||||||
|
picocolors.dim('(wall-clock attributed to the task\'s spans, on whichever thread they ran; cpu / wait)')
|
||||||
|
);
|
||||||
|
|
||||||
const usedCategories = CATEGORY_ORDER.filter(c => analysis.tasks.some(t => t.byCategory[c] > 0));
|
const usedCategories = CATEGORY_ORDER.filter(c => analysis.tasks.some(t => attributionTotal(t.byCategory[c]) > 0));
|
||||||
|
|
||||||
const rows = analysis.tasks
|
const rows = analysis.tasks
|
||||||
.toSorted((a, b) => b.duration - a.duration)
|
.toSorted((a, b) => b.duration - a.duration)
|
||||||
.map((task) => {
|
.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)}`)),
|
task.node.name + (task.node.thread === 0 ? '' : picocolors.dim(` @${threadLabel(task.node.thread)}`)),
|
||||||
fmtMs(task.duration),
|
fmtMs(task.duration),
|
||||||
busy,
|
dotIfZero(task.attributed.cpu),
|
||||||
...usedCategories.map(c => (task.byCategory[c] > 0 ? fmtMs(task.byCategory[c]) : picocolors.dim('·')))
|
dotIfZero(task.attributed.wait),
|
||||||
];
|
...usedCategories.map(c => fmtCpuWait(task.byCategory[c]))
|
||||||
});
|
]);
|
||||||
|
|
||||||
table(['task', 'wall', 'loop-busy', ...usedCategories], rows)
|
table(['task', 'wall', 'cpu', 'wait', ...usedCategories], rows)
|
||||||
.forEach(line => console.log(' ' + line));
|
.forEach(line => console.log(' ' + line));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,13 +789,14 @@ function printTaskMatrix(analysis: TraceAnalysis) {
|
|||||||
* the shared timeline).
|
* the shared timeline).
|
||||||
*/
|
*/
|
||||||
export function printBuildReport(traces: TraceResult[], usage?: BuildResourceUsage) {
|
export function printBuildReport(traces: TraceResult[], usage?: BuildResourceUsage) {
|
||||||
traces.forEach(printTraceResult);
|
|
||||||
|
|
||||||
const analysis = analyzeTraces(traces);
|
const analysis = analyzeTraces(traces);
|
||||||
|
|
||||||
|
analysis.traces.forEach(trace => printTraceResult(trace, analysis));
|
||||||
|
|
||||||
console.log();
|
console.log();
|
||||||
printOverview(analysis, usage);
|
printOverview(analysis, usage);
|
||||||
printCategoryBreakdown(analysis);
|
printMainThreadBreakdown(analysis);
|
||||||
|
printWorkerThreads(analysis);
|
||||||
printTopSpans(analysis);
|
printTopSpans(analysis);
|
||||||
printTaskMatrix(analysis);
|
printTaskMatrix(analysis);
|
||||||
console.log();
|
console.log();
|
||||||
|
|||||||
@@ -38,11 +38,14 @@ export interface TraceResult {
|
|||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
/**
|
/**
|
||||||
* Event loop utilization of the thread that ran this span, over the span's
|
* Cumulative event-loop idle time (ms, `performance.eventLoopUtilization().idle`)
|
||||||
* lifetime. `active` is an upper bound of this span's own CPU time: on a shared
|
* of the thread that ran this span, sampled when the span started and stopped.
|
||||||
* event loop it also includes work done by concurrently running spans.
|
* Only differences within the same thread are meaningful. Together with the
|
||||||
|
* start/stop timestamps of every other span on that thread this yields, for each
|
||||||
|
* segment between two span events, how long the loop was busy (CPU) vs idle
|
||||||
|
* (waiting on I/O) -- which is how the report attributes wall-clock to categories.
|
||||||
*/
|
*/
|
||||||
elu?: SpanEventLoopUtilization,
|
loopIdle?: { atStart: number, atEnd: number },
|
||||||
/**
|
/**
|
||||||
* Set when the span wrapped a synchronous function: its whole self time is
|
* 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.
|
* CPU time on its thread, never queueing behind other work on the event loop.
|
||||||
|
|||||||
Reference in New Issue
Block a user