UNPKG

chrome-devtools-frontend

Version:
545 lines (509 loc) • 22.1 kB
// Copyright 2023 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import type * as Protocol from '../../../generated/protocol.js'; import type * as CPUProfile from '../../cpu_profile/cpu_profile.js'; import * as Types from '../types/types.js'; import {milliToMicro} from './Timing.js'; import {extractSampleTraceId, makeProfileCall, mergeEventsInOrder, sortTraceEventsInPlace} from './Trace.js'; /** * This is a helper that integrates CPU profiling data coming in the * shape of samples, with trace events. Samples indicate what the JS * stack trace looked at a given point in time, but they don't have * duration. The SamplesIntegrator task is to make an approximation * of what the duration of each JS call was, given the sample data and * given the trace events profiled during that time. At the end of its * execution, the SamplesIntegrator returns an array of ProfileCalls * (under SamplesIntegrator::buildProfileCalls()), which * represent JS calls, with a call frame and duration. These calls have * the shape of a complete trace events and can be treated as flame * chart entries in the timeline. * * The approach to build the profile calls consists in tracking the * current stack as the following events happen (in order): * 1. A sample was done. * 2. A trace event started. * 3. A trace event ended. * Depending on the event and on the data that's coming with it the * stack is updated by adding or removing JS calls to it and updating * the duration of the calls in the tracking stack. * * note: Although this approach has been implemented since long ago, and * is relatively efficient (adds a complexity over the trace parsing of * O(n) where n is the number of samples) it has proven to be faulty. * It might be worthwhile experimenting with improvements or with a * completely different approach. Improving the approach is tracked in * crbug.com/1417439 */ export class SamplesIntegrator { /** * The result of running the samples integrator. Holds the JS calls * with their approximated duration after integrating samples into the * trace event tree. */ #constructedProfileCalls: Types.Events.SyntheticProfileCall[] = []; /** * tracks the state of the JS stack at each point in time to update * the profile call durations as new events arrive. This doesn't only * happen with new profile calls (in which case we would compare the * stack in them) but also with trace events (in which case we would * update the duration of the events we are tracking at the moment). */ #currentJSStack: Types.Events.SyntheticProfileCall[] = []; /** * Process holding the CPU profile and trace events. */ #processId: Types.Events.ProcessID; /** * Thread holding the CPU profile and trace events. */ #threadId: Types.Events.ThreadID; /** * Tracks the depth of the JS stack at the moment a trace event starts * or ends. It is assumed that for the duration of a trace event, the * JS stack's depth cannot decrease, since JS calls that started * before a trace event cannot end during the trace event. So as trace * events arrive, we store the "locked" amount of JS frames that were * in the stack before the event came. */ #lockedJsStackDepth: number[] = []; /** * Used to keep track when samples should be integrated even if they * are not children of invocation trace events. This is useful in * cases where we can be missing the start of JS invocation events if * we start tracing half-way through. */ #fakeJSInvocation = false; /** * The parsed CPU profile, holding the tree hierarchy of JS frames and * the sample data. */ #profileModel: CPUProfile.CPUProfileDataModel.CPUProfileDataModel; /** * Because GC nodes don't have a stack, we artificially add a stack to * them which corresponds to that of the previous sample. This map * tracks which node is used for the stack of a GC call. * Note that GC samples are not shown in the flamechart, however they * are used during the construction of for profile calls, as we can * infer information about the duration of the executed code when a * GC node is sampled. */ #nodeForGC = new Map<Types.Events.SyntheticProfileCall, CPUProfile.ProfileTreeModel.ProfileNode>(); #engineConfig: Types.Configuration.Configuration; #profileId: Types.Events.ProfileID; /** * Keeps track of the individual samples from the CPU Profile. * Only used with Debug Mode experiment enabled. */ jsSampleEvents: Types.Events.SyntheticJSSample[] = []; constructor( profileModel: CPUProfile.CPUProfileDataModel.CPUProfileDataModel, profileId: Types.Events.ProfileID, pid: Types.Events.ProcessID, tid: Types.Events.ThreadID, configuration?: Types.Configuration.Configuration) { this.#profileModel = profileModel; this.#threadId = tid; this.#processId = pid; this.#engineConfig = configuration || Types.Configuration.defaults(); this.#profileId = profileId; } buildProfileCalls(traceEvents: Types.Events.Event[]): Types.Events.SyntheticProfileCall[] { const mergedEvents = mergeEventsInOrder(traceEvents, this.callsFromProfileSamples()); const stack = []; for (let i = 0; i < mergedEvents.length; i++) { const event = mergedEvents[i]; // Because instant trace events have no duration, they don't provide // useful information for possible changes in the duration of calls // in the JS stack. if (event.ph === Types.Events.Phase.INSTANT && !extractSampleTraceId(event)) { continue; } if (stack.length === 0) { if (Types.Events.isProfileCall(event)) { this.#onProfileCall(event); continue; } stack.push(event); this.#onTraceEventStart(event); continue; } const parentEvent = stack.at(-1); if (parentEvent === undefined) { continue; } const begin = event.ts; const parentBegin = parentEvent.ts; const parentDuration = parentEvent.dur || 0; const parentEnd = parentBegin + parentDuration; const startsAfterParent = begin >= parentEnd; if (startsAfterParent) { this.#onTraceEventEnd(parentEvent); stack.pop(); i--; continue; } if (Types.Events.isProfileCall(event)) { this.#onProfileCall(event, parentEvent); continue; } this.#onTraceEventStart(event); stack.push(event); } while (stack.length) { const last = stack.pop(); if (last) { this.#onTraceEventEnd(last); } } sortTraceEventsInPlace(this.jsSampleEvents); return this.#constructedProfileCalls; } #onTraceEventStart(event: Types.Events.Event): void { // Top level events cannot be nested into JS frames so we reset // the stack when we find one. if (event.name === Types.Events.Name.RUN_MICROTASKS || event.name === Types.Events.Name.RUN_TASK) { this.#lockedJsStackDepth = []; this.#truncateJSStack(0, event.ts); this.#fakeJSInvocation = false; } if (this.#fakeJSInvocation) { this.#truncateJSStack(this.#lockedJsStackDepth.pop() || 0, event.ts); this.#fakeJSInvocation = false; } this.#extractStackTrace(event); // Keep track of the call frames in the stack before the event // happened. For the duration of this event, these frames cannot // change (none can be terminated before this event finishes). // // Also, every frame that is opened after this event, is considered // to be a descendant of the event. So once the event finishes, the // frames that were opened after it, need to be closed (see // onEndEvent). // // TODO(crbug.com/1417439): // The assumption that every frame opened after an event is a // descendant of the event is incorrect. For example, a JS call that // parents a trace event might have been sampled after the event was // dispatched. In this case the JS call would be discarded if this // event isn't an invocation event, otherwise the call will be // considered a child of the event. In both cases, the result would // be incorrect. this.#lockedJsStackDepth.push(this.#currentJSStack.length); } #onProfileCall(event: Types.Events.SyntheticProfileCall, parent?: Types.Events.Event): void { if ((parent && Types.Events.isJSInvocationEvent(parent)) || this.#fakeJSInvocation) { this.#extractStackTrace(event); } else if (Types.Events.isProfileCall(event) && this.#currentJSStack.length === 0) { // Force JS Samples to show up even if we are not inside a JS // invocation event, because we can be missing the start of JS // invocation events if we start tracing half-way through. Pretend // we have a top-level JS invocation event. this.#fakeJSInvocation = true; const stackDepthBefore = this.#currentJSStack.length; this.#extractStackTrace(event); this.#lockedJsStackDepth.push(stackDepthBefore); } } #onTraceEventEnd(event: Types.Events.Event): void { // Because the event has ended, any frames that happened after // this event are terminated. Frames that are ancestors to this // event are extended to cover its ending. const endTime = Types.Timing.Micro(event.ts + (event.dur ?? 0)); this.#truncateJSStack(this.#lockedJsStackDepth.pop() || 0, endTime); } /** * Builds the initial calls with no duration from samples. Their * purpose is to be merged with the trace event array being parsed so * that they can be traversed in order with them and their duration * can be updated as the SampleIntegrator callbacks are invoked. */ callsFromProfileSamples(): Types.Events.SyntheticProfileCall[] { const samples = this.#profileModel.samples; const timestamps = this.#profileModel.timestamps; if (!samples) { return []; } const calls: Types.Events.SyntheticProfileCall[] = []; let prevNode; for (let i = 0; i < samples.length; i++) { const node = this.#profileModel.nodeByIndex(i); const timestamp = milliToMicro(Types.Timing.Milli(timestamps[i])); if (!node) { continue; } const call = makeProfileCall(node, this.#profileId, i, timestamp, this.#processId, this.#threadId); calls.push(call); if (this.#engineConfig.debugMode) { const traceId = this.#profileModel.traceIds?.[i]; this.jsSampleEvents.push(this.#makeJSSampleEvent(call, timestamp, traceId)); } if (node.id === this.#profileModel.gcNode?.id && prevNode) { // GC samples have no stack, so we just put GC node on top of the // last recorded sample. Cache the previous sample for future // reference. this.#nodeForGC.set(call, prevNode); continue; } prevNode = node; } return calls; } /** * Given a synthetic profile call, returns an array of profile calls * representing the stack trace that profile call belongs to based on * its nodeId. The input profile call will be at the top of the * returned stack (last position), meaning that any other frames that * were effectively above it are omitted. * @param profileCall * @param overrideTimeStamp a custom timestamp to use for the returned * profile calls. If not defined, the timestamp of the input * profileCall is used instead. This param is useful for example when * creating the profile calls for a sample with a trace id, since the * timestamp of the corresponding trace event should be used instead * of the sample's. */ #makeProfileCallsForStack(profileCall: Types.Events.SyntheticProfileCall, overrideTimeStamp?: Types.Timing.Micro): Types.Events.SyntheticProfileCall[] { let node = this.#profileModel.nodeById(profileCall.nodeId); const isGarbageCollection = node?.id === this.#profileModel.gcNode?.id; if (isGarbageCollection) { // Because GC don't have a stack, we use the stack of the previous // sample. node = this.#nodeForGC.get(profileCall) || null; } if (!node) { return []; } // `node.depth` is 0 based, so to set the size of the array we need // to add 1 to its value. const callFrames = new Array<Types.Events.SyntheticProfileCall>(node.depth + 1 + Number(isGarbageCollection)); // Add the stack trace in reverse order (bottom first). let i = callFrames.length - 1; if (isGarbageCollection) { // Place the garbage collection call frame on top of the stack. callFrames[i--] = profileCall; } // Many of these ProfileCalls will be GC'd later when we estimate the frame // durations while (node) { callFrames[i--] = makeProfileCall( node, profileCall.profileId, profileCall.sampleIndex, overrideTimeStamp ?? profileCall.ts, this.#processId, this.#threadId); node = node.parent; } return callFrames; } #getStackForSampleTraceId(traceId: number, timestamp: Types.Timing.Micro): Types.Events.SyntheticProfileCall[]|null { const nodeId = this.#profileModel.traceIds?.[traceId]; const node = nodeId && this.#profileModel.nodeById(nodeId); const maybeCallForTraceId = node && makeProfileCall(node, this.#profileId, -1, timestamp, this.#processId, this.#threadId); if (!maybeCallForTraceId) { return null; } if (this.#engineConfig.debugMode) { this.jsSampleEvents.push(this.#makeJSSampleEvent(maybeCallForTraceId, timestamp, traceId)); } return this.#makeProfileCallsForStack(maybeCallForTraceId); } /** * Update tracked stack using this event's call stack. */ #extractStackTrace(event: Types.Events.Event): void { let stackTrace = this.#currentJSStack; if (Types.Events.isProfileCall(event)) { stackTrace = this.#makeProfileCallsForStack(event); } const traceId = extractSampleTraceId(event); const maybeCallForTraceId = traceId && this.#getStackForSampleTraceId(traceId, event.ts); if (maybeCallForTraceId) { stackTrace = maybeCallForTraceId; } SamplesIntegrator.filterStackFrames(stackTrace, this.#engineConfig); const endTime = event.ts + (event.dur || 0); const minFrames = Math.min(stackTrace.length, this.#currentJSStack.length); let i; // Merge a sample's stack frames with the stack frames we have // so far if we detect they are equivalent. // Graphically // This: // Current stack trace Sample // [-------A------] [A] // [-------B------] [B] // [-------C------] [C] // ^ t = x1 ^ t = x2 // Becomes this: // New stack trace after merge // [--------A-------] // [--------B-------] // [--------C-------] // ^ t = x2 for (i = this.#lockedJsStackDepth.at(-1) || 0; i < minFrames; ++i) { const newFrame = stackTrace[i].callFrame; const oldFrame = this.#currentJSStack[i].callFrame; if (!SamplesIntegrator.framesAreEqual(newFrame, oldFrame)) { break; } // Scoot the right edge of this callFrame to the right this.#currentJSStack[i].dur = Types.Timing.Micro(Math.max(this.#currentJSStack[i].dur || 0, endTime - this.#currentJSStack[i].ts)); } // If there are call frames in the sample that differ with the stack // we have, update the stack, but keeping the common frames in place // Graphically // This: // Current stack trace Sample // [-------A------] [A] // [-------B------] [B] // [-------C------] [C] // [-------D------] [E] // ^ t = x1 ^ t = x2 // Becomes this: // New stack trace after merge // [--------A-------] // [--------B-------] // [--------C-------] // [E] // ^ t = x2 this.#truncateJSStack(i, event.ts); for (; i < stackTrace.length; ++i) { const call = stackTrace[i]; if (call.nodeId === this.#profileModel.programNode?.id || call.nodeId === this.#profileModel.root?.id || call.nodeId === this.#profileModel.idleNode?.id || call.nodeId === this.#profileModel.gcNode?.id) { // Skip (root), (program) and (idle) frames, since this are not // relevant for web profiling and we don't want to show them in // the timeline. continue; } this.#currentJSStack.push(call); this.#constructedProfileCalls.push(call); } } /** * When a call stack that differs from the one we are tracking has * been detected in the samples, the latter is "truncated" by * setting the ending time of its call frames and removing the top * call frames that aren't shared with the new call stack. This way, * we can update the tracked stack with the new call frames on top. * @param depth the amount of call frames from bottom to top that * should be kept in the tracking stack trace. AKA amount of shared * call frames between two stacks. * @param time the new end of the call frames in the stack. */ #truncateJSStack(depth: number, time: Types.Timing.Micro): void { if (this.#lockedJsStackDepth.length) { const lockedDepth = this.#lockedJsStackDepth.at(-1); if (lockedDepth && depth < lockedDepth) { console.error(`Child stack is shallower (${depth}) than the parent stack (${lockedDepth}) at ${time}`); depth = lockedDepth; } } if (this.#currentJSStack.length < depth) { console.error(`Trying to truncate higher than the current stack size at ${time}`); depth = this.#currentJSStack.length; } for (let k = 0; k < this.#currentJSStack.length; ++k) { this.#currentJSStack[k].dur = Types.Timing.Micro(Math.max(time - this.#currentJSStack[k].ts, 0)); } this.#currentJSStack.length = depth; } #makeJSSampleEvent(call: Types.Events.SyntheticProfileCall, timestamp: Types.Timing.Micro, traceId?: number): Types.Events.SyntheticJSSample { const JSSampleEvent: Types.Events.SyntheticJSSample = { name: Types.Events.Name.JS_SAMPLE, cat: 'devtools.timeline', args: { data: {traceId, stackTrace: this.#makeProfileCallsForStack(call).map(e => e.callFrame)}, }, ph: Types.Events.Phase.INSTANT, ts: timestamp, dur: Types.Timing.Micro(0), pid: this.#processId, tid: this.#threadId, }; return JSSampleEvent; } static framesAreEqual(frame1: Protocol.Runtime.CallFrame, frame2: Protocol.Runtime.CallFrame): boolean { return frame1.scriptId === frame2.scriptId && frame1.functionName === frame2.functionName && frame1.lineNumber === frame2.lineNumber; } static showNativeName(name: string, runtimeCallStatsEnabled: boolean): boolean { return runtimeCallStatsEnabled && Boolean(SamplesIntegrator.nativeGroup(name)); } static nativeGroup(nativeName: string): SamplesIntegrator.NativeGroups|null { if (nativeName.startsWith('Parse')) { return SamplesIntegrator.NativeGroups.PARSE; } if (nativeName.startsWith('Compile') || nativeName.startsWith('Recompile')) { return SamplesIntegrator.NativeGroups.COMPILE; } return null; } static isNativeRuntimeFrame(frame: Protocol.Runtime.CallFrame): boolean { return frame.url === 'native V8Runtime'; } static filterStackFrames(stack: Types.Events.SyntheticProfileCall[], engineConfig: Types.Configuration.Configuration): void { const showAllEvents = engineConfig.showAllEvents; if (showAllEvents) { return; } let previousNativeFrameName: string|null = null; let j = 0; for (let i = 0; i < stack.length; ++i) { const frame = stack[i].callFrame; const nativeRuntimeFrame = SamplesIntegrator.isNativeRuntimeFrame(frame); if (nativeRuntimeFrame && !SamplesIntegrator.showNativeName(frame.functionName, engineConfig.includeRuntimeCallStats)) { continue; } const nativeFrameName = nativeRuntimeFrame ? SamplesIntegrator.nativeGroup(frame.functionName) : null; if (previousNativeFrameName && previousNativeFrameName === nativeFrameName) { continue; } previousNativeFrameName = nativeFrameName; stack[j++] = stack[i]; } stack.length = j; } static createFakeTraceFromCpuProfile(profile: Protocol.Profiler.Profile, tid: Types.Events.ThreadID): Types.File.TraceFile { if (!profile) { return {traceEvents: [], metadata: {}}; } // The |Name.CPU_PROFILE| will let MetaHandler to set |traceIsGeneric| to false // The start time and duration is important here because we'll use them to determine the traceBounds // We use the start and end time of the profile (which is longer than all samples), so the Performance // panel won't truncate this time period. const cpuProfileEvent: Types.Events.SyntheticCpuProfile = { cat: 'disabled-by-default-devtools.timeline', name: Types.Events.Name.CPU_PROFILE, ph: Types.Events.Phase.COMPLETE, pid: Types.Events.ProcessID(1), tid, ts: Types.Timing.Micro(profile.startTime), dur: Types.Timing.Micro(profile.endTime - profile.startTime), args: {data: {cpuProfile: profile}}, // Create an arbitrary profile id. id: '0x1' as Types.Events.ProfileID, }; return { traceEvents: [cpuProfileEvent], metadata: { dataOrigin: Types.File.DataOrigin.CPU_PROFILE, } }; } static extractCpuProfileFromFakeTrace(traceEvents: readonly Types.Events.Event[]): Protocol.Profiler.Profile { const profileEvent = traceEvents.find(e => Types.Events.isSyntheticCpuProfile(e)); const profile = profileEvent?.args.data.cpuProfile; if (!profile) { throw new Error('Missing cpuProfile data'); } return profile; } } export namespace SamplesIntegrator { export const enum NativeGroups { COMPILE = 'Compile', PARSE = 'Parse', } }