Skip to content

Commit

Permalink
Speed up filterSamples by taking advantage of sortedness.
Browse files Browse the repository at this point in the history
On the profile from firefox-devtools#4668 this makes filterSamples 10x faster. It changes
the algorithm complexity from N * M to N + M, N being the number of
samples and M the number of ranges.

Before: https://share.firefox.dev/3Ji8qPD
After: https://share.firefox.dev/3CAtmxA
  • Loading branch information
mstange committed Jun 20, 2023
1 parent e6fecc6 commit d685977
Show file tree
Hide file tree
Showing 4 changed files with 210 additions and 58 deletions.
39 changes: 34 additions & 5 deletions src/profile-logic/profile-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<IndexIntoStackTable | null>,
Array<Milliseconds>
) => Array<IndexIntoStackTable | null>
): Thread {
const { jsAllocations, nativeAllocations, samples } = thread;

const newSamples = {
...samples,
stack: samples.stack.map((oldStack) => convertStack(oldStack)),
stack: computeFilteredStackColumn(samples.stack, samples.time),
};

const newThread = {
Expand All @@ -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
Expand Down
103 changes: 50 additions & 53 deletions src/profile-logic/transforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<IndexIntoStackTable | null>,
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<IndexIntoStackTable | null>,
timeColumn: Milliseconds[]
): Array<IndexIntoStackTable | null> {
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
);
});
}

Expand Down
76 changes: 76 additions & 0 deletions src/test/unit/range-set.test.js
Original file line number Diff line number Diff line change
@@ -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 },
]);
});
});
50 changes: 50 additions & 0 deletions src/utils/range-set.js
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit d685977

Please sign in to comment.