lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
592 lines (521 loc) • 22.8 kB
JavaScript
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {MainThreadTasks} from './main-thread-tasks.js';
const SAMPLER_TRACE_EVENT_NAME = 'FunctionCall-SynthesizedByProfilerModel';
/**
* @fileoverview
*
* This model converts the `Profile` and `ProfileChunk` mega trace events from the `disabled-by-default-v8.cpu_profiler`
* category into B/E-style trace events that main-thread-tasks.js already knows how to parse into a task tree.
*
* The V8 CPU profiler measures where time is being spent by sampling the stack (See https://www.jetbrains.com/help/profiler/Profiling_Guidelines__Choosing_the_Right_Profiling_Mode.html
* for a generic description of the differences between tracing and sampling).
*
* A `Profile` event is a record of the stack that was being executed at different sample points in time.
* It has a structure like this:
*
* nodes: [function A, function B, function C]
* samples: [node with id 2, node with id 1, ...]
* timeDeltas: [4125μs since last sample, 121μs since last sample, ...]
*
* Note that this is subtly different from the protocol-based Crdp.Profiler.Profile type.
*
* Helpful prior art:
* @see https://cs.chromium.org/chromium/src/third_party/devtools-frontend/src/front_end/sdk/CPUProfileDataModel.js?sq=package:chromium&g=0&l=42
* @see https://github.com/v8/v8/blob/99ca333b0efba3236954b823101315aefeac51ab/tools/profile.js
* @see https://github.com/jlfwong/speedscope/blob/9ed1eb192cb7e9dac43a5f25bd101af169dc654a/src/import/chrome.ts#L200
*/
/**
* @typedef CpuProfile
* @property {string} id
* @property {number} pid
* @property {number} tid
* @property {number} startTime
* @property {Required<LH.TraceCpuProfile>['nodes']} nodes
* @property {Array<number>} samples
* @property {Array<number>} timeDeltas
*/
/** @typedef {Required<Required<LH.TraceEvent['args']>['data']>['_syntheticProfilerRange']} ProfilerRange */
/** @typedef {LH.TraceEvent & {args: {data: {_syntheticProfilerRange: ProfilerRange}}}} SynthethicEvent */
/** @typedef {Omit<LH.Artifacts.TaskNode, 'event'> & {event: SynthethicEvent, endEvent: SynthethicEvent}} SynthethicTaskNode */
class CpuProfileModel {
/**
* @param {CpuProfile} profile
*/
constructor(profile) {
this._profile = profile;
this._nodesById = this._createNodeMap();
this._activeNodeArraysById = this._createActiveNodeArrays();
}
/**
* Initialization function to enable O(1) access to nodes by node ID.
* @return {Map<number, CpuProfile['nodes'][0]>}
*/
_createNodeMap() {
/** @type {Map<number, CpuProfile['nodes'][0]>} */
const map = new Map();
for (const node of this._profile.nodes) {
map.set(node.id, node);
}
return map;
}
/**
* Initialization function to enable O(1) access to the set of active nodes in the stack by node ID.
* @return {Map<number, Array<number>>}
*/
_createActiveNodeArrays() {
/** @type {Map<number, Array<number>>} */
const map = new Map();
/** @param {number} id @return {Array<number>} */
const getActiveNodes = id => {
if (map.has(id)) return map.get(id) || [];
const node = this._nodesById.get(id);
if (!node) throw new Error(`No such node ${id}`);
if (typeof node.parent === 'number') {
const array = getActiveNodes(node.parent).concat([id]);
map.set(id, array);
return array;
} else {
return [id];
}
};
for (const node of this._profile.nodes) {
map.set(node.id, getActiveNodes(node.id));
}
return map;
}
/**
* Returns all the node IDs in a stack when a specific nodeId is at the top of the stack
* (i.e. a stack's node ID and the node ID of all of its parents).
*
* @param {number} nodeId
* @return {Array<number>}
*/
_getActiveNodeIds(nodeId) {
const activeNodeIds = this._activeNodeArraysById.get(nodeId);
if (!activeNodeIds) throw new Error(`No such node ID ${nodeId}`);
return activeNodeIds;
}
/**
* Generates the necessary B/E-style trace events for a single transition from stack A to stack B
* at the given latest timestamp (includes possible range in event.args.data).
*
* Example:
*
* latestPossibleTimestamp 1234
* previousNodeIds 1,2,3
* currentNodeIds 1,2,4
*
* yields [end 3 at ts 1234, begin 4 at ts 1234]
*
* @param {number} earliestPossibleTimestamp
* @param {number} latestPossibleTimestamp
* @param {Array<number>} previousNodeIds
* @param {Array<number>} currentNodeIds
* @return {Array<SynthethicEvent>}
*/
_synthesizeTraceEventsForTransition(
earliestPossibleTimestamp,
latestPossibleTimestamp,
previousNodeIds,
currentNodeIds
) {
const startNodes = currentNodeIds
.filter(id => !previousNodeIds.includes(id))
.map(id => this._nodesById.get(id))
.filter(node => !!node);
const endNodes = previousNodeIds
.filter(id => !currentNodeIds.includes(id))
.map(id => this._nodesById.get(id))
.filter(node => !!node);
/** @param {CpuProfile['nodes'][0]} node @return {SynthethicEvent} */
const createSyntheticEvent = node => ({
ts: Number.isFinite(latestPossibleTimestamp)
? latestPossibleTimestamp
: earliestPossibleTimestamp,
pid: this._profile.pid,
tid: this._profile.tid,
dur: 0,
ph: 'I',
// This trace event name is Lighthouse-specific and wouldn't be found in a real trace.
// Attribution logic in main-thread-tasks.js special cases this event.
name: SAMPLER_TRACE_EVENT_NAME,
cat: 'lighthouse',
args: {
data: {
callFrame: node.callFrame,
_syntheticProfilerRange: {earliestPossibleTimestamp, latestPossibleTimestamp},
},
},
});
/** @type {Array<SynthethicEvent>} */
const startEvents = startNodes.map(createSyntheticEvent).map(evt => ({...evt, ph: 'B'}));
/** @type {Array<SynthethicEvent>} */
const endEvents = endNodes.map(createSyntheticEvent).map(evt => ({...evt, ph: 'E'}));
// Ensure we put end events in first to finish prior tasks before starting new ones.
return [...endEvents.reverse(), ...startEvents];
}
/**
* @param {LH.TraceEvent | undefined} event
* @return {event is SynthethicEvent}
*/
static isSyntheticEvent(event) {
if (!event) return false;
return Boolean(
event.name === SAMPLER_TRACE_EVENT_NAME &&
event.args.data?._syntheticProfilerRange
);
}
/**
* @param {LH.Artifacts.TaskNode} task
* @return {task is SynthethicTaskNode}
*/
static isSyntheticTask(task) {
return CpuProfileModel.isSyntheticEvent(task.event) &&
CpuProfileModel.isSyntheticEvent(task.endEvent);
}
/**
* Finds all the tasks that started or ended (depending on `type`) within the provided time range.
* Uses a memory index to remember the place in the array the last invocation left off to avoid
* re-traversing the entire array, but note that this index might still be slightly off from the
* true start position.
*
* @param {Array<{startTime: number, endTime: number}>} knownTasks
* @param {{type: 'startTime'|'endTime', initialIndex: number, earliestPossibleTimestamp: number, latestPossibleTimestamp: number}} options
*/
static _getTasksInRange(knownTasks, options) {
const {type, initialIndex, earliestPossibleTimestamp, latestPossibleTimestamp} = options;
// We may have overshot a little from last time, so back up to find the real starting index.
let startIndex = initialIndex;
while (startIndex > 0) {
const task = knownTasks[startIndex];
if (task && task[type] < earliestPossibleTimestamp) break;
startIndex--;
}
/** @type {Array<{startTime: number, endTime: number}>} */
const matchingTasks = [];
for (let i = startIndex; i < knownTasks.length; i++) {
const task = knownTasks[i];
// Task is before our range of interest, keep looping.
if (task[type] < earliestPossibleTimestamp) continue;
// Task is after our range of interest, we're done.
if (task[type] > latestPossibleTimestamp) {
return {tasks: matchingTasks, lastIndex: i};
}
// Task is in our range of interest, add it to our list.
matchingTasks.push(task);
}
// We went through all tasks before reaching the end of our range.
return {tasks: matchingTasks, lastIndex: knownTasks.length};
}
/**
* Given a particular time range and a set of known true tasks, find the correct timestamp to use
* for a transition between tasks.
*
* Because the sampling profiler only provides a *range* of start/stop function boundaries, this
* method uses knowledge of a known set of tasks to find the most accurate timestamp for a particular
* range. For example, if we know that a function ended between 800ms and 810ms, we can use the
* knowledge that a toplevel task ended at 807ms to use 807ms as the correct endtime for this function.
*
* @param {{syntheticTask: SynthethicTaskNode, eventType: 'start'|'end', allEventsAtTs: {naive: Array<SynthethicEvent>, refined: Array<SynthethicEvent>}, knownTaskStartTimeIndex: number, knownTaskEndTimeIndex: number, knownTasksByStartTime: Array<{startTime: number, endTime: number}>, knownTasksByEndTime: Array<{startTime: number, endTime: number}>}} data
* @return {{timestamp: number, lastStartTimeIndex: number, lastEndTimeIndex: number}}
*/
static _findEffectiveTimestamp(data) {
const {
eventType,
syntheticTask,
allEventsAtTs,
knownTasksByStartTime,
knownTaskStartTimeIndex,
knownTasksByEndTime,
knownTaskEndTimeIndex,
} = data;
const targetEvent = eventType === 'start' ? syntheticTask.event : syntheticTask.endEvent;
const pairEvent = eventType === 'start' ? syntheticTask.endEvent : syntheticTask.event;
const timeRange = targetEvent.args.data._syntheticProfilerRange;
const pairTimeRange = pairEvent.args.data._syntheticProfilerRange;
const {tasks: knownTasksStarting, lastIndex: lastStartTimeIndex} = this._getTasksInRange(
knownTasksByStartTime,
{
type: 'startTime',
initialIndex: knownTaskStartTimeIndex,
earliestPossibleTimestamp: timeRange.earliestPossibleTimestamp,
latestPossibleTimestamp: timeRange.latestPossibleTimestamp,
}
);
const {tasks: knownTasksEnding, lastIndex: lastEndTimeIndex} = this._getTasksInRange(
knownTasksByEndTime,
{
type: 'endTime',
initialIndex: knownTaskEndTimeIndex,
earliestPossibleTimestamp: timeRange.earliestPossibleTimestamp,
latestPossibleTimestamp: timeRange.latestPossibleTimestamp,
}
);
// First, find all the tasks that span *across* (not fully contained within) our ambiguous range.
const knownTasksStartingNotContained = knownTasksStarting
.filter(t => !knownTasksEnding.includes(t));
const knownTasksEndingNotContained = knownTasksEnding
.filter(t => !knownTasksStarting.includes(t));
// Each one of these spanning tasks can be in one of three situations:
// - Task is a parent of the sample.
// - Task is a child of the sample.
// - Task has no overlap with the sample.
// Parent tasks must satisfy...
// parentTask.startTime <= syntheticTask.startTime
// AND
// syntheticTask.endTime <= parentTask.endTime
const parentTasks =
eventType === 'start'
? knownTasksStartingNotContained.filter(
t => t.endTime >= pairTimeRange.earliestPossibleTimestamp
)
: knownTasksEndingNotContained.filter(
t => t.startTime <= pairTimeRange.latestPossibleTimestamp
);
// Child tasks must satisfy...
// syntheticTask.startTime <= childTask.startTime
// AND
// childTask.endTime <= syntheticTask.endTime
const childTasks =
eventType === 'start'
? knownTasksStartingNotContained.filter(
t => t.endTime < pairTimeRange.earliestPossibleTimestamp
)
: knownTasksEndingNotContained.filter(
t => t.startTime > pairTimeRange.latestPossibleTimestamp
);
// Unrelated tasks must satisfy...
// unrelatedTask.endTime <= syntheticTask.startTime
// OR
// syntheticTask.endTime <= unrelatedTask.startTime
const unrelatedTasks =
eventType === 'start' ? knownTasksEndingNotContained : knownTasksStartingNotContained;
// Now we narrow our allowable range using the three types of tasks and the other events
// that we've already refined.
const minimumTs = Math.max(
// Sampled event couldn't be earlier than this to begin with.
timeRange.earliestPossibleTimestamp,
// Sampled start event can't be before its parent started.
// Sampled end event can't be before its child ended.
...(eventType === 'start'
? parentTasks.map(t => t.startTime)
: childTasks.map(t => t.endTime)),
// Sampled start event can't be before unrelated tasks ended.
...(eventType === 'start' ? unrelatedTasks.map(t => t.endTime) : []),
// Sampled start event can't be before the other `E` events at its same timestamp.
...(eventType === 'start'
? allEventsAtTs.refined.filter(e => e.ph === 'E').map(e => e.ts)
: [])
);
const maximumTs = Math.min(
// Sampled event couldn't be later than this to begin with.
timeRange.latestPossibleTimestamp,
// Sampled start event can't be after its child started.
// Sampled end event can't be after its parent ended.
...(eventType === 'start'
? childTasks.map(t => t.startTime)
: parentTasks.map(t => t.endTime)),
// Sampled end event can't be after unrelated tasks started.
...(eventType === 'start' ? [] : unrelatedTasks.map(t => t.startTime)),
// Sampled end event can't be after the other `B` events at its same timestamp.
// This is _currently_ only possible in contrived scenarios due to the sorted order of processing,
// but it's a non-obvious observation and case to account for.
...(eventType === 'start'
? []
: allEventsAtTs.refined.filter(e => e.ph === 'B').map(e => e.ts))
);
// We want to maximize the size of the sampling tasks within our constraints, so we'll pick
// the _earliest_ possible time for start events and the _latest_ possible time for end events.
const effectiveTimestamp =
(eventType === 'start' && Number.isFinite(minimumTs)) || !Number.isFinite(maximumTs)
? minimumTs
: maximumTs;
return {timestamp: effectiveTimestamp, lastStartTimeIndex, lastEndTimeIndex};
}
/**
* Creates the B/E-style trace events using only data from the profile itself. Each B/E event will
* include the actual _range_ the timestamp could have been in its metadata that is used for
* refinement later.
*
* @return {Array<SynthethicEvent>}
*/
_synthesizeNaiveTraceEvents() {
const profile = this._profile;
const length = profile.samples.length;
if (profile.timeDeltas.length !== length) throw new Error(`Invalid CPU profile length`);
/** @type {Array<SynthethicEvent>} */
const events = [];
let currentProfilerTimestamp = profile.startTime;
let earliestPossibleTimestamp = -Infinity;
/** @type {Array<number>} */
let lastActiveNodeIds = [];
for (let i = 0; i < profile.samples.length; i++) {
const nodeId = profile.samples[i];
const timeDelta = Math.max(profile.timeDeltas[i], 1);
const node = this._nodesById.get(nodeId);
if (!node) throw new Error(`Missing node ${nodeId}`);
currentProfilerTimestamp += timeDelta;
const activeNodeIds = this._getActiveNodeIds(nodeId);
events.push(
...this._synthesizeTraceEventsForTransition(
earliestPossibleTimestamp,
currentProfilerTimestamp,
lastActiveNodeIds,
activeNodeIds
)
);
earliestPossibleTimestamp = currentProfilerTimestamp;
lastActiveNodeIds = activeNodeIds;
}
events.push(
...this._synthesizeTraceEventsForTransition(
currentProfilerTimestamp,
Infinity,
lastActiveNodeIds,
[]
)
);
return events;
}
/**
* Creates a copy of B/E-style trace events with refined timestamps using knowledge from the
* tasks that have definitive timestamps.
*
* With the sampling profiler we know that a function started/ended _sometime between_ two points,
* but not exactly when. Using the information from other tasks gives us more information to be
* more precise with timings and allows us to create a valid task tree later on.
*
* @param {Array<{startTime: number, endTime: number}>} knownTasks
* @param {Array<SynthethicTaskNode>} syntheticTasks
* @param {Array<SynthethicEvent>} syntheticEvents
* @return {Array<SynthethicEvent>}
*/
_refineTraceEventsWithTasks(knownTasks, syntheticTasks, syntheticEvents) {
/** @type {Array<SynthethicEvent>} */
const refinedEvents = [];
/** @type {Map<number, {naive: Array<SynthethicEvent>, refined: Array<SynthethicEvent>}>} */
const syntheticEventsByTs = new Map();
for (const event of syntheticEvents) {
const group = syntheticEventsByTs.get(event.ts) || {naive: [], refined: []};
group.naive.push(event);
syntheticEventsByTs.set(event.ts, group);
}
/** @type {Map<SynthethicEvent, SynthethicTaskNode>} */
const syntheticTasksByEvent = new Map();
for (const task of syntheticTasks) {
syntheticTasksByEvent.set(task.event, task);
syntheticTasksByEvent.set(task.endEvent, task);
}
const knownTasksByStartTime = knownTasks.slice().sort((a, b) => a.startTime - b.startTime);
const knownTasksByEndTime = knownTasks.slice().sort((a, b) => a.endTime - b.endTime);
let knownTaskStartTimeIndex = 0;
let knownTaskEndTimeIndex = 0;
for (const event of syntheticEvents) {
const syntheticTask = syntheticTasksByEvent.get(event);
if (!syntheticTask) throw new Error('Impossible - all events have a task');
const allEventsAtTs = syntheticEventsByTs.get(event.ts);
if (!allEventsAtTs) throw new Error('Impossible - we just mapped every event');
const effectiveTimestampData = CpuProfileModel._findEffectiveTimestamp({
eventType: event.ph === 'B' ? 'start' : 'end',
syntheticTask,
allEventsAtTs,
knownTaskStartTimeIndex,
knownTaskEndTimeIndex,
knownTasksByStartTime,
knownTasksByEndTime,
});
knownTaskStartTimeIndex = effectiveTimestampData.lastStartTimeIndex;
knownTaskEndTimeIndex = effectiveTimestampData.lastEndTimeIndex;
const refinedEvent = {...event, ts: effectiveTimestampData.timestamp};
refinedEvents.push(refinedEvent);
allEventsAtTs.refined.push(refinedEvent);
}
return refinedEvents;
}
/**
* Creates B/E-style trace events from a CpuProfile object created by `collectProfileEvents()`.
* An optional set of tasks can be passed in to refine the start/end times.
*
* @param {Array<LH.Artifacts.TaskNode>} [knownTaskNodes]
* @return {Array<LH.TraceEvent>}
*/
synthesizeTraceEvents(knownTaskNodes = []) {
const naiveEvents = this._synthesizeNaiveTraceEvents();
if (!naiveEvents.length) return [];
let finalEvents = naiveEvents;
if (knownTaskNodes.length) {
// If we have task information, put the times back into raw trace event ts scale.
/** @type {(baseTs: number) => (node: LH.Artifacts.TaskNode) => LH.Artifacts.TaskNode} */
const rebaseTaskTime = baseTs => node => ({
...node,
startTime: baseTs + node.startTime * 1000,
endTime: baseTs + node.endTime * 1000,
duration: node.duration * 1000,
});
// The first task node might not be time 0, so recompute the baseTs.
const baseTs = knownTaskNodes[0].event.ts - knownTaskNodes[0].startTime * 1000;
const knownTasks = knownTaskNodes.map(rebaseTaskTime(baseTs));
// We'll also create tasks for our naive events so we have the B/E pairs readily available.
const naiveProfilerTasks = MainThreadTasks.getMainThreadTasks(naiveEvents, [], Infinity)
.map(rebaseTaskTime(naiveEvents[0].ts))
.filter(CpuProfileModel.isSyntheticTask);
if (!naiveProfilerTasks.length) throw new Error('Failed to create naive profiler tasks');
finalEvents = this._refineTraceEventsWithTasks(knownTasks, naiveProfilerTasks, naiveEvents);
}
return finalEvents;
}
/**
* Creates B/E-style trace events from a CpuProfile object created by `collectProfileEvents()`
*
* @param {CpuProfile} profile
* @param {Array<LH.Artifacts.TaskNode>} tasks
* @return {Array<LH.TraceEvent>}
*/
static synthesizeTraceEvents(profile, tasks) {
const model = new CpuProfileModel(profile);
return model.synthesizeTraceEvents(tasks);
}
/**
* Merges the data of all the `ProfileChunk` trace events into a single CpuProfile object for consumption
* by `synthesizeTraceEvents()`.
*
* @param {Array<LH.TraceEvent>} traceEvents
* @return {Array<CpuProfile>}
*/
static collectProfileEvents(traceEvents) {
/** @type {Map<string, CpuProfile>} */
const profiles = new Map();
for (const event of traceEvents) {
if (event.name !== 'Profile' && event.name !== 'ProfileChunk') continue;
if (typeof event.id !== 'string') continue;
// `Profile` or `ProfileChunk` can partially define these across multiple events.
// We'll fallback to empty values and worry about validation in the `synthesizeTraceEvents` phase.
const cpuProfileArg = event.args.data?.cpuProfile || {};
const timeDeltas = event.args.data?.timeDeltas || cpuProfileArg.timeDeltas;
let profile = profiles.get(event.id);
if (event.name === 'Profile') {
profile = {
id: event.id,
pid: event.pid,
tid: event.tid,
startTime: event.args.data?.startTime || event.ts,
nodes: cpuProfileArg.nodes || [],
samples: cpuProfileArg.samples || [],
timeDeltas: timeDeltas || [],
};
} else {
if (!profile) continue;
profile.nodes.push(...(cpuProfileArg.nodes || []));
profile.samples.push(...(cpuProfileArg.samples || []));
profile.timeDeltas.push(...(timeDeltas || []));
}
profiles.set(profile.id, profile);
}
return Array.from(profiles.values());
}
}
export {CpuProfileModel};