From b7fd9c3068627c0331a2f43b9b88b203a8495cb8 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 31 Mar 2026 11:39:10 -0400 Subject: [PATCH 01/41] Implement column sorting in the generic TreeView. It's not used by any TreeView users yet. The next commit will use it for the marker table. The intention is to also use it for the function list. --- src/components/marker-table/index.tsx | 12 +- src/components/shared/TreeView.css | 27 +++- src/components/shared/TreeView.tsx | 193 ++++++++++++++++++++++-- src/profile-logic/call-tree.ts | 5 + src/profile-logic/zip-files.ts | 5 + src/test/unit/column-sort-state.test.ts | 170 +++++++++++++++++++++ 6 files changed, 391 insertions(+), 21 deletions(-) create mode 100644 src/test/unit/column-sort-state.test.ts diff --git a/src/components/marker-table/index.tsx b/src/components/marker-table/index.tsx index a02b10c06d..762f1ceef4 100644 --- a/src/components/marker-table/index.tsx +++ b/src/components/marker-table/index.tsx @@ -6,7 +6,7 @@ import { PureComponent } from 'react'; import memoize from 'memoize-immutable'; import explicitConnect from '../../utils/connect'; -import { TreeView } from '../shared/TreeView'; +import { type SortableColumn, type Tree, TreeView } from '../shared/TreeView'; import { MarkerTableEmptyReasons } from './MarkerTableEmptyReasons'; import { getZeroAt, @@ -50,7 +50,7 @@ type MarkerDisplayData = { details: string; }; -class MarkerTree { +class MarkerTree implements Tree { _getMarker: (param: MarkerIndex) => Marker; _markerIndexes: MarkerIndex[]; _zeroAt: Milliseconds; @@ -73,6 +73,10 @@ class MarkerTree { this._getMarkerLabel = getMarkerLabel; } + getSortableColumns(): SortableColumn[] { + return []; + } + copyTable = ( format: 'plain' | 'markdown', onExceeedMaxCopyRows: (rows: number, maxRows: number) => void @@ -180,7 +184,7 @@ class MarkerTree { } getAllDescendants() { - return new Set(); + return new Set(); } getParent(): MarkerIndex { @@ -366,7 +370,7 @@ class MarkerTableImpl extends PureComponent { ) : ( > = { }>; }; +export type SingleColumnSortState = { + column: string; + ascending: boolean; +}; + +export class ColumnSortState { + sortedColumns: SingleColumnSortState[]; + + constructor(sortedColumns: SingleColumnSortState[]) { + this.sortedColumns = sortedColumns; + } + + withToggledSortForColumn( + column: string, + prefersDescending: boolean + ): ColumnSortState { + const current = this.current(); + const sortedColumns = this.sortedColumns.filter((c) => c.column !== column); + + sortedColumns.push({ + column, + ascending: + current && current.column === column + ? !current.ascending + : !prefersDescending, + }); + return new ColumnSortState(sortedColumns); + } + + current(): SingleColumnSortState | null { + return this.sortedColumns.length > 0 + ? this.sortedColumns[this.sortedColumns.length - 1] + : null; + } + + /** + * Sort `items` by all columns in `sortedColumns`, with the last column being + * the primary key. `compareColumn(a, b, column)` must return the sign of + * (a - b) for that column — i.e. ascending order. Array.prototype.sort is + * stable, so earlier-listed columns act as tiebreakers. + */ + sortItemsHelper( + items: T[], + compareColumn: (a: T, b: T, column: string) => number + ): T[] { + const sorted = items.slice(); + for (const { column, ascending } of this.sortedColumns) { + const sign = ascending ? 1 : -1; + sorted.sort((a, b) => sign * compareColumn(a, b, column)); + } + return sorted; + } +} + export type MaybeResizableColumn> = Column & { /** defaults to initialWidth */ @@ -73,6 +127,9 @@ type TreeViewHeaderProps> = { // passes the column index and the start x coordinate readonly onColumnWidthChangeStart: (param: number, x: CssPixels) => void; readonly onColumnWidthReset: (param: number) => void; + readonly onToggleSortForColumn: (column: string) => void; + readonly currentSortedColumn: SingleColumnSortState | null; + readonly sortableColumns?: Set; }; class TreeViewHeader< @@ -91,8 +148,22 @@ class TreeViewHeader< ); }; + _onToggleSortForColumn = (e: React.MouseEvent) => { + const { onToggleSortForColumn } = this.props; + const target = e.currentTarget; + if (target instanceof HTMLElement && target.dataset.column) { + onToggleSortForColumn(target.dataset.column); + } + }; + override render() { - const { fixedColumns, mainColumn, viewOptions } = this.props; + const { + fixedColumns, + mainColumn, + viewOptions, + currentSortedColumn, + sortableColumns, + } = this.props; const columnWidths = viewOptions.fixedColumnWidths; if (fixedColumns.length === 0 && !mainColumn.titleL10nId) { // If there is nothing to display in the header, do not render it. @@ -102,14 +173,50 @@ class TreeViewHeader<
{fixedColumns.map((col, i) => { const width = columnWidths[i] + (col.headerWidthAdjustment || 0); + const isSortable = sortableColumns?.has(col.propName) ?? false; + let sortClass = ''; + let ariaSort: 'ascending' | 'descending' | 'none' | undefined; + if (isSortable) { + if ( + currentSortedColumn && + currentSortedColumn.column === col.propName + ) { + sortClass = currentSortedColumn.ascending + ? 'sortAscending' + : 'sortDescending'; + ariaSort = currentSortedColumn.ascending + ? 'ascending' + : 'descending'; + } else { + sortClass = 'sortInactive'; + ariaSort = 'none'; + } + } + const cellClassName = `treeViewHeaderColumn treeViewFixedColumn ${col.propName}`; return ( - - - + {isSortable ? ( + + + + ) : ( + + + + )} {col.hideDividerAfter !== true ? ( > { +export type SortableColumn = { + name: string; + prefersDescending: boolean; +}; + +export interface Tree> { getDepth(nodeIndex: NodeIndex): number; - getRoots(): NodeIndex[]; + getRoots(sort: ColumnSortState | null): NodeIndex[]; getDisplayData(nodeIndex: NodeIndex): DisplayData; getParent(nodeIndex: NodeIndex): NodeIndex; - getChildren(nodeIndex: NodeIndex): NodeIndex[]; + getChildren(nodeIndex: NodeIndex, sort: ColumnSortState | null): NodeIndex[]; hasChildren(nodeIndex: NodeIndex): boolean; getAllDescendants(nodeIndex: NodeIndex): Set; + + getSortableColumns(): SortableColumn[]; // constant } type TreeViewProps> = { @@ -460,6 +574,8 @@ type TreeViewProps> = { readonly onKeyDown?: (param: React.KeyboardEvent) => void; readonly viewOptions: TableViewOptions; readonly onViewOptionsChange?: (param: TableViewOptions) => void; + readonly sortedColumns?: ColumnSortState; + readonly onColumnSortChange?: (sortedColumns: ColumnSortState) => void; }; type TreeViewState = { @@ -467,6 +583,8 @@ type TreeViewState = { readonly isResizingColumns: boolean; }; +const EMPTY_SORT_STATE = new ColumnSortState([]); + export class TreeView< DisplayData extends Record, > extends React.PureComponent, TreeViewState> { @@ -482,7 +600,7 @@ export class TreeView< initialWidth: CssPixels; } | null = null; - override state = { + override state: TreeViewState = { // This contains the current widths, while or after the user resizes them. fixedColumnWidths: null, @@ -490,6 +608,10 @@ export class TreeView< isResizingColumns: false, }; + _getSortedColumns(): ColumnSortState { + return this.props.sortedColumns ?? EMPTY_SORT_STATE; + } + // This is incremented when a column changed its size. We use this to force a // rerender of the VirtualList component. _columnSizeChangedCounter: number = 0; @@ -519,6 +641,11 @@ export class TreeView< fixedColumns.map((c) => c.initialWidth) ); + _getSortableColumnNames = memoize( + (tree: Tree): Set => + new Set(tree.getSortableColumns().map((c) => c.name)) + ); + // This returns the column widths from several possible sources, in this order: // * the current state (this means the user changed them recently, or is // currently changing them) @@ -609,7 +736,11 @@ export class TreeView< }; _computeAllVisibleRowsMemoized = memoize( - (tree: Tree, expandedNodes: Set) => { + ( + tree: Tree, + expandedNodes: Set, + sortedColumns: ColumnSortState + ) => { function _addVisibleRowsFromNode( tree: Tree, expandedNodes: Set, @@ -620,13 +751,13 @@ export class TreeView< if (!expandedNodes.has(nodeId)) { return; } - const children = tree.getChildren(nodeId); + const children = tree.getChildren(nodeId, sortedColumns); for (let i = 0; i < children.length; i++) { _addVisibleRowsFromNode(tree, expandedNodes, arr, children[i]); } } - const roots = tree.getRoots(); + const roots = tree.getRoots(sortedColumns); const allRows: NodeIndex[] = []; for (let i = 0; i < roots.length; i++) { _addVisibleRowsFromNode(tree, expandedNodes, allRows, roots[i]); @@ -718,7 +849,11 @@ export class TreeView< _getAllVisibleRows(): NodeIndex[] { const { tree } = this.props; - return this._computeAllVisibleRowsMemoized(tree, this._getExpandedNodes()); + return this._computeAllVisibleRowsMemoized( + tree, + this._getExpandedNodes(), + this._getSortedColumns() + ); } _getSpecialItems(): [NodeIndex | void, NodeIndex | void] { @@ -906,7 +1041,10 @@ export class TreeView< // Do KEY_DOWN only if the next element is a child if (this.props.tree.hasChildren(selected)) { this._selectWithKeyboard( - this.props.tree.getChildren(selected)[0] + this.props.tree.getChildren( + selected, + this._getSortedColumns() + )[0] ); } } @@ -931,6 +1069,25 @@ export class TreeView< } }; + _onToggleSortForColumn = (column: string) => { + const { onColumnSortChange } = this.props; + if (!onColumnSortChange) { + return; + } + const sortableColumn = this.props.tree + .getSortableColumns() + .find((c) => c.name === column); + if (sortableColumn === undefined) { + return; + } + onColumnSortChange( + this._getSortedColumns().withToggledSortForColumn( + column, + sortableColumn.prefersDescending + ) + ); + }; + /* This method is used by users of this component. */ /* eslint-disable-next-line react/no-unused-class-component-methods */ focus() { @@ -951,6 +1108,7 @@ export class TreeView< selectedNodeId, } = this.props; const { isResizingColumns } = this.state; + const sortableColumns = this._getSortableColumnNames(this.props.tree); return (
{ + if (column === 'a') { + return x.a - y.a; + } + if (column === 'b') { + return x.b - y.b; + } + throw new Error(`unknown column ${column}`); + }; + + it('sorts stably across multiple criteria with the last column as primary', function () { + const items: Item[] = [ + { a: 1, b: 2 }, + { a: 1, b: 1 }, + { a: 0, b: 9 }, + ]; + // Primary: a ascending; tiebreaker: b ascending. Tiebreakers are listed + // first; the primary is last. + const state = new ColumnSortState([ + { column: 'b', ascending: true }, + { column: 'a', ascending: true }, + ]); + expect(state.sortItemsHelper(items, compareColumn)).toEqual([ + { a: 0, b: 9 }, + { a: 1, b: 1 }, + { a: 1, b: 2 }, + ]); + }); + + it('returns a copy of the input when sortedColumns is empty and does not mutate the input', function () { + const items: Item[] = [ + { a: 1, b: 2 }, + { a: 1, b: 1 }, + { a: 0, b: 9 }, + ]; + const inputSnapshot = items.slice(); + const state = new ColumnSortState([]); + const result = state.sortItemsHelper(items, compareColumn); + + expect(result).toEqual(inputSnapshot); + expect(result).not.toBe(items); + expect(items).toEqual(inputSnapshot); + }); + + it('sorts descending when ascending is false', function () { + const items: Item[] = [ + { a: 1, b: 0 }, + { a: 3, b: 0 }, + { a: 2, b: 0 }, + ]; + const state = new ColumnSortState([{ column: 'a', ascending: false }]); + expect(state.sortItemsHelper(items, compareColumn)).toEqual([ + { a: 3, b: 0 }, + { a: 2, b: 0 }, + { a: 1, b: 0 }, + ]); + }); + }); +}); From d17fade1ad445924beb05c095530674e4f77f1a1 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 31 Mar 2026 11:39:10 -0400 Subject: [PATCH 02/41] Implement column sorting in the marker table. The sort is also persisted in the URL. --- src/actions/profile-view.ts | 8 ++ src/app-logic/url-handling.ts | 62 ++++++++++++- src/components/marker-table/index.tsx | 88 +++++++++++++++++-- src/reducers/url-state.ts | 14 +++ src/selectors/url-state.ts | 3 + src/test/components/MarkerTable.test.tsx | 8 +- .../__snapshots__/MarkerTable.test.tsx.snap | 27 ++++-- src/test/url-handling.test.ts | 50 +++++++++++ src/types/actions.ts | 5 ++ src/types/state.ts | 2 + 10 files changed, 247 insertions(+), 20 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 51ff3dcdb4..ed06ca604c 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -82,6 +82,7 @@ import { import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { CallNodeInfo } from '../profile-logic/call-node-info'; +import type { SingleColumnSortState } from '../components/shared/TreeView'; import { intersectSets } from 'firefox-profiler/utils/set'; /** @@ -1615,6 +1616,13 @@ export function changeMarkersSearchString(searchString: string): Action { }; } +export function changeMarkerTableSort(sort: SingleColumnSortState[]): Action { + return { + type: 'CHANGE_MARKER_TABLE_SORT', + sort, + }; +} + export function changeNetworkSearchString(searchString: string): Action { return { type: 'CHANGE_NETWORK_SEARCH_STRING', diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 02b2907421..a8568da7a0 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -52,8 +52,9 @@ import { tabSlugs } from '../app-logic/tabs-handling'; import { StringTable } from 'firefox-profiler/utils/string-table'; import type { ProfileUpgradeInfo } from 'firefox-profiler/profile-logic/processed-profile-versioning'; import type { ProfileAndProfileUpgradeInfo } from 'firefox-profiler/actions/receive-profile'; +import type { SingleColumnSortState } from '../components/shared/TreeView'; -export const CURRENT_URL_VERSION = 16; +export const CURRENT_URL_VERSION = 17; /** * This static piece of state might look like an anti-pattern, but it's a relatively @@ -190,6 +191,7 @@ type CallTreeQuery = BaseQuery & { type MarkersQuery = BaseQuery & { markerSearch: string; // "DOMEvent" marker?: MarkerIndex; // Selected marker index for the current thread, e.g. 42 + markerSort?: string; // "duration:desc,start:asc" — primary first }; type NetworkQuery = BaseQuery & { @@ -228,6 +230,7 @@ type Query = BaseQuery & { // Markers specific markerSearch?: string; marker?: MarkerIndex; + markerSort?: string; // Network specific networkSearch?: string; @@ -394,6 +397,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { urlState.profileSpecific.selectedMarkers[selectedThreadsKey] !== null ? urlState.profileSpecific.selectedMarkers[selectedThreadsKey] : undefined; + query.markerSort = convertMarkerTableSortToString( + urlState.profileSpecific.markerTableSort + ); break; case 'network-chart': query = baseQuery as NetworkQueryShape; @@ -632,10 +638,59 @@ export function stateFromLocation( ? query.hiddenThreads.split('-').map((index) => Number(index)) : null, selectedMarkers, + markerTableSort: convertMarkerTableSortFromString(query.markerSort), }, }; } +// MarkerTable sort URL encoding. The internal ColumnSortState stores the +// primary-sorted column last (newest click wins as primary); the URL puts the +// primary first for human readability. +const VALID_MARKER_SORT_COLUMNS = new Set(['start', 'duration', 'name']); + +function convertMarkerTableSortToString( + sort: SingleColumnSortState[] +): string | undefined { + if (sort.length === 0) { + return undefined; + } + // Omit when it matches the marker table's own default. + if (sort.length === 1 && sort[0].column === 'start' && sort[0].ascending) { + return undefined; + } + return sort + .slice() + .reverse() + .map((s) => `${s.column}-${s.ascending ? 'asc' : 'desc'}`) + .join('~'); +} + +function convertMarkerTableSortFromString( + raw: string | null | void +): SingleColumnSortState[] { + if (!raw) { + return []; + } + const parsed: SingleColumnSortState[] = []; + for (const part of raw.split('~')) { + const dashIndex = part.lastIndexOf('-'); + if (dashIndex === -1) { + return []; + } + const column = part.slice(0, dashIndex); + const dir = part.slice(dashIndex + 1); + if ( + !VALID_MARKER_SORT_COLUMNS.has(column) || + (dir !== 'asc' && dir !== 'desc') + ) { + return []; + } + parsed.push({ column, ascending: dir === 'asc' }); + } + // URL is primary-first; internal storage is primary-last. + return parsed.reverse(); +} + function convertGlobalTrackOrderFromString( rawString: string | null | void ): TrackIndex[] { @@ -1443,6 +1498,11 @@ const _upgraders: { .join('~'); } }, + [17]: (_processedLocation: ProcessedLocationBeforeUpgrade) => { + // Adds the optional `markerSort` query parameter for the marker table. + // No migration is necessary: older URLs simply omit it and the default + // (sort by start ascending) is used. + }, }; /** diff --git a/src/components/marker-table/index.tsx b/src/components/marker-table/index.tsx index 762f1ceef4..66354ac0a3 100644 --- a/src/components/marker-table/index.tsx +++ b/src/components/marker-table/index.tsx @@ -6,7 +6,7 @@ import { PureComponent } from 'react'; import memoize from 'memoize-immutable'; import explicitConnect from '../../utils/connect'; -import { type SortableColumn, type Tree, TreeView } from '../shared/TreeView'; +import { ColumnSortState, TreeView } from '../shared/TreeView'; import { MarkerTableEmptyReasons } from './MarkerTableEmptyReasons'; import { getZeroAt, @@ -15,11 +15,15 @@ import { getCurrentTableViewOptions, } from '../../selectors/profile'; import { selectedThreadSelectors } from '../../selectors/per-thread'; -import { getSelectedThreadsKey } from '../../selectors/url-state'; +import { + getSelectedThreadsKey, + getMarkerTableSort, +} from '../../selectors/url-state'; import { changeSelectedMarker, changeRightClickedMarker, changeTableViewOptions, + changeMarkerTableSort, } from '../../actions/profile-view'; import { MarkerSettings } from '../shared/MarkerSettings'; import { formatSeconds, formatTimestamp } from '../../utils/format-numbers'; @@ -37,12 +41,21 @@ import type { TableViewOptions, SelectionContext, } from 'firefox-profiler/types'; +import type { + SingleColumnSortState, + Tree, + SortableColumn, +} from '../shared/TreeView'; import type { ConnectedProps } from '../../utils/connect'; // Limit how many characters in the description get sent to the DOM. const MAX_DESCRIPTION_CHARACTERS = 500; +const DEFAULT_MARKER_TABLE_SORT: SingleColumnSortState[] = [ + { column: 'start', ascending: true }, +]; + type MarkerDisplayData = { start: string; duration: string | null; @@ -73,8 +86,14 @@ class MarkerTree implements Tree { this._getMarkerLabel = getMarkerLabel; } + static _sortableColumns: SortableColumn[] = [ + { name: 'start', prefersDescending: false }, + { name: 'duration', prefersDescending: true }, + { name: 'name', prefersDescending: false }, + ]; + getSortableColumns(): SortableColumn[] { - return []; + return MarkerTree._sortableColumns; } copyTable = ( @@ -171,12 +190,49 @@ class MarkerTree implements Tree { copy(text); }; - getRoots(): MarkerIndex[] { + getRoots(sort: ColumnSortState | null = null): MarkerIndex[] { + if (sort !== null) { + return sort.sortItemsHelper( + this._markerIndexes, + (first: MarkerIndex, second: MarkerIndex, column: string) => { + const firstValue = this._getSortValueForColumn(first, column); + const secondValue = this._getSortValueForColumn(second, column); + if (typeof firstValue === 'string') { + return firstValue.localeCompare(secondValue as string); + } + return (firstValue as number) - (secondValue as number); + } + ); + } return this._markerIndexes; } - getChildren(markerIndex: MarkerIndex): MarkerIndex[] { - return markerIndex === -1 ? this.getRoots() : []; + getChildren( + markerIndex: MarkerIndex, + sort: ColumnSortState | null = null + ): MarkerIndex[] { + return markerIndex === -1 ? this.getRoots(sort) : []; + } + + _getSortValueForColumn( + markerIndex: MarkerIndex, + column: string + ): string | number { + const marker = this._getMarker(markerIndex); + switch (column) { + case 'start': + return marker.start; + case 'duration': { + if (marker.incomplete || marker.end === null) { + return -Infinity; + } + return marker.end - marker.start; + } + case 'name': + return marker.name; + default: + throw new Error('Invalid column ' + column); + } } hasChildren(_markerIndex: MarkerIndex): boolean { @@ -209,10 +265,11 @@ class MarkerTree implements Tree { } let duration = null; + const markerEnd = marker.end; if (marker.incomplete) { duration = 'unknown'; - } else if (marker.end !== null) { - duration = formatTimestamp(marker.end - marker.start); + } else if (markerEnd !== null) { + duration = formatTimestamp(markerEnd - marker.start); } displayData = { @@ -242,12 +299,14 @@ type StateProps = { readonly markerSchemaByName: MarkerSchemaByName; readonly getMarkerLabel: (param: MarkerIndex) => string; readonly tableViewOptions: TableViewOptions; + readonly sort: SingleColumnSortState[]; }; type DispatchProps = { readonly changeSelectedMarker: typeof changeSelectedMarker; readonly changeRightClickedMarker: typeof changeRightClickedMarker; readonly onTableViewOptionsChange: (param: TableViewOptions) => any; + readonly changeMarkerTableSort: typeof changeMarkerTableSort; }; type Props = ConnectedProps<{}, StateProps, DispatchProps>; @@ -284,6 +343,11 @@ class MarkerTableImpl extends PureComponent { this._treeView = treeView; }; + _getSortedColumns = memoize( + (sort: SingleColumnSortState[]) => + new ColumnSortState(sort.length > 0 ? sort : DEFAULT_MARKER_TABLE_SORT) + ); + getMarkerTree = memoize( ( getMarker: any, @@ -335,6 +399,10 @@ class MarkerTableImpl extends PureComponent { changeSelectedMarker(threadsKey, selectedMarker, context); }; + _onColumnSortChange = (sortedColumns: ColumnSortState) => { + this.props.changeMarkerTableSort(sortedColumns.sortedColumns); + }; + _onRightClickSelection = (selectedMarker: MarkerIndex) => { const { threadsKey, changeRightClickedMarker } = this.props; changeRightClickedMarker(threadsKey, selectedMarker); @@ -385,6 +453,8 @@ class MarkerTableImpl extends PureComponent { indentWidth={10} viewOptions={this.props.tableViewOptions} onViewOptionsChange={this.props.onTableViewOptionsChange} + sortedColumns={this._getSortedColumns(this.props.sort)} + onColumnSortChange={this._onColumnSortChange} /> )}
@@ -405,12 +475,14 @@ export const MarkerTable = explicitConnect<{}, StateProps, DispatchProps>({ markerSchemaByName: getMarkerSchemaByName(state), getMarkerLabel: selectedThreadSelectors.getMarkerTableLabelGetter(state), tableViewOptions: getCurrentTableViewOptions(state), + sort: getMarkerTableSort(state), }), mapDispatchToProps: { changeSelectedMarker, changeRightClickedMarker, onTableViewOptionsChange: (tableViewOptions) => changeTableViewOptions('marker-table', tableViewOptions), + changeMarkerTableSort, }, component: MarkerTableImpl, }); diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 085244f0cc..5c162859f7 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -27,6 +27,7 @@ import type { } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; +import type { SingleColumnSortState } from '../components/shared/TreeView'; import { translateThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; import { translateTransformStack } from 'firefox-profiler/profile-logic/transforms'; @@ -193,6 +194,18 @@ const markersSearchString: Reducer = (state = '', action) => { } }; +const markerTableSort: Reducer = ( + state = [], + action +) => { + switch (action.type) { + case 'CHANGE_MARKER_TABLE_SORT': + return action.sort; + default: + return state; + } +}; + const networkSearchString: Reducer = (state = '', action) => { switch (action.type) { case 'CHANGE_NETWORK_SEARCH_STRING': @@ -793,6 +806,7 @@ const profileSpecific = combineReducers({ showJsTracerSummary, tabFilter, selectedMarkers, + markerTableSort, // The timeline tracks used to be hidden and sorted by thread indexes, rather than // track indexes. The only way to migrate this information to tracks-based data is to // first retrieve the profile, so they can't be upgraded by the normal url upgrading diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index bf2bc8fe65..80c9733a3c 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -36,6 +36,7 @@ import type { import type { TabSlug } from '../app-logic/tabs-handling'; import type { MarkerRegExps } from '../profile-logic/marker-data'; +import type { SingleColumnSortState } from '../components/shared/TreeView'; import urlStateReducer from '../reducers/url-state'; import { formatMetaInfoString } from '../profile-logic/profile-metainfo'; @@ -117,6 +118,8 @@ export const getCurrentSearchString: Selector = (state) => getProfileSpecificState(state).callTreeSearchString; export const getMarkersSearchString: Selector = (state) => getProfileSpecificState(state).markersSearchString; +export const getMarkerTableSort: Selector = (state) => + getProfileSpecificState(state).markerTableSort; export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => diff --git a/src/test/components/MarkerTable.test.tsx b/src/test/components/MarkerTable.test.tsx index 39acc17e6b..7de8a582dc 100644 --- a/src/test/components/MarkerTable.test.tsx +++ b/src/test/components/MarkerTable.test.tsx @@ -478,7 +478,9 @@ describe('MarkerTable', function () { let dividerForFirstColumn = ensureExists( document.querySelector('.treeViewHeaderColumnDivider') ); - let firstColumn = screen.getByText('Start'); + let firstColumn = ensureExists( + document.querySelector('.treeViewHeaderColumn.start') + ); expect(firstColumn).toHaveStyle({ width: '95px' }); fireEvent.mouseDown(dividerForFirstColumn, { clientX: 95 }); @@ -505,7 +507,9 @@ describe('MarkerTable', function () { ); // Make sure the first column kept its width - firstColumn = screen.getByText('Start'); + firstColumn = ensureExists( + document.querySelector('.treeViewHeaderColumn.start') + ); expect(firstColumn).toHaveStyle({ width: '80px' }); // Now double click to reset the style. diff --git a/src/test/components/__snapshots__/MarkerTable.test.tsx.snap b/src/test/components/__snapshots__/MarkerTable.test.tsx.snap index df20a0b201..0558e43a1b 100644 --- a/src/test/components/__snapshots__/MarkerTable.test.tsx.snap +++ b/src/test/components/__snapshots__/MarkerTable.test.tsx.snap @@ -205,32 +205,41 @@ exports[`MarkerTable renders some basic markers and updates when needed 1`] = `
- Start - + - Duration - + - Name - + Date: Tue, 31 Mar 2026 11:39:10 -0400 Subject: [PATCH 03/41] Split getSelfAndTotal. --- src/profile-logic/call-tree.ts | 41 +++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 4c39a53158..88c1e0ae91 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -106,7 +106,8 @@ interface CallTreeInternal { hasChildren(callNodeIndex: IndexIntoCallNodeTable): boolean; createChildren(nodeIndex: IndexIntoCallNodeTable): CallNodeChildren; createRoots(): CallNodeChildren; - getSelfAndTotal(nodeIndex: IndexIntoCallNodeTable): SelfAndTotal; + getSelf(nodeIndex: IndexIntoCallNodeTable): number; + getTotal(nodeIndex: IndexIntoCallNodeTable): number; findHeaviestPathInSubtree( callNodeIndex: IndexIntoCallNodeTable ): CallNodePath; @@ -172,10 +173,12 @@ export class CallTreeInternalNonInverted implements CallTreeInternal { return this._callNodeHasChildren[callNodeIndex] !== 0; } - getSelfAndTotal(callNodeIndex: IndexIntoCallNodeTable): SelfAndTotal { - const self = this._callTreeTimings.self[callNodeIndex]; - const total = this._callTreeTimings.total[callNodeIndex]; - return { self, total }; + getSelf(callNodeIndex: IndexIntoCallNodeTable): number { + return this._callTreeTimings.self[callNodeIndex]; + } + + getTotal(callNodeIndex: IndexIntoCallNodeTable): number { + return this._callTreeTimings.total[callNodeIndex]; } findHeaviestPathInSubtree( @@ -217,11 +220,12 @@ export class CallTreeInternalFunctionList implements CallTreeInternal { return this._timings.sortedFuncs; } - getSelfAndTotal(nodeIndex: IndexIntoCallNodeTable): SelfAndTotal { - return { - self: this._timings.funcSelf[nodeIndex], - total: this._timings.funcTotal[nodeIndex], - }; + getSelf(nodeIndex: IndexIntoCallNodeTable): number { + return this._timings.funcSelf[nodeIndex]; + } + + getTotal(nodeIndex: IndexIntoCallNodeTable): number { + return this._timings.funcTotal[nodeIndex]; } findHeaviestPathInSubtree( @@ -289,13 +293,19 @@ class CallTreeInternalInverted implements CallTreeInternal { return children; } - getSelfAndTotal(callNodeIndex: IndexIntoCallNodeTable): SelfAndTotal { + getSelf(callNodeIndex: IndexIntoCallNodeTable): number { + if (this._callNodeInfo.isRoot(callNodeIndex)) { + return this._totalPerRootFunc[callNodeIndex]; + } + return 0; + } + + getTotal(callNodeIndex: IndexIntoCallNodeTable): number { if (this._callNodeInfo.isRoot(callNodeIndex)) { - const total = this._totalPerRootFunc[callNodeIndex]; - return { self: total, total }; + return this._totalPerRootFunc[callNodeIndex]; } const { total } = this._getTotalAndHasChildren(callNodeIndex); - return { self: 0, total }; + return total; } _getTotalAndHasChildren( @@ -450,7 +460,8 @@ export class CallTree { this._thread.funcTable.name[funcIndex] ); - const { self, total } = this._internal.getSelfAndTotal(callNodeIndex); + const total = this._internal.getTotal(callNodeIndex); + const self = this._internal.getSelf(callNodeIndex); const totalRelative = total / this._rootTotalSummary; const selfRelative = self / this._rootTotalSummary; From f8fd647313db26ca998ab07019ff67b9b1f219db Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 27 Nov 2024 13:20:00 -0500 Subject: [PATCH 04/41] Add a function list panel. lint fix --- locales/en-US/app.ftl | 1 + res/css/style.css | 3 +- src/actions/profile-view.ts | 35 +++ src/app-logic/tabs-handling.ts | 2 + src/app-logic/url-handling.ts | 60 ++++ src/components/app/Details.tsx | 2 + src/components/calltree/CallTree.css | 6 +- src/components/calltree/CallTree.tsx | 100 +------ src/components/calltree/FunctionList.tsx | 277 ++++++++++++++++++ .../calltree/ProfileFunctionListView.tsx | 20 ++ src/components/calltree/columns.ts | 215 ++++++++++++++ src/components/sidebar/index.tsx | 1 + src/profile-logic/call-tree.ts | 38 ++- src/profile-logic/profile-data.ts | 94 ++++++ src/reducers/profile-view.ts | 69 +++++ src/reducers/url-state.ts | 13 + src/selectors/per-thread/index.ts | 106 +++++++ src/selectors/per-thread/stack-sample.ts | 28 ++ src/selectors/url-state.ts | 2 + src/test/components/Details.test.tsx | 3 + src/test/components/DetailsContainer.test.tsx | 1 + .../__snapshots__/profile-view.test.ts.snap | 1 + src/test/store/icons.test.ts | 1 + src/test/store/useful-tabs.test.ts | 4 + src/test/unit/profile-tree.test.ts | 2 + src/test/url-handling.test.ts | 52 ++++ src/types/actions.ts | 16 + src/types/profile-derived.ts | 1 + src/types/state.ts | 9 + src/utils/types.ts | 1 + 30 files changed, 1065 insertions(+), 98 deletions(-) create mode 100644 src/components/calltree/FunctionList.tsx create mode 100644 src/components/calltree/ProfileFunctionListView.tsx create mode 100644 src/components/calltree/columns.ts diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 5625f479b5..414842d164 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -900,6 +900,7 @@ StackSettings--panel-search = ## Tab Bar for the bottom half of the analysis UI. TabBar--calltree-tab = Call Tree +TabBar--function-list-tab = Function List TabBar--flame-graph-tab = Flame Graph TabBar--stack-chart-tab = Stack Chart TabBar--marker-chart-tab = Marker Chart diff --git a/res/css/style.css b/res/css/style.css index fac5320156..ca0c17e3c5 100644 --- a/res/css/style.css +++ b/res/css/style.css @@ -57,7 +57,8 @@ body { flex-shrink: 1; } -.treeAndSidebarWrapper { +.treeAndSidebarWrapper, +.functionTableAndSidebarWrapper { display: flex; flex: 1; flex-flow: column nowrap; diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index ed06ca604c..16483c30bc 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -74,6 +74,7 @@ import type { TableViewOptions, SelectionContext, BottomBoxInfo, + IndexIntoFuncTable, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, @@ -130,6 +131,22 @@ export function changeSelectedCallNode( }; } +/** + * Select a function for a given thread in the function list. + */ +export function changeSelectedFunctionIndex( + threadsKey: ThreadsKey, + selectedFunctionIndex: IndexIntoFuncTable | null, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_FUNCTION', + selectedFunctionIndex, + threadsKey, + context, + }; +} + /** * This action is used when the user right clicks on a call node (in panels such * as the call tree, the flame chart, or the stack chart). It's especially used @@ -146,6 +163,17 @@ export function changeRightClickedCallNode( }; } +export function changeRightClickedFunctionIndex( + threadsKey: ThreadsKey, + functionIndex: IndexIntoFuncTable | null +) { + return { + type: 'CHANGE_RIGHT_CLICKED_FUNCTION', + threadsKey, + functionIndex, + }; +} + /** * Given a threadIndex and a sampleIndex, select the call node which carries the * sample's self time. In the inverted tree, this will be a root node. @@ -1623,6 +1651,13 @@ export function changeMarkerTableSort(sort: SingleColumnSortState[]): Action { }; } +export function changeFunctionListSort(sort: SingleColumnSortState[]): Action { + return { + type: 'CHANGE_FUNCTION_LIST_SORT', + sort, + }; +} + export function changeNetworkSearchString(searchString: string): Action { return { type: 'CHANGE_NETWORK_SEARCH_STRING', diff --git a/src/app-logic/tabs-handling.ts b/src/app-logic/tabs-handling.ts index 3e2f205c3b..20f3742b48 100644 --- a/src/app-logic/tabs-handling.ts +++ b/src/app-logic/tabs-handling.ts @@ -9,6 +9,7 @@ */ export const tabsWithTitleL10nId = { calltree: 'TabBar--calltree-tab', + 'function-list': 'TabBar--function-list-tab', 'flame-graph': 'TabBar--flame-graph-tab', 'stack-chart': 'TabBar--stack-chart-tab', 'marker-chart': 'TabBar--marker-chart-tab', @@ -41,6 +42,7 @@ export const tabsWithTitleL10nIdArray: readonly TabsWithTitleL10nId[] = export const tabsShowingSampleData: readonly TabSlug[] = [ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', ]; diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index a8568da7a0..01adaa14a7 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -186,6 +186,7 @@ type CallTreeQuery = BaseQuery & { invertCallstack: null | undefined; hideIdleSamples: null | undefined; ctSummary: string; + functionListSort?: string; // "total-desc~self-asc" — primary first }; type MarkersQuery = BaseQuery & { @@ -232,6 +233,9 @@ type Query = BaseQuery & { marker?: MarkerIndex; markerSort?: string; + // Function list specific + functionListSort?: string; + // Network specific networkSearch?: string; @@ -338,6 +342,7 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { : undefined; /* fallsthrough */ case 'flame-graph': + case 'function-list': case 'calltree': { query = baseQuery as CallTreeQueryShape; @@ -385,6 +390,11 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.bottomFullscreen = true; } } + if (selectedTab === 'function-list') { + query.functionListSort = convertFunctionListSortToString( + urlState.profileSpecific.functionListSort + ); + } break; } case 'marker-table': @@ -639,6 +649,9 @@ export function stateFromLocation( : null, selectedMarkers, markerTableSort: convertMarkerTableSortFromString(query.markerSort), + functionListSort: convertFunctionListSortFromString( + query.functionListSort + ), }, }; } @@ -691,6 +704,53 @@ function convertMarkerTableSortFromString( return parsed.reverse(); } +// FunctionList sort URL encoding. Same convention as the marker table: +// internal storage is primary-last, URL is primary-first. +const VALID_FUNCTION_LIST_SORT_COLUMNS = new Set(['total', 'self']); + +function convertFunctionListSortToString( + sort: SingleColumnSortState[] +): string | undefined { + if (sort.length === 0) { + return undefined; + } + // Omit when it matches the function list's own default (total descending). + if (sort.length === 1 && sort[0].column === 'total' && !sort[0].ascending) { + return undefined; + } + return sort + .slice() + .reverse() + .map((s) => `${s.column}-${s.ascending ? 'asc' : 'desc'}`) + .join('~'); +} + +function convertFunctionListSortFromString( + raw: string | null | void +): SingleColumnSortState[] { + if (!raw) { + return []; + } + const parsed: SingleColumnSortState[] = []; + for (const part of raw.split('~')) { + const dashIndex = part.lastIndexOf('-'); + if (dashIndex === -1) { + return []; + } + const column = part.slice(0, dashIndex); + const dir = part.slice(dashIndex + 1); + if ( + !VALID_FUNCTION_LIST_SORT_COLUMNS.has(column) || + (dir !== 'asc' && dir !== 'desc') + ) { + return []; + } + parsed.push({ column, ascending: dir === 'asc' }); + } + // URL is primary-first; internal storage is primary-last. + return parsed.reverse(); +} + function convertGlobalTrackOrderFromString( rawString: string | null | void ): TrackIndex[] { diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index 643e6c5929..c571fd6945 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -10,6 +10,7 @@ import explicitConnect from 'firefox-profiler/utils/connect'; import { TabBar } from './TabBar'; import { LocalizedErrorBoundary } from './ErrorBoundary'; import { ProfileCallTreeView } from 'firefox-profiler/components/calltree/ProfileCallTreeView'; +import { ProfileFunctionListView } from 'firefox-profiler/components/calltree/ProfileFunctionListView'; import { MarkerTable } from 'firefox-profiler/components/marker-table'; import { StackChart } from 'firefox-profiler/components/stack-chart/'; import { MarkerChart } from 'firefox-profiler/components/marker-chart/'; @@ -122,6 +123,7 @@ class ProfileViewerImpl extends PureComponent { { { calltree: , + 'function-list': , 'flame-graph': , 'stack-chart': , 'marker-chart': , diff --git a/src/components/calltree/CallTree.css b/src/components/calltree/CallTree.css index 73e7f3935c..2efc3e8b38 100644 --- a/src/components/calltree/CallTree.css +++ b/src/components/calltree/CallTree.css @@ -7,8 +7,9 @@ text-align: right; } -/* The header for the totalPercent column is not visible */ -.treeViewHeaderColumn.totalPercent { +/* The headers for the percent columns are not visible */ +.treeViewHeaderColumn.totalPercent, +.treeViewHeaderColumn.selfPercent { display: none; } @@ -26,6 +27,7 @@ .treeViewRowColumn.total, .treeViewRowColumn.totalPercent, .treeViewRowColumn.self, +.treeViewRowColumn.selfPercent, .treeViewRowColumn.timestamp { text-align: right; } diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index 401247eee5..3449a34389 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -6,10 +6,8 @@ import memoize from 'memoize-immutable'; import explicitConnect from 'firefox-profiler/utils/connect'; import { TreeView } from 'firefox-profiler/components/shared/TreeView'; import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; -import { Icon } from 'firefox-profiler/components/shared/Icon'; import { getInvertCallstack, - getImplementationFilter, getSearchStringsAsRegExp, getSelectedThreadsKey, } from 'firefox-profiler/selectors/url-state'; @@ -34,7 +32,6 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import type { State, - ImplementationFilter, ThreadsKey, IndexIntoCategoryList, IndexIntoCallNodeTable, @@ -53,6 +50,11 @@ import type { import type { ConnectedProps } from 'firefox-profiler/utils/connect'; import './CallTree.css'; +import { + treeColumnsForBytes, + treeColumnsForSamples, + treeColumnsForTracingMs, +} from './columns'; type StateProps = { readonly threadsKey: ThreadsKey; @@ -67,7 +69,6 @@ type StateProps = { readonly searchStringsRegExp: RegExp | null; readonly disableOverscan: boolean; readonly invertCallstack: boolean; - readonly implementationFilter: ImplementationFilter; readonly callNodeMaxDepthPlusOne: number; readonly weightType: WeightType; readonly tableViewOptions: TableViewOptions; @@ -107,95 +108,11 @@ class CallTreeImpl extends PureComponent { (weightType: WeightType): MaybeResizableColumn[] => { switch (weightType) { case 'tracing-ms': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 55, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--tracing-ms-total', - minWidth: 30, - initialWidth: 70, - resizable: true, - headerWidthAdjustment: 55 /* totalPercent initialWidth */, - }, - { - propName: 'self', - titleL10nId: 'CallTree--tracing-ms-self', - minWidth: 40, - initialWidth: 80, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 20, - }, - ]; + return treeColumnsForTracingMs; case 'samples': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 55, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--samples-total', - minWidth: 30, - initialWidth: 70, - resizable: true, - headerWidthAdjustment: 55 /* totalPercent initialWidth */, - }, - { - propName: 'self', - titleL10nId: 'CallTree--samples-self', - minWidth: 40, - initialWidth: 80, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 20, - }, - ]; + return treeColumnsForSamples; case 'bytes': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 55, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--bytes-total', - minWidth: 30, - initialWidth: 140, - resizable: true, - headerWidthAdjustment: 55 /* totalPercent initialWidth */, - }, - { - propName: 'self', - titleL10nId: 'CallTree--bytes-self', - minWidth: 40, - initialWidth: 100, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 20, - }, - ]; + return treeColumnsForBytes; default: throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); } @@ -409,7 +326,6 @@ export const CallTree = explicitConnect<{}, StateProps, DispatchProps>({ searchStringsRegExp: getSearchStringsAsRegExp(state), disableOverscan: getPreviewSelectionIsBeingModified(state), invertCallstack: getInvertCallstack(state), - implementationFilter: getImplementationFilter(state), // Use the filtered call node max depth, rather than the preview filtered call node // max depth so that the width of the TreeView component is stable across preview // selections. diff --git a/src/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx new file mode 100644 index 0000000000..c54f6e01b7 --- /dev/null +++ b/src/components/calltree/FunctionList.tsx @@ -0,0 +1,277 @@ +/* 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 { PureComponent } from 'react'; +import memoize from 'memoize-immutable'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + TreeView, + ColumnSortState, +} from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, + getFunctionListSort, +} from 'firefox-profiler/selectors/url-state'; +import { + getScrollToSelectionGeneration, + getFocusCallTreeGeneration, + getCurrentTableViewOptions, + getPreviewSelectionIsBeingModified, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + changeRightClickedFunctionIndex, + changeSelectedFunctionIndex, + addTransformToStack, + changeTableViewOptions, + updateBottomBoxContentsAndMaybeOpen, + changeFunctionListSort, +} from 'firefox-profiler/actions/profile-view'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { + functionListColumnsForTracingMs, + functionListColumnsForSamples, + functionListColumnsForBytes, +} from './columns'; + +import type { + State, + ThreadsKey, + IndexIntoFuncTable, + CallNodeDisplayData, + WeightType, + TableViewOptions, + SelectionContext, +} from 'firefox-profiler/types'; +import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; + +import type { + Column, + MaybeResizableColumn, + SingleColumnSortState, +} from 'firefox-profiler/components/shared/TreeView'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallTree.css'; + +const DEFAULT_FUNCTION_LIST_SORT: SingleColumnSortState[] = [ + { column: 'total', ascending: false }, +]; + +type StateProps = { + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly focusCallTreeGeneration: number; + readonly tree: CallTree; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; + readonly searchStringsRegExp: RegExp | null; + readonly disableOverscan: boolean; + readonly weightType: WeightType; + readonly tableViewOptions: TableViewOptions; + readonly sort: SingleColumnSortState[]; +}; + +type DispatchProps = { + readonly changeSelectedFunctionIndex: typeof changeSelectedFunctionIndex; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; + readonly addTransformToStack: typeof addTransformToStack; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly onTableViewOptionsChange: (opts: TableViewOptions) => any; + readonly changeFunctionListSort: typeof changeFunctionListSort; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class FunctionListImpl extends PureComponent { + _mainColumn: Column = { + propName: 'name', + titleL10nId: '', + }; + _appendageColumn: Column = { + propName: 'lib', + titleL10nId: '', + }; + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView) => + (this._treeView = treeView); + + _expandedIndexes: Array = []; + + _getSortedColumns = memoize( + (sort: SingleColumnSortState[]) => + new ColumnSortState(sort.length > 0 ? sort : DEFAULT_FUNCTION_LIST_SORT) + ); + + _onColumnSortChange = (sortedColumns: ColumnSortState) => { + this.props.changeFunctionListSort(sortedColumns.sortedColumns); + }; + + /** + * Call Trees can have different types of "weights" for the data. Choose the + * appropriate labels for the call tree based on this weight. + */ + _weightTypeToColumns = memoize( + (weightType: WeightType): MaybeResizableColumn[] => { + switch (weightType) { + case 'tracing-ms': + return functionListColumnsForTracingMs; + case 'samples': + return functionListColumnsForSamples; + case 'bytes': + return functionListColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } + }, + // Use a Map cache, as the function only takes one argument, which is a simple string. + { cache: new Map() } + ); + + override componentDidMount() { + this.focus(); + + if (this.props.selectedFunctionIndex !== null && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + } + + override componentDidUpdate(prevProps: Props) { + if ( + this.props.focusCallTreeGeneration > prevProps.focusCallTreeGeneration + ) { + this.focus(); + } + + if ( + this.props.selectedFunctionIndex !== null && + this.props.scrollToSelectionGeneration > + prevProps.scrollToSelectionGeneration && + this._treeView + ) { + this._treeView.scrollSelectionIntoView(); + } + } + + focus() { + if (this._treeView) { + this._treeView.focus(); + } + } + + _onSelectionChange = ( + newSelectedFunction: IndexIntoFuncTable, + context: SelectionContext + ) => { + const { threadsKey, changeSelectedFunctionIndex } = this.props; + changeSelectedFunctionIndex(threadsKey, newSelectedFunction, context); + }; + + _onRightClickSelection = (newSelectedFunction: IndexIntoFuncTable) => { + const { threadsKey, changeRightClickedFunctionIndex } = this.props; + changeRightClickedFunctionIndex(threadsKey, newSelectedFunction); + }; + + _onExpandedCallNodesChange = ( + _newExpandedCallNodeIndexes: Array + ) => {}; + + _onKeyDown = (_event: React.KeyboardEvent) => { + // const { + // selectedFunctionIndex, + // rightClickedFunctionIndex, + // threadsKey, + // } = this.props; + // const nodeIndex = + // rightClickedFunctionIndex !== null + // ? rightClickedFunctionIndex + // : selectedFunctionIndex; + // if (nodeIndex === null) { + // return; + // } + // handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + }; + + _onEnterOrDoubleClick = (_nodeId: IndexIntoFuncTable) => { + // const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + // const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); + // updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + }; + + override render() { + const { + tree, + selectedFunctionIndex, + rightClickedFunctionIndex, + searchStringsRegExp, + disableOverscan, + weightType, + tableViewOptions, + onTableViewOptionsChange, + sort, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } +} + +export const FunctionList = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State) => ({ + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + focusCallTreeGeneration: getFocusCallTreeGeneration(state), + tree: selectedThreadSelectors.getFunctionListTree(state), + selectedFunctionIndex: + selectedThreadSelectors.getSelectedFunctionIndex(state), + rightClickedFunctionIndex: + selectedThreadSelectors.getRightClickedFunctionIndex(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + sort: getFunctionListSort(state), + }), + mapDispatchToProps: { + changeSelectedFunctionIndex, + changeRightClickedFunctionIndex, + addTransformToStack, + updateBottomBoxContentsAndMaybeOpen, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + changeFunctionListSort, + }, + component: FunctionListImpl, +}); diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx new file mode 100644 index 0000000000..ab1d348eaf --- /dev/null +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -0,0 +1,20 @@ +/* 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/. */ + +import { FunctionList } from './FunctionList'; +import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; +import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; + +export const ProfileFunctionListView = () => ( +
+ + + +
+); diff --git a/src/components/calltree/columns.ts b/src/components/calltree/columns.ts new file mode 100644 index 0000000000..b87d83d983 --- /dev/null +++ b/src/components/calltree/columns.ts @@ -0,0 +1,215 @@ +/* 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/. */ + +import { Icon } from 'firefox-profiler/components/shared/Icon'; + +import type { MaybeResizableColumn } from 'firefox-profiler/components/shared/TreeView'; +import type { CallNodeDisplayData } from 'firefox-profiler/types'; + +export const treeColumnsForTracingMs: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--tracing-ms-total', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'self', + titleL10nId: 'CallTree--tracing-ms-self', + minWidth: 40, + initialWidth: 80, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export const treeColumnsForSamples: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--samples-total', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'self', + titleL10nId: 'CallTree--samples-self', + minWidth: 40, + initialWidth: 80, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export const treeColumnsForBytes: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--bytes-total', + minWidth: 30, + initialWidth: 140, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'self', + titleL10nId: 'CallTree--bytes-self', + minWidth: 40, + initialWidth: 100, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export const functionListColumnsForTracingMs: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--tracing-ms-total', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'selfPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'self', + titleL10nId: 'CallTree--tracing-ms-self', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55 /* selfPercent initialWidth */, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export const functionListColumnsForSamples: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--samples-total', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'selfPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'self', + titleL10nId: 'CallTree--samples-self', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55 /* selfPercent initialWidth */, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export const functionListColumnsForBytes: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--bytes-total', + minWidth: 30, + initialWidth: 140, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'selfPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'self', + titleL10nId: 'CallTree--bytes-self', + minWidth: 30, + initialWidth: 90, + resizable: true, + headerWidthAdjustment: 55 /* selfPercent initialWidth */, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx index a8115372a3..e4a6901069 100644 --- a/src/components/sidebar/index.tsx +++ b/src/components/sidebar/index.tsx @@ -15,6 +15,7 @@ export function selectSidebar( ): React.ComponentType<{}> | null { return { calltree: CallTreeSidebar, + 'function-list': CallTreeSidebar, 'flame-graph': CallTreeSidebar, 'stack-chart': null, 'marker-chart': null, diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 88c1e0ae91..1a22e34d5e 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -38,7 +38,10 @@ import { checkBit } from '../utils/bitset'; import * as ProfileData from './profile-data'; import type { CallTreeSummaryStrategy } from '../types/actions'; import type { CallNodeInfo, CallNodeInfoInverted } from './call-node-info'; -import type { SortableColumn } from '../components/shared/TreeView'; +import type { + ColumnSortState, + SortableColumn, +} from '../components/shared/TreeView'; import { getBottomBoxInfoForCallNode } from './bottom-box'; type CallNodeChildren = IndexIntoCallNodeTable[]; @@ -402,6 +405,12 @@ export class CallTree { } getSortableColumns(): SortableColumn[] { + if (this._internal instanceof CallTreeInternalFunctionList) { + return [ + { name: 'total', prefersDescending: true }, + { name: 'self', prefersDescending: true }, + ]; + } return []; } @@ -409,7 +418,28 @@ export class CallTree { return this._rootTotalSummary; } - getRoots() { + getRoots(sort: ColumnSortState | null = null): IndexIntoCallNodeTable[] { + if ( + sort !== null && + sort.sortedColumns.length > 0 && + this._internal instanceof CallTreeInternalFunctionList + ) { + const internal = this._internal; + return sort.sortItemsHelper( + this._roots, + ( + a: IndexIntoCallNodeTable, + b: IndexIntoCallNodeTable, + column: string + ) => { + const aValue = + column === 'self' ? internal.getSelf(a) : internal.getTotal(a); + const bValue = + column === 'self' ? internal.getSelf(b) : internal.getTotal(b); + return aValue - bValue; + } + ); + } return this._roots; } @@ -513,7 +543,7 @@ export class CallTree { let displayData: CallNodeDisplayData | void = this._displayDataByIndex.get(callNodeIndex); if (displayData === undefined) { - const { funcName, total, totalRelative, self } = + const { funcName, total, totalRelative, self, selfRelative } = this.getNodeData(callNodeIndex); const funcIndex = this._callNodeInfo.funcForNode(callNodeIndex); const categoryIndex = this._callNodeInfo.categoryForNode(callNodeIndex); @@ -551,6 +581,7 @@ export class CallTree { self ); const totalPercent = `${formatPercent(totalRelative)}`; + const selfPercent = `${formatPercent(selfRelative)}`; let ariaLabel; let totalWithUnit; @@ -601,6 +632,7 @@ export class CallTree { self: self === 0 ? '—' : formattedSelf, selfWithUnit: self === 0 ? '—' : selfWithUnit, totalPercent, + selfPercent, name: funcName, lib: libName.slice(0, 1000), // Dim platform pseudo-stacks. diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 787fdc7fda..1c2c1f6c5f 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -1134,6 +1134,65 @@ export function getSampleSelectedStates( ); } +/** + * Go through the samples, and determine their current state. + * + * For samples that are neither 'FILTERED_OUT_*' nor 'SELECTED', + * this function uses 'UNSELECTED_ORDERED_AFTER_SELECTED'. It uses the same + * ordering as the function compareCallNodes in getTreeOrderComparator. + */ +export function getSamplesSelectedStatesForFunction( + sampleCallNodes: Array, + selectedFunctionIndex: IndexIntoFuncTable | null, + callNodeTable: CallNodeTable +): Uint8Array { + if (selectedFunctionIndex === null) { + return _getSampleSelectedStatesForNoSelection(sampleCallNodes); + } + + const sampleCount = sampleCallNodes.length; + + // Go through each call node, and label it as containing the function or not. + // callNodeContainsFunc is a callNodeIndex => bool map, implemented as a U8 typed + // array for better performance. 0 means false, 1 means true. + const callNodeCount = callNodeTable.length; + const callNodeContainsFunc = new Uint8Array(callNodeCount); + for (let callNodeIndex = 0; callNodeIndex < callNodeCount; callNodeIndex++) { + const prefix = callNodeTable.prefix[callNodeIndex]; + const funcIndex = callNodeTable.func[callNodeIndex]; + if ( + funcIndex === selectedFunctionIndex || + // The parent of this stack contained the function. + (prefix !== -1 && callNodeContainsFunc[prefix] === 1) + ) { + callNodeContainsFunc[callNodeIndex] = 1; + } + } + + // Go through each sample, and label its state. + const samplesSelectedStates = new Uint8Array(sampleCount); + for ( + let sampleIndex = 0; + sampleIndex < sampleCallNodes.length; + sampleIndex++ + ) { + let sampleSelectedState: SelectedState = SelectedState.Selected; + const callNodeIndex = sampleCallNodes[sampleIndex]; + if (callNodeIndex !== null) { + if (callNodeContainsFunc[callNodeIndex] === 1) { + sampleSelectedState = SelectedState.Selected; + } else { + sampleSelectedState = SelectedState.UnselectedOrderedBeforeSelected; + } + } else { + // This sample was filtered out. + sampleSelectedState = SelectedState.FilteredOutByTransform; + } + samplesSelectedStates[sampleIndex] = sampleSelectedState; + } + return samplesSelectedStates; +} + /** * This function returns the function index for a specific call node path. This * is the last element of this path, or the leaf element of the path. @@ -1436,6 +1495,41 @@ export function computeCallNodeFuncIsDuplicate( return nodeFuncIsDuplicateBitSet; } +/** + * This function returns the timings for a specific function. + * + * Note that the unfilteredThread should be the original thread before any filtering + * (by range or other) happens. Also sampleIndexOffset needs to be properly + * specified and is the offset to be applied on thread's indexes to access + * the same samples in unfilteredThread. + */ +export function getTimingsForFunction( + _funcIndex: IndexIntoFuncTable | null, + _interval: Milliseconds, + _thread: Thread, + _unfilteredThread: Thread, + _sampleIndexOffset: number, + _categories: CategoryList, + _samples: SamplesLikeTable, + _unfilteredSamples: SamplesLikeTable, + _displayImplementation: boolean +): TimingsForPath { + // TODO + return { + forPath: { + selfTime: { + value: 0, + breakdownByCategory: null, + }, + totalTime: { + value: 0, + breakdownByCategory: null, + }, + }, + rootTime: 1, + }; +} + // This function computes the time range for a thread, using both its samples // and markers data. It's memoized and exported below, because it's called both // here in getTimeRangeIncludingAllThreads, and in selectors when dealing with diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index b2a58f78c0..fa2b3fd6c8 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -30,6 +30,7 @@ import type { ThreadsKey, Milliseconds, TableViewOptions, + RightClickedFunction, } from 'firefox-profiler/types'; import { applyFuncSubstitutionToCallPath, @@ -188,6 +189,7 @@ export const defaultThreadViewOptions: ThreadViewOptions = { selectedInvertedCallNodePath: [], expandedNonInvertedCallNodePaths: new PathSet(), expandedInvertedCallNodePaths: new PathSet(), + selectedFunctionIndex: null, selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -315,6 +317,23 @@ const viewOptionsPerThread: Reducer = ( } ); } + case 'CHANGE_SELECTED_FUNCTION': { + const { selectedFunctionIndex, threadsKey } = action; + + const threadState = _getThreadViewOptions(state, threadsKey); + + const previousSelectedFunction = threadState.selectedFunctionIndex; + + // If the selected function doesn't actually change, let's return the previous + // state to avoid rerenders. + if (selectedFunctionIndex === previousSelectedFunction) { + return state; + } + + return _updateThreadViewOptions(state, threadsKey, { + selectedFunctionIndex, + }); + } case 'CHANGE_INVERT_CALLSTACK': { const { newSelectedCallNodePath, @@ -636,6 +655,7 @@ const scrollToSelectionGeneration: Reducer = (state = 0, action) => { case 'CHANGE_NETWORK_SEARCH_STRING': return state + 1; case 'CHANGE_SELECTED_CALL_NODE': + case 'CHANGE_SELECTED_FUNCTION': case 'CHANGE_SELECTED_MARKER': case 'CHANGE_SELECTED_NETWORK_MARKER': if (action.context.source === 'pointer') { @@ -781,6 +801,54 @@ const rightClickedCallNode: Reducer = ( } }; +const rightClickedFunction: Reducer = ( + state = null, + action +) => { + switch (action.type) { + case 'BULK_SYMBOLICATION': { + if (state === null) { + return null; + } + + const { oldFuncToNewFuncsMap } = action; + const functionIndexes = oldFuncToNewFuncsMap.get(state.functionIndex); + if (functionIndexes === undefined || functionIndexes.length === 0) { + return null; + } + + return { + ...state, + functionIndex: functionIndexes[0], + }; + } + case 'CHANGE_RIGHT_CLICKED_FUNCTION': + if (action.functionIndex !== null) { + return { + threadsKey: action.threadsKey, + functionIndex: action.functionIndex, + }; + } + + return null; + case 'SET_CONTEXT_MENU_VISIBILITY': + // We want to change the state only when the menu is hidden. + if (action.isVisible) { + return state; + } + + return null; + case 'PROFILE_LOADED': + case 'CHANGE_INVERT_CALLSTACK': + case 'ADD_TRANSFORM_TO_STACK': + case 'POP_TRANSFORMS_FROM_STACK': + case 'CHANGE_IMPLEMENTATION_FILTER': + return null; + default: + return state; + } +}; + const rightClickedMarker: Reducer = ( state = null, action @@ -882,6 +950,7 @@ const profileViewReducer: Reducer = wrapReducerInResetter( lastNonShiftClick, rightClickedTrack, rightClickedCallNode, + rightClickedFunction, rightClickedMarker, hoveredMarker, mouseTimePosition, diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 5c162859f7..f3902a4fff 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -206,6 +206,18 @@ const markerTableSort: Reducer = ( } }; +const functionListSort: Reducer = ( + state = [], + action +) => { + switch (action.type) { + case 'CHANGE_FUNCTION_LIST_SORT': + return action.sort; + default: + return state; + } +}; + const networkSearchString: Reducer = (state = '', action) => { switch (action.type) { case 'CHANGE_NETWORK_SEARCH_STRING': @@ -807,6 +819,7 @@ const profileSpecific = combineReducers({ tabFilter, selectedMarkers, markerTableSort, + functionListSort, // The timeline tracks used to be hidden and sorted by thread indexes, rather than // track indexes. The only way to migrate this information to tracks-based data is to // first retrieve the profile, so they can't be upgraded by the normal url upgrading diff --git a/src/selectors/per-thread/index.ts b/src/selectors/per-thread/index.ts index 5460a7a95d..e1d17f49f1 100644 --- a/src/selectors/per-thread/index.ts +++ b/src/selectors/per-thread/index.ts @@ -269,3 +269,109 @@ export const selectedNodeSelectors: NodeSelectors = (() => { getTimingsForSidebar, }; })(); + +// export type FunctionSelectors = {| +// +getTimingsForSidebar: Selector, +// +getSourceViewStackLineInfo: Selector, +// +getSourceViewLineTimings: Selector, +// |}; +// +// export const selectedFunctionTableNodeSelectors: FunctionSelectors = (() => { +// // const getName: Selector = createSelector( +// // selectedThreadSelectors.getSelectedFunctionTableFunction, +// // selectedThreadSelectors.getFilteredThread, +// // (selectedFunction, { stringTable, funcTable }) => { +// // if (selectedFunction === null) { +// // return ''; +// // } +// +// // return stringTable.getString(funcTable.name[selectedFunction]); +// // } +// // ); +// +// // const getIsJS: Selector = createSelector( +// // selectedThreadSelectors.getSelectedFunctionTableFunction, +// // selectedThreadSelectors.getFilteredThread, +// // (selectedFunction, { funcTable }) => { +// // return selectedFunction !== null && funcTable.isJS[selectedFunction]; +// // } +// // ); +// +// // const getLib: Selector = createSelector( +// // selectedThreadSelectors.getSelectedFunctionTableFunction, +// // selectedThreadSelectors.getFilteredThread, +// // (selectedFunction, { stringTable, funcTable, resourceTable }) => { +// // if (selectedFunction === null) { +// // return ''; +// // } +// +// // return ProfileData.getOriginAnnotationForFunc( +// // selectedFunction, +// // funcTable, +// // resourceTable, +// // stringTable +// // ); +// // } +// // ); +// +// const getTimingsForSidebar: Selector = createSelector( +// selectedThreadSelectors.getSelectedFunctionIndex, +// ProfileSelectors.getProfileInterval, +// selectedThreadSelectors.getPreviewFilteredThread, +// selectedThreadSelectors.getThread, +// selectedThreadSelectors.getSampleIndexOffsetFromPreviewRange, +// ProfileSelectors.getCategories, +// selectedThreadSelectors.getPreviewFilteredSamplesForCallTree, +// selectedThreadSelectors.getUnfilteredSamplesForCallTree, +// ProfileSelectors.getProfileUsesFrameImplementation, +// ProfileData.getTimingsForFunction +// ); +// +// const getSourceViewStackLineInfo: Selector = +// createSelector( +// selectedThreadSelectors.getFilteredThread, +// UrlState.getSourceViewFile, +// selectedThreadSelectors.getCallNodeInfo, +// selectedThreadSelectors.getSelectedCallNodeIndex, +// UrlState.getInvertCallstack, +// ( +// { stackTable, frameTable, funcTable, stringTable }: Thread, +// sourceViewFile, +// callNodeInfo, +// selectedCallNodeIndex, +// invertCallStack +// ): StackLineInfo | null => { +// if (sourceViewFile === null || selectedCallNodeIndex === null) { +// return null; +// } +// const selectedFunc = +// callNodeInfo.callNodeTable.func[selectedCallNodeIndex]; +// const selectedFuncFile = funcTable.fileName[selectedFunc]; +// if ( +// selectedFuncFile === null || +// stringTable.getString(selectedFuncFile) !== sourceViewFile +// ) { +// return null; +// } +// return getStackLineInfoForCallNode( +// stackTable, +// frameTable, +// selectedCallNodeIndex, +// callNodeInfo, +// invertCallStack +// ); +// } +// ); +// +// const getSourceViewLineTimings: Selector = createSelector( +// getSourceViewStackLineInfo, +// selectedThreadSelectors.getPreviewFilteredSamplesForCallTree, +// getLineTimings +// ); +// +// return { +// // getTimingsForSidebar, +// // getSourceViewStackLineInfo, +// // getSourceViewLineTimings, +// }; +// })(); diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 21bbecb964..304e70b9ec 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -42,6 +42,7 @@ import type { CallNodeSelfAndSummary, State, CallNodeTableBitSet, + IndexIntoFuncTable, } from 'firefox-profiler/types'; import type { CallNodeInfo, @@ -208,6 +209,14 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSelectedFunctionIndex: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): IndexIntoFuncTable | null => { + return threadViewOptions.selectedFunctionIndex; + } + ); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -498,6 +507,23 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getRightClickedFunctionIndex: Selector = + createSelector( + ProfileSelectors.getProfileViewOptions, + (profileViewOptions) => { + const rightClickedFunctionInfo = + profileViewOptions.rightClickedFunction; + if ( + rightClickedFunctionInfo !== null && + threadsKey === rightClickedFunctionInfo.threadsKey + ) { + return rightClickedFunctionInfo.functionIndex; + } + + return null; + } + ); + return { unfilteredSamplesRange, getWeightTypeForCallTree, @@ -507,6 +533,7 @@ export function getStackAndSampleSelectorsPerThread( getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, @@ -524,5 +551,6 @@ export function getStackAndSampleSelectorsPerThread( getFilteredCallNodeMaxDepthPlusOne, getFlameGraphTiming, getRightClickedCallNodeIndex, + getRightClickedFunctionIndex, }; } diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 80c9733a3c..5fa38566ce 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -120,6 +120,8 @@ export const getMarkersSearchString: Selector = (state) => getProfileSpecificState(state).markersSearchString; export const getMarkerTableSort: Selector = (state) => getProfileSpecificState(state).markerTableSort; +export const getFunctionListSort: Selector = (state) => + getProfileSpecificState(state).functionListSort; export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => diff --git a/src/test/components/Details.test.tsx b/src/test/components/Details.test.tsx index 73b05db864..0970c890d0 100644 --- a/src/test/components/Details.test.tsx +++ b/src/test/components/Details.test.tsx @@ -20,6 +20,9 @@ import type { TabSlug } from '../../app-logic/tabs-handling'; jest.mock('../../components/calltree/ProfileCallTreeView', () => ({ ProfileCallTreeView: 'call-tree', })); +jest.mock('../../components/calltree/ProfileFunctionListView', () => ({ + ProfileFunctionListView: 'function-list', +})); jest.mock('../../components/flame-graph', () => ({ FlameGraph: 'flame-graph', })); diff --git a/src/test/components/DetailsContainer.test.tsx b/src/test/components/DetailsContainer.test.tsx index 54c142dba0..59d449fccb 100644 --- a/src/test/components/DetailsContainer.test.tsx +++ b/src/test/components/DetailsContainer.test.tsx @@ -37,6 +37,7 @@ describe('app/DetailsContainer', function () { const expectedSidebar: { [slug in TabSlug]: boolean } = { calltree: true, + 'function-list': true, 'flame-graph': true, 'stack-chart': false, 'marker-chart': false, diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index c342a59f5a..d4702f22e1 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4429,6 +4429,7 @@ Object { }, }, "lastSeenTransformCount": 1, + "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], "selectedNetworkMarker": null, "selectedNonInvertedCallNodePath": Array [ diff --git a/src/test/store/icons.test.ts b/src/test/store/icons.test.ts index 3a9d3df13f..c1b77810b9 100644 --- a/src/test/store/icons.test.ts +++ b/src/test/store/icons.test.ts @@ -37,6 +37,7 @@ describe('actions/icons', function () { totalPercent: '0', self: '0', selfWithUnit: '0 ms', + selfPercent: '0', name: 'icon', lib: 'icon', isFrameLabel: false, diff --git a/src/test/store/useful-tabs.test.ts b/src/test/store/useful-tabs.test.ts index 2e770a2581..7f6444c07e 100644 --- a/src/test/store/useful-tabs.test.ts +++ b/src/test/store/useful-tabs.test.ts @@ -21,6 +21,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -59,6 +60,7 @@ describe('getUsefulTabs', function () { }); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -84,6 +86,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -116,6 +119,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index 566bedf105..de37b8d418 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -330,6 +330,7 @@ describe('unfiltered call tree', function () { name: 'A', self: '—', selfWithUnit: '—', + selfPercent: '0%', total: '3', totalWithUnit: '3 samples', totalPercent: '100%', @@ -346,6 +347,7 @@ describe('unfiltered call tree', function () { name: 'I', self: '1', selfWithUnit: '1 sample', + selfPercent: '33%', total: '1', totalWithUnit: '1 sample', totalPercent: '33%', diff --git a/src/test/url-handling.test.ts b/src/test/url-handling.test.ts index 2ad21c34e0..3bdf75cc3f 100644 --- a/src/test/url-handling.test.ts +++ b/src/test/url-handling.test.ts @@ -23,6 +23,7 @@ import { changeIncludeIdleSamples, changeSelectedMarker, changeMarkerTableSort, + changeFunctionListSort, } from '../actions/profile-view'; import { changeSelectedTab, changeProfilesToCompare } from '../actions/app'; import { @@ -543,6 +544,57 @@ describe('marker table sort', function () { }); }); +describe('function list sort', function () { + function _getStoreOnFunctionList() { + const store = _getStoreWithURL(); + store.dispatch(changeSelectedTab('function-list')); + return store; + } + + it('omits the default sort from the URL', function () { + const { getState } = _getStoreOnFunctionList(); + expect(getQueryStringFromState(getState())).not.toContain( + 'functionListSort' + ); + }); + + it('serializes a non-default sort with primary first', function () { + const { getState, dispatch } = _getStoreOnFunctionList(); + // self desc primary, total asc tiebreaker (internal: primary last) + dispatch( + changeFunctionListSort([ + { column: 'total', ascending: true }, + { column: 'self', ascending: false }, + ]) + ); + expect(getQueryStringFromState(getState())).toContain( + 'functionListSort=self-desc~total-asc' + ); + }); + + it('round-trips a non-default sort through the URL', function () { + const { getState, dispatch } = _getStoreOnFunctionList(); + dispatch(changeFunctionListSort([{ column: 'self', ascending: false }])); + const url = urlFromState(getState().urlState); + const restored = stateFromLocation({ + pathname: new URL(url, 'http://localhost').pathname, + search: new URL(url, 'http://localhost').search, + hash: '', + }); + expect(restored.profileSpecific.functionListSort).toEqual([ + { column: 'self', ascending: false }, + ]); + }); + + it('falls back to the default when the URL has an invalid column', function () { + const { getState } = _getStoreWithURL({ + pathname: '/public/abc/function-list/', + search: '?functionListSort=bogus-desc', + }); + expect(getState().urlState.profileSpecific.functionListSort).toEqual([]); + }); +}); + describe('profileName', function () { it('serializes the profileName in the URL', function () { const { getState, dispatch } = _getStoreWithURL(); diff --git a/src/types/actions.ts b/src/types/actions.ts index 278f3d5cc6..9ceb650bc5 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -18,6 +18,7 @@ import type { FrameTable, SourceLocationTable, SourceTable, + IndexIntoFuncTable, } from './profile'; import type { Thread, @@ -192,6 +193,12 @@ type ProfileAction = readonly optionalExpandedToCallNodePath: CallNodePath | undefined; readonly context: SelectionContext; } + | { + readonly type: 'CHANGE_SELECTED_FUNCTION'; + readonly threadsKey: ThreadsKey; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly context: SelectionContext; + } | { readonly type: 'UPDATE_TRACK_THREAD_HEIGHT'; readonly height: CssPixels; @@ -202,6 +209,11 @@ type ProfileAction = readonly threadsKey: ThreadsKey; readonly callNodePath: CallNodePath | null; } + | { + readonly type: 'CHANGE_RIGHT_CLICKED_FUNCTION'; + readonly threadsKey: ThreadsKey; + readonly functionIndex: IndexIntoFuncTable | null; + } | { readonly type: 'FOCUS_CALL_TREE'; } @@ -563,6 +575,10 @@ type UrlStateAction = readonly type: 'CHANGE_MARKER_TABLE_SORT'; readonly sort: SingleColumnSortState[]; } + | { + readonly type: 'CHANGE_FUNCTION_LIST_SORT'; + readonly sort: SingleColumnSortState[]; + } | { readonly type: 'CHANGE_NETWORK_SEARCH_STRING'; readonly searchString: string; diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 75325be75c..71955232e9 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -517,6 +517,7 @@ export type CallNodeDisplayData = Readonly<{ totalPercent: string; self: string; selfWithUnit: string; + selfPercent: string; name: string; lib: string; isFrameLabel: boolean; diff --git a/src/types/state.ts b/src/types/state.ts index 0db1ffe6d4..a73b8b4772 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -23,6 +23,7 @@ import type { TabID, IndexIntoLibs, IndexIntoSourceTable, + IndexIntoFuncTable, } from './profile'; import type { @@ -61,6 +62,7 @@ export type ThreadViewOptions = { readonly selectedInvertedCallNodePath: CallNodePath; readonly expandedNonInvertedCallNodePaths: PathSet; readonly expandedInvertedCallNodePaths: PathSet; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; readonly selectedNetworkMarker: MarkerIndex | null; // Track the number of transforms to detect when they change via browser // navigation. This helps us know when to reset paths that may be invalid @@ -83,6 +85,11 @@ export type RightClickedCallNode = { readonly callNodePath: CallNodePath; }; +export type RightClickedFunction = { + readonly threadsKey: ThreadsKey; + readonly functionIndex: IndexIntoFuncTable; +}; + export type MarkerReference = { readonly threadsKey: ThreadsKey; readonly markerIndex: MarkerIndex; @@ -108,6 +115,7 @@ export type ProfileViewState = { lastNonShiftClick: LastNonShiftClickInformation | null; rightClickedTrack: TrackReference | null; rightClickedCallNode: RightClickedCallNode | null; + rightClickedFunction: RightClickedFunction | null; rightClickedMarker: MarkerReference | null; hoveredMarker: MarkerReference | null; mouseTimePosition: Milliseconds | null; @@ -385,6 +393,7 @@ export type ProfileSpecificUrlState = { legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; markerTableSort: SingleColumnSortState[]; + functionListSort: SingleColumnSortState[]; }; export type UrlState = { diff --git a/src/utils/types.ts b/src/utils/types.ts index 1d6df05f5e..9b41c5b1d9 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -42,6 +42,7 @@ export function toValidTabSlug(tabSlug: any): TabSlug | null { const coercedTabSlug = tabSlug as TabSlug; switch (coercedTabSlug) { case 'calltree': + case 'function-list': case 'stack-chart': case 'marker-chart': case 'network-chart': From a047e74475d2978e75c94aad8aefa8fb184563f4 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 18:57:03 -0400 Subject: [PATCH 05/41] Implement activity graph highlighting for function list --- .../calltree/ProfileFunctionListView.tsx | 2 +- src/components/timeline/TrackThread.tsx | 5 +- src/selectors/per-thread/stack-sample.ts | 14 ++ src/test/unit/profile-data.test.ts | 144 ++++++++++++++++++ 4 files changed, 163 insertions(+), 2 deletions(-) diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index ab1d348eaf..7695a6c4d2 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -13,7 +13,7 @@ export const ProfileFunctionListView = () => ( role="tabpanel" aria-labelledby="function-list-tab-button" > - +
diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx index b0ee5338a6..b1f24edaa7 100644 --- a/src/components/timeline/TrackThread.tsx +++ b/src/components/timeline/TrackThread.tsx @@ -25,6 +25,7 @@ import { getImplementationFilter, getZeroAt, getProfileTimelineUnit, + getSelectedTab, } from 'firefox-profiler/selectors'; import { TimelineMarkersJank, @@ -349,7 +350,9 @@ export const TimelineTrackThread = explicitConnect< hasFileIoMarkers: selectors.getTimelineFileIoMarkerIndexes(state).length !== 0, sampleSelectedStates: - selectors.getSampleSelectedStatesInFilteredThread(state), + getSelectedTab(state) === 'function-list' + ? selectors.getSampleSelectedStatesForFunctionListTab(state) + : selectors.getSampleSelectedStatesInFilteredThread(state), treeOrderSampleComparator: selectors.getTreeOrderComparatorInFilteredThread(state), selectedThreadIndexes, diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 304e70b9ec..181868fac1 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -301,6 +301,19 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSampleSelectedStatesForFunctionListTab: Selector = + createSelector( + getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, + _getCallNodeTable, + getSelectedFunctionIndex, + (sampleCallNodes, callNodeTable, selectedFunctionIndex) => + ProfileData.getSamplesSelectedStatesForFunction( + sampleCallNodes, + selectedFunctionIndex, + callNodeTable + ) + ); + const getTreeOrderComparatorInFilteredThread: Selector< ( sampleIndexA: IndexIntoSamplesTable, @@ -538,6 +551,7 @@ export function getStackAndSampleSelectorsPerThread( getExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, + getSampleSelectedStatesForFunctionListTab, getTreeOrderComparatorInFilteredThread, getCallTree, getFunctionListTree, diff --git a/src/test/unit/profile-data.test.ts b/src/test/unit/profile-data.test.ts index be69a1c439..62149a0ac3 100644 --- a/src/test/unit/profile-data.test.ts +++ b/src/test/unit/profile-data.test.ts @@ -19,6 +19,7 @@ import { getSampleIndexToCallNodeIndex, getTreeOrderComparator, getSampleSelectedStates, + getSamplesSelectedStatesForFunction, extractProfileFilterPageData, findAddressProofForFile, calculateFunctionSizeLowerBound, @@ -1235,6 +1236,149 @@ describe('getSampleSelectedStates', function () { }); }); +describe('getSamplesSelectedStatesForFunction', function () { + function setup(textSamples: string) { + const { + derivedThreads, + funcNamesDictPerThread: [funcNamesDict], + } = getProfileFromTextSamples(textSamples); + const [thread] = derivedThreads; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + 0 + ); + const sampleCallNodes = getSampleIndexToCallNodeIndex( + thread.samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ); + return { + callNodeTable: callNodeInfo.getCallNodeTable(), + sampleCallNodes, + funcNamesDict, + }; + } + + it('marks all non-filtered samples as selected when nothing is selected', function () { + const { callNodeTable, sampleCallNodes } = setup(` + A A A + B C + `); + expect( + Array.from( + getSamplesSelectedStatesForFunction( + sampleCallNodes, + null, + callNodeTable + ) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.Selected, + SelectedState.Selected, + ]); + }); + + it('marks samples as selected when their call stack contains the selected function', function () { + // 0 1 2 3 4 + // A A A A A + // B D B D D + // C E F G + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { B, D }, + } = setup(` + A A A A A + B D B D D + C E F G + `); + + // Selecting function B: samples 0, 2 have B in their stack + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.UnselectedOrderedBeforeSelected, + ]); + + // Selecting function D: samples 1, 3, 4 have D in their stack + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, D, callNodeTable) + ) + ).toEqual([ + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.Selected, + ]); + }); + + it('marks filtered-out samples as FilteredOutByTransform', function () { + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { B }, + } = setup(` + A A A + B C + `); + // Sample 2 has no stack (null), treated as filtered out. + // Manually null out sample 2's call node. + sampleCallNodes[2] = null; + + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.FilteredOutByTransform, + ]); + }); + + it('selects samples whose ancestor call node contains the function, not just the leaf', function () { + // 0 1 + // A A + // B C + // D + // Selecting A should match all samples (A is an ancestor of everything). + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { A, B }, + } = setup(` + A A + B C + D + `); + + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, A, callNodeTable) + ) + ).toEqual([SelectedState.Selected, SelectedState.Selected]); + + // Selecting B only matches sample 0 (B is in the path A->B->D). + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + ]); + }); +}); + describe('extractProfileFilterPageData', function () { const pages = { mozilla: { From 6692b1339721ad75dbc5e6f19dddd8fd59fdaffa Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 19:09:53 -0400 Subject: [PATCH 06/41] Implement function list context menu. --- src/actions/profile-view.ts | 2 +- src/components/app/Details.tsx | 2 + .../shared/FunctionListContextMenu.tsx | 488 ++++++++++++++++++ .../FunctionListContextMenu.test.tsx | 119 +++++ .../FunctionListContextMenu.test.tsx.snap | 216 ++++++++ 5 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 src/components/shared/FunctionListContextMenu.tsx create mode 100644 src/test/components/FunctionListContextMenu.test.tsx create mode 100644 src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 16483c30bc..fc03f222ee 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -166,7 +166,7 @@ export function changeRightClickedCallNode( export function changeRightClickedFunctionIndex( threadsKey: ThreadsKey, functionIndex: IndexIntoFuncTable | null -) { +): Action { return { type: 'CHANGE_RIGHT_CLICKED_FUNCTION', threadsKey, diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index c571fd6945..85d92cc99a 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -27,6 +27,7 @@ import { getSelectedTab } from 'firefox-profiler/selectors/url-state'; import { getIsSidebarOpen } from 'firefox-profiler/selectors/app'; import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { CallNodeContextMenu } from 'firefox-profiler/components/shared/CallNodeContextMenu'; +import { FunctionListContextMenu } from 'firefox-profiler/components/shared/FunctionListContextMenu'; import { MaybeMarkerContextMenu } from 'firefox-profiler/components/shared/MarkerContextMenu'; import { toValidTabSlug } from 'firefox-profiler/utils/types'; @@ -135,6 +136,7 @@ class ProfileViewerImpl extends PureComponent { +
); diff --git a/src/components/shared/FunctionListContextMenu.tsx b/src/components/shared/FunctionListContextMenu.tsx new file mode 100644 index 0000000000..ccbdeb54a8 --- /dev/null +++ b/src/components/shared/FunctionListContextMenu.tsx @@ -0,0 +1,488 @@ +/* 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/. */ +import * as React from 'react'; +import { PureComponent } from 'react'; +import { MenuItem } from '@firefox-devtools/react-contextmenu'; +import { Localized } from '@fluent/react'; + +import { ContextMenu } from './ContextMenu'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + funcHasDirectRecursiveCall, + funcHasRecursiveCall, +} from 'firefox-profiler/profile-logic/transforms'; +import { getFunctionName } from 'firefox-profiler/profile-logic/function-info'; + +import copy from 'copy-to-clipboard'; +import { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, +} from 'firefox-profiler/actions/profile-view'; +import { getImplementationFilter } from 'firefox-profiler/selectors/url-state'; +import { getThreadSelectorsFromThreadsKey } from 'firefox-profiler/selectors/per-thread'; +import { + getProfileViewOptions, + getShouldDisplaySearchfox, +} from 'firefox-profiler/selectors/profile'; +import { oneLine } from 'common-tags'; + +import { + convertToTransformType, + assertExhaustiveCheck, +} from 'firefox-profiler/utils/types'; + +import type { + TransformType, + ImplementationFilter, + IndexIntoFuncTable, + Thread, + ThreadsKey, + CallNodeTable, + State, +} from 'firefox-profiler/types'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallNodeContextMenu.css'; + +type StateProps = { + readonly thread: Thread | null; + readonly threadsKey: ThreadsKey | null; + readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; + readonly callNodeTable: CallNodeTable | null; + readonly implementation: ImplementationFilter; + readonly displaySearchfox: boolean; +}; + +type DispatchProps = { + readonly addTransformToStack: typeof addTransformToStack; + readonly addCollapseResourceTransformToStack: typeof addCollapseResourceTransformToStack; + readonly setContextMenuVisibility: typeof setContextMenuVisibility; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class FunctionListContextMenuImpl extends PureComponent { + _hidingTimeout: NodeJS.Timeout | null = null; + + _onShow = () => { + if (this._hidingTimeout) { + clearTimeout(this._hidingTimeout); + } + this.props.setContextMenuVisibility(true); + }; + + _onHide = () => { + this._hidingTimeout = setTimeout(() => { + this._hidingTimeout = null; + this.props.setContextMenuVisibility(false); + }); + }; + + _getRightClickedInfo(): null | { + readonly thread: Thread; + readonly threadsKey: ThreadsKey; + readonly funcIndex: IndexIntoFuncTable; + readonly callNodeTable: CallNodeTable; + } { + const { thread, threadsKey, rightClickedFunctionIndex, callNodeTable } = + this.props; + if ( + thread !== null && + threadsKey !== null && + rightClickedFunctionIndex !== null && + callNodeTable !== null + ) { + return { + thread, + threadsKey, + funcIndex: rightClickedFunctionIndex, + callNodeTable, + }; + } + return null; + } + + _getFunctionName(): string { + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { + thread: { stringTable, funcTable }, + funcIndex, + } = info; + const isJS = funcTable.isJS[funcIndex]; + const functionCall = stringTable.getString(funcTable.name[funcIndex]); + return isJS ? functionCall : getFunctionName(functionCall); + } + + lookupFunctionOnSearchfox(): void { + window.open( + `https://searchfox.org/mozilla-central/search?q=${encodeURIComponent( + this._getFunctionName() + )}`, + '_blank' + ); + } + + copyFunctionName(): void { + copy(this._getFunctionName()); + } + + getNameForSelectedResource(): string | null { + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { + thread: { funcTable, stringTable, resourceTable, sources }, + funcIndex, + } = info; + const isJS = funcTable.isJS[funcIndex]; + if (isJS) { + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex === null) { + return null; + } + return stringTable.getString(sources.filename[sourceIndex]); + } + const resourceIndex = funcTable.resource[funcIndex]; + if (resourceIndex === -1) { + return null; + } + return stringTable.getString(resourceTable.name[resourceIndex]); + } + + addTransformToStack(type: TransformType): void { + const { + addTransformToStack, + addCollapseResourceTransformToStack, + implementation, + } = this.props; + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { threadsKey, thread, funcIndex } = info; + + switch (type) { + case 'focus-function': + addTransformToStack(threadsKey, { + type: 'focus-function', + funcIndex, + }); + break; + case 'focus-self': + addTransformToStack(threadsKey, { + type: 'focus-self', + funcIndex, + implementation, + }); + break; + case 'merge-function': + addTransformToStack(threadsKey, { + type: 'merge-function', + funcIndex, + }); + break; + case 'drop-function': + addTransformToStack(threadsKey, { + type: 'drop-function', + funcIndex, + }); + break; + case 'collapse-resource': { + const resourceIndex = thread.funcTable.resource[funcIndex]; + addCollapseResourceTransformToStack( + threadsKey, + resourceIndex, + implementation + ); + break; + } + case 'collapse-direct-recursion': + addTransformToStack(threadsKey, { + type: 'collapse-direct-recursion', + funcIndex, + implementation, + }); + break; + case 'collapse-recursion': + addTransformToStack(threadsKey, { + type: 'collapse-recursion', + funcIndex, + }); + break; + case 'collapse-function-subtree': + addTransformToStack(threadsKey, { + type: 'collapse-function-subtree', + funcIndex, + }); + break; + case 'focus-subtree': + case 'merge-call-node': + case 'focus-category': + case 'filter-samples': + throw new Error( + `The transform "${type}" is not supported in the function list context menu.` + ); + default: + assertExhaustiveCheck(type); + } + } + + _handleClick = ( + _event: React.ChangeEvent, + data: { type: string } + ): void => { + const { type } = data; + + const transformType = convertToTransformType(type); + if (transformType) { + this.addTransformToStack(transformType); + return; + } + + switch (type) { + case 'searchfox': + this.lookupFunctionOnSearchfox(); + break; + case 'copy-function-name': + this.copyFunctionName(); + break; + default: + throw new Error(`Unknown type ${type}`); + } + }; + + renderTransformMenuItem(props: { + readonly l10nId: string; + readonly content: React.ReactNode; + readonly onClick: ( + event: React.ChangeEvent, + data: { type: string } + ) => void; + readonly transform: string; + readonly shortcut: string; + readonly icon: string; + readonly title: string; + readonly l10nVars?: Record; + readonly l10nElems?: Record; + }) { + return ( + + + +
+ {props.content} +
+
+ {props.shortcut} +
+ ); + } + + renderContextMenuContents() { + const { displaySearchfox } = this.props; + const info = this._getRightClickedInfo(); + + if (info === null) { + console.error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + return
; + } + + const { funcIndex, callNodeTable } = info; + const nameForResource = this.getNameForSelectedResource(); + + return ( + <> + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-merge-function', + shortcut: 'm', + icon: 'Merge', + onClick: this._handleClick, + transform: 'merge-function', + title: '', + content: 'Merge function', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-focus-function', + shortcut: 'f', + icon: 'Focus', + onClick: this._handleClick, + transform: 'focus-function', + title: '', + content: 'Focus on function', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-focus-self', + shortcut: 'S', + icon: 'FocusSelf', + onClick: this._handleClick, + transform: 'focus-self', + title: '', + content: 'Focus on self only', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree', + shortcut: 'c', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-function-subtree', + title: '', + content: 'Collapse function', + })} + + {nameForResource + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-resource', + l10nVars: { nameForResource }, + l10nElems: { strong: }, + shortcut: 'C', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-resource', + title: '', + content: `Collapse ${nameForResource}`, + }) + : null} + + {funcHasRecursiveCall(callNodeTable, funcIndex) + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-recursion', + shortcut: 'r', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-recursion', + title: '', + content: 'Collapse recursion', + }) + : null} + + {funcHasDirectRecursiveCall(callNodeTable, funcIndex) + ? this.renderTransformMenuItem({ + l10nId: + 'CallNodeContextMenu--transform-collapse-direct-recursion-only', + shortcut: 'R', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-direct-recursion', + title: '', + content: 'Collapse direct recursion only', + }) + : null} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-drop-function', + shortcut: 'd', + icon: 'Drop', + onClick: this._handleClick, + transform: 'drop-function', + title: '', + content: 'Drop samples with this function', + })} + +
+ + {displaySearchfox ? ( + + + Look up the function name on Searchfox + + + ) : null} + + + Copy function name + + + + ); + } + + override render() { + if (this._getRightClickedInfo() === null) { + return null; + } + + return ( + + {this.renderContextMenuContents()} + + ); + } +} + +export const FunctionListContextMenu = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State) => { + const rightClickedFunction = + getProfileViewOptions(state).rightClickedFunction; + + let thread = null; + let threadsKey = null; + let rightClickedFunctionIndex = null; + let callNodeTable = null; + + if (rightClickedFunction !== null) { + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedFunction.threadsKey + ); + thread = selectors.getFilteredThread(state); + threadsKey = rightClickedFunction.threadsKey; + rightClickedFunctionIndex = rightClickedFunction.functionIndex; + // Use the non-inverted call node table for recursion detection. + callNodeTable = selectors.getCallNodeInfo(state).getCallNodeTable(); + } + + return { + thread, + threadsKey, + rightClickedFunctionIndex, + callNodeTable, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + }, + mapDispatchToProps: { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, + }, + component: FunctionListContextMenuImpl, +}); diff --git a/src/test/components/FunctionListContextMenu.test.tsx b/src/test/components/FunctionListContextMenu.test.tsx new file mode 100644 index 0000000000..6f85957870 --- /dev/null +++ b/src/test/components/FunctionListContextMenu.test.tsx @@ -0,0 +1,119 @@ +/* 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/. */ + +import { Provider } from 'react-redux'; +import copy from 'copy-to-clipboard'; + +import { render, screen } from 'firefox-profiler/test/fixtures/testing-library'; +import { FunctionListContextMenu } from '../../components/shared/FunctionListContextMenu'; +import { storeWithProfile } from '../fixtures/stores'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { fireFullClick } from '../fixtures/utils'; +import { + changeRightClickedFunctionIndex, + setContextMenuVisibility, +} from '../../actions/profile-view'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; +import { ensureExists } from '../../utils/types'; + +describe('FunctionListContextMenu', function () { + // Create a profile that exercises all the conditional menu items: + // - B[lib:XUL] appears three times in a row (direct + indirect recursion) + // - B[lib:XUL] belongs to the XUL library (collapse-resource) + function createStore() { + const { + profile, + funcNamesDictPerThread: [{ B }], + } = getProfileFromTextSamples(` + A A A + B[lib:XUL] B[lib:XUL] B[lib:XUL] + B[lib:XUL] B[lib:XUL] B[lib:XUL] + B[lib:XUL] B[lib:XUL] B[lib:XUL] + C C H + D F I + E E + `); + const store = storeWithProfile(profile); + store.dispatch(changeRightClickedFunctionIndex(0, B)); + return store; + } + + function setup(store = createStore()) { + store.dispatch(setContextMenuVisibility(true)); + const renderResult = render( + + + + ); + return { ...renderResult, getState: store.getState }; + } + + describe('basic rendering', function () { + it('does not render when no function is right-clicked', () => { + const store = storeWithProfile(getProfileFromTextSamples('A').profile); + store.dispatch(setContextMenuVisibility(true)); + const { container } = render( + + + + ); + expect(container.querySelector('.react-contextmenu')).toBeNull(); + }); + + it('renders a full context menu when a function is right-clicked', () => { + const { container } = setup(); + expect( + ensureExists( + container.querySelector('.react-contextmenu'), + `Couldn't find the context menu root component .react-contextmenu` + ).children.length > 1 + ).toBeTruthy(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('does not include call-node-specific transforms', () => { + setup(); + expect(screen.queryByText(/Merge node only/)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Focus on subtree only/) + ).not.toBeInTheDocument(); + expect(screen.queryByText(/Expand all/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Copy stack/)).not.toBeInTheDocument(); + }); + }); + + describe('clicking on transforms', function () { + const fixtures = [ + { matcher: /Merge function/, type: 'merge-function' }, + { matcher: /Focus on function/, type: 'focus-function' }, + { matcher: /Focus on self only/, type: 'focus-self' }, + { matcher: /Collapse function/, type: 'collapse-function-subtree' }, + { matcher: /XUL/, type: 'collapse-resource' }, + { matcher: /^Collapse recursion/, type: 'collapse-recursion' }, + { + matcher: /Collapse direct recursion/, + type: 'collapse-direct-recursion', + }, + { matcher: /Drop samples/, type: 'drop-function' }, + ]; + + fixtures.forEach(({ matcher, type }) => { + it(`adds a transform for "${type}"`, function () { + const { getState } = setup(); + fireFullClick(screen.getByText(matcher)); + expect( + selectedThreadSelectors.getTransformStack(getState())[0].type + ).toBe(type); + }); + }); + }); + + describe('clicking on utility items', function () { + it('can copy a function name', function () { + setup(); + fireFullClick(screen.getByText('Copy function name')); + expect(copy).toHaveBeenCalledWith('B'); + }); + }); +}); diff --git a/src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap b/src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap new file mode 100644 index 0000000000..ce0c6f9ce3 --- /dev/null +++ b/src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap @@ -0,0 +1,216 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`FunctionListContextMenu basic rendering renders a full context menu when a function is right-clicked 1`] = ` +
+ +
+`; From b37ca583f2d0346a7da38bdda9b9f226aee79391 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 19:17:12 -0400 Subject: [PATCH 07/41] Implement transform shortcut keys for function list --- src/actions/profile-view.ts | 97 ++++++++++ src/components/calltree/FunctionList.tsx | 32 ++-- .../components/TransformShortcuts.test.tsx | 176 ++++++++++++++++++ 3 files changed, 291 insertions(+), 14 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index fc03f222ee..1e760ef995 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2176,3 +2176,100 @@ export function handleCallNodeTransformShortcut( } }; } + +export function handleFunctionTransformShortcut( + event: React.KeyboardEvent, + threadsKey: ThreadsKey, + funcIndex: IndexIntoFuncTable +): ThunkAction { + return (dispatch, getState) => { + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); + const callNodeInfo = threadSelectors.getCallNodeInfo(getState()); + const implementation = getImplementationFilter(getState()); + const callNodeTable = callNodeInfo.getCallNodeTable(); + const unfilteredThread = threadSelectors.getThread(getState()); + + switch (event.key) { + case 'f': + dispatch( + addTransformToStack(threadsKey, { + type: 'focus-function', + funcIndex, + }) + ); + break; + case 'S': + dispatch( + addTransformToStack(threadsKey, { + type: 'focus-self', + funcIndex, + implementation, + }) + ); + break; + case 'm': + dispatch( + addTransformToStack(threadsKey, { + type: 'merge-function', + funcIndex, + }) + ); + break; + case 'd': + dispatch( + addTransformToStack(threadsKey, { + type: 'drop-function', + funcIndex, + }) + ); + break; + case 'C': { + const resourceIndex = unfilteredThread.funcTable.resource[funcIndex]; + dispatch( + addCollapseResourceTransformToStack( + threadsKey, + resourceIndex, + implementation + ) + ); + break; + } + case 'r': { + if (funcHasRecursiveCall(callNodeTable, funcIndex)) { + dispatch( + addTransformToStack(threadsKey, { + type: 'collapse-recursion', + funcIndex, + }) + ); + } + break; + } + case 'R': { + if (funcHasDirectRecursiveCall(callNodeTable, funcIndex)) { + dispatch( + addTransformToStack(threadsKey, { + type: 'collapse-direct-recursion', + funcIndex, + implementation, + }) + ); + } + break; + } + case 'c': + dispatch( + addTransformToStack(threadsKey, { + type: 'collapse-function-subtree', + funcIndex, + }) + ); + break; + default: + // This did not match a function transform. + } + }; +} diff --git a/src/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx index c54f6e01b7..21451f58d3 100644 --- a/src/components/calltree/FunctionList.tsx +++ b/src/components/calltree/FunctionList.tsx @@ -30,6 +30,7 @@ import { changeTableViewOptions, updateBottomBoxContentsAndMaybeOpen, changeFunctionListSort, + handleFunctionTransformShortcut, } from 'firefox-profiler/actions/profile-view'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import { @@ -81,6 +82,7 @@ type DispatchProps = { readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; readonly addTransformToStack: typeof addTransformToStack; readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly handleFunctionTransformShortcut: typeof handleFunctionTransformShortcut; readonly onTableViewOptionsChange: (opts: TableViewOptions) => any; readonly changeFunctionListSort: typeof changeFunctionListSort; }; @@ -180,20 +182,21 @@ class FunctionListImpl extends PureComponent { _newExpandedCallNodeIndexes: Array ) => {}; - _onKeyDown = (_event: React.KeyboardEvent) => { - // const { - // selectedFunctionIndex, - // rightClickedFunctionIndex, - // threadsKey, - // } = this.props; - // const nodeIndex = - // rightClickedFunctionIndex !== null - // ? rightClickedFunctionIndex - // : selectedFunctionIndex; - // if (nodeIndex === null) { - // return; - // } - // handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + _onKeyDown = (event: React.KeyboardEvent) => { + const { + selectedFunctionIndex, + rightClickedFunctionIndex, + threadsKey, + handleFunctionTransformShortcut, + } = this.props; + const funcIndex = + rightClickedFunctionIndex !== null + ? rightClickedFunctionIndex + : selectedFunctionIndex; + if (funcIndex === null) { + return; + } + handleFunctionTransformShortcut(event, threadsKey, funcIndex); }; _onEnterOrDoubleClick = (_nodeId: IndexIntoFuncTable) => { @@ -269,6 +272,7 @@ export const FunctionList = explicitConnect<{}, StateProps, DispatchProps>({ changeRightClickedFunctionIndex, addTransformToStack, updateBottomBoxContentsAndMaybeOpen, + handleFunctionTransformShortcut, onTableViewOptionsChange: (options: TableViewOptions) => changeTableViewOptions('calltree', options), changeFunctionListSort, diff --git a/src/test/components/TransformShortcuts.test.tsx b/src/test/components/TransformShortcuts.test.tsx index aefbcfbd4a..dd17a20645 100644 --- a/src/test/components/TransformShortcuts.test.tsx +++ b/src/test/components/TransformShortcuts.test.tsx @@ -11,6 +11,8 @@ import { storeWithProfile } from '../fixtures/stores'; import { changeSelectedCallNode, changeRightClickedCallNode, + changeSelectedFunctionIndex, + changeRightClickedFunctionIndex, } from '../../actions/profile-view'; import { FlameGraph } from '../../components/flame-graph'; import { selectedThreadSelectors } from 'firefox-profiler/selectors'; @@ -18,6 +20,7 @@ import { ensureExists, objectEntries } from '../../utils/types'; import { fireFullKeyPress } from '../fixtures/utils'; import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context'; import { ProfileCallTreeView } from '../../components/calltree/ProfileCallTreeView'; +import { ProfileFunctionListView } from '../../components/calltree/ProfileFunctionListView'; import { StackChart } from 'firefox-profiler/components/stack-chart'; import type { Transform, @@ -186,6 +189,104 @@ const pressKeyBuilder = (className: string) => (options: KeyPressOptions) => { fireFullKeyPress(div, options); }; +function testFunctionTransformKeyboardShortcuts( + setup: () => { + getTransform: () => null | Transform; + pressKey: (options: KeyPressOptions) => void; + expectedFuncIndex: IndexIntoFuncTable; + expectedResourceIndex: IndexIntoResourceTable; + } +) { + describe('function shortcuts', () => { + it('handles focus-function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'f' }); + expect(getTransform()).toEqual({ + type: 'focus-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles focus-self', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'S' }); + expect(getTransform()).toMatchObject({ + type: 'focus-self', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles merge function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'm' }); + expect(getTransform()).toEqual({ + type: 'merge-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles drop function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'd' }); + expect(getTransform()).toEqual({ + type: 'drop-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse resource', () => { + const { pressKey, getTransform, expectedResourceIndex } = setup(); + pressKey({ key: 'C' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-resource', + resourceIndex: expectedResourceIndex, + }); + }); + + it('handles collapse recursion', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'r' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-recursion', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse direct recursion', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'R' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-direct-recursion', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse function subtree', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'c' }); + expect(getTransform()).toEqual({ + type: 'collapse-function-subtree', + funcIndex: expectedFuncIndex, + }); + }); + + it('does not handle call-node-specific shortcuts', () => { + const { pressKey, getTransform } = setup(); + pressKey({ key: 'F' }); // focus-subtree + pressKey({ key: 'M' }); // merge-call-node + pressKey({ key: 'g' }); // focus-category + expect(getTransform()).toBeNull(); + }); + + it('ignores shortcuts with modifiers', () => { + const { pressKey, getTransform } = setup(); + pressKey({ key: 'c', ctrlKey: true }); + pressKey({ key: 'c', metaKey: true }); + expect(getTransform()).toBeNull(); + }); + }); // end describe('function shortcuts') +} + /* eslint-disable jest/no-standalone-expect */ // Disable the jest/no-standalone-expect rule because eslint doesn't know that // these expectations will run in a test block later. @@ -326,3 +427,78 @@ describe('stack chart transform shortcuts', () => { }); } }); + +/* eslint-disable jest/no-standalone-expect */ +const functionListActions = { + 'a selected function': ( + { dispatch, getState }: Store, + { B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).not.toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).toBeNull(); + }, + 'a right-clicked function': ( + { dispatch, getState }: Store, + { B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, null)); + }); + act(() => { + dispatch(changeRightClickedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).not.toBeNull(); + }, + 'both a selected and a right-clicked function': ( + { dispatch, getState }: Store, + { A, B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, A)); + }); + act(() => { + dispatch(changeRightClickedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).not.toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).not.toBeNull(); + }, +}; +/* eslint-enable jest/no-standalone-expect */ + +describe('function list transform shortcuts', () => { + for (const [name, action] of objectEntries(functionListActions)) { + describe(`with ${name}`, () => { + testFunctionTransformKeyboardShortcuts(() => { + const { store, funcNames, getTransform } = setupStore( + + ); + + const { B } = funcNames; + action(store, funcNames); + + return { + getTransform, + pressKey: pressKeyBuilder('treeViewBody'), + expectedFuncIndex: B, + expectedResourceIndex: 0, + }; + }); + }); + } +}); From 46f1ee6b435ac431765d03e41d129405e6035241 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 19:24:43 -0400 Subject: [PATCH 08/41] Scroll selection into view when applying/unapplying transforms --- src/reducers/profile-view.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index fa2b3fd6c8..d5fb624b2e 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -653,6 +653,8 @@ const scrollToSelectionGeneration: Reducer = (state = 0, action) => { case 'CHANGE_CALL_TREE_SEARCH_STRING': case 'CHANGE_MARKER_SEARCH_STRING': case 'CHANGE_NETWORK_SEARCH_STRING': + case 'ADD_TRANSFORM_TO_STACK': + case 'POP_TRANSFORMS_FROM_STACK': return state + 1; case 'CHANGE_SELECTED_CALL_NODE': case 'CHANGE_SELECTED_FUNCTION': From 242382da939d99ff250798f3c43d3fb18c2d6a67 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 19:35:34 -0400 Subject: [PATCH 09/41] Select self function when clicking in the activity graph --- src/components/timeline/TrackThread.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx index b1f24edaa7..7b713077b9 100644 --- a/src/components/timeline/TrackThread.tsx +++ b/src/components/timeline/TrackThread.tsx @@ -38,6 +38,7 @@ import { changeSelectedCallNode, focusCallTree, selectSelfCallNode, + selectSelfFunction, } from 'firefox-profiler/actions/profile-view'; import { reportTrackThreadHeight } from 'firefox-profiler/actions/app'; import { EmptyThreadIndicator } from './EmptyThreadIndicator'; @@ -100,6 +101,7 @@ type DispatchProps = { readonly changeSelectedCallNode: typeof changeSelectedCallNode; readonly focusCallTree: typeof focusCallTree; readonly selectSelfCallNode: typeof selectSelfCallNode; + readonly selectSelfFunction: typeof selectSelfFunction; readonly reportTrackThreadHeight: typeof reportTrackThreadHeight; }; @@ -123,6 +125,7 @@ class TimelineTrackThreadImpl extends PureComponent { const { threadsKey, selectSelfCallNode, + selectSelfFunction, focusCallTree, selectedThreadIndexes, callTreeVisible, @@ -130,6 +133,7 @@ class TimelineTrackThreadImpl extends PureComponent { // Sample clicking only works for one thread. See issue #2709 if (selectedThreadIndexes.size === 1) { + selectSelfFunction(threadsKey, sampleIndex); selectSelfCallNode(threadsKey, sampleIndex); if (sampleIndex !== null && callTreeVisible) { @@ -368,6 +372,7 @@ export const TimelineTrackThread = explicitConnect< changeSelectedCallNode, focusCallTree, selectSelfCallNode, + selectSelfFunction, reportTrackThreadHeight, }, component: withSize(TimelineTrackThreadImpl), From 7cd34e2902c3e3bcb17f8795eedd53f2fd04c1af Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 20:14:36 -0400 Subject: [PATCH 10/41] Select first row in the function list on mount. --- src/actions/profile-view.ts | 34 ++++++++++++++++++++++++ src/components/calltree/FunctionList.tsx | 12 +++++++++ 2 files changed, 46 insertions(+) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 1e760ef995..f3dc3d45e1 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -236,6 +236,40 @@ export function selectSelfCallNode( }; } +/** + * Like selectSelfCallNode, but selects the function of the self call node + * instead. Used when the function list tab is active. + */ +export function selectSelfFunction( + threadsKey: ThreadsKey, + sampleIndex: IndexIntoSamplesTable | null +): ThunkAction { + return (dispatch, getState) => { + if (sampleIndex === null || sampleIndex < 0) { + dispatch(changeSelectedFunctionIndex(threadsKey, null)); + return; + } + const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); + const sampleCallNodes = + threadSelectors.getSampleIndexToNonInvertedCallNodeIndexForFilteredThread( + getState() + ); + if (sampleIndex >= sampleCallNodes.length) { + dispatch(changeSelectedFunctionIndex(threadsKey, null)); + return; + } + const nonInvertedSelfCallNode = sampleCallNodes[sampleIndex]; + if (nonInvertedSelfCallNode === null) { + dispatch(changeSelectedFunctionIndex(threadsKey, null)); + return; + } + const callNodeInfo = threadSelectors.getCallNodeInfo(getState()); + const funcIndex = + callNodeInfo.getCallNodeTable().func[nonInvertedSelfCallNode]; + dispatch(changeSelectedFunctionIndex(threadsKey, funcIndex)); + }; +} + /** * This selects a set of thread from thread indexes. * Please use it in tests only. diff --git a/src/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx index 21451f58d3..2d7c4c6720 100644 --- a/src/components/calltree/FunctionList.tsx +++ b/src/components/calltree/FunctionList.tsx @@ -136,6 +136,7 @@ class FunctionListImpl extends PureComponent { override componentDidMount() { this.focus(); + this.maybeProcureInitialSelection(); if (this.props.selectedFunctionIndex !== null && this._treeView) { this._treeView.scrollSelectionIntoView(); @@ -159,6 +160,17 @@ class FunctionListImpl extends PureComponent { } } + maybeProcureInitialSelection() { + if (this.props.selectedFunctionIndex !== null) { + return; + } + const { tree, threadsKey, changeSelectedFunctionIndex } = this.props; + const firstRoot = tree.getRoots()[0]; + if (firstRoot !== undefined) { + changeSelectedFunctionIndex(threadsKey, firstRoot, { source: 'auto' }); + } + } + focus() { if (this._treeView) { this._treeView.focus(); From b0a207139aa481499192e7d4b35181885f0a1dbe Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 21:43:21 -0400 Subject: [PATCH 11/41] Implement double-clicking to show the bottom box in the function list. --- src/components/calltree/FunctionList.tsx | 8 +- src/profile-logic/bottom-box.ts | 101 +++++++++++++++++++++++ src/profile-logic/call-tree.ts | 13 ++- src/profile-logic/profile-data.ts | 40 +++++++++ src/test/store/bottom-box.test.ts | 69 +++++++++++++++- 5 files changed, 225 insertions(+), 6 deletions(-) diff --git a/src/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx index 2d7c4c6720..b3cc7357ea 100644 --- a/src/components/calltree/FunctionList.tsx +++ b/src/components/calltree/FunctionList.tsx @@ -211,10 +211,10 @@ class FunctionListImpl extends PureComponent { handleFunctionTransformShortcut(event, threadsKey, funcIndex); }; - _onEnterOrDoubleClick = (_nodeId: IndexIntoFuncTable) => { - // const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; - // const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); - // updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + _onEnterOrDoubleClick = (nodeId: IndexIntoFuncTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForFunction(nodeId); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); }; override render() { diff --git a/src/profile-logic/bottom-box.ts b/src/profile-logic/bottom-box.ts index cbd935ed8f..82214c5631 100644 --- a/src/profile-logic/bottom-box.ts +++ b/src/profile-logic/bottom-box.ts @@ -8,12 +8,14 @@ import type { Thread, IndexIntoStackTable, IndexIntoCallNodeTable, + IndexIntoFuncTable, BottomBoxInfo, SamplesLikeTable, } from 'firefox-profiler/types'; import type { CallNodeInfo } from './call-node-info'; import { getCallNodeFramePerStack, + getFunctionFramePerStack, getNativeSymbolInfo, getNativeSymbolsForCallNode, getOriginalPositionForFrame, @@ -202,3 +204,102 @@ export function getBottomBoxInfoForStackFrame( instructionAddress !== -1 ? instructionAddress : null, }; } + +/** + * Calculate the BottomBoxInfo for a function, i.e. information about which + * things should be shown in the profiler UI's "bottom box" when a function is + * double-clicked in the function list. + * + * Unlike getBottomBoxInfoForCallNode, this considers all stacks where the + * function appears anywhere (not just as the self function), using the + * innermost (leaf-most) frame when the function appears multiple times in one + * stack due to recursion. + */ +export function getBottomBoxInfoForFunction( + funcIndex: IndexIntoFuncTable, + thread: Thread, + samples: SamplesLikeTable +): BottomBoxInfo { + const { + stackTable, + frameTable, + funcTable, + stringTable, + resourceTable, + nativeSymbols, + } = thread; + + const sourceIndex = funcTable.source[funcIndex]; + const resource = funcTable.resource[funcIndex]; + const libIndex = + resource !== -1 && resourceTable.type[resource] === ResourceType.Library + ? resourceTable.lib[resource] + : null; + + const funcFramePerStack = getFunctionFramePerStack( + funcIndex, + stackTable, + frameTable + ); + + const nativeSymbolsForFunc = getNativeSymbolsForCallNode( + funcFramePerStack, + frameTable + ); + let initialNativeSymbol = null; + const nativeSymbolTimings = getTotalNativeSymbolTimingsForCallNode( + samples, + funcFramePerStack, + frameTable + ); + const hottestNativeSymbol = mapGetKeyWithMaxValue(nativeSymbolTimings); + if (hottestNativeSymbol !== undefined) { + nativeSymbolsForFunc.add(hottestNativeSymbol); + initialNativeSymbol = hottestNativeSymbol; + } + const nativeSymbolsForFuncArr = [...nativeSymbolsForFunc]; + nativeSymbolsForFuncArr.sort((a, b) => a - b); + if (nativeSymbolsForFuncArr.length !== 0 && initialNativeSymbol === null) { + initialNativeSymbol = nativeSymbolsForFuncArr[0]; + } + + const nativeSymbolInfosForFunc = nativeSymbolsForFuncArr.map( + (nativeSymbolIndex) => + getNativeSymbolInfo( + nativeSymbolIndex, + nativeSymbols, + frameTable, + stringTable + ) + ); + + const funcLine = funcTable.lineNumber[funcIndex]; + const lineTimings = getTotalLineTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + funcLine + ); + const hottestLine = mapGetKeyWithMaxValue(lineTimings); + const addressTimings = getTotalAddressTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + initialNativeSymbol + ); + const hottestInstructionAddress = mapGetKeyWithMaxValue(addressTimings); + + return { + libIndex, + sourceIndex, + nativeSymbols: nativeSymbolInfosForFunc, + initialNativeSymbol: + initialNativeSymbol !== null + ? nativeSymbolsForFuncArr.indexOf(initialNativeSymbol) + : null, + scrollToLineNumber: hottestLine, + scrollToInstructionAddress: hottestInstructionAddress, + highlightedLineNumber: null, + highlightedInstructionAddress: null, + }; +} diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 1a22e34d5e..9d7b16573a 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -42,7 +42,10 @@ import type { ColumnSortState, SortableColumn, } from '../components/shared/TreeView'; -import { getBottomBoxInfoForCallNode } from './bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from './bottom-box'; type CallNodeChildren = IndexIntoCallNodeTable[]; @@ -677,6 +680,14 @@ export class CallTree { ); } + getBottomBoxInfoForFunction(funcIndex: IndexIntoFuncTable): BottomBoxInfo { + return getBottomBoxInfoForFunction( + funcIndex, + this._thread, + this._previewFilteredCtssSamples + ); + } + /** * Take a IndexIntoCallNodeTable, and compute an inverted path for it. * diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 1c2c1f6c5f..f8aa91b421 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -946,6 +946,46 @@ export function getCallNodeFramePerStackInverted( return callNodeFramePerStack; } +/** + * For each stack, returns the innermost (leaf-most) frame whose function matches + * funcIndex, or -1 if funcIndex doesn't appear in that stack at all. + * + * This is used when double-clicking a function in the function list, to find + * which frame to show in the source and assembly views. When a function appears + * multiple times in a stack (due to recursion), we use the innermost occurrence, + * because that is the one doing the most specific work. + * + * Example: for stack A -> B -> C -> B -> D, asking for func B gives: + * - frame of the B in "C -> B" (the innermost B), not the B in "A -> B" + * + * The algorithm takes advantage of the stack table's ordering (parents before + * children): for each stack, we start with the parent's result and overwrite + * whenever we encounter funcIndex again, so the last write wins (innermost). + */ +export function getFunctionFramePerStack( + funcIndex: IndexIntoFuncTable, + stackTable: StackTable, + frameTable: FrameTable +): Int32Array { + const { frame: frameCol, prefix: prefixCol, length: stackCount } = stackTable; + const funcCol = frameTable.func; + + const funcFramePerStack = new Int32Array(stackCount); + + for (let stackIndex = 0; stackIndex < stackCount; stackIndex++) { + const frame = frameCol[stackIndex]; + if (funcCol[frame] === funcIndex) { + // This stack's own frame matches: it is the innermost so far, overwrite. + funcFramePerStack[stackIndex] = frame; + } else { + // Inherit from parent (or -1 if there is no parent). + const prefix = prefixCol[stackIndex]; + funcFramePerStack[stackIndex] = prefix !== null ? funcFramePerStack[prefix] : -1; + } + } + return funcFramePerStack; +} + /** * Take a samples table, and return an array that contain indexes that point to the * leaf most call node, or null. diff --git a/src/test/store/bottom-box.test.ts b/src/test/store/bottom-box.test.ts index 32a6e1c1a4..b27d251bf4 100644 --- a/src/test/store/bottom-box.test.ts +++ b/src/test/store/bottom-box.test.ts @@ -8,7 +8,10 @@ import * as UrlStateSelectors from '../../selectors/url-state'; import * as ProfileSelectors from '../../selectors/profile'; import { selectedThreadSelectors } from '../../selectors/per-thread'; import { emptyAddressTimings } from '../../profile-logic/address-timings'; -import { getBottomBoxInfoForCallNode } from '../../profile-logic/bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from '../../profile-logic/bottom-box'; import { changeSelectedCallNode, updateBottomBoxContentsAndMaybeOpen, @@ -373,3 +376,67 @@ describe('bottom box', function () { // - A test with multiple threads: Open the assembly view for a symbol, switch // to a different thread, check timings }); + +describe('getBottomBoxInfoForFunction', function () { + it('uses the innermost frame when a function appears multiple times due to recursion (A->B->A->B)', function () { + // Stack: A[line:20] -> B[line:30] -> A[line:21] -> B[line:31] + // B appears at line 30 (outer) and line 31 (inner/leaf). + // Double-clicking B in the function list should use line 31 (innermost). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + A[file:a.js][line:21] + B[file:b.js][line:31] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction(B, thread, thread.samples); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); + + it('uses the innermost frame when a function appears multiple times due to recursion via another function (A->B->C->B->D)', function () { + // Stack: A[line:20] -> B[line:30] -> C[line:40] -> B[line:31] -> D[line:50] + // B appears at line 30 (outer) and line 31 (inner), with C and D in between. + // Double-clicking B in the function list should use line 31 (innermost B). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + C[file:c.js][line:40] + B[file:b.js][line:31] + D[file:d.js][line:50] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction(B, thread, thread.samples); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); + + it('opens the source view when double-clicking a function in the function list', function () { + const { profile, derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + `); + const { dispatch, getState } = storeWithProfile(profile); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + dispatch(changeSelectedTab('function-list')); + + const bottomBoxInfo = getBottomBoxInfoForFunction(B, thread, thread.samples); + dispatch(updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo)); + + expect(UrlStateSelectors.getIsBottomBoxOpen(getState())).toBeTrue(); + expect(UrlStateSelectors.getSourceViewScrollToLineNumber(getState())).toBe( + 30 + ); + }); +}); From 753722cef69a77750dd2bffdf8fb460f251e0237 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 30 Mar 2026 17:48:50 -0400 Subject: [PATCH 12/41] Pass callNodeInfo to handleCallNodeTransformShortcut. --- src/actions/profile-view.ts | 2 +- src/components/calltree/CallTree.tsx | 3 ++- src/components/flame-graph/FlameGraph.tsx | 2 +- src/components/stack-chart/index.tsx | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index f3dc3d45e1..36369cf580 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2092,6 +2092,7 @@ export function toggleBottomBoxFullscreen(): ThunkAction { export function handleCallNodeTransformShortcut( event: React.KeyboardEvent, threadsKey: ThreadsKey, + callNodeInfo: CallNodeInfo, callNodeIndex: IndexIntoCallNodeTable ): ThunkAction { return (dispatch, getState) => { @@ -2100,7 +2101,6 @@ export function handleCallNodeTransformShortcut( } const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); const unfilteredThread = threadSelectors.getThread(getState()); - const callNodeInfo = threadSelectors.getCallNodeInfo(getState()); const implementation = getImplementationFilter(getState()); const inverted = getInvertCallstack(getState()); const callNodePath = callNodeInfo.getCallNodePathFromIndex(callNodeIndex); diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index 3449a34389..7fbca46ba5 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -193,6 +193,7 @@ class CallTreeImpl extends PureComponent { rightClickedCallNodeIndex, handleCallNodeTransformShortcut, threadsKey, + callNodeInfo, } = this.props; const nodeIndex = rightClickedCallNodeIndex !== null @@ -201,7 +202,7 @@ class CallTreeImpl extends PureComponent { if (nodeIndex === null) { return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 376d0f3025..b0c2ad5953 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -302,7 +302,7 @@ class FlameGraphImpl return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onCopy = (event: ClipboardEvent) => { diff --git a/src/components/stack-chart/index.tsx b/src/components/stack-chart/index.tsx index ea20211a15..eeb1347034 100644 --- a/src/components/stack-chart/index.tsx +++ b/src/components/stack-chart/index.tsx @@ -179,7 +179,7 @@ class StackChartImpl extends React.PureComponent { return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onDoubleClick = (callNodeIndex: IndexIntoCallNodeTable | null) => { From 866d8a972c3eaa85427163c82c2b6e57faafccd5 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 30 Mar 2026 17:47:28 -0400 Subject: [PATCH 13/41] Add inverted butterfly wing. --- src/actions/profile-view.ts | 58 ++- src/components/calltree/Butterfly.css | 15 + src/components/calltree/LowerWing.tsx | 345 ++++++++++++++++++ .../calltree/ProfileFunctionListView.tsx | 18 +- src/profile-logic/call-tree.ts | 53 +++ src/reducers/profile-view.ts | 80 ++-- src/selectors/per-thread/stack-sample.ts | 83 +++++ src/selectors/right-clicked-call-node.tsx | 2 + src/test/fixtures/utils.ts | 50 +++ .../__snapshots__/profile-view.test.ts.snap | 4 + src/test/store/profile-view.test.ts | 2 + src/test/unit/profile-tree.test.ts | 42 +++ src/types/actions.ts | 6 +- src/types/state.ts | 5 + 14 files changed, 724 insertions(+), 39 deletions(-) create mode 100644 src/components/calltree/Butterfly.css create mode 100644 src/components/calltree/LowerWing.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 36369cf580..cc40106d38 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -122,7 +122,7 @@ export function changeSelectedCallNode( const isInverted = getInvertCallstack(getState()); dispatch({ type: 'CHANGE_SELECTED_CALL_NODE', - isInverted, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', selectedCallNodePath, optionalExpandedToCallNodePath, threadsKey, @@ -131,6 +131,21 @@ export function changeSelectedCallNode( }; } +export function changeLowerWingSelectedCallNode( + threadsKey: ThreadsKey, + selectedCallNodePath: CallNodePath, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_CALL_NODE', + area: 'LOWER_WING', + selectedCallNodePath, + optionalExpandedToCallNodePath: [], + threadsKey, + context, + }; +} + /** * Select a function for a given thread in the function list. */ @@ -155,11 +170,15 @@ export function changeSelectedFunctionIndex( export function changeRightClickedCallNode( threadsKey: ThreadsKey, callNodePath: CallNodePath | null -): Action { - return { - type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', - threadsKey, - callNodePath, +): ThunkAction { + return (dispatch, getState) => { + const isInverted = getInvertCallstack(getState()); + dispatch({ + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', + callNodePath, + }); }; } @@ -174,6 +193,18 @@ export function changeRightClickedFunctionIndex( }; } +export function changeLowerWingRightClickedCallNode( + threadsKey: ThreadsKey, + callNodePath: CallNodePath | null +) { + return { + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: 'LOWER_WING', + callNodePath, + }; +} + /** * Given a threadIndex and a sampleIndex, select the call node which carries the * sample's self time. In the inverted tree, this will be a root node. @@ -1608,12 +1639,25 @@ export function changeExpandedCallNodes( const isInverted = getInvertCallstack(getState()); dispatch({ type: 'CHANGE_EXPANDED_CALL_NODES', - isInverted, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', threadsKey, expandedCallNodePaths, }); }; } + +export function changeLowerWingExpandedCallNodes( + threadsKey: ThreadsKey, + expandedCallNodePaths: Array +): Action { + return { + type: 'CHANGE_EXPANDED_CALL_NODES', + area: 'LOWER_WING', + threadsKey, + expandedCallNodePaths, + }; +} + export function changeSelectedMarker( threadsKey: ThreadsKey, selectedMarker: MarkerIndex | null, diff --git a/src/components/calltree/Butterfly.css b/src/components/calltree/Butterfly.css new file mode 100644 index 0000000000..d1bc05f276 --- /dev/null +++ b/src/components/calltree/Butterfly.css @@ -0,0 +1,15 @@ +/* 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/. */ + +.butterflyWrapper { + position: relative; + display: flex; + min-height: 0; + flex: 1; +} + +.butterflyWings > .resizableWithSplitterInner { + display: flex; + flex-flow: column nowrap; +} \ No newline at end of file diff --git a/src/components/calltree/LowerWing.tsx b/src/components/calltree/LowerWing.tsx new file mode 100644 index 0000000000..027399eec6 --- /dev/null +++ b/src/components/calltree/LowerWing.tsx @@ -0,0 +1,345 @@ +/* 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 { PureComponent } from 'react'; +import memoize from 'memoize-immutable'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { TreeView } from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { + treeColumnsForTracingMs, + treeColumnsForSamples, + treeColumnsForBytes, +} from './columns'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, +} from 'firefox-profiler/selectors/url-state'; +import { + getScrollToSelectionGeneration, + getCategories, + getCurrentTableViewOptions, + getPreviewSelectionIsBeingModified, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + changeLowerWingSelectedCallNode, + changeLowerWingRightClickedCallNode, + changeLowerWingExpandedCallNodes, + addTransformToStack, + handleCallNodeTransformShortcut, + changeTableViewOptions, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +import type { + State, + ThreadsKey, + CategoryList, + IndexIntoCallNodeTable, + CallNodeDisplayData, + WeightType, + TableViewOptions, + SelectionContext, +} from 'firefox-profiler/types'; +import type { CallTree as CallTreeType } from 'firefox-profiler/profile-logic/call-tree'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + Column, + MaybeResizableColumn, +} from 'firefox-profiler/components/shared/TreeView'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallTree.css'; + +type StateProps = { + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly tree: CallTreeType; + readonly callNodeInfo: CallNodeInfo; + readonly categories: CategoryList; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly expandedCallNodeIndexes: Array; + readonly searchStringsRegExp: RegExp | null; + readonly disableOverscan: boolean; + readonly callNodeMaxDepthPlusOne: number; + readonly weightType: WeightType; + readonly tableViewOptions: TableViewOptions; +}; + +type DispatchProps = { + readonly changeLowerWingSelectedCallNode: typeof changeLowerWingSelectedCallNode; + readonly changeLowerWingRightClickedCallNode: typeof changeLowerWingRightClickedCallNode; + readonly changeLowerWingExpandedCallNodes: typeof changeLowerWingExpandedCallNodes; + readonly addTransformToStack: typeof addTransformToStack; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly onTableViewOptionsChange: (options: TableViewOptions) => any; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class LowerWingImpl extends PureComponent { + _mainColumn: Column = { + propName: 'name', + titleL10nId: '', + }; + _appendageColumn: Column = { + propName: 'lib', + titleL10nId: '', + }; + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView) => + (this._treeView = treeView); + + /** + * Call Trees can have different types of "weights" for the data. Choose the + * appropriate labels for the call tree based on this weight. + */ + _weightTypeToColumns = memoize( + (weightType: WeightType): MaybeResizableColumn[] => { + switch (weightType) { + case 'tracing-ms': + return treeColumnsForTracingMs; + case 'samples': + return treeColumnsForSamples; + case 'bytes': + return treeColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } + }, + // Use a Map cache, as the function only takes one argument, which is a simple string. + { cache: new Map() } + ); + + override componentDidMount() { + this.focus(); + this.maybeProcureInterestingInitialSelection(); + + if (this.props.selectedCallNodeIndex !== null && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + } + + override componentDidUpdate(prevProps: Props) { + this.maybeProcureInterestingInitialSelection(); + + if ( + this.props.selectedCallNodeIndex !== null && + this.props.scrollToSelectionGeneration > + prevProps.scrollToSelectionGeneration && + this._treeView + ) { + this._treeView.scrollSelectionIntoView(); + } + } + + focus() { + if (this._treeView) { + this._treeView.focus(); + } + } + + _onSelectedCallNodeChange = ( + newSelectedCallNode: IndexIntoCallNodeTable, + context: SelectionContext + ) => { + const { callNodeInfo, threadsKey, changeLowerWingSelectedCallNode } = + this.props; + changeLowerWingSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode), + context + ); + }; + + _onRightClickSelection = (newSelectedCallNode: IndexIntoCallNodeTable) => { + const { callNodeInfo, threadsKey, changeLowerWingRightClickedCallNode } = + this.props; + changeLowerWingRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode) + ); + }; + + _onExpandedCallNodesChange = ( + newExpandedCallNodeIndexes: Array + ) => { + const { callNodeInfo, threadsKey, changeLowerWingExpandedCallNodes } = + this.props; + changeLowerWingExpandedCallNodes( + threadsKey, + newExpandedCallNodeIndexes.map((callNodeIndex) => + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ) + ); + }; + + _onKeyDown = (event: React.KeyboardEvent) => { + const { + selectedCallNodeIndex, + rightClickedCallNodeIndex, + callNodeInfo, + handleCallNodeTransformShortcut, + threadsKey, + } = this.props; + const nodeIndex = + rightClickedCallNodeIndex !== null + ? rightClickedCallNodeIndex + : selectedCallNodeIndex; + if (nodeIndex === null) { + return; + } + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); + updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + }; + + maybeProcureInterestingInitialSelection() { + // Expand the heaviest callstack up to a certain depth and select the frame + // at that depth. + const { + tree, + expandedCallNodeIndexes, + selectedCallNodeIndex, + callNodeInfo, + categories, + } = this.props; + + if (selectedCallNodeIndex !== null || expandedCallNodeIndexes.length > 0) { + // Let's not change some existing state. + return; + } + + const idleCategoryIndex = categories.findIndex( + (category) => category.name === 'Idle' + ); + + const newExpandedCallNodeIndexes = expandedCallNodeIndexes.slice(); + const maxInterestingDepth = 17; // scientifically determined + let currentCallNodeIndex = tree.getRoots()[0]; + if (currentCallNodeIndex === undefined) { + // This tree is empty. + return; + } + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + for (let i = 0; i < maxInterestingDepth; i++) { + const children = tree.getChildren(currentCallNodeIndex); + if (children.length === 0) { + break; + } + + // Let's find if there's a non idle children. + const firstNonIdleNode = children.find( + (nodeIndex) => + callNodeInfo.categoryForNode(nodeIndex) !== idleCategoryIndex + ); + + // If there's a non idle children, use it; otherwise use the first + // children (that will be idle). + currentCallNodeIndex = + firstNonIdleNode !== undefined ? firstNonIdleNode : children[0]; + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + } + this._onExpandedCallNodesChange(newExpandedCallNodeIndexes); + + const categoryIndex = callNodeInfo.categoryForNode(currentCallNodeIndex); + if (categoryIndex !== idleCategoryIndex) { + // If we selected the call node with a "idle" category, we'd have a + // completely dimmed activity graph because idle stacks are not drawn in + // this graph. Because this isn't probably what the average user wants we + // do it only when the category is something different. + this._onSelectedCallNodeChange(currentCallNodeIndex, { source: 'auto' }); + } + } + + override render() { + const { + tree, + selectedCallNodeIndex, + rightClickedCallNodeIndex, + expandedCallNodeIndexes, + searchStringsRegExp, + disableOverscan, + callNodeMaxDepthPlusOne, + weightType, + tableViewOptions, + onTableViewOptionsChange, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } +} + +export const LowerWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State) => ({ + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + tree: selectedThreadSelectors.getLowerWingCallTree(state), + callNodeInfo: selectedThreadSelectors.getLowerWingCallNodeInfo(state), + categories: getCategories(state), + selectedCallNodeIndex: + selectedThreadSelectors.getLowerWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getLowerWingRightClickedCallNodeIndex(state), + expandedCallNodeIndexes: + selectedThreadSelectors.getLowerWingExpandedCallNodeIndexes(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + // Use the filtered call node max depth, rather than the preview filtered call node + // max depth so that the width of the TreeView component is stable across preview + // selections. + callNodeMaxDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + }), + mapDispatchToProps: { + changeLowerWingSelectedCallNode, + changeLowerWingRightClickedCallNode, + changeLowerWingExpandedCallNodes, + addTransformToStack, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + }, + component: LowerWingImpl, +}); diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 7695a6c4d2..456e57d9c5 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -3,8 +3,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { FunctionList } from './FunctionList'; +import { LowerWing } from './LowerWing'; import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; +import { ResizableWithSplitter } from '../shared/ResizableWithSplitter'; + +import './Butterfly.css'; export const ProfileFunctionListView = () => (
( > - +
+ + + + + +
); diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 9d7b16573a..4bb91b7402 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -887,6 +887,59 @@ export function computeCallTreeTimingsInverted( }; } +function _computeLowerWingCallNodeSelf( + callNodeSelf: Float64Array, + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable +): Float64Array { + // There is an implicit mapping so that every call node in the non-inverted table is mapped to: + // - either the root-most ancestor whose func is selectedFuncIndex, or + // - -1 if no such ancestor exists + const callNodeCount = callNodeTable.length; + const funcCol = callNodeTable.func; + const subtreeEndCol = callNodeTable.subtreeRangeEnd; + const mappedSelf = new Float64Array(callNodeCount); + for (let i = 0; i < callNodeCount; i++) { + if (funcCol[i] !== selectedFuncIndex) { + continue; + } + + // Call node i is the root of a subtree for the selected function. + const subtreeEnd = subtreeEndCol[i]; + let subtreeTotal = 0; + for (let j = i; j < subtreeEnd; j++) { + subtreeTotal += callNodeSelf[j]; + } + mappedSelf[i] = subtreeTotal; + i = subtreeEnd - 1; + } + return mappedSelf; +} + +export function computeLowerWingTimings( + callNodeInfo: CallNodeInfoInverted, + { callNodeSelf, rootTotalSummary }: CallNodeSelfAndSummary, + selectedFuncIndex: IndexIntoFuncTable | null +): CallTreeTimings { + const callNodeTable = callNodeInfo.getCallNodeTable(); + const mappedSelf = + selectedFuncIndex !== null + ? _computeLowerWingCallNodeSelf( + callNodeSelf, + callNodeTable, + selectedFuncIndex + ) + : new Float64Array(callNodeSelf.length); + + return { + type: 'INVERTED', + timings: computeCallTreeTimingsInverted(callNodeInfo, { + callNodeSelf: mappedSelf, + rootTotalSummary, + }), + }; +} + export function computeCallTreeTimings( callNodeInfo: CallNodeInfo, callNodeSelfAndSummary: CallNodeSelfAndSummary diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index d5fb624b2e..80a8111b06 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -38,7 +38,7 @@ import { } from '../profile-logic/symbolication'; import type { TabSlug } from '../app-logic/tabs-handling'; -import { objectMap } from '../utils/types'; +import { assertExhaustiveCheck, objectMap } from '../utils/types'; const profile: Reducer = (state = null, action) => { switch (action.type) { @@ -187,8 +187,10 @@ const sourceMapSymbolicationStatus: Reducer = ( export const defaultThreadViewOptions: ThreadViewOptions = { selectedNonInvertedCallNodePath: [], selectedInvertedCallNodePath: [], + selectedLowerWingCallNodePath: [], expandedNonInvertedCallNodePaths: new PathSet(), expandedInvertedCallNodePaths: new PathSet(), + expandedLowerWingCallNodePaths: new PathSet(), selectedFunctionIndex: null, selectedNetworkMarker: null, lastSeenTransformCount: 0, @@ -256,7 +258,7 @@ const viewOptionsPerThread: Reducer = ( } case 'CHANGE_SELECTED_CALL_NODE': { const { - isInverted, + area, selectedCallNodePath, threadsKey, optionalExpandedToCallNodePath, @@ -264,9 +266,11 @@ const viewOptionsPerThread: Reducer = ( const threadState = _getThreadViewOptions(state, threadsKey); - const previousSelectedCallNodePath = isInverted - ? threadState.selectedInvertedCallNodePath - : threadState.selectedNonInvertedCallNodePath; + const previousSelectedCallNodePath = { + INVERTED_TREE: threadState.selectedInvertedCallNodePath, + NON_INVERTED_TREE: threadState.selectedNonInvertedCallNodePath, + LOWER_WING: threadState.selectedLowerWingCallNodePath, + }[area]; // If the selected node doesn't actually change, let's return the previous // state to avoid rerenders. @@ -277,9 +281,11 @@ const viewOptionsPerThread: Reducer = ( return state; } - let expandedCallNodePaths = isInverted - ? threadState.expandedInvertedCallNodePaths - : threadState.expandedNonInvertedCallNodePaths; + let expandedCallNodePaths = { + INVERTED_TREE: threadState.expandedInvertedCallNodePaths, + NON_INVERTED_TREE: threadState.expandedNonInvertedCallNodePaths, + LOWER_WING: threadState.expandedLowerWingCallNodePaths, + }[area]; const expandToNode = optionalExpandedToCallNodePath ? optionalExpandedToCallNodePath : selectedCallNodePath; @@ -303,19 +309,25 @@ const viewOptionsPerThread: Reducer = ( ); } - return _updateThreadViewOptions( - state, - threadsKey, - isInverted - ? { - selectedInvertedCallNodePath: selectedCallNodePath, - expandedInvertedCallNodePaths: expandedCallNodePaths, - } - : { - selectedNonInvertedCallNodePath: selectedCallNodePath, - expandedNonInvertedCallNodePaths: expandedCallNodePaths, - } - ); + switch (area) { + case 'INVERTED_TREE': + return _updateThreadViewOptions(state, threadsKey, { + selectedInvertedCallNodePath: selectedCallNodePath, + expandedInvertedCallNodePaths: expandedCallNodePaths, + }); + case 'NON_INVERTED_TREE': + return _updateThreadViewOptions(state, threadsKey, { + selectedNonInvertedCallNodePath: selectedCallNodePath, + expandedNonInvertedCallNodePaths: expandedCallNodePaths, + }); + case 'LOWER_WING': + return _updateThreadViewOptions(state, threadsKey, { + selectedLowerWingCallNodePath: selectedCallNodePath, + expandedLowerWingCallNodePaths: expandedCallNodePaths, + }); + default: + throw assertExhaustiveCheck(area, 'Unhandled case'); + } } case 'CHANGE_SELECTED_FUNCTION': { const { selectedFunctionIndex, threadsKey } = action; @@ -368,16 +380,25 @@ const viewOptionsPerThread: Reducer = ( }); } case 'CHANGE_EXPANDED_CALL_NODES': { - const { threadsKey, isInverted } = action; + const { threadsKey, area } = action; const expandedCallNodePaths = new PathSet(action.expandedCallNodePaths); - return _updateThreadViewOptions( - state, - threadsKey, - isInverted - ? { expandedInvertedCallNodePaths: expandedCallNodePaths } - : { expandedNonInvertedCallNodePaths: expandedCallNodePaths } - ); + switch (area) { + case 'INVERTED_TREE': + return _updateThreadViewOptions(state, threadsKey, { + expandedInvertedCallNodePaths: expandedCallNodePaths, + }); + case 'NON_INVERTED_TREE': + return _updateThreadViewOptions(state, threadsKey, { + expandedNonInvertedCallNodePaths: expandedCallNodePaths, + }); + case 'LOWER_WING': + return _updateThreadViewOptions(state, threadsKey, { + expandedLowerWingCallNodePaths: expandedCallNodePaths, + }); + default: + throw assertExhaustiveCheck(area, 'Unhandled case'); + } } case 'CHANGE_SELECTED_NETWORK_MARKER': { const { threadsKey, selectedNetworkMarker } = action; @@ -780,6 +801,7 @@ const rightClickedCallNode: Reducer = ( if (action.callNodePath !== null) { return { threadsKey: action.threadsKey, + area: action.area, callNodePath: action.callNodePath, }; } diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 181868fac1..4925f7249a 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -145,6 +145,8 @@ export function getStackAndSampleSelectorsPerThread( const _getCallNodeTable: Selector = (state) => _getNonInvertedCallNodeInfo(state).getCallNodeTable(); + const getLowerWingCallNodeInfo = _getInvertedCallNodeInfo; + const _getCallNodeFuncIsDuplicate: Selector = createSelector( _getCallNodeTable, @@ -217,6 +219,13 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getLowerWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): CallNodePath => + threadViewOptions.selectedLowerWingCallNodePath + ); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -235,6 +244,15 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getLowerWingSelectedCallNodeIndex: Selector = + createSelector( + getLowerWingCallNodeInfo, + getLowerWingSelectedCallNodePath, + (callNodeInfo, callNodePath) => { + return callNodeInfo.getCallNodeIndexFromPath(callNodePath); + } + ); + const getExpandedCallNodePaths: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -244,6 +262,11 @@ export function getStackAndSampleSelectorsPerThread( : threadViewOptions.expandedNonInvertedCallNodePaths ); + const getLowerWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedLowerWingCallNodePaths + ); + const getExpandedCallNodeIndexes: Selector< Array > = createSelector( @@ -255,6 +278,17 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getLowerWingExpandedCallNodeIndexes: Selector< + Array + > = createSelector( + getLowerWingCallNodeInfo, + getLowerWingExpandedCallNodePaths, + (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + const _getSampleIndexToNonInvertedCallNodeIndexForPreviewFilteredCtssThread: Selector< Array > = createSelector( @@ -363,6 +397,14 @@ export function getStackAndSampleSelectorsPerThread( CallTree.computeCallTreeTimings ); + const _getLowerWingCallTreeTimings: Selector = + createSelector( + _getInvertedCallNodeInfo, + getCallNodeSelfAndSummary, + getSelectedFunctionIndex, + CallTree.computeLowerWingTimings + ); + const getCallTreeTimingsNonInverted: Selector = createSelector( getCallNodeInfo, @@ -416,6 +458,16 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getLowerWingCallTree: Selector = createSelector( + threadSelectors.getPreviewFilteredThread, + getLowerWingCallNodeInfo, + ProfileSelectors.getCategories, + threadSelectors.getPreviewFilteredCtssSamples, + _getLowerWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + const getSourceViewLineTimings: Selector = createSelector( getSourceViewStackLineInfo, threadSelectors.getPreviewFilteredCtssSamples, @@ -510,6 +562,30 @@ export function getStackAndSampleSelectorsPerThread( if ( rightClickedCallNodeInfo !== null && threadsKey === rightClickedCallNodeInfo.threadsKey + ) { + const expectedArea = callNodeInfo.isInverted() + ? 'INVERTED_TREE' + : 'NON_INVERTED_TREE'; + if (rightClickedCallNodeInfo.area === expectedArea) { + return callNodeInfo.getCallNodeIndexFromPath( + rightClickedCallNodeInfo.callNodePath + ); + } + } + + return null; + } + ); + + const getLowerWingRightClickedCallNodeIndex: Selector = + createSelector( + getRightClickedCallNodeInfo, + getCallNodeInfo, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.threadsKey === threadsKey && + rightClickedCallNodeInfo.area === 'LOWER_WING' ) { return callNodeInfo.getCallNodeIndexFromPath( rightClickedCallNodeInfo.callNodePath @@ -541,14 +617,19 @@ export function getStackAndSampleSelectorsPerThread( unfilteredSamplesRange, getWeightTypeForCallTree, getCallNodeInfo, + getLowerWingCallNodeInfo, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getLowerWingSelectedCallNodePath, + getLowerWingSelectedCallNodeIndex, getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, + getLowerWingExpandedCallNodePaths, + getLowerWingExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, getSampleSelectedStatesForFunctionListTab, @@ -556,6 +637,7 @@ export function getStackAndSampleSelectorsPerThread( getCallTree, getFunctionListTree, getFunctionListTimings, + getLowerWingCallTree, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, @@ -566,5 +648,6 @@ export function getStackAndSampleSelectorsPerThread( getFlameGraphTiming, getRightClickedCallNodeIndex, getRightClickedFunctionIndex, + getLowerWingRightClickedCallNodeIndex, }; } diff --git a/src/selectors/right-clicked-call-node.tsx b/src/selectors/right-clicked-call-node.tsx index a72dc73d35..f793e4df86 100644 --- a/src/selectors/right-clicked-call-node.tsx +++ b/src/selectors/right-clicked-call-node.tsx @@ -9,10 +9,12 @@ import type { ThreadsKey, CallNodePath, Selector, + CallNodeArea, } from 'firefox-profiler/types'; export type RightClickedCallNodeInfo = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath; }; diff --git a/src/test/fixtures/utils.ts b/src/test/fixtures/utils.ts index 31b3093efa..d6ee431804 100644 --- a/src/test/fixtures/utils.ts +++ b/src/test/fixtures/utils.ts @@ -7,6 +7,7 @@ import { computeCallNodeSelfAndSummary, computeCallTreeTimings, computeFunctionListTimings, + computeLowerWingTimings, type CallTree, } from 'firefox-profiler/profile-logic/call-tree'; import { getEmptyThread } from 'firefox-profiler/profile-logic/data-structures'; @@ -265,6 +266,55 @@ export function functionListTreeFromProfile( ); } +/** + * This function creates the "lower wing" CallTree for a profile and a selected + * function. The lower wing is an inverted call tree where each root's total + * counts only samples where the selected function appears in the call stack. + */ +export function lowerWingTreeFromProfile( + profile: Profile, + selectedFuncName: string, + threadIndex: number = 0 +): CallTree { + const { derivedThreads, defaultCategory } = getProfileWithDicts(profile); + const thread = derivedThreads[threadIndex]; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + const invertedCallNodeInfo = getInvertedCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length + ); + const selectedFunc = + thread.funcTable.name.findIndex( + (i) => thread.stringTable.getString(i) === selectedFuncName + ) ?? null; + const selfAndSummary = computeCallNodeSelfAndSummary( + thread.samples, + getSampleIndexToCallNodeIndex( + thread.samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ), + callNodeInfo.getCallNodeTable().length + ); + const timings = computeLowerWingTimings( + invertedCallNodeInfo, + selfAndSummary, + selectedFunc === -1 ? null : selectedFunc + ); + return getCallTree( + thread, + invertedCallNodeInfo, + ensureExists(profile.meta.categories), + thread.samples, + timings, + 'samples' + ); +} + /** * This function formats a call tree into a human readable form, to make it easy * to assert certain relationships about the data structure in a really terse diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index d4702f22e1..2accdd4f7c 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4417,6 +4417,9 @@ Object { "expandedInvertedCallNodePaths": PathSet { "_table": Map {}, }, + "expandedLowerWingCallNodePaths": PathSet { + "_table": Map {}, + }, "expandedNonInvertedCallNodePaths": PathSet { "_table": Map { "0" => Array [ @@ -4431,6 +4434,7 @@ Object { "lastSeenTransformCount": 1, "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], + "selectedLowerWingCallNodePath": Array [], "selectedNetworkMarker": null, "selectedNonInvertedCallNodePath": Array [ 0, diff --git a/src/test/store/profile-view.test.ts b/src/test/store/profile-view.test.ts index 2b830e08c5..84fd1c41b7 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -3597,6 +3597,7 @@ describe('right clicked call node info', () => { expect(getRightClickedCallNodeInfo(getState())).toEqual({ threadsKey: 0, + area: 'NON_INVERTED_TREE', callNodePath: [0, 1], }); }); @@ -3608,6 +3609,7 @@ describe('right clicked call node info', () => { expect(getRightClickedCallNodeInfo(getState())).toEqual({ threadsKey: 0, + area: 'NON_INVERTED_TREE', callNodePath: [0, 1], }); diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index de37b8d418..aad3511810 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -24,6 +24,7 @@ import { ResourceType } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, addSourceToTable, @@ -579,6 +580,47 @@ describe('function list', function () { }); }); +describe('lower wing', function () { + // Samples: A->B->C, A->B->D, A->E->C, A->E->F + const textSamples = ` + A A A A + B B E E + C D C F + `; + + it('shows callers of the selected function as inverted roots', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // Select C: C has self-time in both A->B->C and A->E->C, so C becomes the + // inverted root with total 2. Its callers B and E appear as children. + const callTree = lowerWingTreeFromProfile(profile, 'C'); + expect(formatTree(callTree)).toEqual([ + '- C (total: 2, self: 2)', + ' - B (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - E (total: 1, self: —)', + ' - A (total: 1, self: —)', + ]); + }); + + it('only counts samples where the selected function is present', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // Select B: the self-time of B's subtree (C and D) gets attributed to B in + // the non-inverted table, so the inverted tree shows B as the root with + // total 2, and its caller A as a child. + const callTree = lowerWingTreeFromProfile(profile, 'B'); + expect(formatTree(callTree)).toEqual([ + '- B (total: 2, self: 2)', + ' - A (total: 2, self: —)', + ]); + }); + + it('returns an empty tree when no function is selected', function () { + const { profile } = getProfileFromTextSamples(textSamples); + const callTree = lowerWingTreeFromProfile(profile, 'NONEXISTENT'); + expect(formatTree(callTree)).toEqual([]); + }); +}); + describe('diffing trees', function () { function getProfile() { return getMergedProfileFromTextSamples([ diff --git a/src/types/actions.ts b/src/types/actions.ts index 9ceb650bc5..703d98e9b7 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -47,6 +47,7 @@ import type { ApiQueryError, TableViewOptions, DecodedInstruction, + CallNodeArea, } from './state'; import type { CssPixels, StartEndRange, Milliseconds } from './units'; import type { BrowserConnectionStatus } from '../app-logic/browser-connection'; @@ -187,7 +188,7 @@ type ProfileAction = } | { readonly type: 'CHANGE_SELECTED_CALL_NODE'; - readonly isInverted: boolean; + readonly area: CallNodeArea; readonly threadsKey: ThreadsKey; readonly selectedCallNodePath: CallNodePath; readonly optionalExpandedToCallNodePath: CallNodePath | undefined; @@ -207,6 +208,7 @@ type ProfileAction = | { readonly type: 'CHANGE_RIGHT_CLICKED_CALL_NODE'; readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath | null; } | { @@ -220,7 +222,7 @@ type ProfileAction = | { readonly type: 'CHANGE_EXPANDED_CALL_NODES'; readonly threadsKey: ThreadsKey; - readonly isInverted: boolean; + readonly area: CallNodeArea; readonly expandedCallNodePaths: Array; } | { diff --git a/src/types/state.ts b/src/types/state.ts index a73b8b4772..216a5a93e6 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -63,6 +63,8 @@ export type ThreadViewOptions = { readonly expandedNonInvertedCallNodePaths: PathSet; readonly expandedInvertedCallNodePaths: PathSet; readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly selectedLowerWingCallNodePath: CallNodePath; + readonly expandedLowerWingCallNodePaths: PathSet; readonly selectedNetworkMarker: MarkerIndex | null; // Track the number of transforms to detect when they change via browser // navigation. This helps us know when to reset paths that may be invalid @@ -80,8 +82,11 @@ export type TableViewOptions = { export type TableViewOptionsPerTab = { [K in TabSlug]: TableViewOptions }; +export type CallNodeArea = 'NON_INVERTED_TREE' | 'INVERTED_TREE' | 'LOWER_WING'; + export type RightClickedCallNode = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath; }; From b595afc207ea238b0c6a8a036ba19497bf9ee994 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 21:10:51 -0400 Subject: [PATCH 14/41] Add context menu for inverted wing --- src/actions/profile-view.ts | 2 +- src/components/app/Details.tsx | 2 + src/components/calltree/LowerWing.tsx | 2 +- .../shared/LowerWingContextMenu.tsx | 489 ++++++++++++++++++ src/selectors/per-thread/stack-sample.ts | 25 +- .../components/LowerWingContextMenu.test.tsx | 119 +++++ 6 files changed, 636 insertions(+), 3 deletions(-) create mode 100644 src/components/shared/LowerWingContextMenu.tsx create mode 100644 src/test/components/LowerWingContextMenu.test.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index cc40106d38..4f68f55c0f 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -196,7 +196,7 @@ export function changeRightClickedFunctionIndex( export function changeLowerWingRightClickedCallNode( threadsKey: ThreadsKey, callNodePath: CallNodePath | null -) { +): Action { return { type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', threadsKey, diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index 85d92cc99a..0f58c97955 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -28,6 +28,7 @@ import { getIsSidebarOpen } from 'firefox-profiler/selectors/app'; import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { CallNodeContextMenu } from 'firefox-profiler/components/shared/CallNodeContextMenu'; import { FunctionListContextMenu } from 'firefox-profiler/components/shared/FunctionListContextMenu'; +import { LowerWingContextMenu } from 'firefox-profiler/components/shared/LowerWingContextMenu'; import { MaybeMarkerContextMenu } from 'firefox-profiler/components/shared/MarkerContextMenu'; import { toValidTabSlug } from 'firefox-profiler/utils/types'; @@ -137,6 +138,7 @@ class ProfileViewerImpl extends PureComponent { +
); diff --git a/src/components/calltree/LowerWing.tsx b/src/components/calltree/LowerWing.tsx index 027399eec6..0f59ecb587 100644 --- a/src/components/calltree/LowerWing.tsx +++ b/src/components/calltree/LowerWing.tsx @@ -294,7 +294,7 @@ class LowerWingImpl extends PureComponent { highlightRegExp={searchStringsRegExp} disableOverscan={disableOverscan} ref={this._takeTreeViewRef} - contextMenuId="CallNodeContextMenu" + contextMenuId="LowerWingContextMenu" maxNodeDepth={callNodeMaxDepthPlusOne} rowHeight={16} indentWidth={10} diff --git a/src/components/shared/LowerWingContextMenu.tsx b/src/components/shared/LowerWingContextMenu.tsx new file mode 100644 index 0000000000..8c31ef6c3e --- /dev/null +++ b/src/components/shared/LowerWingContextMenu.tsx @@ -0,0 +1,489 @@ +/* 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/. */ +import * as React from 'react'; +import { PureComponent } from 'react'; +import { MenuItem } from '@firefox-devtools/react-contextmenu'; +import { Localized } from '@fluent/react'; + +import { ContextMenu } from './ContextMenu'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + funcHasDirectRecursiveCall, + funcHasRecursiveCall, +} from 'firefox-profiler/profile-logic/transforms'; +import { getFunctionName } from 'firefox-profiler/profile-logic/function-info'; + +import copy from 'copy-to-clipboard'; +import { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, +} from 'firefox-profiler/actions/profile-view'; +import { getImplementationFilter } from 'firefox-profiler/selectors/url-state'; +import { getThreadSelectorsFromThreadsKey } from 'firefox-profiler/selectors/per-thread'; +import { getShouldDisplaySearchfox } from 'firefox-profiler/selectors/profile'; +import { getRightClickedCallNodeInfo } from 'firefox-profiler/selectors/right-clicked-call-node'; +import { oneLine } from 'common-tags'; + +import { + convertToTransformType, + assertExhaustiveCheck, +} from 'firefox-profiler/utils/types'; + +import type { + TransformType, + ImplementationFilter, + IndexIntoFuncTable, + Thread, + ThreadsKey, + CallNodeTable, + State, +} from 'firefox-profiler/types'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallNodeContextMenu.css'; + +type StateProps = { + readonly thread: Thread | null; + readonly threadsKey: ThreadsKey | null; + readonly rightClickedFuncIndex: IndexIntoFuncTable | null; + readonly callNodeTable: CallNodeTable | null; + readonly implementation: ImplementationFilter; + readonly displaySearchfox: boolean; +}; + +type DispatchProps = { + readonly addTransformToStack: typeof addTransformToStack; + readonly addCollapseResourceTransformToStack: typeof addCollapseResourceTransformToStack; + readonly setContextMenuVisibility: typeof setContextMenuVisibility; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class LowerWingContextMenuImpl extends PureComponent { + _hidingTimeout: NodeJS.Timeout | null = null; + + _onShow = () => { + if (this._hidingTimeout) { + clearTimeout(this._hidingTimeout); + } + this.props.setContextMenuVisibility(true); + }; + + _onHide = () => { + this._hidingTimeout = setTimeout(() => { + this._hidingTimeout = null; + this.props.setContextMenuVisibility(false); + }); + }; + + _getRightClickedInfo(): null | { + readonly thread: Thread; + readonly threadsKey: ThreadsKey; + readonly funcIndex: IndexIntoFuncTable; + readonly callNodeTable: CallNodeTable; + } { + const { thread, threadsKey, rightClickedFuncIndex, callNodeTable } = + this.props; + if ( + thread !== null && + threadsKey !== null && + rightClickedFuncIndex !== null && + callNodeTable !== null + ) { + return { + thread, + threadsKey, + funcIndex: rightClickedFuncIndex, + callNodeTable, + }; + } + return null; + } + + _getFunctionName(): string { + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { + thread: { stringTable, funcTable }, + funcIndex, + } = info; + const isJS = funcTable.isJS[funcIndex]; + const functionCall = stringTable.getString(funcTable.name[funcIndex]); + return isJS ? functionCall : getFunctionName(functionCall); + } + + lookupFunctionOnSearchfox(): void { + window.open( + `https://searchfox.org/mozilla-central/search?q=${encodeURIComponent( + this._getFunctionName() + )}`, + '_blank' + ); + } + + copyFunctionName(): void { + copy(this._getFunctionName()); + } + + getNameForSelectedResource(): string | null { + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { + thread: { funcTable, stringTable, resourceTable, sources }, + funcIndex, + } = info; + const isJS = funcTable.isJS[funcIndex]; + if (isJS) { + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex === null) { + return null; + } + return stringTable.getString(sources.filename[sourceIndex]); + } + const resourceIndex = funcTable.resource[funcIndex]; + if (resourceIndex === -1) { + return null; + } + return stringTable.getString(resourceTable.name[resourceIndex]); + } + + addTransformToStack(type: TransformType): void { + const { + addTransformToStack, + addCollapseResourceTransformToStack, + implementation, + } = this.props; + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { threadsKey, thread, funcIndex } = info; + + switch (type) { + case 'focus-function': + addTransformToStack(threadsKey, { + type: 'focus-function', + funcIndex, + }); + break; + case 'focus-self': + addTransformToStack(threadsKey, { + type: 'focus-self', + funcIndex, + implementation, + }); + break; + case 'merge-function': + addTransformToStack(threadsKey, { + type: 'merge-function', + funcIndex, + }); + break; + case 'drop-function': + addTransformToStack(threadsKey, { + type: 'drop-function', + funcIndex, + }); + break; + case 'collapse-resource': { + const resourceIndex = thread.funcTable.resource[funcIndex]; + addCollapseResourceTransformToStack( + threadsKey, + resourceIndex, + implementation + ); + break; + } + case 'collapse-direct-recursion': + addTransformToStack(threadsKey, { + type: 'collapse-direct-recursion', + funcIndex, + implementation, + }); + break; + case 'collapse-recursion': + addTransformToStack(threadsKey, { + type: 'collapse-recursion', + funcIndex, + }); + break; + case 'collapse-function-subtree': + addTransformToStack(threadsKey, { + type: 'collapse-function-subtree', + funcIndex, + }); + break; + case 'focus-subtree': + case 'merge-call-node': + case 'focus-category': + case 'filter-samples': + throw new Error( + `The transform "${type}" is not supported in the lower wing context menu.` + ); + default: + assertExhaustiveCheck(type); + } + } + + _handleClick = ( + _event: React.ChangeEvent, + data: { type: string } + ): void => { + const { type } = data; + + const transformType = convertToTransformType(type); + if (transformType) { + this.addTransformToStack(transformType); + return; + } + + switch (type) { + case 'searchfox': + this.lookupFunctionOnSearchfox(); + break; + case 'copy-function-name': + this.copyFunctionName(); + break; + default: + throw new Error(`Unknown type ${type}`); + } + }; + + renderTransformMenuItem(props: { + readonly l10nId: string; + readonly content: React.ReactNode; + readonly onClick: ( + event: React.ChangeEvent, + data: { type: string } + ) => void; + readonly transform: string; + readonly shortcut: string; + readonly icon: string; + readonly title: string; + readonly l10nVars?: Record; + readonly l10nElems?: Record; + }) { + return ( + + + +
+ {props.content} +
+
+ {props.shortcut} +
+ ); + } + + renderContextMenuContents() { + const { displaySearchfox } = this.props; + const info = this._getRightClickedInfo(); + + if (info === null) { + console.error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + return
; + } + + const { funcIndex, callNodeTable } = info; + const nameForResource = this.getNameForSelectedResource(); + + return ( + <> + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-merge-function', + shortcut: 'm', + icon: 'Merge', + onClick: this._handleClick, + transform: 'merge-function', + title: '', + content: 'Merge function', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-focus-function', + shortcut: 'f', + icon: 'Focus', + onClick: this._handleClick, + transform: 'focus-function', + title: '', + content: 'Focus on function', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-focus-self', + shortcut: 'S', + icon: 'FocusSelf', + onClick: this._handleClick, + transform: 'focus-self', + title: '', + content: 'Focus on self only', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree', + shortcut: 'c', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-function-subtree', + title: '', + content: 'Collapse function', + })} + + {nameForResource + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-resource', + l10nVars: { nameForResource }, + l10nElems: { strong: }, + shortcut: 'C', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-resource', + title: '', + content: `Collapse ${nameForResource}`, + }) + : null} + + {funcHasRecursiveCall(callNodeTable, funcIndex) + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-recursion', + shortcut: 'r', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-recursion', + title: '', + content: 'Collapse recursion', + }) + : null} + + {funcHasDirectRecursiveCall(callNodeTable, funcIndex) + ? this.renderTransformMenuItem({ + l10nId: + 'CallNodeContextMenu--transform-collapse-direct-recursion-only', + shortcut: 'R', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-direct-recursion', + title: '', + content: 'Collapse direct recursion only', + }) + : null} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-drop-function', + shortcut: 'd', + icon: 'Drop', + onClick: this._handleClick, + transform: 'drop-function', + title: '', + content: 'Drop samples with this function', + })} + +
+ + {displaySearchfox ? ( + + + Look up the function name on Searchfox + + + ) : null} + + + Copy function name + + + + ); + } + + override render() { + if (this._getRightClickedInfo() === null) { + return null; + } + + return ( + + {this.renderContextMenuContents()} + + ); + } +} + +export const LowerWingContextMenu = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State) => { + const rightClickedCallNodeInfo = getRightClickedCallNodeInfo(state); + + let thread = null; + let threadsKey = null; + let rightClickedFuncIndex = null; + let callNodeTable = null; + + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.area === 'LOWER_WING' + ) { + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedCallNodeInfo.threadsKey + ); + thread = selectors.getFilteredThread(state); + threadsKey = rightClickedCallNodeInfo.threadsKey; + rightClickedFuncIndex = + selectors.getLowerWingRightClickedFuncIndex(state); + // Use the non-inverted call node table for recursion detection. + callNodeTable = selectors.getCallNodeInfo(state).getCallNodeTable(); + } + + return { + thread, + threadsKey, + rightClickedFuncIndex, + callNodeTable, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + }, + mapDispatchToProps: { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, + }, + component: LowerWingContextMenuImpl, +}); diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 4925f7249a..932e75bcbb 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -580,7 +580,7 @@ export function getStackAndSampleSelectorsPerThread( const getLowerWingRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, - getCallNodeInfo, + getLowerWingCallNodeInfo, (rightClickedCallNodeInfo, callNodeInfo) => { if ( rightClickedCallNodeInfo !== null && @@ -596,6 +596,28 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getLowerWingRightClickedFuncIndex: Selector = + createSelector( + getRightClickedCallNodeInfo, + getLowerWingCallNodeInfo, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo === null || + rightClickedCallNodeInfo.threadsKey !== threadsKey || + rightClickedCallNodeInfo.area !== 'LOWER_WING' + ) { + return null; + } + const callNodeIndex = callNodeInfo.getCallNodeIndexFromPath( + rightClickedCallNodeInfo.callNodePath + ); + if (callNodeIndex === null) { + return null; + } + return callNodeInfo.funcForNode(callNodeIndex); + } + ); + const getRightClickedFunctionIndex: Selector = createSelector( ProfileSelectors.getProfileViewOptions, @@ -649,5 +671,6 @@ export function getStackAndSampleSelectorsPerThread( getRightClickedCallNodeIndex, getRightClickedFunctionIndex, getLowerWingRightClickedCallNodeIndex, + getLowerWingRightClickedFuncIndex, }; } diff --git a/src/test/components/LowerWingContextMenu.test.tsx b/src/test/components/LowerWingContextMenu.test.tsx new file mode 100644 index 0000000000..2094e95275 --- /dev/null +++ b/src/test/components/LowerWingContextMenu.test.tsx @@ -0,0 +1,119 @@ +/* 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/. */ + +import { Provider } from 'react-redux'; + +import { render, screen } from 'firefox-profiler/test/fixtures/testing-library'; +import { LowerWingContextMenu } from '../../components/shared/LowerWingContextMenu'; +import { storeWithProfile } from '../fixtures/stores'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { fireFullClick } from '../fixtures/utils'; +import { + changeLowerWingRightClickedCallNode, + setContextMenuVisibility, +} from '../../actions/profile-view'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; +import { ensureExists } from '../../utils/types'; + +describe('LowerWingContextMenu', function () { + // Samples: A->B->C, A->E->C + // When C is selected, the lower wing (inverted) tree shows: + // C (root/self function) + // B (caller of C) + // A + // E (caller of C) + // A + // + // Right-clicking B (an inverted child = caller) should give a context menu + // for B, not C. + function createStore() { + const { + profile, + funcNamesDictPerThread: [{ B, C }], + } = getProfileFromTextSamples(` + A A + B E + C C + `); + const store = storeWithProfile(profile); + + // The inverted call node path for B-as-caller-of-C is [C, B]. + const threadsKey = 0; + store.dispatch( + changeLowerWingRightClickedCallNode(threadsKey, [C, B]) + ); + return store; + } + + function setup(store = createStore()) { + store.dispatch(setContextMenuVisibility(true)); + const renderResult = render( + + + + ); + return { ...renderResult, getState: store.getState }; + } + + describe('basic rendering', function () { + it('does not render when no node is right-clicked', () => { + const store = storeWithProfile(getProfileFromTextSamples('A').profile); + store.dispatch(setContextMenuVisibility(true)); + const { container } = render( + + + + ); + expect(container.querySelector('.react-contextmenu')).toBeNull(); + }); + + it('renders a context menu when a node is right-clicked', () => { + const { container } = setup(); + expect( + ensureExists( + container.querySelector('.react-contextmenu'), + `Couldn't find the context menu root component .react-contextmenu` + ).children.length > 1 + ).toBeTruthy(); + }); + + it('does not include call-node-specific transforms', () => { + setup(); + expect(screen.queryByText(/Merge node only/)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Focus on subtree only/) + ).not.toBeInTheDocument(); + expect(screen.queryByText(/Expand all/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Copy stack/)).not.toBeInTheDocument(); + }); + }); + + describe('clicking on transforms', function () { + it('applies transforms to function B, not to the selected function C', function () { + const { getState } = setup(); + fireFullClick(screen.getByText(/Merge function/)); + const transform = selectedThreadSelectors.getTransformStack(getState())[0]; + expect(transform.type).toBe('merge-function'); + // The transform should target B (the right-clicked caller), not C (the root). + if (transform.type === 'merge-function') { + const { + funcNamesDictPerThread: [{ B }], + } = getProfileFromTextSamples(` + A A + B E + C C + `); + expect(transform.funcIndex).toBe(B); + } + }); + + it('adds a focus-function transform for the right-clicked node', function () { + const { getState } = setup(); + fireFullClick(screen.getByText(/Focus on function/)); + expect( + selectedThreadSelectors.getTransformStack(getState())[0].type + ).toBe('focus-function'); + }); + }); +}); From 39182999666430e0beba32ca4b92def1d0d8ffc4 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 30 Mar 2026 17:47:55 -0400 Subject: [PATCH 15/41] Add the upper butterfly wing. --- src/actions/profile-view.ts | 39 ++ .../calltree/ProfileFunctionListView.tsx | 3 +- src/components/calltree/UpperWing.tsx | 345 ++++++++++++++++ src/profile-logic/profile-data.ts | 386 +++++++++++++++++- src/reducers/profile-view.ts | 27 ++ src/selectors/per-thread/stack-sample.ts | 129 ++++++ src/selectors/right-clicked-call-node.tsx | 2 +- src/test/fixtures/utils.ts | 50 +++ .../__snapshots__/profile-view.test.ts.snap | 4 + src/test/unit/profile-tree.test.ts | 36 ++ src/types/state.ts | 9 +- 11 files changed, 1022 insertions(+), 8 deletions(-) create mode 100644 src/components/calltree/UpperWing.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 4f68f55c0f..b337984531 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -146,6 +146,21 @@ export function changeLowerWingSelectedCallNode( }; } +export function changeUpperWingSelectedCallNode( + threadsKey: ThreadsKey, + selectedCallNodePath: CallNodePath, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_CALL_NODE', + area: 'UPPER_WING', + selectedCallNodePath, + optionalExpandedToCallNodePath: [], + threadsKey, + context, + }; +} + /** * Select a function for a given thread in the function list. */ @@ -205,6 +220,18 @@ export function changeLowerWingRightClickedCallNode( }; } +export function changeUpperWingRightClickedCallNode( + threadsKey: ThreadsKey, + callNodePath: CallNodePath | null +) { + return { + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: 'UPPER_WING', + callNodePath, + }; +} + /** * Given a threadIndex and a sampleIndex, select the call node which carries the * sample's self time. In the inverted tree, this will be a root node. @@ -1658,6 +1685,18 @@ export function changeLowerWingExpandedCallNodes( }; } +export function changeUpperWingExpandedCallNodes( + threadsKey: ThreadsKey, + expandedCallNodePaths: Array +): Action { + return { + type: 'CHANGE_EXPANDED_CALL_NODES', + area: 'UPPER_WING', + threadsKey, + expandedCallNodePaths, + }; +} + export function changeSelectedMarker( threadsKey: ThreadsKey, selectedMarker: MarkerIndex | null, diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 456e57d9c5..9bff75c8a0 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { FunctionList } from './FunctionList'; +import { UpperWing } from './UpperWing'; import { LowerWing } from './LowerWing'; import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; @@ -28,7 +29,7 @@ export const ProfileFunctionListView = () => ( percent={true} initialSize="50%" > - +
diff --git a/src/components/calltree/UpperWing.tsx b/src/components/calltree/UpperWing.tsx new file mode 100644 index 0000000000..b044751c11 --- /dev/null +++ b/src/components/calltree/UpperWing.tsx @@ -0,0 +1,345 @@ +/* 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 { PureComponent } from 'react'; +import memoize from 'memoize-immutable'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { TreeView } from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { + treeColumnsForTracingMs, + treeColumnsForSamples, + treeColumnsForBytes, +} from './columns'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, +} from 'firefox-profiler/selectors/url-state'; +import { + getScrollToSelectionGeneration, + getCategories, + getCurrentTableViewOptions, + getPreviewSelectionIsBeingModified, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + changeUpperWingSelectedCallNode, + changeUpperWingRightClickedCallNode, + changeUpperWingExpandedCallNodes, + addTransformToStack, + handleCallNodeTransformShortcut, + changeTableViewOptions, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +import type { + State, + ThreadsKey, + CategoryList, + IndexIntoCallNodeTable, + CallNodeDisplayData, + WeightType, + TableViewOptions, + SelectionContext, +} from 'firefox-profiler/types'; +import type { CallTree as CallTreeType } from 'firefox-profiler/profile-logic/call-tree'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + Column, + MaybeResizableColumn, +} from 'firefox-profiler/components/shared/TreeView'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallTree.css'; + +type StateProps = { + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly tree: CallTreeType; + readonly callNodeInfo: CallNodeInfo; + readonly categories: CategoryList; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly expandedCallNodeIndexes: Array; + readonly searchStringsRegExp: RegExp | null; + readonly disableOverscan: boolean; + readonly callNodeMaxDepthPlusOne: number; + readonly weightType: WeightType; + readonly tableViewOptions: TableViewOptions; +}; + +type DispatchProps = { + readonly changeUpperWingSelectedCallNode: typeof changeUpperWingSelectedCallNode; + readonly changeUpperWingRightClickedCallNode: typeof changeUpperWingRightClickedCallNode; + readonly changeUpperWingExpandedCallNodes: typeof changeUpperWingExpandedCallNodes; + readonly addTransformToStack: typeof addTransformToStack; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly onTableViewOptionsChange: (options: TableViewOptions) => any; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class UpperWingImpl extends PureComponent { + _mainColumn: Column = { + propName: 'name', + titleL10nId: '', + }; + _appendageColumn: Column = { + propName: 'lib', + titleL10nId: '', + }; + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView) => + (this._treeView = treeView); + + /** + * Call Trees can have different types of "weights" for the data. Choose the + * appropriate labels for the call tree based on this weight. + */ + _weightTypeToColumns = memoize( + (weightType: WeightType): MaybeResizableColumn[] => { + switch (weightType) { + case 'tracing-ms': + return treeColumnsForTracingMs; + case 'samples': + return treeColumnsForSamples; + case 'bytes': + return treeColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } + }, + // Use a Map cache, as the function only takes one argument, which is a simple string. + { cache: new Map() } + ); + + override componentDidMount() { + this.focus(); + this.maybeProcureInterestingInitialSelection(); + + if (this.props.selectedCallNodeIndex !== null && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + } + + override componentDidUpdate(prevProps: Props) { + this.maybeProcureInterestingInitialSelection(); + + if ( + this.props.selectedCallNodeIndex !== null && + this.props.scrollToSelectionGeneration > + prevProps.scrollToSelectionGeneration && + this._treeView + ) { + this._treeView.scrollSelectionIntoView(); + } + } + + focus() { + if (this._treeView) { + this._treeView.focus(); + } + } + + _onSelectedCallNodeChange = ( + newSelectedCallNode: IndexIntoCallNodeTable, + context: SelectionContext + ) => { + const { callNodeInfo, threadsKey, changeUpperWingSelectedCallNode } = + this.props; + changeUpperWingSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode), + context + ); + }; + + _onRightClickSelection = (newSelectedCallNode: IndexIntoCallNodeTable) => { + const { callNodeInfo, threadsKey, changeUpperWingRightClickedCallNode } = + this.props; + changeUpperWingRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode) + ); + }; + + _onExpandedCallNodesChange = ( + newExpandedCallNodeIndexes: Array + ) => { + const { callNodeInfo, threadsKey, changeUpperWingExpandedCallNodes } = + this.props; + changeUpperWingExpandedCallNodes( + threadsKey, + newExpandedCallNodeIndexes.map((callNodeIndex) => + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ) + ); + }; + + _onKeyDown = (event: React.KeyboardEvent) => { + const { + selectedCallNodeIndex, + rightClickedCallNodeIndex, + callNodeInfo, + handleCallNodeTransformShortcut, + threadsKey, + } = this.props; + const nodeIndex = + rightClickedCallNodeIndex !== null + ? rightClickedCallNodeIndex + : selectedCallNodeIndex; + if (nodeIndex === null) { + return; + } + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); + updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + }; + + maybeProcureInterestingInitialSelection() { + // Expand the heaviest callstack up to a certain depth and select the frame + // at that depth. + const { + tree, + expandedCallNodeIndexes, + selectedCallNodeIndex, + callNodeInfo, + categories, + } = this.props; + + if (selectedCallNodeIndex !== null || expandedCallNodeIndexes.length > 0) { + // Let's not change some existing state. + return; + } + + const idleCategoryIndex = categories.findIndex( + (category) => category.name === 'Idle' + ); + + const newExpandedCallNodeIndexes = expandedCallNodeIndexes.slice(); + const maxInterestingDepth = 17; // scientifically determined + let currentCallNodeIndex = tree.getRoots()[0]; + if (currentCallNodeIndex === undefined) { + // This tree is empty. + return; + } + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + for (let i = 0; i < maxInterestingDepth; i++) { + const children = tree.getChildren(currentCallNodeIndex); + if (children.length === 0) { + break; + } + + // Let's find if there's a non idle children. + const firstNonIdleNode = children.find( + (nodeIndex) => + callNodeInfo.categoryForNode(nodeIndex) !== idleCategoryIndex + ); + + // If there's a non idle children, use it; otherwise use the first + // children (that will be idle). + currentCallNodeIndex = + firstNonIdleNode !== undefined ? firstNonIdleNode : children[0]; + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + } + this._onExpandedCallNodesChange(newExpandedCallNodeIndexes); + + const categoryIndex = callNodeInfo.categoryForNode(currentCallNodeIndex); + if (categoryIndex !== idleCategoryIndex) { + // If we selected the call node with a "idle" category, we'd have a + // completely dimmed activity graph because idle stacks are not drawn in + // this graph. Because this isn't probably what the average user wants we + // do it only when the category is something different. + this._onSelectedCallNodeChange(currentCallNodeIndex, { source: 'auto' }); + } + } + + override render() { + const { + tree, + selectedCallNodeIndex, + rightClickedCallNodeIndex, + expandedCallNodeIndexes, + searchStringsRegExp, + disableOverscan, + callNodeMaxDepthPlusOne, + weightType, + tableViewOptions, + onTableViewOptionsChange, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } +} + +export const UpperWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State) => ({ + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + tree: selectedThreadSelectors.getUpperWingCallTree(state), + callNodeInfo: selectedThreadSelectors.getUpperWingCallNodeInfo(state), + categories: getCategories(state), + selectedCallNodeIndex: + selectedThreadSelectors.getUpperWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getUpperWingRightClickedCallNodeIndex(state), + expandedCallNodeIndexes: + selectedThreadSelectors.getUpperWingExpandedCallNodeIndexes(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + // Use the filtered call node max depth, rather than the preview filtered call node + // max depth so that the width of the TreeView component is stable across preview + // selections. + callNodeMaxDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + }), + mapDispatchToProps: { + changeUpperWingSelectedCallNode, + changeUpperWingRightClickedCallNode, + changeUpperWingExpandedCallNodes, + addTransformToStack, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + }, + component: UpperWingImpl, +}); diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index f8aa91b421..d6a557f2a1 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -183,7 +183,10 @@ export function computeCallNodeTable( } const hierarchy = _computeCallNodeTableHierarchy(stackTable, frameTable); - const dfsOrder = _computeCallNodeTableDFSOrder(hierarchy); + const dfsOrder = _computeCallNodeTableDFSOrder( + hierarchy, + hierarchy.stackIndexToCallNodeIndex + ); const { stackIndexToCallNodeIndex } = dfsOrder; const frameInlinedIntoCol = _computeFrameTableInlinedIntoColumn(frameTable); const extraColumns = _computeCallNodeTableExtraColumns( @@ -230,7 +233,6 @@ type CallNodeTableHierarchy = { // there are no call nodes. firstRoot: IndexIntoCallNodeTable; length: number; - stackIndexToCallNodeIndex: Int32Array; }; /** @@ -295,7 +297,7 @@ type CallNodeTableExtraColumns = { function _computeCallNodeTableHierarchy( stackTable: StackTable, frameTable: FrameTable -): CallNodeTableHierarchy { +): CallNodeTableHierarchy & { stackIndexToCallNodeIndex: Int32Array } { const stackIndexToCallNodeIndex = new Int32Array(stackTable.length); // The callNodeTable components. @@ -446,7 +448,8 @@ function _computeCallNodeTableHierarchy( * siblings as what's in the `hierarchy` argument.) */ function _computeCallNodeTableDFSOrder( - hierarchy: CallNodeTableHierarchy + hierarchy: CallNodeTableHierarchy, + stackIndexToCallNodeIndex: Int32Array ): CallNodeTableDFSOrder { const { prefix, @@ -454,7 +457,6 @@ function _computeCallNodeTableDFSOrder( firstRoot, nextSibling, length, - stackIndexToCallNodeIndex, } = hierarchy; const prefixSorted = new Int32Array(length); @@ -549,6 +551,114 @@ function _computeCallNodeTableDFSOrder( }; } +// mutates originalCallNodeToCallNodeIndex +function _computeCallNodeTableDFSOrder2( + hierarchy: CallNodeTableHierarchy, + originalCallNodeToCallNodeIndex: Int32Array, + stackIndexToOriginalCallNodeIndex: Int32Array +): CallNodeTableDFSOrder { + const { prefix, firstChild, nextSibling, length } = hierarchy; + + const prefixSorted = new Int32Array(length); + const nextSiblingSorted = new Int32Array(length); + const subtreeRangeEndSorted = new Uint32Array(length); + const depthSorted = new Int32Array(length); + let maxDepth = 0; + + if (length === 0) { + return { + prefixSorted, + subtreeRangeEndSorted, + nextSiblingSorted, + depthSorted, + maxDepth, + length, + stackIndexToCallNodeIndex: stackIndexToOriginalCallNodeIndex, + }; + } + + // Traverse the entire tree, as follows: + // 1. nextOldIndex is the next node in DFS order. Copy over all values from + // the unsorted columns into the sorted columns. + // 2. Find the next node in DFS order, set nextOldIndex to it, and continue + // to the next loop iteration. + const oldIndexToNewIndex = new Uint32Array(length); + let nextOldIndex = 0; + let nextNewIndex = 0; + let currentDepth = 0; + let currentOldPrefix = -1; + let currentNewPrefix = -1; + while (nextOldIndex !== -1) { + const oldIndex = nextOldIndex; + const newIndex = nextNewIndex; + oldIndexToNewIndex[oldIndex] = newIndex; + nextNewIndex++; + + prefixSorted[newIndex] = currentNewPrefix; + depthSorted[newIndex] = currentDepth; + // The remaining two columns, nextSiblingSorted and subtreeRangeEndSorted, + // will be filled in when we get to the end of the current subtree. + + // Find the next index in DFS order: If we have children, then our first child + // is next. Otherwise, we need to advance to our next sibling, if we have one, + // otherwise to the next sibling of the first ancestor which has one. + const oldFirstChild = firstChild[oldIndex]; + if (oldFirstChild !== -1) { + // We have children. Our first child is the next node in DFS order. + currentOldPrefix = oldIndex; + currentNewPrefix = newIndex; + nextOldIndex = oldFirstChild; + currentDepth++; + if (currentDepth > maxDepth) { + maxDepth = currentDepth; + } + continue; + } + + // We have no children. The next node is the next sibling of this node or + // of an ancestor node. Now is also a good time to fill in the values for + // subtreeRangeEnd and nextSibling. + subtreeRangeEndSorted[newIndex] = nextNewIndex; + nextOldIndex = nextSibling[oldIndex]; + nextSiblingSorted[newIndex] = nextOldIndex === -1 ? -1 : nextNewIndex; + while (nextOldIndex === -1 && currentOldPrefix !== -1) { + subtreeRangeEndSorted[currentNewPrefix] = nextNewIndex; + const oldPrefixNextSibling = nextSibling[currentOldPrefix]; + nextSiblingSorted[currentNewPrefix] = + oldPrefixNextSibling === -1 ? -1 : nextNewIndex; + nextOldIndex = oldPrefixNextSibling; + currentOldPrefix = prefix[currentOldPrefix]; + currentNewPrefix = prefixSorted[currentNewPrefix]; + currentDepth--; + } + } + + for (let i = 0; i < originalCallNodeToCallNodeIndex.length; i++) { + const oldCallNodeIndex = originalCallNodeToCallNodeIndex[i]; + if (oldCallNodeIndex !== -1) { + originalCallNodeToCallNodeIndex[i] = oldIndexToNewIndex[oldCallNodeIndex]; + } + } + + const stackIndexToCallNodeIndex = new Int32Array( + stackIndexToOriginalCallNodeIndex.length + ); + for (let i = 0; i < stackIndexToCallNodeIndex.length; i++) { + stackIndexToCallNodeIndex[i] = + originalCallNodeToCallNodeIndex[stackIndexToOriginalCallNodeIndex[i]]; + } + + return { + prefixSorted, + subtreeRangeEndSorted, + nextSiblingSorted, + depthSorted, + maxDepth, + length, + stackIndexToCallNodeIndex, + }; +} + /** * Used as part of creating the call node table. * @@ -638,6 +748,81 @@ function _computeCallNodeTableExtraColumns( }; } +function _computeCallNodeTableExtraColumns2( + originalCallNodeTable: CallNodeTable, + oldCallNodeToNewCallNode: Int32Array, + callNodeCount: number, + defaultCategory: IndexIntoCategoryList +): CallNodeTableExtraColumns { + const originalCallNodeTableCategoryCol = originalCallNodeTable.category; + const originalCallNodeTableSubcategoryCol = originalCallNodeTable.subcategory; + const funcCol = new Int32Array(callNodeCount); + const categoryCol = new Int32Array(callNodeCount); + const subcategoryCol = new Int32Array(callNodeCount); + const innerWindowIDCol = new Float64Array(callNodeCount); + const inlinedIntoCol = new Int32Array(callNodeCount); + + const haveFilled = new Uint8Array(callNodeCount); + + for (let i = 0; i < originalCallNodeTable.length; i++) { + const category = originalCallNodeTableCategoryCol[i]; + const subcategory = originalCallNodeTableSubcategoryCol[i]; + const inlinedIntoSymbol = + originalCallNodeTable.sourceFramesInlinedIntoSymbol[i]; + + const callNodeIndex = oldCallNodeToNewCallNode[i]; + if (callNodeIndex === -1) { + continue; + } + + if (haveFilled[callNodeIndex] === 0) { + funcCol[callNodeIndex] = originalCallNodeTable.func[i]; + + categoryCol[callNodeIndex] = category; + subcategoryCol[callNodeIndex] = subcategory; + inlinedIntoCol[callNodeIndex] = inlinedIntoSymbol; + + const innerWindowID = originalCallNodeTable.innerWindowID[i]; + if (innerWindowID !== null && innerWindowID !== 0) { + // Set innerWindowID when it's not zero. Otherwise the value is already + // zero because typed arrays are initialized to zero. + innerWindowIDCol[callNodeIndex] = innerWindowID; + } + + haveFilled[callNodeIndex] = 1; + } else { + // Resolve category conflicts, by resetting a conflicting subcategory or + // category to the default category. + if (categoryCol[callNodeIndex] !== category) { + // Conflicting origin stack categories -> default category + subcategory. + categoryCol[callNodeIndex] = defaultCategory; + subcategoryCol[callNodeIndex] = 0; + } else if (subcategoryCol[callNodeIndex] !== subcategory) { + // Conflicting origin stack subcategories -> "Other" subcategory. + subcategoryCol[callNodeIndex] = 0; + } + + // Resolve "inlined into" conflicts. This can happen if you have two + // function calls A -> B where only one of the B calls is inlined, or + // if you use call tree transforms in such a way that a function B which + // was inlined into two different callers (A -> B, C -> B) gets collapsed + // into one call node. + if (inlinedIntoCol[callNodeIndex] !== inlinedIntoSymbol) { + // Conflicting inlining: -1. + inlinedIntoCol[callNodeIndex] = -1; + } + } + } + + return { + funcCol, + categoryCol, + subcategoryCol, + innerWindowIDCol, + inlinedIntoCol, + }; +} + /** * Generate the inverted CallNodeInfo for a thread. */ @@ -4906,3 +5091,194 @@ export function computeStackTableFromRawStackTable( length: rawStackTable.length, }; } + +export function createUpperWingCallNodeInfo( + callNodeInfo: CallNodeInfo, + selectedFunc: IndexIntoFuncTable | null, + stackTable: StackTable, + frameTable: FrameTable, + funcCount: number, + defaultCategory: IndexIntoCategoryList +): CallNodeInfo { + const originalCallNodeTable = callNodeInfo.getCallNodeTable(); + const originalStackIndexToCallNodeIndex = + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(); + const { callNodeTable, stackIndexToCallNodeIndex } = + _computeSelectedFuncCallNodeTable3( + selectedFunc, + originalCallNodeTable, + originalStackIndexToCallNodeIndex, + stackTable, + frameTable, + funcCount, + defaultCategory + ); + return new CallNodeInfoNonInverted(callNodeTable, stackIndexToCallNodeIndex); +} + +function _computeSelectedFuncCallNodeTableHierarchy( + originalCallNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable | null +): CallNodeTableHierarchy & { + originalCallNodeToCallNodeIndex: Int32Array; +} { + const prefix = new Array(); + const firstChild = new Array(); + const nextSibling = new Array(); + const func = new Array(); + + const originalCallNodeToCallNodeIndex = new Int32Array( + originalCallNodeTable.length + ); + + let length = 0; + + // An extra column that only gets used while the table is built up: For each + // node A, currentLastChild[A] tracks the last currently-known child node of A. + // It is updated whenever a new node is created; e.g. creating node B updates + // currentLastChild[prefix[B]]. + // currentLastChild[A] is -1 while A has no children. + const currentLastChild: Array = []; + + // The last currently-known root node, i.e. the last known "child of -1". + let currentLastRoot = -1; + + // Go through each stack, and create a new callNode table, which is based off of + // functions rather than frames. + for (let i = 0; i < originalCallNodeTable.length; i++) { + const funcIndex = originalCallNodeTable.func[i]; + + const originalPrefixCallNode = originalCallNodeTable.prefix[i]; + // We know that at this point the following condition holds: + // assert(originalPrefixCallNode === -1 || originalPrefixCallNode < i); + const prefixCallNode = + funcIndex === selectedFuncIndex || originalPrefixCallNode === -1 + ? -1 + : originalCallNodeToCallNodeIndex[originalPrefixCallNode]; + + if (prefixCallNode === -1 && funcIndex !== selectedFuncIndex) { + originalCallNodeToCallNodeIndex[i] = -1; + continue; + } + + // Check if the call node for this stack already exists. + let callNodeIndex = -1; + if (length !== 0) { + const currentFirstSibling = + prefixCallNode === -1 ? 0 : firstChild[prefixCallNode]; + for ( + let currentSibling = currentFirstSibling; + currentSibling !== -1; + currentSibling = nextSibling[currentSibling] + ) { + if (func[currentSibling] === funcIndex) { + callNodeIndex = currentSibling; + break; + } + } + } + + if (callNodeIndex !== -1) { + originalCallNodeToCallNodeIndex[i] = callNodeIndex; + continue; + } + + // New call node. + callNodeIndex = length++; + originalCallNodeToCallNodeIndex[i] = callNodeIndex; + + prefix[callNodeIndex] = prefixCallNode; + func[callNodeIndex] = funcIndex; + + // Initialize these firstChild and nextSibling to -1. They will be updated + // once this node's first child or next sibling gets created. + firstChild[callNodeIndex] = -1; + nextSibling[callNodeIndex] = -1; + currentLastChild[callNodeIndex] = -1; + + // Update the next sibling of our previous sibling, and the first child of + // our prefix (if we're the first child). + // Also set this node's depth. + if (prefixCallNode === -1) { + // This node is a root. Just update the previous root's nextSibling. Because + // this node has no parent, there's also no firstChild information to update. + if (currentLastRoot !== -1) { + nextSibling[currentLastRoot] = callNodeIndex; + } + currentLastRoot = callNodeIndex; + } else { + // This node is not a root: update both firstChild and nextSibling information + // when appropriate. + const prevSiblingIndex = currentLastChild[prefixCallNode]; + if (prevSiblingIndex === -1) { + // This is the first child for this prefix. + firstChild[prefixCallNode] = callNodeIndex; + } else { + nextSibling[prevSiblingIndex] = callNodeIndex; + } + currentLastChild[prefixCallNode] = callNodeIndex; + } + } + + return { + prefix, + firstRoot: 0, + firstChild, + nextSibling, + originalCallNodeToCallNodeIndex, + length, + }; +} + +function _computeSelectedFuncCallNodeTable3( + selectedFuncIndex: IndexIntoFuncTable | null, + originalCallNodeTable: CallNodeTable, + stackIndexToOriginalCallNodeIndex: Int32Array, + _stackTable: StackTable, + _frameTable: FrameTable, + _funcCount: number, + defaultCategory: IndexIntoCategoryList +): CallNodeTableAndStackMap { + if (originalCallNodeTable.length === 0) { + return { + callNodeTable: getEmptyCallNodeTable(), + stackIndexToCallNodeIndex: new Int32Array(0), + }; + } + + const hierarchy = _computeSelectedFuncCallNodeTableHierarchy( + originalCallNodeTable, + selectedFuncIndex + ); + const { originalCallNodeToCallNodeIndex } = hierarchy; + const dfsOrder = _computeCallNodeTableDFSOrder2( + hierarchy, + originalCallNodeToCallNodeIndex, + stackIndexToOriginalCallNodeIndex + ); + const { stackIndexToCallNodeIndex } = dfsOrder; + const extraColumns = _computeCallNodeTableExtraColumns2( + originalCallNodeTable, + originalCallNodeToCallNodeIndex, + hierarchy.length, + defaultCategory + ); + + const callNodeTable = { + prefix: dfsOrder.prefixSorted, + nextSibling: dfsOrder.nextSiblingSorted, + subtreeRangeEnd: dfsOrder.subtreeRangeEndSorted, + func: extraColumns.funcCol, + category: extraColumns.categoryCol, + subcategory: extraColumns.subcategoryCol, + innerWindowID: extraColumns.innerWindowIDCol, + sourceFramesInlinedIntoSymbol: extraColumns.inlinedIntoCol, + depth: dfsOrder.depthSorted, + maxDepth: dfsOrder.maxDepth, + length: hierarchy.length, + }; + return { + callNodeTable, + stackIndexToCallNodeIndex, + }; +} diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index 80a8111b06..1cbfc9919c 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -188,9 +188,11 @@ export const defaultThreadViewOptions: ThreadViewOptions = { selectedNonInvertedCallNodePath: [], selectedInvertedCallNodePath: [], selectedLowerWingCallNodePath: [], + selectedUpperWingCallNodePath: [], expandedNonInvertedCallNodePaths: new PathSet(), expandedInvertedCallNodePaths: new PathSet(), expandedLowerWingCallNodePaths: new PathSet(), + expandedUpperWingCallNodePaths: new PathSet(), selectedFunctionIndex: null, selectedNetworkMarker: null, lastSeenTransformCount: 0, @@ -270,6 +272,7 @@ const viewOptionsPerThread: Reducer = ( INVERTED_TREE: threadState.selectedInvertedCallNodePath, NON_INVERTED_TREE: threadState.selectedNonInvertedCallNodePath, LOWER_WING: threadState.selectedLowerWingCallNodePath, + UPPER_WING: threadState.selectedUpperWingCallNodePath, }[area]; // If the selected node doesn't actually change, let's return the previous @@ -285,6 +288,7 @@ const viewOptionsPerThread: Reducer = ( INVERTED_TREE: threadState.expandedInvertedCallNodePaths, NON_INVERTED_TREE: threadState.expandedNonInvertedCallNodePaths, LOWER_WING: threadState.expandedLowerWingCallNodePaths, + UPPER_WING: threadState.expandedUpperWingCallNodePaths, }[area]; const expandToNode = optionalExpandedToCallNodePath ? optionalExpandedToCallNodePath @@ -325,6 +329,11 @@ const viewOptionsPerThread: Reducer = ( selectedLowerWingCallNodePath: selectedCallNodePath, expandedLowerWingCallNodePaths: expandedCallNodePaths, }); + case 'UPPER_WING': + return _updateThreadViewOptions(state, threadsKey, { + selectedUpperWingCallNodePath: selectedCallNodePath, + expandedUpperWingCallNodePaths: expandedCallNodePaths, + }); default: throw assertExhaustiveCheck(area, 'Unhandled case'); } @@ -342,6 +351,20 @@ const viewOptionsPerThread: Reducer = ( return state; } + if (selectedFunctionIndex !== null) { + return _updateThreadViewOptions(state, threadsKey, { + selectedFunctionIndex, + selectedLowerWingCallNodePath: [selectedFunctionIndex], + expandedLowerWingCallNodePaths: new PathSet([ + [selectedFunctionIndex], + ]), + selectedUpperWingCallNodePath: [selectedFunctionIndex], + expandedUpperWingCallNodePaths: new PathSet([ + [selectedFunctionIndex], + ]), + }); + } + return _updateThreadViewOptions(state, threadsKey, { selectedFunctionIndex, }); @@ -396,6 +419,10 @@ const viewOptionsPerThread: Reducer = ( return _updateThreadViewOptions(state, threadsKey, { expandedLowerWingCallNodePaths: expandedCallNodePaths, }); + case 'UPPER_WING': + return _updateThreadViewOptions(state, threadsKey, { + expandedUpperWingCallNodePaths: expandedCallNodePaths, + }); default: throw assertExhaustiveCheck(area, 'Unhandled case'); } diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 932e75bcbb..21cba59093 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -43,6 +43,7 @@ import type { State, CallNodeTableBitSet, IndexIntoFuncTable, + IndexIntoStackTable, } from 'firefox-profiler/types'; import type { CallNodeInfo, @@ -219,6 +220,16 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingCallNodeInfo: Selector = createSelector( + _getNonInvertedCallNodeInfo, + getSelectedFunctionIndex, + (state: State) => threadSelectors.getFilteredThread(state).stackTable, + (state: State) => threadSelectors.getFilteredThread(state).frameTable, + (state: State) => threadSelectors.getFilteredThread(state).funcTable.length, + ProfileSelectors.getDefaultCategory, + ProfileData.createUpperWingCallNodeInfo + ); + const getLowerWingSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, @@ -235,6 +246,13 @@ export function getStackAndSampleSelectorsPerThread( : threadViewOptions.selectedNonInvertedCallNodePath ); + const getUpperWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): CallNodePath => + threadViewOptions.selectedUpperWingCallNodePath + ); + const getSelectedCallNodeIndex: Selector = createSelector( getCallNodeInfo, @@ -253,6 +271,15 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingSelectedCallNodeIndex: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingSelectedCallNodePath, + (callNodeInfo, callNodePath) => { + return callNodeInfo.getCallNodeIndexFromPath(callNodePath); + } + ); + const getExpandedCallNodePaths: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -267,6 +294,11 @@ export function getStackAndSampleSelectorsPerThread( (threadViewOptions) => threadViewOptions.expandedLowerWingCallNodePaths ); + const getUpperWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedUpperWingCallNodePaths + ); + const getExpandedCallNodeIndexes: Selector< Array > = createSelector( @@ -289,6 +321,17 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getUpperWingExpandedCallNodeIndexes: Selector< + Array + > = createSelector( + getUpperWingCallNodeInfo, + getUpperWingExpandedCallNodePaths, + (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + const _getSampleIndexToNonInvertedCallNodeIndexForPreviewFilteredCtssThread: Selector< Array > = createSelector( @@ -308,6 +351,27 @@ export function getStackAndSampleSelectorsPerThread( ProfileData.getSampleIndexToCallNodeIndex ); + const _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex: Selector< + Array + > = createSelector( + (state: State) => + threadSelectors.getPreviewFilteredCtssSamples(state).stack, + (state: State) => + getUpperWingCallNodeInfo(state).getStackIndexToNonInvertedCallNodeIndex(), + (sampleStacks, stackIndexToCallNodeIndex) => { + return sampleStacks.map((stackIndex: IndexIntoStackTable | null) => { + if (stackIndex === null) { + return null; + } + const callNodeIndex = stackIndexToCallNodeIndex[stackIndex]; + if (callNodeIndex === -1) { + return null; + } + return callNodeIndex; + }); + } + ); + const getSampleIndexToNonInvertedCallNodeIndexForFilteredThread: Selector< Array > = createSelector( @@ -391,6 +455,28 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingCallNodeSelfAndSummary: Selector = + createSelector( + threadSelectors.getPreviewFilteredCtssSamples, + _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex, + getUpperWingCallNodeInfo, + getCallNodeSelfAndSummary, + ( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo, + regularTreeSelfAndSummary + ) => { + const { rootTotalSummary } = regularTreeSelfAndSummary; + const { callNodeSelf } = CallTree.computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + return { rootTotalSummary, callNodeSelf }; + } + ); + const getCallTreeTimings: Selector = createSelector( getCallNodeInfo, getCallNodeSelfAndSummary, @@ -405,6 +491,13 @@ export function getStackAndSampleSelectorsPerThread( CallTree.computeLowerWingTimings ); + const _getUpperWingCallTreeTimings: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimings + ); + const getCallTreeTimingsNonInverted: Selector = createSelector( getCallNodeInfo, @@ -458,6 +551,16 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getUpperWingCallTree: Selector = createSelector( + threadSelectors.getPreviewFilteredThread, + getUpperWingCallNodeInfo, + ProfileSelectors.getCategories, + threadSelectors.getPreviewFilteredCtssSamples, + _getUpperWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + const getLowerWingCallTree: Selector = createSelector( threadSelectors.getPreviewFilteredThread, getLowerWingCallNodeInfo, @@ -618,6 +721,25 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingRightClickedCallNodeIndex: Selector = + createSelector( + getRightClickedCallNodeInfo, + getUpperWingCallNodeInfo, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.threadsKey === threadsKey && + rightClickedCallNodeInfo.area === 'UPPER_WING' + ) { + return callNodeInfo.getCallNodeIndexFromPath( + rightClickedCallNodeInfo.callNodePath + ); + } + + return null; + } + ); + const getRightClickedFunctionIndex: Selector = createSelector( ProfileSelectors.getProfileViewOptions, @@ -640,6 +762,7 @@ export function getStackAndSampleSelectorsPerThread( getWeightTypeForCallTree, getCallNodeInfo, getLowerWingCallNodeInfo, + getUpperWingCallNodeInfo, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo, @@ -647,11 +770,15 @@ export function getStackAndSampleSelectorsPerThread( getSelectedCallNodeIndex, getLowerWingSelectedCallNodePath, getLowerWingSelectedCallNodeIndex, + getUpperWingSelectedCallNodePath, + getUpperWingSelectedCallNodeIndex, getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, getLowerWingExpandedCallNodePaths, getLowerWingExpandedCallNodeIndexes, + getUpperWingExpandedCallNodePaths, + getUpperWingExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, getSampleSelectedStatesForFunctionListTab, @@ -660,6 +787,7 @@ export function getStackAndSampleSelectorsPerThread( getFunctionListTree, getFunctionListTimings, getLowerWingCallTree, + getUpperWingCallTree, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, @@ -672,5 +800,6 @@ export function getStackAndSampleSelectorsPerThread( getRightClickedFunctionIndex, getLowerWingRightClickedCallNodeIndex, getLowerWingRightClickedFuncIndex, + getUpperWingRightClickedCallNodeIndex, }; } diff --git a/src/selectors/right-clicked-call-node.tsx b/src/selectors/right-clicked-call-node.tsx index f793e4df86..a9d03cff46 100644 --- a/src/selectors/right-clicked-call-node.tsx +++ b/src/selectors/right-clicked-call-node.tsx @@ -14,7 +14,7 @@ import type { export type RightClickedCallNodeInfo = { readonly threadsKey: ThreadsKey; - readonly area: CallNodeArea; + readonly area: CallNodeArea, readonly callNodePath: CallNodePath; }; diff --git a/src/test/fixtures/utils.ts b/src/test/fixtures/utils.ts index d6ee431804..1fa7032894 100644 --- a/src/test/fixtures/utils.ts +++ b/src/test/fixtures/utils.ts @@ -20,6 +20,7 @@ import { createThreadFromDerivedTables, computeStackTableFromRawStackTable, computeSamplesTableFromRawSamplesTable, + createUpperWingCallNodeInfo, } from 'firefox-profiler/profile-logic/profile-data'; import { getProfileWithDicts } from './profiles/processed-profile'; import { StringTable } from '../../utils/string-table'; @@ -266,6 +267,55 @@ export function functionListTreeFromProfile( ); } +/** + * This function creates the "upper wing" CallTree for a profile and a selected + * function. The upper wing shows the call subtrees that are rooted at the + * selected function, i.e. it answers "where is this function called from / what + * does it call". + */ +export function upperWingTreeFromProfile( + profile: Profile, + selectedFuncName: string, + threadIndex: number = 0 +): CallTree { + const { derivedThreads, defaultCategory } = getProfileWithDicts(profile); + const thread = derivedThreads[threadIndex]; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + const selectedFunc = + thread.funcTable.name.findIndex( + (i) => thread.stringTable.getString(i) === selectedFuncName + ) ?? null; + const upperWingCallNodeInfo = createUpperWingCallNodeInfo( + callNodeInfo, + selectedFunc === -1 ? null : selectedFunc, + thread.stackTable, + thread.frameTable, + thread.funcTable.length, + defaultCategory + ); + const selfAndSummary = computeCallNodeSelfAndSummary( + thread.samples, + getSampleIndexToCallNodeIndex( + thread.samples.stack, + upperWingCallNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ), + upperWingCallNodeInfo.getCallNodeTable().length + ); + const timings = computeCallTreeTimings(upperWingCallNodeInfo, selfAndSummary); + return getCallTree( + thread, + upperWingCallNodeInfo, + ensureExists(profile.meta.categories), + thread.samples, + timings, + 'samples' + ); +} + /** * This function creates the "lower wing" CallTree for a profile and a selected * function. The lower wing is an inverted call tree where each root's total diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 2accdd4f7c..1d1716ee54 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4431,6 +4431,9 @@ Object { ], }, }, + "expandedUpperWingCallNodePaths": PathSet { + "_table": Map {}, + }, "lastSeenTransformCount": 1, "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], @@ -4440,5 +4443,6 @@ Object { 0, 1, ], + "selectedUpperWingCallNodePath": Array [], } `; diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index aad3511810..466996c7d1 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -24,6 +24,7 @@ import { ResourceType } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + upperWingTreeFromProfile, lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, @@ -580,6 +581,41 @@ describe('function list', function () { }); }); +describe('upper wing', function () { + // Samples: A->B->C, A->B->D, A->E->C, A->E->F + const textSamples = ` + A A A A + B B E E + C D C F + `; + + it('shows all callee subtrees of the selected function', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // Select B: show subtrees rooted at B (i.e. B->C and B->D) + const callTree = upperWingTreeFromProfile(profile, 'B'); + expect(formatTree(callTree)).toEqual([ + '- B (total: 2, self: —)', + ' - C (total: 1, self: 1)', + ' - D (total: 1, self: 1)', + ]); + }); + + it('merges call nodes with the same function across different callers', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // C appears under both B and E; the upper wing for C should show both + // subtrees merged into one root C node + const callTree = upperWingTreeFromProfile(profile, 'C'); + expect(formatTree(callTree)).toEqual(['- C (total: 2, self: 2)']); + }); + + it('returns an empty tree when no function is selected', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // null selection: no subtrees to show + const callTree = upperWingTreeFromProfile(profile, 'NONEXISTENT'); + expect(formatTree(callTree)).toEqual([]); + }); +}); + describe('lower wing', function () { // Samples: A->B->C, A->B->D, A->E->C, A->E->F const textSamples = ` diff --git a/src/types/state.ts b/src/types/state.ts index 216a5a93e6..8f753e50ac 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -57,6 +57,7 @@ export type SourceMapSymbolicationStatus = | 'INACTIVE' | 'FETCHING' | 'SYMBOLICATING'; + export type ThreadViewOptions = { readonly selectedNonInvertedCallNodePath: CallNodePath; readonly selectedInvertedCallNodePath: CallNodePath; @@ -65,6 +66,8 @@ export type ThreadViewOptions = { readonly selectedFunctionIndex: IndexIntoFuncTable | null; readonly selectedLowerWingCallNodePath: CallNodePath; readonly expandedLowerWingCallNodePaths: PathSet; + readonly selectedUpperWingCallNodePath: CallNodePath; + readonly expandedUpperWingCallNodePaths: PathSet; readonly selectedNetworkMarker: MarkerIndex | null; // Track the number of transforms to detect when they change via browser // navigation. This helps us know when to reset paths that may be invalid @@ -82,7 +85,11 @@ export type TableViewOptions = { export type TableViewOptionsPerTab = { [K in TabSlug]: TableViewOptions }; -export type CallNodeArea = 'NON_INVERTED_TREE' | 'INVERTED_TREE' | 'LOWER_WING'; +export type CallNodeArea = + | 'NON_INVERTED_TREE' + | 'INVERTED_TREE' + | 'LOWER_WING' + | 'UPPER_WING'; export type RightClickedCallNode = { readonly threadsKey: ThreadsKey; From 0dfd3550d610200c09da87adf6a1aa2ba0f25df4 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 31 Mar 2026 15:45:03 -0400 Subject: [PATCH 16/41] Fix splitter styling --- src/components/app/DetailsContainer.css | 14 +++++------ src/components/calltree/Butterfly.css | 33 ++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/components/app/DetailsContainer.css b/src/components/app/DetailsContainer.css index 5c24522df0..7723bd075f 100644 --- a/src/components/app/DetailsContainer.css +++ b/src/components/app/DetailsContainer.css @@ -6,22 +6,22 @@ contain: size; } -.DetailsContainer .resizableWithSplitterInner > * { - min-width: 0; - flex: 1; -} - .DetailsContainerResizableSidebarWrapper { max-width: 600px; } /* overriding defaults from ResizableWithSplitter.css */ -.DetailsContainer .resizableWithSplitterSplitter { +.DetailsContainerResizableSidebarWrapper > .resizableWithSplitterSplitter { border-top: 1px solid var(--panel-border-color); border-left: 1px solid var(--panel-border-color); background: var(--panel-background-color); /* Same background as sidebars */ } -.DetailsContainer .resizableWithSplitterSplitter:hover { +.DetailsContainerResizableSidebarWrapper > .resizableWithSplitterSplitter:hover { background: var(--panel-background-color); /* same as the border above */ } + +.DetailsContainerResizableSidebarWrapper > .resizableWithSplitterInner > * { + min-width: 0; + flex: 1; +} diff --git a/src/components/calltree/Butterfly.css b/src/components/calltree/Butterfly.css index d1bc05f276..c40a375bfa 100644 --- a/src/components/calltree/Butterfly.css +++ b/src/components/calltree/Butterfly.css @@ -12,4 +12,35 @@ .butterflyWings > .resizableWithSplitterInner { display: flex; flex-flow: column nowrap; -} \ No newline at end of file +} + +/* Provide 3px extra grabbable surface on each side of the splitter */ +.butterflyWrapper .resizableWithSplitterSplitter { + position: relative; /* containing block for absolute ::before */ + border: none; + background-color: var(--base-border-color) !important; +} + +.butterflyWrapper .resizableWithSplitterSplitter::before { + position: absolute; + z-index: var(--z-bottom-box); + display: block; + content: ''; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesWidth { + width: 1px; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesWidth::before { + inset: 0 -3px; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesHeight { + height: 1px; + margin-bottom: -1px; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesHeight::before { + inset: -3px 0; +} From 7c2d9eded5eec718d2c22387c415836eb4b2a7d9 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 2 Apr 2026 15:19:01 -0400 Subject: [PATCH 17/41] First attempt at FlameGraph refactor --- .../flame-graph/ConnectedFlameGraph.tsx | 248 ++++++++++++++++++ src/components/flame-graph/FlameGraph.tsx | 166 +++--------- .../flame-graph/MaybeFlameGraph.tsx | 88 ------- src/components/flame-graph/index.tsx | 76 ++++-- 4 files changed, 339 insertions(+), 239 deletions(-) create mode 100644 src/components/flame-graph/ConnectedFlameGraph.tsx delete mode 100644 src/components/flame-graph/MaybeFlameGraph.tsx diff --git a/src/components/flame-graph/ConnectedFlameGraph.tsx b/src/components/flame-graph/ConnectedFlameGraph.tsx new file mode 100644 index 0000000000..109fcb4ead --- /dev/null +++ b/src/components/flame-graph/ConnectedFlameGraph.tsx @@ -0,0 +1,248 @@ +/* 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/. */ +import * as React from 'react'; + +import { explicitConnectWithForwardRef } from '../../utils/connect'; +import { FlameGraph } from './FlameGraph'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + getSelectedThreadsKey, + getInvertCallstack, +} from '../../selectors/url-state'; +import { + changeSelectedCallNode, + changeRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + CallTree, + CallTreeTimings, +} from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly isInverted: boolean; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly tracedTiming: CallTreeTimings | null; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly changeSelectedCallNode: typeof changeSelectedCallNode; + readonly changeRightClickedCallNode: typeof changeRightClickedCallNode; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +export interface ConnectedFlameGraphHandle { + focus(): void; +} + +class ConnectedFlameGraphImpl + extends React.PureComponent + implements ConnectedFlameGraphHandle +{ + _flameGraph: React.RefObject = React.createRef(); + + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; + changeRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('flame-graph', bottomBoxInfo); + }; + + _onKeyboardTransformShortcut = ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => { + const { threadsKey, callNodeInfo, handleCallNodeTransformShortcut } = + this.props; + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + override render() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + rightClickedCallNodeIndex, + selectedCallNodeIndex, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + isInverted, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + tracedTiming, + displayStackType, + } = this.props; + + return ( + + ); + } +} + +export const ConnectedFlameGraph = explicitConnectWithForwardRef< + {}, + StateProps, + DispatchProps, + ConnectedFlameGraphHandle +>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getFilteredThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + // Use the filtered call node max depth, rather than the preview filtered one, so + // that the viewport height is stable across preview selections. + maxStackDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + flameGraphTiming: selectedThreadSelectors.getFlameGraphTiming(state), + callTree: selectedThreadSelectors.getCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + selectedCallNodeIndex: + selectedThreadSelectors.getSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getRightClickedCallNodeIndex(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + isInverted: getInvertCallstack(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + tracedTiming: selectedThreadSelectors.getTracedTiming(state), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + changeSelectedCallNode, + changeRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + component: ConnectedFlameGraphImpl, +}); diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index b0c2ad5953..9702569d9a 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -3,30 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import { explicitConnectWithForwardRef } from '../../utils/connect'; import { FlameGraphCanvas } from './Canvas'; - -import { - getCategories, - getCommittedRange, - getPreviewSelection, - getScrollToSelectionGeneration, - getProfileInterval, - getInnerWindowIDToPageMap, - getProfileUsesMultipleStackTypes, -} from 'firefox-profiler/selectors/profile'; -import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { - getSelectedThreadsKey, - getInvertCallstack, -} from '../../selectors/url-state'; import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; -import { - changeSelectedCallNode, - changeRightClickedCallNode, - handleCallNodeTransformShortcut, - updateBottomBoxContentsAndMaybeOpen, -} from 'firefox-profiler/actions/profile-view'; import { extractNonInvertedCallTreeTimings } from 'firefox-profiler/profile-logic/call-tree'; import { ensureExists } from 'firefox-profiler/utils/types'; @@ -54,8 +32,6 @@ import type { CallTreeTimings, } from 'firefox-profiler/profile-logic/call-tree'; -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - import './FlameGraph.css'; const STACK_FRAME_HEIGHT = 16; @@ -67,7 +43,7 @@ const STACK_FRAME_HEIGHT = 16; */ const SELECTABLE_THRESHOLD = 0.001; -type StateProps = { +export type Props = { readonly thread: Thread; readonly weightType: WeightType; readonly innerWindowIDToPageMap: Map | null; @@ -89,14 +65,20 @@ type StateProps = { readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; readonly tracedTiming: CallTreeTimings | null; readonly displayStackType: boolean; + readonly onSelectedCallNodeChange: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onRightClickedCallNodeChange: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onCallNodeEnterOrDoubleClick: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onKeyboardTransformShortcut: ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => void; }; -type DispatchProps = { - readonly changeSelectedCallNode: typeof changeSelectedCallNode; - readonly changeRightClickedCallNode: typeof changeRightClickedCallNode; - readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; - readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; -}; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; export interface FlameGraphHandle { focus(): void; @@ -116,44 +98,13 @@ class FlameGraphImpl document.removeEventListener('copy', this._onCopy, false); } - _onSelectedCallNodeChange = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); - }; - - _onRightClickedCallNodeChange = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; - changeRightClickedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); - }; - - _onCallNodeEnterOrDoubleClick = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - if (callNodeIndex === null) { - return; - } - const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; - const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); - updateBottomBoxContentsAndMaybeOpen('flame-graph', bottomBoxInfo); - }; - _shouldDisplayTooltips = () => this.props.rightClickedCallNodeIndex === null; _takeViewportRef = (viewport: HTMLDivElement | null) => { this._viewport = viewport; }; - /* This method is called from MaybeFlameGraph. */ + /* This method is called from ConnectedFlameGraph. */ /* eslint-disable-next-line react/no-unused-class-component-methods */ focus = () => { if (this._viewport) { @@ -215,13 +166,13 @@ class FlameGraphImpl _handleKeyDown = (event: React.KeyboardEvent) => { const { - threadsKey, callTree, callNodeInfo, selectedCallNodeIndex, rightClickedCallNodeIndex, - changeSelectedCallNode, - handleCallNodeTransformShortcut, + onSelectedCallNodeChange, + onCallNodeEnterOrDoubleClick, + onKeyboardTransformShortcut, } = this.props; const callNodeTable = callNodeInfo.getCallNodeTable(); @@ -231,10 +182,7 @@ class FlameGraphImpl ) { if (selectedCallNodeIndex === null) { // Just select the "root" node if we've got no prior selection. - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(0) - ); + onSelectedCallNodeChange(0); return; } @@ -242,10 +190,7 @@ class FlameGraphImpl case 'ArrowDown': { const prefix = callNodeTable.prefix[selectedCallNodeIndex]; if (prefix !== -1) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(prefix) - ); + onSelectedCallNodeChange(prefix); } break; } @@ -257,10 +202,7 @@ class FlameGraphImpl // thus the widest box. if (callNodeIndex !== undefined && this._wideEnough(callNodeIndex)) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -272,10 +214,7 @@ class FlameGraphImpl ); if (callNodeIndex !== undefined) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -298,11 +237,11 @@ class FlameGraphImpl } if (event.key === 'Enter') { - this._onCallNodeEnterOrDoubleClick(nodeIndex); + onCallNodeEnterOrDoubleClick(nodeIndex); return; } - handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + onKeyboardTransformShortcut(event, nodeIndex); }; _onCopy = (event: ClipboardEvent) => { @@ -343,6 +282,9 @@ class FlameGraphImpl ctssSampleCategoriesAndSubcategories, tracedTiming, displayStackType, + onSelectedCallNodeChange, + onRightClickedCallNodeChange, + onCallNodeEnterOrDoubleClick, } = this.props; // Get the CallTreeTimingsNonInverted out of tracedTiming. We pass this @@ -398,9 +340,9 @@ class FlameGraphImpl scrollToSelectionGeneration, callTreeSummaryStrategy, stackFrameHeight: STACK_FRAME_HEIGHT, - onSelectionChange: this._onSelectedCallNodeChange, - onRightClick: this._onRightClickedCallNodeChange, - onDoubleClick: this._onCallNodeEnterOrDoubleClick, + onSelectionChange: onSelectedCallNodeChange, + onRightClick: onRightClickedCallNodeChange, + onDoubleClick: onCallNodeEnterOrDoubleClick, shouldDisplayTooltips: this._shouldDisplayTooltips, interval, isInverted, @@ -424,50 +366,4 @@ function viewportNeedsUpdate() { return false; } -export const FlameGraph = explicitConnectWithForwardRef< - {}, - StateProps, - DispatchProps, - FlameGraphHandle ->({ - mapStateToProps: (state) => ({ - thread: selectedThreadSelectors.getFilteredThread(state), - weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), - // Use the filtered call node max depth, rather than the preview filtered one, so - // that the viewport height is stable across preview selections. - maxStackDepthPlusOne: - selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), - flameGraphTiming: selectedThreadSelectors.getFlameGraphTiming(state), - callTree: selectedThreadSelectors.getCallTree(state), - timeRange: getCommittedRange(state), - previewSelection: getPreviewSelection(state), - callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), - categories: getCategories(state), - threadsKey: getSelectedThreadsKey(state), - selectedCallNodeIndex: - selectedThreadSelectors.getSelectedCallNodeIndex(state), - rightClickedCallNodeIndex: - selectedThreadSelectors.getRightClickedCallNodeIndex(state), - scrollToSelectionGeneration: getScrollToSelectionGeneration(state), - interval: getProfileInterval(state), - isInverted: getInvertCallstack(state), - callTreeSummaryStrategy: - selectedThreadSelectors.getCallTreeSummaryStrategy(state), - innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), - ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), - ctssSampleCategoriesAndSubcategories: - selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( - state - ), - tracedTiming: selectedThreadSelectors.getTracedTiming(state), - displayStackType: getProfileUsesMultipleStackTypes(state), - }), - mapDispatchToProps: { - changeSelectedCallNode, - changeRightClickedCallNode, - handleCallNodeTransformShortcut, - updateBottomBoxContentsAndMaybeOpen, - }, - options: { forwardRef: true }, - component: FlameGraphImpl, -}); +export { FlameGraphImpl as FlameGraph }; diff --git a/src/components/flame-graph/MaybeFlameGraph.tsx b/src/components/flame-graph/MaybeFlameGraph.tsx deleted file mode 100644 index 5dd0f0f227..0000000000 --- a/src/components/flame-graph/MaybeFlameGraph.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* 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/. */ -import * as React from 'react'; - -import { explicitConnectWithForwardRef } from 'firefox-profiler/utils/connect'; -import { getInvertCallstack } from '../../selectors/url-state'; -import { selectedThreadSelectors } from '../../selectors/per-thread'; -import { changeInvertCallstack } from '../../actions/profile-view'; -import { FlameGraphEmptyReasons } from './FlameGraphEmptyReasons'; -import { FlameGraph, type FlameGraphHandle } from './FlameGraph'; - -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './MaybeFlameGraph.css'; - -// TODO: This component isn't needed any more. Whenever the selected tab -// is "flame-graph", `invertCallstack` will be `false`. is -// only used in the "flame-graph" tab. - -type StateProps = { - readonly isPreviewSelectionEmpty: boolean; - readonly invertCallstack: boolean; -}; -type DispatchProps = { - readonly changeInvertCallstack: typeof changeInvertCallstack; -}; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; - -class MaybeFlameGraphImpl extends React.PureComponent { - _flameGraph: React.RefObject = React.createRef(); - - _onSwitchToNormalCallstackClick = () => { - this.props.changeInvertCallstack(false); - }; - - override componentDidMount() { - const flameGraph = this._flameGraph.current; - if (flameGraph) { - flameGraph.focus(); - } - } - - override render() { - const { isPreviewSelectionEmpty, invertCallstack } = this.props; - - if (isPreviewSelectionEmpty) { - return ; - } - - if (invertCallstack) { - return ( -
-

The Flame Graph is not available for inverted call stacks

-

- {' '} - to show the Flame Graph. -

-
- ); - } - return ; - } -} - -export const MaybeFlameGraph = explicitConnectWithForwardRef< - {}, - StateProps, - DispatchProps, - MaybeFlameGraphImpl ->({ - mapStateToProps: (state) => { - return { - invertCallstack: getInvertCallstack(state), - isPreviewSelectionEmpty: - !selectedThreadSelectors.getHasPreviewFilteredCtssSamples(state), - }; - }, - mapDispatchToProps: { - changeInvertCallstack, - }, - component: MaybeFlameGraphImpl, -}); diff --git a/src/components/flame-graph/index.tsx b/src/components/flame-graph/index.tsx index 141147302c..cd4f7eefe4 100644 --- a/src/components/flame-graph/index.tsx +++ b/src/components/flame-graph/index.tsx @@ -2,21 +2,65 @@ * 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/. */ +import * as React from 'react'; + import { StackSettings } from '../shared/StackSettings'; import { TransformNavigator } from '../shared/TransformNavigator'; -import { MaybeFlameGraph } from './MaybeFlameGraph'; - -const FlameGraphView = () => ( -
- - - -
-); - -export const FlameGraph = FlameGraphView; +import { + ConnectedFlameGraph, + type ConnectedFlameGraphHandle, +} from './ConnectedFlameGraph'; +import { FlameGraphEmptyReasons } from './FlameGraphEmptyReasons'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './MaybeFlameGraph.css'; + +type StateProps = { + readonly isPreviewSelectionEmpty: boolean; +}; + +type Props = ConnectedProps<{}, StateProps, {}>; + +class FlameGraphViewImpl extends React.PureComponent { + _connectedFlameGraph: React.RefObject = + React.createRef(); + + override componentDidMount() { + this._connectedFlameGraph.current?.focus(); + } + + override render() { + const { isPreviewSelectionEmpty } = this.props; + + return ( +
+ + + {isPreviewSelectionEmpty ? ( + + ) : ( + + )} +
+ ); + } +} + +const FlameGraphViewConnected = explicitConnect<{}, StateProps, {}>({ + mapStateToProps: (state) => ({ + isPreviewSelectionEmpty: + !selectedThreadSelectors.getHasPreviewFilteredCtssSamples(state), + }), + mapDispatchToProps: {}, + component: FlameGraphViewImpl, +}); + +export const FlameGraph = FlameGraphViewConnected; From 559f41fa839be032bafe7c27c84a5b8e6f85cd28 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 2 Apr 2026 16:31:04 -0400 Subject: [PATCH 18/41] Upper wing is now a flame graph --- src/components/calltree/UpperWing.tsx | 8 +- .../calltree/UpperWingFlameGraph.tsx | 254 ++++++++++++++++++ src/selectors/per-thread/stack-sample.ts | 26 ++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 src/components/calltree/UpperWingFlameGraph.tsx diff --git a/src/components/calltree/UpperWing.tsx b/src/components/calltree/UpperWing.tsx index b044751c11..6733c64d9e 100644 --- a/src/components/calltree/UpperWing.tsx +++ b/src/components/calltree/UpperWing.tsx @@ -3,11 +3,13 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // @flow +import * as React from 'react'; import { PureComponent } from 'react'; import memoize from 'memoize-immutable'; import explicitConnect from 'firefox-profiler/utils/connect'; import { TreeView } from 'firefox-profiler/components/shared/TreeView'; import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { UpperWingFlameGraph } from './UpperWingFlameGraph'; import { treeColumnsForTracingMs, treeColumnsForSamples, @@ -263,7 +265,7 @@ class UpperWingImpl extends PureComponent { } } - override render() { + _renderCallTree() { const { tree, selectedCallNodeIndex, @@ -306,6 +308,10 @@ class UpperWingImpl extends PureComponent { /> ); } + + override render() { + return ; + } } export const UpperWing = explicitConnect<{}, StateProps, DispatchProps>({ diff --git a/src/components/calltree/UpperWingFlameGraph.tsx b/src/components/calltree/UpperWingFlameGraph.tsx new file mode 100644 index 0000000000..3ff9f77eae --- /dev/null +++ b/src/components/calltree/UpperWingFlameGraph.tsx @@ -0,0 +1,254 @@ +/* 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 * as React from 'react'; + +import { explicitConnectWithForwardRef } from 'firefox-profiler/utils/connect'; +import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + getSelectedThreadsKey, + getInvertCallstack, +} from 'firefox-profiler/selectors/url-state'; +import { + changeUpperWingSelectedCallNode, + changeUpperWingRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, + SelectionContext, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + CallTree, + CallTreeTimings, +} from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly isInverted: boolean; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly tracedTiming: CallTreeTimings | null; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly changeUpperWingSelectedCallNode: typeof changeUpperWingSelectedCallNode; + readonly changeUpperWingRightClickedCallNode: typeof changeUpperWingRightClickedCallNode; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +export interface UpperWingFlameGraphHandle { + focus(): void; +} + +class UpperWingFlameGraphImpl + extends React.PureComponent + implements UpperWingFlameGraphHandle +{ + _flameGraph: React.RefObject = React.createRef(); + + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeUpperWingSelectedCallNode } = + this.props; + const context: SelectionContext = { source: 'pointer' }; + changeUpperWingSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex), + context + ); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeUpperWingRightClickedCallNode } = + this.props; + changeUpperWingRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + }; + + _onKeyboardTransformShortcut = ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => { + const { threadsKey, callNodeInfo, handleCallNodeTransformShortcut } = + this.props; + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + override render() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + rightClickedCallNodeIndex, + selectedCallNodeIndex, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + isInverted, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + tracedTiming, + displayStackType, + } = this.props; + + return ( + + ); + } +} + +export const UpperWingFlameGraph = explicitConnectWithForwardRef< + {}, + StateProps, + DispatchProps, + UpperWingFlameGraphHandle +>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getPreviewFilteredThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + maxStackDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + flameGraphTiming: + selectedThreadSelectors.getUpperWingFlameGraphTiming(state), + callTree: selectedThreadSelectors.getUpperWingCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getUpperWingCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + selectedCallNodeIndex: + selectedThreadSelectors.getUpperWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getUpperWingRightClickedCallNodeIndex(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + isInverted: getInvertCallstack(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + tracedTiming: selectedThreadSelectors.getTracedTiming(state), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + changeUpperWingSelectedCallNode, + changeUpperWingRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + component: UpperWingFlameGraphImpl, +}); diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 21cba59093..c3f0c7cfc9 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -657,6 +657,31 @@ export function getStackAndSampleSelectorsPerThread( FlameGraph.getFlameGraphTiming ); + const _getUpperWingCallTreeTimingsNonInverted: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimingsNonInverted + ); + + const getUpperWingFlameGraphRows: Selector = + createSelector( + (state: State) => getUpperWingCallNodeInfo(state).getCallNodeTable(), + (state: State) => + threadSelectors.getPreviewFilteredThread(state).funcTable, + (state: State) => + threadSelectors.getPreviewFilteredThread(state).stringTable, + FlameGraph.computeFlameGraphRows + ); + + const getUpperWingFlameGraphTiming: Selector = + createSelector( + getUpperWingFlameGraphRows, + (state: State) => getUpperWingCallNodeInfo(state).getCallNodeTable(), + _getUpperWingCallTreeTimingsNonInverted, + FlameGraph.getFlameGraphTiming + ); + const getRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, @@ -788,6 +813,7 @@ export function getStackAndSampleSelectorsPerThread( getFunctionListTimings, getLowerWingCallTree, getUpperWingCallTree, + getUpperWingFlameGraphTiming, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, From 19ebedfa821f19f5761f28ea8b8e4a1007a5ef54 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 2 Apr 2026 16:56:28 -0400 Subject: [PATCH 19/41] Add self wing --- .../calltree/ProfileFunctionListView.tsx | 2 + src/components/calltree/SelfWing.tsx | 220 ++++++++++++++++++ src/selectors/per-thread/stack-sample.ts | 116 +++++++++ 3 files changed, 338 insertions(+) create mode 100644 src/components/calltree/SelfWing.tsx diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 9bff75c8a0..a39157f3b7 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { FunctionList } from './FunctionList'; +import { SelfWing } from './SelfWing'; import { UpperWing } from './UpperWing'; import { LowerWing } from './LowerWing'; import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; @@ -31,6 +32,7 @@ export const ProfileFunctionListView = () => ( > +
diff --git a/src/components/calltree/SelfWing.tsx b/src/components/calltree/SelfWing.tsx new file mode 100644 index 0000000000..2a6d8f86fb --- /dev/null +++ b/src/components/calltree/SelfWing.tsx @@ -0,0 +1,220 @@ +/* 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 * as React from 'react'; + +import explicitConnect from 'firefox-profiler/utils/connect'; +import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + getSelectedThreadsKey, + getInvertCallstack, +} from 'firefox-profiler/selectors/url-state'; +import { updateBottomBoxContentsAndMaybeOpen } from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; +import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly isInverted: boolean; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +type LocalState = { + selectedCallNodeIndex: IndexIntoCallNodeTable | null; + rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; +}; + +class SelfWingImpl extends React.PureComponent { + override state: LocalState = { + selectedCallNodeIndex: null, + rightClickedCallNodeIndex: null, + }; + + override componentDidUpdate(prevProps: Props, _prevState: LocalState) { + // Reset local selection when the call node info changes (e.g. different + // function selected) since old call node indices are no longer valid. + if ( + prevProps.callNodeInfo !== this.props.callNodeInfo || + prevProps.threadsKey !== this.props.threadsKey + ) { + this.setState({ + selectedCallNodeIndex: null, + rightClickedCallNodeIndex: null, + }); + } + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + this.setState({ selectedCallNodeIndex: callNodeIndex }); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + this.setState({ rightClickedCallNodeIndex: callNodeIndex }); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + }; + + // Transforms are disabled in the SelfWing because it operates on an ephemeral + // thread that is not part of the Redux transform stack. + _onKeyboardTransformShortcut = ( + _event: React.KeyboardEvent, + _nodeIndex: IndexIntoCallNodeTable + ) => {}; + + override render() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + isInverted, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + displayStackType, + } = this.props; + + const { selectedCallNodeIndex, rightClickedCallNodeIndex } = this.state; + + return ( + + ); + } +} + +export const SelfWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getSelfWingThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + maxStackDepthPlusOne: + selectedThreadSelectors.getSelfWingCallNodeMaxDepthPlusOne(state), + flameGraphTiming: + selectedThreadSelectors.getSelfWingFlameGraphTiming(state), + callTree: selectedThreadSelectors.getSelfWingCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getSelfWingCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + isInverted: getInvertCallstack(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getSelfWingCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getSelfWingCtssSampleCategoriesAndSubcategories( + state + ), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + updateBottomBoxContentsAndMaybeOpen, + }, + component: SelfWingImpl, +}); diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index c3f0c7cfc9..72d4529db2 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -11,6 +11,7 @@ import * as ProfileData from '../../profile-logic/profile-data'; import * as StackTiming from '../../profile-logic/stack-timing'; import * as FlameGraph from '../../profile-logic/flame-graph'; import * as CallTree from '../../profile-logic/call-tree'; +import * as Transforms from '../../profile-logic/transforms'; import type { PathSet } from '../../utils/path'; import * as ProfileSelectors from '../profile'; import { getRightClickedCallNodeInfo } from '../right-clicked-call-node'; @@ -44,6 +45,8 @@ import type { CallNodeTableBitSet, IndexIntoFuncTable, IndexIntoStackTable, + SamplesLikeTable, + SampleCategoriesAndSubcategories, } from 'firefox-profiler/types'; import type { CallNodeInfo, @@ -682,6 +685,112 @@ export function getStackAndSampleSelectorsPerThread( FlameGraph.getFlameGraphTiming ); + // Self wing: focusSelf(rangeAndTransformFilteredThread, selectedFunc, implFilter) + // This uses the thread BEFORE the implementation filter so that native frames + // that are "inside" the selected function's self time are visible even when + // the implementation filter is set to "JS only". + const getSelfWingThread: Selector = createSelector( + threadSelectors.getRangeAndTransformFilteredThread, + getSelectedFunctionIndex, + UrlState.getImplementationFilter, + (thread, funcIndex, implFilter) => { + if (funcIndex === null) { + return thread; + } + return Transforms.focusSelf(thread, funcIndex, implFilter); + } + ); + + const _getSelfWingCallNodeInfo: Selector = createSelector( + (state: State) => getSelfWingThread(state).stackTable, + (state: State) => getSelfWingThread(state).frameTable, + ProfileSelectors.getDefaultCategory, + ProfileData.getCallNodeInfo + ); + + const _getSelfWingCtssSamples: Selector = createSelector( + getSelfWingThread, + threadSelectors.getCallTreeSummaryStrategy, + CallTree.extractSamplesLikeTable + ); + + const _getSelfWingSampleIndexToCallNodeIndex: Selector< + Array + > = createSelector( + (state: State) => _getSelfWingCtssSamples(state).stack, + (state: State) => + _getSelfWingCallNodeInfo(state).getStackIndexToNonInvertedCallNodeIndex(), + ProfileData.getSampleIndexToCallNodeIndex + ); + + const _getSelfWingCallNodeSelfAndSummary: Selector = + createSelector( + _getSelfWingCtssSamples, + _getSelfWingSampleIndexToCallNodeIndex, + (state: State) => + _getSelfWingCallNodeInfo(state).getCallNodeTable().length, + CallTree.computeCallNodeSelfAndSummary + ); + + const _getSelfWingCallTreeTimings: Selector = + createSelector( + _getSelfWingCallNodeInfo, + _getSelfWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimings + ); + + const _getSelfWingCallTreeTimingsNonInverted: Selector = + createSelector( + _getSelfWingCallNodeInfo, + _getSelfWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimingsNonInverted + ); + + const getSelfWingCallTree: Selector = createSelector( + getSelfWingThread, + _getSelfWingCallNodeInfo, + ProfileSelectors.getCategories, + _getSelfWingCtssSamples, + _getSelfWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + + const _getSelfWingFlameGraphRows: Selector = + createSelector( + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + (state: State) => getSelfWingThread(state).funcTable, + (state: State) => getSelfWingThread(state).stringTable, + FlameGraph.computeFlameGraphRows + ); + + const getSelfWingFlameGraphTiming: Selector = + createSelector( + _getSelfWingFlameGraphRows, + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + _getSelfWingCallTreeTimingsNonInverted, + FlameGraph.getFlameGraphTiming + ); + + const getSelfWingCallNodeMaxDepthPlusOne: Selector = createSelector( + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + (callNodeTable) => callNodeTable.maxDepth + 1 + ); + + const getSelfWingCallNodeInfo: Selector = + _getSelfWingCallNodeInfo; + + const getSelfWingCtssSamples: Selector = + _getSelfWingCtssSamples; + + const getSelfWingCtssSampleCategoriesAndSubcategories: Selector = + createSelector( + getSelfWingThread, + _getSelfWingCtssSamples, + ProfileSelectors.getDefaultCategory, + CallTree.computeUnfilteredCtssSampleCategoriesAndSubcategories + ); + const getRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, @@ -814,6 +923,13 @@ export function getStackAndSampleSelectorsPerThread( getLowerWingCallTree, getUpperWingCallTree, getUpperWingFlameGraphTiming, + getSelfWingThread, + getSelfWingCallNodeInfo, + getSelfWingCallTree, + getSelfWingFlameGraphTiming, + getSelfWingCallNodeMaxDepthPlusOne, + getSelfWingCtssSamples, + getSelfWingCtssSampleCategoriesAndSubcategories, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, From 37abc89cccd39630df9517b150c4ff8c3f6af570 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 2 Apr 2026 17:31:33 -0400 Subject: [PATCH 20/41] Add disclosure box --- .../calltree/ProfileFunctionListView.tsx | 13 ++++- src/components/shared/DisclosureBox.css | 56 +++++++++++++++++++ src/components/shared/DisclosureBox.tsx | 49 ++++++++++++++++ 3 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/components/shared/DisclosureBox.css create mode 100644 src/components/shared/DisclosureBox.tsx diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index a39157f3b7..7a38e7c778 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -6,6 +6,7 @@ import { FunctionList } from './FunctionList'; import { SelfWing } from './SelfWing'; import { UpperWing } from './UpperWing'; import { LowerWing } from './LowerWing'; +import { DisclosureBox } from 'firefox-profiler/components/shared/DisclosureBox'; import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; import { ResizableWithSplitter } from '../shared/ResizableWithSplitter'; @@ -30,9 +31,15 @@ export const ProfileFunctionListView = () => ( percent={true} initialSize="50%" > - - - + + + + + + + + + diff --git a/src/components/shared/DisclosureBox.css b/src/components/shared/DisclosureBox.css new file mode 100644 index 0000000000..f0f6544419 --- /dev/null +++ b/src/components/shared/DisclosureBox.css @@ -0,0 +1,56 @@ +/* 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/. */ + +.disclosureBox { + display: flex; + min-height: 0; + flex-direction: column; +} + +.disclosureBox.open { + flex: 1; +} + +.disclosureBoxButton { + display: flex; + flex-shrink: 0; + align-items: center; + padding: 2px 6px; + border: none; + border-top: 1px solid var(--base-border-color); + background: var(--panel-background-color); + color: var(--panel-foreground-color); + cursor: pointer; + font-size: 11px; + font-weight: bold; + gap: 4px; + text-align: start; +} + +.disclosureBoxButton:hover { + background: var(--clickable-ghost-hover-background-color); +} + +.disclosureBoxButton:active { + background: var(--clickable-ghost-active-background-color); +} + +.disclosureBoxArrow { + display: inline-block; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 6px solid currentcolor; +} + +.disclosureBox.open .disclosureBoxArrow { + transform: rotate(90deg); +} + +.disclosureBoxContents { + display: flex; + min-height: 0; + flex: 1; +} diff --git a/src/components/shared/DisclosureBox.tsx b/src/components/shared/DisclosureBox.tsx new file mode 100644 index 0000000000..c0326ee675 --- /dev/null +++ b/src/components/shared/DisclosureBox.tsx @@ -0,0 +1,49 @@ +/* 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/. */ + +import * as React from 'react'; + +import './DisclosureBox.css'; + +type Props = { + readonly label: string; + readonly initialOpen?: boolean; + readonly children: React.ReactNode; +}; + +type State = { + isOpen: boolean; +}; + +export class DisclosureBox extends React.PureComponent { + override state: State = { + isOpen: this.props.initialOpen ?? true, + }; + + _onToggle = () => { + this.setState((state) => ({ isOpen: !state.isOpen })); + }; + + override render() { + const { label, children } = this.props; + const { isOpen } = this.state; + + return ( +
+ + {isOpen ? ( +
{children}
+ ) : null} +
+ ); + } +} From 9bdb60e0e93ea6441fc7f92257d5f214c402c8e2 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 09:34:45 -0400 Subject: [PATCH 21/41] Remove horizontal splitters, just use disclosure boxes. --- src/components/calltree/Butterfly.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/calltree/Butterfly.css b/src/components/calltree/Butterfly.css index c40a375bfa..62f236e0cb 100644 --- a/src/components/calltree/Butterfly.css +++ b/src/components/calltree/Butterfly.css @@ -44,3 +44,12 @@ .butterflyWrapper .resizableWithSplitterSplitter.resizesHeight::before { inset: -3px 0; } + +.functionListTreeWrapper { + display: flex; + flex-flow: column nowrap; +} + +.functionListTreeWrapper .treeRowToggleButton { + display: none; +} From b2515c163053b342a91da1bf53160c487f7b9542 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 10:33:49 -0400 Subject: [PATCH 22/41] Fix flamegraph percentages for the function list subtree views. --- src/components/flame-graph/Canvas.tsx | 13 +++++----- src/components/flame-graph/FlameGraph.tsx | 4 +-- src/profile-logic/call-tree.ts | 9 ++++--- src/profile-logic/flame-graph.ts | 30 ++++++++++++++++++----- src/selectors/per-thread/stack-sample.ts | 30 ++++++++++++++++++++--- src/test/store/actions.test.ts | 4 +-- src/types/profile-derived.ts | 6 +++++ 7 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 1c163512b2..5f2a6bc476 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -245,7 +245,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { // The graph is drawn from bottom to top, in order of increasing depth. for (let depth = startDepth; depth < endDepth; depth++) { // Get the timing information for a row of stack frames. - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.rows[depth]; if (!stackTiming) { continue; @@ -373,7 +373,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { return null; } - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.rows[depth]; if (!stackTiming) { return null; } @@ -383,8 +383,9 @@ class FlameGraphCanvasImpl extends React.PureComponent { } const ratio = - stackTiming.end[flameGraphTimingIndex] - - stackTiming.start[flameGraphTimingIndex]; + (stackTiming.end[flameGraphTimingIndex] - + stackTiming.start[flameGraphTimingIndex]) * + flameGraphTiming.tooltipRatioMultiplier; let percentage = formatPercent(ratio); if (tracedTiming) { @@ -443,7 +444,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { const { depth, flameGraphTimingIndex } = hoveredItem; const { flameGraphTiming } = this.props; - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.rows[depth]; const callNodeIndex = stackTiming.callNode[flameGraphTimingIndex]; return callNodeIndex; } @@ -477,7 +478,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { const depth = Math.floor( maxStackDepthPlusOne - (y + viewportTop) / ROW_HEIGHT ); - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.rows[depth]; if (!stackTiming) { return null; diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 9702569d9a..07fd5d47b5 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -120,7 +120,7 @@ class FlameGraphImpl const callNodeTable = callNodeInfo.getCallNodeTable(); const depth = callNodeTable.depth[callNodeIndex]; - const row = flameGraphTiming[depth]; + const row = flameGraphTiming.rows[depth]; const columnIndex = row.callNode.indexOf(callNodeIndex); return row.end[columnIndex] - row.start[columnIndex] > SELECTABLE_THRESHOLD; }; @@ -145,7 +145,7 @@ class FlameGraphImpl const callNodeTable = callNodeInfo.getCallNodeTable(); const depth = callNodeTable.depth[callNodeIndex]; - const row = flameGraphTiming[depth]; + const row = flameGraphTiming.rows[depth]; let columnIndex = row.callNode.indexOf(callNodeIndex); do { diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 4bb91b7402..2b2525ee34 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -54,6 +54,7 @@ export type CallTreeTimingsNonInverted = { self: Float64Array; total: Float64Array; rootTotalSummary: number; // sum of absolute values, this is used for computing percentages + flameGraphTotalForScaling: number; // used as 100% reference for flame graph box widths }; type TotalAndHasChildren = { total: number; hasChildren: boolean }; @@ -748,7 +749,7 @@ export function computeCallNodeSelfAndSummary( rootTotalSummary += abs(callNodeSelf[callNodeIndex]); } - return { callNodeSelf, rootTotalSummary }; + return { callNodeSelf, rootTotalSummary, flameGraphTotalForScaling: rootTotalSummary }; } export function getSelfAndTotalForCallNode( @@ -936,6 +937,7 @@ export function computeLowerWingTimings( timings: computeCallTreeTimingsInverted(callNodeInfo, { callNodeSelf: mappedSelf, rootTotalSummary, + flameGraphTotalForScaling: rootTotalSummary, }), }; } @@ -972,7 +974,7 @@ export function computeCallTreeTimingsNonInverted( callNodeSelfAndSummary: CallNodeSelfAndSummary ): CallTreeTimingsNonInverted { const callNodeTable = callNodeInfo.getCallNodeTable(); - const { callNodeSelf, rootTotalSummary } = callNodeSelfAndSummary; + const { callNodeSelf, rootTotalSummary, flameGraphTotalForScaling } = callNodeSelfAndSummary; // Compute the following variables: const callNodeTotal = new Float64Array(callNodeTable.length); @@ -1010,6 +1012,7 @@ export function computeCallTreeTimingsNonInverted( total: callNodeTotal, callNodeHasChildren, rootTotalSummary, + flameGraphTotalForScaling, }; } @@ -1412,5 +1415,5 @@ export function computeCallNodeTracedSelfAndSummary( } } - return { callNodeSelf, rootTotalSummary }; + return { callNodeSelf, rootTotalSummary, flameGraphTotalForScaling: rootTotalSummary }; } diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 2cf8e3701d..da07641f10 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -30,13 +30,28 @@ export type IndexIntoFlameGraphTiming = number; * selfRelative contains the self time relative to the total time, * which is used to color the drawn functions. */ -export type FlameGraphTiming = Array<{ +export type FlameGraphTimingRow = { start: UnitIntervalOfProfileRange[]; end: UnitIntervalOfProfileRange[]; selfRelative: Array; callNode: IndexIntoCallNodeTable[]; length: number; -}>; +}; + +/** + * FlameGraphTiming is an array of rows plus a scalar adjustment factor. + * + * tooltipRatioMultiplier converts a box's (end - start) width to a percentage + * relative to all filtered samples. Multiply (end - start) by this to get the + * tooltip percentage. It equals flameGraphTotalForScaling / rootTotalSummary, + * which is 1.0 for normal flame graphs and < 1.0 for the upper wing (where + * boxes are scaled so that the root fills the full width, but tooltips still + * show percentages relative to all filtered samples). + */ +export type FlameGraphTiming = { + rows: FlameGraphTimingRow[]; + tooltipRatioMultiplier: number; +}; /** * FlameGraphRows is an array of rows, where each row is an array of call node @@ -232,7 +247,7 @@ export function getFlameGraphTiming( callNodeTable: CallNodeTable, callTreeTimings: CallTreeTimingsNonInverted ): FlameGraphTiming { - const { total, self, rootTotalSummary } = callTreeTimings; + const { total, self, rootTotalSummary, flameGraphTotalForScaling } = callTreeTimings; const { prefix } = callNodeTable; // This is where we build up the return value, one row at a time. @@ -284,8 +299,8 @@ export function getFlameGraphTiming( startPerCallNode[nodeIndex] = currentStart; // Take the absolute value, as native deallocations can be negative. - const totalRelativeVal = abs(totalVal / rootTotalSummary); - const selfRelativeVal = abs(self[nodeIndex] / rootTotalSummary); + const totalRelativeVal = abs(totalVal / flameGraphTotalForScaling); + const selfRelativeVal = abs(self[nodeIndex] / flameGraphTotalForScaling); const currentEnd = currentStart + totalRelativeVal; start.push(currentStart); @@ -305,5 +320,8 @@ export function getFlameGraphTiming( }; } - return timing; + return { + rows: timing, + tooltipRatioMultiplier: flameGraphTotalForScaling / rootTotalSummary, + }; } diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 72d4529db2..cc5d52c55f 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -471,12 +471,20 @@ export function getStackAndSampleSelectorsPerThread( regularTreeSelfAndSummary ) => { const { rootTotalSummary } = regularTreeSelfAndSummary; - const { callNodeSelf } = CallTree.computeCallNodeSelfAndSummary( + const upperWingSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( samples, sampleIndexToCallNodeIndex, callNodeInfo.getCallNodeTable().length ); - return { rootTotalSummary, callNodeSelf }; + const { callNodeSelf } = upperWingSelfAndSummary; + // Use the upper wing's own total as the flame graph scaling reference, + // so that the root node (the selected function) fills the full flame + // graph width. The rootTotalSummary from the regular tree is kept for + // percentage display, so tooltips show percentages relative to all + // filtered samples (e.g. "80%" if 800 of 1000 samples contain the + // selected function). + const flameGraphTotalForScaling = upperWingSelfAndSummary.rootTotalSummary; + return { rootTotalSummary, callNodeSelf, flameGraphTotalForScaling }; } ); @@ -729,7 +737,23 @@ export function getStackAndSampleSelectorsPerThread( _getSelfWingSampleIndexToCallNodeIndex, (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable().length, - CallTree.computeCallNodeSelfAndSummary + getCallNodeSelfAndSummary, + (samples, sampleIndexToCallNodeIndex, callNodeCount, regularTreeSelfAndSummary) => { + const selfWingSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeCount + ); + // Keep flameGraphTotalForScaling as the self wing's own total so the + // root fills the full flame graph width. Override rootTotalSummary with + // the regular tree's value so tooltips show percentages relative to all + // filtered samples. + return { + callNodeSelf: selfWingSelfAndSummary.callNodeSelf, + flameGraphTotalForScaling: selfWingSelfAndSummary.rootTotalSummary, + rootTotalSummary: regularTreeSelfAndSummary.rootTotalSummary, + }; + } ); const _getSelfWingCallTreeTimings: Selector = diff --git a/src/test/store/actions.test.ts b/src/test/store/actions.test.ts index 7c2b2f17c6..42a02d1ea3 100644 --- a/src/test/store/actions.test.ts +++ b/src/test/store/actions.test.ts @@ -206,7 +206,7 @@ describe('selectors/getFlameGraphTiming', function () { store.getState() ); - return flameGraphTiming.map(({ callNode, end, length, start }) => { + return flameGraphTiming.rows.map(({ callNode, end, length, start }) => { const lines = []; for (let i = 0; i < length; i++) { const callNodeIndex = callNode[i]; @@ -240,7 +240,7 @@ describe('selectors/getFlameGraphTiming', function () { store.getState() ); - return flameGraphTiming.map(({ selfRelative, callNode, length }) => { + return flameGraphTiming.rows.map(({ selfRelative, callNode, length }) => { const lines = []; for (let i = 0; i < length; i++) { const callNodeIndex = callNode[i]; diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 71955232e9..bf5c0dd800 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -730,6 +730,12 @@ export type CallNodeSelfAndSummary = { // The sum of absolute values in callNodeSelf. // This is used for computing the percentages displayed in the call tree. rootTotalSummary: number; + // The total used as the 100% reference for flame graph box widths. + // Usually equals rootTotalSummary, but for the upper wing it is set to the + // total of samples that contain the selected function, so that the root node + // fills the full flame graph width while percentages remain relative to all + // filtered samples. + flameGraphTotalForScaling: number; }; /** From bc34598af58b5bebcbc21acfd8be910fd7d15da5 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 9 Apr 2026 17:09:57 -0400 Subject: [PATCH 23/41] Fix double-click opening source view and context menus in butterfly wings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix wrong tab slug ('calltree' → 'function-list') in _onEnterOrDoubleClick for SelfWing, UpperWingFlameGraph, LowerWing, and UpperWing so that double-clicking a call node opens the bottom box for the correct tab - Add a contextMenuId prop to FlameGraph (defaulting to 'CallNodeContextMenu') so wings can specify a different context menu - Wire up FunctionListContextMenu for UpperWingFlameGraph and SelfWing: right-clicking now dispatches changeRightClickedFunctionIndex so the FunctionListContextMenu (already rendered in Details) can respond --- src/components/calltree/LowerWing.tsx | 2 +- src/components/calltree/SelfWing.tsx | 15 +++++++++++++-- src/components/calltree/UpperWing.tsx | 2 +- src/components/calltree/UpperWingFlameGraph.tsx | 17 ++++++++++++++--- src/components/flame-graph/FlameGraph.tsx | 4 +++- 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/components/calltree/LowerWing.tsx b/src/components/calltree/LowerWing.tsx index 0f59ecb587..f57957a422 100644 --- a/src/components/calltree/LowerWing.tsx +++ b/src/components/calltree/LowerWing.tsx @@ -202,7 +202,7 @@ class LowerWingImpl extends PureComponent { _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); - updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); }; maybeProcureInterestingInitialSelection() { diff --git a/src/components/calltree/SelfWing.tsx b/src/components/calltree/SelfWing.tsx index 2a6d8f86fb..4f7fc26a87 100644 --- a/src/components/calltree/SelfWing.tsx +++ b/src/components/calltree/SelfWing.tsx @@ -22,7 +22,10 @@ import { getSelectedThreadsKey, getInvertCallstack, } from 'firefox-profiler/selectors/url-state'; -import { updateBottomBoxContentsAndMaybeOpen } from 'firefox-profiler/actions/profile-view'; +import { + updateBottomBoxContentsAndMaybeOpen, + changeRightClickedFunctionIndex, +} from 'firefox-profiler/actions/profile-view'; import type { Thread, @@ -69,6 +72,7 @@ type StateProps = { type DispatchProps = { readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; }; type Props = ConnectedProps<{}, StateProps, DispatchProps>; @@ -108,6 +112,11 @@ class SelfWingImpl extends React.PureComponent { callNodeIndex: IndexIntoCallNodeTable | null ) => { this.setState({ rightClickedCallNodeIndex: callNodeIndex }); + const { callNodeInfo, threadsKey, changeRightClickedFunctionIndex } = + this.props; + const funcIndex = + callNodeIndex !== null ? callNodeInfo.funcForNode(callNodeIndex) : null; + changeRightClickedFunctionIndex(threadsKey, funcIndex); }; _onCallNodeEnterOrDoubleClick = ( @@ -118,7 +127,7 @@ class SelfWingImpl extends React.PureComponent { } const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); - updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); }; // Transforms are disabled in the SelfWing because it operates on an ephemeral @@ -177,6 +186,7 @@ class SelfWingImpl extends React.PureComponent { } tracedTiming={null} displayStackType={displayStackType} + contextMenuId="FunctionListContextMenu" onSelectedCallNodeChange={this._onSelectedCallNodeChange} onRightClickedCallNodeChange={this._onRightClickedCallNodeChange} onCallNodeEnterOrDoubleClick={this._onCallNodeEnterOrDoubleClick} @@ -215,6 +225,7 @@ export const SelfWing = explicitConnect<{}, StateProps, DispatchProps>({ }), mapDispatchToProps: { updateBottomBoxContentsAndMaybeOpen, + changeRightClickedFunctionIndex, }, component: SelfWingImpl, }); diff --git a/src/components/calltree/UpperWing.tsx b/src/components/calltree/UpperWing.tsx index 6733c64d9e..f03a545ed4 100644 --- a/src/components/calltree/UpperWing.tsx +++ b/src/components/calltree/UpperWing.tsx @@ -204,7 +204,7 @@ class UpperWingImpl extends PureComponent { _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); - updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); }; maybeProcureInterestingInitialSelection() { diff --git a/src/components/calltree/UpperWingFlameGraph.tsx b/src/components/calltree/UpperWingFlameGraph.tsx index 3ff9f77eae..9d95d35bd0 100644 --- a/src/components/calltree/UpperWingFlameGraph.tsx +++ b/src/components/calltree/UpperWingFlameGraph.tsx @@ -25,6 +25,7 @@ import { import { changeUpperWingSelectedCallNode, changeUpperWingRightClickedCallNode, + changeRightClickedFunctionIndex, handleCallNodeTransformShortcut, updateBottomBoxContentsAndMaybeOpen, } from 'firefox-profiler/actions/profile-view'; @@ -83,6 +84,7 @@ type StateProps = { type DispatchProps = { readonly changeUpperWingSelectedCallNode: typeof changeUpperWingSelectedCallNode; readonly changeUpperWingRightClickedCallNode: typeof changeUpperWingRightClickedCallNode; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; }; @@ -119,12 +121,19 @@ class UpperWingFlameGraphImpl _onRightClickedCallNodeChange = ( callNodeIndex: IndexIntoCallNodeTable | null ) => { - const { callNodeInfo, threadsKey, changeUpperWingRightClickedCallNode } = - this.props; + const { + callNodeInfo, + threadsKey, + changeUpperWingRightClickedCallNode, + changeRightClickedFunctionIndex, + } = this.props; changeUpperWingRightClickedCallNode( threadsKey, callNodeInfo.getCallNodePathFromIndex(callNodeIndex) ); + const funcIndex = + callNodeIndex !== null ? callNodeInfo.funcForNode(callNodeIndex) : null; + changeRightClickedFunctionIndex(threadsKey, funcIndex); }; _onCallNodeEnterOrDoubleClick = ( @@ -135,7 +144,7 @@ class UpperWingFlameGraphImpl } const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); - updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); }; _onKeyboardTransformShortcut = ( @@ -198,6 +207,7 @@ class UpperWingFlameGraphImpl } tracedTiming={tracedTiming} displayStackType={displayStackType} + contextMenuId="FunctionListContextMenu" onSelectedCallNodeChange={this._onSelectedCallNodeChange} onRightClickedCallNodeChange={this._onRightClickedCallNodeChange} onCallNodeEnterOrDoubleClick={this._onCallNodeEnterOrDoubleClick} @@ -247,6 +257,7 @@ export const UpperWingFlameGraph = explicitConnectWithForwardRef< mapDispatchToProps: { changeUpperWingSelectedCallNode, changeUpperWingRightClickedCallNode, + changeRightClickedFunctionIndex, handleCallNodeTransformShortcut, updateBottomBoxContentsAndMaybeOpen, }, diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 07fd5d47b5..d76d079f2d 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -65,6 +65,7 @@ export type Props = { readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; readonly tracedTiming: CallTreeTimings | null; readonly displayStackType: boolean; + readonly contextMenuId?: string; readonly onSelectedCallNodeChange: ( callNodeIndex: IndexIntoCallNodeTable | null ) => void; @@ -282,6 +283,7 @@ class FlameGraphImpl ctssSampleCategoriesAndSubcategories, tracedTiming, displayStackType, + contextMenuId = 'CallNodeContextMenu', onSelectedCallNodeChange, onRightClickedCallNodeChange, onCallNodeEnterOrDoubleClick, @@ -305,7 +307,7 @@ class FlameGraphImpl return (
Date: Tue, 14 Apr 2026 16:08:11 -0400 Subject: [PATCH 24/41] Implement context menu for self wing --- .../shared/FunctionListContextMenu.tsx | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/components/shared/FunctionListContextMenu.tsx b/src/components/shared/FunctionListContextMenu.tsx index ccbdeb54a8..aa4bdc1069 100644 --- a/src/components/shared/FunctionListContextMenu.tsx +++ b/src/components/shared/FunctionListContextMenu.tsx @@ -52,6 +52,7 @@ type StateProps = { readonly threadsKey: ThreadsKey | null; readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; readonly callNodeTable: CallNodeTable | null; + readonly selfWingCallNodeTable: CallNodeTable | null; readonly implementation: ImplementationFilter; readonly displaySearchfox: boolean; }; @@ -86,9 +87,15 @@ class FunctionListContextMenuImpl extends PureComponent { readonly threadsKey: ThreadsKey; readonly funcIndex: IndexIntoFuncTable; readonly callNodeTable: CallNodeTable; + readonly selfWingCallNodeTable: CallNodeTable | null; } { - const { thread, threadsKey, rightClickedFunctionIndex, callNodeTable } = - this.props; + const { + thread, + threadsKey, + rightClickedFunctionIndex, + callNodeTable, + selfWingCallNodeTable, + } = this.props; if ( thread !== null && threadsKey !== null && @@ -100,6 +107,7 @@ class FunctionListContextMenuImpl extends PureComponent { threadsKey, funcIndex: rightClickedFunctionIndex, callNodeTable, + selfWingCallNodeTable, }; } return null; @@ -312,7 +320,7 @@ class FunctionListContextMenuImpl extends PureComponent { return
; } - const { funcIndex, callNodeTable } = info; + const { funcIndex, callNodeTable, selfWingCallNodeTable } = info; const nameForResource = this.getNameForSelectedResource(); return ( @@ -371,7 +379,9 @@ class FunctionListContextMenuImpl extends PureComponent { }) : null} - {funcHasRecursiveCall(callNodeTable, funcIndex) + {funcHasRecursiveCall(callNodeTable, funcIndex) || + (selfWingCallNodeTable !== null && + funcHasRecursiveCall(selfWingCallNodeTable, funcIndex)) ? this.renderTransformMenuItem({ l10nId: 'CallNodeContextMenu--transform-collapse-recursion', shortcut: 'r', @@ -383,7 +393,9 @@ class FunctionListContextMenuImpl extends PureComponent { }) : null} - {funcHasDirectRecursiveCall(callNodeTable, funcIndex) + {funcHasDirectRecursiveCall(callNodeTable, funcIndex) || + (selfWingCallNodeTable !== null && + funcHasDirectRecursiveCall(selfWingCallNodeTable, funcIndex)) ? this.renderTransformMenuItem({ l10nId: 'CallNodeContextMenu--transform-collapse-direct-recursion-only', @@ -458,6 +470,7 @@ export const FunctionListContextMenu = explicitConnect< let threadsKey = null; let rightClickedFunctionIndex = null; let callNodeTable = null; + let selfWingCallNodeTable = null; if (rightClickedFunction !== null) { const selectors = getThreadSelectorsFromThreadsKey( @@ -468,6 +481,11 @@ export const FunctionListContextMenu = explicitConnect< rightClickedFunctionIndex = rightClickedFunction.functionIndex; // Use the non-inverted call node table for recursion detection. callNodeTable = selectors.getCallNodeInfo(state).getCallNodeTable(); + // Also check the self wing's call node table, which may reveal recursion + // not visible in the regular call node table due to the focusSelf filter. + selfWingCallNodeTable = selectors + .getSelfWingCallNodeInfo(state) + .getCallNodeTable(); } return { @@ -475,6 +493,7 @@ export const FunctionListContextMenu = explicitConnect< threadsKey, rightClickedFunctionIndex, callNodeTable, + selfWingCallNodeTable, implementation: getImplementationFilter(state), displaySearchfox: getShouldDisplaySearchfox(state), }; From cdddd7ee82d23b82d5f4d0ab12210676736d2cdc Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 7 May 2026 18:33:52 -0400 Subject: [PATCH 25/41] Persist disclosure box state in the URL. --- src/actions/profile-view.ts | 11 ++ src/app-logic/url-handling.ts | 61 +++++++++ .../calltree/ProfileFunctionListView.tsx | 119 +++++++++++++----- src/components/shared/DisclosureBox.tsx | 14 ++- src/reducers/url-state.ts | 20 +++ src/selectors/url-state.ts | 4 + src/types/actions.ts | 5 + src/types/state.ts | 7 ++ 8 files changed, 209 insertions(+), 32 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index b337984531..109b66239d 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -1775,6 +1775,17 @@ export function changeFunctionListSort(sort: SingleColumnSortState[]): Action { }; } +export function changeFunctionListSectionOpen( + section: 'descendants' | 'ancestors' | 'self', + isOpen: boolean +): Action { + return { + type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN', + section, + isOpen, + }; +} + export function changeNetworkSearchString(searchString: string): Action { return { type: 'CHANGE_NETWORK_SEARCH_STRING', diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 01adaa14a7..0409e0a4c6 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -42,6 +42,7 @@ import type { IndexIntoFrameTable, MarkerIndex, SelectedMarkersPerThread, + FunctionListSectionsOpenState, } from 'firefox-profiler/types'; import { decodeUintArrayFromUrlComponent, @@ -187,6 +188,7 @@ type CallTreeQuery = BaseQuery & { hideIdleSamples: null | undefined; ctSummary: string; functionListSort?: string; // "total-desc~self-asc" — primary first + funcListSections?: string; // "descendants,self" — comma-separated open sections }; type MarkersQuery = BaseQuery & { @@ -235,6 +237,7 @@ type Query = BaseQuery & { // Function list specific functionListSort?: string; + funcListSections?: string; // Network specific networkSearch?: string; @@ -394,6 +397,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.functionListSort = convertFunctionListSortToString( urlState.profileSpecific.functionListSort ); + query.funcListSections = convertFunctionListSectionsOpenToString( + urlState.profileSpecific.functionListSectionsOpen + ); } break; } @@ -652,6 +658,9 @@ export function stateFromLocation( functionListSort: convertFunctionListSortFromString( query.functionListSort ), + functionListSectionsOpen: convertFunctionListSectionsOpenFromString( + query.funcListSections + ), }, }; } @@ -751,6 +760,58 @@ function convertFunctionListSortFromString( return parsed.reverse(); } +// FunctionList section disclosure-box open/closed state. The URL stores a +// comma-separated list of the open sections; the param is omitted when the +// state matches the default (only "descendants" open). The value "none" is +// used as a sentinel for the all-closed case so the param is non-empty. +const FUNCTION_LIST_SECTION_NAMES: ReadonlyArray< + keyof FunctionListSectionsOpenState +> = ['descendants', 'ancestors', 'self']; +const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = { + descendants: true, + ancestors: false, + self: false, +}; + +function convertFunctionListSectionsOpenToString( + state: FunctionListSectionsOpenState +): string | undefined { + const matchesDefault = FUNCTION_LIST_SECTION_NAMES.every( + (name) => state[name] === FUNCTION_LIST_SECTIONS_OPEN_DEFAULT[name] + ); + if (matchesDefault) { + return undefined; + } + const open = FUNCTION_LIST_SECTION_NAMES.filter((name) => state[name]); + return open.length === 0 ? 'none' : open.join(','); +} + +function convertFunctionListSectionsOpenFromString( + raw: string | null | void +): FunctionListSectionsOpenState { + if (raw === undefined || raw === null) { + return { ...FUNCTION_LIST_SECTIONS_OPEN_DEFAULT }; + } + const result: FunctionListSectionsOpenState = { + descendants: false, + ancestors: false, + self: false, + }; + if (raw === 'none' || raw === '') { + return result; + } + for (const part of raw.split(',')) { + if ( + part === 'descendants' || + part === 'ancestors' || + part === 'self' + ) { + result[part] = true; + } + } + return result; +} + function convertGlobalTrackOrderFromString( rawString: string | null | void ): TrackIndex[] { diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 7a38e7c778..1ba34afb0a 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -2,6 +2,9 @@ * 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/. */ +import * as React from 'react'; + +import explicitConnect from 'firefox-profiler/utils/connect'; import { FunctionList } from './FunctionList'; import { SelfWing } from './SelfWing'; import { UpperWing } from './UpperWing'; @@ -10,37 +13,93 @@ import { DisclosureBox } from 'firefox-profiler/components/shared/DisclosureBox' import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; import { ResizableWithSplitter } from '../shared/ResizableWithSplitter'; +import { getFunctionListSectionsOpen } from 'firefox-profiler/selectors/url-state'; +import { changeFunctionListSectionOpen } from 'firefox-profiler/actions/profile-view'; + +import type { FunctionListSectionsOpenState } from 'firefox-profiler/types'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; import './Butterfly.css'; -export const ProfileFunctionListView = () => ( -
- - -
- - ; + +class ProfileFunctionListViewImpl extends React.PureComponent { + _onDescendantsToggle = (isOpen: boolean) => { + this.props.changeFunctionListSectionOpen('descendants', isOpen); + }; + _onAncestorsToggle = (isOpen: boolean) => { + this.props.changeFunctionListSectionOpen('ancestors', isOpen); + }; + _onSelfToggle = (isOpen: boolean) => { + this.props.changeFunctionListSectionOpen('self', isOpen); + }; + + override render() { + const { sectionsOpen } = this.props; + return ( +
- - - - - - - - - - -
-
-); + + +
+ + + + + + + + + + + + +
+
+ ); + } +} + +export const ProfileFunctionListView = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state) => ({ + sectionsOpen: getFunctionListSectionsOpen(state), + }), + mapDispatchToProps: { + changeFunctionListSectionOpen, + }, + component: ProfileFunctionListViewImpl, +}); diff --git a/src/components/shared/DisclosureBox.tsx b/src/components/shared/DisclosureBox.tsx index c0326ee675..13ba2ad891 100644 --- a/src/components/shared/DisclosureBox.tsx +++ b/src/components/shared/DisclosureBox.tsx @@ -9,6 +9,8 @@ import './DisclosureBox.css'; type Props = { readonly label: string; readonly initialOpen?: boolean; + readonly isOpen?: boolean; + readonly onToggle?: (isOpen: boolean) => void; readonly children: React.ReactNode; }; @@ -22,12 +24,20 @@ export class DisclosureBox extends React.PureComponent { }; _onToggle = () => { + const { isOpen, onToggle } = this.props; + if (isOpen !== undefined) { + if (onToggle) { + onToggle(!isOpen); + } + return; + } this.setState((state) => ({ isOpen: !state.isOpen })); }; override render() { - const { label, children } = this.props; - const { isOpen } = this.state; + const { label, children, isOpen: controlledOpen } = this.props; + const isOpen = + controlledOpen !== undefined ? controlledOpen : this.state.isOpen; return (
diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index f3902a4fff..0ac35c9e42 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -24,6 +24,7 @@ import type { IsOpenPerPanelState, TabID, SelectedMarkersPerThread, + FunctionListSectionsOpenState, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -218,6 +219,24 @@ const functionListSort: Reducer = ( } }; +const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = { + descendants: true, + ancestors: false, + self: false, +}; + +const functionListSectionsOpen: Reducer = ( + state = FUNCTION_LIST_SECTIONS_OPEN_DEFAULT, + action +) => { + switch (action.type) { + case 'CHANGE_FUNCTION_LIST_SECTION_OPEN': + return { ...state, [action.section]: action.isOpen }; + default: + return state; + } +}; + const networkSearchString: Reducer = (state = '', action) => { switch (action.type) { case 'CHANGE_NETWORK_SEARCH_STRING': @@ -820,6 +839,7 @@ const profileSpecific = combineReducers({ selectedMarkers, markerTableSort, functionListSort, + functionListSectionsOpen, // The timeline tracks used to be hidden and sorted by thread indexes, rather than // track indexes. The only way to migrate this information to tracks-based data is to // first retrieve the profile, so they can't be upgraded by the normal url upgrading diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 5fa38566ce..ad08d14df8 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -32,6 +32,7 @@ import type { TabID, IndexIntoSourceTable, MarkerIndex, + FunctionListSectionsOpenState, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -122,6 +123,9 @@ export const getMarkerTableSort: Selector = (state) => getProfileSpecificState(state).markerTableSort; export const getFunctionListSort: Selector = (state) => getProfileSpecificState(state).functionListSort; +export const getFunctionListSectionsOpen: Selector< + FunctionListSectionsOpenState +> = (state) => getProfileSpecificState(state).functionListSectionsOpen; export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => diff --git a/src/types/actions.ts b/src/types/actions.ts index 703d98e9b7..438455f21b 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -581,6 +581,11 @@ type UrlStateAction = readonly type: 'CHANGE_FUNCTION_LIST_SORT'; readonly sort: SingleColumnSortState[]; } + | { + readonly type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN'; + readonly section: 'descendants' | 'ancestors' | 'self'; + readonly isOpen: boolean; + } | { readonly type: 'CHANGE_NETWORK_SEARCH_STRING'; readonly searchString: string; diff --git a/src/types/state.ts b/src/types/state.ts index 8f753e50ac..be70f31d94 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -406,6 +406,13 @@ export type ProfileSpecificUrlState = { selectedMarkers: SelectedMarkersPerThread; markerTableSort: SingleColumnSortState[]; functionListSort: SingleColumnSortState[]; + functionListSectionsOpen: FunctionListSectionsOpenState; +}; + +export type FunctionListSectionsOpenState = { + descendants: boolean; + ancestors: boolean; + self: boolean; }; export type UrlState = { From 6b3569c1a2b4b7a0238c6397b9e8ecbf334588cf Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 7 May 2026 19:40:00 -0400 Subject: [PATCH 26/41] Persist selected function in the URL. --- src/actions/profile-view.ts | 21 +++++-- src/app-logic/url-handling.ts | 25 ++++++++ src/reducers/profile-view.ts | 62 ++++++++++++++++--- src/reducers/url-state.ts | 22 +++++++ src/selectors/per-thread/stack-sample.ts | 10 +-- src/selectors/url-state.ts | 7 +++ .../__snapshots__/profile-view.test.ts.snap | 1 - src/types/state.ts | 6 +- 8 files changed, 130 insertions(+), 24 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 109b66239d..4cc047bf74 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -81,6 +81,7 @@ import { funcHasRecursiveCall, } from '../profile-logic/transforms'; import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db'; +import { withHistoryReplaceStateSync } from 'firefox-profiler/app-logic/url-handling'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { CallNodeInfo } from '../profile-logic/call-node-info'; import type { SingleColumnSortState } from '../components/shared/TreeView'; @@ -163,17 +164,25 @@ export function changeUpperWingSelectedCallNode( /** * Select a function for a given thread in the function list. + * + * Uses replaceState rather than pushState so that holding e.g. the down arrow + * key in the function list doesn't get rate-limited by the browser and doesn't + * flood the back/forward history. */ export function changeSelectedFunctionIndex( threadsKey: ThreadsKey, selectedFunctionIndex: IndexIntoFuncTable | null, context: SelectionContext = { source: 'auto' } -): Action { - return { - type: 'CHANGE_SELECTED_FUNCTION', - selectedFunctionIndex, - threadsKey, - context, +): ThunkAction { + return (dispatch) => { + withHistoryReplaceStateSync(() => { + dispatch({ + type: 'CHANGE_SELECTED_FUNCTION', + selectedFunctionIndex, + threadsKey, + context, + }); + }); }; } diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 0409e0a4c6..6c4dc13b02 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -42,6 +42,7 @@ import type { IndexIntoFrameTable, MarkerIndex, SelectedMarkersPerThread, + SelectedFunctionsPerThread, FunctionListSectionsOpenState, } from 'firefox-profiler/types'; import { @@ -189,6 +190,7 @@ type CallTreeQuery = BaseQuery & { ctSummary: string; functionListSort?: string; // "total-desc~self-asc" — primary first funcListSections?: string; // "descendants,self" — comma-separated open sections + selectedFunc?: number; // Selected function index for the current thread, e.g. 42 }; type MarkersQuery = BaseQuery & { @@ -238,6 +240,7 @@ type Query = BaseQuery & { // Function list specific functionListSort?: string; funcListSections?: string; + selectedFunc?: number; // Network specific networkSearch?: string; @@ -400,6 +403,14 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.funcListSections = convertFunctionListSectionsOpenToString( urlState.profileSpecific.functionListSectionsOpen ); + query.selectedFunc = + selectedThreadsKey !== null && + urlState.profileSpecific.selectedFunctions[selectedThreadsKey] !== + null && + urlState.profileSpecific.selectedFunctions[selectedThreadsKey] !== + undefined + ? urlState.profileSpecific.selectedFunctions[selectedThreadsKey] + : undefined; } break; } @@ -563,6 +574,19 @@ export function stateFromLocation( } } + // Parse the selected function for the current thread + const selectedFunctions: SelectedFunctionsPerThread = {}; + if ( + selectedThreadsKey !== null && + query.selectedFunc !== undefined && + query.selectedFunc !== null + ) { + const funcIndex = Number(query.selectedFunc); + if (Number.isInteger(funcIndex) && funcIndex >= 0) { + selectedFunctions[selectedThreadsKey] = funcIndex; + } + } + // tabID is used for the tab selector that we have in our full view. let tabID = null; if (query.tabID && Number.isInteger(Number(query.tabID))) { @@ -654,6 +678,7 @@ export function stateFromLocation( ? query.hiddenThreads.split('-').map((index) => Number(index)) : null, selectedMarkers, + selectedFunctions, markerTableSort: convertMarkerTableSortFromString(query.markerSort), functionListSort: convertFunctionListSortFromString( query.functionListSort diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index 1cbfc9919c..42e5004397 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -193,7 +193,6 @@ export const defaultThreadViewOptions: ThreadViewOptions = { expandedInvertedCallNodePaths: new PathSet(), expandedLowerWingCallNodePaths: new PathSet(), expandedUpperWingCallNodePaths: new PathSet(), - selectedFunctionIndex: null, selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -343,17 +342,19 @@ const viewOptionsPerThread: Reducer = ( const threadState = _getThreadViewOptions(state, threadsKey); - const previousSelectedFunction = threadState.selectedFunctionIndex; + const previousLowerWingPath = threadState.selectedLowerWingCallNodePath; + const isSameSelection = + selectedFunctionIndex === null + ? previousLowerWingPath.length === 0 + : previousLowerWingPath.length === 1 && + previousLowerWingPath[0] === selectedFunctionIndex; - // If the selected function doesn't actually change, let's return the previous - // state to avoid rerenders. - if (selectedFunctionIndex === previousSelectedFunction) { + if (isSameSelection) { return state; } if (selectedFunctionIndex !== null) { return _updateThreadViewOptions(state, threadsKey, { - selectedFunctionIndex, selectedLowerWingCallNodePath: [selectedFunctionIndex], expandedLowerWingCallNodePaths: new PathSet([ [selectedFunctionIndex], @@ -366,7 +367,10 @@ const viewOptionsPerThread: Reducer = ( } return _updateThreadViewOptions(state, threadsKey, { - selectedFunctionIndex, + selectedLowerWingCallNodePath: [], + expandedLowerWingCallNodePaths: new PathSet(), + selectedUpperWingCallNodePath: [], + expandedUpperWingCallNodePaths: new PathSet(), }); } case 'CHANGE_INVERT_CALLSTACK': { @@ -506,8 +510,48 @@ const viewOptionsPerThread: Reducer = ( return state; } - const { transforms } = action.newUrlState.profileSpecific; - return objectMap(state, (viewOptions, threadsKey) => { + const { transforms, selectedFunctions } = action.newUrlState.profileSpecific; + + // The selected function lives in URL state; mirror it into the per-thread + // wing paths so that initial loads and back/forward navigation restore the + // wings to the right function. + const newState: ThreadViewOptionsPerThreads = { ...state }; + for (const threadsKey of Object.keys(selectedFunctions)) { + const selectedFunctionIndex = selectedFunctions[threadsKey]; + const viewOptions = _getThreadViewOptions(newState, threadsKey); + const previousLowerWingPath = viewOptions.selectedLowerWingCallNodePath; + const matchesExisting = + selectedFunctionIndex === null + ? previousLowerWingPath.length === 0 + : previousLowerWingPath.length === 1 && + previousLowerWingPath[0] === selectedFunctionIndex; + if (matchesExisting) { + continue; + } + if (selectedFunctionIndex === null) { + newState[threadsKey] = { + ...viewOptions, + selectedLowerWingCallNodePath: [], + expandedLowerWingCallNodePaths: new PathSet(), + selectedUpperWingCallNodePath: [], + expandedUpperWingCallNodePaths: new PathSet(), + }; + } else { + newState[threadsKey] = { + ...viewOptions, + selectedLowerWingCallNodePath: [selectedFunctionIndex], + expandedLowerWingCallNodePaths: new PathSet([ + [selectedFunctionIndex], + ]), + selectedUpperWingCallNodePath: [selectedFunctionIndex], + expandedUpperWingCallNodePaths: new PathSet([ + [selectedFunctionIndex], + ]), + }; + } + } + + return objectMap(newState, (viewOptions, threadsKey) => { const transformStack = transforms[threadsKey] || []; const newTransformCount = transformStack.length; const oldTransformCount = viewOptions.lastSeenTransformCount; diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 0ac35c9e42..75f9457383 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -24,6 +24,7 @@ import type { IsOpenPerPanelState, TabID, SelectedMarkersPerThread, + SelectedFunctionsPerThread, FunctionListSectionsOpenState, } from 'firefox-profiler/types'; @@ -808,6 +809,26 @@ const selectedMarkers: Reducer = ( } }; +const selectedFunctions: Reducer = ( + state = {}, + action +): SelectedFunctionsPerThread => { + switch (action.type) { + case 'CHANGE_SELECTED_FUNCTION': { + const { threadsKey, selectedFunctionIndex } = action; + if (state[threadsKey] === selectedFunctionIndex) { + return state; + } + return { + ...state, + [threadsKey]: selectedFunctionIndex, + }; + } + default: + return state; + } +}; + /** * These values are specific to an individual profile. */ @@ -837,6 +858,7 @@ const profileSpecific = combineReducers({ showJsTracerSummary, tabFilter, selectedMarkers, + selectedFunctions, markerTableSort, functionListSort, functionListSectionsOpen, diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index cc5d52c55f..34556bab3a 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -215,13 +215,9 @@ export function getStackAndSampleSelectorsPerThread( } ); - const getSelectedFunctionIndex: Selector = - createSelector( - threadSelectors.getViewOptions, - (threadViewOptions): IndexIntoFuncTable | null => { - return threadViewOptions.selectedFunctionIndex; - } - ); + const getSelectedFunctionIndex: Selector = ( + state + ) => UrlState.getSelectedFunction(state, threadsKey); const getUpperWingCallNodeInfo: Selector = createSelector( _getNonInvertedCallNodeInfo, diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index ad08d14df8..7c6b4a3e4f 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -32,6 +32,7 @@ import type { TabID, IndexIntoSourceTable, MarkerIndex, + IndexIntoFuncTable, FunctionListSectionsOpenState, } from 'firefox-profiler/types'; @@ -254,6 +255,12 @@ export const getSelectedMarker: DangerousSelectorWithArguments< > = (state, threadsKey) => getProfileSpecificState(state).selectedMarkers[threadsKey] ?? null; +export const getSelectedFunction: DangerousSelectorWithArguments< + IndexIntoFuncTable | null, + ThreadsKey +> = (state, threadsKey) => + getProfileSpecificState(state).selectedFunctions[threadsKey] ?? null; + export const getIsBottomBoxOpen: Selector = (state) => { const tab = getSelectedTab(state); return getProfileSpecificState(state).isBottomBoxOpenPerPanel[tab]; diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 1d1716ee54..e101c52597 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4435,7 +4435,6 @@ Object { "_table": Map {}, }, "lastSeenTransformCount": 1, - "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], "selectedLowerWingCallNodePath": Array [], "selectedNetworkMarker": null, diff --git a/src/types/state.ts b/src/types/state.ts index be70f31d94..9ee1968514 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -63,7 +63,6 @@ export type ThreadViewOptions = { readonly selectedInvertedCallNodePath: CallNodePath; readonly expandedNonInvertedCallNodePaths: PathSet; readonly expandedInvertedCallNodePaths: PathSet; - readonly selectedFunctionIndex: IndexIntoFuncTable | null; readonly selectedLowerWingCallNodePath: CallNodePath; readonly expandedLowerWingCallNodePaths: PathSet; readonly selectedUpperWingCallNodePath: CallNodePath; @@ -111,6 +110,10 @@ export type SelectedMarkersPerThread = { [key: ThreadsKey]: MarkerIndex | null; }; +export type SelectedFunctionsPerThread = { + [key: ThreadsKey]: IndexIntoFuncTable | null; +}; + /** * Profile view state */ @@ -404,6 +407,7 @@ export type ProfileSpecificUrlState = { legacyThreadOrder: ThreadIndex[] | null; legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; + selectedFunctions: SelectedFunctionsPerThread; markerTableSort: SingleColumnSortState[]; functionListSort: SingleColumnSortState[]; functionListSectionsOpen: FunctionListSectionsOpenState; From e3af008777846a7d30b127171088629eed9ec4c7 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 7 May 2026 20:06:05 -0400 Subject: [PATCH 27/41] Make it so the replaceState idea actually works. --- src/actions/profile-view.ts | 27 +++++++++++++++------------ src/app-logic/url-handling.ts | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 4cc047bf74..51ace9f520 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -34,6 +34,7 @@ import { getHiddenLocalTracks, getInvertCallstack, getHash, + getUrlState, } from 'firefox-profiler/selectors/url-state'; import { assertExhaustiveCheck, @@ -81,7 +82,7 @@ import { funcHasRecursiveCall, } from '../profile-logic/transforms'; import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db'; -import { withHistoryReplaceStateSync } from 'firefox-profiler/app-logic/url-handling'; +import { replaceHistoryWithUrlState } from 'firefox-profiler/app-logic/url-handling'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { CallNodeInfo } from '../profile-logic/call-node-info'; import type { SingleColumnSortState } from '../components/shared/TreeView'; @@ -165,24 +166,26 @@ export function changeUpperWingSelectedCallNode( /** * Select a function for a given thread in the function list. * - * Uses replaceState rather than pushState so that holding e.g. the down arrow - * key in the function list doesn't get rate-limited by the browser and doesn't - * flood the back/forward history. + * Replaces the current history entry rather than pushing a new one, so that + * holding e.g. the down arrow key in the function list doesn't get rate-limited + * by the browser and doesn't flood the back/forward history. */ export function changeSelectedFunctionIndex( threadsKey: ThreadsKey, selectedFunctionIndex: IndexIntoFuncTable | null, context: SelectionContext = { source: 'auto' } ): ThunkAction { - return (dispatch) => { - withHistoryReplaceStateSync(() => { - dispatch({ - type: 'CHANGE_SELECTED_FUNCTION', - selectedFunctionIndex, - threadsKey, - context, - }); + return (dispatch, getState) => { + dispatch({ + type: 'CHANGE_SELECTED_FUNCTION', + selectedFunctionIndex, + threadsKey, + context, }); + // Update window.history synchronously instead of waiting for the + // UrlManager's componentDidUpdate, which is deferred by React's render + // scheduling and would otherwise pushState a new entry. + replaceHistoryWithUrlState(getUrlState(getState())); }; } diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 6c4dc13b02..09758d41b8 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -122,6 +122,23 @@ export function getIsHistoryReplaceState(): boolean { return _isReplaceState; } +/** + * Synchronously replace the current history entry to match the given UrlState. + * + * Use this from action thunks for high-frequency actions (e.g. arrow-key + * navigation) where deferring the URL update to UrlManager.componentDidUpdate + * would result in unwanted pushState calls and history-flooding. By updating + * window.history synchronously here, the URL already matches the new state by + * the time UrlManager's componentDidUpdate runs, so it becomes a no-op. + */ +export function replaceHistoryWithUrlState(urlState: UrlState): void { + window.history.replaceState( + urlState, + document.title, + urlFromState(urlState) + ); +} + function getPathParts(urlState: UrlState): string[] { const { dataSource } = urlState; switch (dataSource) { From 68ff1b7f0cc4939971dfdfbe7f2375e5540de9b5 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 7 May 2026 18:53:06 -0400 Subject: [PATCH 28/41] Add profiler-edit --only-keep-threads-with-markers-matching . --- src/node-tools/profiler-edit.ts | 34 +++++++++++- src/profile-logic/marker-data.ts | 88 ++++++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index f6ae9032f4..f390175237 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -25,6 +25,7 @@ import { applyWasmSymbolication, type WasmSymbolicationSpec, } from 'firefox-profiler/profile-logic/wasm-symbolication'; +import { getThreadsWithMarkersMatchingSearchFilter } from 'firefox-profiler/profile-logic/marker-data'; import type { Profile } from 'firefox-profiler/types/profile'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import { @@ -52,6 +53,9 @@ import { * * node node-tools-dist/profiler-edit.js --from-hash w1spyw917hg... -o out.json.gz \ * --insert-label-frames known-functions.toml + * + * node node-tools-dist/profiler-edit.js -i big.json.gz -o small.json.gz \ + * --only-keep-threads-with-markers-matching '-async,-sync' */ type ProfileSource = @@ -76,6 +80,7 @@ export interface CliOptions { symbolicateWithServer?: string; symbolicateWasm: WasmSymbolicationCliSpec[]; insertLabelFrames?: string; + onlyKeepThreadsWithMarkersMatching?: string; } function loadWasmSymbolicationSpecs( @@ -265,6 +270,24 @@ export async function run(options: CliOptions) { profile = insertStackLabels(profile, labels); } + if ( + options.onlyKeepThreadsWithMarkersMatching !== undefined && + options.onlyKeepThreadsWithMarkersMatching !== '' + ) { + const before = profile.threads.length; + const matchingThreadIndexes = getThreadsWithMarkersMatchingSearchFilter( + profile, + options.onlyKeepThreadsWithMarkersMatching + ); + const matchingThreads = profile.threads.filter((_thread, threadIndex) => + matchingThreadIndexes.has(threadIndex) + ); + profile = { ...profile, threads: matchingThreads }; + console.log( + `Kept ${profile.threads.length} of ${before} threads with markers matching ${JSON.stringify(options.onlyKeepThreadsWithMarkersMatching)}.` + ); + } + const { profile: compactedProfile } = computeCompactedProfile(profile); const outputFilename = options.output; @@ -324,7 +347,11 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { .argParser(collectWasm) .default([] as WasmSymbolicationCliSpec[]) ) - .option('--insert-label-frames ', 'TOML file with label definitions'); + .option('--insert-label-frames ', 'TOML file with label definitions') + .option( + '--only-keep-threads-with-markers-matching ', + 'Keep only threads with markers matching the given search string' + ); program.parse(processArgv); const opts = program.opts(); @@ -376,6 +403,11 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { opts.insertLabelFrames !== '' ? opts.insertLabelFrames : undefined, + onlyKeepThreadsWithMarkersMatching: + typeof opts.onlyKeepThreadsWithMarkersMatching === 'string' && + opts.onlyKeepThreadsWithMarkersMatching !== '' + ? opts.onlyKeepThreadsWithMarkersMatching + : undefined, }; } diff --git a/src/profile-logic/marker-data.ts b/src/profile-logic/marker-data.ts index a984f89afa..779e95bf73 100644 --- a/src/profile-logic/marker-data.ts +++ b/src/profile-logic/marker-data.ts @@ -1,9 +1,17 @@ /* 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/. */ -import { getEmptyRawMarkerTable } from './data-structures'; -import { getFriendlyThreadName } from './profile-data'; -import { removeFilePath, removeURLs, stringsToRegExp } from '../utils/string'; +import { + getDefaultCategories, + getEmptyRawMarkerTable, +} from './data-structures'; +import { getFriendlyThreadName, getTimeRangeForThread } from './profile-data'; +import { + removeFilePath, + removeURLs, + stringsToRegExp, + splitSearchString, +} from '../utils/string'; import { StringTable } from '../utils/string-table'; import { ensureExists, assertExhaustiveCheck } from '../utils/types'; import { @@ -15,6 +23,7 @@ import { import { getSchemaFromMarker, markerPayloadMatchesSearch, + markerSchemaFrontEndOnly, } from './marker-schema'; import type { @@ -42,6 +51,8 @@ import type { MarkerDisplayLocation, Tid, LogMarkerPayload, + ThreadIndex, + Profile, } from 'firefox-profiler/types'; /** @@ -998,6 +1009,77 @@ export function deriveMarkersFromRawMarkerTable( return { markers, markerIndexToRawMarkerIndexes }; } +/** + * Return the set of threads that have at least one marker matching the given + * marker search string, using the same regular marker search syntax: comma- + * separated terms, optional `field:value` and `-field:value` qualifiers. + * + * This is a somewhat expensive operation because we call deriveMarkersFromRawMarkerTable + * for every thread. + */ +export function getThreadsWithMarkersMatchingSearchFilter( + profile: Profile, + markerSearch: string +): Set { + const searchRegExps = stringsToMarkerRegExps(splitSearchString(markerSearch)); + if (searchRegExps === null) { + return new Set(); + } + + const stringTable = StringTable.withBackingArray(profile.shared.stringArray); + const categoryList = profile.meta.categories ?? getDefaultCategories(); + + const frontEndSchemaNames = new Set( + markerSchemaFrontEndOnly.map((schema) => schema.name) + ); + const schemaList = [ + ...(profile.meta.markerSchema ?? []).filter( + (schema) => !frontEndSchemaNames.has(schema.name) + ), + ...markerSchemaFrontEndOnly, + ]; + const markerSchemaByName: MarkerSchemaByName = Object.create(null); + for (const schema of schemaList) { + markerSchemaByName[schema.name] = schema; + } + + const ipcCorrelations = correlateIPCMarkers(profile.threads, profile.shared); + + const matchingThreads = new Set(); + + for ( + let threadIndex = 0; + threadIndex < profile.threads.length; + threadIndex++ + ) { + const thread = profile.threads[threadIndex]; + const { markers } = deriveMarkersFromRawMarkerTable( + thread.markers, + profile.shared.stringArray, + thread.tid, + getTimeRangeForThread(thread, profile.meta.interval), + ipcCorrelations + ); + if (markers.length === 0) { + continue; + } + const markerIndexes = markers.map((_, i) => i); + const filtered = getSearchFilteredMarkerIndexes( + (i) => markers[i], + markerIndexes, + markerSchemaByName, + searchRegExps, + stringTable, + categoryList + ); + if (filtered.length > 0) { + matchingThreads.add(threadIndex); + } + } + + return matchingThreads; +} + /** * This function filters markers from a thread's raw marker table using the * range specified as parameter. It's not used by the normal marker filtering From 48231c45ca35ebdcc72ba079de93688daa522fd7 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 4 Jun 2026 15:30:04 -0400 Subject: [PATCH 29/41] Add profiler-edit --merge-non-overlapping-threads. --- src/node-tools/profiler-edit.ts | 23 +++- src/profile-logic/merge-compare.ts | 189 +++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 5 deletions(-) diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index f390175237..3461d70c47 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -33,6 +33,7 @@ import { type LabelDescription, resolveAllLabels, } from 'firefox-profiler/utils/label-templates'; +import { mergeNonOverlappingThreadsByName } from 'firefox-profiler/profile-logic/merge-compare'; /** * A CLI tool for editing profiles. @@ -55,10 +56,11 @@ import { * --insert-label-frames known-functions.toml * * node node-tools-dist/profiler-edit.js -i big.json.gz -o small.json.gz \ - * --only-keep-threads-with-markers-matching '-async,-sync' + * --only-keep-threads-with-markers-matching '-async,-sync' \ + * --merge-non-overlapping-threads-by-name */ -type ProfileSource = +export type ProfileSource = | { type: 'FILE'; path: string } | { type: 'URL'; url: string } | { type: 'HASH'; hash: string }; @@ -67,7 +69,7 @@ type ProfileSource = // supplies symbol names, plus (optionally) the URL of the stripped wasm in the // profile to which those names should be applied. If `strippedWasmUrl` is // omitted, the profile must contain exactly one .wasm source, which is used. -interface WasmSymbolicationCliSpec { +export interface WasmSymbolicationCliSpec { // Path to the local unstripped .wasm file (with a "name" custom section). unstrippedWasmPath: string; // URL of the matching stripped wasm as it appears in the profile. @@ -81,9 +83,10 @@ export interface CliOptions { symbolicateWasm: WasmSymbolicationCliSpec[]; insertLabelFrames?: string; onlyKeepThreadsWithMarkersMatching?: string; + mergeNonOverlappingThreadsByName?: boolean; } -function loadWasmSymbolicationSpecs( +export function loadWasmSymbolicationSpecs( cliSpecs: WasmSymbolicationCliSpec[] ): WasmSymbolicationSpec[] { return cliSpecs.map((spec) => { @@ -102,7 +105,7 @@ function loadWasmSymbolicationSpecs( * (mirrors getLabelIndexForFunc in insert-stack-labels.ts), so auto-discovery * sees the same strings the labeler will compare against. */ -function collectFuncNames(profile: Profile): string[] { +export function collectFuncNames(profile: Profile): string[] { const { funcTable, sources, stringArray } = profile.shared; const result: string[] = []; for (let i = 0; i < funcTable.length; i++) { @@ -288,6 +291,10 @@ export async function run(options: CliOptions) { ); } + if (options.mergeNonOverlappingThreadsByName) { + profile = mergeNonOverlappingThreadsByName(profile); + } + const { profile: compactedProfile } = computeCompactedProfile(profile); const outputFilename = options.output; @@ -351,6 +358,10 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { .option( '--only-keep-threads-with-markers-matching ', 'Keep only threads with markers matching the given search string' + ) + .option( + '--merge-non-overlapping-threads-by-name', + 'Merge same-named threads across non-overlapping process runs' ); program.parse(processArgv); @@ -408,6 +419,8 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { opts.onlyKeepThreadsWithMarkersMatching !== '' ? opts.onlyKeepThreadsWithMarkersMatching : undefined, + mergeNonOverlappingThreadsByName: + opts.mergeNonOverlappingThreadsByName === true, }; } diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index 5eeecb1705..21946675e4 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -68,6 +68,8 @@ import type { Tid, RawProfileSharedData, ProfileIndexTranslationMaps, + StartEndRange, + Pid, } from 'firefox-profiler/types'; import { translateTransformStack } from './transforms'; @@ -1492,3 +1494,190 @@ function getThreadMarkersAndScreenshotMarkers( return targetMarkerTable; } + +/** + * First-fit interval coloring: partition `items` (sorted by start time) into + * subgroups such that within each subgroup no two items overlap. + */ +function partitionNonOverlapping( + itemsSortedByStart: T[], + rangeOf: (item: T) => StartEndRange +): T[][] { + const subgroups: { items: T[]; lastEnd: number }[] = []; + for (const item of itemsSortedByStart) { + const range = rangeOf(item); + let placed = false; + for (const sg of subgroups) { + if (sg.lastEnd <= range.start) { + sg.items.push(item); + sg.lastEnd = range.end; + placed = true; + break; + } + } + if (!placed) { + subgroups.push({ items: [item], lastEnd: range.end }); + } + } + return subgroups.map((sg) => sg.items); +} + +/** + * Merges threads from sequential runs of the same logical workload. + * + * Two-stage approach: + * + * 1. Group processes (i.e. all threads sharing a pid) by (processName, + * processType, mainThreadName) and partition each group into matched + * bundles of non-overlapping processes via first-fit interval coloring. + * Each non-singleton bundle represents one logical process whose + * lifetime spans multiple runs. + * + * 2. Within each matched bundle, merge same-named threads across the + * bundled processes. Same-named threads inside a single process are + * not merged (they may overlap), so we again partition by non-overlap + * before merging. + * + * Threads belonging to a singleton process bundle are passed through + * unchanged. + */ +export function mergeNonOverlappingThreadsByName(profile: Profile): Profile { + const interval = profile.meta.interval; + const threads = profile.threads; + + const threadRanges = threads.map((t) => getTimeRangeForThread(t, interval)); + + type ProcessInfo = { + pid: Pid; + threadIndices: number[]; + range: StartEndRange; + processName: string | undefined; + processType: string; + mainThreadName: string; + }; + + const processesByPid = new Map(); + for (let i = 0; i < threads.length; i++) { + const t = threads[i]; + let proc = processesByPid.get(t.pid); + if (proc === undefined) { + proc = { + pid: t.pid, + threadIndices: [], + range: { start: Infinity, end: -Infinity }, + processName: t.processName, + processType: t.processType, + mainThreadName: t.name, + }; + processesByPid.set(t.pid, proc); + } + proc.threadIndices.push(i); + if (t.isMainThread) { + proc.mainThreadName = t.name; + if (t.processName !== undefined) { + proc.processName = t.processName; + } + } + const r = threadRanges[i]; + if (r.start < proc.range.start) { + proc.range.start = r.start; + } + if (r.end > proc.range.end) { + proc.range.end = r.end; + } + } + + const processGroups = new Map(); + for (const proc of processesByPid.values()) { + const key = `${proc.processName ?? ''}\u0000${proc.processType}\u0000${proc.mainThreadName}`; + let g = processGroups.get(key); + if (g === undefined) { + g = []; + processGroups.set(key, g); + } + g.push(proc); + } + + const mergedIndexes = new Set(); + const mergeReplacements = new Map(); + let mergedProcessBundles = 0; + + for (const procs of processGroups.values()) { + if (procs.length <= 1) { + continue; + } + procs.sort((a, b) => a.range.start - b.range.start); + const bundles = partitionNonOverlapping(procs, (p) => p.range); + + for (const bundle of bundles) { + if (bundle.length <= 1) { + continue; + } + mergedProcessBundles++; + + // Group threads in this bundle by name, partition each by non-overlap, + // and merge subgroups of size > 1. + const threadsByName = new Map(); + for (const proc of bundle) { + for (const tIdx of proc.threadIndices) { + const name = threads[tIdx].name; + let arr = threadsByName.get(name); + if (arr === undefined) { + arr = []; + threadsByName.set(name, arr); + } + arr.push(tIdx); + } + } + + for (const tIndices of threadsByName.values()) { + if (tIndices.length <= 1) { + continue; + } + tIndices.sort((a, b) => threadRanges[a].start - threadRanges[b].start); + const tBundles = partitionNonOverlapping( + tIndices, + (i) => threadRanges[i] + ); + for (const tb of tBundles) { + if (tb.length <= 1) { + continue; + } + const sourceThreads = tb.map((i) => threads[i]); + const original = sourceThreads[0]; + const merged = mergeThreads(sourceThreads); + merged.name = original.name; + merged.pid = original.pid; + merged.tid = original.tid; + merged.processType = original.processType; + merged.processName = original.processName; + merged.isMainThread = original.isMainThread; + + mergeReplacements.set(tb[0], merged); + for (let k = 1; k < tb.length; k++) { + mergedIndexes.add(tb[k]); + } + } + } + } + } + + if (mergeReplacements.size === 0) { + return profile; + } + + const newThreads: RawThread[] = []; + for (let i = 0; i < threads.length; i++) { + if (mergedIndexes.has(i)) { + continue; + } + const replacement = mergeReplacements.get(i); + newThreads.push(replacement ?? threads[i]); + } + + console.log( + `Matched ${mergedProcessBundles} non-overlapping process bundles. Merged ${mergedIndexes.size + mergeReplacements.size} threads into ${mergeReplacements.size}, going from ${threads.length} to ${newThreads.length} threads.` + ); + + return { ...profile, threads: newThreads }; +} From 191b0a1e4dfdc62d83bca3064d25ce00599db75c Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 7 May 2026 19:31:40 -0400 Subject: [PATCH 30/41] Preserve CPU delta during thread merging. --- src/profile-logic/merge-compare.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index 21946675e4..7cb504fd47 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -1361,6 +1361,16 @@ function combineSamplesForMerging(threads: RawThread[]): RawSamplesTable { threadId: newThreadId, }; + // If every source thread has threadCPUDelta, carry the per-sample values + // through unchanged. For non-overlapping inputs the resulting deltas remain + // meaningful; for overlapping inputs the values are nonsensical but harmless + // (still numerically valid). + const allHaveThreadCPUDelta = samplesPerThread.every( + (s) => s.threadCPUDelta !== undefined + ); + const newThreadCPUDelta: Array | undefined = + allHaveThreadCPUDelta ? [] : undefined; + while (true) { let earliestNextSampleThreadIndex: number | null = null; let earliestNextSampleTime = Infinity; @@ -1410,11 +1420,21 @@ function combineSamplesForMerging(threads: RawThread[]): RawSamplesTable { ? sourceThreadSamples.threadId[sourceThreadSampleIndex] : threads[sourceThreadIndex].tid ); + if (newThreadCPUDelta !== undefined) { + newThreadCPUDelta.push( + ensureExists(sourceThreadSamples.threadCPUDelta)[ + sourceThreadSampleIndex + ] + ); + } newSamples.length++; nextSampleIndexPerThread[sourceThreadIndex]++; } + if (newThreadCPUDelta !== undefined) { + return { ...newSamples, threadCPUDelta: newThreadCPUDelta }; + } return newSamples; } From e496d7cc6eb11a544ed51db46050db346e80432f Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 7 May 2026 19:45:34 -0400 Subject: [PATCH 31/41] Add a --set-name argument to profiler-edit. --- src/node-tools/profiler-edit.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index 3461d70c47..7917d3af08 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -2,7 +2,12 @@ * 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/. */ import fs from 'fs'; -import { Command, CommanderError, Option } from 'commander'; +import { + Command, + CommanderError, + InvalidArgumentError, + Option, +} from 'commander'; import { parse as parseToml } from 'smol-toml'; import { @@ -84,6 +89,7 @@ export interface CliOptions { insertLabelFrames?: string; onlyKeepThreadsWithMarkersMatching?: string; mergeNonOverlappingThreadsByName?: boolean; + setName?: string; } export function loadWasmSymbolicationSpecs( @@ -295,6 +301,10 @@ export async function run(options: CliOptions) { profile = mergeNonOverlappingThreadsByName(profile); } + if (options.setName !== undefined) { + profile.meta.product = options.setName; + } + const { profile: compactedProfile } = computeCompactedProfile(profile); const outputFilename = options.output; @@ -328,6 +338,15 @@ function collectWasm( return [...previous, { unstrippedWasmPath: value }]; } +function requireNonEmpty(flagName: string): (value: string) => string { + return (value: string) => { + if (value === '') { + throw new InvalidArgumentError(`${flagName} requires a non-empty value`); + } + return value; + }; +} + export function makeOptionsFromArgv(processArgv: string[]): CliOptions { const program = new Command(); program @@ -362,6 +381,11 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { .option( '--merge-non-overlapping-threads-by-name', 'Merge same-named threads across non-overlapping process runs' + ) + .option( + '--set-name ', + 'Override the profile product name', + requireNonEmpty('--set-name') ); program.parse(processArgv); @@ -421,6 +445,7 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { : undefined, mergeNonOverlappingThreadsByName: opts.mergeNonOverlappingThreadsByName === true, + setName: typeof opts.setName === 'string' ? opts.setName : undefined, }; } From 93b9527da1101e1b040e1140224211c00adf4d99 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 1 Jun 2026 17:11:34 -0400 Subject: [PATCH 32/41] Add profiler-edit --canonicalize-js-location. This is a workaround for Firefox and Chrome using different syntax to indicate the location of a JS function. On Windows, the difference is samply's fault: The JIT ETW events already allow specifying the URL and line/col separately from the function name, but samply puts it back into the function name. And Firefox doesn't make use of those ETW events yet. --- src/node-tools/profiler-edit.ts | 80 +++++++++++++++++++++++++++++ src/test/unit/profiler-edit.test.ts | 23 +++++++++ 2 files changed, 103 insertions(+) diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index 7917d3af08..5450a31996 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -39,6 +39,7 @@ import { resolveAllLabels, } from 'firefox-profiler/utils/label-templates'; import { mergeNonOverlappingThreadsByName } from 'firefox-profiler/profile-logic/merge-compare'; +import { StringTable } from 'firefox-profiler/utils/string-table'; /** * A CLI tool for editing profiles. @@ -63,6 +64,9 @@ import { mergeNonOverlappingThreadsByName } from 'firefox-profiler/profile-logic * node node-tools-dist/profiler-edit.js -i big.json.gz -o small.json.gz \ * --only-keep-threads-with-markers-matching '-async,-sync' \ * --merge-non-overlapping-threads-by-name + * + * node node-tools-dist/profiler-edit.js -i in.json -o out.json \ + * --canonicalize-js-location */ export type ProfileSource = @@ -90,6 +94,7 @@ export interface CliOptions { onlyKeepThreadsWithMarkersMatching?: string; mergeNonOverlappingThreadsByName?: boolean; setName?: string; + canonicalizeJsLocation?: boolean; } export function loadWasmSymbolicationSpecs( @@ -126,6 +131,72 @@ export function collectFuncNames(profile: Profile): string[] { return result; } +/** + * Strip ` (file:line:col)` or ` file:line:col` location suffixes from JS func + * names and move the location into the funcTable + sources columns instead. + * Idempotent: re-running on an already-canonicalized profile is a no-op + * because the trailing suffix is gone. + */ +function canonicalizeJsLocations(profile: Profile): Profile { + const { funcTable, sources, stringArray } = profile.shared; + const stringTable = StringTable.withBackingArray(stringArray); + + // Reuse existing source entries that already cover a whole file at the + // standard (1, 1) origin, keyed by the filename's string index. + const filenameToSourceIndex = new Map(); + for (let i = 0; i < sources.length; i++) { + if (sources.startLine[i] === 1 && sources.startColumn[i] === 1) { + const filenameIndex = sources.filename[i]; + if (!filenameToSourceIndex.has(filenameIndex)) { + filenameToSourceIndex.set(filenameIndex, i); + } + } + } + + // The filename may contain colons (URLs), so we rely on greedy matching + // to anchor `:line:col` at the very end of the string. + const parenRegex = /^(.+) \((.+):(\d+):(\d+)\)$/; + const plainRegex = /^(.+) (.+):(\d+):(\d+)$/; + + let canonicalized = 0; + for (let i = 0; i < funcTable.length; i++) { + if (!funcTable.isJS[i]) { + continue; + } + const name = stringArray[funcTable.name[i]]; + const match = parenRegex.exec(name) ?? plainRegex.exec(name); + if (match === null) { + continue; + } + const cleanName = match[1]; + const filename = match[2]; + const line = parseInt(match[3], 10); + const col = parseInt(match[4], 10); + + const filenameIndex = stringTable.indexForString(filename); + let sourceIndex = filenameToSourceIndex.get(filenameIndex); + if (sourceIndex === undefined) { + sourceIndex = sources.length; + sources.id[sourceIndex] = null; + sources.filename[sourceIndex] = filenameIndex; + sources.startLine[sourceIndex] = 1; + sources.startColumn[sourceIndex] = 1; + sources.sourceMapURL[sourceIndex] = null; + sources.length++; + filenameToSourceIndex.set(filenameIndex, sourceIndex); + } + + funcTable.name[i] = stringTable.indexForString(cleanName); + funcTable.source[i] = sourceIndex; + funcTable.lineNumber[i] = line; + funcTable.columnNumber[i] = col; + canonicalized++; + } + + console.log(`Canonicalized location of ${canonicalized} JS function(s).`); + return profile; +} + export type ParsedLabelToml = { labels: LabelDescription[]; autoLabels: AutoLabel[]; @@ -301,6 +372,10 @@ export async function run(options: CliOptions) { profile = mergeNonOverlappingThreadsByName(profile); } + if (options.canonicalizeJsLocation) { + profile = canonicalizeJsLocations(profile); + } + if (options.setName !== undefined) { profile.meta.product = options.setName; } @@ -386,6 +461,10 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { '--set-name ', 'Override the profile product name', requireNonEmpty('--set-name') + ) + .option( + '--canonicalize-js-location', + 'Move "name (file:line:col)" suffixes on JS functions into the funcTable + sources columns' ); program.parse(processArgv); @@ -446,6 +525,7 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { mergeNonOverlappingThreadsByName: opts.mergeNonOverlappingThreadsByName === true, setName: typeof opts.setName === 'string' ? opts.setName : undefined, + canonicalizeJsLocation: opts.canonicalizeJsLocation === true, }; } diff --git a/src/test/unit/profiler-edit.test.ts b/src/test/unit/profiler-edit.test.ts index 20a83c0dad..0c0035445e 100644 --- a/src/test/unit/profiler-edit.test.ts +++ b/src/test/unit/profiler-edit.test.ts @@ -127,6 +127,29 @@ describe('makeOptionsFromArgv', function () { expect(options.insertLabelFrames).toEqual('/path/to/labels.toml'); }); + it('recognizes optional --canonicalize-js-location', function () { + const options = makeOptionsFromArgv([ + ...commonArgs, + '-i', + '/path/to/profile.json', + '-o', + '/path/to/output.json', + '--canonicalize-js-location', + ]); + expect(options.canonicalizeJsLocation).toBe(true); + }); + + it('defaults --canonicalize-js-location to false', function () { + const options = makeOptionsFromArgv([ + ...commonArgs, + '-i', + '/path/to/profile.json', + '-o', + '/path/to/output.json', + ]); + expect(options.canonicalizeJsLocation).toBe(false); + }); + it('throws when no input is provided', function () { expect(() => makeOptionsFromArgv([...commonArgs, '-o', '/path/to/output.json']) From 993f38c0d816b9c7802724b686260ab362d8faa0 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 6 May 2025 15:04:46 -0400 Subject: [PATCH 33/41] Add some speedometer analysis helper scripts --- scripts/build-node-tools.mjs | 24 + scripts/generate-known-functions-toml.mjs | 24 + src/actions/app.ts | 7 + src/actions/receive-profile.ts | 3 +- src/app-logic/url-handling.ts | 11 + src/components/app/AppViewRouter.tsx | 7 + src/components/app/BenchmarkCompareViewer.css | 218 +++++++ src/components/app/BenchmarkCompareViewer.tsx | 499 ++++++++++++++++ src/components/app/CompareHome.css | 10 +- src/components/app/CompareHome.tsx | 45 +- src/components/app/MenuButtons/index.tsx | 1 + src/components/app/ProfileLoader.tsx | 1 + src/components/app/ServiceWorkerManager.tsx | 2 + src/components/app/WindowTitle.tsx | 3 + src/node-tools/analyze-benchmark.ts | 281 +++++++++ src/node-tools/compare-benchmark-stats.ts | 210 +++++++ src/node-tools/extract-benchmark-stats.ts | 39 ++ src/node-tools/profile-insert-labels.ts | 200 +++++++ src/node-tools/symbolicator-cli.ts | 151 +++++ .../benchmark/benchmark-stuff.ts | 564 ++++++++++++++++++ .../benchmark/compare-benchmark-stats.ts | 188 ++++++ .../benchmark/extract-benchmark-stats.ts | 259 ++++++++ .../benchmark/perf-compare-stats.ts | 453 ++++++++++++++ src/reducers/url-state.ts | 3 + src/types/actions.ts | 7 + src/utils/stats.ts | 92 +++ 26 files changed, 3289 insertions(+), 13 deletions(-) create mode 100644 scripts/generate-known-functions-toml.mjs create mode 100644 src/components/app/BenchmarkCompareViewer.css create mode 100644 src/components/app/BenchmarkCompareViewer.tsx create mode 100644 src/node-tools/analyze-benchmark.ts create mode 100644 src/node-tools/compare-benchmark-stats.ts create mode 100644 src/node-tools/extract-benchmark-stats.ts create mode 100644 src/node-tools/profile-insert-labels.ts create mode 100644 src/node-tools/symbolicator-cli.ts create mode 100644 src/profile-logic/benchmark/benchmark-stuff.ts create mode 100644 src/profile-logic/benchmark/compare-benchmark-stats.ts create mode 100644 src/profile-logic/benchmark/extract-benchmark-stats.ts create mode 100644 src/profile-logic/benchmark/perf-compare-stats.ts create mode 100644 src/utils/stats.ts diff --git a/scripts/build-node-tools.mjs b/scripts/build-node-tools.mjs index 72aa260e96..b453f3e2ea 100644 --- a/scripts/build-node-tools.mjs +++ b/scripts/build-node-tools.mjs @@ -10,9 +10,33 @@ const profilerEditConfig = { outfile: 'node-tools-dist/profiler-edit.js', }; +const analyzeBenchmarkConfig = { + ...nodeBaseConfig, + entryPoints: ['src/node-tools/analyze-benchmark.ts'], + outfile: 'node-tools-dist/analyze-benchmark.js', +}; + +const extractBenchmarkStatsConfig = { + ...nodeBaseConfig, + entryPoints: ['src/node-tools/extract-benchmark-stats.ts'], + outfile: 'node-tools-dist/extract-benchmark-stats.js', +}; + +const compareBenchmarkStatsConfig = { + ...nodeBaseConfig, + entryPoints: ['src/node-tools/compare-benchmark-stats.ts'], + outfile: 'node-tools-dist/compare-benchmark-stats.js', +}; + async function build() { await esbuild.build(profilerEditConfig); console.log('✅ profiler-edit build completed'); + await esbuild.build(analyzeBenchmarkConfig); + console.log('✅ analyze-benchmark build completed'); + await esbuild.build(extractBenchmarkStatsConfig); + console.log('✅ extract-benchmark-stats build completed'); + await esbuild.build(compareBenchmarkStatsConfig); + console.log('✅ compare-benchmark-stats build completed'); } build().catch(console.error); diff --git a/scripts/generate-known-functions-toml.mjs b/scripts/generate-known-functions-toml.mjs new file mode 100644 index 0000000000..037394084b --- /dev/null +++ b/scripts/generate-known-functions-toml.mjs @@ -0,0 +1,24 @@ +import { execSync } from 'child_process'; +import { writeFileSync } from 'fs'; + +const jsCode = execSync( + 'node_modules/.bin/esbuild src/node-tools/profile-insert-labels/known-functions.ts --platform=node --format=esm' +).toString(); + +const dataUrl = 'data:text/javascript,' + encodeURIComponent(jsCode); +const { BREAK_OUT_BUCKETS } = await import(dataUrl); + +let toml = ''; +for (const bucket of BREAK_OUT_BUCKETS) { + toml += `[[buckets]]\n`; + toml += `name = ${JSON.stringify(bucket.name)}\n`; + toml += `funcPrefixes = [\n`; + for (const prefix of bucket.funcPrefixes) { + toml += ` ${JSON.stringify(prefix)},\n`; + } + toml += `]\n\n`; +} + +const outPath = 'src/node-tools/profile-insert-labels/known-functions.toml'; +writeFileSync(outPath, toml.trimEnd() + '\n'); +console.log(`Wrote ${outPath}`); diff --git a/src/actions/app.ts b/src/actions/app.ts index 5792312927..b391dc1858 100644 --- a/src/actions/app.ts +++ b/src/actions/app.ts @@ -78,6 +78,13 @@ export function changeProfilesToCompare(profiles: string[]): Action { }; } +export function changeProfilesToCompareBenchmark(profiles: string[]): Action { + return { + type: 'CHANGE_PROFILES_TO_COMPARE_BENCHMARK', + profiles, + }; +} + export function startFetchingProfiles(): Action { return { type: 'START_FETCHING_PROFILES' }; } diff --git a/src/actions/receive-profile.ts b/src/actions/receive-profile.ts index e197bee440..3c7da1219b 100644 --- a/src/actions/receive-profile.ts +++ b/src/actions/receive-profile.ts @@ -1254,7 +1254,7 @@ export function viewProfileFromPostMessage( // Given a profile view URL, extract the raw URL needed to fetch the profile // data. This mirrors the manual pathname splitting done in retrieveProfileForRawUrl, // so we can fetch the profile before calling stateFromLocation. -function getProfileFetchUrl(urlString: string): string { +export function getProfileFetchUrl(urlString: string): string { const pathParts = new URL(urlString).pathname.split('/').filter((d) => d); const dataSource = ensureIsValidDataSource(pathParts[0]); switch (dataSource) { @@ -1459,6 +1459,7 @@ export function retrieveProfileForRawUrl( case 'uploaded-recordings': case 'none': case 'local': + case 'compare-benchmark': // There is no profile to download for these datasources. break; default: diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 02b2907421..c96193983e 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -131,6 +131,8 @@ function getPathParts(urlState: UrlState): string[] { return ['compare']; } return ['compare', urlState.selectedTab]; + case 'compare-benchmark': + return ['compare-benchmark']; case 'uploaded-recordings': return ['uploaded-recordings']; case 'from-browser': @@ -264,6 +266,14 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { return ''; } break; + case 'compare-benchmark': + if (urlState.profilesToCompare === null) { + return ''; + } + return queryString.stringify( + { profiles: urlState.profilesToCompare }, + { arrayFormat: 'bracket' } + ); case 'public': case 'local': case 'from-browser': @@ -449,6 +459,7 @@ export function ensureIsValidDataSource( case 'public': case 'from-url': case 'compare': + case 'compare-benchmark': case 'uploaded-recordings': return coercedDataSource; default: diff --git a/src/components/app/AppViewRouter.tsx b/src/components/app/AppViewRouter.tsx index fc04a205ff..2cc6b1e302 100644 --- a/src/components/app/AppViewRouter.tsx +++ b/src/components/app/AppViewRouter.tsx @@ -9,6 +9,7 @@ import { ProfileViewer } from './ProfileViewer'; import { ZipFileViewer } from './ZipFileViewer'; import { Home } from './Home'; import { CompareHome } from './CompareHome'; +import { BenchmarkCompareViewer } from './BenchmarkCompareViewer'; import { ProfileRootMessage } from './ProfileRootMessage'; import { getView } from 'firefox-profiler/selectors/app'; import { getHasZipFile } from 'firefox-profiler/selectors/zipped-profiles'; @@ -34,6 +35,7 @@ const ERROR_MESSAGES_L10N_ID: { [key: string]: string } = Object.freeze({ public: 'AppViewRouter--error-public', 'from-url': 'AppViewRouter--error-from-url', compare: 'AppViewRouter--error-compare', + 'compare-benchmark': 'AppViewRouter--error-compare', }); type AppViewRouterStateProps = { @@ -61,6 +63,11 @@ class AppViewRouterImpl extends PureComponent { return ; } break; + case 'compare-benchmark': + if (profilesToCompare === null) { + return ; + } + return ; case 'uploaded-recordings': return ; case 'from-browser': diff --git a/src/components/app/BenchmarkCompareViewer.css b/src/components/app/BenchmarkCompareViewer.css new file mode 100644 index 0000000000..463589e874 --- /dev/null +++ b/src/components/app/BenchmarkCompareViewer.css @@ -0,0 +1,218 @@ +.benchmarkCompareViewer { + flex: 1; + box-sizing: border-box; + width: 100%; + min-height: 100%; + padding: 2em 3em; + background: var(--base-background-color); +} + +.benchmarkResults { + font-size: 14px; +} + +.benchmarkTitle { + margin-bottom: 1em; +} + +/* Loading */ + +.benchmarkLoading { + display: flex; + flex-direction: column; + align-items: center; + padding: 4em; + color: var(--grey-60); + gap: 1em; +} + +.benchmarkSpinner { + width: 2em; + height: 2em; + border: 3px solid var(--grey-30); + border-radius: 50%; + border-top-color: var(--blue-50); + animation: benchmarkSpin 0.8s linear infinite; +} + +@keyframes benchmarkSpin { + to { + transform: rotate(360deg); + } +} + +/* Error */ + +.benchmarkError { + padding: 1em; + border: 1px solid var(--red-50, #ff0039); + border-radius: 4px; + background: var(--red-10, #ffe8e8); + color: var(--red-70, #a4000f); +} + +/* Profile URLs */ + +.benchmarkProfileUrls { + display: flex; + flex-direction: column; + margin-bottom: 1.5em; + color: var(--grey-70); + font-size: 0.9em; + gap: 0.25em; + word-break: break-all; +} + +/* Section headings */ + +.benchmarkSectionTitle { + margin: 1.5em 0 0.5em; + font-size: 1.1em; + font-weight: bold; +} + +/* Suite details */ + +.benchmarkSuiteDetails > .benchmarkSectionTitle { + cursor: pointer; + user-select: none; +} + +.benchmarkSuiteDetails > .benchmarkSectionTitle::marker { + font-size: 0.8em; +} + +.benchmarkNoChanges { + padding: 0.5em 0; + color: var(--grey-60); + font-style: italic; +} + +/* Tables */ + +.benchmarkTable { + width: 100%; + margin-bottom: 1em; + border-collapse: collapse; + font-size: 0.9em; + + /* Fixed layout so the numeric column widths are honored exactly. With + * the same numeric widths in both the outer score table and the inner + * subtest tables, and zero right-padding on the expansion row, the + * numeric columns line up vertically across both tables. The first + * column (label / bucket name) flexes to fill the remaining space. */ + table-layout: fixed; +} + +.benchmarkTable th, +.benchmarkTable td { + box-sizing: border-box; + padding: 4px 8px; + border-bottom: 1px solid var(--grey-20); + text-align: left; + white-space: nowrap; +} + +.benchmarkTable th { + position: sticky; + z-index: 1; + top: 0; + background: var(--grey-10); + font-weight: bold; +} + +.benchmarkCell--number { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.benchmarkCell--colFixed { + width: 9rem; +} + +.benchmarkCell--bucketName, +.benchmarkCell--scoreLabel { + overflow: hidden; + text-overflow: ellipsis; +} + +.benchmarkRow--overall td { + border-bottom: 2px solid var(--grey-40); + font-weight: bold; +} + +.benchmarkCell--indented { + padding-left: 1.5em; +} + +/* Expandable subtest rows in the score table */ + +.benchmarkRow--suite-expandable { + cursor: pointer; +} + +.benchmarkRow--suite-expandable:hover { + background: var(--grey-10); +} + +.benchmarkCell--suiteLabel { + position: relative; +} + +.benchmarkDisclosure { + display: inline-block; + width: 1em; + margin-right: 0.25em; + color: var(--grey-60); + font-size: 0.75em; + text-align: center; + user-select: none; +} + +.benchmarkRow--expansion > td { + padding: 0.5em 0 0.5em 2.5em; + background: var(--grey-10); +} + +.benchmarkRow--expansion .benchmarkTable { + margin-bottom: 0; +} + +/* Color coding. + * + * Confidence drives shading (background): HIGH = full, MEDIUM = muted, + * LOW = no shading. Effect size drives boldness: Large = bold, + * Moderate = semibold, Small/Negligible = normal. + */ + +.benchmarkCell--regressed { + color: var(--red-70, #a4000f); +} + +.benchmarkCell--improved { + color: var(--green-80, #006504); +} + +.benchmarkCell--regressed.benchmarkCell--conf-high { + background: var(--red-10, #ffe8e8); +} + +.benchmarkCell--regressed.benchmarkCell--conf-medium { + background: color-mix(in srgb, var(--red-10, #ffe8e8) 40%, transparent); +} + +.benchmarkCell--improved.benchmarkCell--conf-high { + background: var(--green-10, #d3f3d8); +} + +.benchmarkCell--improved.benchmarkCell--conf-medium { + background: color-mix(in srgb, var(--green-10, #d3f3d8) 40%, transparent); +} + +.benchmarkCell--effect-large { + font-weight: bold; +} + +.benchmarkCell--effect-moderate { + font-weight: 600; +} diff --git a/src/components/app/BenchmarkCompareViewer.tsx b/src/components/app/BenchmarkCompareViewer.tsx new file mode 100644 index 0000000000..7a75dc5ae3 --- /dev/null +++ b/src/components/app/BenchmarkCompareViewer.tsx @@ -0,0 +1,499 @@ +/* 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/. */ + +import { Fragment, useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; + +import { AppHeader } from './AppHeader'; +import { getProfilesToCompare } from 'firefox-profiler/selectors/url-state'; +import { fetchProfile } from 'firefox-profiler/utils/profile-fetch'; +import { unserializeProfileOfArbitraryFormat } from 'firefox-profiler/profile-logic/process-profile'; +import { expandUrl } from 'firefox-profiler/utils/shorten-url'; +import { getProfileFetchUrl } from 'firefox-profiler/actions/receive-profile'; +import { extractBenchmarkStatsFromProfile } from 'firefox-profiler/profile-logic/benchmark/extract-benchmark-stats'; +import { + compareBuckets, + compareIterationTotals, + suiteIterationTotals, +} from 'firefox-profiler/profile-logic/benchmark/compare-benchmark-stats'; +import type { + BucketComparison, + ScoreComparison, +} from 'firefox-profiler/profile-logic/benchmark/compare-benchmark-stats'; +import type { + ConfidenceRating, + EffectSize, +} from 'firefox-profiler/profile-logic/benchmark/perf-compare-stats'; +import './BenchmarkCompareViewer.css'; + +type ComparisonData = { + baseUrl: string; + newUrl: string; + overallScore: ScoreComparison; + suiteScores: ScoreComparison[]; + suiteComparisons: Array<{ + suiteName: string; + comparisons: BucketComparison[]; + }>; +}; + +type State = + | { phase: 'loading' } + | { phase: 'error'; error: string } + | { phase: 'done'; data: ComparisonData }; + +const TOP_N = 100; + +async function loadOneProfile(viewerUrl: string) { + let url = viewerUrl; + if ( + url.startsWith('https://perfht.ml/') || + url.startsWith('https://share.firefox.dev/') || + url.startsWith('https://bit.ly/') + ) { + url = await expandUrl(url); + } + const dataUrl = getProfileFetchUrl(url); + const response = await fetchProfile({ + url: dataUrl, + onTemporaryError: () => {}, + }); + if (response.responseType !== 'BYTES') { + throw new Error('Expected a profile, not a zip file.'); + } + return unserializeProfileOfArbitraryFormat(response.bytes, dataUrl); +} + +async function computeComparison( + baseUrl: string, + newUrl: string +): Promise { + const [baseProfile, newProfile] = await Promise.all([ + loadOneProfile(baseUrl), + loadOneProfile(newUrl), + ]); + + const baseStats = extractBenchmarkStatsFromProfile(baseProfile); + const newStats = extractBenchmarkStatsFromProfile(newProfile); + + const iterationCount = baseStats.suites[0]?.iterationCount ?? 1; + + const baseGlobalIter = suiteIterationTotals( + baseStats.globalBuckets, + iterationCount + ); + const newGlobalIter = suiteIterationTotals( + newStats.globalBuckets, + iterationCount + ); + const overallScore = compareIterationTotals( + 'Overall (geomean-normalised)', + baseGlobalIter, + newGlobalIter + ); + + const suiteScores: ScoreComparison[] = []; + for (const baseSuite of baseStats.suites) { + const newSuite = newStats.suites.find( + (s) => s.suiteName === baseSuite.suiteName + ); + const baseIter = suiteIterationTotals( + baseSuite.buckets, + baseSuite.iterationCount + ); + const newIter = newSuite + ? suiteIterationTotals(newSuite.buckets, newSuite.iterationCount) + : new Array(baseSuite.iterationCount).fill(0); + suiteScores.push( + compareIterationTotals(baseSuite.suiteName, baseIter, newIter) + ); + } + suiteScores.sort((a, b) => a.label.localeCompare(b.label)); + + const suiteComparisons = baseStats.suites.flatMap((baseSuite) => { + const newSuite = newStats.suites.find( + (s) => s.suiteName === baseSuite.suiteName + ); + if (!newSuite) return []; + const comparisons = compareBuckets( + baseSuite.buckets, + newSuite.buckets, + baseStats.bucketNames, + newStats.bucketNames, + baseSuite.iterationCount + ); + return [{ suiteName: baseSuite.suiteName, comparisons }]; + }); + suiteComparisons.sort((a, b) => a.suiteName.localeCompare(b.suiteName)); + + return { + baseUrl, + newUrl, + overallScore, + suiteScores, + suiteComparisons, + }; +} + +/** + * Given a relative change of a single subtest's mean, compute the resulting + * relative change in the overall geomean across `numSuites` subtests, assuming + * the other subtests are unchanged. Exact (not a linearization): + * newGeomean / baseGeomean = (newSuiteMean / baseSuiteMean)^(1/N) + */ +function impactOnGeomean(suiteRel: number, numSuites: number): number { + if (!isFinite(suiteRel)) return suiteRel; + return Math.pow(1 + suiteRel, 1 / numSuites) - 1; +} + +function formatChange(rel: number): string { + if (!isFinite(rel)) return rel > 0 ? 'appeared' : 'disappeared'; + const pct = (rel * 100).toFixed(2); + return rel >= 0 ? `+${pct}%` : `${pct}%`; +} + +function changeClass( + relChange: number, + confidence: ConfidenceRating, + effectSize: EffectSize +): string { + if (!isFinite(relChange) || relChange === 0) return ''; + const direction = relChange > 0 ? 'regressed' : 'improved'; + const classes = []; + // Only color the text (and add background shading) when we have at least + // medium confidence. Below that, leave the text in the default color. + if (confidence === 'HIGH') { + classes.push(`benchmarkCell--${direction}`, 'benchmarkCell--conf-high'); + } else if (confidence === 'MEDIUM') { + classes.push(`benchmarkCell--${direction}`, 'benchmarkCell--conf-medium'); + } + if (effectSize === 'Large') classes.push('benchmarkCell--effect-large'); + else if (effectSize === 'Moderate') + classes.push('benchmarkCell--effect-moderate'); + // Small / Negligible: normal weight. + return classes.join(' '); +} + +const SCORE_TABLE_COLUMN_COUNT = 6; + +function ScoreRow({ + row, + isOverall, + numSuites, +}: { + row: ScoreComparison; + isOverall: boolean; + numSuites: number; +}) { + const absDiff = row.newMean - row.baseMean; + const absDiffStr = (absDiff >= 0 ? '+' : '') + absDiff.toFixed(2); + // For the overall row, the score IS the geomean — there's no enclosing + // subtest, so leave the subtest column blank, and the overall column shows + // the actual measured geomean relChange. For a subtest row, the subtest's + // relChange is its own, and we compute its impact on the overall geomean + // assuming only this subtest changed. + const subtestRel = isOverall ? null : row.relChange; + const overallRel = isOverall + ? row.relChange + : impactOnGeomean(row.relChange, numSuites); + return ( + <> + {row.baseMean.toFixed(2)} + {row.newMean.toFixed(2)} + {absDiffStr} + + {subtestRel === null ? '—' : formatChange(subtestRel)} + + + {formatChange(overallRel)} + + + ); +} + +function ScoreTable({ + overallScore, + suiteScores, + suiteComparisonsByName, +}: { + overallScore: ScoreComparison; + suiteScores: ScoreComparison[]; + suiteComparisonsByName: Map; +}) { + const [expanded, setExpanded] = useState>(new Set()); + const numSuites = suiteScores.length; + + const toggle = (label: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(label)) next.delete(label); + else next.add(label); + return next; + }); + }; + + return ( + + + + + + + + + + + + + + + + + {suiteScores.map((row) => { + const isExpanded = expanded.has(row.label); + const comparisons = suiteComparisonsByName.get(row.label); + const expandable = comparisons !== undefined; + return ( + + toggle(row.label) : undefined} + > + + + + {isExpanded && comparisons && ( + + + + )} + + ); + })} + +
Score + Base mean + + New mean + + Δ abs + + Δ% subtest + + Δ% overall +
+ {overallScore.label} +
+ {expandable && ( + + )} + {row.label} +
+ +
+ ); +} + +function BucketTable({ + comparisons, + label, + baseSubtestMean, + numSuites, +}: { + comparisons: BucketComparison[]; + label: string; + /** When provided together with numSuites, two percent columns are shown + * (Δ% overall and Δ% subtest) instead of the bucket-relative Δ%. */ + baseSubtestMean?: number; + numSuites?: number; +}) { + const showSubtestColumns = + baseSubtestMean !== undefined && numSuites !== undefined; + + const significant = comparisons + .filter((c) => c.confidence !== 'LOW' && c.effectSize !== 'Negligible') + .sort( + (a, b) => + Math.abs(b.newMean - b.baseMean) - Math.abs(a.newMean - a.baseMean) + ) + .slice(0, TOP_N); + + if (significant.length === 0) { + return ( +

+ No bucket changes in {label} with at least medium confidence and a + non-negligible effect size. +

+ ); + } + + return ( + + {/* Column widths come from the colgroup so we don't need a thead. The + * headers in the outer score table double as labels for these aligned + * columns. */} + + + + + + + {showSubtestColumns && } + + + {significant.map((c, i) => { + const absDiff = c.newMean - c.baseMean; + const absDiffStr = (absDiff >= 0 ? '+' : '') + absDiff.toFixed(2); + let pctCells; + if (showSubtestColumns) { + const subtestRel = + baseSubtestMean === 0 ? Infinity : absDiff / baseSubtestMean!; + const overallRel = impactOnGeomean(subtestRel, numSuites!); + pctCells = ( + <> + + + + ); + } else { + pctCells = ( + + ); + } + return ( + + + + + + {pctCells} + + ); + })} + +
+ {formatChange(subtestRel)} + + {formatChange(overallRel)} + + {formatChange(c.relChange)} +
+ {c.bucketName} + {c.baseMean.toFixed(2)}{c.newMean.toFixed(2)}{absDiffStr}
+ ); +} + +function ComparisonResults({ data }: { data: ComparisonData }) { + const suiteComparisonsByName = new Map( + data.suiteComparisons.map(({ suiteName, comparisons }) => [ + suiteName, + comparisons, + ]) + ); + + return ( +
+
+ + Base:{' '} + + {data.baseUrl} + + + + New:{' '} + + {data.newUrl} + + +
+ +

Score and subtest totals

+ +
+ ); +} + +export function BenchmarkCompareViewer() { + const profilesToCompare = useSelector(getProfilesToCompare); + const [state, setState] = useState({ phase: 'loading' }); + + useEffect(() => { + if (!profilesToCompare || profilesToCompare.length < 2) { + setState({ phase: 'error', error: 'Two profile URLs are required.' }); + return; + } + setState({ phase: 'loading' }); + const [baseUrl, newUrl] = profilesToCompare; + computeComparison(baseUrl, newUrl) + .then((data) => setState({ phase: 'done', data })) + .catch((err) => + setState({ phase: 'error', error: String(err?.message ?? err) }) + ); + }, [profilesToCompare]); + + return ( +
+ +

Benchmark Comparison

+ + {state.phase === 'loading' && ( +
+
+

Loading profiles and computing statistics…

+
+ )} + + {state.phase === 'error' && ( +
+

+ Error: {state.error} +

+
+ )} + + {state.phase === 'done' && } +
+ ); +} diff --git a/src/components/app/CompareHome.css b/src/components/app/CompareHome.css index 2cf68ab9b2..f2c183ef3d 100644 --- a/src/components/app/CompareHome.css +++ b/src/components/app/CompareHome.css @@ -21,11 +21,17 @@ grid-template-columns: auto 1fr; } -.compareHomeSubmitButton { - font-size: inherit; /* override the photon style to make it nicer with the rest of the form. */ +.compareHomeButtons { + display: flex; + flex-wrap: wrap; + gap: 0.5em; grid-column-start: span 2; } +.compareHomeButtons .photon-button { + font-size: inherit; /* override the photon style to make it nicer with the rest of the form. */ +} + .compareHomeFormLabel { white-space: nowrap; } diff --git a/src/components/app/CompareHome.tsx b/src/components/app/CompareHome.tsx index 71a1923459..a87b659d0a 100644 --- a/src/components/app/CompareHome.tsx +++ b/src/components/app/CompareHome.tsx @@ -6,13 +6,17 @@ import { PureComponent } from 'react'; import { Localized } from '@fluent/react'; import { AppHeader } from './AppHeader'; -import { changeProfilesToCompare } from 'firefox-profiler/actions/app'; +import { + changeProfilesToCompare, + changeProfilesToCompareBenchmark, +} from 'firefox-profiler/actions/app'; import explicitConnect from 'firefox-profiler/utils/connect'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; import './CompareHome.css'; type DispatchProps = { readonly changeProfilesToCompare: typeof changeProfilesToCompare; + readonly changeProfilesToCompareBenchmark: typeof changeProfilesToCompareBenchmark; }; type Props = ConnectedProps<{}, {}, DispatchProps>; @@ -33,8 +37,17 @@ class CompareHomeImpl extends PureComponent { handleFormSubmit = (e: React.FormEvent) => { e.preventDefault(); const { profile1, profile2 } = this.state; - const { changeProfilesToCompare } = this.props; - changeProfilesToCompare([profile1, profile2]); + const { changeProfilesToCompare, changeProfilesToCompareBenchmark } = + this.props; + const submitter = (e.nativeEvent as SubmitEvent).submitter; + if ( + submitter instanceof HTMLButtonElement && + submitter.name === 'benchmark' + ) { + changeProfilesToCompareBenchmark([profile1, profile2]); + } else { + changeProfilesToCompare([profile1, profile2]); + } }; override render() { @@ -86,13 +99,22 @@ class CompareHomeImpl extends PureComponent { onChange={this.handleInputChange} value={profile2} /> - - + + + + +
); @@ -100,6 +122,9 @@ class CompareHomeImpl extends PureComponent { } export const CompareHome = explicitConnect<{}, {}, DispatchProps>({ - mapDispatchToProps: { changeProfilesToCompare }, + mapDispatchToProps: { + changeProfilesToCompare, + changeProfilesToCompareBenchmark, + }, component: CompareHomeImpl, }); diff --git a/src/components/app/MenuButtons/index.tsx b/src/components/app/MenuButtons/index.tsx index 92c75be4e6..78fb17519e 100644 --- a/src/components/app/MenuButtons/index.tsx +++ b/src/components/app/MenuButtons/index.tsx @@ -109,6 +109,7 @@ class MenuButtonsImpl extends React.PureComponent { return isLocalURL(profileUrl) ? 'local' : 'uploaded'; case 'none': case 'uploaded-recordings': + case 'compare-benchmark': throw new Error(`The datasource ${dataSource} shouldn't happen here.`); default: throw assertExhaustiveCheck(dataSource); diff --git a/src/components/app/ProfileLoader.tsx b/src/components/app/ProfileLoader.tsx index 3446b26c7d..f2d6cfeee5 100644 --- a/src/components/app/ProfileLoader.tsx +++ b/src/components/app/ProfileLoader.tsx @@ -78,6 +78,7 @@ class ProfileLoaderImpl extends PureComponent { case 'from-post-message': case 'uploaded-recordings': case 'unpublished': + case 'compare-benchmark': case 'none': // nothing to do /* istanbul ignore next */ diff --git a/src/components/app/ServiceWorkerManager.tsx b/src/components/app/ServiceWorkerManager.tsx index 6802df69be..cb9ed51ffc 100644 --- a/src/components/app/ServiceWorkerManager.tsx +++ b/src/components/app/ServiceWorkerManager.tsx @@ -166,6 +166,7 @@ class ServiceWorkerManagerImpl extends PureComponent { switch (dataSource) { case 'none': case 'uploaded-recordings': + case 'compare-benchmark': return false; case 'from-file': case 'from-browser': @@ -214,6 +215,7 @@ class ServiceWorkerManagerImpl extends PureComponent { switch (dataSource) { case 'none': case 'uploaded-recordings': + case 'compare-benchmark': // These datasources have no profile loaded, we can update it right away. return true; case 'from-file': diff --git a/src/components/app/WindowTitle.tsx b/src/components/app/WindowTitle.tsx index d30dc47c67..9e41f6d7d0 100644 --- a/src/components/app/WindowTitle.tsx +++ b/src/components/app/WindowTitle.tsx @@ -55,6 +55,9 @@ class WindowTitleImpl extends PureComponent { case 'compare': document.title = 'Compare Profiles' + SEPARATOR + PRODUCT; break; + case 'compare-benchmark': + document.title = 'Benchmark Comparison' + SEPARATOR + PRODUCT; + break; case 'public': case 'local': case 'unpublished': diff --git a/src/node-tools/analyze-benchmark.ts b/src/node-tools/analyze-benchmark.ts new file mode 100644 index 0000000000..de8f55c21d --- /dev/null +++ b/src/node-tools/analyze-benchmark.ts @@ -0,0 +1,281 @@ +/** + * Merge two existing profiles, taking the samples from the first profile and + * the markers from the second profile. + * + * This was useful during early 2025 when the Mozilla Performance team was + * doing a lot of Android startup profiling: + * + * - The "samples" profile would be collected using simpleperf and converted + * with samply import. + * - The "markers" profile would be collected using the Gecko profiler. + * + * To use this script, it first needs to be built: + * yarn build-node-tools + * + * Then it can be run from the `node-tools-dist` directory: + * node node-tools-dist/analyze-benchmark.js --input ~/Downloads/munged-profile.json + * + * For example: + * yarn build-node-tools && node node-tools-dist/analyze-benchmark.js --input ~/Downloads/munged-profile.json + * + */ + +import fs from 'fs'; +import minimist from 'minimist'; + +import { unserializeProfileOfArbitraryFormat } from '../profile-logic/process-profile'; +import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants'; + +import type { + IndexIntoFuncTable, + IndexIntoStackTable, + Profile, + RawProfileSharedData, + RawThread, +} from '../types/profile'; +import type { SamplesTableForThisStuff } from 'firefox-profiler/profile-logic/benchmark/benchmark-stuff'; +import { + computeBenchmarkScores, + computeIterationMarkersAndMeasuredSamples, + computeSampleWeightsWithSuiteFactorsApplied, + getBenchmarkInfo, +} from 'firefox-profiler/profile-logic/benchmark/benchmark-stuff'; +import { + correlateIPCMarkers, + deriveMarkersFromRawMarkerTable, +} from 'firefox-profiler/profile-logic/marker-data'; +import { + computeTimeColumnForRawSamplesTable, + getTimeRangeForThread, +} from 'firefox-profiler/profile-logic/profile-data'; +import { StringTable } from 'firefox-profiler/utils/string-table'; +import { compress } from 'firefox-profiler/utils/gz'; + +type ProfileSource = + | { + type: 'HASH'; + hash: string; + } + | { + type: 'FILE'; + file: string; + }; + +interface CliOptions { + profile: ProfileSource; + outputProfilePath: string | undefined; + outputJsonPath: string | undefined; +} + +export function getProfileUrlForHash(hash: string): string { + // See https://cloud.google.com/storage/docs/access-public-data + // The URL is https://storage.googleapis.com//. + // https://.storage.googleapis.com/ seems to also work but + // is not documented nowadays. + + // By convention, "profile-store" is the name of our bucket, and the file path + // is the hash we receive in the URL. + return `https://storage.googleapis.com/${GOOGLE_STORAGE_BUCKET}/${hash}`; +} + +async function fetchProfileWithHash(hash: string): Promise { + const response = await fetch(getProfileUrlForHash(hash)); + const serializedProfile = await response.json(); + return unserializeProfileOfArbitraryFormat(serializedProfile); +} + +async function loadProfileFromFile(path: string): Promise { + const uint8Array = fs.readFileSync(path, null); + return unserializeProfileOfArbitraryFormat(uint8Array.buffer); +} + +async function loadProfile(source: ProfileSource): Promise { + switch (source.type) { + case 'HASH': + return fetchProfileWithHash(source.hash); + case 'FILE': + return loadProfileFromFile(source.file); + default: + return source; + } +} + +function computeJsOnlySampleBuckets( + shared: RawProfileSharedData, + sampleStacks: Array +): { + bucketFuncs: Array; + sampleBuckets: Int32Array; +} { + const { funcTable, stackTable, frameTable } = shared; + const bucketFuncs = new Array(); + const funcIndexToBucketIndex = new Map(); + + const stackIndexToJsOnlyFuncIndex = new Int32Array(stackTable.length); + for (let stackIndex = 0; stackIndex < stackTable.length; stackIndex++) { + const frameIndex = stackTable.frame[stackIndex]; + const funcIndex = frameTable.func[frameIndex]; + if (funcTable.isJS[funcIndex] || funcTable.relevantForJS[funcIndex]) { + stackIndexToJsOnlyFuncIndex[stackIndex] = funcIndex; + } else { + const parentStackIndex = stackTable.prefix[stackIndex]; + if (parentStackIndex !== null) { + stackIndexToJsOnlyFuncIndex[stackIndex] = + stackIndexToJsOnlyFuncIndex[parentStackIndex]; + } else { + stackIndexToJsOnlyFuncIndex[stackIndex] = -1; + } + } + } + + const sampleBuckets = new Int32Array(sampleStacks.length); + for (let sampleIndex = 0; sampleIndex < sampleBuckets.length; sampleIndex++) { + const stackIndex = sampleStacks[sampleIndex]; + if (stackIndex !== null) { + const jsOnlyFuncIndex = stackIndexToJsOnlyFuncIndex[stackIndex]; + let bucketIndex = + jsOnlyFuncIndex !== -1 + ? funcIndexToBucketIndex.get(jsOnlyFuncIndex) + : -1; + if (bucketIndex === undefined) { + bucketIndex = bucketFuncs.length; + bucketFuncs[bucketIndex] = jsOnlyFuncIndex; + funcIndexToBucketIndex.set(jsOnlyFuncIndex, bucketIndex); + } + sampleBuckets[sampleIndex] = bucketIndex; + } else { + sampleBuckets[sampleIndex] = -1; + } + } + + return { bucketFuncs, sampleBuckets }; +} + +export async function run(options: CliOptions) { + const profile: Profile = await loadProfile(options.profile); + const benchmarkInfo = getBenchmarkInfo(profile, 'speedometer'); + const { shared } = profile; + const thread = profile.threads[benchmarkInfo.threadIndex]; + const { markers } = deriveMarkersFromRawMarkerTable( + thread.markers, + shared.stringArray, + thread.tid, + getTimeRangeForThread(thread, profile.meta.interval), + correlateIPCMarkers(profile.threads, shared) + ); + const stringTable = StringTable.withBackingArray(shared.stringArray); + const sampleCount = thread.samples.length; + const { sampleBuckets, bucketFuncs } = computeJsOnlySampleBuckets( + shared, + thread.samples.stack + ); + const profileOverheadBucket = bucketFuncs.findIndex( + (func) => + shared.stringArray[shared.funcTable.name[func]] === 'Profiling overhead' + ); + const bucketsToIgnore = + profileOverheadBucket !== -1 ? [profileOverheadBucket] : []; + const samples: SamplesTableForThisStuff = { + length: sampleCount, + time: new Float64Array(computeTimeColumnForRawSamplesTable(thread.samples)), + weight: thread.samples.weight + ? new Float64Array(thread.samples.weight) + : new Float64Array(sampleCount).fill(1), + bucketIndex: sampleBuckets, + bucketCount: bucketFuncs.length, + }; + const iterationMarkersAndMeasuredSamples = + computeIterationMarkersAndMeasuredSamples( + benchmarkInfo, + markers, + samples, + stringTable, + bucketsToIgnore + ); + const benchmarkScores = computeBenchmarkScores( + iterationMarkersAndMeasuredSamples + ); + const sampleWeightsWithSuiteFactorsApplied = + computeSampleWeightsWithSuiteFactorsApplied( + iterationMarkersAndMeasuredSamples, + benchmarkScores.factorPerSuite + ); + console.log(benchmarkScores); + + const bucketNames = bucketFuncs.map( + (funcIndex) => shared.stringArray[shared.funcTable.name[funcIndex]] + ); + + const profileBenchmarkInfo = { + bucketFuncs, + bucketNames, + // bucketKeys, (type: label | js, when js include path and start line/col) + benchmarkScores, + }; + if (options.outputJsonPath !== undefined) { + fs.writeFileSync( + options.outputJsonPath, + JSON.stringify(profileBenchmarkInfo) + ); + } + + const adjustedWeightThread: RawThread = { + ...thread, + samples: { + ...thread.samples, + weight: [...sampleWeightsWithSuiteFactorsApplied], + // weightType: 'tracing-ms', + }, + }; + const adjustedWeightThreads = profile.threads.slice(); + adjustedWeightThreads[benchmarkInfo.threadIndex] = adjustedWeightThread; + const adjustedWeightProfile: Profile = { + ...profile, + threads: adjustedWeightThreads, + }; + + if (options.outputProfilePath !== undefined) { + if (options.outputProfilePath.endsWith('.gz')) { + fs.writeFileSync( + options.outputProfilePath, + await compress(JSON.stringify(adjustedWeightProfile)) + ); + } + } +} + +export function makeOptionsFromArgv(processArgv: string[]): CliOptions { + const argv = minimist(processArgv.slice(2)); + + const hasSamplesHash = 'hash' in argv && typeof argv.hash === 'string'; + const hasSamplesFile = 'input' in argv && typeof argv.input === 'string'; + + if (!hasSamplesHash && !hasSamplesFile) { + throw new Error('Either --input or --hash must be supplied'); + } + if (hasSamplesHash && hasSamplesFile) { + throw new Error('Only one of --input or --hash can be supplied'); + } + + const profile: ProfileSource = hasSamplesHash + ? { type: 'HASH', hash: argv.hash } + : { type: 'FILE', file: argv.input }; + + return { + profile, + outputProfilePath: argv['output-profile'], + outputJsonPath: argv['output-json'], + }; +} + +if (!module.parent) { + try { + const options = makeOptionsFromArgv(process.argv); + run(options).catch((err) => { + throw err; + }); + } catch (e) { + console.error(e); + process.exit(1); + } +} diff --git a/src/node-tools/compare-benchmark-stats.ts b/src/node-tools/compare-benchmark-stats.ts new file mode 100644 index 0000000000..61a76e95b0 --- /dev/null +++ b/src/node-tools/compare-benchmark-stats.ts @@ -0,0 +1,210 @@ +/** + * CLI entry point for compare-benchmark-stats. + * See compare-benchmark-stats.ts for the browser-safe library logic. + */ + +import fs from 'fs'; +import minimist from 'minimist'; +import type { ProfileBenchmarkStats } from 'firefox-profiler/profile-logic/benchmark/extract-benchmark-stats'; +import { + compareBuckets, + compareIterationTotals, + suiteIterationTotals, +} from 'firefox-profiler/profile-logic/benchmark/compare-benchmark-stats'; +import type { + BucketComparison, + ScoreComparison, +} from 'firefox-profiler/profile-logic/benchmark/compare-benchmark-stats'; + +// --------------------------------------------------------------------------- +// Formatting +// --------------------------------------------------------------------------- + +function formatChange(rel: number): string { + if (!isFinite(rel)) return rel > 0 ? 'appeared' : 'disappeared'; + const pct = (rel * 100).toFixed(2); + return rel >= 0 ? `+${pct}%` : `${pct}%`; +} + +function printScoreAndSubtests( + overall: ScoreComparison, + suites: ScoreComparison[] +) { + const COL = 45; + const overallAbsDiff = overall.newMean - overall.baseMean; + const overallAbsStr = + (overallAbsDiff >= 0 ? '+' : '') + overallAbsDiff.toFixed(2); + console.log( + `${'Score'.padEnd(COL)} ${'base mean'.padStart(10)} ${'new mean'.padStart(10)} ${'Δ abs'.padStart(10)} ${'Δ%'.padStart(10)} ${'effect'.padStart(10)} ${'confidence'.padStart(12)}` + ); + console.log('-'.repeat(COL + 64)); + console.log( + `${'Overall (geomean-normalised)'.padEnd(COL)} ${overall.baseMean.toFixed(2).padStart(10)} ${overall.newMean.toFixed(2).padStart(10)} ${overallAbsStr.padStart(10)} ${formatChange(overall.relChange).padStart(10)} ${overall.effectSize.padStart(10)} ${overall.confidence.padStart(12)}` + ); + console.log(''); + for (const s of suites) { + const absDiff = s.newMean - s.baseMean; + const absDiffStr = (absDiff >= 0 ? '+' : '') + absDiff.toFixed(2); + const label = + s.label.length > COL - 2 ? s.label.slice(0, COL - 5) + '...' : s.label; + console.log( + `${' ' + label.padEnd(COL - 2)} ${s.baseMean.toFixed(2).padStart(10)} ${s.newMean.toFixed(2).padStart(10)} ${absDiffStr.padStart(10)} ${formatChange(s.relChange).padStart(10)} ${s.effectSize.padStart(10)} ${s.confidence.padStart(12)}` + ); + } +} + +function printBucketResults( + label: string, + comparisons: BucketComparison[], + topN: number | null +) { + const significant = comparisons + .filter((c) => c.confidence !== 'LOW') + .sort( + (a, b) => + Math.abs(b.newMean - b.baseMean) - Math.abs(a.newMean - a.baseMean) + ); + + if (significant.length === 0) { + console.log(`\n[${label}] No significant bucket changes.`); + return; + } + + const shown = topN !== null ? significant.slice(0, topN) : significant; + console.log( + `\n[${label}] ${significant.length} significant buckets` + + (topN !== null && significant.length > topN + ? `, showing top ${topN} by absolute impact:` + : ':') + ); + console.log( + `${'Bucket name'.padEnd(60)} ${'base mean'.padStart(10)} ${'new mean'.padStart(10)} ${'Δ abs'.padStart(10)} ${'Δ%'.padStart(10)} ${'effect'.padStart(10)} ${'confidence'.padStart(12)}` + ); + console.log('-'.repeat(125)); + for (const c of shown) { + const name = + c.bucketName.length > 59 + ? c.bucketName.slice(0, 56) + '...' + : c.bucketName; + const absDiff = c.newMean - c.baseMean; + const absDiffStr = (absDiff >= 0 ? '+' : '') + absDiff.toFixed(2); + console.log( + `${name.padEnd(60)} ${c.baseMean.toFixed(2).padStart(10)} ${c.newMean.toFixed(2).padStart(10)} ${absDiffStr.padStart(10)} ${formatChange(c.relChange).padStart(10)} ${c.effectSize.padStart(10)} ${c.confidence.padStart(12)}` + ); + } +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main() { + const argv = minimist(process.argv.slice(2)); + + if (!argv.base || !argv.new) { + console.error( + 'Usage: compare-benchmark-stats --base --new \n' + + ' [--suite ] [--global] [--top 100] [--all] [--no-appeared]' + ); + process.exit(1); + } + + const topN: number | null = argv.all ? null : (argv.top ?? 100); + const suiteFilter: string | undefined = argv.suite; + const showGlobal: boolean = !suiteFilter || argv.global; + // minimist turns --no-appeared into { appeared: false } + const excludeAppearedDisappeared: boolean = argv.appeared === false; + + const base: ProfileBenchmarkStats = JSON.parse( + fs.readFileSync(argv.base, 'utf8') + ); + const newStats: ProfileBenchmarkStats = JSON.parse( + fs.readFileSync(argv.new, 'utf8') + ); + + const iterationCount = base.suites[0]?.iterationCount ?? 1; + + if (showGlobal) { + const baseGlobalIter = suiteIterationTotals( + base.globalBuckets, + iterationCount + ); + const newGlobalIter = suiteIterationTotals( + newStats.globalBuckets, + iterationCount + ); + const overallScore = compareIterationTotals( + 'Overall', + baseGlobalIter, + newGlobalIter + ); + + const suiteScores: ScoreComparison[] = []; + for (const baseSuite of base.suites) { + const newSuite = newStats.suites.find( + (s) => s.suiteName === baseSuite.suiteName + ); + const baseIter = suiteIterationTotals( + baseSuite.buckets, + baseSuite.iterationCount + ); + const newIter = newSuite + ? suiteIterationTotals(newSuite.buckets, newSuite.iterationCount) + : new Array(baseSuite.iterationCount).fill(0); + suiteScores.push( + compareIterationTotals(baseSuite.suiteName, baseIter, newIter) + ); + } + + console.log('\n--- Score and subtest totals ---\n'); + printScoreAndSubtests(overallScore, suiteScores); + + const globalComparisons = compareBuckets( + base.globalBuckets, + newStats.globalBuckets, + base.bucketNames, + newStats.bucketNames, + iterationCount, + excludeAppearedDisappeared + ); + printBucketResults('Global (geomean-normalised)', globalComparisons, topN); + } + + if (suiteFilter !== undefined) { + const matchingSuites = base.suites.filter((s) => + s.suiteName.toLowerCase().includes(suiteFilter.toLowerCase()) + ); + + if (matchingSuites.length === 0) { + console.error(`No suites matching "${suiteFilter}". Available suites:`); + for (const s of base.suites) console.error(` ${s.suiteName}`); + process.exit(1); + } + + for (const baseSuite of matchingSuites) { + const newSuite = newStats.suites.find( + (s) => s.suiteName === baseSuite.suiteName + ); + if (newSuite === undefined) { + console.warn( + `Suite "${baseSuite.suiteName}" not found in new stats, skipping.` + ); + continue; + } + const comparisons = compareBuckets( + baseSuite.buckets, + newSuite.buckets, + base.bucketNames, + newStats.bucketNames, + baseSuite.iterationCount, + excludeAppearedDisappeared + ); + printBucketResults(baseSuite.suiteName, comparisons, topN); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/node-tools/extract-benchmark-stats.ts b/src/node-tools/extract-benchmark-stats.ts new file mode 100644 index 0000000000..c680084775 --- /dev/null +++ b/src/node-tools/extract-benchmark-stats.ts @@ -0,0 +1,39 @@ +/** + * CLI entry point for extract-benchmark-stats. + * See extract-benchmark-stats.ts for the browser-safe library logic. + */ + +import fs from 'fs'; +import minimist from 'minimist'; +import { unserializeProfileOfArbitraryFormat } from '../profile-logic/process-profile'; +import { extractBenchmarkStatsFromProfile } from 'firefox-profiler/profile-logic/benchmark/extract-benchmark-stats'; +import type { BenchmarkHarness } from 'firefox-profiler/profile-logic/benchmark/benchmark-stuff'; + +async function main() { + const argv = minimist(process.argv.slice(2)); + + if (!argv.input || !argv.output) { + console.error( + 'Usage: extract-benchmark-stats --input --output [--harness speedometer|jetstream]' + ); + process.exit(1); + } + + const harness: BenchmarkHarness = argv.harness ?? 'speedometer'; + const uint8Array = fs.readFileSync(argv.input, null); + const profile = await unserializeProfileOfArbitraryFormat(uint8Array.buffer); + const stats = extractBenchmarkStatsFromProfile(profile, harness); + + fs.writeFileSync(argv.output, JSON.stringify(stats)); + console.log( + `Wrote ${stats.suites.length} suites, ` + + `${stats.globalBuckets.length} global buckets, ` + + `${stats.suites.reduce((s, su) => s + su.buckets.length, 0)} suite-bucket pairs ` + + `to ${argv.output}` + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/node-tools/profile-insert-labels.ts b/src/node-tools/profile-insert-labels.ts new file mode 100644 index 0000000000..9bde279b03 --- /dev/null +++ b/src/node-tools/profile-insert-labels.ts @@ -0,0 +1,200 @@ +/** + * Merge two existing profiles, taking the samples from the first profile and + * the markers from the second profile. + * + * This was useful during early 2025 when the Mozilla Performance team was + * doing a lot of Android startup profiling: + * + * - The "samples" profile would be collected using simpleperf and converted + * with samply import. + * - The "markers" profile would be collected using the Gecko profiler. + * + * To use this script, it first needs to be built: + * yarn build-node-tools + * + * Then it can be run from the `node-tools-dist` directory: + * node node-tools-dist/profile-insert-labels.js --labels src/node-tools/profile-insert-labels/known-functions.toml --hash w1spyw917hgfw56x5jzfs27q89dkphhqqzw2nag --output-file ~/Downloads/labeled-profile.json.gz + * + * For example: + * yarn build-node-tools && node node-tools-dist/profile-insert-labels.js --labels src/node-tools/profile-insert-labels/known-functions.toml --hash w1spyw917hgfw56x5jzfs27q89dkphhqqzw2nag --output-file ~/Downloads/labeled-profile.json.gz + * + */ + +import fs from 'fs'; +import minimist from 'minimist'; +import { parse as parseToml } from 'smol-toml'; + +import { unserializeProfileOfArbitraryFormat } from 'firefox-profiler/profile-logic/process-profile'; +import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants'; + +import type { Profile } from 'firefox-profiler/types/profile'; +import { compress } from 'firefox-profiler/utils/gz'; +import { insertStackLabels } from 'firefox-profiler/profile-logic/insert-stack-labels'; + +type ProfileSource = + | { + type: 'HASH'; + hash: string; + } + | { + type: 'FILE'; + file: string; + }; + +interface CliOptions { + profile: ProfileSource; + labelsFile: string; + outputFile: string; +} + +export function getProfileUrlForHash(hash: string): string { + // See https://cloud.google.com/storage/docs/access-public-data + // The URL is https://storage.googleapis.com//. + // https://.storage.googleapis.com/ seems to also work but + // is not documented nowadays. + + // By convention, "profile-store" is the name of our bucket, and the file path + // is the hash we receive in the URL. + return `https://storage.googleapis.com/${GOOGLE_STORAGE_BUCKET}/${hash}`; +} + +async function fetchProfileWithHash(hash: string): Promise { + const response = await fetch(getProfileUrlForHash(hash)); + const serializedProfile = await response.json(); + return unserializeProfileOfArbitraryFormat(serializedProfile); +} + +async function loadProfileFromFile(path: string): Promise { + const uint8Array = fs.readFileSync(path, null); + return unserializeProfileOfArbitraryFormat(uint8Array.buffer); +} + +async function loadProfile(source: ProfileSource): Promise { + switch (source.type) { + case 'HASH': + return fetchProfileWithHash(source.hash); + case 'FILE': + return loadProfileFromFile(source.file); + default: + return source; + } +} + +interface Template { + name: string; + patterns: string[]; +} + +interface BucketConfig { + name: string; + funcPrefixes?: string[]; + apply?: Array<{ template: string; [key: string]: string }>; +} + +export function applyModifier(value: string, modifier: string | undefined): string { + switch (modifier) { + case 'pascal': + return value.charAt(0).toUpperCase() + value.slice(1); + case 'snake': + return value + .replace(/([a-z])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toLowerCase(); + case undefined: + return value; + default: + throw new Error(`Unknown template modifier: ${modifier}`); + } +} + +export function expandPattern(pattern: string, vars: Record): string { + return pattern.replace( + /\{(\w+)(?::(\w+))?\}/g, + (_match, name: string, modifier: string | undefined) => { + if (!(name in vars)) { + throw new Error(`Template variable "${name}" not provided`); + } + return applyModifier(vars[name], modifier); + } + ); +} + +export function resolveTemplates( + bucketConfigs: BucketConfig[], + templates: Template[] +): Array<{ name: string; funcPrefixes: string[] }> { + const templateMap = new Map(templates.map((t) => [t.name, t])); + return bucketConfigs.map((bucket) => { + const funcPrefixes = [...(bucket.funcPrefixes ?? [])]; + for (const { template: templateName, ...vars } of bucket.apply ?? []) { + const template = templateMap.get(templateName); + if (!template) { + throw new Error(`Unknown template: "${templateName}"`); + } + for (const pattern of template.patterns) { + funcPrefixes.push(expandPattern(pattern, vars)); + } + } + return { name: bucket.name, funcPrefixes }; + }); +} + +export async function run(options: CliOptions) { + const tomlText = fs.readFileSync(options.labelsFile, 'utf8'); + const { buckets: bucketConfigs, templates = [] } = parseToml(tomlText) as unknown as { + buckets: BucketConfig[]; + templates?: Template[]; + }; + const buckets = resolveTemplates(bucketConfigs, templates); + const oldProfile: Profile = await loadProfile(options.profile); + const profile: Profile = insertStackLabels(oldProfile, buckets); + + if (options.outputFile.endsWith('.gz')) { + fs.writeFileSync( + options.outputFile, + await compress(JSON.stringify(profile)) + ); + } else { + fs.writeFileSync(options.outputFile, JSON.stringify(profile)); + } +} + +export function makeOptionsFromArgv(processArgv: string[]): CliOptions { + const argv = minimist(processArgv.slice(2)); + + const hasSamplesHash = 'hash' in argv && typeof argv.hash === 'string'; + const hasSamplesFile = 'input' in argv && typeof argv.input === 'string'; + + if (!hasSamplesHash && !hasSamplesFile) { + throw new Error('Either --input or --hash must be supplied'); + } + if (hasSamplesHash && hasSamplesFile) { + throw new Error('Only one of --input or --hash can be supplied'); + } + + const profile: ProfileSource = hasSamplesHash + ? { type: 'HASH', hash: argv.hash } + : { type: 'FILE', file: argv.input }; + + if (!('labels' in argv) || typeof argv.labels !== 'string') { + throw new Error('--labels must be supplied'); + } + + return { + profile, + labelsFile: argv.labels, + outputFile: argv['output-file'], + }; +} + +if (!module.parent) { + try { + const options = makeOptionsFromArgv(process.argv); + run(options).catch((err) => { + throw err; + }); + } catch (e) { + console.error(e); + process.exit(1); + } +} diff --git a/src/node-tools/symbolicator-cli.ts b/src/node-tools/symbolicator-cli.ts new file mode 100644 index 0000000000..9817cff688 --- /dev/null +++ b/src/node-tools/symbolicator-cli.ts @@ -0,0 +1,151 @@ +/* + * This implements a simple CLI to symbolicate profiles captured by the profiler + * or by samply. + * + * To use it it first needs to be built: + * yarn build-symbolicator-cli + * + * Then it can be run from the `dist` directory: + * node dist/symbolicator-cli.js --input --output --server + * + * For example: + * node dist/symbolicator-cli.js --input samply-profile.json --output profile-symbolicated.json --server http://localhost:3000 + * + */ + +import fs from 'fs'; +import minimist from 'minimist'; + +import { unserializeProfileOfArbitraryFormat } from 'firefox-profiler/profile-logic/process-profile'; +import { SymbolStore } from 'firefox-profiler/profile-logic/symbol-store'; +import { + symbolicateProfile, + applySymbolicationSteps, +} from 'firefox-profiler/profile-logic/symbolication'; +import type { SymbolicationStepInfo } from 'firefox-profiler/profile-logic/symbolication'; +import * as MozillaSymbolicationAPI from 'firefox-profiler/profile-logic/mozilla-symbolication-api'; + +export interface CliOptions { + input: string; + output: string; + server: string; +} + +export async function run(options: CliOptions) { + console.log(`Loading profile from ${options.input}`); + + // Read the raw bytes from the file. It might be a JSON file, but it could also + // be a binary file, e.g. a .json.gz file, or any of the binary formats supported + // by our importers. + const bytes = fs.readFileSync(options.input, null); + + // Load the profile. + const profile = await unserializeProfileOfArbitraryFormat(bytes); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + + /** + * SymbolStore implementation which just forwards everything to the symbol server in + * MozillaSymbolicationAPI format. No support for getting symbols from 'the browser' as + * there is no browser in this context. + */ + const symbolStore = new SymbolStore({ + requestSymbolsFromServer: async (requests) => { + for (const { lib } of requests) { + console.log(` Loading symbols for ${lib.debugName}`); + } + try { + return await MozillaSymbolicationAPI.requestSymbols( + 'symbol server', + requests, + async (path, json) => { + const response = await fetch(options.server + path, { + body: json, + method: 'POST', + }); + return response.json(); + } + ); + } catch (e) { + throw new Error( + `There was a problem with the symbolication API request to the symbol server: ${e.message}` + ); + } + }, + + requestSymbolsFromBrowser: async () => { + return []; + }, + + requestSymbolsViaSymbolTableFromBrowser: async () => { + throw new Error('Not supported in this context'); + }, + }); + + console.log('Symbolicating...'); + + const symbolicationSteps: SymbolicationStepInfo[] = []; + await symbolicateProfile( + profile, + symbolStore, + (symbolicationStepInfo: SymbolicationStepInfo) => { + symbolicationSteps.push(symbolicationStepInfo); + } + ); + + console.log('Applying collected symbolication steps...'); + + const { shared, threads } = applySymbolicationSteps( + profile.threads, + profile.shared, + symbolicationSteps + ); + profile.shared = shared; + profile.threads = threads; + profile.meta.symbolicated = true; + + console.log(`Saving profile to ${options.output}`); + fs.writeFileSync(options.output, JSON.stringify(profile)); + console.log('Finished.'); +} + +export function makeOptionsFromArgv(processArgv: string[]): CliOptions { + const argv = minimist(processArgv.slice(2)); + + if (!('input' in argv && typeof argv.input === 'string')) { + throw new Error( + 'Argument --input must be supplied with the path to the input profile' + ); + } + + if (!('output' in argv && typeof argv.output === 'string')) { + throw new Error( + 'Argument --output must be supplied with the path to the output profile' + ); + } + + if (!('server' in argv && typeof argv.server === 'string')) { + throw new Error( + 'Argument --server must be supplied with the URI of the symbol server endpoint' + ); + } + + return { + input: argv.input, + output: argv.output, + server: argv.server, + }; +} + +if (!module.parent) { + try { + const options = makeOptionsFromArgv(process.argv); + run(options).catch((err) => { + throw err; + }); + } catch (e) { + console.error(e); + process.exit(1); + } +} diff --git a/src/profile-logic/benchmark/benchmark-stuff.ts b/src/profile-logic/benchmark/benchmark-stuff.ts new file mode 100644 index 0000000000..21839a8e2a --- /dev/null +++ b/src/profile-logic/benchmark/benchmark-stuff.ts @@ -0,0 +1,564 @@ +/* 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/. */ + +import type { + Marker, + Profile, + RawProfileSharedData, + RawThread, + StartEndRange, +} from 'firefox-profiler/types'; +import type { StringTable } from 'firefox-profiler/utils/string-table'; +import { ensureExists } from 'firefox-profiler/utils/types'; +// import { computeBucketStats } from 'firefox-profiler/utils/stats'; + +export type BenchmarkHarness = 'speedometer' | 'jetstream'; + +type BenchmarkInfo = { + suiteNameIfSingleSuite: string | null; + threadIndex: number; + getMeasuredTimeRanges: ( + markers: any, + stringTable: any + ) => StartEndRange[] | null; + getMarkersPerSuite: (markers: any, stringTable: any) => Map; +}; + +export function getBenchmarkInfo( + profile: Profile, + benchmarkHarness: BenchmarkHarness +): BenchmarkInfo { + if (benchmarkHarness === 'speedometer') { + return getSpeedometerBenchmarkInfo(profile); + } + if (benchmarkHarness === 'jetstream') { + return getJetStreamBenchmarkInfo(profile); + } + throw new Error(`Unknown benchmarkHarness: ${benchmarkHarness}`); +} + +export function getSpeedometerBenchmarkInfo(profile: Profile): BenchmarkInfo { + const { threads, shared } = profile; + for (let threadIndex = 0; threadIndex < threads.length; threadIndex++) { + const thread = threads[threadIndex]; + const suiteNames = speedometerSuiteNamesOnThread(thread, shared); + if (suiteNames.length !== 0) { + const suiteNameIfSingleSuite = + suiteNames.length === 1 ? suiteNames[0] : null; + return { + suiteNameIfSingleSuite, + threadIndex, + getMarkersPerSuite: getSpeedometerMarkersPerSuite, + getMeasuredTimeRanges: getSpeedometerMeasuredTimeRanges, + }; + } + } + throw new Error( + "Could not find a thread with markers that start with 'suite-'" + ); +} + +export function getSpeedometerMarkersPerSuite( + markers: Marker[], + stringTable: StringTable +): Map { + const markersPerSuiteName: Map = new Map(); + for (const m of markers) { + if ( + (m.name === 'UserTiming' || m.name === 'SimpleMarker') && + m.end !== null && + m.data && + 'name' in m.data && + m.data.name + ) { + const nameOrNameIndex = m.data.name; + let markerName = ''; + if (typeof nameOrNameIndex === 'number') { + markerName = stringTable.getString(nameOrNameIndex); + } + if (markerName.startsWith('suite-') && !markerName.endsWith('-prepare')) { + const suiteName = markerName.slice('suite-'.length); + let markersForThisSuite = markersPerSuiteName.get(suiteName); + if (markersForThisSuite === undefined) { + markersForThisSuite = []; + markersPerSuiteName.set(suiteName, markersForThisSuite); + } + markersForThisSuite.push(m); + } + } + } + return markersPerSuiteName; +} + +export function getSpeedometerMeasuredTimeRanges( + markers: Marker[], + stringTable: StringTable +): StartEndRange[] | null { + const ranges = []; + for (const m of markers) { + if ( + (m.name === 'UserTiming' || m.name === 'SimpleMarker') && + m.end !== null && + m.data && + 'name' in m.data && + m.data.name + ) { + const nameOrNameIndex = m.data.name; + let markerName = ''; + if (typeof nameOrNameIndex === 'number') { + markerName = stringTable.getString(nameOrNameIndex); + } + if (markerName.includes('-sync') || markerName.includes('-async')) { + ranges.push({ start: m.start, end: m.end }); + } + } + } + return ranges; +} + +export function getJetStreamBenchmarkInfo(profile: Profile): BenchmarkInfo { + const { threads, shared } = profile; + for (let threadIndex = 0; threadIndex < threads.length; threadIndex++) { + const thread = threads[threadIndex]; + const suiteNames = jetstreamSuiteNamesOnThread(thread, shared); + if (suiteNames.length !== 0) { + const suiteNameIfSingleSuite = + suiteNames.length === 1 ? suiteNames[0] : null; + return { + suiteNameIfSingleSuite, + threadIndex, + getMarkersPerSuite: getJetstreamMarkersPerSuite, + getMeasuredTimeRanges: () => null, + }; + } + } + throw new Error( + "Could not find a thread with markers that include '-iteration-'" + ); +} + +export function jetstreamSuiteNamesOnThread( + rawThread: RawThread, + shared: RawProfileSharedData +): string[] { + const names: Set = new Set(); + const { markers } = rawThread; + const { stringArray } = shared; + let userTimingMarkerNameStringIndex = stringArray.indexOf('UserTiming'); + const simpleMarkerNameStringIndex = stringArray.indexOf('SimpleMarker'); + if ( + userTimingMarkerNameStringIndex === -1 || + (simpleMarkerNameStringIndex !== -1 && + simpleMarkerNameStringIndex < userTimingMarkerNameStringIndex) + ) { + userTimingMarkerNameStringIndex = simpleMarkerNameStringIndex; + } + for (let i = 0; i < markers.length; i++) { + if (markers.phase[i] === 0) { + continue; + } + if (markers.name[i] !== userTimingMarkerNameStringIndex) { + continue; + } + const data = markers.data[i]; + if (!data || !('name' in data) || !data.name) { + continue; + } + + const markerName = + typeof data.name === 'string' ? data.name : stringArray[data.name]; + const match = markerName.match(/^(.*?)-iteration-[0-9]+$/); + if (match !== null) { + names.add(match[1]); + } + } + return [...names]; +} +export function getJetstreamMarkersPerSuite( + markers: Marker[], + stringTable: StringTable +): Map { + const markersPerSuiteName: Map = new Map(); + for (const m of markers) { + if ( + (m.name === 'UserTiming' || m.name === 'SimpleMarker') && + m.end !== null && + m.data && + 'name' in m.data && + m.data.name + ) { + const data = m.data; + const markerName = + typeof data.name === 'string' + ? data.name + : stringTable.getString(data.name); + const match = markerName.match(/^(.*?)-iteration-[0-9]+$/); + if (match !== null) { + const suiteName = match[1]; + let markersForThisSuite = markersPerSuiteName.get(suiteName); + if (markersForThisSuite === undefined) { + markersForThisSuite = []; + markersPerSuiteName.set(suiteName, markersForThisSuite); + } + markersForThisSuite.push(m); + } + } + } + return markersPerSuiteName; +} + +export function speedometerSuiteNamesOnThread( + rawThread: RawThread, + shared: RawProfileSharedData +): string[] { + const names: Set = new Set(); + const { markers } = rawThread; + const { stringArray } = shared; + let userTimingMarkerNameStringIndex = stringArray.indexOf('UserTiming'); + const simpleMarkerNameStringIndex = stringArray.indexOf('SimpleMarker'); + if ( + userTimingMarkerNameStringIndex === -1 || + (simpleMarkerNameStringIndex !== -1 && + simpleMarkerNameStringIndex < userTimingMarkerNameStringIndex) + ) { + userTimingMarkerNameStringIndex = simpleMarkerNameStringIndex; + } + for (let i = 0; i < markers.length; i++) { + if (markers.phase[i] === 0) { + continue; + } + if (markers.name[i] !== userTimingMarkerNameStringIndex) { + continue; + } + const data = markers.data[i]; + if (!data || !('name' in data) || !data.name) { + continue; + } + + const markerName = + typeof data.name === 'string' ? data.name : stringArray[data.name]; + if (markerName.startsWith('suite-')) { + const suiteName = ensureExists( + markerName.match(/^suite-(.*?)(-prepare)?$/) + )[1]; + names.add(suiteName); + } + } + return [...names]; +} + +export function threadHasMatchingMarkers( + rawThread: RawThread, + shared: RawProfileSharedData, + markerFilter: string +) { + const { markers } = rawThread; + const { stringArray } = shared; + let userTimingMarkerNameStringIndex = stringArray.indexOf('UserTiming'); + const simpleMarkerNameStringIndex = stringArray.indexOf('SimpleMarker'); + if ( + userTimingMarkerNameStringIndex === -1 || + (simpleMarkerNameStringIndex !== -1 && + simpleMarkerNameStringIndex < userTimingMarkerNameStringIndex) + ) { + userTimingMarkerNameStringIndex = simpleMarkerNameStringIndex; + } + for (let i = 0; i < markers.length; i++) { + if (markers.phase[i] === 0) { + continue; + } + if (markers.name[i] !== userTimingMarkerNameStringIndex) { + continue; + } + const data = markers.data[i]; + if (!data || !('name' in data) || !data.name) { + continue; + } + + const markerName = + typeof data.name === 'string' ? data.name : stringArray[data.name]; + // Check if the `markerFilter` string is contained in the marker name. + // TODO: Let the front-end do the matching, so that all the various search + // syntaxes work correctly (comma separated multi search, matching by field, etc) + if (markerName.includes(markerFilter)) { + return true; + } + } + return false; +} + +export type SamplesTableForThisStuff = { + time: Float64Array; + weight: Float64Array; + bucketIndex: Int32Array; + bucketCount: number; + length: number; +}; + +export type BenchmarkScores = { + geomean: number; + allSuiteScores: SuiteScores[]; + factorPerSuite: number[]; +}; + +export type IterationMarkersAndMeasuredSamples = { + markersPerSuite: Array<[string, Marker[]]>; + measuredSamples: SamplesTableForThisStuff; +}; + +export function computeIterationMarkersAndMeasuredSamples( + benchmarkInfo: BenchmarkInfo, + filteredMarkers: Marker[], + samples: SamplesTableForThisStuff, + stringTable: StringTable, + bucketsToIgnore: number[] +): IterationMarkersAndMeasuredSamples { + const measuredTimeRanges = benchmarkInfo.getMeasuredTimeRanges( + filteredMarkers, + stringTable + ); + const measuredWeights = samples.weight.slice(); + if (measuredTimeRanges !== null) { + zeroWeightsOutsideRanges(measuredWeights, samples.time, measuredTimeRanges); + } + zeroWeightsForBuckets(measuredWeights, samples.bucketIndex, bucketsToIgnore); + const measuredSamples = { + ...samples, + weight: measuredWeights, + }; + const markersPerSuite = [ + ...benchmarkInfo.getMarkersPerSuite(filteredMarkers, stringTable), + ]; + return { markersPerSuite, measuredSamples }; +} + +export function computeBenchmarkScores( + iterationMarkersAndMeasuredSamples: IterationMarkersAndMeasuredSamples +): BenchmarkScores { + const { markersPerSuite, measuredSamples } = + iterationMarkersAndMeasuredSamples; + const allSuiteScores = markersPerSuite.map(([suiteName, iterationMarkers]) => + computeSuiteScores(suiteName, iterationMarkers, measuredSamples) + ); + const geomean = computeGeomean(allSuiteScores.map((s) => s.total)); + const factorPerSuite = allSuiteScores.map( + (suiteScores) => geomean / suiteScores.total + ); + return { geomean, allSuiteScores, factorPerSuite }; +} + +function computeGeomean(values: number[]): number { + let product = 1; + for (const value of values) { + product *= value; + } + return Math.pow(product, 1 / values.length); +} + +function zeroWeightsOutsideRanges( + sampleWeights: Float64Array, + sampleTimes: Float64Array, + nonZeroRanges: StartEndRange[] +) { + let sampleIndex = 0; + const sampleCount = sampleTimes.length; + for (let rangeIndex = 0; rangeIndex < nonZeroRanges.length; rangeIndex++) { + const range = nonZeroRanges[rangeIndex]; + const rangeStart = range.start; + const rangeEnd = range.end; + + // Zero out sample weights before the range. + for (; sampleIndex < sampleCount; sampleIndex++) { + if (sampleTimes[sampleIndex] >= rangeStart) { + break; + } + sampleWeights[sampleIndex] = 0; + } + + // Skip over samples inside the range. + for (; sampleIndex < sampleCount; sampleIndex++) { + if (sampleTimes[sampleIndex] >= rangeEnd) { + break; + } + } + } + + // Zero out sample weights at the end + for (; sampleIndex < sampleCount; sampleIndex++) { + sampleWeights[sampleIndex] = 0; + } +} + +function zeroWeightsForBuckets( + sampleWeights: Float64Array, + sampleBuckets: Int32Array, + bucketsToZeroOut: number[] +) { + for (let i = 0; i < sampleWeights.length; i++) { + if (bucketsToZeroOut.includes(sampleBuckets[i])) { + sampleWeights[i] = 0; + } + } +} + +export function computeSampleWeightsWithSuiteFactorsApplied( + iterationMarkersAndMeasuredSamples: IterationMarkersAndMeasuredSamples, + suiteFactors: Array +): Float64Array { + const { markersPerSuite, measuredSamples: samples } = + iterationMarkersAndMeasuredSamples; + const newWeights = samples.weight.slice(); + for (let i = 0; i < markersPerSuite.length; i++) { + const [_suiteName, iterationMarkers] = markersPerSuite[i]; + const factor = suiteFactors[i]; + applySuiteFactor(samples.time, newWeights, iterationMarkers, factor); + } + return newWeights; +} + +function applySuiteFactor( + sampleTimes: Float64Array, + sampleWeights: Float64Array, + iterationMarkers: Marker[], + factor: number +) { + let sampleIndex = 0; + const sampleCount = sampleWeights.length; + for ( + let iterationIndex = 0; + iterationIndex < iterationMarkers.length; + iterationIndex++ + ) { + const marker = iterationMarkers[iterationIndex]; + const rangeStart = marker.start; + const rangeEnd = ensureExists(marker.end); + + // Skip over samples before the range. + for (; sampleIndex < sampleCount; sampleIndex++) { + if (sampleTimes[sampleIndex] >= rangeStart) { + break; + } + } + + // Process samples inside the range. + for (; sampleIndex < sampleCount; sampleIndex++) { + if (sampleTimes[sampleIndex] >= rangeEnd) { + break; + } + sampleWeights[sampleIndex] *= factor; + } + } +} + +function computeBucketStats( + bucketIterationTotals: Float64Array, + bucketCount: number, + iterationCount: number +): AllBucketStats { + const bucketMeans = new Float64Array(bucketCount); + const bucketVariances = new Float64Array(bucketCount); + for (let bucketIndex = 0; bucketIndex < bucketCount; bucketIndex++) { + const startIndex = bucketIndex * iterationCount; + let totalSum = 0; + for ( + let iterationIndex = 0; + iterationIndex < iterationCount; + iterationIndex++ + ) { + totalSum += bucketIterationTotals[startIndex + iterationIndex]; + } + const mean = totalSum / iterationCount; + let squareDiffSum = 0; + for ( + let iterationIndex = 0; + iterationIndex < iterationCount; + iterationIndex++ + ) { + const diff = bucketIterationTotals[startIndex + iterationIndex] - mean; + const squareDiff = diff * diff; + squareDiffSum += squareDiff; + } + const variance = squareDiffSum / (iterationCount - 1); + bucketMeans[bucketIndex] = mean; + bucketVariances[bucketIndex] = variance; + } + return { iterationCount, bucketMeans, bucketVariances }; +} + +export type AllBucketStats = { + iterationCount: number; + bucketMeans: Float64Array; + bucketVariances: Float64Array; +}; + +export type SuiteScores = { + suiteName: string; + total: number; + bucketTotals: Float64Array; + bucketIterationTotals: Float64Array; + bucketStats: AllBucketStats | null; +}; + +function computeSuiteScores( + suiteName: string, + iterationMarkers: Marker[], + samples: SamplesTableForThisStuff +): SuiteScores { + const iterationCount = iterationMarkers.length; + const bucketCount = samples.bucketCount; + const bucketTotals = new Float64Array(bucketCount); + const bucketIterationTotals = new Float64Array(bucketCount * iterationCount); + let total = 0; + + let sampleIndex = 0; + const sampleCount = samples.length; + for ( + let iterationIndex = 0; + iterationIndex < iterationMarkers.length; + iterationIndex++ + ) { + const marker = iterationMarkers[iterationIndex]; + const rangeStart = marker.start; + const rangeEnd = ensureExists(marker.end); + + // Skip over samples before the range. + for (; sampleIndex < sampleCount; sampleIndex++) { + if (samples.time[sampleIndex] >= rangeStart) { + break; + } + } + + // Process samples inside the range. + for (; sampleIndex < sampleCount; sampleIndex++) { + if (samples.time[sampleIndex] >= rangeEnd) { + break; + } + const bucketIndex = samples.bucketIndex[sampleIndex]; + if (bucketIndex === -1) { + continue; + } + + // Map this sample to its bucket and accumulate the weight. + const sampleWeight = samples.weight[sampleIndex]; + total += sampleWeight; + bucketTotals[bucketIndex] += sampleWeight; + bucketIterationTotals[bucketIndex * iterationCount + iterationIndex] += + sampleWeight; + } + } + + const bucketStats = computeBucketStats( + bucketIterationTotals, + bucketCount, + iterationCount + ); + + return { + suiteName, + total, + bucketTotals, + bucketIterationTotals, + bucketStats, + }; +} diff --git a/src/profile-logic/benchmark/compare-benchmark-stats.ts b/src/profile-logic/benchmark/compare-benchmark-stats.ts new file mode 100644 index 0000000000..774c14bdd8 --- /dev/null +++ b/src/profile-logic/benchmark/compare-benchmark-stats.ts @@ -0,0 +1,188 @@ +/** + * Compare two benchmark profile stats files (produced by extract-benchmark-stats) + * and report which buckets changed significantly between them. + * + * Uses Mann-Whitney U test with normal approximation. + * + * Usage: + * yarn build-node-tools + * node node-tools-dist/compare-benchmark-stats.js \ + * --base /tmp/base-stats.json \ + * --new /tmp/new-stats.json + * + * Options: + * --suite Show per-suite results for this suite (substring match) + * --global Show results from the geomean-normalised global view (default) + * --pvalue <0.05> Significance threshold (default 0.05) + * --top <20> Show top N changed buckets (default 20) + * --all Show all significant buckets, not just top N + */ + +import type { SparseBucketEntry } from './extract-benchmark-stats'; +import { + mannWhitneyU, + mannWhitneyPValue, + cliffsDelta, + interpretEffectSize, + pValueToConfidence, +} from './perf-compare-stats'; +import type { EffectSize, ConfidenceRating } from './perf-compare-stats'; + +// --------------------------------------------------------------------------- +// Comparison logic +// --------------------------------------------------------------------------- + +export type BucketComparison = { + bucketName: string; + baseMean: number; + newMean: number; + /** Relative change: (newMean - baseMean) / baseMean */ + relChange: number; + cliffdsDelta: number; + effectSize: EffectSize; + confidence: ConfidenceRating; +}; + +/** Build a name → iterationTotals map for a set of sparse bucket entries. */ +function buildNameMap( + buckets: SparseBucketEntry[], + bucketNames: string[] +): Map { + const map = new Map(); + for (const entry of buckets) { + const name = + bucketNames[entry.bucketIndex] ?? `bucket#${entry.bucketIndex}`; + // If the same name appears twice (two different functions with identical names), + // sum their iteration totals together. + const existing = map.get(name); + if (existing !== undefined) { + for (let i = 0; i < existing.length; i++) { + existing[i] += entry.iterationTotals[i]; + } + } else { + map.set(name, entry.iterationTotals.slice()); + } + } + return map; +} + +/** + * Compare two sparse bucket lists, matching by bucket name across profiles. + * Buckets that appear in only one profile are treated as "appeared"/"disappeared" + * unless excludeAppearedDisappeared is set. + */ +export function compareBuckets( + baseBuckets: SparseBucketEntry[], + newBuckets: SparseBucketEntry[], + baseBucketNames: string[], + newBucketNames: string[], + iterationCount: number, + excludeAppearedDisappeared: boolean = false +): BucketComparison[] { + const baseMap = buildNameMap(baseBuckets, baseBucketNames); + const newMap = buildNameMap(newBuckets, newBucketNames); + + const allNames = excludeAppearedDisappeared + ? new Set([...baseMap.keys()].filter((k) => newMap.has(k))) + : new Set([...baseMap.keys(), ...newMap.keys()]); + + const zeros = new Array(iterationCount).fill(0); + + const results: BucketComparison[] = []; + for (const name of allNames) { + const baseIter = baseMap.get(name) ?? zeros; + const newIter = newMap.get(name) ?? zeros; + + const baseMean = mean(baseIter); + const newMean = mean(newIter); + + if (baseMean === 0 && newMean === 0) continue; + + const allValues = [...baseIter, ...newIter]; + const u = mannWhitneyU(baseIter, newIter); + const pValue = mannWhitneyPValue( + u, + baseIter.length, + newIter.length, + allValues + ); + const relChange = + baseMean === 0 ? Infinity : (newMean - baseMean) / baseMean; + const delta = cliffsDelta(u, baseIter.length, newIter.length); + const effectSize = interpretEffectSize(delta); + const confidence = pValueToConfidence(pValue); + + results.push({ + bucketName: name, + baseMean, + newMean, + relChange, + cliffdsDelta: delta, + effectSize, + confidence, + }); + } + + return results; +} + +export function mean(arr: number[]): number { + if (arr.length === 0) return 0; + let sum = 0; + for (const v of arr) sum += v; + return sum / arr.length; +} + +/** Sum all bucket iterationTotals element-wise to get a per-iteration total for a suite. */ +export function suiteIterationTotals( + buckets: SparseBucketEntry[], + iterationCount: number +): number[] { + const totals = new Array(iterationCount).fill(0); + for (const entry of buckets) { + for (let i = 0; i < iterationCount; i++) { + totals[i] += entry.iterationTotals[i]; + } + } + return totals; +} + +export type ScoreComparison = { + label: string; + baseMean: number; + newMean: number; + relChange: number; + cliffdsDelta: number; + effectSize: EffectSize; + confidence: ConfidenceRating; +}; + +export function compareIterationTotals( + label: string, + baseIter: number[], + newIter: number[] +): ScoreComparison { + const baseMean = mean(baseIter); + const newMean = mean(newIter); + const allValues = [...baseIter, ...newIter]; + const u = mannWhitneyU(baseIter, newIter); + const pValue = mannWhitneyPValue( + u, + baseIter.length, + newIter.length, + allValues + ); + const relChange = baseMean === 0 ? Infinity : (newMean - baseMean) / baseMean; + const delta = cliffsDelta(u, baseIter.length, newIter.length); + const effectSize = interpretEffectSize(delta); + const confidence = pValueToConfidence(pValue); + return { + label, + baseMean, + newMean, + relChange, + cliffdsDelta: delta, + effectSize, + confidence, + }; +} diff --git a/src/profile-logic/benchmark/extract-benchmark-stats.ts b/src/profile-logic/benchmark/extract-benchmark-stats.ts new file mode 100644 index 0000000000..bf733cf2a8 --- /dev/null +++ b/src/profile-logic/benchmark/extract-benchmark-stats.ts @@ -0,0 +1,259 @@ +/** + * Extract per-bucket, per-iteration statistics from a benchmark profile into a + * compact intermediate JSON file suitable for cross-profile comparison. + * + * The output is intentionally sparse: only (suite, bucket) pairs with nonzero + * weight are stored. At 200 iterations × 10578 buckets × 20 suites a dense + * representation would be ~323 MB; the sparse form is ~2 MB. + * + * Usage: + * yarn build-node-tools + * node node-tools-dist/extract-benchmark-stats.js \ + * --input ~/Downloads/profile.json \ + * --output /tmp/profile-stats.json + */ + +import { + computeBenchmarkScores, + computeIterationMarkersAndMeasuredSamples, + getBenchmarkInfo, +} from 'firefox-profiler/profile-logic/benchmark/benchmark-stuff'; +import type { + BenchmarkHarness, + SamplesTableForThisStuff, +} from 'firefox-profiler/profile-logic/benchmark/benchmark-stuff'; +import { + correlateIPCMarkers, + deriveMarkersFromRawMarkerTable, +} from 'firefox-profiler/profile-logic/marker-data'; +import { + computeTimeColumnForRawSamplesTable, + getTimeRangeForThread, +} from 'firefox-profiler/profile-logic/profile-data'; +import { StringTable } from 'firefox-profiler/utils/string-table'; + +import type { + IndexIntoFuncTable, + IndexIntoStackTable, + Profile, + RawProfileSharedData, +} from '../../types/profile'; + +// --------------------------------------------------------------------------- +// Types for the intermediate JSON +// --------------------------------------------------------------------------- + +/** One (suite, bucket) pair with nonzero weight. */ +export type SparseBucketEntry = { + /** Index into the profile's global bucket list. */ + bucketIndex: number; + /** Weight sum per iteration, length = iterationCount. */ + iterationTotals: number[]; +}; + +export type SuiteStats = { + suiteName: string; + iterationCount: number; + /** Only buckets that have nonzero total weight across all iterations. */ + buckets: SparseBucketEntry[]; +}; + +export type ProfileBenchmarkStats = { + /** Name of each bucket (JS function name or similar). Length = total bucket count. */ + bucketNames: string[]; + /** + * Per-bucket weight summed across all suites, with suite geomean factors applied, + * per iteration. Sparse: only buckets with nonzero global total. + * This is the "geomean-normalised" global view. + */ + globalBuckets: SparseBucketEntry[]; + /** Per-suite sparse bucket data. */ + suites: SuiteStats[]; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function computeJsOnlySampleBuckets( + shared: RawProfileSharedData, + sampleStacks: Array +): { + bucketFuncs: Array; + sampleBuckets: Int32Array; +} { + const { funcTable, stackTable, frameTable } = shared; + const bucketFuncs = new Array(); + const funcIndexToBucketIndex = new Map(); + + const stackIndexToJsOnlyFuncIndex = new Int32Array(stackTable.length); + for (let stackIndex = 0; stackIndex < stackTable.length; stackIndex++) { + const frameIndex = stackTable.frame[stackIndex]; + const funcIndex = frameTable.func[frameIndex]; + if (funcTable.isJS[funcIndex] || funcTable.relevantForJS[funcIndex]) { + stackIndexToJsOnlyFuncIndex[stackIndex] = funcIndex; + } else { + const parentStackIndex = stackTable.prefix[stackIndex]; + if (parentStackIndex !== null) { + stackIndexToJsOnlyFuncIndex[stackIndex] = + stackIndexToJsOnlyFuncIndex[parentStackIndex]; + } else { + stackIndexToJsOnlyFuncIndex[stackIndex] = -1; + } + } + } + + const sampleBuckets = new Int32Array(sampleStacks.length); + for (let sampleIndex = 0; sampleIndex < sampleBuckets.length; sampleIndex++) { + const stackIndex = sampleStacks[sampleIndex]; + if (stackIndex !== null) { + const jsOnlyFuncIndex = stackIndexToJsOnlyFuncIndex[stackIndex]; + let bucketIndex = + jsOnlyFuncIndex !== -1 + ? funcIndexToBucketIndex.get(jsOnlyFuncIndex) + : -1; + if (bucketIndex === undefined) { + bucketIndex = bucketFuncs.length; + bucketFuncs[bucketIndex] = jsOnlyFuncIndex; + funcIndexToBucketIndex.set(jsOnlyFuncIndex, bucketIndex); + } + sampleBuckets[sampleIndex] = bucketIndex; + } else { + sampleBuckets[sampleIndex] = -1; + } + } + + return { bucketFuncs, sampleBuckets }; +} + +// --------------------------------------------------------------------------- +// Main extraction logic +// --------------------------------------------------------------------------- + +/** + * Extract per-bucket, per-iteration statistics from an already-loaded Profile. + * This is the browser-safe core of the extraction logic; it has no I/O dependencies. + */ +export function extractBenchmarkStatsFromProfile( + profile: Profile, + benchmarkHarness: BenchmarkHarness = 'speedometer' +): ProfileBenchmarkStats { + const benchmarkInfo = getBenchmarkInfo(profile, benchmarkHarness); + const { shared } = profile; + const thread = profile.threads[benchmarkInfo.threadIndex]; + + const { markers } = deriveMarkersFromRawMarkerTable( + thread.markers, + shared.stringArray, + thread.tid, + getTimeRangeForThread(thread, profile.meta.interval), + correlateIPCMarkers(profile.threads, shared) + ); + const stringTable = StringTable.withBackingArray(shared.stringArray); + + const sampleCount = thread.samples.length; + const { sampleBuckets, bucketFuncs } = computeJsOnlySampleBuckets( + shared, + thread.samples.stack + ); + + const profileOverheadBucket = bucketFuncs.findIndex( + (func) => + shared.stringArray[shared.funcTable.name[func]] === 'Profiling overhead' + ); + const bucketsToIgnore = + profileOverheadBucket !== -1 ? [profileOverheadBucket] : []; + + const samples: SamplesTableForThisStuff = { + length: sampleCount, + time: new Float64Array(computeTimeColumnForRawSamplesTable(thread.samples)), + weight: thread.samples.weight + ? new Float64Array(thread.samples.weight) + : new Float64Array(sampleCount).fill(1), + bucketIndex: sampleBuckets, + bucketCount: bucketFuncs.length, + }; + + const iterationMarkersAndMeasuredSamples = + computeIterationMarkersAndMeasuredSamples( + benchmarkInfo, + markers, + samples, + stringTable, + bucketsToIgnore + ); + + const benchmarkScores = computeBenchmarkScores( + iterationMarkersAndMeasuredSamples + ); + + const bucketNames = bucketFuncs.map( + (funcIndex) => shared.stringArray[shared.funcTable.name[funcIndex]] + ); + + const bucketCount = bucketFuncs.length; + const { allSuiteScores, factorPerSuite } = benchmarkScores; + + // Build per-suite sparse entries + const suites: SuiteStats[] = allSuiteScores.map((suiteScores) => { + const iterationCount = suiteScores.bucketStats!.iterationCount; + const buckets: SparseBucketEntry[] = []; + + for (let b = 0; b < bucketCount; b++) { + if (suiteScores.bucketTotals[b] === 0) { + continue; + } + const iterationTotals: number[] = new Array(iterationCount); + const base = b * iterationCount; + for (let i = 0; i < iterationCount; i++) { + iterationTotals[i] = suiteScores.bucketIterationTotals[base + i]; + } + buckets.push({ bucketIndex: b, iterationTotals }); + } + + return { + suiteName: suiteScores.suiteName, + iterationCount, + buckets, + }; + }); + + // Build global sparse entries: sum factorPerSuite[s] * bucketIterationTotals[s][b][i] + // All suites share the same iterationCount, so we can use the first suite's value. + const iterationCount = allSuiteScores[0].bucketStats!.iterationCount; + const globalIterTotals = new Float64Array(bucketCount * iterationCount); + + for (let suiteIndex = 0; suiteIndex < allSuiteScores.length; suiteIndex++) { + const factor = factorPerSuite[suiteIndex]; + const suiteScores = allSuiteScores[suiteIndex]; + for (let b = 0; b < bucketCount; b++) { + if (suiteScores.bucketTotals[b] === 0) { + continue; + } + const base = b * iterationCount; + for (let i = 0; i < iterationCount; i++) { + globalIterTotals[base + i] += + factor * suiteScores.bucketIterationTotals[base + i]; + } + } + } + + const globalBuckets: SparseBucketEntry[] = []; + for (let b = 0; b < bucketCount; b++) { + const base = b * iterationCount; + let total = 0; + for (let i = 0; i < iterationCount; i++) { + total += globalIterTotals[base + i]; + } + if (total === 0) { + continue; + } + const iterationTotals: number[] = new Array(iterationCount); + for (let i = 0; i < iterationCount; i++) { + iterationTotals[i] = globalIterTotals[base + i]; + } + globalBuckets.push({ bucketIndex: b, iterationTotals }); + } + + return { bucketNames, globalBuckets, suites }; +} diff --git a/src/profile-logic/benchmark/perf-compare-stats.ts b/src/profile-logic/benchmark/perf-compare-stats.ts new file mode 100644 index 0000000000..50a865b4cd --- /dev/null +++ b/src/profile-logic/benchmark/perf-compare-stats.ts @@ -0,0 +1,453 @@ +// Non-parametric statistics for comparing performance samples. +// +// Mann-Whitney U: Wilcoxon (1945), Biometrics Bulletin 1(6):80-83 +// Cliff's delta: Cliff (1993), Psychological Bulletin 114(3):494-509 +// Shapiro-Wilk: Shapiro & Wilk (1965), Biometrika 52(3-4):591-611 +// Coefficients: Royston (1992), Statistics and Computing 2(3):117-119 +// p-value: Royston (1995) + +// --------------------------------------------------------------------------- +// Normal distribution +// --------------------------------------------------------------------------- + +function normalQuantile(p: number): number { + const a = [ + -3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, + 1.38357751867269e2, -3.066479806614716e1, 2.506628277459239, + ]; + const b = [ + -5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, + 6.680131188771972e1, -1.328068155288572e1, + ]; + const c = [ + -7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, + -2.549732539343734, 4.374664141464968, 2.938163982698783, + ]; + const d = [ + 7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, + 3.754408661907416, + ]; + const pLow = 0.02425; + const pHigh = 1 - pLow; + + if (p < pLow) { + const q = Math.sqrt(-2 * Math.log(p)); + return ( + (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / + ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1) + ); + } + if (p <= pHigh) { + const q = p - 0.5; + const r = q * q; + return ( + ((((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * + q) / + (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1) + ); + } + const q = Math.sqrt(-2 * Math.log(1 - p)); + return -( + (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / + ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1) + ); +} + +// Abramowitz & Stegun 7.1.26 via the error function. +// The coefficients are for erf(z), not Φ(x) directly. +export function normalCDF(x: number): number { + const z = Math.abs(x) / Math.SQRT2; + const t = 1 / (1 + 0.3275911 * z); + const poly = + t * + (0.254829592 + + t * + (-0.284496736 + + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429)))); + const erfVal = 1 - poly * Math.exp(-z * z); + return x >= 0 ? 0.5 * (1 + erfVal) : 0.5 * (1 - erfVal); +} + +// --------------------------------------------------------------------------- +// Median +// --------------------------------------------------------------------------- + +export function median(arr: number[]): number { + if (!arr.length) return NaN; + const s = arr.slice().sort((a, b) => a - b); + const m = s.length >> 1; + return s.length & 1 ? s[m] : (s[m - 1] + s[m]) / 2; +} + +// --------------------------------------------------------------------------- +// Mann-Whitney U +// --------------------------------------------------------------------------- + +export function mannWhitneyU(a: number[], b: number[]): number { + let u = 0; + for (const ai of a) { + for (const bj of b) { + if (ai < bj) u += 1; + else if (ai === bj) u += 0.5; + } + } + return u; +} + +export function mannWhitneyPValue( + u: number, + n1: number, + n2: number, + allValues: number[] +): number { + const mu = (n1 * n2) / 2; + const counts = new Map(); + for (const v of allValues) counts.set(v, (counts.get(v) ?? 0) + 1); + let tieCorrection = 0; + for (const t of counts.values()) { + if (t > 1) tieCorrection += t * t * t - t; + } + const n = n1 + n2; + const variance = ((n1 * n2) / 12) * (n + 1 - tieCorrection / (n * (n - 1))); + if (variance <= 0) return 1; + const z = (u - mu) / Math.sqrt(variance); + return 2 * (1 - normalCDF(Math.abs(z))); +} + +// --------------------------------------------------------------------------- +// Cliff's delta / CLES / effect size +// --------------------------------------------------------------------------- + +export type EffectSize = 'Negligible' | 'Small' | 'Moderate' | 'Large'; + +export function cliffsDelta(u: number, n1: number, n2: number): number { + return (2 * u) / (n1 * n2) - 1; +} + +export function cles(u: number, n1: number, n2: number): number { + return u / (n1 * n2); +} + +export function interpretEffectSize(delta: number): EffectSize { + const magnitude = Math.abs(delta); + if (magnitude < 0.15) return 'Negligible'; + if (magnitude < 0.33) return 'Small'; + if (magnitude < 0.47) return 'Moderate'; + return 'Large'; +} + +const EFFECT_SIZE_ORDER: EffectSize[] = [ + 'Negligible', + 'Small', + 'Moderate', + 'Large', +]; + +export function effectSizeLessThan(e1: EffectSize, e2: EffectSize): boolean { + return EFFECT_SIZE_ORDER.indexOf(e1) < EFFECT_SIZE_ORDER.indexOf(e2); +} + +// --------------------------------------------------------------------------- +// Confidence rating from p-value +// --------------------------------------------------------------------------- + +export type ConfidenceRating = 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; + +export function pValueToConfidence(pValue: number): ConfidenceRating { + if (pValue <= 0.05) return 'HIGH'; + if (pValue <= 0.15) return 'MEDIUM'; + return 'LOW'; +} + +export function confidenceLessThan( + conf1: ConfidenceRating, + conf2: ConfidenceRating +): boolean { + return ( + (conf2 === 'HIGH' && conf1 !== 'HIGH') || + (conf2 === 'MEDIUM' && conf1 === 'LOW') + ); +} + +// --------------------------------------------------------------------------- +// Shapiro-Wilk normality test +// --------------------------------------------------------------------------- + +function poly5(coeffs: number[], u: number): number { + return ( + ((((coeffs[0] * u + coeffs[1]) * u + coeffs[2]) * u + coeffs[3]) * u + + coeffs[4]) * + u + + coeffs[5] + ); +} + +function iqrFilter(data: number[]): number[] { + if (data.length < 4) return data; + const s = [...data].sort((a, b) => a - b); + const n = s.length; + const q1 = s[Math.floor(n * 0.25)]; + const q3 = s[Math.floor(n * 0.75)]; + const iqr = q3 - q1; + return s.filter((x) => x >= q1 - 1.5 * iqr && x <= q3 + 1.5 * iqr); +} + +export function shapiroWilkTest( + data: number[] +): { w: number; pvalue: number } | null { + const x = iqrFilter(data).sort((a, b) => a - b); + const n = x.length; + if (n < 3 || n > 5000) return null; + + const m = Array.from({ length: n }, (_, i) => + normalQuantile((i + 1 - 0.375) / (n + 0.25)) + ); + const md = m.reduce((s, v) => s + v * v, 0); + const sqrtMd = Math.sqrt(md); + + const c1 = [-2.706056, 4.434685, -2.07119, -0.147981, 0.221157, 0]; + const c2 = [-3.582633, 5.682633, -1.752461, -0.293762, 0.042981, 0]; + const u = 1 / Math.sqrt(n); + c1[5] = m[n - 1] / sqrtMd; + c2[5] = m[n - 2] / sqrtMd; + const an = poly5(c1, u); + const ann = poly5(c2, u); + + const half = Math.floor(n / 2); + let phi: number; + if (n > 5) { + phi = + (md - 2 * m[n - 1] ** 2 - 2 * m[n - 2] ** 2) / + (1 - 2 * an ** 2 - 2 * ann ** 2); + } else { + phi = (md - 2 * m[n - 1] ** 2) / (1 - 2 * an ** 2); + } + const sqrtPhi = Math.sqrt(phi); + + const a: number[] = Array.from({ length: half }); + a[0] = an; + if (n > 5 && half > 1) a[1] = ann; + const startJ = n > 5 ? 2 : 1; + for (let j = startJ; j < half; j++) { + a[j] = m[n - 1 - j] / sqrtPhi; + } + + const xbar = x.reduce((s, v) => s + v, 0) / n; + const ss = x.reduce((s, v) => s + (v - xbar) ** 2, 0); + if (ss === 0) return null; + + let num = 0; + for (let j = 0; j < half; j++) num += a[j] * (x[n - 1 - j] - x[j]); + const w = Math.min(num ** 2 / ss, 1); + + const logn = Math.log(n); + let g: number, mu2: number, sigma: number; + if (n < 12) { + const gamma = 0.459 * n - 2.273; + g = -Math.log(gamma - Math.log(1 - w)); + mu2 = -0.0006714 * n ** 3 + 0.025054 * n ** 2 - 0.39978 * n + 0.544; + sigma = Math.exp( + -0.0020322 * n ** 3 + 0.062767 * n ** 2 - 0.77857 * n + 1.3822 + ); + } else { + g = Math.log(1 - w); + mu2 = + 0.0038915 * logn ** 3 - 0.083751 * logn ** 2 - 0.31082 * logn - 1.5861; + sigma = Math.exp(0.0030302 * logn ** 2 - 0.082676 * logn - 0.4803); + } + + const pvalue = 1 - normalCDF((g - mu2) / sigma); + return { w, pvalue }; +} + +// --------------------------------------------------------------------------- +// Bootstrap CI for the median difference (comp − base) +// --------------------------------------------------------------------------- + +export type BootstrapCIResult = { + shift: number; + lo: number; + hi: number; +}; + +export function bootstrapMedianCI( + base: number[], + comp: number[], + nIter: number = 500 +): BootstrapCIResult | null { + if (base.length < 2 || comp.length < 2) return null; + const shifts = new Array(nIter); + for (let i = 0; i < nIter; i++) { + shifts[i] = median(bootSample(comp)) - median(bootSample(base)); + } + shifts.sort((a, b) => a - b); + return { + shift: median(comp) - median(base), + lo: shifts[Math.floor(0.025 * nIter)], + hi: shifts[Math.ceil(0.975 * nIter) - 1], + }; +} + +function bootSample(arr: number[]): number[] { + const out = new Array(arr.length); + for (let i = 0; i < arr.length; i++) + out[i] = arr[Math.floor(Math.random() * arr.length)]; + return out; +} + +// --------------------------------------------------------------------------- +// Mode matching — min-cost bipartite assignment (bitmask DP, exact for ≤8 modes) +// +// Cost = 0.75 × normalised location distance + 0.25 × fraction difference +// --------------------------------------------------------------------------- + +export type MatchResult = { + pairs: [number, number][]; + unmatchedBase: number[]; + unmatchedNew: number[]; +}; + +export function matchModes( + baseLocs: number[], + baseFracs: number[], + newLocs: number[], + newFracs: number[] +): MatchResult { + const n = baseLocs.length; + const m = newLocs.length; + if (!n || !m) + return { pairs: [], unmatchedBase: range(n), unmatchedNew: range(m) }; + + if (n > m) { + const sw = matchModes(newLocs, newFracs, baseLocs, baseFracs); + return { + pairs: sw.pairs.map(([a, b]) => [b, a]), + unmatchedBase: sw.unmatchedNew, + unmatchedNew: sw.unmatchedBase, + }; + } + + // n <= m: assign all n base modes to n of the m new modes + const all = baseLocs.concat(newLocs); + let lo = all[0], + hi = all[0]; + for (let i = 1; i < all.length; i++) { + if (all[i] < lo) lo = all[i]; + if (all[i] > hi) hi = all[i]; + } + const span = hi - lo || 1; + + const cost = baseLocs.map((bl, i) => + newLocs.map( + (nl, j) => + (0.75 * Math.abs(bl - nl)) / span + + 0.25 * Math.abs(baseFracs[i] - newFracs[j]) + ) + ); + + const INF = 1e9; + const states = 1 << m; + const dp = new Float64Array(states).fill(INF); + const prev = new Int16Array(states).fill(-1); + dp[0] = 0; + for (let mask = 0; mask < states; mask++) { + if (dp[mask] === INF) continue; + const i = popcount(mask); + if (i >= n) continue; + for (let j = 0; j < m; j++) { + if ((mask >> j) & 1) continue; + const nm = mask | (1 << j); + const c = dp[mask] + cost[i][j]; + if (c < dp[nm]) { + dp[nm] = c; + prev[nm] = j; + } + } + } + + let best = -1; + let bc = INF; + for (let mask = 0; mask < states; mask++) { + if (popcount(mask) === n && dp[mask] < bc) { + bc = dp[mask]; + best = mask; + } + } + + const pairs: [number, number][] = []; + let cur = best; + for (let i = n - 1; i >= 0; i--) { + const j = prev[cur]; + pairs.unshift([i, j]); + cur ^= 1 << j; + } + const matchedNew = new Set(pairs.map(([, b]) => b)); + return { + pairs, + unmatchedBase: [], + unmatchedNew: range(m).filter((j) => !matchedNew.has(j)), + }; +} + +function popcount(x: number): number { + let c = 0; + while (x) { + c += x & 1; + x >>= 1; + } + return c; +} + +function range(n: number): number[] { + return Array.from({ length: n }, (_, i) => i); +} + +// --------------------------------------------------------------------------- +// Mode helpers +// --------------------------------------------------------------------------- + +// Split raw samples into mode buckets using boundary x-values. +export function splitByMode(data: number[], boundaries: number[]): number[][] { + const buckets: number[][] = Array.from( + { length: boundaries.length + 1 }, + () => [] + ); + for (const v of data) { + let m = 0; + while (m < boundaries.length && v > boundaries[m]) m++; + buckets[m].push(v); + } + return buckets; +} + +// Fraction of KDE area in each mode bucket (trapezoid rule). +export function areaFractions( + x: number[], + y: number[], + boundaries: number[] +): number[] { + const buckets = new Array(boundaries.length + 1).fill(0); + let total = 0; + for (let i = 1; i < x.length; i++) { + const area = 0.5 * (y[i] + y[i - 1]) * (x[i] - x[i - 1]); + total += area; + let m = 0; + while (m < boundaries.length && x[i] > boundaries[m]) m++; + buckets[m] += area; + } + return total > 0 + ? buckets.map((b: number) => b / total) + : buckets.map(() => 1 / buckets.length); +} + +// Assign letter labels: A = lowest value (fastest), B = next, etc. +export function assignModeLetters(peakLocs: number[]): string[] { + const sorted = peakLocs + .map((_, i) => i) + .sort((a, b) => peakLocs[a] - peakLocs[b]); + const letters = new Array(peakLocs.length); + sorted.forEach((idx, rank) => { + letters[idx] = String.fromCharCode(65 + rank); + }); + return letters; +} diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 085244f0cc..262328a162 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -56,6 +56,8 @@ const dataSource: Reducer = (state = 'none', action) => { return 'unpublished'; case 'SET_DATA_SOURCE': return action.dataSource; + case 'CHANGE_PROFILES_TO_COMPARE_BENCHMARK': + return 'compare-benchmark'; default: return state; } @@ -87,6 +89,7 @@ const profileUrl: Reducer = (state = '', action) => { const profilesToCompare: Reducer = (state = null, action) => { switch (action.type) { case 'CHANGE_PROFILES_TO_COMPARE': + case 'CHANGE_PROFILES_TO_COMPARE_BENCHMARK': return action.profiles; default: return state; diff --git a/src/types/actions.ts b/src/types/actions.ts index 100bf3f6ab..ba4e9e74fd 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -85,6 +85,9 @@ export type DataSource = // This is used when comparing two profiles. The displayed profile is a // comparison profile created from two input profiles. | 'compare' + // This is used for the benchmark comparison page at /compare-benchmark/. + // It loads two benchmark profiles and shows statistical comparison tables. + | 'compare-benchmark' // This is a page which displays a list of profiles that were uploaded from // this browser, and allows deleting / unpublishing those profiles. | 'uploaded-recordings'; @@ -563,6 +566,10 @@ type UrlStateAction = readonly searchString: string; } | { readonly type: 'CHANGE_PROFILES_TO_COMPARE'; readonly profiles: string[] } + | { + readonly type: 'CHANGE_PROFILES_TO_COMPARE_BENCHMARK'; + readonly profiles: string[]; + } | { readonly type: 'CHANGE_PROFILE_NAME'; readonly profileName: string | null; diff --git a/src/utils/stats.ts b/src/utils/stats.ts new file mode 100644 index 0000000000..0b2c0c3e7f --- /dev/null +++ b/src/utils/stats.ts @@ -0,0 +1,92 @@ +/* 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/. */ + +export type BucketStats = { + mean: number; + variance: number; + iterationCount: number; +}; + +export type ConfidenceRating = 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; + +export type ComparedStats = { + pooledStdDev: number; + tStat: number; + pValue: number; + confidence: ConfidenceRating; +}; + +export function confidenceLessThan( + conf1: ConfidenceRating, + conf2: ConfidenceRating +): boolean { + return ( + (conf2 === 'HIGH' && conf1 !== 'HIGH') || + (conf2 === 'MEDIUM' && conf1 === 'LOW') + ); +} + +function pValueToConfidence(pValue: number): ConfidenceRating { + if (pValue <= 0.05) { + return 'HIGH'; + } + if (pValue <= 0.15) { + return 'MEDIUM'; + } + return 'LOW'; +} + +// Function to calculate the cumulative distribution function of the t-distribution +function cumulativeDistributionT(t: number, df: number): number { + const x = df / (t * t + df); + return 0.5 * (1 + (t > 0 ? 1 : -1) * Math.sqrt(1 - x)); +} + +export function computeComparedStats( + statsComp: BucketStats | null, + statsRef: BucketStats | null +): ComparedStats | null { + if (statsComp === null && statsRef !== null) { + statsComp = { + mean: 0, + variance: 0, + iterationCount: statsRef.iterationCount, + }; + } else if (statsRef === null && statsComp !== null) { + statsRef = { + mean: 0, + variance: 0, + iterationCount: statsComp.iterationCount, + }; + } else if (statsRef === null || statsComp === null) { + return null; + } + if (statsComp === statsRef) { + return null; + } + + // Calculate pooled standard deviation + const pooledStdDev = Math.sqrt( + statsComp.variance / statsComp.iterationCount + + statsRef.variance / statsRef.iterationCount + ); + + // Calculate t-statistic + const tStat = + (statsComp.mean - statsRef.mean) / + (pooledStdDev * + Math.sqrt(1 / statsComp.iterationCount + 1 / statsRef.iterationCount)); + + // Calculate degrees of freedom + const degreesOfFreedom = + statsComp.iterationCount + statsRef.iterationCount - 2; + + // Calculate the P-value + const pValue = + 2 * (1 - cumulativeDistributionT(Math.abs(tStat), degreesOfFreedom)); + + const confidence = pValueToConfidence(pValue); + + return { pooledStdDev, tStat, pValue, confidence }; +} From c52af8118e2afec99d3030c24e3bc95cf405467f Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 8 May 2026 15:24:59 -0400 Subject: [PATCH 34/41] Add flame graphs. --- src/components/app/BenchmarkCompareViewer.css | 75 +++++- src/components/app/BenchmarkCompareViewer.tsx | 116 ++++++++- src/components/app/BucketFlameGraphPair.tsx | 169 +++++++++++++ src/node-tools/compare-benchmark-stats.ts | 13 + .../benchmark/bucket-flame-graph-data.ts | 235 ++++++++++++++++++ .../benchmark/compare-benchmark-stats.ts | 60 ++++- .../benchmark/extract-benchmark-stats.ts | 9 +- 7 files changed, 652 insertions(+), 25 deletions(-) create mode 100644 src/components/app/BucketFlameGraphPair.tsx create mode 100644 src/profile-logic/benchmark/bucket-flame-graph-data.ts diff --git a/src/components/app/BenchmarkCompareViewer.css b/src/components/app/BenchmarkCompareViewer.css index 463589e874..17a7970f18 100644 --- a/src/components/app/BenchmarkCompareViewer.css +++ b/src/components/app/BenchmarkCompareViewer.css @@ -1,5 +1,6 @@ .benchmarkCompareViewer { - flex: 1; + /* this element is a child in a horizontal flex parent */ + align-self: flex-start; /* allow extending beyond the parent's height */ box-sizing: border-box; width: 100%; min-height: 100%; @@ -216,3 +217,75 @@ .benchmarkCell--effect-moderate { font-weight: 600; } + +/* Expandable bucket rows (inner table). Mirror the suite-row styling. */ + +.benchmarkRow--bucket-expandable { + cursor: pointer; +} + +.benchmarkRow--bucket-expandable:hover { + background: var(--grey-10); +} + +.benchmarkRow--bucket-expansion > td { + padding: 0.5em 0; + background: var(--grey-10); + white-space: normal; +} + +/* Stacked base / new flame graphs for one expanded bucket. The base flame + * graph is on top, the new one below, so they can be visually compared by + * scanning vertically. Each side's width is set inline (as a percentage) + * proportional to its total sample count, so 1 sample takes up the same + * pixel width on both sides. */ + +.bucketFlameGraphPair { + display: flex; + flex-direction: column; + gap: 0.5em; + padding: 0.5em 1em; +} + +.bucketFlameGraphSide { + display: flex; + overflow: hidden; + min-width: 0; + height: 280px; + flex-direction: column; + border: 1px solid var(--grey-30); + border-radius: 4px; + background: var(--base-background-color); +} + +.bucketFlameGraphSide__label { + padding: 4px 8px; + border-bottom: 1px solid var(--grey-30); + background: var(--grey-20); + color: var(--grey-70); + font-size: 0.85em; + font-weight: bold; + text-transform: uppercase; +} + +.bucketFlameGraphSide__sampleCount { + color: var(--grey-60); + font-weight: normal; + text-transform: none; +} + +.bucketFlameGraphSide__chart { + display: flex; + overflow: hidden; + flex: 1; + flex-direction: column; +} + +.bucketFlameGraphSide__empty { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + color: var(--grey-60); + font-style: italic; +} diff --git a/src/components/app/BenchmarkCompareViewer.tsx b/src/components/app/BenchmarkCompareViewer.tsx index 7a75dc5ae3..d8c4ecb511 100644 --- a/src/components/app/BenchmarkCompareViewer.tsx +++ b/src/components/app/BenchmarkCompareViewer.tsx @@ -2,7 +2,7 @@ * 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/. */ -import { Fragment, useState, useEffect } from 'react'; +import { Fragment, useState, useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { AppHeader } from './AppHeader'; @@ -25,11 +25,24 @@ import type { ConfidenceRating, EffectSize, } from 'firefox-profiler/profile-logic/benchmark/perf-compare-stats'; +import type { Profile } from 'firefox-profiler/types'; +import { BucketFlameGraphPair } from './BucketFlameGraphPair'; +import type { BucketProfileBundle } from './BucketFlameGraphPair'; +import { + buildDerivedThread, + getCategoriesForProfile, + getDefaultCategoryIndex, +} from 'firefox-profiler/profile-logic/benchmark/bucket-flame-graph-data'; +import { getBenchmarkInfo } from 'firefox-profiler/profile-logic/benchmark/benchmark-stuff'; import './BenchmarkCompareViewer.css'; type ComparisonData = { baseUrl: string; newUrl: string; + /** The loaded source profiles, retained so we can render flame graphs of + * individual buckets on demand (focusSelf on a bucket's representative func). */ + baseProfile: Profile; + newProfile: Profile; overallScore: ScoreComparison; suiteScores: ScoreComparison[]; suiteComparisons: Array<{ @@ -121,6 +134,8 @@ async function computeComparison( newSuite.buckets, baseStats.bucketNames, newStats.bucketNames, + baseStats.bucketFuncs, + newStats.bucketFuncs, baseSuite.iterationCount ); return [{ suiteName: baseSuite.suiteName, comparisons }]; @@ -130,6 +145,8 @@ async function computeComparison( return { baseUrl, newUrl, + baseProfile, + newProfile, overallScore, suiteScores, suiteComparisons, @@ -224,10 +241,14 @@ function ScoreTable({ overallScore, suiteScores, suiteComparisonsByName, + baseBundle, + newBundle, }: { overallScore: ScoreComparison; suiteScores: ScoreComparison[]; suiteComparisonsByName: Map; + baseBundle: BucketProfileBundle; + newBundle: BucketProfileBundle; }) { const [expanded, setExpanded] = useState>(new Set()); const numSuites = suiteScores.length; @@ -313,6 +334,8 @@ function ScoreTable({ label={row.label} baseSubtestMean={row.baseMean} numSuites={numSuites} + baseBundle={baseBundle} + newBundle={newBundle} /> @@ -330,6 +353,8 @@ function BucketTable({ label, baseSubtestMean, numSuites, + baseBundle, + newBundle, }: { comparisons: BucketComparison[]; label: string; @@ -337,9 +362,22 @@ function BucketTable({ * (Δ% overall and Δ% subtest) instead of the bucket-relative Δ%. */ baseSubtestMean?: number; numSuites?: number; + baseBundle: BucketProfileBundle; + newBundle: BucketProfileBundle; }) { const showSubtestColumns = baseSubtestMean !== undefined && numSuites !== undefined; + const columnCount = showSubtestColumns ? 6 : 5; + + const [expanded, setExpanded] = useState>(new Set()); + const toggle = (bucketName: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(bucketName)) next.delete(bucketName); + else next.add(bucketName); + return next; + }); + }; const significant = comparisons .filter((c) => c.confidence !== 'LOW' && c.effectSize !== 'Negligible') @@ -403,16 +441,47 @@ function BucketTable({ ); } + // A bucket can be expanded if at least one side has a func index. + // (If both are null it's a degenerate "appeared/disappeared with no + // attributable func" case.) + const expandable = c.baseFunc !== null || c.newFunc !== null; + const isExpanded = expanded.has(c.bucketName); return ( - - - {c.bucketName} - - {c.baseMean.toFixed(2)} - {c.newMean.toFixed(2)} - {absDiffStr} - {pctCells} - + + toggle(c.bucketName) : undefined} + > + + {expandable && ( + + )} + {c.bucketName} + + + {c.baseMean.toFixed(2)} + + {c.newMean.toFixed(2)} + {absDiffStr} + {pctCells} + + {expandable && isExpanded && ( + + + + + + )} + ); })} @@ -420,6 +489,22 @@ function BucketTable({ ); } +/** Build the (profile, derivedThread, categories) bundle once per profile. + * Computing the derived thread is expensive, so we memoize on profile identity + * and reuse the same bundle across every bucket the user expands. */ +function makeBucketProfileBundle(profile: Profile): BucketProfileBundle { + const categories = getCategoriesForProfile(profile); + const defaultCategory = getDefaultCategoryIndex(categories); + const benchmarkInfo = getBenchmarkInfo(profile, 'speedometer'); + const thread = buildDerivedThread( + profile, + benchmarkInfo.threadIndex, + categories, + defaultCategory + ); + return { profile, thread, categories, defaultCategory }; +} + function ComparisonResults({ data }: { data: ComparisonData }) { const suiteComparisonsByName = new Map( data.suiteComparisons.map(({ suiteName, comparisons }) => [ @@ -428,6 +513,15 @@ function ComparisonResults({ data }: { data: ComparisonData }) { ]) ); + const baseBundle = useMemo( + () => makeBucketProfileBundle(data.baseProfile), + [data.baseProfile] + ); + const newBundle = useMemo( + () => makeBucketProfileBundle(data.newProfile), + [data.newProfile] + ); + return (
@@ -450,6 +544,8 @@ function ComparisonResults({ data }: { data: ComparisonData }) { overallScore={data.overallScore} suiteScores={data.suiteScores} suiteComparisonsByName={suiteComparisonsByName} + baseBundle={baseBundle} + newBundle={newBundle} />
); diff --git a/src/components/app/BucketFlameGraphPair.tsx b/src/components/app/BucketFlameGraphPair.tsx new file mode 100644 index 0000000000..a75921d067 --- /dev/null +++ b/src/components/app/BucketFlameGraphPair.tsx @@ -0,0 +1,169 @@ +/* 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/. */ + +import { useMemo, useState } from 'react'; + +import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph'; +import { computeBucketFlameGraphData } from 'firefox-profiler/profile-logic/benchmark/bucket-flame-graph-data'; + +import type { BucketFlameGraphData } from 'firefox-profiler/profile-logic/benchmark/bucket-flame-graph-data'; +import type { + Profile, + Thread, + CategoryList, + IndexIntoCategoryList, + IndexIntoFuncTable, + IndexIntoCallNodeTable, +} from 'firefox-profiler/types'; + +/** Per-profile prep data passed in from the viewer. The derived `thread` is + * expensive to build, so it's computed once at the viewer level and reused + * across every bucket the user expands. */ +export type BucketProfileBundle = { + profile: Profile; + thread: Thread; + categories: CategoryList; + defaultCategory: IndexIntoCategoryList; +}; + +type SideProps = { + label: string; + data: BucketFlameGraphData | null; + /** Stable React key (e.g. "base"/"new"); also used as the FlameGraph + * `threadsKey` to scope its internal state per side. */ + sideKey: string; + /** Width as a fraction (0..1) of the row, so the side with fewer samples is + * narrower and 1 sample takes the same pixel width on both sides. */ + widthFraction: number; +}; + +function BucketFlameGraphSide({ + label, + data, + sideKey, + widthFraction, +}: SideProps) { + const [selectedCallNodeIndex, setSelectedCallNodeIndex] = + useState(null); + + const sampleCountText = + data === null ? '' : ` — ${data.rootTotalSummary.toFixed(0)} samples`; + + return ( +
+
+ {label} + + {sampleCountText} + +
+
+ {data === null ? ( +
+ No data for this bucket. +
+ ) : ( + + )} +
+
+ ); +} + +function noop() {} + +type Props = { + baseBundle: BucketProfileBundle; + newBundle: BucketProfileBundle; + baseFunc: IndexIntoFuncTable | null; + newFunc: IndexIntoFuncTable | null; +}; + +function computeForBundle( + bundle: BucketProfileBundle, + funcIndex: IndexIntoFuncTable | null +): BucketFlameGraphData | null { + if (funcIndex === null) { + return null; + } + return computeBucketFlameGraphData( + bundle.profile, + bundle.thread, + funcIndex, + bundle.categories, + bundle.defaultCategory + ); +} + +/** Two flame graphs stacked vertically (base on top, new below). Each side's + * width is proportional to its sample-count total so 1 sample takes up the + * same pixel width across both. */ +export function BucketFlameGraphPair({ + baseBundle, + newBundle, + baseFunc, + newFunc, +}: Props) { + const baseData = useMemo( + () => computeForBundle(baseBundle, baseFunc), + [baseBundle, baseFunc] + ); + const newData = useMemo( + () => computeForBundle(newBundle, newFunc), + [newBundle, newFunc] + ); + + const baseTotal = baseData?.rootTotalSummary ?? 0; + const newTotal = newData?.rootTotalSummary ?? 0; + const maxTotal = Math.max(baseTotal, newTotal, 1); + + return ( +
+ + +
+ ); +} diff --git a/src/node-tools/compare-benchmark-stats.ts b/src/node-tools/compare-benchmark-stats.ts index 61a76e95b0..5e92e1cc8f 100644 --- a/src/node-tools/compare-benchmark-stats.ts +++ b/src/node-tools/compare-benchmark-stats.ts @@ -122,6 +122,15 @@ async function main() { fs.readFileSync(argv.new, 'utf8') ); + // bucketFuncs was added later; older stats files don't include it. The CLI + // doesn't need real func indices (no flame graph here), so fill with -1. + if (!base.bucketFuncs) { + base.bucketFuncs = new Array(base.bucketNames.length).fill(-1); + } + if (!newStats.bucketFuncs) { + newStats.bucketFuncs = new Array(newStats.bucketNames.length).fill(-1); + } + const iterationCount = base.suites[0]?.iterationCount ?? 1; if (showGlobal) { @@ -164,6 +173,8 @@ async function main() { newStats.globalBuckets, base.bucketNames, newStats.bucketNames, + base.bucketFuncs, + newStats.bucketFuncs, iterationCount, excludeAppearedDisappeared ); @@ -196,6 +207,8 @@ async function main() { newSuite.buckets, base.bucketNames, newStats.bucketNames, + base.bucketFuncs, + newStats.bucketFuncs, baseSuite.iterationCount, excludeAppearedDisappeared ); diff --git a/src/profile-logic/benchmark/bucket-flame-graph-data.ts b/src/profile-logic/benchmark/bucket-flame-graph-data.ts new file mode 100644 index 0000000000..7caf496cf4 --- /dev/null +++ b/src/profile-logic/benchmark/bucket-flame-graph-data.ts @@ -0,0 +1,235 @@ +/* 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/. */ + +/** + * Pure helpers that compute everything needed to render a SelfWing-style + * flame graph (focusSelf with 'js' implementation filter) for one + * (Profile, funcIndex) pair, without going through the Redux store. + * + * Used by the benchmark-comparison page to expand a bucket row and show two + * flame graphs (base vs new). Each call here mirrors the chain of selectors + * in selectors/per-thread/stack-sample.ts (the "self wing" cluster) and + * selectors/per-thread/thread.tsx (`getThread`), but operates on a profile + * that is not the one currently loaded in Redux state. + */ + +import { + computeStackTableFromRawStackTable, + computeSamplesTableFromRawSamplesTable, + reserveFunctionsForCollapsedResources, + createThreadFromDerivedTables, + getCallNodeInfo, + getSampleIndexToCallNodeIndex, +} from '../profile-data'; +import * as Transforms from '../transforms'; +import * as CallTree from '../call-tree'; +import * as FlameGraph from '../flame-graph'; +import { computeReferenceCPUDeltaPerMs } from '../cpu'; +import { getDefaultCategories } from '../data-structures'; +import { StringTable } from '../../utils/string-table'; +import { base64StringToBytes } from '../../utils/base64'; + +import type { + Thread, + Profile, + IndexIntoFuncTable, + IndexIntoCategoryList, + CategoryList, + StartEndRange, + WeightType, + SamplesLikeTable, + SampleCategoriesAndSubcategories, +} from '../../types'; +import type { CallNodeInfo } from '../call-node-info'; +import type { FlameGraphTiming } from '../flame-graph'; +import type { CallTree as CallTreeT } from '../call-tree'; + +export type BucketFlameGraphData = { + thread: Thread; + callNodeInfo: CallNodeInfo; + callTree: CallTreeT; + flameGraphTiming: FlameGraphTiming; + maxStackDepthPlusOne: number; + ctssSamples: SamplesLikeTable; + ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + weightType: WeightType; + categories: CategoryList; + defaultCategory: IndexIntoCategoryList; + timeRange: StartEndRange; + interval: number; + /** Total weight of all samples in the focused thread. Used by callers to + * scale the flame-graph viewport so that 1 sample takes up the same pixel + * width across multiple flame graphs. */ + rootTotalSummary: number; +}; + +/** Categories list with fallback to defaults (matches selectors/profile.ts). */ +export function getCategoriesForProfile(profile: Profile): CategoryList { + return profile.meta.categories ?? getDefaultCategories(); +} + +/** Default category index — the "Other" / grey category. */ +export function getDefaultCategoryIndex(categories: CategoryList): IndexIntoCategoryList { + return categories.findIndex((c) => c.color === 'grey'); +} + +/** + * Build a derived `Thread` from `profile.threads[threadIndex]` without going + * through Redux. Equivalent to the `getThread` selector in + * selectors/per-thread/thread.tsx, minus the per-thread stuff that doesn't + * apply when there's no thread merging. + */ +export function buildDerivedThread( + profile: Profile, + threadIndex: number, + categories: CategoryList, + defaultCategory: IndexIntoCategoryList +): Thread { + const rawThread = profile.threads[threadIndex]; + const { shared, meta } = profile; + const stringTable = StringTable.withBackingArray( + shared.stringArray as string[] + ); + const stackTable = computeStackTableFromRawStackTable( + shared.stackTable, + shared.frameTable, + categories, + defaultCategory + ); + const { funcTable } = reserveFunctionsForCollapsedResources( + shared.funcTable, + shared.resourceTable + ); + const referenceCPUDeltaPerMs = computeReferenceCPUDeltaPerMs(profile); + const samples = computeSamplesTableFromRawSamplesTable( + rawThread.samples, + stackTable, + meta.sampleUnits, + referenceCPUDeltaPerMs, + defaultCategory + ); + const tracedValuesBuffer = rawThread.tracedValuesBuffer + ? base64StringToBytes(rawThread.tracedValuesBuffer) + : undefined; + return createThreadFromDerivedTables( + rawThread, + samples, + stackTable, + shared.frameTable, + funcTable, + shared.nativeSymbols, + shared.resourceTable, + stringTable, + shared.sources, + tracedValuesBuffer + ); +} + +/** + * Compute everything needed to render one SelfWing-style flame graph for the + * given function in the given thread. Mirrors the `_getSelfWing*` selectors. + */ +export function computeBucketFlameGraphData( + profile: Profile, + thread: Thread, + funcIndex: IndexIntoFuncTable, + categories: CategoryList, + defaultCategory: IndexIntoCategoryList +): BucketFlameGraphData { + // 1. focusSelf with 'js' implementation filter — this is what "self wing" + // does in the call tree / function list. The 'js' filter matches the + // benchmark's bucketing logic in computeJsOnlySampleBuckets, so the flame + // graph reflects the same notion of "this bucket's time". + const selfWingThread = Transforms.focusSelf(thread, funcIndex, 'js'); + + // 2. Call-node info for the focused thread. + const callNodeInfo = getCallNodeInfo( + selfWingThread.stackTable, + selfWingThread.frameTable, + defaultCategory + ); + + // 3. CTSS samples (timing strategy → just thread.samples). + const ctssSamples = CallTree.extractSamplesLikeTable(selfWingThread, 'timing'); + + // 4. Map samples → call nodes. + const sampleIndexToCallNodeIndex = getSampleIndexToCallNodeIndex( + ctssSamples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ); + + // 5. Per-callnode self-time + scaling totals. + const callNodeSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( + ctssSamples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + + // 6. Full timings. + const callTreeTimingsNonInverted = CallTree.computeCallTreeTimingsNonInverted( + callNodeInfo, + callNodeSelfAndSummary + ); + const callTreeTimings: CallTree.CallTreeTimings = { + type: 'NON_INVERTED', + timings: callTreeTimingsNonInverted, + }; + + // 7. Flame graph layout. + const flameGraphRows = FlameGraph.computeFlameGraphRows( + callNodeInfo.getCallNodeTable(), + selfWingThread.funcTable, + selfWingThread.stringTable + ); + const flameGraphTiming = FlameGraph.getFlameGraphTiming( + flameGraphRows, + callNodeInfo.getCallNodeTable(), + callTreeTimingsNonInverted + ); + + // 8. CallTree object (used by FlameGraph for tooltips and double-click). + const weightType: WeightType = ctssSamples.weightType ?? 'samples'; + const callTree = CallTree.getCallTree( + selfWingThread, + callNodeInfo, + categories, + ctssSamples, + callTreeTimings, + weightType + ); + + // 9. Per-sample categories. + const ctssSampleCategoriesAndSubcategories = + CallTree.computeUnfilteredCtssSampleCategoriesAndSubcategories( + selfWingThread, + ctssSamples, + defaultCategory + ); + + // Time range from the original (un-focused) thread's samples. The flame + // graph doesn't actually scrub by time, but ChartViewport requires a range. + const interval = profile.meta.interval; + const timeColumn = thread.samples.time; + const sampleCount = thread.samples.length; + const timeRange: StartEndRange = + sampleCount > 0 + ? { start: timeColumn[0], end: timeColumn[sampleCount - 1] + interval } + : { start: 0, end: interval }; + + return { + thread: selfWingThread, + callNodeInfo, + callTree, + flameGraphTiming, + maxStackDepthPlusOne: callNodeInfo.getCallNodeTable().maxDepth + 1, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + weightType, + categories, + defaultCategory, + timeRange, + interval, + rootTotalSummary: callNodeSelfAndSummary.rootTotalSummary, + }; +} diff --git a/src/profile-logic/benchmark/compare-benchmark-stats.ts b/src/profile-logic/benchmark/compare-benchmark-stats.ts index 774c14bdd8..be8ded7f65 100644 --- a/src/profile-logic/benchmark/compare-benchmark-stats.ts +++ b/src/profile-logic/benchmark/compare-benchmark-stats.ts @@ -27,6 +27,7 @@ import { pValueToConfidence, } from './perf-compare-stats'; import type { EffectSize, ConfidenceRating } from './perf-compare-stats'; +import type { IndexIntoFuncTable } from '../../types/profile'; // --------------------------------------------------------------------------- // Comparison logic @@ -34,6 +35,12 @@ import type { EffectSize, ConfidenceRating } from './perf-compare-stats'; export type BucketComparison = { bucketName: string; + /** Func index of the bucket in the base profile, or null if absent there. + * If multiple funcs share this name within the profile, the one with the + * largest sum of iterationTotals is chosen (representative func). */ + baseFunc: IndexIntoFuncTable | null; + /** Func index of the bucket in the new profile, or null if absent there. */ + newFunc: IndexIntoFuncTable | null; baseMean: number; newMean: number; /** Relative change: (newMean - baseMean) / baseMean */ @@ -43,24 +50,45 @@ export type BucketComparison = { confidence: ConfidenceRating; }; -/** Build a name → iterationTotals map for a set of sparse bucket entries. */ +type NameMapEntry = { + iterationTotals: number[]; + /** Func index of the highest-weight bucket with this name (representative). */ + representativeFunc: IndexIntoFuncTable; + /** Sum of iterationTotals for that representative bucket alone. */ + representativeWeight: number; +}; + +/** Build a name → iterationTotals + representative-func map for a set of sparse bucket entries. */ function buildNameMap( buckets: SparseBucketEntry[], - bucketNames: string[] -): Map { - const map = new Map(); + bucketNames: string[], + bucketFuncs: IndexIntoFuncTable[] +): Map { + const map = new Map(); for (const entry of buckets) { const name = bucketNames[entry.bucketIndex] ?? `bucket#${entry.bucketIndex}`; - // If the same name appears twice (two different functions with identical names), - // sum their iteration totals together. + const func = bucketFuncs[entry.bucketIndex]; + let weight = 0; + for (const v of entry.iterationTotals) weight += v; const existing = map.get(name); if (existing !== undefined) { - for (let i = 0; i < existing.length; i++) { - existing[i] += entry.iterationTotals[i]; + // If the same name appears twice (two different functions with identical names), + // sum their iteration totals together. Pick the heaviest as the representative + // func, since the flame graph can only focusSelf on one func. + for (let i = 0; i < existing.iterationTotals.length; i++) { + existing.iterationTotals[i] += entry.iterationTotals[i]; + } + if (weight > existing.representativeWeight) { + existing.representativeFunc = func; + existing.representativeWeight = weight; } } else { - map.set(name, entry.iterationTotals.slice()); + map.set(name, { + iterationTotals: entry.iterationTotals.slice(), + representativeFunc: func, + representativeWeight: weight, + }); } } return map; @@ -76,11 +104,13 @@ export function compareBuckets( newBuckets: SparseBucketEntry[], baseBucketNames: string[], newBucketNames: string[], + baseBucketFuncs: IndexIntoFuncTable[], + newBucketFuncs: IndexIntoFuncTable[], iterationCount: number, excludeAppearedDisappeared: boolean = false ): BucketComparison[] { - const baseMap = buildNameMap(baseBuckets, baseBucketNames); - const newMap = buildNameMap(newBuckets, newBucketNames); + const baseMap = buildNameMap(baseBuckets, baseBucketNames, baseBucketFuncs); + const newMap = buildNameMap(newBuckets, newBucketNames, newBucketFuncs); const allNames = excludeAppearedDisappeared ? new Set([...baseMap.keys()].filter((k) => newMap.has(k))) @@ -90,8 +120,10 @@ export function compareBuckets( const results: BucketComparison[] = []; for (const name of allNames) { - const baseIter = baseMap.get(name) ?? zeros; - const newIter = newMap.get(name) ?? zeros; + const baseEntry = baseMap.get(name); + const newEntry = newMap.get(name); + const baseIter = baseEntry?.iterationTotals ?? zeros; + const newIter = newEntry?.iterationTotals ?? zeros; const baseMean = mean(baseIter); const newMean = mean(newIter); @@ -114,6 +146,8 @@ export function compareBuckets( results.push({ bucketName: name, + baseFunc: baseEntry?.representativeFunc ?? null, + newFunc: newEntry?.representativeFunc ?? null, baseMean, newMean, relChange, diff --git a/src/profile-logic/benchmark/extract-benchmark-stats.ts b/src/profile-logic/benchmark/extract-benchmark-stats.ts index bf733cf2a8..9df34388a0 100644 --- a/src/profile-logic/benchmark/extract-benchmark-stats.ts +++ b/src/profile-logic/benchmark/extract-benchmark-stats.ts @@ -61,6 +61,13 @@ export type SuiteStats = { export type ProfileBenchmarkStats = { /** Name of each bucket (JS function name or similar). Length = total bucket count. */ bucketNames: string[]; + /** + * Func index (in profile.shared.funcTable) for each bucket. Same length as + * bucketNames. -1 for the synthetic "no JS frame" bucket. Useful when callers + * want to reach back into the source profile for a given bucket, e.g. to feed + * a focusSelf() flame graph. + */ + bucketFuncs: Array; /** * Per-bucket weight summed across all suites, with suite geomean factors applied, * per iteration. Sparse: only buckets with nonzero global total. @@ -255,5 +262,5 @@ export function extractBenchmarkStatsFromProfile( globalBuckets.push({ bucketIndex: b, iterationTotals }); } - return { bucketNames, globalBuckets, suites }; + return { bucketNames, bucketFuncs, globalBuckets, suites }; } From 2338e972b954c58ab5263963eee8afd533794535 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 8 May 2026 16:52:08 -0400 Subject: [PATCH 35/41] Fix tooltip coordinates when scrolled down The tooltip uses `position: fixed` which interprets `left/top` as viewport-relative, but `ChartCanvas` was storing `event.pageX/pageY` (document-relative, including scroll offsets). In a non-scrolling page these match, but inside a scrollable container like our benchmark page they diverge by `window.scrollY`, dragging the tooltip down. Switched the state field from `pageX/pageY` to `clientX/clientY`. --- src/components/shared/chart/Canvas.tsx | 49 ++++++++++++++------------ 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/components/shared/chart/Canvas.tsx b/src/components/shared/chart/Canvas.tsx index 4f12710e52..978060258a 100644 --- a/src/components/shared/chart/Canvas.tsx +++ b/src/components/shared/chart/Canvas.tsx @@ -43,13 +43,16 @@ type Props = { } | null; }; -// The naming of the X and Y coordinates here correspond to the ones -// found on the MouseEvent interface. +// Mouse coordinates passed to the Tooltip component. The Tooltip uses +// `position: fixed`, so these must be VIEWPORT-relative (i.e. clientX/clientY, +// not pageX/pageY). Otherwise the tooltip is mispositioned whenever the page +// is scrolled (the chart's container can be in a scrollable region — e.g. the +// benchmark-comparison page — even though the chart itself doesn't scroll). type State = { hoveredItem: Item | null; selectedItem: Item | null; - pageX: CssPixels; - pageY: CssPixels; + clientX: CssPixels; + clientY: CssPixels; }; export type ChartCanvasScale = { @@ -103,8 +106,8 @@ export class ChartCanvas extends React.Component< override state: State = { hoveredItem: null, selectedItem: null, - pageX: 0, - pageY: 0, + clientX: 0, + clientY: 0, }; _scheduleDraw( @@ -238,8 +241,8 @@ export class ChartCanvas extends React.Component< if (this.props.stickyTooltips) { this.setState((state) => ({ selectedItem: state.hoveredItem, - pageX: e.pageX, - pageY: e.pageY, + clientX: e.clientX, + clientY: e.clientY, })); } @@ -292,20 +295,18 @@ export class ChartCanvas extends React.Component< const maybeHoveredItem = this.props.hitTest(offsetX, offsetY); if (maybeHoveredItem !== null) { if (this.state.selectedItem === null) { - // Update both the hovered item and the pageX and pageY values. The - // pageX and pageY values are used to change the position of the tooltip - // and if there is no selected item, it means that we can update this - // position freely. + // Update both the hovered item and the cursor position. The cursor + // position is used to position the tooltip; if there is no selected + // item we can update it freely. this.setState({ hoveredItem: maybeHoveredItem, - pageX: event.pageX, - pageY: event.pageY, + clientX: event.clientX, + clientY: event.clientY, }); } else { // If there is a selected item, only update the hoveredItem and not the - // pageX and pageY values which is used for the position of the tooltip. - // By keeping the x and y values the same, we make sure that the tooltip - // stays in its initial position where it's clicked. + // cursor position used to position the tooltip. By keeping the x and y + // values the same, the tooltip stays at the position where it was clicked. this.setState({ hoveredItem: maybeHoveredItem, }); @@ -385,9 +386,11 @@ export class ChartCanvas extends React.Component< const { offsetX, offsetY } = selectedItemTooltipOffset; const canvasRect = this._canvas.getBoundingClientRect(); - const pageX = canvasRect.left + window.scrollX + offsetX; - const pageY = canvasRect.top + window.scrollY + offsetY; - this.setState({ selectedItem, pageX, pageY }); + // Viewport-relative coordinates (no scroll offsets), since the Tooltip is + // positioned with `position: fixed`. See the State type comment above. + const clientX = canvasRect.left + offsetX; + const clientY = canvasRect.top + offsetY; + this.setState({ selectedItem, clientX, clientY }); }; override UNSAFE_componentWillReceiveProps() { @@ -495,7 +498,7 @@ export class ChartCanvas extends React.Component< override render() { const { isDragging } = this.props; - const { hoveredItem, pageX, pageY } = this.state; + const { hoveredItem, clientX, clientY } = this.state; const className = classNames({ chartCanvas: true, @@ -519,8 +522,8 @@ export class ChartCanvas extends React.Component< /> {!isDragging && tooltipContents ? ( Date: Wed, 13 May 2026 18:25:02 -0400 Subject: [PATCH 36/41] Make flame graphs in benchmark comparison viewer only show measured samples of that subtest --- src/components/app/BenchmarkCompareViewer.tsx | 72 +++++----- src/components/app/BucketFlameGraphPair.tsx | 19 +-- .../benchmark/benchmark-stuff.ts | 31 ++++- .../benchmark/bucket-flame-graph-data.ts | 127 +++++++++++++++++- 4 files changed, 193 insertions(+), 56 deletions(-) diff --git a/src/components/app/BenchmarkCompareViewer.tsx b/src/components/app/BenchmarkCompareViewer.tsx index d8c4ecb511..87a06d4ca9 100644 --- a/src/components/app/BenchmarkCompareViewer.tsx +++ b/src/components/app/BenchmarkCompareViewer.tsx @@ -27,13 +27,11 @@ import type { } from 'firefox-profiler/profile-logic/benchmark/perf-compare-stats'; import type { Profile } from 'firefox-profiler/types'; import { BucketFlameGraphPair } from './BucketFlameGraphPair'; -import type { BucketProfileBundle } from './BucketFlameGraphPair'; import { - buildDerivedThread, - getCategoriesForProfile, - getDefaultCategoryIndex, + makeBucketProfileBundle, + makeSuiteFilteredThread, } from 'firefox-profiler/profile-logic/benchmark/bucket-flame-graph-data'; -import { getBenchmarkInfo } from 'firefox-profiler/profile-logic/benchmark/benchmark-stuff'; +import type { BucketProfileBundle } from 'firefox-profiler/profile-logic/benchmark/bucket-flame-graph-data'; import './BenchmarkCompareViewer.css'; type ComparisonData = { @@ -286,17 +284,10 @@ function ScoreTable({ - + {overallScore.label} - + {suiteScores.map((row) => { const isExpanded = expanded.has(row.label); @@ -315,10 +306,7 @@ function ScoreTable({ title={row.label} > {expandable && ( -