@quick-game/cli
Version:
Command line interface for rapid qg development
448 lines • 19.7 kB
JavaScript
// 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