Bug 1929003 - [devtools] Use native profiler to collect JS Traces from DevTools. r=dthayer

Stop manually crafting the profiler data by using SpiderMonkey hooks.
Instead, use the profiler native pipeline to do the collection.

This still relies on JSTracer class in order to trigger the record on next interaction or page load.
One caveat is that it starts the profiler from the content process, which prevents recording screenshots correctly.

Differential Revision: https://phabricator.services.mozilla.com/D227831
This commit is contained in:
Alexandre Poirot 2024-11-07 15:52:02 +00:00
Родитель 5959627cee
Коммит a66046ef90
3 изменённых файлов: 37 добавлений и 548 удалений

Просмотреть файл

@ -63,7 +63,7 @@ class TracingStateWatcher {
* @param {String} reason
* Optional string to justify why the tracer stopped.
*/
onTracingToggled(enabled, reason) {
async onTracingToggled(enabled, reason) {
const tracerActor = this.targetActor.getTargetScopedActor("tracer");
const logMethod = tracerActor?.getLogMethod();
@ -81,7 +81,7 @@ class TracingStateWatcher {
logMethod,
profile:
logMethod == TRACER_LOG_METHODS.PROFILER && !enabled
? tracerActor.getProfile()
? await tracerActor.getProfile()
: undefined,
timeStamp: ChromeUtils.dateNow(),
reason,

Просмотреть файл

@ -232,7 +232,7 @@ class TracerActor extends Actor {
}
}
stopTracing() {
async stopTracing() {
if (!this.tracingListener) {
return;
}
@ -252,13 +252,9 @@ class TracerActor extends Actor {
*
* @return {Object} Gecko profiler profile object.
*/
getProfile() {
const profile = this.#stopResult;
// We only open the profile if it contains samples, otherwise it can crash the frontend.
if (profile.threads[0].samples.data.length) {
return profile;
}
return null;
async getProfile() {
// #stopResult is a promise
return this.#stopResult;
}
createValueGrip(value) {

Просмотреть файл

@ -8,65 +8,12 @@
// arguments intact.
/* eslint "no-unused-vars": ["error", {args: "none"} ]*/
// The fallback color for unexpected cases
const DEFAULT_COLOR = "grey";
// The default category for unexpected cases
const DEFAULT_CATEGORIES = [
{
name: "Mixed",
color: DEFAULT_COLOR,
subcategories: ["Other"],
},
];
// Color for each type of category/frame's implementation
const PREDEFINED_COLORS = {
interpreter: "yellow",
baseline: "orange",
ion: "blue",
wasm: "purple",
label: "lightblue",
};
// Indexes of attributes in arrays
const INDEXES = {
stacks: {
prefix: 0,
frame: 1,
},
frames: {
location: 0,
relevantForJS: 1,
innerWindowID: 2,
implementation: 3,
line: 4,
column: 5,
category: 6,
subcategory: 7,
},
};
class ProfilerTracingListener {
constructor({ targetActor, traceValues, traceActor }) {
constructor({ targetActor, traceActor }) {
this.targetActor = targetActor;
this.traceValues = traceValues;
this.sourcesManager = targetActor.sourcesManager;
this.traceActor = traceActor;
this.#reset();
this.#thread = this.#getEmptyThread();
this.#startTime = ChromeUtils.dateNow();
}
#thread = null;
#stackMap = new Map();
#frameMap = new Map();
#categories = DEFAULT_CATEGORIES;
#currentStackIndex = null;
#startTime = null;
/**
* Stop the record and return the gecko profiler data.
*
@ -76,275 +23,34 @@ class ProfilerTracingListener {
* @return {Object}
* The Gecko profile object.
*/
stop(nativeTrace) {
if (nativeTrace) {
const KIND_INDEX = 0;
async stop(nativeTrace) {
// Pause profiler before we collect the profile, so that we don't capture
// more samples while the parent process or android threads wait for subprocess profiles.
Services.profiler.Pause();
const LINENO_INDEX = 1;
const COLUMN_INDEX = 2;
const SCRIPT_ID_INDEX = 3;
const FUNCTION_NAME_ID_INDEX = 4;
const IMPLEMENTATION_INDEX = 5;
const TIME_INDEX = 6;
let profile;
try {
// Attempt to pull out the data.
profile = await Services.profiler.getProfileDataAsync();
const LABEL_INDEX = 1;
const LABEL_TIME_INDEX = 2;
const IMPLEMENTATION_STRINGS = ["interpreter", "baseline", "ion", "wasm"];
for (const entry of nativeTrace.events) {
const kind = entry[KIND_INDEX];
switch (kind) {
case Debugger.TRACING_EVENT_KIND_FUNCTION_ENTER: {
this.#onFramePush(
{
name: nativeTrace.atoms[entry[FUNCTION_NAME_ID_INDEX]],
url: nativeTrace.scriptURLs[entry[SCRIPT_ID_INDEX]],
lineNumber: entry[LINENO_INDEX],
columnNumber: entry[COLUMN_INDEX],
category: IMPLEMENTATION_STRINGS[entry[IMPLEMENTATION_INDEX]],
sourceId: entry[SCRIPT_ID_INDEX],
},
entry[TIME_INDEX]
);
break;
}
case Debugger.TRACING_EVENT_KIND_FUNCTION_LEAVE: {
this.#onFramePop(entry[TIME_INDEX], false);
break;
}
case Debugger.TRACING_EVENT_KIND_LABEL_ENTER: {
this.#logDOMEvent(entry[LABEL_INDEX], entry[LABEL_TIME_INDEX]);
break;
}
case Debugger.TRACING_EVENT_KIND_LABEL_LEAVE: {
this.#onFramePop(entry[LABEL_TIME_INDEX], false);
break;
}
}
if (Object.keys(profile).length === 0) {
console.error(
"An empty object was received from getProfileDataAsync.getProfileDataAsync(), " +
"meaning that a profile could not successfully be serialized and captured."
);
profile = null;
}
} catch (e) {
// Explicitly set the profile to null if there as an error.
profile = null;
console.error(`There was an error fetching a profile`, e);
}
// Create the profile to return.
const profile = this.#getEmptyProfile();
profile.meta.categories = this.#categories;
profile.threads.push(this.#thread);
// Cleanup.
this.#reset();
Services.profiler.StopProfiler();
return profile;
}
/**
* Clear all the internal state of this class.
*/
#reset() {
this.#thread = null;
this.#stackMap = new Map();
this.#frameMap = new Map();
this.#categories = DEFAULT_CATEGORIES;
this.#currentStackIndex = null;
}
/**
* Initialize an empty Gecko profile object.
*
* @return {Object}
* Gecko profile object.
*/
#getEmptyProfile() {
const httpHandler = isWorker
? {}
: Cc["@mozilla.org/network/protocol;1?name=http"].getService(
Ci.nsIHttpProtocolHandler
);
return {
meta: {
// Currently interval is 1, but the frontend mostly ignores this (mandatory) attribute.
// Instead it relies on sample's 'time' attribute to position frames in the stack chart.
interval: 1,
startTime: this.#startTime,
product: Services.appinfo?.name,
importedFrom: "JS Tracer",
version: 28,
presymbolicated: true,
abi: Services.appinfo?.XPCOMABI,
misc: httpHandler.misc,
oscpu: httpHandler.oscpu,
platform: httpHandler.platform,
processType: Services.appinfo?.processType,
categories: [],
stackwalk: 0,
toolkit: Services.appinfo?.widgetToolkit,
appBuildID: Services.appinfo?.appBuildID,
sourceURL: Services.appinfo?.sourceURL,
physicalCPUs: 0,
logicalCPUs: 0,
CPUName: "",
markerSchema: [],
},
libs: [],
pages: [],
threads: [],
processes: [],
};
}
/**
* Generate a thread object to be stored in the Gecko profile object.
*/
#getEmptyThread() {
return {
processType: "default",
processStartupTime: 0,
processShutdownTime: null,
registerTime: 0,
unregisterTime: null,
pausedRanges: [],
name: "GeckoMain",
"eTLD+1": "JS Tracer",
isMainThread: true,
// In workers, you wouldn't have access to appinfo
pid: Services.appinfo?.processID,
tid: 0,
samples: {
schema: {
stack: 0,
time: 1,
eventDelay: 2,
},
data: [],
},
markers: {
schema: {
name: 0,
startTime: 1,
endTime: 2,
phase: 3,
category: 4,
data: 5,
},
data: [],
},
stackTable: {
schema: INDEXES.stacks,
data: [],
},
frameTable: {
schema: INDEXES.frames,
data: [],
},
stringTable: [],
};
}
/**
* Get a frame index for a label frame name.
* Label frame are fake frames in order to display arbitrary strings in the stack chart.
*
* @param {String} label
* @return {Number}
* Frame index for this label frame.
*/
#getOrCreateLabelFrame(label) {
const { frameTable, stringTable } = this.#thread;
const key = `label:${label}`;
let frameIndex = this.#frameMap.get(key);
if (frameIndex === undefined) {
frameIndex = frameTable.data.length;
const locationStringIndex = stringTable.length;
stringTable.push(label);
const categoryIndex = this.#getOrCreateCategory("label");
frameTable.data.push([
locationStringIndex,
true, // relevantForJS
0, // innerWindowID
null, // implementation
null, // line
null, // column
categoryIndex,
0, // subcategory
]);
this.#frameMap.set(key, frameIndex);
}
return frameIndex;
}
/**
* Get the unique index for the given frame.
*
* @param {Object} frameInfo
* @return {Number}
* The index for the given frame.
*/
#getOrCreateFrame(frameInfo) {
const { frameTable, stringTable } = this.#thread;
const key = `${frameInfo.sourceId}:${frameInfo.lineNumber}:${frameInfo.columnNumber}:${frameInfo.category}`;
let frameIndex = this.#frameMap.get(key);
if (frameIndex === undefined) {
frameIndex = frameTable.data.length;
const locationStringIndex = stringTable.length;
// Profiler frontend except a particular string to match the source URL:
// `functionName (http://script.url/:1234:1234)`
// https://github.com/firefox-devtools/profiler/blob/dab645b2db7e1b21185b286f96dd03b77f68f5c3/src/profile-logic/process-profile.js#L518
stringTable.push(
`${frameInfo.name} (${frameInfo.url}:${frameInfo.lineNumber}:${frameInfo.columnNumber})`
);
const categoryIndex = this.#getOrCreateCategory(frameInfo.category);
frameTable.data.push([
locationStringIndex,
true, // relevantForJS
0, // innerWindowID
null, // implementation
frameInfo.lineNumber, // line
frameInfo.columnNumber, // column
categoryIndex,
0, // subcategory
]);
this.#frameMap.set(key, frameIndex);
}
return frameIndex;
}
#getOrCreateStack(frameIndex, prefix) {
const { stackTable } = this.#thread;
const key = prefix === null ? `${frameIndex}` : `${frameIndex},${prefix}`;
let stackIndex = this.#stackMap.get(key);
if (stackIndex === undefined) {
stackIndex = stackTable.data.length;
stackTable.data.push([prefix, frameIndex]);
this.#stackMap.set(key, stackIndex);
}
return stackIndex;
}
#getOrCreateCategory(category) {
const categories = this.#categories;
let categoryIndex = categories.findIndex(c => c.name === category);
if (categoryIndex === -1) {
categoryIndex = categories.length;
categories.push({
name: category,
color: PREDEFINED_COLORS[category] ?? DEFAULT_COLOR,
subcategories: ["Other"],
});
}
return categoryIndex;
}
/**
* Be notified by the underlying JavaScriptTracer class
* in case it stops by itself, instead of being stopped when the Actor's stopTracing
@ -358,6 +64,17 @@ class ProfilerTracingListener {
onTracingToggled(enabled) {
if (!enabled) {
this.traceActor.stopTracing();
} else {
Services.profiler.StartProfiler(
// Note that this is the same default as profiler ones defined in:
// devtools/client/performance-new/shared/background.sys.mjs
128 * 1024 * 1024,
1,
["screenshots", "tracing"],
["GeckoMain", "DOM Worker"],
this.targetActor.sessionContext.browserId,
0
);
}
return false;
}
@ -395,230 +112,6 @@ class ProfilerTracingListener {
// dom mutation in the stack chart.
return false;
}
/**
* Called by JavaScriptTracer class on each step of a function call.
*
* @param {Object} options
* @param {Debugger.Frame} options.frame
* A descriptor object for the JavaScript frame.
* @param {Number} options.depth
* Represents the depth of the frame in the call stack.
* @param {String} options.prefix
* A string to be displayed as a prefix of any logged frame.
* @return {Boolean}
* Return true, if the JavaScriptTracer should log the step to stdout.
*/
onTracingFrameStep({ frame, depth, prefix }) {
// Steps within a function execution aren't recorded in the profiler mode
return false;
}
/**
* Called by JavaScriptTracer class when a new JavaScript frame is executed.
*
* @param {Debugger.Frame} frame
* A descriptor object for the JavaScript frame.
* @param {Number} depth
* Represents the depth of the frame in the call stack.
* @param {String} formatedDisplayName
* A human readable name for the current frame.
* @param {String} prefix
* A string to be displayed as a prefix of any logged frame.
* @param {String} currentDOMEvent
* If this is a top level frame (depth==0), and we are currently processing
* a DOM Event, this will refer to the name of that DOM Event.
* Note that it may also refer to setTimeout and setTimeout callback calls.
* @return {Boolean}
* Return true, if the JavaScriptTracer should log the frame to stdout.
*/
onTracingFrame({
frame,
depth,
formatedDisplayName,
prefix,
currentDOMEvent,
}) {
const { script } = frame;
const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
const url = script.source.url;
// NOTE: Debugger.Script.prototype.getOffsetMetadata returns
// columnNumber in 1-based.
// Convert to 0-based, while keeping the wasm's column (1) as is.
// (bug 1863878)
const columnBase = script.format === "wasm" ? 0 : 1;
const column = columnNumber - columnBase;
// Ignore blackboxed sources
if (this.sourcesManager.isBlackBoxed(url, lineNumber, column)) {
return false;
}
if (currentDOMEvent && depth == 0) {
this.#logDOMEvent(currentDOMEvent);
}
this.#onFramePush({
// formatedDisplayName has a lambda at the beginning, remove it.
name: formatedDisplayName.replace("λ ", ""),
url,
lineNumber,
columnNumber,
category: frame.implementation,
sourceId: script.source.id,
});
return false;
}
/**
* Called when a DOM Event just fired (and some listener in JS is about to run).
*
* @param {String} domEventName
* @param {Number|undefined} [time=undefined]
* The time at which this event occurred
*/
#logDOMEvent(domEventName, time = undefined) {
if (time === undefined) {
time = ChromeUtils.dateNow();
}
const frameIndex = this.#getOrCreateLabelFrame(domEventName);
this.#currentStackIndex = this.#getOrCreateStack(
frameIndex,
this.#currentStackIndex
);
this.#thread.samples.data.push([
this.#currentStackIndex,
time - this.#startTime,
0, // eventDelay
]);
}
/**
* Called by JavaScriptTracer class when a JavaScript frame exits (i.e. a function returns or throw).
*
* @param {Object} options
* @param {Number} options.frameId
* Unique identifier for the current frame.
* This should match a frame notified via onTracingFrame.
* @param {Debugger.Frame} options.frame
* A descriptor object for the JavaScript frame.
* @param {Number} options.depth
* Represents the depth of the frame in the call stack.
* @param {String} options.formatedDisplayName
* A human readable name for the current frame.
* @param {String} options.prefix
* A string to be displayed as a prefix of any logged frame.
* @param {String} options.why
* A string to explain why the function stopped.
* See tracer.sys.mjs's FRAME_EXIT_REASONS.
* @param {Debugger.Object|primitive} options.rv
* The returned value. It can be the returned value, or the thrown exception.
* It is either a primitive object, otherwise it is a Debugger.Object for any other JS Object type.
* @return {Boolean}
* Return true, if the JavaScriptTracer should log the frame to stdout.
*/
onTracingFrameExit({
frameId,
frame,
depth,
formatedDisplayName,
prefix,
why,
rv,
}) {
const { script } = frame;
const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
const url = script.source.url;
// NOTE: Debugger.Script.prototype.getOffsetMetadata returns
// columnNumber in 1-based.
// Convert to 0-based, while keeping the wasm's column (1) as is.
// (bug 1863878)
const columnBase = script.format === "wasm" ? 0 : 1;
const column = columnNumber - columnBase;
// Ignore blackboxed sources
if (this.sourcesManager.isBlackBoxed(url, lineNumber, column)) {
return false;
}
this.#onFramePop();
return false;
}
/**
* Called when a new function is called.
*
* @param {Object} frameInfo
* @param {Number|undefined} [time=undefined]
* The time at which this event occurred
*/
#onFramePush(frameInfo, time) {
if (time === undefined) {
time = ChromeUtils.dateNow();
}
const frameIndex = this.#getOrCreateFrame(frameInfo);
this.#currentStackIndex = this.#getOrCreateStack(
frameIndex,
this.#currentStackIndex
);
this.#thread.samples.data.push([
this.#currentStackIndex,
time - this.#startTime,
0, // eventDelay
]);
}
/**
* Called when a function call ends and returns.
* @param {Number|undefined} [time=undefined]
* The time at which this event occurred
* @param {Boolean} [autoPopLabels=false]
* Whether we should automatically pop label frames if we're popping a root
*/
#onFramePop(time = undefined, autoPopLabels = true) {
if (this.#currentStackIndex === null) {
return;
}
if (time === undefined) {
time = ChromeUtils.dateNow();
}
this.#currentStackIndex =
this.#thread.stackTable.data[this.#currentStackIndex][
INDEXES.stacks.prefix
];
// Record a sample for the parent's stack (or null if there is none [i.e. on top level frame pop])
// so that the frontend considers that the last executed frame stops its execution.
this.#thread.samples.data.push([
this.#currentStackIndex,
time - this.#startTime,
0, // eventDelay
]);
// If we popped and now are on a label frame, with a null line,
// automatically also pop that label frame.
if (autoPopLabels && this.#currentStackIndex !== null) {
const currentFrameIndex =
this.#thread.stackTable.data[this.#currentStackIndex][
INDEXES.stacks.frame
];
const currentFrameLine =
this.#thread.frameTable.data[currentFrameIndex][INDEXES.frames.line];
if (currentFrameLine == null) {
this.#onFramePop(time);
}
}
}
}
exports.ProfilerTracingListener = ProfilerTracingListener;