Chore: event loop wall build time tracking

This commit is contained in:
SukkaW
2026-09-02 16:03:38 +08:00
parent 3e03018bbe
commit 68ab592d76
4 changed files with 454 additions and 210 deletions

View File

@@ -3,18 +3,26 @@ import { expect } from 'earl';
import { performance } from 'node:perf_hooks';
import { analyzeTraces } from './report';
import type { CategoryStat, TraceAnalysis } 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 };
interface SpanExtra extends Partial<Pick<TraceResult, 'category' | 'thread' | 'sync' | 'timeOrigin'>> {
/** cumulative loop idle (ms) at start and end; defaults to "loop never idle" */
idle?: [atStart: number, atEnd: number]
}
function span(name: string, start: number, end: number, extra: SpanExtra = {}, children: TraceResult[] = []): TraceResult {
const { idle, ...rest } = extra;
return {
name,
start,
end,
thread: 0,
children,
loopIdle: { atStart: idle?.[0] ?? 0, atEnd: idle?.[1] ?? 0 },
...rest
};
}
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', () => {
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, {}, [
// 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 })
span('download', 0, 100, { category: SpanCategory.Network }),
span('parse', 40, 60, { 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);
expect(byCategory[SpanCategory.Compute].main).toEqual({ cpu: 20, wait: 0 });
expect(byCategory[SpanCategory.Network].main).toEqual({ cpu: 80, wait: 0 });
expect(byCategory[UNCATEGORIZED].main).toEqual({ cpu: 0, wait: 0 });
});
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 })]);
it('reports time with no span in flight as untraced', () => {
const analysis = analyzeTraces([
span('first', 0, 10),
span('second', 30, 40)
]);
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);
expect(mainThread(analysis).untraced).toEqual({ cpu: 20, wait: 0 });
expect(analysis.wall).toEqual(40);
});
it('skips unfinished spans but counts them', () => {
@@ -68,29 +99,30 @@ describe('trace report analysis', () => {
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);
// the unfinished child does not steal the parent's time
expect(indexByCategory(analysis)[UNCATEGORIZED].main).toEqual({ cpu: 10, wait: 0 });
});
it('splits self time between the main thread and workers', () => {
const task = span('task', 0, 100, {}, [
span('offload', 0, 100, { category: SpanCategory.Worker }, [
it('sweeps each thread on its own and keeps the main thread waiting on the worker', () => {
const task = span('task', 0, 100, { idle: [0, 100] }, [
span('offload', 0, 100, { category: SpanCategory.Worker, idle: [0, 100] }, [
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)!;
const byCategory = indexByCategory(analysis);
expect(compute.selfOnWorkers).toEqual(40);
expect(compute.selfOnMainThread).toEqual(0);
expect(worker.selfOnMainThread).toEqual(60);
// the child runs on another thread, so `offload` stays innermost on main for its full 100ms
expect(byCategory[SpanCategory.Worker].main).toEqual({ cpu: 0, wait: 100 });
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', () => {
// 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 analysis = analyzeTraces([workerTask, mainTask]);
@@ -98,5 +130,7 @@ describe('trace report analysis', () => {
expect(analysis.wallStart).toEqual(1000);
expect(analysis.wallEnd).toEqual(1010);
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 });
});
});