diff --git a/src/profile-logic/profile-data.js b/src/profile-logic/profile-data.js index 65039b31d6..618feb340d 100644 --- a/src/profile-logic/profile-data.js +++ b/src/profile-logic/profile-data.js @@ -2101,16 +2101,19 @@ export function invertCallstack( * out the manipulation of the data structures so that we can properly update * the stack table and any possible allocation information. */ -export function updateThreadStacks( +export function updateThreadStacksByGeneratingNewStackColumns( thread: Thread, newStackTable: StackTable, - convertStack: (IndexIntoStackTable | null) => IndexIntoStackTable | null + computeFilteredStackColumn: ( + Array, + Array + ) => Array ): Thread { const { jsAllocations, nativeAllocations, samples } = thread; const newSamples = { ...samples, - stack: samples.stack.map((oldStack) => convertStack(oldStack)), + stack: computeFilteredStackColumn(samples.stack, samples.time), }; const newThread = { @@ -2120,21 +2123,47 @@ export function updateThreadStacks( }; if (jsAllocations) { + // Filter the JS allocations if there are any. newThread.jsAllocations = { ...jsAllocations, - stack: jsAllocations.stack.map((oldStack) => convertStack(oldStack)), + stack: computeFilteredStackColumn( + jsAllocations.stack, + jsAllocations.time + ), }; } if (nativeAllocations) { + // Filter the native allocations if there are any. newThread.nativeAllocations = { ...nativeAllocations, - stack: nativeAllocations.stack.map((oldStack) => convertStack(oldStack)), + stack: computeFilteredStackColumn( + nativeAllocations.stack, + nativeAllocations.time + ), }; } return newThread; } +/** + * A simpler variant of updateThreadStacksByGeneratingNewStackColumns which just + * accepts a convertStack function. Use this when you don't need to filter by + * sample timestamp. + */ +export function updateThreadStacks( + thread: Thread, + newStackTable: StackTable, + convertStack: (IndexIntoStackTable | null) => IndexIntoStackTable | null +): Thread { + return updateThreadStacksByGeneratingNewStackColumns( + thread, + newStackTable, + (stackColumn, _timeColumn) => + stackColumn.map((oldStack) => convertStack(oldStack)) + ); +} + /** * When manipulating stack tables, the most common operation is to map from one * stack to a new stack using a Map. This function returns another function that diff --git a/src/profile-logic/transforms.js b/src/profile-logic/transforms.js index 9a8f07cc12..49ba09e9ea 100644 --- a/src/profile-logic/transforms.js +++ b/src/profile-logic/transforms.js @@ -11,11 +11,13 @@ import { toValidImplementationFilter, getCallNodeIndexFromPath, updateThreadStacks, + updateThreadStacksByGeneratingNewStackColumns, getMapStackUpdater, getCallNodeIndexFromParentAndFunc, } from './profile-data'; import { timeCode } from '../utils/time-code'; import { assertExhaustiveCheck, convertToTransformType } from '../utils/flow'; +import { canonicalizeRangeSet } from '../utils/range-set'; import { CallTree } from '../profile-logic/call-tree'; import { getSearchFilteredMarkerIndexes } from '../profile-logic/marker-data'; import { @@ -1799,71 +1801,66 @@ export function filterSamples( ): Thread { return timeCode('filterSamples', () => { // Find the ranges to filter. - let ranges: StartEndRange[]; - switch (filterType) { - case 'marker-search': - ranges = _findRangesByMarkerFilter( - getMarker, - markerIndexes, - markerSchemaByName, - categoryList, - filter - ); - break; - default: - throw assertExhaustiveCheck(filterType); - } - - // Now let's go through all the samples and remove the ones that are outside - // of the ranges. - const { samples, jsAllocations, nativeAllocations } = thread; - - function filterTable< - Table: { - stack: Array, - time: Milliseconds[], - length: number, + function getFilterRanges(): StartEndRange[] { + switch (filterType) { + case 'marker-search': + return _findRangesByMarkerFilter( + getMarker, + markerIndexes, + markerSchemaByName, + categoryList, + filter + ); + default: + throw assertExhaustiveCheck(filterType); } - >(table: Table): Table { - const newTable = { - ...table, - stack: table.stack.slice(), - }; - - for (let tableIndex = 0; tableIndex < newTable.length; tableIndex++) { - const sampleTime = newTable.time[tableIndex]; + } - let sampleInRange = false; - for (const { start, end } of ranges) { - if (sampleTime >= start && sampleTime <= end) { - sampleInRange = true; + const ranges = canonicalizeRangeSet(getFilterRanges()); + + function computeFilteredStackColumn( + originalStackColumn: Array, + timeColumn: Milliseconds[] + ): Array { + const newStackColumn = originalStackColumn.slice(); + + // Walk the ranges and samples in order. Both are sorted by time. + // For each range, drop the samples before the range and skip the samples + // inside the range. + let sampleIndex = 0; + const sampleCount = timeColumn.length; + for (const range of ranges) { + const { start: rangeStart, end: rangeEnd } = range; + // Drop samples before the range. + for (; sampleIndex < sampleCount; sampleIndex++) { + if (timeColumn[sampleIndex] >= rangeStart) { break; } + newStackColumn[sampleIndex] = null; } - if (!sampleInRange) { - newTable.stack[tableIndex] = null; + // Skip over samples inside the range. + for (; sampleIndex < sampleCount; sampleIndex++) { + if (timeColumn[sampleIndex] >= rangeEnd) { + break; + } } } - return newTable; - } - - const newThread = { - ...thread, - samples: filterTable(samples), - }; + // Drop the remaining samples, i.e. the samples after the last range. + while (sampleIndex < sampleCount) { + newStackColumn[sampleIndex] = null; + sampleIndex++; + } - if (jsAllocations) { - // Filter the JS allocations if there are any. - newThread.jsAllocations = filterTable(jsAllocations); - } - if (nativeAllocations) { - // Filter the native allocations if there are any. - newThread.nativeAllocations = filterTable(nativeAllocations); + return newStackColumn; } - return newThread; + return updateThreadStacksByGeneratingNewStackColumns( + thread, + thread.stackTable, + computeFilteredStackColumn + ); }); } diff --git a/src/test/unit/range-set.test.js b/src/test/unit/range-set.test.js new file mode 100644 index 0000000000..76b3c343bd --- /dev/null +++ b/src/test/unit/range-set.test.js @@ -0,0 +1,76 @@ +/* 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/. */ + +// @flow + +import { canonicalizeRangeSet } from '../../utils/range-set'; + +describe('canonicalizeRangeSet', function () { + it('sorts unsorted ranges', function () { + expect( + canonicalizeRangeSet([ + { start: 5, end: 6 }, + { start: 1, end: 2 }, + ]) + ).toEqual([ + { start: 1, end: 2 }, + { start: 5, end: 6 }, + ]); + }); + + it('absorbs nested ranges', function () { + expect( + canonicalizeRangeSet([ + { start: 1, end: 6 }, + { start: 3, end: 6 }, + ]) + ).toEqual([{ start: 1, end: 6 }]); + }); + + it('unifies overlapping ranges', function () { + expect( + canonicalizeRangeSet([ + { start: 1, end: 4 }, + { start: 3, end: 6 }, + ]) + ).toEqual([{ start: 1, end: 6 }]); + }); + + it('unifies adjacent ranges', function () { + expect( + canonicalizeRangeSet([ + { start: 1, end: 3 }, + { start: 3, end: 6 }, + ]) + ).toEqual([{ start: 1, end: 6 }]); + }); + + it('removes empty ranges', function () { + expect( + canonicalizeRangeSet([ + { start: 1, end: 3 }, + { start: 6, end: 6 }, + ]) + ).toEqual([{ start: 1, end: 3 }]); + }); + + it('handles this complicated example', function () { + expect( + canonicalizeRangeSet([ + { start: 1, end: 2.5 }, + { start: 1.5, end: 2 }, + { start: 1, end: 2 }, + { start: 1.7, end: 2.6 }, + { start: -4, end: -4 }, + { start: -4, end: -3.8 }, + { start: -3.5, end: 2.5 }, + { start: -3.5, end: -3 }, + { start: -6, end: -4 }, + ]) + ).toEqual([ + { start: -6, end: -3.8 }, + { start: -3.5, end: 2.6 }, + ]); + }); +}); diff --git a/src/utils/range-set.js b/src/utils/range-set.js new file mode 100644 index 0000000000..a94f1ae4fe --- /dev/null +++ b/src/utils/range-set.js @@ -0,0 +1,50 @@ +/* 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/. */ +// @flow + +import type { StartEndRange } from 'firefox-profiler/types'; + +/** + * Canonicalize the list of ranges, by OR'ing nested and overlapping ranges + * together so that the resulting list of ranges is a flat list of ranges which + * covers the same time values. The resulting list has the following properties: + * + * - Sorted by range.start + * - No empty ranges + * - No overlap + * - Adjacent ranges are collapsed into one + */ +export function canonicalizeRangeSet(ranges: StartEndRange[]): StartEndRange[] { + if (ranges.length === 0) { + return []; + } + + const sortedRanges = ranges.slice().sort((a, b) => a.start - b.start); + let lastCanonRange = { ...sortedRanges[0] }; + const canonRanges = [lastCanonRange]; + + for (let i = 1; i < sortedRanges.length; i++) { + const range = sortedRanges[i]; + if (range.start >= range.end) { + // Empty or invalid range, skip. + continue; + } + + if (range.end <= lastCanonRange.end) { + // lastCanonRange already covers this range completely. + continue; + } + + if (range.start <= lastCanonRange.end) { + // range's beginning overlaps lastCanonRange's end. Merge the two ranges. + lastCanonRange.end = range.end; + continue; + } + + lastCanonRange = { ...range }; + canonRanges.push(lastCanonRange); + } + + return canonRanges; +}