Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 deletions src/components/stack-chart/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { changeMouseTimePosition } from '../../actions/profile-view';
type ChangeMouseTimePosition = typeof changeMouseTimePosition;
import {
mapCategoryColorNameToStackChartStyles,
getDimmedStyles,
getForegroundColor,
getBackgroundColor,
} from '../../utils/colors';
Expand Down Expand Up @@ -78,6 +79,7 @@ type OwnProps = {
readonly displayStackType: boolean;
readonly useStackChartSameWidths: boolean;
readonly timelineUnit: TimelineUnit;
readonly searchStringsRegExp: RegExp | null;
};

type Props = Readonly<
Expand Down Expand Up @@ -183,6 +185,7 @@ class StackChartCanvasImpl extends React.PureComponent<Props> {
getMarker,
marginLeft,
useStackChartSameWidths,
searchStringsRegExp,
viewport: {
containerWidth,
containerHeight,
Expand Down Expand Up @@ -359,6 +362,48 @@ class StackChartCanvasImpl extends React.PureComponent<Props> {

const callNodeTable = callNodeInfo.getCallNodeTable();

// Pre-compute which call nodes match the search string so we can dim
// non-matching nodes when a search is active.
let searchMatchedCallNodes: Set<IndexIntoCallNodeTable> | null = null;
if (searchStringsRegExp) {
searchMatchedCallNodes = new Set();
const { funcTable, resourceTable, sources, stringTable } = thread;
for (
let callNodeIndex = 0;
callNodeIndex < callNodeTable.length;
callNodeIndex++
) {
Comment on lines +371 to +375
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I thought a lot about this Set and the for loop here. I'm a bit scared of its performance impact and think it could be improved.

The possible issues I can see:

  • There is no caching, we are rebuilding this set on every draw.
  • It adds another loop, and we are doing this check before here with funcMatchesSearch:
    const funcMatchesSearch = makeBitSet(funcTable.length);
    for (let funcIndex = 0; funcIndex < funcTable.length; funcIndex++) {
    if (computeFuncMatchesSearch(funcIndex)) {
    setBit(funcMatchesSearch, funcIndex);
    }

    funcMatchesSearch here is a BitSet of all the funcTable. And we mostly care about the funcTable. Currently it uses callNodeIndex, but there is no reason not to check funcIndex below as we already have it.
  • The set can get pretty big on large profiles.
  • If flame graph needs it, we will need to move it out so we don't do it twice.

But this is a lot more architectural changes than this small change...
funcMatchesSearch is an local variable, which needs to be extracted and memoized. And then we can use that extracted BitSet in 2 places without computing it twice. And this would allow us store it and have a selector like getSearchMatchedFuncs. Then instead of passing searchStringsRegExp here, we would pass that bitset to this component. And the component only does checkBit(searchMatchedFuncs, currentFuncIndex) instead.

Since this is a much bigger change, I'm okay with landing this one. But let's at least file a bug about the improvements we can make.

const funcIndex = callNodeTable.func[callNodeIndex];
searchStringsRegExp.lastIndex = 0;
const funcName = stringTable.getString(funcTable.name[funcIndex]);
if (searchStringsRegExp.test(funcName)) {
searchMatchedCallNodes.add(callNodeIndex);
Comment thread
fatadel marked this conversation as resolved.
continue;
}

const sourceIndex = funcTable.source[funcIndex];
if (sourceIndex !== null) {
searchStringsRegExp.lastIndex = 0;
const fileName = stringTable.getString(sources.filename[sourceIndex]);
if (searchStringsRegExp.test(fileName)) {
searchMatchedCallNodes.add(callNodeIndex);
continue;
}
}

const resourceIndex = funcTable.resource[funcIndex];
if (resourceIndex !== -1) {
searchStringsRegExp.lastIndex = 0;
const resourceName = stringTable.getString(
resourceTable.name[resourceIndex]
);
if (searchStringsRegExp.test(resourceName)) {
searchMatchedCallNodes.add(callNodeIndex);
}
}
}
}

// Only draw the stack frames that are vertically within view.
for (let depth = startDepth; depth < endDepth; depth++) {
// Get the timing information for a row of stack frames.
Expand Down Expand Up @@ -480,8 +525,10 @@ class StackChartCanvasImpl extends React.PureComponent<Props> {

// Look up information about this stack frame.
let text, category, isSelected;
let currentCallNodeIndex: IndexIntoCallNodeTable | null = null;
if ('callNode' in stackTiming && stackTiming.callNode) {
const callNodeIndex = stackTiming.callNode[i];
currentCallNodeIndex = callNodeIndex;
const funcIndex = callNodeTable.func[callNodeIndex];
const funcNameIndex = thread.funcTable.name[funcIndex];
text = thread.stringTable.getString(funcNameIndex);
Expand All @@ -507,9 +554,19 @@ class StackChartCanvasImpl extends React.PureComponent<Props> {
depth === hoveredItem.depth &&
i === hoveredItem.stackTimingIndex;

const colorStyles = mapCategoryColorNameToStackChartStyles(
category.color
);
// When a search is active, use the dimmed style for non-matching nodes
// so that matching nodes stand out with their category color.
// Hovered or selected nodes always use their real category color.
const isDimmed =
searchMatchedCallNodes !== null &&
currentCallNodeIndex !== null &&
!searchMatchedCallNodes.has(currentCallNodeIndex) &&
!isHovered &&
!isSelected;
const colorStyles = isDimmed
? getDimmedStyles()
: mapCategoryColorNameToStackChartStyles(category.color);

// Draw the box.
fastFillStyle.set(
isHovered || isSelected
Expand Down Expand Up @@ -542,11 +599,11 @@ class StackChartCanvasImpl extends React.PureComponent<Props> {
if (textW > textMeasurement.minWidth) {
const fittedText = textMeasurement.getFittedText(text, textW);
if (fittedText) {
fastFillStyle.set(
isHovered || isSelected
? colorStyles.getSelectedTextColor()
: getForegroundColor()
);
if (isHovered || isSelected || isDimmed) {
fastFillStyle.set(colorStyles.getSelectedTextColor());
} else {
fastFillStyle.set(getForegroundColor());
}
ctx.fillText(fittedText, textX, intY + textDevicePixelsOffsetTop);
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/components/stack-chart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getStackChartSameWidths,
getShowUserTimings,
getSelectedThreadsKey,
getSearchStringsAsRegExp,
} from 'firefox-profiler/selectors/url-state';
import type { SameWidthsIndexToTimestampMap } from 'firefox-profiler/profile-logic/stack-timing';
import { selectedThreadSelectors } from '../../selectors/per-thread';
Expand Down Expand Up @@ -87,6 +88,7 @@ type StateProps = {
readonly hasFilteredCtssSamples: boolean;
readonly useStackChartSameWidths: boolean;
readonly timelineUnit: TimelineUnit;
readonly searchStringsRegExp: RegExp | null;
};

type DispatchProps = {
Expand Down Expand Up @@ -244,6 +246,7 @@ class StackChartImpl extends React.PureComponent<Props> {
hasFilteredCtssSamples,
useStackChartSameWidths,
timelineUnit,
searchStringsRegExp,
} = this.props;

const maxViewportHeight = combinedTimingRows.length * STACK_FRAME_HEIGHT;
Expand Down Expand Up @@ -304,6 +307,7 @@ class StackChartImpl extends React.PureComponent<Props> {
displayStackType: displayStackType,
useStackChartSameWidths,
timelineUnit,
searchStringsRegExp,
}}
/>
</div>
Expand Down Expand Up @@ -347,6 +351,7 @@ export const StackChart = explicitConnect<{}, StateProps, DispatchProps>({
selectedThreadSelectors.getHasFilteredCtssSamples(state),
useStackChartSameWidths: getStackChartSameWidths(state),
timelineUnit: getProfileTimelineUnit(state),
searchStringsRegExp: getSearchStringsAsRegExp(state),
};
},
mapDispatchToProps: {
Expand Down
51 changes: 51 additions & 0 deletions src/test/components/StackChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
changeImplementationFilter,
changeCallTreeSummaryStrategy,
updatePreviewSelection,
changeCallTreeSearchString,
} from '../../actions/profile-view';
import { changeSelectedTab } from '../../actions/app';
import { selectedThreadSelectors } from '../../selectors/per-thread';
Expand Down Expand Up @@ -271,6 +272,56 @@ describe('StackChart', function () {
expect(drawnFrames).not.toContain('Z');
});

it('dims non-matching boxes when searching', function () {
const { dispatch, flushRafCalls } = setupSamples();
flushDrawLog();

// Dispatch a search string that matches some function names.
act(() => {
dispatch(changeCallTreeSearchString('B'));
});
flushRafCalls();

const drawCalls = flushDrawLog();

// Non-matching boxes should be drawn with the dimmed style.
const dimmedFillCalls = drawCalls.filter(
([fn, value]) => fn === 'set fillStyle' && value === '#f9f9fa'
);
expect(dimmedFillCalls.length).toBeGreaterThan(0);
});

it('does not dim boxes that match the search string', function () {
// Use a single-node call stack so there is exactly one box.
const { dispatch, flushRafCalls } = setupSamples(`
A[cat:DOM]
`);
flushDrawLog();

// Search for "A" — the only node matches, so nothing should be dimmed.
act(() => {
dispatch(changeCallTreeSearchString('A'));
});
flushRafCalls();

const drawCalls = flushDrawLog();
const dimmedFillCalls = drawCalls.filter(
([fn, value]) => fn === 'set fillStyle' && value === '#f9f9fa'
);
expect(dimmedFillCalls).toHaveLength(0);
});

it('does not dim any boxes when there is no search string', function () {
setupSamples();
const drawCalls = flushDrawLog();

// No dimmed fill should be applied without a search.
const dimmedFillCalls = drawCalls.filter(
([fn, value]) => fn === 'set fillStyle' && value === '#f9f9fa'
);
expect(dimmedFillCalls).toHaveLength(0);
});

describe('EmptyReasons', () => {
it('shows reasons when a profile has no samples', () => {
const profile = getEmptyProfile();
Expand Down
1 change: 0 additions & 1 deletion src/test/fixtures/mocks/canvas-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export type SetFillStyleOperation = ['set fillStyle', string];
export type FillRectOperation = ['fillRect', number, number, number, number];
export type ClearRectOperation = ['clearRect', number, number, number, number];
export type FillTextOperation = ['fillText', string];

export type DrawOperation =
| BeginPathOperation
| MoveToOperation
Expand Down
19 changes: 19 additions & 0 deletions src/utils/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
GREEN_50,
GREEN_60,
GREEN_70,
GREY_10,
GREY_20,
GREY_30,
GREY_40,
GREY_50,
GREY_60,
GREY_70,
GREY_80,
MAGENTA_60,
MAGENTA_70,
ORANGE_50,
Expand Down Expand Up @@ -209,6 +211,23 @@ export function mapCategoryColorNameToStackChartStyles(
return mapCategoryColorNameToStyles(colorName);
}

/**
* A neutral style used to dim non-matching nodes in the stack chart when a
* search filter is active. Closer to the background than any category color
* so that matching nodes stand out clearly.
*/
const DIMMED_STYLE: ColorStyles = {
...DEFAULT_STYLE,
_selectedFillStyle: [GREY_10, GREY_80],
_unselectedFillStyle: [GREY_10, GREY_80],
_selectedTextColor: [GREY_50, GREY_40],
gravity: 0,
};

export function getDimmedStyles(): ColorStyles {
return DIMMED_STYLE;
}

export function getForegroundColor(): string {
return lightDark('#000000', GREY_20);
}
Expand Down
Loading