diff options
Diffstat (limited to 'devtools/shared/performance')
-rw-r--r-- | devtools/shared/performance/moz.build | 12 | ||||
-rw-r--r-- | devtools/shared/performance/recording-common.js | 97 | ||||
-rw-r--r-- | devtools/shared/performance/recording-utils.js | 628 | ||||
-rw-r--r-- | devtools/shared/performance/test/head.js | 7 | ||||
-rw-r--r-- | devtools/shared/performance/test/test_perf-utils-allocations-to-samples.js | 93 | ||||
-rw-r--r-- | devtools/shared/performance/test/xpcshell.ini | 8 |
6 files changed, 845 insertions, 0 deletions
diff --git a/devtools/shared/performance/moz.build b/devtools/shared/performance/moz.build new file mode 100644 index 000000000..202aac278 --- /dev/null +++ b/devtools/shared/performance/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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/. + +XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell.ini'] + +DevToolsModules( + 'recording-common.js', + 'recording-utils.js', +) diff --git a/devtools/shared/performance/recording-common.js b/devtools/shared/performance/recording-common.js new file mode 100644 index 000000000..d0826bd18 --- /dev/null +++ b/devtools/shared/performance/recording-common.js @@ -0,0 +1,97 @@ +/* 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"; + +/** + * A mixin to be used for PerformanceRecordingActor, PerformanceRecordingFront, + * and LegacyPerformanceRecording for helper methods to access data. + */ + +const PerformanceRecordingCommon = exports.PerformanceRecordingCommon = { + // Private fields, only needed when a recording is started or stopped. + _console: false, + _imported: false, + _recording: false, + _completed: false, + _configuration: {}, + _startingBufferStatus: null, + _localStartTime: 0, + + // Serializable fields, necessary and sufficient for import and export. + _label: "", + _duration: 0, + _markers: null, + _frames: null, + _memory: null, + _ticks: null, + _allocations: null, + _profile: null, + _systemHost: null, + _systemClient: null, + + /** + * Helper methods for returning the status of the recording. + * These methods should be consistent on both the front and actor. + */ + isRecording: function () { return this._recording; }, + isCompleted: function () { return this._completed || this.isImported(); }, + isFinalizing: function () { return !this.isRecording() && !this.isCompleted(); }, + isConsole: function () { return this._console; }, + isImported: function () { return this._imported; }, + + /** + * Helper methods for returning configuration for the recording. + * These methods should be consistent on both the front and actor. + */ + getConfiguration: function () { return this._configuration; }, + getLabel: function () { return this._label; }, + + /** + * Gets duration of this recording, in milliseconds. + * @return number + */ + getDuration: function () { + // Compute an approximate ending time for the current recording if it is + // still in progress. This is needed to ensure that the view updates even + // when new data is not being generated. If recording is completed, use + // the duration from the profiler; if between recording and being finalized, + // use the last estimated duration. + if (this.isRecording()) { + return this._estimatedDuration = Date.now() - this._localStartTime; + } else { + return this._duration || this._estimatedDuration || 0; + } + }, + + /** + * Helper methods for returning recording data. + * These methods should be consistent on both the front and actor. + */ + getMarkers: function () { return this._markers; }, + getFrames: function () { return this._frames; }, + getMemory: function () { return this._memory; }, + getTicks: function () { return this._ticks; }, + getAllocations: function () { return this._allocations; }, + getProfile: function () { return this._profile; }, + getHostSystemInfo: function () { return this._systemHost; }, + getClientSystemInfo: function () { return this._systemClient; }, + getStartingBufferStatus: function () { return this._startingBufferStatus; }, + + getAllData: function () { + let label = this.getLabel(); + let duration = this.getDuration(); + let markers = this.getMarkers(); + let frames = this.getFrames(); + let memory = this.getMemory(); + let ticks = this.getTicks(); + let allocations = this.getAllocations(); + let profile = this.getProfile(); + let configuration = this.getConfiguration(); + let systemHost = this.getHostSystemInfo(); + let systemClient = this.getClientSystemInfo(); + + return { label, duration, markers, frames, memory, ticks, allocations, profile, configuration, systemHost, systemClient }; + }, +}; diff --git a/devtools/shared/performance/recording-utils.js b/devtools/shared/performance/recording-utils.js new file mode 100644 index 000000000..64ed12c71 --- /dev/null +++ b/devtools/shared/performance/recording-utils.js @@ -0,0 +1,628 @@ +/* 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, Cu, Cr } = require("chrome"); +loader.lazyRequireGetter(this, "extend", + "sdk/util/object", true); + +/** + * Utility functions for managing recording models and their internal data, + * such as filtering profile samples or offsetting timestamps. + */ + +function mapRecordingOptions(type, options) { + if (type === "profiler") { + return { + entries: options.bufferSize, + interval: options.sampleFrequency ? (1000 / (options.sampleFrequency * 1000)) : void 0 + }; + } + + if (type === "memory") { + return { + probability: options.allocationsSampleProbability, + maxLogLength: options.allocationsMaxLogLength + }; + } + + if (type === "timeline") { + return { + withMarkers: true, + withTicks: options.withTicks, + withMemory: options.withMemory, + withFrames: true, + withGCEvents: true, + withDocLoadingEvents: false + }; + } + + return options; +} + +/** + * Takes an options object for `startRecording`, and normalizes + * it based off of server support. For example, if the user + * requests to record memory `withMemory = true`, but the server does + * not support that feature, then the `false` will overwrite user preference + * in order to define the recording with what is actually available, not + * what the user initially requested. + * + * @param {object} options + * @param {boolean} + */ +function normalizePerformanceFeatures(options, supportedFeatures) { + return Object.keys(options).reduce((modifiedOptions, feature) => { + if (supportedFeatures[feature] !== false) { + modifiedOptions[feature] = options[feature]; + } + return modifiedOptions; + }, Object.create(null)); +} + +/** + * Filters all the samples in the provided profiler data to be more recent + * than the specified start time. + * + * @param object profile + * The profiler data received from the backend. + * @param number profilerStartTime + * The earliest acceptable sample time (in milliseconds). + */ +function filterSamples(profile, profilerStartTime) { + let firstThread = profile.threads[0]; + const TIME_SLOT = firstThread.samples.schema.time; + firstThread.samples.data = firstThread.samples.data.filter(e => { + return e[TIME_SLOT] >= profilerStartTime; + }); +} + +/** + * Offsets all the samples in the provided profiler data by the specified time. + * + * @param object profile + * The profiler data received from the backend. + * @param number timeOffset + * The amount of time to offset by (in milliseconds). + */ +function offsetSampleTimes(profile, timeOffset) { + let firstThread = profile.threads[0]; + const TIME_SLOT = firstThread.samples.schema.time; + let samplesData = firstThread.samples.data; + for (let i = 0; i < samplesData.length; i++) { + samplesData[i][TIME_SLOT] -= timeOffset; + } +} + +/** + * Offsets all the markers in the provided timeline data by the specified time. + * + * @param array markers + * The markers array received from the backend. + * @param number timeOffset + * The amount of time to offset by (in milliseconds). + */ +function offsetMarkerTimes(markers, timeOffset) { + for (let marker of markers) { + marker.start -= timeOffset; + marker.end -= timeOffset; + } +} + +/** + * Offsets and scales all the timestamps in the provided array by the + * specified time and scale factor. + * + * @param array array + * A list of timestamps received from the backend. + * @param number timeOffset + * The amount of time to offset by (in milliseconds). + * @param number timeScale + * The factor to scale by, after offsetting. + */ +function offsetAndScaleTimestamps(timestamps, timeOffset, timeScale) { + for (let i = 0, len = timestamps.length; i < len; i++) { + timestamps[i] -= timeOffset; + if (timeScale) { + timestamps[i] /= timeScale; + } + } +} + +/** + * Push all elements of src array into dest array. Marker data will come in small chunks + * and add up over time, whereas allocation arrays can be > 500000 elements (and + * Function.prototype.apply throws if applying more than 500000 elements, which + * is what spawned this separate function), so iterate one element at a time. + * @see bug 1166823 + * @see http://jsperf.com/concat-large-arrays + * @see http://jsperf.com/concat-large-arrays/2 + * + * @param {Array} dest + * @param {Array} src + */ +function pushAll(dest, src) { + let length = src.length; + for (let i = 0; i < length; i++) { + dest.push(src[i]); + } +} + +/** + * Cache used in `RecordingUtils.getProfileThreadFromAllocations`. + */ +var gProfileThreadFromAllocationCache = new WeakMap(); + +/** + * Converts allocation data from the memory actor to something that follows + * the same structure as the samples data received from the profiler. + * + * @see MemoryActor.prototype.getAllocations for more information. + * + * @param object allocations + * A list of { sites, timestamps, frames, sizes } arrays. + * @return object + * The "profile" describing the allocations log. + */ +function getProfileThreadFromAllocations(allocations) { + let cached = gProfileThreadFromAllocationCache.get(allocations); + if (cached) { + return cached; + } + + let { sites, timestamps, frames, sizes } = allocations; + let uniqueStrings = new UniqueStrings(); + + // Convert allocation frames to the the stack and frame tables expected by + // the profiler format. + // + // Since the allocations log is already presented as a tree, we would be + // wasting time if we jumped through the same hoops as deflateProfile below + // and instead use the existing structure of the allocations log to build up + // the profile JSON. + // + // The allocations.frames array corresponds roughly to the profile stack + // table: a trie of all stacks. We could work harder to further deduplicate + // each individual frame as the profiler does, but it is not necessary for + // correctness. + let stackTable = new Array(frames.length); + let frameTable = new Array(frames.length); + + // Array used to concat the location. + let locationConcatArray = new Array(5); + + for (let i = 0; i < frames.length; i++) { + let frame = frames[i]; + if (!frame) { + stackTable[i] = frameTable[i] = null; + continue; + } + + let prefix = frame.parent; + + // Schema: + // [prefix, frame] + stackTable[i] = [frames[prefix] ? prefix : null, i]; + + // Schema: + // [location] + // + // The only field a frame will have in an allocations profile is location. + // + // If frame.functionDisplayName is present, the format is + // "functionDisplayName (source:line:column)" + // Otherwise, it is + // "source:line:column" + // + // A static array is used to join to save memory on intermediate strings. + locationConcatArray[0] = frame.source; + locationConcatArray[1] = ":"; + locationConcatArray[2] = String(frame.line); + locationConcatArray[3] = ":"; + locationConcatArray[4] = String(frame.column); + locationConcatArray[5] = ""; + + let location = locationConcatArray.join(""); + let funcName = frame.functionDisplayName; + + if (funcName) { + locationConcatArray[0] = funcName; + locationConcatArray[1] = " ("; + locationConcatArray[2] = location; + locationConcatArray[3] = ")"; + locationConcatArray[4] = ""; + locationConcatArray[5] = ""; + location = locationConcatArray.join(""); + } + + frameTable[i] = [uniqueStrings.getOrAddStringIndex(location)]; + } + + let samples = new Array(sites.length); + let writePos = 0; + for (let i = 0; i < sites.length; i++) { + // Schema: + // [stack, time, size] + // + // Originally, sites[i] indexes into the frames array. Note that in the + // loop above, stackTable[sites[i]] and frames[sites[i]] index the same + // information. + let stackIndex = sites[i]; + if (frames[stackIndex]) { + samples[writePos++] = [stackIndex, timestamps[i], sizes[i]]; + } + } + samples.length = writePos; + + let thread = { + name: "allocations", + samples: allocationsWithSchema(samples), + stackTable: stackTableWithSchema(stackTable), + frameTable: frameTableWithSchema(frameTable), + stringTable: uniqueStrings.stringTable + }; + + gProfileThreadFromAllocationCache.set(allocations, thread); + return thread; +} + +function allocationsWithSchema(data) { + let slot = 0; + return { + schema: { + stack: slot++, + time: slot++, + size: slot++, + }, + data: data + }; +} + +/** + * Deduplicates a profile by deduplicating stacks, frames, and strings. + * + * This is used to adapt version 2 profiles from the backend to version 3, for + * use with older Geckos (like B2G). + * + * Note that the schemas used by this must be kept in sync with schemas used + * by the C++ UniqueStacks class in tools/profiler/ProfileEntry.cpp. + * + * @param object profile + * A profile with version 2. + */ +function deflateProfile(profile) { + profile.threads = profile.threads.map((thread) => { + let uniqueStacks = new UniqueStacks(); + return deflateThread(thread, uniqueStacks); + }); + + profile.meta.version = 3; + return profile; +} + +/** + * Given an array of frame objects, deduplicates each frame as well as all + * prefixes in the stack. Returns the index of the deduplicated stack. + * + * @param object frames + * Array of frame objects. + * @param UniqueStacks uniqueStacks + * @return number index + */ +function deflateStack(frames, uniqueStacks) { + // Deduplicate every prefix in the stack by keeping track of the current + // prefix hash. + let prefixIndex = null; + for (let i = 0; i < frames.length; i++) { + let frameIndex = uniqueStacks.getOrAddFrameIndex(frames[i]); + prefixIndex = uniqueStacks.getOrAddStackIndex(prefixIndex, frameIndex); + } + return prefixIndex; +} + +/** + * Given an array of sample objects, deduplicate each sample's stack and + * convert the samples to a table with a schema. Returns the deflated samples. + * + * @param object samples + * Array of samples + * @param UniqueStacks uniqueStacks + * @return object + */ +function deflateSamples(samples, uniqueStacks) { + // Schema: + // [stack, time, responsiveness, rss, uss, frameNumber, power] + + let deflatedSamples = new Array(samples.length); + for (let i = 0; i < samples.length; i++) { + let sample = samples[i]; + deflatedSamples[i] = [ + deflateStack(sample.frames, uniqueStacks), + sample.time, + sample.responsiveness, + sample.rss, + sample.uss, + sample.frameNumber, + sample.power + ]; + } + + return samplesWithSchema(deflatedSamples); +} + +/** + * Given an array of marker objects, convert the markers to a table with a + * schema. Returns the deflated markers. + * + * If a marker contains a backtrace as its payload, the backtrace stack is + * deduplicated in the context of the profile it's in. + * + * @param object markers + * Array of markers + * @param UniqueStacks uniqueStacks + * @return object + */ +function deflateMarkers(markers, uniqueStacks) { + // Schema: + // [name, time, data] + + let deflatedMarkers = new Array(markers.length); + for (let i = 0; i < markers.length; i++) { + let marker = markers[i]; + if (marker.data && marker.data.type === "tracing" && marker.data.stack) { + marker.data.stack = deflateThread(marker.data.stack, uniqueStacks); + } + + deflatedMarkers[i] = [ + uniqueStacks.getOrAddStringIndex(marker.name), + marker.time, + marker.data + ]; + } + + let slot = 0; + return { + schema: { + name: slot++, + time: slot++, + data: slot++ + }, + data: deflatedMarkers + }; +} + +/** + * Deflate a thread. + * + * @param object thread + * The profile thread. + * @param UniqueStacks uniqueStacks + * @return object + */ +function deflateThread(thread, uniqueStacks) { + // Some extra threads in a profile come stringified as a full profile (so + // it has nested threads itself) so the top level "thread" does not have markers + // or samples. We don't use this anyway so just make this safe to deflate. + // can be a string rather than an object on import. Bug 1173695 + if (typeof thread === "string") { + thread = JSON.parse(thread); + } + if (!thread.samples) { + thread.samples = []; + } + if (!thread.markers) { + thread.markers = []; + } + + return { + name: thread.name, + tid: thread.tid, + samples: deflateSamples(thread.samples, uniqueStacks), + markers: deflateMarkers(thread.markers, uniqueStacks), + stackTable: uniqueStacks.getStackTableWithSchema(), + frameTable: uniqueStacks.getFrameTableWithSchema(), + stringTable: uniqueStacks.getStringTable() + }; +} + +function stackTableWithSchema(data) { + let slot = 0; + return { + schema: { + prefix: slot++, + frame: slot++ + }, + data: data + }; +} + +function frameTableWithSchema(data) { + let slot = 0; + return { + schema: { + location: slot++, + implementation: slot++, + optimizations: slot++, + line: slot++, + category: slot++ + }, + data: data + }; +} + +function samplesWithSchema(data) { + let slot = 0; + return { + schema: { + stack: slot++, + time: slot++, + responsiveness: slot++, + rss: slot++, + uss: slot++, + frameNumber: slot++, + power: slot++ + }, + data: data + }; +} + +/** + * A helper class to deduplicate strings. + */ +function UniqueStrings() { + this.stringTable = []; + this._stringHash = Object.create(null); +} + +UniqueStrings.prototype.getOrAddStringIndex = function (s) { + if (!s) { + return null; + } + + let stringHash = this._stringHash; + let stringTable = this.stringTable; + let index = stringHash[s]; + if (index !== undefined) { + return index; + } + + index = stringTable.length; + stringHash[s] = index; + stringTable.push(s); + return index; +}; + +/** + * A helper class to deduplicate old-version profiles. + * + * The main functionality provided is deduplicating frames and stacks. + * + * For example, given 2 stacks + * [A, B, C] + * and + * [A, B, D] + * + * There are 4 unique frames: A, B, C, and D. + * There are 4 unique prefixes: [A], [A, B], [A, B, C], [A, B, D] + * + * For the example, the output of using UniqueStacks is: + * + * Frame table: + * [A, B, C, D] + * + * That is, A has id 0, B has id 1, etc. + * + * Since stack prefixes are themselves deduplicated (shared), stacks are + * represented as a tree, or more concretely, a pair of ids, the prefix and + * the leaf. + * + * Stack table: + * [ + * [null, 0], + * [0, 1], + * [1, 2], + * [1, 3] + * ] + * + * That is, [A] has id 0 and value [null, 0]. This means it has no prefix, and + * has the leaf frame 0, which resolves to A in the frame table. + * + * [A, B] has id 1 and value [0, 1]. This means it has prefix 0, which is [A], + * and leaf 1, thus [A, B]. + * + * [A, B, C] has id 2 and value [1, 2]. This means it has prefix 1, which in + * turn is [A, B], and leaf 2, thus [A, B, C]. + * + * [A, B, D] has id 3 and value [1, 3]. Note how it shares the prefix 1 with + * [A, B, C]. + */ +function UniqueStacks() { + this._frameTable = []; + this._stackTable = []; + this._frameHash = Object.create(null); + this._stackHash = Object.create(null); + this._uniqueStrings = new UniqueStrings(); +} + +UniqueStacks.prototype.getStackTableWithSchema = function () { + return stackTableWithSchema(this._stackTable); +}; + +UniqueStacks.prototype.getFrameTableWithSchema = function () { + return frameTableWithSchema(this._frameTable); +}; + +UniqueStacks.prototype.getStringTable = function () { + return this._uniqueStrings.stringTable; +}; + +UniqueStacks.prototype.getOrAddFrameIndex = function (frame) { + // Schema: + // [location, implementation, optimizations, line, category] + + let frameHash = this._frameHash; + let frameTable = this._frameTable; + + let locationIndex = this.getOrAddStringIndex(frame.location); + let implementationIndex = this.getOrAddStringIndex(frame.implementation); + + // Super dumb. + let hash = `${locationIndex} ${implementationIndex || ""} ${frame.line || ""} ${frame.category || ""}`; + + let index = frameHash[hash]; + if (index !== undefined) { + return index; + } + + index = frameTable.length; + frameHash[hash] = index; + frameTable.push([ + this.getOrAddStringIndex(frame.location), + this.getOrAddStringIndex(frame.implementation), + // Don't bother with JIT optimization info for deflating old profile data + // format to the new format. + null, + frame.line, + frame.category + ]); + return index; +}; + +UniqueStacks.prototype.getOrAddStackIndex = function (prefixIndex, frameIndex) { + // Schema: + // [prefix, frame] + + let stackHash = this._stackHash; + let stackTable = this._stackTable; + + // Also super dumb. + let hash = prefixIndex + " " + frameIndex; + + let index = stackHash[hash]; + if (index !== undefined) { + return index; + } + + index = stackTable.length; + stackHash[hash] = index; + stackTable.push([prefixIndex, frameIndex]); + return index; +}; + +UniqueStacks.prototype.getOrAddStringIndex = function (s) { + return this._uniqueStrings.getOrAddStringIndex(s); +}; + +exports.pushAll = pushAll; +exports.mapRecordingOptions = mapRecordingOptions; +exports.normalizePerformanceFeatures = normalizePerformanceFeatures; +exports.filterSamples = filterSamples; +exports.offsetSampleTimes = offsetSampleTimes; +exports.offsetMarkerTimes = offsetMarkerTimes; +exports.offsetAndScaleTimestamps = offsetAndScaleTimestamps; +exports.getProfileThreadFromAllocations = getProfileThreadFromAllocations; +exports.deflateProfile = deflateProfile; +exports.deflateThread = deflateThread; +exports.UniqueStrings = UniqueStrings; +exports.UniqueStacks = UniqueStacks; diff --git a/devtools/shared/performance/test/head.js b/devtools/shared/performance/test/head.js new file mode 100644 index 000000000..9e7748055 --- /dev/null +++ b/devtools/shared/performance/test/head.js @@ -0,0 +1,7 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +var { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); diff --git a/devtools/shared/performance/test/test_perf-utils-allocations-to-samples.js b/devtools/shared/performance/test/test_perf-utils-allocations-to-samples.js new file mode 100644 index 000000000..6429eff2d --- /dev/null +++ b/devtools/shared/performance/test/test_perf-utils-allocations-to-samples.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if allocations data received from the performance actor is properly + * converted to something that follows the same structure as the samples data + * received from the profiler. + */ + +function run_test() { + run_next_test(); +} + +add_task(function () { + const { getProfileThreadFromAllocations } = require("devtools/shared/performance/recording-utils"); + let output = getProfileThreadFromAllocations(TEST_DATA); + equal(output.toSource(), EXPECTED_OUTPUT.toSource(), "The output is correct."); +}); + +var TEST_DATA = { + sites: [0, 0, 1, 2, 3], + timestamps: [50, 100, 150, 200, 250], + sizes: [0, 0, 100, 200, 300], + frames: [ + null, { + source: "A", + line: 1, + column: 2, + functionDisplayName: "x", + parent: 0 + }, { + source: "B", + line: 3, + column: 4, + functionDisplayName: "y", + parent: 1 + }, { + source: "C", + line: 5, + column: 6, + functionDisplayName: null, + parent: 2 + } + ] +}; + +var EXPECTED_OUTPUT = { + name: "allocations", + samples: { + "schema": { + "stack": 0, + "time": 1, + "size": 2, + }, + data: [ + [ 1, 150, 100 ], + [ 2, 200, 200 ], + [ 3, 250, 300 ] + ] + }, + stackTable: { + "schema": { + "prefix": 0, + "frame": 1 + }, + "data": [ + null, + [ null, 1 ], // x (A:1:2) + [ 1, 2 ], // x (A:1:2) > y (B:3:4) + [ 2, 3 ] // x (A:1:2) > y (B:3:4) > C:5:6 + ] + }, + frameTable: { + "schema": { + "location": 0, + "implementation": 1, + "optimizations": 2, + "line": 3, + "category": 4 + }, + data: [ + null, + [ 0 ], + [ 1 ], + [ 2 ] + ] + }, + "stringTable": [ + "x (A:1:2)", + "y (B:3:4)", + "C:5:6" + ], +}; diff --git a/devtools/shared/performance/test/xpcshell.ini b/devtools/shared/performance/test/xpcshell.ini new file mode 100644 index 000000000..737f6d67b --- /dev/null +++ b/devtools/shared/performance/test/xpcshell.ini @@ -0,0 +1,8 @@ +[DEFAULT] +tags = devtools +head = head.js +tail = +firefox-appdir = browser +skip-if = toolkit == 'android' + +[test_perf-utils-allocations-to-samples.js] |