UNPKG

@quick-game/cli

Version:

Command line interface for rapid qg development

448 lines 19.7 kB
// Copyright 2023 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Root from '../../../core/root/root.js'; import * as Common from '../../../core/common/common.js'; import * as Types from '../types/types.js'; import { millisecondsToMicroseconds } from './Timing.js'; import { mergeEventsInOrder } from './Trace.js'; /** * This is a helpers 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 efficent (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 runing the samples integrator. Holds the JS calls * with their approximated duration after integrating samples into the * trace event tree. */ #constructedProfileCalls = []; /** * 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 = []; /** * Process holding the CPU profile and trace events. */ #processId; /** * Thread holding the CPU profile and trace 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 = []; /** * 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; /** * 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. */ #nodeForGC = new Map(); constructor(profileModel, pid, tid) { this.#profileModel = profileModel; this.#threadId = tid; this.#processId = pid; } buildProfileCalls(traceEvents) { const mergedEvents = mergeEventsInOrder(traceEvents, this.callsFromProfileSamples()); const stack = []; for (let i = 0; i < mergedEvents.length; i++) { const event = mergedEvents[i]; if (stack.length === 0) { if (Types.TraceEvents.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.TraceEvents.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); } } return this.#constructedProfileCalls; } #onTraceEventStart(event) { // 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 === "I" /* Types.TraceEvents.Phase.INSTANT */) { return; } // Top level events cannot be nested into JS frames so we reset // the stack when we find one. if (event.name === "RunMicrotasks" /* Types.TraceEvents.KnownEventName.RunMicrotasks */ || event.name === "RunTask" /* Types.TraceEvents.KnownEventName.RunTask */) { 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, parent) { if ((parent && SamplesIntegrator.isJSInvocationEvent(parent)) || this.#fakeJSInvocation) { this.#extractStackTrace(event); } else if (Types.TraceEvents.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) { // 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.MicroSeconds(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() { const samples = this.#profileModel.samples; const timestamps = this.#profileModel.timestamps; if (!samples) { return []; } const calls = []; let prevNode; for (let i = 0; i < samples.length; i++) { const node = this.#profileModel.nodeByIndex(i); const timestamp = millisecondsToMicroseconds(Types.Timing.MilliSeconds(timestamps[i])); if (!node) { continue; } const call = SamplesIntegrator.makeProfileCall(node, timestamp, this.#processId, this.#threadId); calls.push(call); 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; } #getStackTraceFromProfileCall(profileCall) { let node = this.#profileModel.nodeById(profileCall.nodeId); const isGarbageCollection = Boolean(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(node.depth + 1 + Number(isGarbageCollection)); // Add the stack trace in reverse order (bottom first). let i = callFrames.length - 1; if (isGarbageCollection) { callFrames[i--] = profileCall; } while (node) { callFrames[i--] = SamplesIntegrator.makeProfileCall(node, profileCall.ts, this.#processId, this.#threadId); node = node.parent; } return callFrames; } /** * Update tracked stack using this event's call stack. */ #extractStackTrace(event) { const stackTrace = Types.TraceEvents.isProfileCall(event) ? this.#getStackTraceFromProfileCall(event) : this.#currentJSStack; SamplesIntegrator.filterStackFrames(stackTrace); 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.MicroSeconds(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]; this.#currentJSStack.push(call); if (call.nodeId === this.#profileModel.programNode?.id || call.nodeId === this.#profileModel.root?.id || call.nodeId === this.#profileModel.idleNode?.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.#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, time) { 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.MicroSeconds(Math.max(time - this.#currentJSStack[k].ts, 0)); } this.#currentJSStack.length = depth; } /** * Generally, before JS is executed, a trace event is dispatched that * parents the JS calls. These we call "invocation" events. This * function determines if an event is one of such. */ static isJSInvocationEvent(event) { switch (event.name) { case "RunMicrotasks" /* Types.TraceEvents.KnownEventName.RunMicrotasks */: case "FunctionCall" /* Types.TraceEvents.KnownEventName.FunctionCall */: case "EvaluateScript" /* Types.TraceEvents.KnownEventName.EvaluateScript */: case "v8.evaluateModule" /* Types.TraceEvents.KnownEventName.EvaluateModule */: case "EventDispatch" /* Types.TraceEvents.KnownEventName.EventDispatch */: case "V8.Execute" /* Types.TraceEvents.KnownEventName.V8Execute */: return true; } // Also consider any new v8 trace events. (eg 'V8.RunMicrotasks' and 'v8.run') if (event.name.startsWith('v8') || event.name.startsWith('V8')) { return true; } return false; } static framesAreEqual(frame1, frame2) { return frame1.scriptId === frame2.scriptId && frame1.functionName === frame2.functionName && frame1.lineNumber === frame2.lineNumber; } static showNativeName(name) { try { // Querying for unregistered experiments will error on debug // builds. const showRuntimeCallStats = Root.Runtime.experiments.isEnabled('timelineV8RuntimeCallStats'); return showRuntimeCallStats && Boolean(SamplesIntegrator.nativeGroup(name)); } catch (error) { return false; } } static nativeGroup(nativeName) { if (nativeName.startsWith('Parse')) { return 'Parse'; } if (nativeName.startsWith('Compile') || nativeName.startsWith('Recompile')) { return 'Compile'; } return null; } static isNativeRuntimeFrame(frame) { return frame.url === 'native V8Runtime'; } static filterStackFrames(stack) { let showAllEvents = false; try { // Querying for unregistered experiments will error on debug // builds. showAllEvents = Root.Runtime.experiments.isEnabled('timelineShowAllEvents'); } catch (_err) { } const showNativeFunctions = Common.Settings.Settings.hasInstance() && Common.Settings.Settings.instance().moduleSetting('showNativeFunctionsInJSProfile').get(); if (showAllEvents) { return; } let previousNativeFrameName = null; let j = 0; for (let i = 0; i < stack.length; ++i) { const frame = stack[i].callFrame; const url = frame.url; const isNativeFrame = url && url.startsWith('native '); if (!showNativeFunctions && isNativeFrame) { continue; } const nativeRuntimeFrame = SamplesIntegrator.isNativeRuntimeFrame(frame); if (nativeRuntimeFrame && !SamplesIntegrator.showNativeName(frame.functionName)) { continue; } const nativeFrameName = nativeRuntimeFrame ? SamplesIntegrator.nativeGroup(frame.functionName) : null; if (previousNativeFrameName && previousNativeFrameName === nativeFrameName) { continue; } previousNativeFrameName = nativeFrameName; stack[j++] = stack[i]; } stack.length = j; } static makeProfileCall(node, ts, pid, tid) { return { cat: '', name: 'ProfileCall', nodeId: node.id, ph: "X" /* Types.TraceEvents.Phase.COMPLETE */, pid, tid, ts, dur: Types.Timing.MicroSeconds(0), selfTime: Types.Timing.MicroSeconds(0), callFrame: node.callFrame, }; } } //# sourceMappingURL=SamplesIntegrator.js.map