diff options
Diffstat (limited to 'devtools/client/performance/modules')
22 files changed, 4390 insertions, 0 deletions
diff --git a/devtools/client/performance/modules/categories.js b/devtools/client/performance/modules/categories.js new file mode 100644 index 0000000000..f6ef7073ef --- /dev/null +++ b/devtools/client/performance/modules/categories.js @@ -0,0 +1,87 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { L10N } = require("devtools/client/performance/modules/global"); + +/** + * Details about each label stack frame category. + * To be kept in sync with the JS::ProfilingCategory enum in ProfilingCategory.h + */ +const CATEGORIES = [ + { + color: "#d99b28", + abbrev: "idle", + label: L10N.getStr("category.idle"), + }, + { + color: "#5e88b0", + abbrev: "other", + label: L10N.getStr("category.other"), + }, + { + color: "#46afe3", + abbrev: "layout", + label: L10N.getStr("category.layout"), + }, + { + color: "#d96629", + abbrev: "js", + label: L10N.getStr("category.js"), + }, + { + color: "#eb5368", + abbrev: "gc", + label: L10N.getStr("category.gc"), + }, + { + color: "#df80ff", + abbrev: "network", + label: L10N.getStr("category.network"), + }, + { + color: "#70bf53", + abbrev: "graphics", + label: L10N.getStr("category.graphics"), + }, + { + color: "#8fa1b2", + abbrev: "dom", + label: L10N.getStr("category.dom"), + }, + { + // The devtools-only "tools" category which gets computed by + // computeIsContentAndCategory and is not part of the category list in + // ProfilingStack.h. + color: "#8fa1b2", + abbrev: "tools", + label: L10N.getStr("category.tools"), + }, +]; + +/** + * Get the numeric index for the given category abbreviation. + * See `CATEGORIES` above. + */ +const CATEGORY_INDEX = (() => { + const indexForCategory = {}; + for ( + let categoryIndex = 0; + categoryIndex < CATEGORIES.length; + categoryIndex++ + ) { + const category = CATEGORIES[categoryIndex]; + indexForCategory[category.abbrev] = categoryIndex; + } + + return function(name) { + if (!(name in indexForCategory)) { + throw new Error(`Category abbreviation "${name}" does not exist.`); + } + return indexForCategory[name]; + }; +})(); + +exports.CATEGORIES = CATEGORIES; +exports.CATEGORY_INDEX = CATEGORY_INDEX; diff --git a/devtools/client/performance/modules/constants.js b/devtools/client/performance/modules/constants.js new file mode 100644 index 0000000000..a0adaf5961 --- /dev/null +++ b/devtools/client/performance/modules/constants.js @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +exports.Constants = { + // ms + FRAMERATE_GRAPH_LOW_RES_INTERVAL: 100, + // ms + FRAMERATE_GRAPH_HIGH_RES_INTERVAL: 16, +}; diff --git a/devtools/client/performance/modules/global.js b/devtools/client/performance/modules/global.js new file mode 100644 index 0000000000..7816becc70 --- /dev/null +++ b/devtools/client/performance/modules/global.js @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { MultiLocalizationHelper } = require("devtools/shared/l10n"); +const { PrefsHelper } = require("devtools/client/shared/prefs"); + +/** + * Localization convenience methods. + */ +exports.L10N = new MultiLocalizationHelper( + "devtools/client/locales/markers.properties", + "devtools/client/locales/performance.properties" +); + +/** + * A list of preferences for this tool. The values automatically update + * if somebody edits edits about:config or the prefs change somewhere else. + * + * This needs to be registered and unregistered when used for the auto-update + * functionality to work. The PerformanceController handles this, but if you + * just use this module in a test independently, ensure you call + * `registerObserver()` and `unregisterUnobserver()`. + */ +exports.PREFS = new PrefsHelper("devtools.performance", { + "show-triggers-for-gc-types": ["Char", "ui.show-triggers-for-gc-types"], + "show-platform-data": ["Bool", "ui.show-platform-data"], + "hidden-markers": ["Json", "timeline.hidden-markers"], + "memory-sample-probability": ["Float", "memory.sample-probability"], + "memory-max-log-length": ["Int", "memory.max-log-length"], + "profiler-buffer-size": ["Int", "profiler.buffer-size"], + "profiler-sample-frequency": ["Int", "profiler.sample-frequency-hz"], + // TODO: re-enable once we flame charts via bug 1148663. + "enable-memory-flame": ["Bool", "ui.enable-memory-flame"], +}); diff --git a/devtools/client/performance/modules/io.js b/devtools/client/performance/modules/io.js new file mode 100644 index 0000000000..97fb16dc41 --- /dev/null +++ b/devtools/client/performance/modules/io.js @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Cc, Ci } = require("chrome"); + +const RecordingUtils = require("devtools/shared/performance/recording-utils"); +const { FileUtils } = require("resource://gre/modules/FileUtils.jsm"); +const { NetUtil } = require("resource://gre/modules/NetUtil.jsm"); + +// This identifier string is used to tentatively ascertain whether or not +// a JSON loaded from disk is actually something generated by this tool. +// It isn't, of course, a definitive verification, but a Good Enough™ +// approximation before continuing the import. Don't localize this. +const PERF_TOOL_SERIALIZER_IDENTIFIER = "Recorded Performance Data"; +const PERF_TOOL_SERIALIZER_LEGACY_VERSION = 1; +const PERF_TOOL_SERIALIZER_CURRENT_VERSION = 2; + +/** + * Helpers for importing/exporting JSON. + */ + +/** + * Gets a nsIScriptableUnicodeConverter instance with a default UTF-8 charset. + * @return object + */ +function getUnicodeConverter() { + const cname = "@mozilla.org/intl/scriptableunicodeconverter"; + const converter = Cc[cname].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + return converter; +} + +/** + * Saves a recording as JSON to a file. The provided data is assumed to be + * acyclical, so that it can be properly serialized. + * + * @param object recordingData + * The recording data to stream as JSON. + * @param nsIFile file + * The file to stream the data into. + * @return object + * A promise that is resolved once streaming finishes, or rejected + * if there was an error. + */ +function saveRecordingToFile(recordingData, file) { + recordingData.fileType = PERF_TOOL_SERIALIZER_IDENTIFIER; + recordingData.version = PERF_TOOL_SERIALIZER_CURRENT_VERSION; + + const string = JSON.stringify(recordingData); + const inputStream = getUnicodeConverter().convertToInputStream(string); + const outputStream = FileUtils.openSafeFileOutputStream(file); + + return new Promise(resolve => { + NetUtil.asyncCopy(inputStream, outputStream, resolve); + }); +} + +/** + * Loads a recording stored as JSON from a file. + * + * @param nsIFile file + * The file to import the data from. + * @return object + * A promise that is resolved once importing finishes, or rejected + * if there was an error. + */ +function loadRecordingFromFile(file) { + const channel = NetUtil.newChannel({ + uri: NetUtil.newURI(file), + loadUsingSystemPrincipal: true, + }); + + channel.contentType = "text/plain"; + + return new Promise((resolve, reject) => { + NetUtil.asyncFetch(channel, inputStream => { + let recordingData; + + try { + const string = NetUtil.readInputStreamToString( + inputStream, + inputStream.available() + ); + recordingData = JSON.parse(string); + } catch (e) { + reject(new Error("Could not read recording data file.")); + return; + } + + if (recordingData.fileType != PERF_TOOL_SERIALIZER_IDENTIFIER) { + reject(new Error("Unrecognized recording data file.")); + return; + } + + if (!isValidSerializerVersion(recordingData.version)) { + reject(new Error("Unsupported recording data file version.")); + return; + } + + if (recordingData.version === PERF_TOOL_SERIALIZER_LEGACY_VERSION) { + recordingData = convertLegacyData(recordingData); + } + + if (recordingData.profile.meta.version === 2) { + RecordingUtils.deflateProfile(recordingData.profile); + } + + // If the recording has no label, set it to be the + // filename without its extension. + if (!recordingData.label) { + recordingData.label = file.leafName.replace(/\.[^.]+$/, ""); + } + + resolve(recordingData); + }); + }); +} + +/** + * Returns a boolean indicating whether or not the passed in `version` + * is supported by this serializer. + * + * @param number version + * @return boolean + */ +function isValidSerializerVersion(version) { + return !!~[ + PERF_TOOL_SERIALIZER_LEGACY_VERSION, + PERF_TOOL_SERIALIZER_CURRENT_VERSION, + ].indexOf(version); +} + +/** + * Takes recording data (with version `1`, from the original profiler tool), + * and massages the data to be line with the current performance tool's + * property names and values. + * + * @param object legacyData + * @return object + */ +function convertLegacyData(legacyData) { + const { profilerData, ticksData, recordingDuration } = legacyData; + + // The `profilerData` and `ticksData` stay, but the previously unrecorded + // fields just are empty arrays or objects. + const data = { + label: profilerData.profilerLabel, + duration: recordingDuration, + markers: [], + frames: [], + memory: [], + ticks: ticksData, + allocations: { sites: [], timestamps: [], frames: [], sizes: [] }, + profile: profilerData.profile, + // Fake a configuration object here if there's tick data, + // so that it can be rendered. + configuration: { + withTicks: !!ticksData.length, + withMarkers: false, + withMemory: false, + withAllocations: false, + }, + systemHost: {}, + systemClient: {}, + }; + + return data; +} + +exports.saveRecordingToFile = saveRecordingToFile; +exports.loadRecordingFromFile = loadRecordingFromFile; diff --git a/devtools/client/performance/modules/logic/frame-utils.js b/devtools/client/performance/modules/logic/frame-utils.js new file mode 100644 index 0000000000..a799381274 --- /dev/null +++ b/devtools/client/performance/modules/logic/frame-utils.js @@ -0,0 +1,510 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const global = require("devtools/client/performance/modules/global"); +const demangle = require("devtools/client/shared/demangle"); +const { assert } = require("devtools/shared/DevToolsUtils"); +const { + isChromeScheme, + isContentScheme, + isWASM, + parseURL, +} = require("devtools/client/shared/source-utils"); + +const { + CATEGORY_INDEX, + CATEGORIES, +} = require("devtools/client/performance/modules/categories"); + +// Character codes used in various parsing helper functions. +const CHAR_CODE_R = "r".charCodeAt(0); +const CHAR_CODE_0 = "0".charCodeAt(0); +const CHAR_CODE_9 = "9".charCodeAt(0); +const CHAR_CODE_CAP_Z = "Z".charCodeAt(0); + +const CHAR_CODE_LPAREN = "(".charCodeAt(0); +const CHAR_CODE_RPAREN = ")".charCodeAt(0); +const CHAR_CODE_COLON = ":".charCodeAt(0); +const CHAR_CODE_SPACE = " ".charCodeAt(0); +const CHAR_CODE_UNDERSCORE = "_".charCodeAt(0); + +const EVAL_TOKEN = "%20%3E%20eval"; + +// The cache used to store inflated frames. +const gInflatedFrameStore = new WeakMap(); + +// The cache used to store frame data from `getInfo`. +const gFrameData = new WeakMap(); + +/** + * Parses the raw location of this function call to retrieve the actual + * function name, source url, host name, line and column. + */ +// eslint-disable-next-line complexity +function parseLocation(location, fallbackLine, fallbackColumn) { + // Parse the `location` for the function name, source url, line, column etc. + + let line, column, url; + + // These two indices are used to extract the resource substring, which is + // location[parenIndex + 1 .. lineAndColumnIndex]. + // + // There are 3 variants of location strings in the profiler (with optional + // column numbers): + // 1) "name (resource:line)" + // 2) "resource:line" + // 3) "resource" + // + // For example for (1), take "foo (bar.js:1)". + // ^ ^ + // | | + // | | + // | | + // parenIndex will point to ------+ | + // | + // lineAndColumnIndex will point to -----+ + // + // For an example without parentheses, take "bar.js:2". + // ^ ^ + // | | + // parenIndex will point to ----------------+ | + // | + // lineAndColumIndex will point to ----------------+ + // + // To parse, we look for the last occurrence of the string ' ('. + // + // For 1), all occurrences of space ' ' characters in the resource string + // are urlencoded, so the last occurrence of ' (' is the separator between + // the function name and the resource. + // + // For 2) and 3), there can be no occurences of ' (' since ' ' characters + // are urlencoded in the resource string. + // + // XXX: Note that 3) is ambiguous with Gecko Profiler marker locations like + // "EnterJIT". We can't distinguish the two, so we treat 3) like a function + // name. + let parenIndex = -1; + let lineAndColumnIndex = -1; + + const lastCharCode = location.charCodeAt(location.length - 1); + let i; + if (lastCharCode === CHAR_CODE_RPAREN) { + // Case 1) + i = location.length - 2; + } else if (isNumeric(lastCharCode)) { + // Case 2) + i = location.length - 1; + } else { + // Case 3) + i = 0; + } + + if (i !== 0) { + // Look for a :number. + let end = i; + while (isNumeric(location.charCodeAt(i))) { + i--; + } + if (location.charCodeAt(i) === CHAR_CODE_COLON) { + column = location.substr(i + 1, end - i); + i--; + } + + // Look for a preceding :number. + end = i; + while (isNumeric(location.charCodeAt(i))) { + i--; + } + + // If two were found, the first is the line and the second is the + // column. If only a single :number was found, then it is the line number. + if (location.charCodeAt(i) === CHAR_CODE_COLON) { + line = location.substr(i + 1, end - i); + lineAndColumnIndex = i; + i--; + } else { + lineAndColumnIndex = i + 1; + line = column; + column = undefined; + } + } + + // Look for the last occurrence of ' (' in case 1). + if (lastCharCode === CHAR_CODE_RPAREN) { + for (; i >= 0; i--) { + if ( + location.charCodeAt(i) === CHAR_CODE_LPAREN && + i > 0 && + location.charCodeAt(i - 1) === CHAR_CODE_SPACE + ) { + parenIndex = i; + break; + } + } + } + + let parsedUrl; + if (lineAndColumnIndex > 0) { + const resource = location.substring(parenIndex + 1, lineAndColumnIndex); + url = resource.split(" -> ").pop(); + if (url) { + parsedUrl = parseURL(url); + } + } + + let functionName, fileName, port, host; + line = line || fallbackLine; + column = column || fallbackColumn; + + // If the URL digged out from the `location` is valid, this is a JS frame. + if (parsedUrl) { + functionName = location.substring(0, parenIndex - 1); + fileName = parsedUrl.fileName; + port = parsedUrl.port; + host = parsedUrl.host; + + // Check for the case of the filename containing eval + // e.g. "file.js%20line%2065%20%3E%20eval" + const evalIndex = fileName.indexOf(EVAL_TOKEN); + if (evalIndex !== -1 && evalIndex === fileName.length - EVAL_TOKEN.length) { + // Match the filename + const evalLine = line; + const [, _fileName, , _line] = + fileName.match(/(.+)(%20line%20(\d+)%20%3E%20eval)/) || []; + fileName = `${_fileName} (eval:${evalLine})`; + line = _line; + assert( + _fileName !== undefined, + "Filename could not be found from an eval location site" + ); + assert( + _line !== undefined, + "Line could not be found from an eval location site" + ); + + // Match the url as well + [, url] = url.match(/(.+)( line (\d+) > eval)/) || []; + assert( + url !== undefined, + "The URL could not be parsed correctly from an eval location site" + ); + } + } else { + functionName = location; + url = null; + } + + return { functionName, fileName, host, port, url, line, column }; +} + +/** + * Sets the properties of `isContent` and `category` on a frame. + * + * @param {InflatedFrame} frame + */ +function computeIsContentAndCategory(frame) { + const location = frame.location; + + // There are 3 variants of location strings in the profiler (with optional + // column numbers): + // 1) "name (resource:line)" + // 2) "resource:line" + // 3) "resource" + const lastCharCode = location.charCodeAt(location.length - 1); + let schemeStartIndex = -1; + if (lastCharCode === CHAR_CODE_RPAREN) { + // Case 1) + // + // Need to search for the last occurrence of ' (' to find the start of the + // resource string. + for (let i = location.length - 2; i >= 0; i--) { + if ( + location.charCodeAt(i) === CHAR_CODE_LPAREN && + i > 0 && + location.charCodeAt(i - 1) === CHAR_CODE_SPACE + ) { + schemeStartIndex = i + 1; + break; + } + } + } else { + // Cases 2) and 3) + schemeStartIndex = 0; + } + + // We can't know if WASM frames are content or not at the time of this writing, so label + // them all as content. + if (isContentScheme(location, schemeStartIndex) || isWASM(location)) { + frame.isContent = true; + return; + } + + if (frame.category !== null && frame.category !== undefined) { + return; + } + + if (schemeStartIndex !== 0) { + for (let j = schemeStartIndex; j < location.length; j++) { + if ( + location.charCodeAt(j) === CHAR_CODE_R && + isChromeScheme(location, j) && + (location.includes("resource://devtools") || + location.includes("resource://devtools")) + ) { + frame.category = CATEGORY_INDEX("tools"); + return; + } + } + } + + if (location === "EnterJIT") { + frame.category = CATEGORY_INDEX("js"); + return; + } + + frame.category = CATEGORY_INDEX("other"); +} + +/** + * Get caches to cache inflated frames and computed frame keys of a frame + * table. + * + * @param object framesTable + * @return object + */ +function getInflatedFrameCache(frameTable) { + let inflatedCache = gInflatedFrameStore.get(frameTable); + if (inflatedCache !== undefined) { + return inflatedCache; + } + + // Fill with nulls to ensure no holes. + inflatedCache = Array.from({ length: frameTable.data.length }, () => null); + gInflatedFrameStore.set(frameTable, inflatedCache); + return inflatedCache; +} + +/** + * Get or add an inflated frame to a cache. + * + * @param object cache + * @param number index + * @param object frameTable + * @param object stringTable + */ +function getOrAddInflatedFrame(cache, index, frameTable, stringTable) { + let inflatedFrame = cache[index]; + if (inflatedFrame === null) { + inflatedFrame = cache[index] = new InflatedFrame( + index, + frameTable, + stringTable + ); + } + return inflatedFrame; +} + +/** + * An intermediate data structured used to hold inflated frames. + * + * @param number index + * @param object frameTable + * @param object stringTable + */ +function InflatedFrame(index, frameTable, stringTable) { + const LOCATION_SLOT = frameTable.schema.location; + const IMPLEMENTATION_SLOT = frameTable.schema.implementation; + const OPTIMIZATIONS_SLOT = frameTable.schema.optimizations; + const LINE_SLOT = frameTable.schema.line; + const CATEGORY_SLOT = frameTable.schema.category; + + const frame = frameTable.data[index]; + const category = frame[CATEGORY_SLOT]; + this.location = stringTable[frame[LOCATION_SLOT]]; + this.implementation = frame[IMPLEMENTATION_SLOT]; + this.optimizations = frame[OPTIMIZATIONS_SLOT]; + this.line = frame[LINE_SLOT]; + this.column = undefined; + this.category = category; + this.isContent = false; + + // Attempt to compute if this frame is a content frame, and if not, + // its category. + // + // Since only C++ stack frames have associated category information, + // attempt to generate a useful category, fallback to the one provided + // by the profiling data, or fallback to an unknown category. + computeIsContentAndCategory(this); +} + +/** + * Gets the frame key (i.e., equivalence group) according to options. Content + * frames are always identified by location. Chrome frames are identified by + * location if content-only filtering is off. If content-filtering is on, they + * are identified by their category. + * + * @param object options + * @return string + */ +InflatedFrame.prototype.getFrameKey = function getFrameKey(options) { + if (this.isContent || !options.contentOnly || options.isRoot) { + options.isMetaCategoryOut = false; + return this.location; + } + + if (options.isLeaf) { + // We only care about leaf platform frames if we are displaying content + // only. If no category is present, give the default category of "other". + // + // 1. The leaf is where time is _actually_ being spent, so we _need_ to + // show it to developers in some way to give them accurate profiling + // data. We decide to split the platform into various category buckets + // and just show time spent in each bucket. + // + // 2. The calls leading to the leaf _aren't_ where we are spending time, + // but _do_ give the developer context for how they got to the leaf + // where they _are_ spending time. For non-platform hackers, the + // non-leaf platform frames don't give any meaningful context, and so we + // can safely filter them out. + options.isMetaCategoryOut = true; + return this.category; + } + + // Return an empty string denoting that this frame should be skipped. + return ""; +}; + +function isNumeric(c) { + return c >= CHAR_CODE_0 && c <= CHAR_CODE_9; +} + +function shouldDemangle(name) { + return ( + name?.charCodeAt && + name.charCodeAt(0) === CHAR_CODE_UNDERSCORE && + name.charCodeAt(1) === CHAR_CODE_UNDERSCORE && + name.charCodeAt(2) === CHAR_CODE_CAP_Z + ); +} + +/** + * Calculates the relative costs of this frame compared to a root, + * and generates allocations information if specified. Uses caching + * if possible. + * + * @param {ThreadNode|FrameNode} node + * The node we are calculating. + * @param {ThreadNode} options.root + * The root thread node to calculate relative costs. + * Generates [self|total] [duration|percentage] values. + * @param {boolean} options.allocations + * Generates `totalAllocations` and `selfAllocations`. + * + * @return {object} + */ +function getFrameInfo(node, options) { + let data = gFrameData.get(node); + + if (!data) { + if (node.nodeType === "Thread") { + data = Object.create(null); + data.functionName = global.L10N.getStr("table.root"); + } else { + data = parseLocation(node.location, node.line, node.column); + data.hasOptimizations = node.hasOptimizations(); + data.isContent = node.isContent; + data.isMetaCategory = node.isMetaCategory; + } + data.samples = node.youngestFrameSamples; + const hasCategory = node.category !== null && node.category !== undefined; + data.categoryData = hasCategory + ? CATEGORIES[node.category] || CATEGORIES[CATEGORY_INDEX("other")] + : {}; + data.nodeType = node.nodeType; + + // Frame name (function location or some meta information) + if (data.isMetaCategory) { + data.name = data.categoryData.label; + } else if (shouldDemangle(data.functionName)) { + data.name = demangle(data.functionName); + } else { + data.name = data.functionName; + } + + data.tooltiptext = data.isMetaCategory + ? data.categoryData.label + : node.location || ""; + + gFrameData.set(node, data); + } + + // If no options specified, we can't calculate relative values, abort here + if (!options) { + return data; + } + + // If a root specified, calculate the relative costs in the context of + // this call tree. The cached store may already have this, but generate + // if it does not. + const totalSamples = options.root.samples; + const totalDuration = options.root.duration; + if (options?.root && !data.COSTS_CALCULATED) { + data.selfDuration = + (node.youngestFrameSamples / totalSamples) * totalDuration; + data.selfPercentage = (node.youngestFrameSamples / totalSamples) * 100; + data.totalDuration = (node.samples / totalSamples) * totalDuration; + data.totalPercentage = (node.samples / totalSamples) * 100; + data.COSTS_CALCULATED = true; + } + + if (options?.allocations && !data.ALLOCATION_DATA_CALCULATED) { + const totalBytes = options.root.byteSize; + data.selfCount = node.youngestFrameSamples; + data.totalCount = node.samples; + data.selfCountPercentage = (node.youngestFrameSamples / totalSamples) * 100; + data.totalCountPercentage = (node.samples / totalSamples) * 100; + data.selfSize = node.youngestFrameByteSize; + data.totalSize = node.byteSize; + data.selfSizePercentage = (node.youngestFrameByteSize / totalBytes) * 100; + data.totalSizePercentage = (node.byteSize / totalBytes) * 100; + data.ALLOCATION_DATA_CALCULATED = true; + } + + return data; +} + +exports.getFrameInfo = getFrameInfo; + +/** + * Takes an inverted ThreadNode and searches its youngest frames for + * a FrameNode with matching location. + * + * @param {ThreadNode} threadNode + * @param {string} location + * @return {?FrameNode} + */ +function findFrameByLocation(threadNode, location) { + if (!threadNode.inverted) { + throw new Error( + "FrameUtils.findFrameByLocation only supports leaf nodes in an inverted tree." + ); + } + + const calls = threadNode.calls; + for (let i = 0; i < calls.length; i++) { + if (calls[i].location === location) { + return calls[i]; + } + } + return null; +} + +exports.findFrameByLocation = findFrameByLocation; +exports.computeIsContentAndCategory = computeIsContentAndCategory; +exports.parseLocation = parseLocation; +exports.getInflatedFrameCache = getInflatedFrameCache; +exports.getOrAddInflatedFrame = getOrAddInflatedFrame; +exports.InflatedFrame = InflatedFrame; +exports.shouldDemangle = shouldDemangle; diff --git a/devtools/client/performance/modules/logic/jit.js b/devtools/client/performance/modules/logic/jit.js new file mode 100644 index 0000000000..5063dd4a8b --- /dev/null +++ b/devtools/client/performance/modules/logic/jit.js @@ -0,0 +1,350 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +// An outcome of an OptimizationAttempt that is considered successful. +const SUCCESSFUL_OUTCOMES = [ + "GenericSuccess", + "Inlined", + "DOM", + "Monomorphic", + "Polymorphic", +]; + +/** + * Model representing JIT optimization sites from the profiler + * for a frame (represented by a FrameNode). Requires optimization data from + * a profile, which is an array of RawOptimizationSites. + * + * When the ThreadNode for the profile iterates over the samples' frames, each + * frame's optimizations are accumulated in their respective FrameNodes. Each + * FrameNode may contain many different optimization sites. One sample may + * pick up optimization X on line Y in the frame, with the next sample + * containing optimization Z on line W in the same frame, as each frame is + * only function. + * + * An OptimizationSite contains a record of how many times the + * RawOptimizationSite was sampled, as well as the unique id based off of the + * original profiler array, and the RawOptimizationSite itself as a reference. + * @see devtools/client/performance/modules/logic/tree-model.js + * + * @struct RawOptimizationSite + * A structure describing a location in a script that was attempted to be optimized. + * Contains all the IonTypes observed, and the sequence of OptimizationAttempts that + * were attempted, and the line and column in the script. This is retrieved from the + * profiler after a recording, and our base data structure. Should always be referenced, + * and unmodified. + * + * Note that propertyName is an index into a string table, which needs to be + * provided in order for the raw optimization site to be inflated. + * + * @type {Array<IonType>} types + * @type {Array<OptimizationAttempt>} attempts + * @type {?number} propertyName + * @type {number} line + * @type {number} column + * + * + * @struct IonType + * IonMonkey attempts to classify each value in an optimization site by some type. + * Based off of the observed types for a value (like a variable that could be a + * string or an instance of an object), it determines what kind of type it should be + * classified as. Each IonType here contains an array of all ObservedTypes under `types`, + * the Ion type that IonMonkey decided this value should be (Int32, Object, etc.) as + * `mirType`, and the component of this optimization type that this value refers to -- + * like a "getter" optimization, `a[b]`, has site `a` (the "Receiver") and `b` + * (the "Index"). + * + * Generally the more ObservedTypes, the more deoptimized this OptimizationSite is. + * There could be no ObservedTypes, in which case `typeset` is undefined. + * + * @type {?Array<ObservedType>} typeset + * @type {string} site + * @type {string} mirType + * + * + * @struct ObservedType + * When IonMonkey attempts to determine what type a value is, it checks on each sample. + * The ObservedType can be thought of in more of JavaScripty-terms, rather than C++. + * The `keyedBy` property is a high level description of the type, like "primitive", + * "constructor", "function", "singleton", "alloc-site" (that one is a bit more weird). + * If the `keyedBy` type is a function or constructor, the ObservedType should have a + * `name` property, referring to the function or constructor name from the JS source. + * If IonMonkey can determine the origin of this type (like where the constructor is + * defined), the ObservedType will also have `location` and `line` properties, but + * `location` can sometimes be non-URL strings like "self-hosted" or a memory location + * like "102ca7880", or no location at all, and maybe `line` is 0 or undefined. + * + * @type {string} keyedBy + * @type {?string} name + * @type {?string} location + * @type {?string} line + * + * + * @struct OptimizationAttempt + * Each RawOptimizationSite contains an array of OptimizationAttempts. Generally, + * IonMonkey goes through a series of strategies for each kind of optimization, starting + * from most-niche and optimized, to the less-optimized, but more general strategies -- + * for example, a getter opt may first try to optimize for the scenario of a getter on an + * `arguments` object -- that will fail most of the time, as most objects are not + * arguments objects, but it will attempt several strategies in order until it finds a + * strategy that works, or fails. Even in the best scenarios, some attempts will fail + * (like the arguments getter example), which is OK, as long as some attempt succeeds + * (with the earlier attempts preferred, as those are more optimized). In an + * OptimizationAttempt structure, we store just the `strategy` name and `outcome` name, + * both from enums in js/public/TrackedOptimizationInfo.h as TRACKED_STRATEGY_LIST and + * TRACKED_OUTCOME_LIST, respectively. An array of successful outcome strings are above + * in SUCCESSFUL_OUTCOMES. + * + * @see js/public/TrackedOptimizationInfo.h + * + * @type {string} strategy + * @type {string} outcome + */ + +/* + * A wrapper around RawOptimizationSite to record sample count and ID (referring to the + * index of where this is in the initially seeded optimizations data), so we don't mutate + * the original data from the profiler. Provides methods to access the underlying + * optimization data easily, so understanding the semantics of JIT data isn't necessary. + * + * @constructor + * + * @param {Array<RawOptimizationSite>} optimizations + * @param {number} optsIndex + * + * @type {RawOptimizationSite} data + * @type {number} samples + * @type {number} id + */ + +const OptimizationSite = function(id, opts) { + this.id = id; + this.data = opts; + this.samples = 1; +}; + +/** + * Constructor for JITOptimizations. A collection of OptimizationSites for a frame. + * + * @constructor + * @param {Array<RawOptimizationSite>} rawSites + * Array of raw optimization sites. + * @param {Array<string>} stringTable + * Array of strings from the profiler used to inflate + * JIT optimizations. Do not modify this! + */ + +const JITOptimizations = function(rawSites, stringTable) { + // Build a histogram of optimization sites. + const sites = []; + + for (const rawSite of rawSites) { + const existingSite = sites.find(site => site.data === rawSite); + if (existingSite) { + existingSite.samples++; + } else { + sites.push(new OptimizationSite(sites.length, rawSite)); + } + } + + // Inflate the optimization information. + for (const site of sites) { + const data = site.data; + const STRATEGY_SLOT = data.attempts.schema.strategy; + const OUTCOME_SLOT = data.attempts.schema.outcome; + const attempts = data.attempts.data.map(a => { + return { + id: site.id, + strategy: stringTable[a[STRATEGY_SLOT]], + outcome: stringTable[a[OUTCOME_SLOT]], + }; + }); + const types = data.types.map(t => { + const typeset = maybeTypeset(t.typeset, stringTable); + if (typeset) { + typeset.forEach(ts => { + ts.id = site.id; + }); + } + + return { + id: site.id, + typeset, + site: stringTable[t.site], + mirType: stringTable[t.mirType], + }; + }); + // Add IDs to to all children objects, so we can correllate sites when + // just looking at a specific type, attempt, etc.. + attempts.id = types.id = site.id; + + site.data = { + attempts, + types, + propertyName: maybeString(stringTable, data.propertyName), + line: data.line, + column: data.column, + }; + } + + this.optimizationSites = sites.sort((a, b) => b.samples - a.samples); +}; + +/** + * Make JITOptimizations iterable. + */ +JITOptimizations.prototype = { + [Symbol.iterator]: function*() { + yield* this.optimizationSites; + }, + + get length() { + return this.optimizationSites.length; + }, +}; + +/** + * Takes an "outcome" string from an OptimizationAttempt and returns + * a boolean indicating whether or not its a successful outcome. + * + * @return {boolean} + */ + +function isSuccessfulOutcome(outcome) { + return !!~SUCCESSFUL_OUTCOMES.indexOf(outcome); +} + +/** + * Takes an OptimizationSite. Returns a boolean indicating if the passed + * in OptimizationSite has a "good" outcome at the end of its attempted strategies. + * + * @param {OptimizationSite} optimizationSite + * @return {boolean} + */ + +function hasSuccessfulOutcome(optimizationSite) { + const attempts = optimizationSite.data.attempts; + const lastOutcome = attempts[attempts.length - 1].outcome; + return isSuccessfulOutcome(lastOutcome); +} + +function maybeString(stringTable, index) { + return index ? stringTable[index] : undefined; +} + +function maybeTypeset(typeset, stringTable) { + if (!typeset) { + return undefined; + } + return typeset.map(ty => { + return { + keyedBy: maybeString(stringTable, ty.keyedBy), + name: maybeString(stringTable, ty.name), + location: maybeString(stringTable, ty.location), + line: ty.line, + }; + }); +} + +// Map of optimization implementation names to an enum. +const IMPLEMENTATION_MAP = { + interpreter: 0, + baseline: 1, + ion: 2, +}; +const IMPLEMENTATION_NAMES = Object.keys(IMPLEMENTATION_MAP); + +/** + * Takes data from a FrameNode and computes rendering positions for + * a stacked mountain graph, to visualize JIT optimization tiers over time. + * + * @param {FrameNode} frameNode + * The FrameNode who's optimizations we're iterating. + * @param {Array<number>} sampleTimes + * An array of every sample time within the range we're counting. + * From a ThreadNode's `sampleTimes` property. + * @param {number} bucketSize + * Size of each bucket in milliseconds. + * `duration / resolution = bucketSize` in OptimizationsGraph. + * @return {?Array<object>} + */ +function createTierGraphDataFromFrameNode(frameNode, sampleTimes, bucketSize) { + const tierData = frameNode.getTierData(); + const stringTable = frameNode._stringTable; + const output = []; + let implEnum; + + let tierDataIndex = 0; + let nextOptSample = tierData[tierDataIndex]; + + // Bucket data + let samplesInCurrentBucket = 0; + let currentBucketStartTime = sampleTimes[0]; + let bucket = []; + + // Store previous data point so we can have straight vertical lines + let previousValues; + + // Iterate one after the samples, so we can finalize the last bucket + for (let i = 0; i <= sampleTimes.length; i++) { + const sampleTime = sampleTimes[i]; + + // If this sample is in the next bucket, or we're done + // checking sampleTimes and on the last iteration, finalize previous bucket + if ( + sampleTime >= currentBucketStartTime + bucketSize || + i >= sampleTimes.length + ) { + const dataPoint = {}; + dataPoint.values = []; + dataPoint.delta = currentBucketStartTime; + + // Map the opt site counts as a normalized percentage (0-1) + // of its count in context of total samples this bucket + for (let j = 0; j < IMPLEMENTATION_NAMES.length; j++) { + dataPoint.values[j] = (bucket[j] || 0) / (samplesInCurrentBucket || 1); + } + + // Push the values from the previous bucket to the same time + // as the current bucket so we get a straight vertical line. + if (previousValues) { + const data = Object.create(null); + data.values = previousValues; + data.delta = currentBucketStartTime; + output.push(data); + } + + output.push(dataPoint); + + // Set the new start time of this bucket and reset its count + currentBucketStartTime += bucketSize; + samplesInCurrentBucket = 0; + previousValues = dataPoint.values; + bucket = []; + } + + // If this sample observed an optimization in this frame, record it + if (nextOptSample && nextOptSample.time === sampleTime) { + // If no implementation defined, it was the "interpreter". + implEnum = + IMPLEMENTATION_MAP[ + stringTable[nextOptSample.implementation] || "interpreter" + ]; + bucket[implEnum] = (bucket[implEnum] || 0) + 1; + nextOptSample = tierData[++tierDataIndex]; + } + + samplesInCurrentBucket++; + } + + return output; +} + +exports.createTierGraphDataFromFrameNode = createTierGraphDataFromFrameNode; +exports.OptimizationSite = OptimizationSite; +exports.JITOptimizations = JITOptimizations; +exports.hasSuccessfulOutcome = hasSuccessfulOutcome; +exports.isSuccessfulOutcome = isSuccessfulOutcome; +exports.SUCCESSFUL_OUTCOMES = SUCCESSFUL_OUTCOMES; diff --git a/devtools/client/performance/modules/logic/moz.build b/devtools/client/performance/modules/logic/moz.build new file mode 100644 index 0000000000..01f77231d7 --- /dev/null +++ b/devtools/client/performance/modules/logic/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "frame-utils.js", + "jit.js", + "telemetry.js", + "tree-model.js", + "waterfall-utils.js", +) diff --git a/devtools/client/performance/modules/logic/telemetry.js b/devtools/client/performance/modules/logic/telemetry.js new file mode 100644 index 0000000000..4ea267d747 --- /dev/null +++ b/devtools/client/performance/modules/logic/telemetry.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Telemetry = require("devtools/client/shared/telemetry"); +const EVENTS = require("devtools/client/performance/events"); + +const EVENT_MAP_FLAGS = new Map([ + [EVENTS.RECORDING_IMPORTED, "DEVTOOLS_PERFTOOLS_RECORDING_IMPORT_FLAG"], + [EVENTS.RECORDING_EXPORTED, "DEVTOOLS_PERFTOOLS_RECORDING_EXPORT_FLAG"], +]); + +const RECORDING_FEATURES = [ + "withMarkers", + "withTicks", + "withMemory", + "withAllocations", +]; + +const SELECTED_VIEW_HISTOGRAM_NAME = "DEVTOOLS_PERFTOOLS_SELECTED_VIEW_MS"; + +function PerformanceTelemetry(emitter) { + this._emitter = emitter; + this._telemetry = new Telemetry(); + this.onFlagEvent = this.onFlagEvent.bind(this); + this.onRecordingStateChange = this.onRecordingStateChange.bind(this); + this.onViewSelected = this.onViewSelected.bind(this); + + for (const [event] of EVENT_MAP_FLAGS) { + this._emitter.on(event, this.onFlagEvent.bind(this, event)); + } + + this._emitter.on(EVENTS.RECORDING_STATE_CHANGE, this.onRecordingStateChange); + this._emitter.on(EVENTS.UI_DETAILS_VIEW_SELECTED, this.onViewSelected); +} + +PerformanceTelemetry.prototype.destroy = function() { + if (this._previousView) { + this._telemetry.finishKeyed( + SELECTED_VIEW_HISTOGRAM_NAME, + this._previousView, + this, + false + ); + } + + for (const [event] of EVENT_MAP_FLAGS) { + this._emitter.off(event, this.onFlagEvent); + } + this._emitter.off(EVENTS.RECORDING_STATE_CHANGE, this.onRecordingStateChange); + this._emitter.off(EVENTS.UI_DETAILS_VIEW_SELECTED, this.onViewSelected); + this._emitter = null; +}; + +PerformanceTelemetry.prototype.onFlagEvent = function(eventName, ...data) { + this._telemetry.getHistogramById(EVENT_MAP_FLAGS.get(eventName)).add(true); +}; + +PerformanceTelemetry.prototype.onRecordingStateChange = function( + status, + model +) { + if (status != "recording-stopped") { + return; + } + + if (model.isConsole()) { + this._telemetry + .getHistogramById("DEVTOOLS_PERFTOOLS_CONSOLE_RECORDING_COUNT") + .add(true); + } else { + this._telemetry + .getHistogramById("DEVTOOLS_PERFTOOLS_RECORDING_COUNT") + .add(true); + } + + this._telemetry + .getHistogramById("DEVTOOLS_PERFTOOLS_RECORDING_DURATION_MS") + .add(model.getDuration()); + + const config = model.getConfiguration(); + for (const k in config) { + if (RECORDING_FEATURES.includes(k)) { + this._telemetry + .getKeyedHistogramById("DEVTOOLS_PERFTOOLS_RECORDING_FEATURES_USED") + .add(k, config[k]); + } + } +}; + +PerformanceTelemetry.prototype.onViewSelected = function(viewName) { + if (this._previousView) { + this._telemetry.finishKeyed( + SELECTED_VIEW_HISTOGRAM_NAME, + this._previousView, + this, + false + ); + } + this._previousView = viewName; + this._telemetry.startKeyed(SELECTED_VIEW_HISTOGRAM_NAME, viewName, this); +}; + +exports.PerformanceTelemetry = PerformanceTelemetry; diff --git a/devtools/client/performance/modules/logic/tree-model.js b/devtools/client/performance/modules/logic/tree-model.js new file mode 100644 index 0000000000..518b4838a2 --- /dev/null +++ b/devtools/client/performance/modules/logic/tree-model.js @@ -0,0 +1,589 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + JITOptimizations, +} = require("devtools/client/performance/modules/logic/jit"); +const FrameUtils = require("devtools/client/performance/modules/logic/frame-utils"); + +/** + * A call tree for a thread. This is essentially a linkage between all frames + * of all samples into a single tree structure, with additional information + * on each node, like the time spent (in milliseconds) and samples count. + * + * @param object thread + * The raw thread object received from the backend. Contains samples, + * stackTable, frameTable, and stringTable. + * @param object options + * Additional supported options + * - number startTime + * - number endTime + * - boolean contentOnly [optional] + * - boolean invertTree [optional] + * - boolean flattenRecursion [optional] + */ +function ThreadNode(thread, options = {}) { + if (options.endTime == void 0 || options.startTime == void 0) { + throw new Error("ThreadNode requires both `startTime` and `endTime`."); + } + this.samples = 0; + this.sampleTimes = []; + this.youngestFrameSamples = 0; + this.calls = []; + this.duration = options.endTime - options.startTime; + this.nodeType = "Thread"; + this.inverted = options.invertTree; + + // Total bytesize of all allocations if enabled + this.byteSize = 0; + this.youngestFrameByteSize = 0; + + const { samples, stackTable, frameTable, stringTable } = thread; + + // Nothing to do if there are no samples. + if (samples.data.length === 0) { + return; + } + + this._buildInverted(samples, stackTable, frameTable, stringTable, options); + if (!options.invertTree) { + this._uninvert(); + } +} + +ThreadNode.prototype = { + /** + * Build an inverted call tree from profile samples. The format of the + * samples is described in tools/profiler/ProfileEntry.h, under the heading + * "Thread profile JSON Format". + * + * The profile data is naturally presented inverted. Inverting the call tree + * is also the default in the Performance tool. + * + * @param object samples + * The raw samples array received from the backend. + * @param object stackTable + * The table of deduplicated stacks from the backend. + * @param object frameTable + * The table of deduplicated frames from the backend. + * @param object stringTable + * The table of deduplicated strings from the backend. + * @param object options + * Additional supported options + * - number startTime + * - number endTime + * - boolean contentOnly [optional] + * - boolean invertTree [optional] + */ + _buildInverted: function buildInverted( + samples, + stackTable, + frameTable, + stringTable, + options + ) { + function getOrAddFrameNode( + calls, + isLeaf, + frameKey, + inflatedFrame, + isMetaCategory, + leafTable + ) { + // Insert the inflated frame into the call tree at the current level. + let frameNode; + + // Leaf nodes have fan out much greater than non-leaf nodes, thus the + // use of a hash table. Otherwise, do linear search. + // + // Note that this method is very hot, thus the manual looping over + // Array.prototype.find. + if (isLeaf) { + frameNode = leafTable[frameKey]; + } else { + for (let i = 0; i < calls.length; i++) { + if (calls[i].key === frameKey) { + frameNode = calls[i]; + break; + } + } + } + + if (!frameNode) { + frameNode = new FrameNode(frameKey, inflatedFrame, isMetaCategory); + if (isLeaf) { + leafTable[frameKey] = frameNode; + } + calls.push(frameNode); + } + + return frameNode; + } + + const SAMPLE_STACK_SLOT = samples.schema.stack; + const SAMPLE_TIME_SLOT = samples.schema.time; + const SAMPLE_BYTESIZE_SLOT = samples.schema.size; + + const STACK_PREFIX_SLOT = stackTable.schema.prefix; + const STACK_FRAME_SLOT = stackTable.schema.frame; + + const getOrAddInflatedFrame = FrameUtils.getOrAddInflatedFrame; + + const samplesData = samples.data; + const stacksData = stackTable.data; + + // Caches. + const inflatedFrameCache = FrameUtils.getInflatedFrameCache(frameTable); + const leafTable = Object.create(null); + + const startTime = options.startTime; + const endTime = options.endTime; + const flattenRecursion = options.flattenRecursion; + + // Reused options object passed to InflatedFrame.prototype.getFrameKey. + const mutableFrameKeyOptions = { + contentOnly: options.contentOnly, + isRoot: false, + isLeaf: false, + isMetaCategoryOut: false, + }; + + let byteSize = 0; + for (let i = 0; i < samplesData.length; i++) { + const sample = samplesData[i]; + const sampleTime = sample[SAMPLE_TIME_SLOT]; + + if (SAMPLE_BYTESIZE_SLOT !== void 0) { + byteSize = sample[SAMPLE_BYTESIZE_SLOT]; + } + + // A sample's end time is considered to be its time of sampling. Its + // start time is the sampling time of the previous sample. + // + // Thus, we compare sampleTime <= start instead of < to filter out + // samples that end exactly at the start time. + if (!sampleTime || sampleTime <= startTime || sampleTime > endTime) { + continue; + } + + let stackIndex = sample[SAMPLE_STACK_SLOT]; + let calls = this.calls; + let prevCalls = this.calls; + let prevFrameKey; + let isLeaf = (mutableFrameKeyOptions.isLeaf = true); + const skipRoot = options.invertTree; + + // Inflate the stack and build the FrameNode call tree directly. + // + // In the profiler data, each frame's stack is referenced by an index + // into stackTable. + // + // Each entry in stackTable is a pair [ prefixIndex, frameIndex ]. The + // prefixIndex is itself an index into stackTable, referencing the + // prefix of the current stack (that is, the younger frames). In other + // words, the stackTable is encoded as a trie of the inverted + // callstack. The frameIndex is an index into frameTable, describing the + // frame at the current depth. + // + // This algorithm inflates each frame in the frame table while walking + // the stack trie as described above. + // + // The frame key is then computed from the inflated frame /and/ the + // current depth in the FrameNode call tree. That is, the frame key is + // not wholly determinable from just the inflated frame. + // + // For content frames, the frame key is just its location. For chrome + // frames, the key may be a metacategory or its location, depending on + // rendering options and its position in the FrameNode call tree. + // + // The frame key is then used to build up the inverted FrameNode call + // tree. + // + // Note that various filtering functions, such as filtering for content + // frames or flattening recursion, are inlined into the stack inflation + // loop. This is important for performance as it avoids intermediate + // structures and multiple passes. + while (stackIndex !== null) { + const stackEntry = stacksData[stackIndex]; + const frameIndex = stackEntry[STACK_FRAME_SLOT]; + + // Fetch the stack prefix (i.e. older frames) index. + stackIndex = stackEntry[STACK_PREFIX_SLOT]; + + // Do not include the (root) node in this sample, as the costs of each frame + // will make it clear to differentiate (root)->B vs (root)->A->B + // when a tree is inverted, a revert of bug 1147604 + if (stackIndex === null && skipRoot) { + break; + } + + // Inflate the frame. + const inflatedFrame = getOrAddInflatedFrame( + inflatedFrameCache, + frameIndex, + frameTable, + stringTable + ); + + // Compute the frame key. + mutableFrameKeyOptions.isRoot = stackIndex === null; + const frameKey = inflatedFrame.getFrameKey(mutableFrameKeyOptions); + + // An empty frame key means this frame should be skipped. + if (frameKey === "") { + continue; + } + + // If we shouldn't flatten the current frame into the previous one, advance a + // level in the call tree. + const shouldFlatten = flattenRecursion && frameKey === prevFrameKey; + if (!shouldFlatten) { + calls = prevCalls; + } + + const frameNode = getOrAddFrameNode( + calls, + isLeaf, + frameKey, + inflatedFrame, + mutableFrameKeyOptions.isMetaCategoryOut, + leafTable + ); + if (isLeaf) { + frameNode.youngestFrameSamples++; + frameNode._addOptimizations( + inflatedFrame.optimizations, + inflatedFrame.implementation, + sampleTime, + stringTable + ); + + if (byteSize) { + frameNode.youngestFrameByteSize += byteSize; + } + } + + // Don't overcount flattened recursive frames. + if (!shouldFlatten) { + frameNode.samples++; + if (byteSize) { + frameNode.byteSize += byteSize; + } + } + + prevFrameKey = frameKey; + prevCalls = frameNode.calls; + isLeaf = mutableFrameKeyOptions.isLeaf = false; + } + + this.samples++; + this.sampleTimes.push(sampleTime); + if (byteSize) { + this.byteSize += byteSize; + } + } + }, + + /** + * Uninverts the call tree after its having been built. + */ + _uninvert: function uninvert() { + function mergeOrAddFrameNode(calls, node, samples, size) { + // Unlike the inverted call tree, we don't use a root table for the top + // level, as in general, there are many fewer entry points than + // leaves. Instead, linear search is used regardless of level. + for (let i = 0; i < calls.length; i++) { + if (calls[i].key === node.key) { + const foundNode = calls[i]; + foundNode._merge(node, samples, size); + return foundNode.calls; + } + } + const copy = node._clone(samples, size); + calls.push(copy); + return copy.calls; + } + + const workstack = [{ node: this, level: 0 }]; + const spine = []; + let entry; + + // The new root. + const rootCalls = []; + + // Walk depth-first and keep the current spine (e.g., callstack). + do { + entry = workstack.pop(); + if (entry) { + spine[entry.level] = entry; + + const node = entry.node; + const calls = node.calls; + let callSamples = 0; + let callByteSize = 0; + + // Continue the depth-first walk. + for (let i = 0; i < calls.length; i++) { + workstack.push({ node: calls[i], level: entry.level + 1 }); + callSamples += calls[i].samples; + callByteSize += calls[i].byteSize; + } + + // The sample delta is used to distinguish stacks. + // + // Suppose we have the following stack samples: + // + // A -> B + // A -> C + // A + // + // The inverted tree is: + // + // A + // / \ + // B C + // + // with A.samples = 3, B.samples = 1, C.samples = 1. + // + // A is distinguished as being its own stack because + // A.samples - (B.samples + C.samples) > 0. + // + // Note that bottoming out is a degenerate where callSamples = 0. + + const samplesDelta = node.samples - callSamples; + const byteSizeDelta = node.byteSize - callByteSize; + if (samplesDelta > 0) { + // Reverse the spine and add them to the uninverted call tree. + let uninvertedCalls = rootCalls; + for (let level = entry.level; level > 0; level--) { + const callee = spine[level]; + uninvertedCalls = mergeOrAddFrameNode( + uninvertedCalls, + callee.node, + samplesDelta, + byteSizeDelta + ); + } + } + } + } while (entry); + + // Replace the toplevel calls with rootCalls, which now contains the + // uninverted roots. + this.calls = rootCalls; + }, + + /** + * Gets additional details about this node. + * @see FrameNode.prototype.getInfo for more information. + * + * @return object + */ + getInfo: function(options) { + return FrameUtils.getFrameInfo(this, options); + }, + + /** + * Mimicks the interface of FrameNode, and a ThreadNode can never have + * optimization data (at the moment, anyway), so provide a function + * to return null so we don't need to check if a frame node is a thread + * or not everytime we fetch optimization data. + * + * @return {null} + */ + + hasOptimizations: function() { + return null; + }, +}; + +/** + * A function call node in a tree. Represents a function call with a unique context, + * resulting in each FrameNode having its own row in the corresponding tree view. + * Take samples: + * A()->B()->C() + * A()->B() + * Q()->B() + * + * In inverted tree, A()->B()->C() would have one frame node, and A()->B() and + * Q()->B() would share a frame node. + * In an uninverted tree, A()->B()->C() and A()->B() would share a frame node, + * with Q()->B() having its own. + * + * In all cases, all the frame nodes originated from the same InflatedFrame. + * + * @param string frameKey + * The key associated with this frame. The key determines identity of + * the node. + * @param string location + * The location of this function call. Note that this isn't sanitized, + * so it may very well (not?) include the function name, url, etc. + * @param number line + * The line number inside the source containing this function call. + * @param number category + * The category type of this function call ("js", "graphics" etc.). + * @param number allocations + * The number of memory allocations performed in this frame. + * @param number isContent + * Whether this frame is content. + * @param boolean isMetaCategory + * Whether or not this is a platform node that should appear as a + * generalized meta category or not. + */ +function FrameNode( + frameKey, + { location, line, category, isContent }, + isMetaCategory +) { + this.key = frameKey; + this.location = location; + this.line = line; + this.youngestFrameSamples = 0; + this.samples = 0; + this.calls = []; + this.isContent = !!isContent; + this._optimizations = null; + this._tierData = []; + this._stringTable = null; + this.isMetaCategory = !!isMetaCategory; + this.category = category; + this.nodeType = "Frame"; + this.byteSize = 0; + this.youngestFrameByteSize = 0; +} + +FrameNode.prototype = { + /** + * Take optimization data observed for this frame. + * + * @param object optimizationSite + * Any JIT optimization information attached to the current + * sample. Lazily inflated via stringTable. + * @param number implementation + * JIT implementation used for this observed frame (baseline, ion); + * can be null indicating "interpreter" + * @param number time + * The time this optimization occurred. + * @param object stringTable + * The string table used to inflate the optimizationSite. + */ + _addOptimizations: function(site, implementation, time, stringTable) { + // Simply accumulate optimization sites for now. Processing is done lazily + // by JITOptimizations, if optimization information is actually displayed. + if (site) { + let opts = this._optimizations; + if (opts === null) { + opts = this._optimizations = []; + } + opts.push(site); + } + + if (!this._stringTable) { + this._stringTable = stringTable; + } + + // Record type of implementation used and the sample time + this._tierData.push({ implementation, time }); + }, + + _clone: function(samples, size) { + const newNode = new FrameNode(this.key, this, this.isMetaCategory); + newNode._merge(this, samples, size); + return newNode; + }, + + _merge: function(otherNode, samples, size) { + if (this === otherNode) { + return; + } + + this.samples += samples; + this.byteSize += size; + if (otherNode.youngestFrameSamples > 0) { + this.youngestFrameSamples += samples; + } + + if (otherNode.youngestFrameByteSize > 0) { + this.youngestFrameByteSize += otherNode.youngestFrameByteSize; + } + + if (this._stringTable === null) { + this._stringTable = otherNode._stringTable; + } + + if (otherNode._optimizations) { + if (!this._optimizations) { + this._optimizations = []; + } + const opts = this._optimizations; + const otherOpts = otherNode._optimizations; + for (let i = 0; i < otherOpts.length; i++) { + opts.push(otherOpts[i]); + } + } + + if (otherNode._tierData.length) { + const tierData = this._tierData; + const otherTierData = otherNode._tierData; + for (let i = 0; i < otherTierData.length; i++) { + tierData.push(otherTierData[i]); + } + tierData.sort((a, b) => a.time - b.time); + } + }, + + /** + * Returns the parsed location and additional data describing + * this frame. Uses cached data if possible. Takes the following + * options: + * + * @param {ThreadNode} options.root + * The root thread node to calculate relative costs. + * Generates [self|total] [duration|percentage] values. + * @param {boolean} options.allocations + * Generates `totalAllocations` and `selfAllocations`. + * + * @return object + * The computed { name, file, url, line } properties for this + * function call, as well as additional params if options specified. + */ + getInfo: function(options) { + return FrameUtils.getFrameInfo(this, options); + }, + + /** + * Returns whether or not the frame node has an JITOptimizations model. + * + * @return {Boolean} + */ + hasOptimizations: function() { + return !this.isMetaCategory && !!this._optimizations; + }, + + /** + * Returns the underlying JITOptimizations model representing + * the optimization attempts occuring in this frame. + * + * @return {JITOptimizations|null} + */ + getOptimizations: function() { + if (!this._optimizations) { + return null; + } + return new JITOptimizations(this._optimizations, this._stringTable); + }, + + /** + * Returns the tiers used overtime. + * + * @return {Array<object>} + */ + getTierData: function() { + return this._tierData; + }, +}; + +exports.ThreadNode = ThreadNode; +exports.FrameNode = FrameNode; diff --git a/devtools/client/performance/modules/logic/waterfall-utils.js b/devtools/client/performance/modules/logic/waterfall-utils.js new file mode 100644 index 0000000000..5fc7e768e1 --- /dev/null +++ b/devtools/client/performance/modules/logic/waterfall-utils.js @@ -0,0 +1,171 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * Utility functions for collapsing markers into a waterfall. + */ + +const { + MarkerBlueprintUtils, +} = require("devtools/client/performance/modules/marker-blueprint-utils"); + +/** + * Creates a parent marker, which functions like a regular marker, + * but is able to hold additional child markers. + * + * The marker is seeded with values from `marker`. + * @param object marker + * @return object + */ +function createParentNode(marker) { + return Object.assign({}, marker, { submarkers: [] }); +} + +/** + * Collapses markers into a tree-like structure. + * @param object rootNode + * @param array markersList + * @param array filter + */ +function collapseMarkersIntoNode({ rootNode, markersList, filter }) { + const { + getCurrentParentNode, + pushNode, + popParentNode, + } = createParentNodeFactory(rootNode); + + for (let i = 0, len = markersList.length; i < len; i++) { + const curr = markersList[i]; + + // If this marker type should not be displayed, just skip + if (!MarkerBlueprintUtils.shouldDisplayMarker(curr, filter)) { + continue; + } + + let parentNode = getCurrentParentNode(); + const blueprint = MarkerBlueprintUtils.getBlueprintFor(curr); + + const nestable = "nestable" in blueprint ? blueprint.nestable : true; + const collapsible = + "collapsible" in blueprint ? blueprint.collapsible : true; + + let finalized = false; + + // Extend the marker with extra properties needed in the marker tree + const extendedProps = { index: i }; + if (collapsible) { + extendedProps.submarkers = []; + } + Object.assign(curr, extendedProps); + + // If not nestible, just push it inside the root node. Additionally, + // markers originating outside the main thread are considered to be + // "never collapsible", to avoid confusion. + // A beter solution would be to collapse every marker with its siblings + // from the same thread, but that would require a thread id attached + // to all markers, which is potentially expensive and rather useless at + // the moment, since we don't really have that many OTMT markers. + if (!nestable || curr.isOffMainThread) { + pushNode(rootNode, curr); + continue; + } + + // First off, if any parent nodes exist, finish them off + // recursively upwards if this marker is outside their ranges and nestable. + while (!finalized && parentNode) { + // If this marker is eclipsed by the current parent marker, + // make it a child of the current parent and stop going upwards. + // If the markers aren't from the same process, attach them to the root + // node as well. Every process has its own main thread. + if ( + nestable && + curr.start >= parentNode.start && + curr.end <= parentNode.end && + curr.processType == parentNode.processType + ) { + pushNode(parentNode, curr); + finalized = true; + break; + } + + // If this marker is still nestable, but outside of the range + // of the current parent, iterate upwards on the next parent + // and finalize the current parent. + if (nestable) { + popParentNode(); + parentNode = getCurrentParentNode(); + continue; + } + } + + if (!finalized) { + pushNode(rootNode, curr); + } + } +} + +/** + * Takes a root marker node and creates a hash of functions used + * to manage the creation and nesting of additional parent markers. + * + * @param {object} root + * @return {object} + */ +function createParentNodeFactory(root) { + const parentMarkers = []; + const factory = { + /** + * Pops the most recent parent node off the stack, finalizing it. + * Sets the `end` time based on the most recent child if not defined. + */ + popParentNode: () => { + if (parentMarkers.length === 0) { + throw new Error("Cannot pop parent markers when none exist."); + } + + const lastParent = parentMarkers.pop(); + + // If this finished parent marker doesn't have an end time, + // so probably a synthesized marker, use the last marker's end time. + if (lastParent.end == void 0) { + lastParent.end = + lastParent.submarkers[lastParent.submarkers.length - 1].end; + } + + // If no children were ever pushed into this parent node, + // remove its submarkers so it behaves like a non collapsible + // node. + if (!lastParent.submarkers.length) { + delete lastParent.submarkers; + } + + return lastParent; + }, + + /** + * Returns the most recent parent node. + */ + getCurrentParentNode: () => + parentMarkers.length ? parentMarkers[parentMarkers.length - 1] : null, + + /** + * Push this marker into the most recent parent node. + */ + pushNode: (parent, marker) => { + parent.submarkers.push(marker); + + // If pushing a parent marker, track it as the top of + // the parent stack. + if (marker.submarkers) { + parentMarkers.push(marker); + } + }, + }; + + return factory; +} + +exports.createParentNode = createParentNode; +exports.collapseMarkersIntoNode = collapseMarkersIntoNode; diff --git a/devtools/client/performance/modules/marker-blueprint-utils.js b/devtools/client/performance/modules/marker-blueprint-utils.js new file mode 100644 index 0000000000..f249c0cb7b --- /dev/null +++ b/devtools/client/performance/modules/marker-blueprint-utils.js @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + TIMELINE_BLUEPRINT, +} = require("devtools/client/performance/modules/markers"); + +/** + * This file contains utilities for parsing out the markers blueprint + * to generate strings to be displayed in the UI. + */ + +exports.MarkerBlueprintUtils = { + /** + * Takes a marker and a list of marker names that should be hidden, and + * determines if this marker should be filtered or not. + * + * @param object marker + * @return boolean + */ + shouldDisplayMarker: function(marker, hiddenMarkerNames) { + if (!hiddenMarkerNames || hiddenMarkerNames.length == 0) { + return true; + } + + // If this marker isn't yet defined in the blueprint, simply check if the + // entire category of "UNKNOWN" markers are supposed to be visible or not. + const isUnknown = !(marker.name in TIMELINE_BLUEPRINT); + if (isUnknown) { + return !hiddenMarkerNames.includes("UNKNOWN"); + } + + return !hiddenMarkerNames.includes(marker.name); + }, + + /** + * Takes a marker and returns the blueprint definition for that marker type, + * falling back to the UNKNOWN blueprint definition if undefined. + * + * @param object marker + * @return object + */ + getBlueprintFor: function(marker) { + return TIMELINE_BLUEPRINT[marker.name] || TIMELINE_BLUEPRINT.UNKNOWN; + }, + + /** + * Returns the label to display for a marker, based off the blueprints. + * + * @param object marker + * @return string + */ + getMarkerLabel: function(marker) { + const blueprint = this.getBlueprintFor(marker); + const dynamic = typeof blueprint.label === "function"; + const label = dynamic ? blueprint.label(marker) : blueprint.label; + return label; + }, + + /** + * Returns the generic label to display for a marker name. + * (e.g. "Function Call" for JS markers, rather than "setTimeout", etc.) + * + * @param string type + * @return string + */ + getMarkerGenericName: function(markerName) { + const blueprint = this.getBlueprintFor({ name: markerName }); + const dynamic = typeof blueprint.label === "function"; + const generic = dynamic ? blueprint.label() : blueprint.label; + + // If no class name found, attempt to throw a descriptive error as to + // how the marker implementor can fix this. + if (!generic) { + let message = `Could not find marker generic name for "${markerName}".`; + if (typeof blueprint.label === "function") { + message += + ` The following function must return a generic name string when no` + + ` marker passed: ${blueprint.label}`; + } else { + message += ` ${markerName}.label must be defined in the marker blueprint.`; + } + throw new Error(message); + } + + return generic; + }, + + /** + * Returns an array of objects with key/value pairs of what should be rendered + * in the marker details view. + * + * @param object marker + * @return array<object> + */ + getMarkerFields: function(marker) { + const blueprint = this.getBlueprintFor(marker); + const dynamic = typeof blueprint.fields === "function"; + const fields = dynamic ? blueprint.fields(marker) : blueprint.fields; + + return Object.entries(fields || {}) + .filter(([_, value]) => (dynamic ? true : value in marker)) + .map(([label, value]) => ({ + label, + value: dynamic ? value : marker[value], + })); + }, +}; diff --git a/devtools/client/performance/modules/marker-dom-utils.js b/devtools/client/performance/modules/marker-dom-utils.js new file mode 100644 index 0000000000..556f940da9 --- /dev/null +++ b/devtools/client/performance/modules/marker-dom-utils.js @@ -0,0 +1,275 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This file contains utilities for creating DOM nodes for markers + * to be displayed in the UI. + */ + +const { L10N, PREFS } = require("devtools/client/performance/modules/global"); +const { + MarkerBlueprintUtils, +} = require("devtools/client/performance/modules/marker-blueprint-utils"); +const { getSourceNames } = require("devtools/client/shared/source-utils"); + +/** + * Utilites for creating elements for markers. + */ +exports.MarkerDOMUtils = { + /** + * Builds all the fields possible for the given marker. Returns an + * array of elements to be appended to a parent element. + * + * @param document doc + * @param object marker + * @return array<Node> + */ + buildFields: function(doc, marker) { + const fields = MarkerBlueprintUtils.getMarkerFields(marker); + return fields.map(({ label, value }) => + this.buildNameValueLabel(doc, label, value) + ); + }, + + /** + * Builds the label representing the marker's type. + * + * @param document doc + * @param object marker + * @return Node + */ + buildTitle: function(doc, marker) { + const blueprint = MarkerBlueprintUtils.getBlueprintFor(marker); + + const hbox = doc.createXULElement("hbox"); + hbox.setAttribute("align", "center"); + + const bullet = doc.createXULElement("hbox"); + bullet.className = `marker-details-bullet marker-color-${blueprint.colorName}`; + + const title = MarkerBlueprintUtils.getMarkerLabel(marker); + const label = doc.createXULElement("label"); + label.className = "marker-details-type"; + label.setAttribute("value", title); + + hbox.appendChild(bullet); + hbox.appendChild(label); + + return hbox; + }, + + /** + * Builds the label representing the marker's duration. + * + * @param document doc + * @param object marker + * @return Node + */ + buildDuration: function(doc, marker) { + const label = L10N.getStr("marker.field.duration"); + const start = L10N.getFormatStrWithNumbers("timeline.tick", marker.start); + const end = L10N.getFormatStrWithNumbers("timeline.tick", marker.end); + const duration = L10N.getFormatStrWithNumbers( + "timeline.tick", + marker.end - marker.start + ); + + const el = this.buildNameValueLabel(doc, label, duration); + el.classList.add("marker-details-duration"); + el.setAttribute("tooltiptext", `${start} → ${end}`); + + return el; + }, + + /** + * Builds labels for name:value pairs. + * E.g. "Start: 100ms", "Duration: 200ms", ... + * + * @param document doc + * @param string field + * @param string value + * @return Node + */ + buildNameValueLabel: function(doc, field, value) { + const hbox = doc.createXULElement("hbox"); + hbox.className = "marker-details-labelcontainer"; + + const nameLabel = doc.createXULElement("label"); + nameLabel.className = "plain marker-details-name-label"; + nameLabel.setAttribute("value", field); + hbox.appendChild(nameLabel); + + const valueLabel = doc.createXULElement("label"); + valueLabel.className = "plain marker-details-value-label"; + valueLabel.setAttribute("value", value); + hbox.appendChild(valueLabel); + + return hbox; + }, + + /** + * Builds a stack trace in an element. + * + * @param document doc + * @param object params + * An options object with the following members: + * - string type: string identifier for type of stack ("stack", "startStack" + or "endStack" + * - number frameIndex: the index of the topmost stack frame + * - array frames: array of stack frames + */ + buildStackTrace: function(doc, { type, frameIndex, frames }) { + const container = doc.createXULElement("vbox"); + container.className = "marker-details-stack"; + container.setAttribute("type", type); + + const nameLabel = doc.createXULElement("label"); + nameLabel.className = "plain marker-details-name-label"; + nameLabel.setAttribute("value", L10N.getStr(`marker.field.${type}`)); + container.appendChild(nameLabel); + + // Workaround for profiles that have looping stack traces. See + // bug 1246555. + let wasAsyncParent = false; + const seen = new Set(); + + while (frameIndex > 0) { + if (seen.has(frameIndex)) { + break; + } + seen.add(frameIndex); + + const frame = frames[frameIndex]; + const url = frame.source; + const displayName = frame.functionDisplayName; + const line = frame.line; + + // If the previous frame had an async parent, then the async + // cause is in this frame and should be displayed. + if (wasAsyncParent) { + const asyncStr = L10N.getFormatStr( + "marker.field.asyncStack", + frame.asyncCause + ); + const asyncBox = doc.createXULElement("hbox"); + const asyncLabel = doc.createXULElement("label"); + asyncLabel.className = "devtools-monospace"; + asyncLabel.setAttribute("value", asyncStr); + asyncBox.appendChild(asyncLabel); + container.appendChild(asyncBox); + wasAsyncParent = false; + } + + const hbox = doc.createXULElement("hbox"); + + if (displayName) { + const functionLabel = doc.createXULElement("label"); + functionLabel.className = "devtools-monospace"; + functionLabel.setAttribute("value", displayName); + hbox.appendChild(functionLabel); + } + + if (url) { + const linkNode = doc.createXULElement("a"); + linkNode.className = "waterfall-marker-location devtools-source-link"; + linkNode.href = url; + linkNode.draggable = false; + linkNode.setAttribute("title", url); + + const urlLabel = doc.createXULElement("label"); + urlLabel.className = "filename"; + urlLabel.setAttribute("value", getSourceNames(url).short); + linkNode.appendChild(urlLabel); + + const lineLabel = doc.createXULElement("label"); + lineLabel.className = "line-number"; + lineLabel.setAttribute("value", `:${line}`); + linkNode.appendChild(lineLabel); + + hbox.appendChild(linkNode); + + // Clicking here will bubble up to the parent, + // which handles the view source. + linkNode.setAttribute( + "data-action", + JSON.stringify({ + url: url, + line: line, + action: "view-source", + }) + ); + } + + if (!displayName && !url) { + const unknownLabel = doc.createXULElement("label"); + unknownLabel.setAttribute( + "value", + L10N.getStr("marker.value.unknownFrame") + ); + hbox.appendChild(unknownLabel); + } + + container.appendChild(hbox); + + if (frame.asyncParent) { + frameIndex = frame.asyncParent; + wasAsyncParent = true; + } else { + frameIndex = frame.parent; + } + } + + return container; + }, + + /** + * Builds any custom fields specific to the marker. + * + * @param document doc + * @param object marker + * @param object options + * @return array<Node> + */ + buildCustom: function(doc, marker, options) { + const elements = []; + + if (options.allocations && shouldShowAllocationsTrigger(marker)) { + const hbox = doc.createXULElement("hbox"); + hbox.className = "marker-details-customcontainer"; + + const label = doc.createXULElement("label"); + label.className = "custom-button"; + label.setAttribute("value", "Show allocation triggers"); + label.setAttribute("type", "show-allocations"); + label.setAttribute( + "data-action", + JSON.stringify({ + endTime: marker.start, + action: "show-allocations", + }) + ); + + hbox.appendChild(label); + elements.push(hbox); + } + + return elements; + }, +}; + +/** + * Takes a marker and determines if this marker should display + * the allocations trigger button. + * + * @param object marker + * @return boolean + */ +function shouldShowAllocationsTrigger(marker) { + if (marker.name == "GarbageCollection") { + const showTriggers = PREFS["show-triggers-for-gc-types"]; + return showTriggers.split(" ").includes(marker.causeName); + } + return false; +} diff --git a/devtools/client/performance/modules/marker-formatters.js b/devtools/client/performance/modules/marker-formatters.js new file mode 100644 index 0000000000..b4b9472259 --- /dev/null +++ b/devtools/client/performance/modules/marker-formatters.js @@ -0,0 +1,204 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This file contains utilities for creating elements for markers to be displayed, + * and parsing out the blueprint to generate correct values for markers. + */ +const { L10N, PREFS } = require("devtools/client/performance/modules/global"); + +// String used to fill in platform data when it should be hidden. +const GECKO_SYMBOL = "(Gecko)"; + +/** + * Mapping of JS marker causes to a friendlier form. Only + * markers that are considered "from content" should be labeled here. + */ +const JS_MARKER_MAP = { + "<script> element": L10N.getStr("marker.label.javascript.scriptElement"), + "promise callback": L10N.getStr("marker.label.javascript.promiseCallback"), + "promise initializer": L10N.getStr("marker.label.javascript.promiseInit"), + "Worker runnable": L10N.getStr("marker.label.javascript.workerRunnable"), + "javascript: URI": L10N.getStr("marker.label.javascript.jsURI"), + // The difference between these two event handler markers are differences + // in their WebIDL implementation, so distinguishing them is not necessary. + EventHandlerNonNull: L10N.getStr("marker.label.javascript.eventHandler"), + "EventListener.handleEvent": L10N.getStr( + "marker.label.javascript.eventHandler" + ), + // These markers do not get L10N'd because they're JS names. + "setInterval handler": "setInterval", + "setTimeout handler": "setTimeout", + FrameRequestCallback: "requestAnimationFrame", +}; + +/** + * A series of formatters used by the blueprint. + */ +exports.Formatters = { + /** + * Uses the marker name as the label for markers that do not have + * a blueprint entry. Uses "Other" in the marker filter menu. + */ + UnknownLabel: function(marker = {}) { + return marker.name || L10N.getStr("marker.label.unknown"); + }, + + /* Group 0 - Reflow and Rendering pipeline */ + + StylesFields: function(marker) { + if ("isAnimationOnly" in marker) { + return { + [L10N.getStr("marker.field.isAnimationOnly")]: marker.isAnimationOnly, + }; + } + return null; + }, + + /* Group 1 - JS */ + + DOMEventFields: function(marker) { + const fields = Object.create(null); + + if ("type" in marker) { + fields[L10N.getStr("marker.field.DOMEventType")] = marker.type; + } + + if ("eventPhase" in marker) { + let label; + switch (marker.eventPhase) { + case Event.AT_TARGET: + label = L10N.getStr("marker.value.DOMEventTargetPhase"); + break; + case Event.CAPTURING_PHASE: + label = L10N.getStr("marker.value.DOMEventCapturingPhase"); + break; + case Event.BUBBLING_PHASE: + label = L10N.getStr("marker.value.DOMEventBubblingPhase"); + break; + } + fields[L10N.getStr("marker.field.DOMEventPhase")] = label; + } + + return fields; + }, + + JSLabel: function(marker = {}) { + const generic = L10N.getStr("marker.label.javascript"); + if ("causeName" in marker) { + return JS_MARKER_MAP[marker.causeName] || generic; + } + return generic; + }, + + JSFields: function(marker) { + if ("causeName" in marker && !JS_MARKER_MAP[marker.causeName]) { + const label = PREFS["show-platform-data"] + ? marker.causeName + : GECKO_SYMBOL; + return { + [L10N.getStr("marker.field.causeName")]: label, + }; + } + return null; + }, + + GCLabel: function(marker) { + if (!marker) { + return L10N.getStr("marker.label.garbageCollection2"); + } + // Only if a `nonincrementalReason` exists, do we want to label + // this as a non incremental GC event. + if ("nonincrementalReason" in marker) { + return L10N.getStr("marker.label.garbageCollection.nonIncremental"); + } + return L10N.getStr("marker.label.garbageCollection.incremental"); + }, + + GCFields: function(marker) { + const fields = Object.create(null); + + if ("causeName" in marker) { + const cause = marker.causeName; + const label = L10N.getStr(`marker.gcreason.label.${cause}`) || cause; + fields[L10N.getStr("marker.field.causeName")] = label; + } + + if ("nonincrementalReason" in marker) { + const label = marker.nonincrementalReason; + fields[L10N.getStr("marker.field.nonIncrementalCause")] = label; + } + + return fields; + }, + + MinorGCFields: function(marker) { + const fields = Object.create(null); + + if ("causeName" in marker) { + const cause = marker.causeName; + const label = L10N.getStr(`marker.gcreason.label.${cause}`) || cause; + fields[L10N.getStr("marker.field.causeName")] = label; + } + + fields[L10N.getStr("marker.field.type")] = L10N.getStr( + "marker.nurseryCollection" + ); + + return fields; + }, + + CycleCollectionFields: function(marker) { + const label = marker.name.replace(/nsCycleCollector::/g, ""); + return { + [L10N.getStr("marker.field.type")]: label, + }; + }, + + WorkerFields: function(marker) { + if ("workerOperation" in marker) { + const label = L10N.getStr(`marker.worker.${marker.workerOperation}`); + return { + [L10N.getStr("marker.field.type")]: label, + }; + } + return null; + }, + + MessagePortFields: function(marker) { + if ("messagePortOperation" in marker) { + const label = L10N.getStr( + `marker.messagePort.${marker.messagePortOperation}` + ); + return { + [L10N.getStr("marker.field.type")]: label, + }; + } + return null; + }, + + /* Group 2 - User Controlled */ + + ConsoleTimeFields: { + [L10N.getStr("marker.field.consoleTimerName")]: "causeName", + }, + + TimeStampFields: { + [L10N.getStr("marker.field.label")]: "causeName", + }, +}; + +/** + * Takes a main label (e.g. "Timestamp") and a property name (e.g. "causeName"), + * and returns a string that represents that property value for a marker if it + * exists (e.g. "Timestamp (rendering)"), or just the main label if it does not. + * + * @param string mainLabel + * @param string propName + */ +exports.Formatters.labelForProperty = function(mainLabel, propName) { + return (marker = {}) => + marker[propName] ? `${mainLabel} (${marker[propName]})` : mainLabel; +}; diff --git a/devtools/client/performance/modules/markers.js b/devtools/client/performance/modules/markers.js new file mode 100644 index 0000000000..dbcd661205 --- /dev/null +++ b/devtools/client/performance/modules/markers.js @@ -0,0 +1,180 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { L10N } = require("devtools/client/performance/modules/global"); +const { + Formatters, +} = require("devtools/client/performance/modules/marker-formatters"); + +/** + * A simple schema for mapping markers to the timeline UI. The keys correspond + * to marker names, while the values are objects with the following format: + * + * - group: The row index in the overview graph; multiple markers + * can be added on the same row. @see <overview.js/buildGraphImage> + * - label: The label used in the waterfall to identify the marker. Can be a + * string or just a function that accepts the marker and returns a + * string (if you want to use a dynamic property for the main label). + * If you use a function for a label, it *must* handle the case where + * no marker is provided, to get a generic label used to describe + * all markers of this type. + * - fields: The fields used in the marker details view to display more + * information about a currently selected marker. Can either be an + * object of fields, or simply a function that accepts the marker and + * returns such an object (if you want to use properties dynamically). + * For example, a field in the object such as { "Cause": "causeName" } + * would render something like `Cause: ${marker.causeName}` in the UI. + * - colorName: The label of the DevTools color used for this marker. If + * adding a new color, be sure to check that there's an entry + * for `.marker-color-graphs-{COLORNAME}` for the equivilent + * entry in "./devtools/client/themes/performance.css" + * - collapsible: Whether or not this marker can contain other markers it + * eclipses, and becomes collapsible to reveal its nestable + * children. Defaults to true. + * - nestable: Whether or not this marker can be nested inside an eclipsing + * collapsible marker. Defaults to true. + */ +const TIMELINE_BLUEPRINT = { + /* Default definition used for markers that occur but are not defined here. + * Should ultimately be defined, but this gives us room to work on the + * front end separately from the platform. */ + UNKNOWN: { + group: 2, + colorName: "graphs-grey", + label: Formatters.UnknownLabel, + }, + + /* Group 0 - Reflow and Rendering pipeline */ + + Styles: { + group: 0, + colorName: "graphs-purple", + label: L10N.getStr("marker.label.styles"), + fields: Formatters.StylesFields, + }, + StylesApplyChanges: { + group: 0, + colorName: "graphs-purple", + label: L10N.getStr("marker.label.stylesApplyChanges"), + }, + Reflow: { + group: 0, + colorName: "graphs-purple", + label: L10N.getStr("marker.label.reflow"), + }, + Paint: { + group: 0, + colorName: "graphs-green", + label: L10N.getStr("marker.label.paint"), + }, + Composite: { + group: 0, + colorName: "graphs-green", + label: L10N.getStr("marker.label.composite"), + }, + CompositeForwardTransaction: { + group: 0, + colorName: "graphs-bluegrey", + label: L10N.getStr("marker.label.compositeForwardTransaction"), + }, + + /* Group 1 - JS */ + + DOMEvent: { + group: 1, + colorName: "graphs-yellow", + label: L10N.getStr("marker.label.domevent"), + fields: Formatters.DOMEventFields, + }, + "document::DOMContentLoaded": { + group: 1, + colorName: "graphs-full-red", + label: "DOMContentLoaded", + }, + "document::Load": { + group: 1, + colorName: "graphs-full-blue", + label: "Load", + }, + Javascript: { + group: 1, + colorName: "graphs-yellow", + label: Formatters.JSLabel, + fields: Formatters.JSFields, + }, + "Parse HTML": { + group: 1, + colorName: "graphs-yellow", + label: L10N.getStr("marker.label.parseHTML"), + }, + "Parse XML": { + group: 1, + colorName: "graphs-yellow", + label: L10N.getStr("marker.label.parseXML"), + }, + GarbageCollection: { + group: 1, + colorName: "graphs-red", + label: Formatters.GCLabel, + fields: Formatters.GCFields, + }, + MinorGC: { + group: 1, + colorName: "graphs-red", + label: L10N.getStr("marker.label.minorGC"), + fields: Formatters.MinorGCFields, + }, + "nsCycleCollector::Collect": { + group: 1, + colorName: "graphs-red", + label: L10N.getStr("marker.label.cycleCollection"), + fields: Formatters.CycleCollectionFields, + }, + "nsCycleCollector::ForgetSkippable": { + group: 1, + colorName: "graphs-red", + label: L10N.getStr("marker.label.cycleCollection.forgetSkippable"), + fields: Formatters.CycleCollectionFields, + }, + Worker: { + group: 1, + colorName: "graphs-orange", + label: L10N.getStr("marker.label.worker"), + fields: Formatters.WorkerFields, + }, + MessagePort: { + group: 1, + colorName: "graphs-orange", + label: L10N.getStr("marker.label.messagePort"), + fields: Formatters.MessagePortFields, + }, + + /* Group 2 - User Controlled */ + + ConsoleTime: { + group: 2, + colorName: "graphs-blue", + label: Formatters.labelForProperty( + L10N.getStr("marker.label.consoleTime"), + "causeName" + ), + fields: Formatters.ConsoleTimeFields, + nestable: false, + collapsible: false, + }, + TimeStamp: { + group: 2, + colorName: "graphs-blue", + label: Formatters.labelForProperty( + L10N.getStr("marker.label.timestamp"), + "causeName" + ), + fields: Formatters.TimeStampFields, + collapsible: false, + }, +}; + +// Exported symbols. +exports.TIMELINE_BLUEPRINT = TIMELINE_BLUEPRINT; diff --git a/devtools/client/performance/modules/moz.build b/devtools/client/performance/modules/moz.build new file mode 100644 index 0000000000..c054669022 --- /dev/null +++ b/devtools/client/performance/modules/moz.build @@ -0,0 +1,22 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "logic", + "widgets", +] + +DevToolsModules( + "categories.js", + "constants.js", + "global.js", + "io.js", + "marker-blueprint-utils.js", + "marker-dom-utils.js", + "marker-formatters.js", + "markers.js", + "utils.js", + "waterfall-ticks.js", +) diff --git a/devtools/client/performance/modules/utils.js b/devtools/client/performance/modules/utils.js new file mode 100644 index 0000000000..ebd0ba2ccb --- /dev/null +++ b/devtools/client/performance/modules/utils.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/* globals document */ + +/** + * React components grab the namespace of the element they are mounting to. This function + * takes a XUL element, and makes sure to create a properly namespaced HTML element to + * avoid React creating XUL elements. + * + * {XULElement} xulElement + * return {HTMLElement} div + */ + +exports.createHtmlMount = function(xulElement) { + const htmlElement = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + xulElement.appendChild(htmlElement); + return htmlElement; +}; diff --git a/devtools/client/performance/modules/waterfall-ticks.js b/devtools/client/performance/modules/waterfall-ticks.js new file mode 100644 index 0000000000..efc88001f3 --- /dev/null +++ b/devtools/client/performance/modules/waterfall-ticks.js @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +const WATERFALL_BACKGROUND_TICKS_MULTIPLE = 5; // ms +const WATERFALL_BACKGROUND_TICKS_SCALES = 3; +const WATERFALL_BACKGROUND_TICKS_SPACING_MIN = 10; // px +const WATERFALL_BACKGROUND_TICKS_COLOR_RGB = [128, 136, 144]; +const WATERFALL_BACKGROUND_TICKS_OPACITY_MIN = 32; // byte +const WATERFALL_BACKGROUND_TICKS_OPACITY_ADD = 32; // byte + +const FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS = 100; + +/** + * Creates the background displayed on the marker's waterfall. + */ +function drawWaterfallBackground(doc, dataScale, waterfallWidth) { + const canvas = doc.createElementNS(HTML_NS, "canvas"); + const ctx = canvas.getContext("2d"); + + // Nuke the context. + const canvasWidth = (canvas.width = Math.max(waterfallWidth, 1)); + // Awww yeah, 1px, repeats on Y axis. + const canvasHeight = (canvas.height = 1); + + // Start over. + const imageData = ctx.createImageData(canvasWidth, canvasHeight); + const pixelArray = imageData.data; + + const buf = new ArrayBuffer(pixelArray.length); + const view8bit = new Uint8ClampedArray(buf); + const view32bit = new Uint32Array(buf); + + // Build new millisecond tick lines... + const [r, g, b] = WATERFALL_BACKGROUND_TICKS_COLOR_RGB; + let alphaComponent = WATERFALL_BACKGROUND_TICKS_OPACITY_MIN; + const tickInterval = findOptimalTickInterval({ + ticksMultiple: WATERFALL_BACKGROUND_TICKS_MULTIPLE, + ticksSpacingMin: WATERFALL_BACKGROUND_TICKS_SPACING_MIN, + dataScale: dataScale, + }); + + // Insert one pixel for each division on each scale. + for (let i = 1; i <= WATERFALL_BACKGROUND_TICKS_SCALES; i++) { + const increment = tickInterval * Math.pow(2, i); + for (let x = 0; x < canvasWidth; x += increment) { + const position = x | 0; + view32bit[position] = (alphaComponent << 24) | (b << 16) | (g << 8) | r; + } + alphaComponent += WATERFALL_BACKGROUND_TICKS_OPACITY_ADD; + } + + // Flush the image data and cache the waterfall background. + pixelArray.set(view8bit); + ctx.putImageData(imageData, 0, 0); + doc.mozSetImageElement("waterfall-background", canvas); + + return canvas; +} + +/** + * Finds the optimal tick interval between time markers in this timeline. + * + * @param number ticksMultiple + * @param number ticksSpacingMin + * @param number dataScale + * @return number + */ +function findOptimalTickInterval({ + ticksMultiple, + ticksSpacingMin, + dataScale, +}) { + let timingStep = ticksMultiple; + const maxIters = FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS; + let numIters = 0; + + if (dataScale > ticksSpacingMin) { + return dataScale; + } + + while (true) { + const scaledStep = dataScale * timingStep; + if (++numIters > maxIters) { + return scaledStep; + } + if (scaledStep < ticksSpacingMin) { + timingStep <<= 1; + continue; + } + return scaledStep; + } +} + +exports.TickUtils = { findOptimalTickInterval, drawWaterfallBackground }; diff --git a/devtools/client/performance/modules/widgets/graphs.js b/devtools/client/performance/modules/widgets/graphs.js new file mode 100644 index 0000000000..926fb43cf6 --- /dev/null +++ b/devtools/client/performance/modules/widgets/graphs.js @@ -0,0 +1,527 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This file contains the base line graph that all Performance line graphs use. + */ + +const { extend } = require("devtools/shared/extend"); +const LineGraphWidget = require("devtools/client/shared/widgets/LineGraphWidget"); +const MountainGraphWidget = require("devtools/client/shared/widgets/MountainGraphWidget"); +const { CanvasGraphUtils } = require("devtools/client/shared/widgets/Graphs"); + +const EventEmitter = require("devtools/shared/event-emitter"); + +const { colorUtils } = require("devtools/shared/css/color"); +const { getColor } = require("devtools/client/shared/theme"); +const ProfilerGlobal = require("devtools/client/performance/modules/global"); +const { + MarkersOverview, +} = require("devtools/client/performance/modules/widgets/markers-overview"); +const { + createTierGraphDataFromFrameNode, +} = require("devtools/client/performance/modules/logic/jit"); + +/** + * For line graphs + */ +const HEIGHT = 35; // px +const STROKE_WIDTH = 1; // px +const DAMPEN_VALUES = 0.95; +const CLIPHEAD_LINE_COLOR = "#666"; +const SELECTION_LINE_COLOR = "#555"; +const SELECTION_BACKGROUND_COLOR_NAME = "graphs-blue"; +const FRAMERATE_GRAPH_COLOR_NAME = "graphs-green"; +const MEMORY_GRAPH_COLOR_NAME = "graphs-blue"; + +/** + * For timeline overview + */ +const MARKERS_GRAPH_HEADER_HEIGHT = 14; // px +const MARKERS_GRAPH_ROW_HEIGHT = 10; // px +const MARKERS_GROUP_VERTICAL_PADDING = 4; // px + +/** + * For optimization graph + */ +const OPTIMIZATIONS_GRAPH_RESOLUTION = 100; + +/** + * A base class for performance graphs to inherit from. + * + * @param Node parent + * The parent node holding the overview. + * @param string metric + * The unit of measurement for this graph. + */ +function PerformanceGraph(parent, metric) { + LineGraphWidget.call(this, parent, { metric }); + this.setTheme(); +} + +PerformanceGraph.prototype = extend(LineGraphWidget.prototype, { + strokeWidth: STROKE_WIDTH, + dampenValuesFactor: DAMPEN_VALUES, + fixedHeight: HEIGHT, + clipheadLineColor: CLIPHEAD_LINE_COLOR, + selectionLineColor: SELECTION_LINE_COLOR, + withTooltipArrows: false, + withFixedTooltipPositions: true, + + /** + * Disables selection and empties this graph. + */ + clearView: function() { + this.selectionEnabled = false; + this.dropSelection(); + this.setData([]); + }, + + /** + * Sets the theme via `theme` to either "light" or "dark", + * and updates the internal styling to match. Requires a redraw + * to see the effects. + */ + setTheme: function(theme) { + theme = theme || "light"; + const mainColor = getColor(this.mainColor || "graphs-blue", theme); + this.backgroundColor = getColor("body-background", theme); + this.strokeColor = mainColor; + this.backgroundGradientStart = colorUtils.setAlpha(mainColor, 0.2); + this.backgroundGradientEnd = colorUtils.setAlpha(mainColor, 0.2); + this.selectionBackgroundColor = colorUtils.setAlpha( + getColor(SELECTION_BACKGROUND_COLOR_NAME, theme), + 0.25 + ); + this.selectionStripesColor = "rgba(255, 255, 255, 0.1)"; + this.maximumLineColor = colorUtils.setAlpha(mainColor, 0.4); + this.averageLineColor = colorUtils.setAlpha(mainColor, 0.7); + this.minimumLineColor = colorUtils.setAlpha(mainColor, 0.9); + }, +}); + +/** + * Constructor for the framerate graph. Inherits from PerformanceGraph. + * + * @param Node parent + * The parent node holding the overview. + */ +function FramerateGraph(parent) { + PerformanceGraph.call(this, parent, ProfilerGlobal.L10N.getStr("graphs.fps")); +} + +FramerateGraph.prototype = extend(PerformanceGraph.prototype, { + mainColor: FRAMERATE_GRAPH_COLOR_NAME, + setPerformanceData: function({ duration, ticks }, resolution) { + this.dataDuration = duration; + return this.setDataFromTimestamps(ticks, resolution, duration); + }, +}); + +/** + * Constructor for the memory graph. Inherits from PerformanceGraph. + * + * @param Node parent + * The parent node holding the overview. + */ +function MemoryGraph(parent) { + PerformanceGraph.call( + this, + parent, + ProfilerGlobal.L10N.getStr("graphs.memory") + ); +} + +MemoryGraph.prototype = extend(PerformanceGraph.prototype, { + mainColor: MEMORY_GRAPH_COLOR_NAME, + setPerformanceData: function({ duration, memory }) { + this.dataDuration = duration; + return this.setData(memory); + }, +}); + +function TimelineGraph(parent, filter) { + MarkersOverview.call(this, parent, filter); +} + +TimelineGraph.prototype = extend(MarkersOverview.prototype, { + headerHeight: MARKERS_GRAPH_HEADER_HEIGHT, + rowHeight: MARKERS_GRAPH_ROW_HEIGHT, + groupPadding: MARKERS_GROUP_VERTICAL_PADDING, + setPerformanceData: MarkersOverview.prototype.setData, +}); + +/** + * Definitions file for GraphsController, indicating the constructor, + * selector and other meta for each of the graphs controller by + * GraphsController. + */ +const GRAPH_DEFINITIONS = { + memory: { + constructor: MemoryGraph, + selector: "#memory-overview", + }, + framerate: { + constructor: FramerateGraph, + selector: "#time-framerate", + }, + timeline: { + constructor: TimelineGraph, + selector: "#markers-overview", + primaryLink: true, + }, +}; + +/** + * A controller for orchestrating the performance's tool overview graphs. Constructs, + * syncs, toggles displays and defines the memory, framerate and timeline view. + * + * @param {object} definition + * @param {DOMElement} root + * @param {function} getFilter + * @param {function} getTheme + */ +function GraphsController({ definition, root, getFilter, getTheme }) { + this._graphs = {}; + this._enabled = new Set(); + this._definition = definition || GRAPH_DEFINITIONS; + this._root = root; + this._getFilter = getFilter; + this._getTheme = getTheme; + this._primaryLink = Object.keys(this._definition).filter( + name => this._definition[name].primaryLink + )[0]; + this.$ = root.ownerDocument.querySelector.bind(root.ownerDocument); + + EventEmitter.decorate(this); + this._onSelecting = this._onSelecting.bind(this); +} + +GraphsController.prototype = { + /** + * Returns the corresponding graph by `graphName`. + */ + get: function(graphName) { + return this._graphs[graphName]; + }, + + /** + * Iterates through all graphs and renders the data + * from a RecordingModel. Takes a resolution value used in + * some graphs. + * Saves rendering progress as a promise to be consumed by `destroy`, + * to wait for cleaning up rendering during destruction. + */ + async render(recordingData, resolution) { + // Get the previous render promise so we don't start rendering + // until the previous render cycle completes, which can occur + // especially when a recording is finished, and triggers a + // fresh rendering at a higher rate + await this._rendering; + + // Check after yielding to ensure we're not tearing down, + // as this can create a race condition in tests + if (this._destroyed) { + return; + } + + this._rendering = (async () => { + for (const graph of await this._getEnabled()) { + await graph.setPerformanceData(recordingData, resolution); + this.emit("rendered", graph.graphName); + } + })(); + await this._rendering; + }, + + /** + * Destroys the underlying graphs. + */ + async destroy() { + const primary = this._getPrimaryLink(); + + this._destroyed = true; + + if (primary) { + primary.off("selecting", this._onSelecting); + } + + // If there was rendering, wait until the most recent render cycle + // has finished + if (this._rendering) { + await this._rendering; + } + + for (const graph of this.getWidgets()) { + await graph.destroy(); + } + }, + + /** + * Applies the theme to the underlying graphs. Optionally takes + * a `redraw` boolean in the options to force redraw. + */ + setTheme: function(options = {}) { + const theme = options.theme || this._getTheme(); + for (const graph of this.getWidgets()) { + graph.setTheme(theme); + graph.refresh({ force: options.redraw }); + } + }, + + /** + * Sets up the graph, if needed. Returns a promise resolving + * to the graph if it is enabled once it's ready, or otherwise returns + * null if disabled. + */ + async isAvailable(graphName) { + if (!this._enabled.has(graphName)) { + return null; + } + + let graph = this.get(graphName); + + if (!graph) { + graph = await this._construct(graphName); + } + + await graph.ready(); + return graph; + }, + + /** + * Enable or disable a subgraph controlled by GraphsController. + * This determines what graphs are visible and get rendered. + */ + enable: function(graphName, isEnabled) { + const el = this.$(this._definition[graphName].selector); + el.classList[isEnabled ? "remove" : "add"]("hidden"); + + // If no status change, just return + if (this._enabled.has(graphName) === isEnabled) { + return; + } + if (isEnabled) { + this._enabled.add(graphName); + } else { + this._enabled.delete(graphName); + } + + // Invalidate our cache of ready-to-go graphs + this._enabledGraphs = null; + }, + + /** + * Disables all graphs controller by the GraphsController, and + * also hides the root element. This is a one way switch, and used + * when older platforms do not have any timeline data. + */ + disableAll: function() { + this._root.classList.add("hidden"); + // Hide all the subelements + Object.keys(this._definition).forEach(graphName => + this.enable(graphName, false) + ); + }, + + /** + * Sets a mapped selection on the graph that is the main controller + * for keeping the graphs' selections in sync. + */ + setMappedSelection: function(selection, { mapStart, mapEnd }) { + return this._getPrimaryLink().setMappedSelection(selection, { + mapStart, + mapEnd, + }); + }, + + /** + * Fetches the currently mapped selection. If graphs are not yet rendered, + * (which throws in Graphs.js), return null. + */ + getMappedSelection: function({ mapStart, mapEnd }) { + const primary = this._getPrimaryLink(); + if (primary && primary.hasData()) { + return primary.getMappedSelection({ mapStart, mapEnd }); + } + return null; + }, + + /** + * Returns an array of graphs that have been created, not necessarily + * enabled currently. + */ + getWidgets: function() { + return Object.keys(this._graphs).map(name => this._graphs[name]); + }, + + /** + * Drops the selection. + */ + dropSelection: function() { + if (this._getPrimaryLink()) { + return this._getPrimaryLink().dropSelection(); + } + return null; + }, + + /** + * Makes sure the selection is enabled or disabled in all the graphs. + */ + async selectionEnabled(enabled) { + for (const graph of await this._getEnabled()) { + graph.selectionEnabled = enabled; + } + }, + + /** + * Creates the graph `graphName` and initializes it. + */ + async _construct(graphName) { + const def = this._definition[graphName]; + const el = this.$(def.selector); + const filter = this._getFilter(); + const graph = (this._graphs[graphName] = new def.constructor(el, filter)); + graph.graphName = graphName; + + await graph.ready(); + + // Sync the graphs' animations and selections together + if (def.primaryLink) { + graph.on("selecting", this._onSelecting); + } else { + CanvasGraphUtils.linkAnimation(this._getPrimaryLink(), graph); + CanvasGraphUtils.linkSelection(this._getPrimaryLink(), graph); + } + + // Sets the container element's visibility based off of enabled status + el.classList[this._enabled.has(graphName) ? "remove" : "add"]("hidden"); + + this.setTheme(); + return graph; + }, + + /** + * Returns the main graph for this collection, that all graphs + * are bound to for syncing and selection. + */ + _getPrimaryLink: function() { + return this.get(this._primaryLink); + }, + + /** + * Emitted when a selection occurs. + */ + _onSelecting: function() { + this.emit("selecting"); + }, + + /** + * Resolves to an array with all graphs that are enabled, and + * creates them if needed. Different than just iterating over `this._graphs`, + * as those could be enabled. Uses caching, as rendering happens many times per second, + * compared to how often which graphs/features are changed (rarely). + */ + async _getEnabled() { + if (this._enabledGraphs) { + return this._enabledGraphs; + } + const enabled = []; + for (const graphName of this._enabled) { + const graph = await this.isAvailable(graphName); + if (graph) { + enabled.push(graph); + } + } + this._enabledGraphs = enabled; + return this._enabledGraphs; + }, +}; + +/** + * A base class for performance graphs to inherit from. + * + * @param Node parent + * The parent node holding the overview. + * @param string metric + * The unit of measurement for this graph. + */ +function OptimizationsGraph(parent) { + MountainGraphWidget.call(this, parent); + this.setTheme(); +} + +OptimizationsGraph.prototype = extend(MountainGraphWidget.prototype, { + async render(threadNode, frameNode) { + // Regardless if we draw or clear the graph, wait + // until it's ready. + await this.ready(); + + if (!threadNode || !frameNode) { + this.setData([]); + return; + } + + const { sampleTimes } = threadNode; + + if (!sampleTimes.length) { + this.setData([]); + return; + } + + // Take startTime/endTime from samples recorded, rather than + // using duration directly from threadNode, as the first sample that + // equals the startTime does not get recorded. + const startTime = sampleTimes[0]; + const endTime = sampleTimes[sampleTimes.length - 1]; + + const bucketSize = (endTime - startTime) / OPTIMIZATIONS_GRAPH_RESOLUTION; + const data = createTierGraphDataFromFrameNode( + frameNode, + sampleTimes, + bucketSize + ); + + // If for some reason we don't have data (like the frameNode doesn't + // have optimizations, but it shouldn't be at this point if it doesn't), + // log an error. + if (!data) { + console.error( + `FrameNode#${frameNode.location} does not have optimizations data to render.` + ); + return; + } + + this.dataOffsetX = startTime; + await this.setData(data); + }, + + /** + * Sets the theme via `theme` to either "light" or "dark", + * and updates the internal styling to match. Requires a redraw + * to see the effects. + */ + setTheme: function(theme) { + theme = theme || "light"; + + const interpreterColor = getColor("graphs-red", theme); + const baselineColor = getColor("graphs-blue", theme); + const ionColor = getColor("graphs-green", theme); + + this.format = [ + { color: interpreterColor }, + { color: baselineColor }, + { color: ionColor }, + ]; + + this.backgroundColor = getColor("sidebar-background", theme); + }, +}); + +exports.OptimizationsGraph = OptimizationsGraph; +exports.FramerateGraph = FramerateGraph; +exports.MemoryGraph = MemoryGraph; +exports.TimelineGraph = TimelineGraph; +exports.GraphsController = GraphsController; diff --git a/devtools/client/performance/modules/widgets/marker-details.js b/devtools/client/performance/modules/widgets/marker-details.js new file mode 100644 index 0000000000..2a8ce17ce4 --- /dev/null +++ b/devtools/client/performance/modules/widgets/marker-details.js @@ -0,0 +1,177 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This file contains the rendering code for the marker sidebar. + */ + +const EventEmitter = require("devtools/shared/event-emitter"); +const { + MarkerDOMUtils, +} = require("devtools/client/performance/modules/marker-dom-utils"); + +/** + * A detailed view for one single marker. + * + * @param Node parent + * The parent node holding the view. + * @param Node splitter + * The splitter node that the resize event is bound to. + */ +function MarkerDetails(parent, splitter) { + EventEmitter.decorate(this); + + this._document = parent.ownerDocument; + this._parent = parent; + this._splitter = splitter; + + this._onClick = this._onClick.bind(this); + this._onSplitterMouseUp = this._onSplitterMouseUp.bind(this); + + this._parent.addEventListener("click", this._onClick); + this._splitter.addEventListener("mouseup", this._onSplitterMouseUp); + + this.hidden = true; +} + +MarkerDetails.prototype = { + /** + * Sets this view's width. + * @param number + */ + set width(value) { + this._parent.setAttribute("width", value); + }, + + /** + * Sets this view's width. + * @return number + */ + get width() { + return +this._parent.getAttribute("width"); + }, + + /** + * Sets this view's visibility. + * @param boolean + */ + set hidden(value) { + if (this._parent.hidden != value) { + this._parent.hidden = value; + this.emit("resize"); + } + }, + + /** + * Gets this view's visibility. + * @param boolean + */ + get hidden() { + return this._parent.hidden; + }, + + /** + * Clears the marker details from this view. + */ + empty: function() { + this._parent.innerHTML = ""; + }, + + /** + * Populates view with marker's details. + * + * @param object params + * An options object holding: + * - marker: The marker to display. + * - frames: Array of stack frame information; see stack.js. + * - allocations: Whether or not allocations were enabled for this + * recording. [optional] + */ + render: function(options) { + const { marker, frames } = options; + this.empty(); + + const elements = []; + elements.push(MarkerDOMUtils.buildTitle(this._document, marker)); + elements.push(MarkerDOMUtils.buildDuration(this._document, marker)); + MarkerDOMUtils.buildFields(this._document, marker).forEach(f => + elements.push(f) + ); + MarkerDOMUtils.buildCustom(this._document, marker, options).forEach(f => + elements.push(f) + ); + + // Build a stack element -- and use the "startStack" label if + // we have both a startStack and endStack. + if (marker.stack) { + const type = marker.endStack ? "startStack" : "stack"; + elements.push( + MarkerDOMUtils.buildStackTrace(this._document, { + frameIndex: marker.stack, + frames, + type, + }) + ); + } + if (marker.endStack) { + const type = "endStack"; + elements.push( + MarkerDOMUtils.buildStackTrace(this._document, { + frameIndex: marker.endStack, + frames, + type, + }) + ); + } + + elements.forEach(el => this._parent.appendChild(el)); + }, + + /** + * Handles click in the marker details view. Based on the target, + * can handle different actions -- only supporting view source links + * for the moment. + */ + _onClick: function(e) { + const data = findActionFromEvent(e.target, this._parent); + if (!data) { + return; + } + + this.emit(data.action, data); + }, + + /** + * Handles the "mouseup" event on the marker details view splitter. + */ + _onSplitterMouseUp: function() { + this.emit("resize"); + }, +}; + +/** + * Take an element from an event `target`, and ascend through + * the DOM, looking for an element with a `data-action` attribute. Return + * the parsed `data-action` value found, or null if none found before + * reaching the parent `container`. + * + * @param {Element} target + * @param {Element} container + * @return {?object} + */ +function findActionFromEvent(target, container) { + let el = target; + let action; + while (el !== container) { + action = el.getAttribute("data-action"); + if (action) { + return JSON.parse(action); + } + el = el.parentNode; + } + return null; +} + +exports.MarkerDetails = MarkerDetails; diff --git a/devtools/client/performance/modules/widgets/markers-overview.js b/devtools/client/performance/modules/widgets/markers-overview.js new file mode 100644 index 0000000000..ea762a371d --- /dev/null +++ b/devtools/client/performance/modules/widgets/markers-overview.js @@ -0,0 +1,256 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This file contains the "markers overview" graph, which is a minimap of all + * the timeline data. Regions inside it may be selected, determining which + * markers are visible in the "waterfall". + */ + +const { extend } = require("devtools/shared/extend"); +const { + AbstractCanvasGraph, +} = require("devtools/client/shared/widgets/Graphs"); + +const { colorUtils } = require("devtools/shared/css/color"); +const { getColor } = require("devtools/client/shared/theme"); +const ProfilerGlobal = require("devtools/client/performance/modules/global"); +const { + MarkerBlueprintUtils, +} = require("devtools/client/performance/modules/marker-blueprint-utils"); +const { + TickUtils, +} = require("devtools/client/performance/modules/waterfall-ticks"); +const { + TIMELINE_BLUEPRINT, +} = require("devtools/client/performance/modules/markers"); + +const OVERVIEW_HEADER_HEIGHT = 14; // px +const OVERVIEW_ROW_HEIGHT = 11; // px + +const OVERVIEW_SELECTION_LINE_COLOR = "#666"; +const OVERVIEW_CLIPHEAD_LINE_COLOR = "#555"; + +const OVERVIEW_HEADER_TICKS_MULTIPLE = 100; // ms +const OVERVIEW_HEADER_TICKS_SPACING_MIN = 75; // px +const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px +const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif"; +const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6; // px +const OVERVIEW_HEADER_TEXT_PADDING_TOP = 1; // px +const OVERVIEW_MARKER_WIDTH_MIN = 4; // px +const OVERVIEW_GROUP_VERTICAL_PADDING = 5; // px + +/** + * An overview for the markers data. + * + * @param Node parent + * The parent node holding the overview. + * @param Array<String> filter + * List of names of marker types that should not be shown. + */ +function MarkersOverview(parent, filter = [], ...args) { + AbstractCanvasGraph.apply(this, [parent, "markers-overview", ...args]); + this.setTheme(); + this.setFilter(filter); +} + +MarkersOverview.prototype = extend(AbstractCanvasGraph.prototype, { + clipheadLineColor: OVERVIEW_CLIPHEAD_LINE_COLOR, + selectionLineColor: OVERVIEW_SELECTION_LINE_COLOR, + headerHeight: OVERVIEW_HEADER_HEIGHT, + rowHeight: OVERVIEW_ROW_HEIGHT, + groupPadding: OVERVIEW_GROUP_VERTICAL_PADDING, + + /** + * Compute the height of the overview. + */ + get fixedHeight() { + return this.headerHeight + this.rowHeight * this._numberOfGroups; + }, + + /** + * List of marker types that should not be shown in the graph. + */ + setFilter: function(filter) { + this._paintBatches = new Map(); + this._filter = filter; + this._groupMap = Object.create(null); + + const observedGroups = new Set(); + + for (const type in TIMELINE_BLUEPRINT) { + if (filter.includes(type)) { + continue; + } + this._paintBatches.set(type, { + definition: TIMELINE_BLUEPRINT[type], + batch: [], + }); + observedGroups.add(TIMELINE_BLUEPRINT[type].group); + } + + // Take our set of observed groups and order them and map + // the group numbers to fill in the holes via `_groupMap`. + // This normalizes our rows by removing rows that aren't used + // if filters are enabled. + let actualPosition = 0; + for (const groupNumber of Array.from(observedGroups).sort()) { + this._groupMap[groupNumber] = actualPosition++; + } + this._numberOfGroups = Object.keys(this._groupMap).length; + }, + + /** + * Disables selection and empties this graph. + */ + clearView: function() { + this.selectionEnabled = false; + this.dropSelection(); + this.setData({ duration: 0, markers: [] }); + }, + + /** + * Renders the graph's data source. + * @see AbstractCanvasGraph.prototype.buildGraphImage + */ + buildGraphImage: function() { + const { markers, duration } = this._data; + + const { canvas, ctx } = this._getNamedCanvas("markers-overview-data"); + const canvasWidth = this._width; + const canvasHeight = this._height; + + // Group markers into separate paint batches. This is necessary to + // draw all markers sharing the same style at once. + for (const marker of markers) { + // Again skip over markers that we're filtering -- we don't want them + // to be labeled as "Unknown" + if (!MarkerBlueprintUtils.shouldDisplayMarker(marker, this._filter)) { + continue; + } + + const markerType = + this._paintBatches.get(marker.name) || + this._paintBatches.get("UNKNOWN"); + markerType.batch.push(marker); + } + + // Calculate each row's height, and the time-based scaling. + + const groupHeight = this.rowHeight * this._pixelRatio; + const groupPadding = this.groupPadding * this._pixelRatio; + const headerHeight = this.headerHeight * this._pixelRatio; + const dataScale = (this.dataScaleX = canvasWidth / duration); + + // Draw the header and overview background. + + ctx.fillStyle = this.headerBackgroundColor; + ctx.fillRect(0, 0, canvasWidth, headerHeight); + + ctx.fillStyle = this.backgroundColor; + ctx.fillRect(0, headerHeight, canvasWidth, canvasHeight); + + // Draw the alternating odd/even group backgrounds. + + ctx.fillStyle = this.alternatingBackgroundColor; + ctx.beginPath(); + + for (let i = 0; i < this._numberOfGroups; i += 2) { + const top = headerHeight + i * groupHeight; + ctx.rect(0, top, canvasWidth, groupHeight); + } + + ctx.fill(); + + // Draw the timeline header ticks. + + const fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio; + const fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY; + const textPaddingLeft = + OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio; + const textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio; + + const tickInterval = TickUtils.findOptimalTickInterval({ + ticksMultiple: OVERVIEW_HEADER_TICKS_MULTIPLE, + ticksSpacingMin: OVERVIEW_HEADER_TICKS_SPACING_MIN, + dataScale: dataScale, + }); + + ctx.textBaseline = "middle"; + ctx.font = fontSize + "px " + fontFamily; + ctx.fillStyle = this.headerTextColor; + ctx.strokeStyle = this.headerTimelineStrokeColor; + ctx.beginPath(); + + for (let x = 0; x < canvasWidth; x += tickInterval) { + const lineLeft = x; + const textLeft = lineLeft + textPaddingLeft; + const time = Math.round(x / dataScale); + const label = ProfilerGlobal.L10N.getFormatStr("timeline.tick", time); + ctx.fillText(label, textLeft, headerHeight / 2 + textPaddingTop); + ctx.moveTo(lineLeft, 0); + ctx.lineTo(lineLeft, canvasHeight); + } + + ctx.stroke(); + + // Draw the timeline markers. + + for (const [, { definition, batch }] of this._paintBatches) { + const group = this._groupMap[definition.group]; + const top = headerHeight + group * groupHeight + groupPadding / 2; + const height = groupHeight - groupPadding; + + const color = getColor(definition.colorName, this.theme); + ctx.fillStyle = color; + ctx.beginPath(); + + for (const { start, end } of batch) { + const left = start * dataScale; + const width = Math.max( + (end - start) * dataScale, + OVERVIEW_MARKER_WIDTH_MIN + ); + ctx.rect(left, top, width, height); + } + + ctx.fill(); + + // Since all the markers in this batch (thus sharing the same style) have + // been drawn, empty it. The next time new markers will be available, + // they will be sorted and drawn again. + batch.length = 0; + } + + return canvas; + }, + + /** + * Sets the theme via `theme` to either "light" or "dark", + * and updates the internal styling to match. Requires a redraw + * to see the effects. + */ + setTheme: function(theme) { + this.theme = theme = theme || "light"; + this.backgroundColor = getColor("body-background", theme); + this.selectionBackgroundColor = colorUtils.setAlpha( + getColor("selection-background", theme), + 0.25 + ); + this.selectionStripesColor = colorUtils.setAlpha("#fff", 0.1); + this.headerBackgroundColor = getColor("body-background", theme); + this.headerTextColor = getColor("body-color", theme); + this.headerTimelineStrokeColor = colorUtils.setAlpha( + getColor("text-color-alt", theme), + 0.25 + ); + this.alternatingBackgroundColor = colorUtils.setAlpha( + getColor("body-color", theme), + 0.05 + ); + }, +}); + +exports.MarkersOverview = MarkersOverview; diff --git a/devtools/client/performance/modules/widgets/moz.build b/devtools/client/performance/modules/widgets/moz.build new file mode 100644 index 0000000000..d04890425c --- /dev/null +++ b/devtools/client/performance/modules/widgets/moz.build @@ -0,0 +1,11 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "graphs.js", + "marker-details.js", + "markers-overview.js", + "tree-view.js", +) diff --git a/devtools/client/performance/modules/widgets/tree-view.js b/devtools/client/performance/modules/widgets/tree-view.js new file mode 100644 index 0000000000..79ad8229ff --- /dev/null +++ b/devtools/client/performance/modules/widgets/tree-view.js @@ -0,0 +1,461 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This file contains the tree view, displaying all the samples and frames + * received from the proviler in a tree-like structure. + */ + +const { L10N } = require("devtools/client/performance/modules/global"); +const { extend } = require("devtools/shared/extend"); +const { + AbstractTreeItem, +} = require("resource://devtools/client/shared/widgets/AbstractTreeItem.jsm"); + +const URL_LABEL_TOOLTIP = L10N.getStr("table.url.tooltiptext"); +const VIEW_OPTIMIZATIONS_TOOLTIP = L10N.getStr( + "table.view-optimizations.tooltiptext2" +); + +const CALL_TREE_INDENTATION = 16; // px + +// Used for rendering values in cells +const FORMATTERS = { + TIME: value => + L10N.getFormatStr("table.ms2", L10N.numberWithDecimals(value, 2)), + PERCENT: value => + L10N.getFormatStr("table.percentage3", L10N.numberWithDecimals(value, 2)), + NUMBER: value => value || 0, + BYTESIZE: value => L10N.getFormatStr("table.bytes", value || 0), +}; + +/** + * Definitions for rendering cells. Triads of class name, property name from + * `frame.getInfo()`, and a formatter function. + */ +const CELLS = { + duration: ["duration", "totalDuration", FORMATTERS.TIME], + percentage: ["percentage", "totalPercentage", FORMATTERS.PERCENT], + selfDuration: ["self-duration", "selfDuration", FORMATTERS.TIME], + selfPercentage: ["self-percentage", "selfPercentage", FORMATTERS.PERCENT], + samples: ["samples", "samples", FORMATTERS.NUMBER], + + selfSize: ["self-size", "selfSize", FORMATTERS.BYTESIZE], + selfSizePercentage: [ + "self-size-percentage", + "selfSizePercentage", + FORMATTERS.PERCENT, + ], + selfCount: ["self-count", "selfCount", FORMATTERS.NUMBER], + selfCountPercentage: [ + "self-count-percentage", + "selfCountPercentage", + FORMATTERS.PERCENT, + ], + size: ["size", "totalSize", FORMATTERS.BYTESIZE], + sizePercentage: [ + "size-percentage", + "totalSizePercentage", + FORMATTERS.PERCENT, + ], + count: ["count", "totalCount", FORMATTERS.NUMBER], + countPercentage: [ + "count-percentage", + "totalCountPercentage", + FORMATTERS.PERCENT, + ], +}; +const CELL_TYPES = Object.keys(CELLS); + +const DEFAULT_SORTING_PREDICATE = (frameA, frameB) => { + const dataA = frameA.getDisplayedData(); + const dataB = frameB.getDisplayedData(); + const isAllocations = "totalSize" in dataA; + + if (isAllocations) { + if (this.inverted && dataA.selfSize !== dataB.selfSize) { + return dataA.selfSize < dataB.selfSize ? 1 : -1; + } + return dataA.totalSize < dataB.totalSize ? 1 : -1; + } + + if (this.inverted && dataA.selfPercentage !== dataB.selfPercentage) { + return dataA.selfPercentage < dataB.selfPercentage ? 1 : -1; + } + return dataA.totalPercentage < dataB.totalPercentage ? 1 : -1; +}; + +// depth +const DEFAULT_AUTO_EXPAND_DEPTH = 3; +const DEFAULT_VISIBLE_CELLS = { + duration: true, + percentage: true, + selfDuration: true, + selfPercentage: true, + samples: true, + function: true, + + // allocation columns + count: false, + selfCount: false, + size: false, + selfSize: false, + countPercentage: false, + selfCountPercentage: false, + sizePercentage: false, + selfSizePercentage: false, +}; + +/** + * An item in a call tree view, which looks like this: + * + * Time (ms) | Cost | Calls | Function + * ============================================================================ + * 1,000.00 | 100.00% | | ▼ (root) + * 500.12 | 50.01% | 300 | ▼ foo Categ. 1 + * 300.34 | 30.03% | 1500 | ▼ bar Categ. 2 + * 10.56 | 0.01% | 42 | ▶ call_with_children Categ. 3 + * 90.78 | 0.09% | 25 | call_without_children Categ. 4 + * + * Every instance of a `CallView` represents a row in the call tree. The same + * parent node is used for all rows. + * + * @param CallView caller + * The CallView considered the "caller" frame. This newly created + * instance will be represent the "callee". Should be null for root nodes. + * @param ThreadNode | FrameNode frame + * Details about this function, like { samples, duration, calls } etc. + * @param number level [optional] + * The indentation level in the call tree. The root node is at level 0. + * @param boolean hidden [optional] + * Whether this node should be hidden and not contribute to depth/level + * calculations. Defaults to false. + * @param boolean inverted [optional] + * Whether the call tree has been inverted (bottom up, rather than + * top-down). Defaults to false. + * @param function sortingPredicate [optional] + * The predicate used to sort the tree items when created. Defaults to + * the caller's `sortingPredicate` if a caller exists, otherwise defaults + * to DEFAULT_SORTING_PREDICATE. The two passed arguments are FrameNodes. + * @param number autoExpandDepth [optional] + * The depth to which the tree should automatically expand. Defualts to + * the caller's `autoExpandDepth` if a caller exists, otherwise defaults + * to DEFAULT_AUTO_EXPAND_DEPTH. + * @param object visibleCells + * An object specifying which cells are visible in the tree. Defaults to + * the caller's `visibleCells` if a caller exists, otherwise defaults + * to DEFAULT_VISIBLE_CELLS. + * @param boolean showOptimizationHint [optional] + * Whether or not to show an icon indicating if the frame has optimization + * data. + */ +function CallView({ + caller, + frame, + level, + hidden, + inverted, + sortingPredicate, + autoExpandDepth, + visibleCells, + showOptimizationHint, +}) { + AbstractTreeItem.call(this, { + parent: caller, + level: level | (0 - (hidden ? 1 : 0)), + }); + + if (sortingPredicate != null) { + this.sortingPredicate = sortingPredicate; + } else if (caller) { + this.sortingPredicate = caller.sortingPredicate; + } else { + this.sortingPredicate = DEFAULT_SORTING_PREDICATE; + } + + if (autoExpandDepth != null) { + this.autoExpandDepth = autoExpandDepth; + } else if (caller) { + this.autoExpandDepth = caller.autoExpandDepth; + } else { + this.autoExpandDepth = DEFAULT_AUTO_EXPAND_DEPTH; + } + + if (visibleCells != null) { + this.visibleCells = visibleCells; + } else if (caller) { + this.visibleCells = caller.visibleCells; + } else { + this.visibleCells = Object.create(DEFAULT_VISIBLE_CELLS); + } + + this.caller = caller; + this.frame = frame; + this.hidden = hidden; + this.inverted = inverted; + this.showOptimizationHint = showOptimizationHint; + + this._onUrlClick = this._onUrlClick.bind(this); +} + +CallView.prototype = extend(AbstractTreeItem.prototype, { + /** + * Creates the view for this tree node. + * @param Node document + * @param Node arrowNode + * @return Node + */ + _displaySelf: function(document, arrowNode) { + const frameInfo = this.getDisplayedData(); + const cells = []; + + for (const type of CELL_TYPES) { + if (this.visibleCells[type]) { + // Inline for speed, but pass in the formatted value via + // cell definition, as well as the element type. + cells.push( + this._createCell( + document, + CELLS[type][2](frameInfo[CELLS[type][1]]), + CELLS[type][0] + ) + ); + } + } + + if (this.visibleCells.function) { + cells.push( + this._createFunctionCell( + document, + arrowNode, + frameInfo.name, + frameInfo, + this.level + ) + ); + } + + const targetNode = document.createXULElement("hbox"); + targetNode.className = "call-tree-item"; + targetNode.setAttribute( + "origin", + frameInfo.isContent ? "content" : "chrome" + ); + targetNode.setAttribute("category", frameInfo.categoryData.abbrev || ""); + targetNode.setAttribute("tooltiptext", frameInfo.tooltiptext); + + if (this.hidden) { + targetNode.style.display = "none"; + } + + for (let i = 0; i < cells.length; i++) { + targetNode.appendChild(cells[i]); + } + + return targetNode; + }, + + /** + * Populates this node in the call tree with the corresponding "callees". + * These are defined in the `frame` data source for this call view. + * @param array:AbstractTreeItem children + */ + _populateSelf: function(children) { + const newLevel = this.level + 1; + + for (const newFrame of this.frame.calls) { + children.push( + new CallView({ + caller: this, + frame: newFrame, + level: newLevel, + inverted: this.inverted, + }) + ); + } + + // Sort the "callees" asc. by samples, before inserting them in the tree, + // if no other sorting predicate was specified on this on the root item. + children.sort(this.sortingPredicate.bind(this)); + }, + + /** + * Functions creating each cell in this call view. + * Invoked by `_displaySelf`. + */ + _createCell: function(doc, value, type) { + const cell = doc.createXULElement("description"); + cell.className = "plain call-tree-cell"; + cell.setAttribute("type", type); + cell.setAttribute("crop", "end"); + // Add a tabulation to the cell text in case it's is selected and copied. + cell.textContent = value + "\t"; + return cell; + }, + + _createFunctionCell: function( + doc, + arrowNode, + frameName, + frameInfo, + frameLevel + ) { + const cell = doc.createXULElement("hbox"); + cell.className = "call-tree-cell"; + cell.style.marginInlineStart = frameLevel * CALL_TREE_INDENTATION + "px"; + cell.setAttribute("type", "function"); + cell.appendChild(arrowNode); + + // Render optimization hint if this frame has opt data. + if ( + this.root.showOptimizationHint && + frameInfo.hasOptimizations && + !frameInfo.isMetaCategory + ) { + const icon = doc.createXULElement("description"); + icon.setAttribute("tooltiptext", VIEW_OPTIMIZATIONS_TOOLTIP); + icon.className = "opt-icon"; + cell.appendChild(icon); + } + + // Don't render a name label node if there's no function name. A different + // location label node will be rendered instead. + if (frameName) { + const nameNode = doc.createXULElement("description"); + nameNode.className = "plain call-tree-name"; + nameNode.textContent = frameName; + cell.appendChild(nameNode); + } + + // Don't render detailed labels for meta category frames + if (!frameInfo.isMetaCategory) { + this._appendFunctionDetailsCells(doc, cell, frameInfo); + } + + // Don't render an expando-arrow for leaf nodes. + const hasDescendants = Object.keys(this.frame.calls).length > 0; + if (!hasDescendants) { + arrowNode.setAttribute("invisible", ""); + } + + // Add a line break to the last description of the row in case it's selected + // and copied. + const lastDescription = cell.querySelector("description:last-of-type"); + lastDescription.textContent = lastDescription.textContent + "\n"; + + // Add spaces as frameLevel indicators in case the row is selected and + // copied. These spaces won't be displayed in the cell content. + const firstDescription = cell.querySelector("description:first-of-type"); + const levelIndicator = frameLevel > 0 ? " ".repeat(frameLevel) : ""; + firstDescription.textContent = + levelIndicator + firstDescription.textContent; + + return cell; + }, + + _appendFunctionDetailsCells: function(doc, cell, frameInfo) { + if (frameInfo.fileName) { + const urlNode = doc.createXULElement("description"); + urlNode.className = "plain call-tree-url"; + urlNode.textContent = frameInfo.fileName; + urlNode.setAttribute( + "tooltiptext", + URL_LABEL_TOOLTIP + " → " + frameInfo.url + ); + urlNode.addEventListener("mousedown", this._onUrlClick); + cell.appendChild(urlNode); + } + + if (frameInfo.line) { + const lineNode = doc.createXULElement("description"); + lineNode.className = "plain call-tree-line"; + lineNode.textContent = ":" + frameInfo.line; + cell.appendChild(lineNode); + } + + if (frameInfo.column) { + const columnNode = doc.createXULElement("description"); + columnNode.className = "plain call-tree-column"; + columnNode.textContent = ":" + frameInfo.column; + cell.appendChild(columnNode); + } + + if (frameInfo.host) { + const hostNode = doc.createXULElement("description"); + hostNode.className = "plain call-tree-host"; + hostNode.textContent = frameInfo.host; + cell.appendChild(hostNode); + } + + if (frameInfo.categoryData.label) { + const categoryNode = doc.createXULElement("description"); + categoryNode.className = "plain call-tree-category"; + categoryNode.style.color = frameInfo.categoryData.color; + categoryNode.textContent = frameInfo.categoryData.label; + cell.appendChild(categoryNode); + } + }, + + /** + * Gets the data displayed about this tree item, based on the FrameNode + * model associated with this view. + * + * @return object + */ + getDisplayedData: function() { + if (this._cachedDisplayedData) { + return this._cachedDisplayedData; + } + + this._cachedDisplayedData = this.frame.getInfo({ + root: this.root.frame, + allocations: this.visibleCells.count || this.visibleCells.selfCount, + }); + + return this._cachedDisplayedData; + + /** + * When inverting call tree, the costs and times are dependent on position + * in the tree. We must only count leaf nodes with self cost, and total costs + * dependent on how many times the leaf node was found with a full stack path. + * + * Total | Self | Calls | Function + * ============================================================================ + * 100% | 100% | 100 | ▼ C + * 50% | 0% | 50 | ▼ B + * 50% | 0% | 50 | ▼ A + * 50% | 0% | 50 | ▼ B + * + * Every instance of a `CallView` represents a row in the call tree. The same + * container node is used for all rows. + */ + }, + + /** + * Toggles the category information hidden or visible. + * @param boolean visible + */ + toggleCategories: function(visible) { + if (!visible) { + this.container.setAttribute("categories-hidden", ""); + } else { + this.container.removeAttribute("categories-hidden"); + } + }, + + /** + * Handler for the "click" event on the url node of this call view. + */ + _onUrlClick: function(e) { + e.preventDefault(); + e.stopPropagation(); + // Only emit for left click events + if (e.button === 0) { + this.root.emit("link", this); + } + }, +}); + +exports.CallView = CallView; |