diff options
Diffstat (limited to 'devtools/client/performance/modules/logic/frame-utils.js')
-rw-r--r-- | devtools/client/performance/modules/logic/frame-utils.js | 510 |
1 files changed, 510 insertions, 0 deletions
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; |