Files
openchamber/scripts/perf/metrics.mjs
T

90 lines
3.8 KiB
JavaScript
Raw Normal View History

/**
* Metric helpers shared by the idle and streaming profilers.
*
* Both commands read the same `Performance.getMetrics` counters and need the
* same derivations, so the maths lives here and each entry point only decides
* which numbers to report.
*/
export const round = (value, digits = 2) => Number(Number(value ?? 0).toFixed(digits))
export const metricMap = (metrics = []) => Object.fromEntries(metrics.map(({ name, value }) => [name, value]))
/**
* Least-squares slope of a sampled series, in units per second. A slope
* separates a genuine upward trend from the sawtooth that garbage collection
* produces, which start/end deltas alone cannot distinguish.
*/
export const growthPerSecond = (samples, key) => {
if (samples.length < 2) return 0
const meanTime = samples.reduce((total, sample) => total + sample.elapsedSeconds, 0) / samples.length
const meanValue = samples.reduce((total, sample) => total + (sample[key] ?? 0), 0) / samples.length
let covariance = 0
let variance = 0
for (const sample of samples) {
const timeDelta = sample.elapsedSeconds - meanTime
covariance += timeDelta * ((sample[key] ?? 0) - meanValue)
variance += timeDelta * timeDelta
}
return variance === 0 ? 0 : Number((covariance / variance).toFixed(3))
}
/** Percentile of an unsorted numeric series, using nearest-rank. */
export const percentile = (values, fraction) => {
if (values.length === 0) return 0
const sorted = [...values].sort((left, right) => left - right)
const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1))
return round(sorted[rank])
}
// `RunTask` and `RunMicrotasks` are containers: their duration already
// includes the work below them, so counting them would double-count.
const CONTAINER_TRACE_EVENTS = new Set(["RunTask", "RunMicrotasks", "ProfileChunk", "Profile"])
/**
* Breaks recorded time down by trace event.
*
* A CPU sampling profile attributes native work to `(program)`, which hides
* whether time went to HTML parsing, style recalculation, layout, or paint.
* The timeline trace names that work explicitly, so this is what turns "76% of
* busy time is native" into an actionable list.
*/
export const summarizeTraceEvents = (traceEvents, topCount = 15) => {
const totals = new Map()
for (const event of traceEvents) {
if (event.ph !== "X" || !(Number(event.dur) > 0)) continue
if (CONTAINER_TRACE_EVENTS.has(event.name)) continue
const entry = totals.get(event.name) ?? { name: event.name, count: 0, totalMs: 0, maxMs: 0 }
const durationMs = Number(event.dur) / 1000
entry.count += 1
entry.totalMs += durationMs
if (durationMs > entry.maxMs) entry.maxMs = durationMs
totals.set(event.name, entry)
}
return [...totals.values()]
.sort((left, right) => right.totalMs - left.totalMs)
.slice(0, topCount)
.map((entry) => ({ ...entry, totalMs: round(entry.totalMs), maxMs: round(entry.maxMs) }))
}
/**
* Long tasks block input and animation, so a streaming capture is judged by
* its task-duration distribution rather than by an average frame rate.
*/
export const summarizeLongTasks = (traceEvents, thresholdMs = 50) => {
const durations = traceEvents
.filter((event) => event.name === "RunTask" && Number(event.dur) > 0)
.map((event) => Number(event.dur) / 1000)
const long = durations.filter((duration) => duration >= thresholdMs)
return {
taskCount: durations.length,
longTaskCount: long.length,
longTaskTotalMs: round(long.reduce((total, duration) => total + duration, 0)),
// Spreading a large array into Math.max overflows the call stack; a trace
// can easily carry hundreds of thousands of tasks.
longestTaskMs: round(durations.reduce((max, duration) => Math.max(max, duration), 0)),
taskP95Ms: percentile(durations, 0.95),
taskP99Ms: percentile(durations, 0.99),
}
}