diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index be577ee3a9..33f0f9d061 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/package.json b/package.json index 2cf3c17999..2206ee6050 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^3.1.0", "reselect": "^4.1.8", + "smol-toml": "^1.6.1", "valibot": "^1.3.1", "workbox-window": "^7.4.0" }, 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/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/profile-view.ts b/src/actions/profile-view.ts index 51ff3dcdb4..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, @@ -74,14 +75,17 @@ import type { TableViewOptions, SelectionContext, BottomBoxInfo, + IndexIntoFuncTable, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, funcHasRecursiveCall, } from '../profile-logic/transforms'; import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db'; +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'; import { intersectSets } from 'firefox-profiler/utils/set'; /** @@ -120,7 +124,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, @@ -129,6 +133,62 @@ 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, + }; +} + +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. + * + * 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, 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())); + }; +} + /** * 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 @@ -137,10 +197,49 @@ export function changeSelectedCallNode( export function changeRightClickedCallNode( threadsKey: ThreadsKey, callNodePath: CallNodePath | null +): ThunkAction { + return (dispatch, getState) => { + const isInverted = getInvertCallstack(getState()); + dispatch({ + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', + callNodePath, + }); + }; +} + +export function changeRightClickedFunctionIndex( + threadsKey: ThreadsKey, + functionIndex: IndexIntoFuncTable | null +): Action { + return { + type: 'CHANGE_RIGHT_CLICKED_FUNCTION', + threadsKey, + functionIndex, + }; +} + +export function changeLowerWingRightClickedCallNode( + threadsKey: ThreadsKey, + callNodePath: CallNodePath | null ): Action { return { type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', threadsKey, + area: 'LOWER_WING', + callNodePath, + }; +} + +export function changeUpperWingRightClickedCallNode( + threadsKey: ThreadsKey, + callNodePath: CallNodePath | null +) { + return { + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: 'UPPER_WING', callNodePath, }; } @@ -207,6 +306,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. @@ -1545,12 +1678,37 @@ 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 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, @@ -1615,6 +1773,31 @@ export function changeMarkersSearchString(searchString: string): Action { }; } +export function changeMarkerTableSort(sort: SingleColumnSortState[]): Action { + return { + type: 'CHANGE_MARKER_TABLE_SORT', + sort, + }; +} + +export function changeFunctionListSort(sort: SingleColumnSortState[]): Action { + return { + type: 'CHANGE_FUNCTION_LIST_SORT', + sort, + }; +} + +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', @@ -2015,6 +2198,7 @@ export function toggleBottomBoxFullscreen(): ThunkAction { export function handleCallNodeTransformShortcut( event: React.KeyboardEvent, threadsKey: ThreadsKey, + callNodeInfo: CallNodeInfo, callNodeIndex: IndexIntoCallNodeTable ): ThunkAction { return (dispatch, getState) => { @@ -2023,7 +2207,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); @@ -2133,3 +2316,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/actions/receive-profile.ts b/src/actions/receive-profile.ts index 7eb4182cdd..2882b01c95 100644 --- a/src/actions/receive-profile.ts +++ b/src/actions/receive-profile.ts @@ -1152,7 +1152,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) { @@ -1355,6 +1355,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/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 02b2907421..5212ea8a5e 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -42,6 +42,8 @@ import type { IndexIntoFrameTable, MarkerIndex, SelectedMarkersPerThread, + SelectedFunctionsPerThread, + FunctionListSectionsOpenState, } from 'firefox-profiler/types'; import { decodeUintArrayFromUrlComponent, @@ -52,8 +54,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 @@ -119,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) { @@ -131,6 +151,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': @@ -185,11 +207,15 @@ type CallTreeQuery = BaseQuery & { invertCallstack: null | undefined; hideIdleSamples: null | undefined; 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 & { 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 +254,12 @@ type Query = BaseQuery & { // Markers specific markerSearch?: string; marker?: MarkerIndex; + markerSort?: string; + + // Function list specific + functionListSort?: string; + funcListSections?: string; + selectedFunc?: number; // Network specific networkSearch?: string; @@ -264,6 +296,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': @@ -335,6 +375,7 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { : undefined; /* fallsthrough */ case 'flame-graph': + case 'function-list': case 'calltree': { query = baseQuery as CallTreeQueryShape; @@ -382,6 +423,22 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.bottomFullscreen = true; } } + if (selectedTab === 'function-list') { + query.functionListSort = convertFunctionListSortToString( + urlState.profileSpecific.functionListSort + ); + 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; } case 'marker-table': @@ -394,6 +451,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; @@ -449,6 +509,7 @@ export function ensureIsValidDataSource( case 'public': case 'from-url': case 'compare': + case 'compare-benchmark': case 'uploaded-recordings': return coercedDataSource; default: @@ -541,6 +602,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))) { @@ -632,10 +706,165 @@ export function stateFromLocation( ? query.hiddenThreads.split('-').map((index) => Number(index)) : null, selectedMarkers, + selectedFunctions, + markerTableSort: convertMarkerTableSortFromString(query.markerSort), + functionListSort: convertFunctionListSortFromString( + query.functionListSort + ), + functionListSectionsOpen: convertFunctionListSectionsOpenFromString( + query.funcListSections + ), }, }; } +// 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(); +} + +// 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(); +} + +// 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[] { @@ -1443,6 +1672,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/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..17a7970f18 --- /dev/null +++ b/src/components/app/BenchmarkCompareViewer.css @@ -0,0 +1,291 @@ +.benchmarkCompareViewer { + /* 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%; + 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; +} + +/* 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 new file mode 100644 index 0000000000..1067018e0b --- /dev/null +++ b/src/components/app/BenchmarkCompareViewer.tsx @@ -0,0 +1,591 @@ +/* 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, useMemo } 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 type { Profile } from 'firefox-profiler/types'; +import { BucketFlameGraphPair } from './BucketFlameGraphPair'; +import { + makeBucketProfileBundle, + makeSuiteFilteredThread, +} from 'firefox-profiler/profile-logic/benchmark/bucket-flame-graph-data'; +import type { BucketProfileBundle } from 'firefox-profiler/profile-logic/benchmark/bucket-flame-graph-data'; +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<{ + 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 !== 'PROFILE') { + throw new Error('Expected a profile, not a zip file.'); + } + return unserializeProfileOfArbitraryFormat(response.profile, 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, + baseStats.bucketFuncs, + newStats.bucketFuncs, + baseSuite.iterationCount + ); + return [{ suiteName: baseSuite.suiteName, comparisons }]; + }); + suiteComparisons.sort((a, b) => a.suiteName.localeCompare(b.suiteName)); + + return { + baseUrl, + newUrl, + baseProfile, + newProfile, + 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, + baseBundle, + newBundle, +}: { + overallScore: ScoreComparison; + suiteScores: ScoreComparison[]; + suiteComparisonsByName: Map; + baseBundle: BucketProfileBundle; + newBundle: BucketProfileBundle; +}) { + 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, + baseBundle, + newBundle, +}: { + 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; + baseBundle: BucketProfileBundle; + newBundle: BucketProfileBundle; +}) { + const showSubtestColumns = + baseSubtestMean !== undefined && numSuites !== undefined; + const columnCount = showSubtestColumns ? 6 : 5; + + // Build per-suite bundles whose `thread.samples.weight` is zeroed outside + // this suite's iteration markers, so flame graphs reflect only the samples + // that contribute to this suite's score. + const baseSuiteBundle = useMemo( + () => withSuiteFilteredThread(baseBundle, label), + [baseBundle, label] + ); + const newSuiteBundle = useMemo( + () => withSuiteFilteredThread(newBundle, label), + [newBundle, label] + ); + + 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') + .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 = ( + + ); + } + // 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 ( + + toggle(c.bucketName) : undefined} + > + + + + + {pctCells} + + {expandable && isExpanded && ( + + + + )} + + ); + })} + +
+ {formatChange(subtestRel)} + + {formatChange(overallRel)} + + {formatChange(c.relChange)} +
+ {expandable && ( + + )} + {c.bucketName} + + {c.baseMean.toFixed(2)} + + {c.newMean.toFixed(2)} + {absDiffStr}
+ +
+ ); +} + +/** Return a copy of `bundle` whose `thread` has sample weights zeroed outside + * this suite's iteration markers (matching the filtering applied to the suite + * count). All other bundle fields are shared with the input. */ +function withSuiteFilteredThread( + bundle: BucketProfileBundle, + suiteName: string +): BucketProfileBundle { + return { ...bundle, thread: makeSuiteFilteredThread(bundle, suiteName) }; +} + +function ComparisonResults({ data }: { data: ComparisonData }) { + const suiteComparisonsByName = new Map( + data.suiteComparisons.map(({ suiteName, comparisons }) => [ + suiteName, + comparisons, + ]) + ); + + const baseBundle = useMemo( + () => makeBucketProfileBundle(data.baseProfile, 'speedometer'), + [data.baseProfile] + ); + const newBundle = useMemo( + () => makeBucketProfileBundle(data.newProfile, 'speedometer'), + [data.newProfile] + ); + + 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/BucketFlameGraphPair.tsx b/src/components/app/BucketFlameGraphPair.tsx new file mode 100644 index 0000000000..3b317bdf4d --- /dev/null +++ b/src/components/app/BucketFlameGraphPair.tsx @@ -0,0 +1,160 @@ +/* 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, + BucketProfileBundle, +} from 'firefox-profiler/profile-logic/benchmark/bucket-flame-graph-data'; +import type { + IndexIntoFuncTable, + IndexIntoCallNodeTable, +} from 'firefox-profiler/types'; + +export type { BucketProfileBundle }; + +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/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/Details.tsx b/src/components/app/Details.tsx index 643e6c5929..0f58c97955 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/'; @@ -26,6 +27,8 @@ 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 { LowerWingContextMenu } from 'firefox-profiler/components/shared/LowerWingContextMenu'; import { MaybeMarkerContextMenu } from 'firefox-profiler/components/shared/MarkerContextMenu'; import { toValidTabSlug } from 'firefox-profiler/utils/types'; @@ -122,6 +125,7 @@ class ProfileViewerImpl extends PureComponent { { { calltree: , + 'function-list': , 'flame-graph': , 'stack-chart': , 'marker-chart': , @@ -133,6 +137,8 @@ class ProfileViewerImpl extends PureComponent { + + ); diff --git a/src/components/app/DetailsContainer.css b/src/components/app/DetailsContainer.css index 78bd724119..507a124fcf 100644 --- a/src/components/app/DetailsContainer.css +++ b/src/components/app/DetailsContainer.css @@ -8,22 +8,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/app/MenuButtons/index.tsx b/src/components/app/MenuButtons/index.tsx index fc2d965154..141ec5bba1 100644 --- a/src/components/app/MenuButtons/index.tsx +++ b/src/components/app/MenuButtons/index.tsx @@ -108,6 +108,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/components/calltree/Butterfly.css b/src/components/calltree/Butterfly.css new file mode 100644 index 0000000000..62f236e0cb --- /dev/null +++ b/src/components/calltree/Butterfly.css @@ -0,0 +1,55 @@ +/* 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; +} + +/* 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; +} + +.functionListTreeWrapper { + display: flex; + flex-flow: column nowrap; +} + +.functionListTreeWrapper .treeRowToggleButton { + display: none; +} 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 e593b01644..e01ba4b0d0 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; @@ -106,95 +107,11 @@ class CallTreeImpl extends PureComponent { (weightType: WeightType): MaybeResizableColumn[] => { switch (weightType) { case 'tracing-ms': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 50, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--tracing-ms-total', - minWidth: 30, - initialWidth: 70, - resizable: true, - headerWidthAdjustment: 50, - }, - { - propName: 'self', - titleL10nId: 'CallTree--tracing-ms-self', - minWidth: 30, - initialWidth: 70, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 10, - }, - ]; + return treeColumnsForTracingMs; case 'samples': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 50, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--samples-total', - minWidth: 30, - initialWidth: 70, - resizable: true, - headerWidthAdjustment: 50, - }, - { - propName: 'self', - titleL10nId: 'CallTree--samples-self', - minWidth: 30, - initialWidth: 70, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 10, - }, - ]; + return treeColumnsForSamples; case 'bytes': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 50, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--bytes-total', - minWidth: 30, - initialWidth: 140, - resizable: true, - headerWidthAdjustment: 50, - }, - { - propName: 'self', - titleL10nId: 'CallTree--bytes-self', - minWidth: 30, - initialWidth: 90, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 10, - }, - ]; + return treeColumnsForBytes; default: throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); } @@ -275,6 +192,7 @@ class CallTreeImpl extends PureComponent { rightClickedCallNodeIndex, handleCallNodeTransformShortcut, threadsKey, + callNodeInfo, } = this.props; const nodeIndex = rightClickedCallNodeIndex !== null @@ -283,7 +201,7 @@ class CallTreeImpl extends PureComponent { if (nodeIndex === null) { return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { @@ -408,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..b3cc7357ea --- /dev/null +++ b/src/components/calltree/FunctionList.tsx @@ -0,0 +1,293 @@ +/* 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, + handleFunctionTransformShortcut, +} 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 handleFunctionTransformShortcut: typeof handleFunctionTransformShortcut; + 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(); + this.maybeProcureInitialSelection(); + + 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(); + } + } + + 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(); + } + } + + _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, + handleFunctionTransformShortcut, + } = this.props; + const funcIndex = + rightClickedFunctionIndex !== null + ? rightClickedFunctionIndex + : selectedFunctionIndex; + if (funcIndex === null) { + return; + } + handleFunctionTransformShortcut(event, threadsKey, funcIndex); + }; + + _onEnterOrDoubleClick = (nodeId: IndexIntoFuncTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForFunction(nodeId); + updateBottomBoxContentsAndMaybeOpen('function-list', 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, + handleFunctionTransformShortcut, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + changeFunctionListSort, + }, + component: FunctionListImpl, +}); diff --git a/src/components/calltree/LowerWing.tsx b/src/components/calltree/LowerWing.tsx new file mode 100644 index 0000000000..f57957a422 --- /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('function-list', 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 new file mode 100644 index 0000000000..1ba34afb0a --- /dev/null +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -0,0 +1,105 @@ +/* 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 explicitConnect from 'firefox-profiler/utils/connect'; +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'; +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'; + +type StateProps = { + readonly sectionsOpen: FunctionListSectionsOpenState; +}; + +type DispatchProps = { + readonly changeFunctionListSectionOpen: typeof changeFunctionListSectionOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +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/calltree/SelfWing.tsx b/src/components/calltree/SelfWing.tsx new file mode 100644 index 0000000000..4f7fc26a87 --- /dev/null +++ b/src/components/calltree/SelfWing.tsx @@ -0,0 +1,231 @@ +/* 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, + changeRightClickedFunctionIndex, +} 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; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; +}; + +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 }); + const { callNodeInfo, threadsKey, changeRightClickedFunctionIndex } = + this.props; + const funcIndex = + callNodeIndex !== null ? callNodeInfo.funcForNode(callNodeIndex) : null; + changeRightClickedFunctionIndex(threadsKey, funcIndex); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('function-list', 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, + changeRightClickedFunctionIndex, + }, + component: SelfWingImpl, +}); diff --git a/src/components/calltree/UpperWing.tsx b/src/components/calltree/UpperWing.tsx new file mode 100644 index 0000000000..f03a545ed4 --- /dev/null +++ b/src/components/calltree/UpperWing.tsx @@ -0,0 +1,351 @@ +/* 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 { 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, + 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('function-list', 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' }); + } + } + + _renderCallTree() { + const { + tree, + selectedCallNodeIndex, + rightClickedCallNodeIndex, + expandedCallNodeIndexes, + searchStringsRegExp, + disableOverscan, + callNodeMaxDepthPlusOne, + weightType, + tableViewOptions, + onTableViewOptionsChange, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } + + override render() { + 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/components/calltree/UpperWingFlameGraph.tsx b/src/components/calltree/UpperWingFlameGraph.tsx new file mode 100644 index 0000000000..9d95d35bd0 --- /dev/null +++ b/src/components/calltree/UpperWingFlameGraph.tsx @@ -0,0 +1,265 @@ +/* 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, + changeRightClickedFunctionIndex, + 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 changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; + 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, + changeRightClickedFunctionIndex, + } = this.props; + changeUpperWingRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + const funcIndex = + callNodeIndex !== null ? callNodeInfo.funcForNode(callNodeIndex) : null; + changeRightClickedFunctionIndex(threadsKey, funcIndex); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('function-list', 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, + changeRightClickedFunctionIndex, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + component: UpperWingFlameGraphImpl, +}); diff --git a/src/components/calltree/columns.ts b/src/components/calltree/columns.ts new file mode 100644 index 0000000000..556b874f0e --- /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, + }, + { + 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, + }, + { + 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, + }, + { + propName: 'selfPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'self', + titleL10nId: 'CallTree--tracing-ms-self', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55, + }, + { + 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, + }, + { + propName: 'selfPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'self', + titleL10nId: 'CallTree--samples-self', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55, + }, + { + 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, + }, + { + propName: 'selfPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'self', + titleL10nId: 'CallTree--bytes-self', + minWidth: 30, + initialWidth: 90, + resizable: true, + headerWidthAdjustment: 55, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; 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/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 376d0f3025..d76d079f2d 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,21 @@ type StateProps = { readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; readonly tracedTiming: CallTreeTimings | null; readonly displayStackType: boolean; + readonly contextMenuId?: string; + 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 +99,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) { @@ -169,7 +121,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; }; @@ -194,7 +146,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 { @@ -215,13 +167,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 +183,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 +191,7 @@ class FlameGraphImpl case 'ArrowDown': { const prefix = callNodeTable.prefix[selectedCallNodeIndex]; if (prefix !== -1) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(prefix) - ); + onSelectedCallNodeChange(prefix); } break; } @@ -257,10 +203,7 @@ class FlameGraphImpl // thus the widest box. if (callNodeIndex !== undefined && this._wideEnough(callNodeIndex)) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -272,10 +215,7 @@ class FlameGraphImpl ); if (callNodeIndex !== undefined) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -298,11 +238,11 @@ class FlameGraphImpl } if (event.key === 'Enter') { - this._onCallNodeEnterOrDoubleClick(nodeIndex); + onCallNodeEnterOrDoubleClick(nodeIndex); return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + onKeyboardTransformShortcut(event, nodeIndex); }; _onCopy = (event: ClipboardEvent) => { @@ -343,6 +283,10 @@ class FlameGraphImpl ctssSampleCategoriesAndSubcategories, tracedTiming, displayStackType, + contextMenuId = 'CallNodeContextMenu', + onSelectedCallNodeChange, + onRightClickedCallNodeChange, + onCallNodeEnterOrDoubleClick, } = this.props; // Get the CallTreeTimingsNonInverted out of tracedTiming. We pass this @@ -363,7 +307,7 @@ class FlameGraphImpl return (
({ - 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 ef281aa4c5..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; diff --git a/src/components/marker-table/index.tsx b/src/components/marker-table/index.tsx index 2ecaad7e11..fcb761bc00 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 { TreeView, ColumnSortState } 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,17 @@ import type { TableViewOptions, SelectionContext, } from 'firefox-profiler/types'; +import type { SingleColumnSortState, 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,6 +82,16 @@ class MarkerTree { this._getMarkerLabel = getMarkerLabel; } + static _sortableColumns: SortableColumn[] = [ + { name: 'start', prefersDescending: false }, + { name: 'duration', prefersDescending: true }, + { name: 'name', prefersDescending: false }, + ]; + + getSortableColumns(): SortableColumn[] { + return MarkerTree._sortableColumns; + } + copyTable = ( format: 'plain' | 'markdown', onExceeedMaxCopyRows: (rows: number, maxRows: number) => void @@ -167,12 +186,49 @@ class MarkerTree { 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() : []; + _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); + } + } + + getChildren( + markerIndex: MarkerIndex, + sort: ColumnSortState | null = null + ): MarkerIndex[] { + return markerIndex === -1 ? this.getRoots(sort) : []; } hasChildren(_markerIndex: MarkerIndex): boolean { @@ -205,10 +261,11 @@ class MarkerTree { } 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 = { @@ -238,12 +295,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>; @@ -279,6 +338,11 @@ class MarkerTableImpl extends PureComponent { _takeTreeViewRef = (treeView: TreeView | null) => (this._treeView = treeView); + _getSortedColumns = memoize( + (sort: SingleColumnSortState[]) => + new ColumnSortState(sort.length > 0 ? sort : DEFAULT_MARKER_TABLE_SORT) + ); + getMarkerTree = memoize( ( getMarker: any, @@ -330,6 +394,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); @@ -380,6 +448,8 @@ class MarkerTableImpl extends PureComponent { indentWidth={10} viewOptions={this.props.tableViewOptions} onViewOptionsChange={this.props.onTableViewOptionsChange} + sortedColumns={this._getSortedColumns(this.props.sort)} + onColumnSortChange={this._onColumnSortChange} /> )}
@@ -400,12 +470,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/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..13ba2ad891 --- /dev/null +++ b/src/components/shared/DisclosureBox.tsx @@ -0,0 +1,59 @@ +/* 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 isOpen?: boolean; + readonly onToggle?: (isOpen: boolean) => void; + readonly children: React.ReactNode; +}; + +type State = { + isOpen: boolean; +}; + +export class DisclosureBox extends React.PureComponent { + override state: State = { + isOpen: this.props.initialOpen ?? true, + }; + + _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, isOpen: controlledOpen } = this.props; + const isOpen = + controlledOpen !== undefined ? controlledOpen : this.state.isOpen; + + return ( +
+ + {isOpen ? ( +
{children}
+ ) : null} +
+ ); + } +} diff --git a/src/components/shared/FunctionListContextMenu.tsx b/src/components/shared/FunctionListContextMenu.tsx new file mode 100644 index 0000000000..aa4bdc1069 --- /dev/null +++ b/src/components/shared/FunctionListContextMenu.tsx @@ -0,0 +1,507 @@ +/* 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 selfWingCallNodeTable: 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; + readonly selfWingCallNodeTable: CallNodeTable | null; + } { + const { + thread, + threadsKey, + rightClickedFunctionIndex, + callNodeTable, + selfWingCallNodeTable, + } = this.props; + if ( + thread !== null && + threadsKey !== null && + rightClickedFunctionIndex !== null && + callNodeTable !== null + ) { + return { + thread, + threadsKey, + funcIndex: rightClickedFunctionIndex, + callNodeTable, + selfWingCallNodeTable, + }; + } + 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, selfWingCallNodeTable } = 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) || + (selfWingCallNodeTable !== null && + funcHasRecursiveCall(selfWingCallNodeTable, 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) || + (selfWingCallNodeTable !== null && + funcHasDirectRecursiveCall(selfWingCallNodeTable, 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; + let selfWingCallNodeTable = 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(); + // 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 { + thread, + threadsKey, + rightClickedFunctionIndex, + callNodeTable, + selfWingCallNodeTable, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + }, + mapDispatchToProps: { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, + }, + component: FunctionListContextMenuImpl, +}); 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/components/shared/TreeView.css b/src/components/shared/TreeView.css index 8bcb1f74a0..df59931eb8 100644 --- a/src/components/shared/TreeView.css +++ b/src/components/shared/TreeView.css @@ -58,8 +58,7 @@ } .treeViewHeader { - height: 15px; - padding: 4px 0; + height: 23px; border-bottom: 1px solid var(--base-border-color); background: var(--raised-background-color); color: var(--raised-foreground-color); @@ -113,38 +112,87 @@ } .treeViewHeaderColumn { - position: relative; line-height: 15px; white-space: nowrap; } +.treeViewHeaderColumn, +.treeViewSortButton, +.treeViewRowColumn, +.treeViewRowScrolledColumns { + box-sizing: border-box; + padding-right: 5px; + padding-left: 5px; +} + .treeViewFixedColumn { overflow: hidden; text-overflow: ellipsis; } -.treeViewColumnDivider { +.treeViewSortButton { + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: inherit; +} + +.treeViewSortButton:active:hover { + background-color: var(--clickable-ghost-active-background-color); +} + +.treeViewSortButton.sortInactive::after { + content: ' ▲'; + opacity: 0; +} + +.treeViewSortButton.sortAscending::after { + content: ' ▲'; +} + +.treeViewSortButton.sortDescending::after { + content: ' ▼'; +} + +.treeViewHeaderColumnDivider, +.treeViewRowColumnDivider { display: flex; - width: 20px; + width: 1px; flex: none; align-items: stretch; justify-content: center; + padding-right: 5px; + padding-left: 5px; margin-right: -5px; margin-left: -5px; } -.treeViewColumnDivider.isResizable, +.treeViewHeaderColumn, +.treeViewHeaderColumnDivider { + padding-top: 4px; + padding-bottom: 4px; +} + +.treeViewHeaderColumnDivider.isResizable { + position: relative; + z-index: 2; +} + +.treeViewHeaderColumnDivider.isResizable, .treeView.isResizingColumns { cursor: col-resize; } -.treeViewColumnDivider::before { +.treeViewRowColumnDivider::before, +.treeViewHeaderColumnDivider::before { border-right: 1px solid var(--base-border-color); content: ''; } -.treeViewColumnDivider.isResizable::before { +.treeViewHeaderColumnDivider.isResizable::before { width: 1px; + flex-shrink: 0; border-left: 1px solid var(--base-border-color); } diff --git a/src/components/shared/TreeView.tsx b/src/components/shared/TreeView.tsx index a6eb6e374d..1b084fec0a 100644 --- a/src/components/shared/TreeView.tsx +++ b/src/components/shared/TreeView.tsx @@ -51,6 +51,60 @@ export type Column> = { }>; }; +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,18 +173,54 @@ 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 ? ( {col.hideDividerAfter !== true ? ( - + ) : null} ); @@ -380,14 +487,9 @@ class TreeViewRowScrolledColumns< ) : null } {displayData.badge ? ( {appendageColumn ? ( {reactStringWithHighlightedSubstrings( displayData[appendageColumn.propName], @@ -429,14 +531,21 @@ class TreeViewRowScrolledColumns< } } +export type SortableColumn = { + name: string; + prefersDescending: boolean; +}; + 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> = { @@ -465,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 = { @@ -472,6 +583,8 @@ type TreeViewState = { readonly isResizingColumns: boolean; }; +const EMPTY_SORT_STATE = new ColumnSortState([]); + export class TreeView< DisplayData extends Record, > extends React.PureComponent, TreeViewState> { @@ -485,7 +598,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, @@ -493,6 +606,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; @@ -522,6 +639,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) @@ -612,7 +734,11 @@ export class TreeView< }; _computeAllVisibleRowsMemoized = memoize( - (tree: Tree, expandedNodes: Set) => { + ( + tree: Tree, + expandedNodes: Set, + sortedColumns: ColumnSortState + ) => { function _addVisibleRowsFromNode( tree: Tree, expandedNodes: Set, @@ -623,13 +749,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]); @@ -721,7 +847,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] { @@ -909,7 +1039,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] ); } } @@ -934,6 +1067,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() { @@ -954,6 +1106,7 @@ export class TreeView< selectedNodeId, } = this.props; const { isResizingColumns } = this.state; + const sortableColumns = this._getSortableColumnNames(this.props.tree); return (
= { } | 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 ? ( | null { return { calltree: CallTreeSidebar, + 'function-list': CallTreeSidebar, 'flame-graph': CallTreeSidebar, 'stack-chart': null, 'marker-chart': null, 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) => { diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx index b0ee5338a6..7b713077b9 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, @@ -37,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'; @@ -99,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; }; @@ -122,6 +125,7 @@ class TimelineTrackThreadImpl extends PureComponent { const { threadsKey, selectSelfCallNode, + selectSelfFunction, focusCallTree, selectedThreadIndexes, callTreeVisible, @@ -129,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) { @@ -349,7 +354,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, @@ -365,6 +372,7 @@ export const TimelineTrackThread = explicitConnect< changeSelectedCallNode, focusCallTree, selectSelfCallNode, + selectSelfFunction, reportTrackThreadHeight, }, component: withSize(TimelineTrackThreadImpl), 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..5e92e1cc8f --- /dev/null +++ b/src/node-tools/compare-benchmark-stats.ts @@ -0,0 +1,223 @@ +/** + * 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') + ); + + // 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) { + 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, + base.bucketFuncs, + newStats.bucketFuncs, + 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, + base.bucketFuncs, + newStats.bucketFuncs, + 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/profiler-edit.ts b/src/node-tools/profiler-edit.ts index 07c56c6133..db3ef85792 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -8,6 +8,7 @@ import { unserializeProfileOfArbitraryFormat } from 'firefox-profiler/profile-lo import { computeCompactedProfile } from 'firefox-profiler/profile-logic/profile-compacting'; import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants'; import { compress } from 'firefox-profiler/utils/gz'; +import { insertStackLabels } from 'firefox-profiler/profile-logic/insert-stack-labels'; import { SymbolStore } from 'firefox-profiler/profile-logic/symbol-store'; import { symbolicateProfile, @@ -19,8 +20,26 @@ import { applyWasmSymbolication, type WasmSymbolicationSpec, } from 'firefox-profiler/profile-logic/wasm-symbolication'; -import type { Profile } from 'firefox-profiler/types/profile'; +import { mergeThreads } from 'firefox-profiler/profile-logic/merge-compare'; +import { getTimeRangeForThread } from 'firefox-profiler/profile-logic/profile-data'; +import { + correlateIPCMarkers, + deriveMarkersFromRawMarkerTable, + getSearchFilteredMarkerIndexes, + stringsToMarkerRegExps, +} from 'firefox-profiler/profile-logic/marker-data'; +import { markerSchemaFrontEndOnly } from 'firefox-profiler/profile-logic/marker-schema'; +import { getDefaultCategories } from 'firefox-profiler/profile-logic/data-structures'; +import { StringTable } from 'firefox-profiler/utils/string-table'; +import { splitSearchString } from 'firefox-profiler/utils/string'; +import type { MarkerSchemaByName } from 'firefox-profiler/types/markers'; +import type { Profile, RawThread } from 'firefox-profiler/types/profile'; +import type { StartEndRange } from 'firefox-profiler/types/units'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { + parseLabelToml, + resolveAllLabels, +} from 'firefox-profiler/utils/label-templates'; /** * A CLI tool for editing profiles. @@ -38,6 +57,13 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; * node node-tools-dist/profiler-edit.js -i input.json.gz -o out.json.gz \ * --symbolicate-wasm http://host/a.wasm=./a-unstripped.wasm \ * --symbolicate-wasm http://host/b.wasm=./b-unstripped.wasm + * + * 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' \ + * --merge-non-overlapping-threads-by-name */ type ProfileSource = @@ -61,6 +87,10 @@ export interface CliOptions { output: string; symbolicateWithServer?: string; symbolicateWasm: WasmSymbolicationCliSpec[]; + insertLabelFrames?: string; + onlyKeepThreadsWithMarkersMatching?: string; + mergeNonOverlappingThreadsByName?: boolean; + setName?: string; } function loadWasmSymbolicationSpecs( @@ -77,6 +107,283 @@ function loadWasmSymbolicationSpecs( }); } +/** + * Reconstruct the func-name strings used by insertStackLabels' prefix matcher + * (mirrors getLabelIndexForFunc in insert-stack-labels.ts), so auto-discovery + * sees the same strings the labeler will compare against. + */ +function collectFuncNames(profile: Profile): string[] { + const { funcTable, sources, stringArray } = profile.shared; + const result: string[] = []; + for (let i = 0; i < funcTable.length; i++) { + let name = stringArray[funcTable.name[i]]; + const sourceIndex = funcTable.source[i]; + if (sourceIndex !== null) { + const filename = stringArray[sources.filename[sourceIndex]]; + const line = funcTable.lineNumber[i]; + const col = funcTable.columnNumber[i]; + if (line !== null && col !== null) { + name += ` (${filename}:${line}:${col})`; + } else if (line !== null) { + name += ` (${filename}:${line})`; + } else { + name += ` (${filename})`; + } + } + result.push(name); + } + return result; +} + +/** + * Keep only the threads that have at least one marker matching the given + * marker search string (using the same syntax as the front-end: comma- + * separated terms, optional `field:value` and `-field:value` qualifiers). + * We derive markers and run the standard search filter so that string-table + * indexed payload fields (UserTiming.name, IPC fields, ...) are resolved + * correctly. + */ +function filterThreadsByMarkerSearch( + profile: Profile, + search: string +): Profile { + const searchRegExps = stringsToMarkerRegExps(splitSearchString(search)); + if (searchRegExps === null) { + return profile; + } + + 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 threads = profile.threads.filter((thread) => { + const { markers } = deriveMarkersFromRawMarkerTable( + thread.markers, + profile.shared.stringArray, + thread.tid, + getTimeRangeForThread(thread, profile.meta.interval), + ipcCorrelations + ); + if (markers.length === 0) { + return false; + } + const markerIndexes = markers.map((_, i) => i); + const filtered = getSearchFilteredMarkerIndexes( + (i) => markers[i], + markerIndexes, + markerSchemaByName, + searchRegExps, + stringTable, + categoryList + ); + return filtered.length > 0; + }); + + return { ...profile, threads }; +} + +/** + * 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. + */ +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: RawThread['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 }; +} + async function loadProfile(source: ProfileSource): Promise { switch (source.type) { case 'FILE': { @@ -129,7 +436,7 @@ async function loadProfile(source: ProfileSource): Promise { } export async function run(options: CliOptions) { - const profile = await loadProfile(options.input); + let profile = await loadProfile(options.input); if (options.symbolicateWithServer !== undefined) { const server = options.symbolicateWithServer; @@ -183,6 +490,37 @@ export async function run(options: CliOptions) { loadWasmSymbolicationSpecs(options.symbolicateWasm) ); + if (options.insertLabelFrames !== undefined) { + console.log('Inserting label frames...'); + const tomlText = fs.readFileSync(options.insertLabelFrames, 'utf8'); + const parsed = parseLabelToml(tomlText); + const funcNames = collectFuncNames(profile); + const labels = resolveAllLabels(parsed, funcNames); + profile = insertStackLabels(profile, labels); + } + + if ( + options.onlyKeepThreadsWithMarkersMatching !== undefined && + options.onlyKeepThreadsWithMarkersMatching !== '' + ) { + const before = profile.threads.length; + profile = filterThreadsByMarkerSearch( + profile, + options.onlyKeepThreadsWithMarkersMatching + ); + console.log( + `Kept ${profile.threads.length} of ${before} threads with markers matching ${JSON.stringify(options.onlyKeepThreadsWithMarkersMatching)}.` + ); + } + + if (options.mergeNonOverlappingThreadsByName) { + profile = mergeNonOverlappingThreadsByName(profile); + } + + if (options.setName !== undefined) { + profile.meta.product = options.setName; + } + const { profile: compactedProfile } = computeCompactedProfile(profile); console.log(`Saving profile to ${options.output}`); @@ -200,6 +538,7 @@ export async function run(options: CliOptions) { export function makeOptionsFromArgv(processArgv: string[]): CliOptions { const argv = minimist(processArgv.slice(2), { alias: { i: 'input', o: 'output' }, + boolean: ['merge-non-overlapping-threads-by-name'], }); const sources: ProfileSource[] = []; @@ -265,6 +604,26 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { } } + const rawMarkerArg = argv['only-keep-threads-with-markers-matching']; + let onlyKeepThreadsWithMarkersMatching: string | undefined; + if (rawMarkerArg !== undefined) { + if (typeof rawMarkerArg !== 'string' || rawMarkerArg === '') { + throw new Error( + '--only-keep-threads-with-markers-matching requires a value (use `=` syntax for values starting with `-`, e.g. --only-keep-threads-with-markers-matching=-async,-sync)' + ); + } + onlyKeepThreadsWithMarkersMatching = rawMarkerArg; + } + + const rawSetName = argv['set-name']; + let setName: string | undefined; + if (rawSetName !== undefined) { + if (typeof rawSetName !== 'string' || rawSetName === '') { + throw new Error('--set-name requires a non-empty value'); + } + setName = rawSetName; + } + return { input: sources[0], output: argv.output, @@ -274,6 +633,15 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { ? argv['symbolicate-with-server'] : undefined, symbolicateWasm, + insertLabelFrames: + typeof argv['insert-label-frames'] === 'string' && + argv['insert-label-frames'] !== '' + ? argv['insert-label-frames'] + : undefined, + onlyKeepThreadsWithMarkersMatching, + mergeNonOverlappingThreadsByName: + argv['merge-non-overlapping-threads-by-name'] === true, + setName, }; } 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..812ccd65f1 --- /dev/null +++ b/src/profile-logic/benchmark/benchmark-stuff.ts @@ -0,0 +1,591 @@ +/* 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'; + +export 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; +}; + +/** + * Compute per-suite sample weights, filtered to (already-applied measured time + * ranges) ∩ (this suite's iteration marker ranges). The input weights are + * `measuredSamples.weight` (i.e. weights with -async/-sync filtering and + * ignored-bucket zeroing already applied). The output zeroes out any weight + * outside this suite's iteration markers, so the flame graph for this suite + * reflects exactly the same samples that the suite's score counts. + * + * Iteration markers are assumed to be sorted by start time and non-overlapping + * (matching the assumption in `computeSuiteScores`). + */ +export function computeSuiteFilteredSampleWeights( + measuredSampleWeights: Float64Array, + sampleTimes: Float64Array, + iterationMarkers: Marker[] +): Float64Array { + const filtered = measuredSampleWeights.slice(); + const ranges: StartEndRange[] = []; + for (const m of iterationMarkers) { + if (m.end !== null) { + ranges.push({ start: m.start, end: m.end }); + } + } + zeroWeightsOutsideRanges(filtered, sampleTimes, ranges); + return filtered; +} + +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); +} + +export 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/bucket-flame-graph-data.ts b/src/profile-logic/benchmark/bucket-flame-graph-data.ts new file mode 100644 index 0000000000..3bfc7195e7 --- /dev/null +++ b/src/profile-logic/benchmark/bucket-flame-graph-data.ts @@ -0,0 +1,358 @@ +/* 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, + getTimeRangeForThread, +} 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 { + correlateIPCMarkers, + deriveMarkersFromRawMarkerTable, +} from '../marker-data'; +import { + computeSuiteFilteredSampleWeights, + getBenchmarkInfo, + zeroWeightsOutsideRanges, +} from './benchmark-stuff'; +import type { BenchmarkHarness, BenchmarkInfo } from './benchmark-stuff'; + +import type { + Marker, + 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, + }; +} + +/** 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. Also carries the benchmark marker + * info needed to lazily build per-suite filtered threads. */ +export type BucketProfileBundle = { + profile: Profile; + thread: Thread; + categories: CategoryList; + defaultCategory: IndexIntoCategoryList; + benchmarkInfo: BenchmarkInfo; + /** `thread.samples.time` as a Float64Array, for fast range filtering. */ + sampleTimes: Float64Array; + /** Sample weights with the global -async/-sync measured-time filter applied, + * matching the `measuredSamples.weight` used by score computation. */ + measuredSampleWeights: Float64Array; + /** Iteration markers per suite name. Sorted by start time, non-overlapping. */ + markersPerSuite: Map; +}; + +export function makeBucketProfileBundle( + profile: Profile, + benchmarkHarness: BenchmarkHarness +): BucketProfileBundle { + const categories = getCategoriesForProfile(profile); + const defaultCategory = getDefaultCategoryIndex(categories); + const benchmarkInfo = getBenchmarkInfo(profile, benchmarkHarness); + const thread = buildDerivedThread( + profile, + benchmarkInfo.threadIndex, + categories, + defaultCategory + ); + + const { shared } = profile; + const rawThread = profile.threads[benchmarkInfo.threadIndex]; + const stringTable = StringTable.withBackingArray(shared.stringArray); + const { markers: derivedMarkers } = deriveMarkersFromRawMarkerTable( + rawThread.markers, + shared.stringArray, + rawThread.tid, + getTimeRangeForThread(rawThread, profile.meta.interval), + correlateIPCMarkers(profile.threads, shared) + ); + + const sampleCount = thread.samples.length; + const sampleTimes = new Float64Array(thread.samples.time); + const measuredSampleWeights = thread.samples.weight + ? new Float64Array(thread.samples.weight) + : new Float64Array(sampleCount).fill(1); + const measuredTimeRanges = benchmarkInfo.getMeasuredTimeRanges( + derivedMarkers, + stringTable + ); + if (measuredTimeRanges !== null) { + zeroWeightsOutsideRanges( + measuredSampleWeights, + sampleTimes, + measuredTimeRanges + ); + } + + const markersPerSuite = benchmarkInfo.getMarkersPerSuite( + derivedMarkers, + stringTable + ); + + return { + profile, + thread, + categories, + defaultCategory, + benchmarkInfo, + sampleTimes, + measuredSampleWeights, + markersPerSuite, + }; +} + +/** + * Return a Thread that shares all tables with `bundle.thread` but has sample + * weights zeroed outside this suite's iteration marker ranges. The flame graph + * built from this thread then reflects only the samples that contribute to + * this suite's score (matching `computeSuiteScores`). + */ +export function makeSuiteFilteredThread( + bundle: BucketProfileBundle, + suiteName: string +): Thread { + const { thread, sampleTimes, measuredSampleWeights, markersPerSuite } = + bundle; + const iterationMarkers = markersPerSuite.get(suiteName) ?? []; + const filteredWeights = computeSuiteFilteredSampleWeights( + measuredSampleWeights, + sampleTimes, + iterationMarkers + ); + return { + ...thread, + samples: { + ...thread.samples, + weight: Array.from(filteredWeights), + weightType: thread.samples.weightType ?? 'samples', + }, + }; +} 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..be8ded7f65 --- /dev/null +++ b/src/profile-logic/benchmark/compare-benchmark-stats.ts @@ -0,0 +1,222 @@ +/** + * 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'; +import type { IndexIntoFuncTable } from '../../types/profile'; + +// --------------------------------------------------------------------------- +// Comparison logic +// --------------------------------------------------------------------------- + +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 */ + relChange: number; + cliffdsDelta: number; + effectSize: EffectSize; + confidence: ConfidenceRating; +}; + +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[], + bucketFuncs: IndexIntoFuncTable[] +): Map { + const map = new Map(); + for (const entry of buckets) { + const name = + bucketNames[entry.bucketIndex] ?? `bucket#${entry.bucketIndex}`; + const func = bucketFuncs[entry.bucketIndex]; + let weight = 0; + for (const v of entry.iterationTotals) weight += v; + const existing = map.get(name); + if (existing !== undefined) { + // 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, { + iterationTotals: entry.iterationTotals.slice(), + representativeFunc: func, + representativeWeight: weight, + }); + } + } + 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[], + baseBucketFuncs: IndexIntoFuncTable[], + newBucketFuncs: IndexIntoFuncTable[], + iterationCount: number, + excludeAppearedDisappeared: boolean = false +): BucketComparison[] { + const baseMap = buildNameMap(baseBuckets, baseBucketNames, baseBucketFuncs); + const newMap = buildNameMap(newBuckets, newBucketNames, newBucketFuncs); + + 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 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); + + 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, + baseFunc: baseEntry?.representativeFunc ?? null, + newFunc: newEntry?.representativeFunc ?? null, + 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..9df34388a0 --- /dev/null +++ b/src/profile-logic/benchmark/extract-benchmark-stats.ts @@ -0,0 +1,266 @@ +/** + * 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[]; + /** + * 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. + * 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, bucketFuncs, 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/profile-logic/bottom-box.ts b/src/profile-logic/bottom-box.ts index 70ff159167..e736b0d031 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, getTotalNativeSymbolTimingsForCallNode, @@ -189,3 +191,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 fe026267cd..031a4a6ecc 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -38,7 +38,14 @@ 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 { getBottomBoxInfoForCallNode } from './bottom-box'; +import type { + ColumnSortState, + SortableColumn, +} from '../components/shared/TreeView'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from './bottom-box'; type CallNodeChildren = IndexIntoCallNodeTable[]; @@ -47,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 }; @@ -105,7 +113,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; @@ -171,10 +180,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( @@ -216,11 +227,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( @@ -288,13 +300,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( @@ -390,11 +408,42 @@ export class CallTree { this._weightType = weightType; } + getSortableColumns(): SortableColumn[] { + if (this._internal instanceof CallTreeInternalFunctionList) { + return [ + { name: 'total', prefersDescending: true }, + { name: 'self', prefersDescending: true }, + ]; + } + return []; + } + getTotal(): number { 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; } @@ -445,7 +494,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; @@ -497,7 +547,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); @@ -535,6 +585,7 @@ export class CallTree { self ); const totalPercent = `${formatPercent(totalRelative)}`; + const selfPercent = `${formatPercent(selfRelative)}`; let ariaLabel; let totalWithUnit; @@ -585,6 +636,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. @@ -626,6 +678,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. * @@ -686,7 +746,7 @@ export function computeCallNodeSelfAndSummary( rootTotalSummary += abs(callNodeSelf[callNodeIndex]); } - return { callNodeSelf, rootTotalSummary }; + return { callNodeSelf, rootTotalSummary, flameGraphTotalForScaling: rootTotalSummary }; } export function getSelfAndTotalForCallNode( @@ -825,6 +885,60 @@ 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, + flameGraphTotalForScaling: rootTotalSummary, + }), + }; +} + export function computeCallTreeTimings( callNodeInfo: CallNodeInfo, callNodeSelfAndSummary: CallNodeSelfAndSummary @@ -857,7 +971,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); @@ -895,6 +1009,7 @@ export function computeCallTreeTimingsNonInverted( total: callNodeTotal, callNodeHasChildren, rootTotalSummary, + flameGraphTotalForScaling, }; } @@ -1297,5 +1412,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/profile-logic/insert-stack-labels.ts b/src/profile-logic/insert-stack-labels.ts new file mode 100644 index 0000000000..c2dd358779 --- /dev/null +++ b/src/profile-logic/insert-stack-labels.ts @@ -0,0 +1,208 @@ +/* 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 { + IndexIntoFrameTable, + IndexIntoStackTable, + RawStackTable, + IndexIntoFuncTable, + Profile, +} from '../types/profile'; +import { + shallowCloneFrameTable, + shallowCloneFuncTable, +} from 'firefox-profiler/profile-logic/data-structures'; +import { StringTable } from 'firefox-profiler/utils/string-table'; +import { updateRawThreadStacks } from 'firefox-profiler/profile-logic/profile-data'; + +export type LabelDescription = { + name: string; + funcPrefixes: string[]; +}; + +export function insertStackLabels( + profile: Profile, + labelDescriptions: LabelDescription[] +): Profile { + const labelCategory = profile.meta.categories!.length; + profile.meta.categories!.push({ + name: 'Label', + color: 'blue', + subcategories: ['Other'], + }); + + const { + funcTable: oldFuncTable, + frameTable: oldFrameTable, + stackTable: oldStackTable, + sources, + stringArray, + } = profile.shared; + const frameTable = shallowCloneFrameTable(oldFrameTable); + const funcTable = shallowCloneFuncTable(oldFuncTable); + const stringTable = StringTable.withBackingArray(stringArray); + const unaccountedLabelFrameIndex = frameTable.length; + const labelFramesStartIndex = unaccountedLabelFrameIndex + 1; + const allLabelNames = [ + 'Unaccounted', + ...labelDescriptions.map((label) => label.name), + ]; + for (let i = 0; i < allLabelNames.length; i++) { + const labelName = allLabelNames[i]; + const funcIndex = funcTable.length++; + funcTable.name[funcIndex] = stringTable.indexForString(labelName); + funcTable.resource[funcIndex] = -1; + funcTable.source[funcIndex] = null; + funcTable.lineNumber[funcIndex] = null; + funcTable.columnNumber[funcIndex] = null; + funcTable.isJS[funcIndex] = false; + funcTable.relevantForJS[funcIndex] = true; + + const frameIndex = frameTable.length++; + frameTable.func[frameIndex] = funcIndex; + frameTable.category[frameIndex] = labelCategory; + frameTable.subcategory[frameIndex] = 0; + frameTable.nativeSymbol[frameIndex] = null; + frameTable.address[frameIndex] = 0; + frameTable.inlineDepth[frameIndex] = 0; + frameTable.line[frameIndex] = null; + frameTable.column[frameIndex] = null; + frameTable.innerWindowID[frameIndex] = null; + } + + function getLabelIndexForFunc(funcIndex: IndexIntoFuncTable): number | null { + let nameString = stringArray[funcTable.name[funcIndex]]; + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex !== null) { + const filenameString = stringArray[sources.filename[sourceIndex]]; + const line = funcTable.lineNumber[funcIndex]; + const col = funcTable.columnNumber[funcIndex]; + if (line !== null && col !== null) { + nameString += ` (${filenameString}:${line}:${col})`; + } else if (line !== null) { + nameString += ` (${filenameString}:${line})`; + } else { + nameString += ` (${filenameString})`; + } + } + for ( + let labelIndex = 0; + labelIndex < labelDescriptions.length; + labelIndex++ + ) { + const labelDescription = labelDescriptions[labelIndex]; + for ( + let prefixIndex = 0; + prefixIndex < labelDescription.funcPrefixes.length; + prefixIndex++ + ) { + const funcNamePrefix = labelDescription.funcPrefixes[prefixIndex]; + if (nameString.startsWith(funcNamePrefix)) { + return labelIndex; + } + } + } + return null; + } + + const funcIndexToLabelFrameIndex = new Array(funcTable.length); + for (let funcIndex = 0; funcIndex < funcTable.length; funcIndex++) { + const labelIndex = getLabelIndexForFunc(funcIndex); + const labelFrameIndex = + labelIndex !== null ? labelFramesStartIndex + labelIndex : null; + funcIndexToLabelFrameIndex[funcIndex] = labelFrameIndex; + } + + const labelFrameIndexToInsertAtStack = new Array( + oldStackTable.length + ); + const inheritedLabelFrameIndexAtStack = new Array( + oldStackTable.length + ); + let stacksToInsertCount = 0; + for (let stackIndex = 0; stackIndex < oldStackTable.length; stackIndex++) { + const parentStackIndex = oldStackTable.prefix[stackIndex]; + const inheritedLabelFrameIndex = + parentStackIndex !== null + ? inheritedLabelFrameIndexAtStack[parentStackIndex] + : null; + const frameIndex = oldStackTable.frame[stackIndex]; + const funcIndex = oldFrameTable.func[frameIndex]; + const labelFrameIndex = funcIndexToLabelFrameIndex[funcIndex]; + if ( + labelFrameIndex !== null && + labelFrameIndex !== inheritedLabelFrameIndex + ) { + labelFrameIndexToInsertAtStack[stackIndex] = labelFrameIndex; + inheritedLabelFrameIndexAtStack[stackIndex] = labelFrameIndex; + stacksToInsertCount++; + } else if ( + funcTable.isJS[funcIndex] || + funcTable.relevantForJS[funcIndex] + ) { + labelFrameIndexToInsertAtStack[stackIndex] = null; + inheritedLabelFrameIndexAtStack[stackIndex] = null; + } else if (parentStackIndex === null) { + labelFrameIndexToInsertAtStack[stackIndex] = unaccountedLabelFrameIndex; + inheritedLabelFrameIndexAtStack[stackIndex] = unaccountedLabelFrameIndex; + stacksToInsertCount++; + } else { + labelFrameIndexToInsertAtStack[stackIndex] = null; + inheritedLabelFrameIndexAtStack[stackIndex] = inheritedLabelFrameIndex; + } + } + + const newStackCount = oldStackTable.length + stacksToInsertCount; + const newPrefixCol = new Array(newStackCount); + const newFrameCol = new Array(newStackCount); + const oldStackToNewStackPlusOne = new Int32Array(oldStackTable.length); + let nextNewStackIndex = 0; + for ( + let oldStackIndex = 0; + oldStackIndex < oldStackTable.length; + oldStackIndex++ + ) { + const labelFrameIndexToInsert = + labelFrameIndexToInsertAtStack[oldStackIndex]; + const oldPrefix = oldStackTable.prefix[oldStackIndex]; + let newPrefix = + oldPrefix !== null ? oldStackToNewStackPlusOne[oldPrefix] - 1 : null; + const frameIndex = oldStackTable.frame[oldStackIndex]; + if (labelFrameIndexToInsert !== null) { + const insertedStackIndex = nextNewStackIndex++; + newPrefixCol[insertedStackIndex] = newPrefix; + newFrameCol[insertedStackIndex] = labelFrameIndexToInsert; + newPrefix = insertedStackIndex; + } + const newStackIndex = nextNewStackIndex++; + newPrefixCol[newStackIndex] = newPrefix; + newFrameCol[newStackIndex] = frameIndex; + oldStackToNewStackPlusOne[oldStackIndex] = newStackIndex + 1; + } + + if (nextNewStackIndex !== newStackCount) { + console.error('Unexpected new stack count!', { + nextNewStackIndex, + newStackCount, + stacksToInsertCount, + }); + } + + const stackTable: RawStackTable = { + prefix: newPrefixCol, + frame: newFrameCol, + length: newStackCount, + }; + + const newShared = { ...profile.shared, stackTable, frameTable, funcTable }; + const newThreads = updateRawThreadStacks(profile.threads, (oldStack) => + oldStack !== null ? oldStackToNewStackPlusOne[oldStack] - 1 : null + ); + + return { + ...profile, + shared: newShared, + threads: newThreads, + }; +} diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index 269fc4fc46..6a87f013b4 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -1275,6 +1275,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; @@ -1324,11 +1334,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; } diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 80e3395805..e97b9ca700 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -181,7 +181,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( @@ -228,7 +231,6 @@ type CallNodeTableHierarchy = { // there are no call nodes. firstRoot: IndexIntoCallNodeTable; length: number; - stackIndexToCallNodeIndex: Int32Array; }; /** @@ -293,7 +295,7 @@ type CallNodeTableExtraColumns = { function _computeCallNodeTableHierarchy( stackTable: StackTable, frameTable: FrameTable -): CallNodeTableHierarchy { +): CallNodeTableHierarchy & { stackIndexToCallNodeIndex: Int32Array } { const stackIndexToCallNodeIndex = new Int32Array(stackTable.length); // The callNodeTable components. @@ -444,7 +446,8 @@ function _computeCallNodeTableHierarchy( * siblings as what's in the `hierarchy` argument.) */ function _computeCallNodeTableDFSOrder( - hierarchy: CallNodeTableHierarchy + hierarchy: CallNodeTableHierarchy, + stackIndexToCallNodeIndex: Int32Array ): CallNodeTableDFSOrder { const { prefix, @@ -452,7 +455,6 @@ function _computeCallNodeTableDFSOrder( firstRoot, nextSibling, length, - stackIndexToCallNodeIndex, } = hierarchy; const prefixSorted = new Int32Array(length); @@ -547,6 +549,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. * @@ -636,6 +746,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. */ @@ -944,6 +1129,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. @@ -1132,6 +1357,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. @@ -1434,6 +1718,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 @@ -4577,3 +4896,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/profile-logic/zip-files.ts b/src/profile-logic/zip-files.ts index 78b451ea5c..6804b546ac 100644 --- a/src/profile-logic/zip-files.ts +++ b/src/profile-logic/zip-files.ts @@ -5,6 +5,7 @@ import { ensureIsValidTabSlug, objectEntries } from '../utils/types'; import type JSZip from 'jszip'; import type { JSZipObject } from 'jszip'; +import type { SortableColumn } from '../components/shared/TreeView'; export type IndexIntoZipFileTable = number; /** @@ -113,6 +114,10 @@ export class ZipFileTree { this._parentToChildren.set(null, this._computeChildrenArray(null)); } + getSortableColumns(): SortableColumn[] { + return []; + } + getRoots(): IndexIntoZipFileTable[] { return this.getChildren(null); } diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index 9a611c1f69..9902dd5de8 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -29,6 +29,7 @@ import type { ThreadsKey, Milliseconds, TableViewOptions, + RightClickedFunction, } from 'firefox-profiler/types'; import { applyFuncSubstitutionToCallPath, @@ -36,7 +37,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) { @@ -139,8 +140,12 @@ const symbolicationStatus: Reducer = ( export const defaultThreadViewOptions: ThreadViewOptions = { selectedNonInvertedCallNodePath: [], selectedInvertedCallNodePath: [], + selectedLowerWingCallNodePath: [], + selectedUpperWingCallNodePath: [], expandedNonInvertedCallNodePaths: new PathSet(), expandedInvertedCallNodePaths: new PathSet(), + expandedLowerWingCallNodePaths: new PathSet(), + expandedUpperWingCallNodePaths: new PathSet(), selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -207,7 +212,7 @@ const viewOptionsPerThread: Reducer = ( } case 'CHANGE_SELECTED_CALL_NODE': { const { - isInverted, + area, selectedCallNodePath, threadsKey, optionalExpandedToCallNodePath, @@ -215,9 +220,12 @@ 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, + UPPER_WING: threadState.selectedUpperWingCallNodePath, + }[area]; // If the selected node doesn't actually change, let's return the previous // state to avoid rerenders. @@ -228,9 +236,12 @@ 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, + UPPER_WING: threadState.expandedUpperWingCallNodePaths, + }[area]; const expandToNode = optionalExpandedToCallNodePath ? optionalExpandedToCallNodePath : selectedCallNodePath; @@ -254,19 +265,66 @@ 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, + }); + case 'UPPER_WING': + return _updateThreadViewOptions(state, threadsKey, { + selectedUpperWingCallNodePath: selectedCallNodePath, + expandedUpperWingCallNodePaths: expandedCallNodePaths, + }); + default: + throw assertExhaustiveCheck(area, 'Unhandled case'); + } + } + case 'CHANGE_SELECTED_FUNCTION': { + const { selectedFunctionIndex, threadsKey } = action; + + const threadState = _getThreadViewOptions(state, threadsKey); + + const previousLowerWingPath = threadState.selectedLowerWingCallNodePath; + const isSameSelection = + selectedFunctionIndex === null + ? previousLowerWingPath.length === 0 + : previousLowerWingPath.length === 1 && + previousLowerWingPath[0] === selectedFunctionIndex; + + if (isSameSelection) { + return state; + } + + if (selectedFunctionIndex !== null) { + return _updateThreadViewOptions(state, threadsKey, { + selectedLowerWingCallNodePath: [selectedFunctionIndex], + expandedLowerWingCallNodePaths: new PathSet([ + [selectedFunctionIndex], + ]), + selectedUpperWingCallNodePath: [selectedFunctionIndex], + expandedUpperWingCallNodePaths: new PathSet([ + [selectedFunctionIndex], + ]), + }); + } + + return _updateThreadViewOptions(state, threadsKey, { + selectedLowerWingCallNodePath: [], + expandedLowerWingCallNodePaths: new PathSet(), + selectedUpperWingCallNodePath: [], + expandedUpperWingCallNodePaths: new PathSet(), + }); } case 'CHANGE_INVERT_CALLSTACK': { const { @@ -302,16 +360,29 @@ 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, + }); + case 'UPPER_WING': + return _updateThreadViewOptions(state, threadsKey, { + expandedUpperWingCallNodePaths: expandedCallNodePaths, + }); + default: + throw assertExhaustiveCheck(area, 'Unhandled case'); + } } case 'CHANGE_SELECTED_NETWORK_MARKER': { const { threadsKey, selectedNetworkMarker } = action; @@ -392,8 +463,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; @@ -587,8 +698,11 @@ 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': case 'CHANGE_SELECTED_MARKER': case 'CHANGE_SELECTED_NETWORK_MARKER': if (action.context.source === 'pointer') { @@ -711,6 +825,7 @@ const rightClickedCallNode: Reducer = ( if (action.callNodePath !== null) { return { threadsKey: action.threadsKey, + area: action.area, callNodePath: action.callNodePath, }; } @@ -734,6 +849,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 @@ -834,6 +997,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 724b545b9f..6cb466b7f9 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -24,9 +24,12 @@ import type { IsOpenPerPanelState, TabID, SelectedMarkersPerThread, + SelectedFunctionsPerThread, + FunctionListSectionsOpenState, } 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'; @@ -56,6 +59,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 +92,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; @@ -193,6 +199,48 @@ 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 functionListSort: Reducer = ( + state = [], + action +) => { + switch (action.type) { + case 'CHANGE_FUNCTION_LIST_SORT': + return action.sort; + default: + return state; + } +}; + +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': @@ -751,6 +799,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. */ @@ -780,6 +848,10 @@ const profileSpecific = combineReducers({ showJsTracerSummary, tabFilter, selectedMarkers, + selectedFunctions, + 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/per-thread/index.ts b/src/selectors/per-thread/index.ts index 6b777e2ef5..e8d9776be8 100644 --- a/src/selectors/per-thread/index.ts +++ b/src/selectors/per-thread/index.ts @@ -256,3 +256,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 d39239f75b..2fc75ca04b 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'; @@ -42,6 +43,10 @@ import type { CallNodeSelfAndSummary, State, CallNodeTableBitSet, + IndexIntoFuncTable, + IndexIntoStackTable, + SamplesLikeTable, + SampleCategoriesAndSubcategories, } from 'firefox-profiler/types'; import type { CallNodeInfo, @@ -144,6 +149,8 @@ export function getStackAndSampleSelectorsPerThread( const _getCallNodeTable: Selector = (state) => _getNonInvertedCallNodeInfo(state).getCallNodeTable(); + const getLowerWingCallNodeInfo = _getInvertedCallNodeInfo; + const _getCallNodeFuncIsDuplicate: Selector = createSelector( _getCallNodeTable, @@ -202,6 +209,27 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSelectedFunctionIndex: Selector = ( + state + ) => UrlState.getSelectedFunction(state, threadsKey); + + 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, + (threadViewOptions): CallNodePath => + threadViewOptions.selectedLowerWingCallNodePath + ); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -211,6 +239,13 @@ export function getStackAndSampleSelectorsPerThread( : threadViewOptions.selectedNonInvertedCallNodePath ); + const getUpperWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): CallNodePath => + threadViewOptions.selectedUpperWingCallNodePath + ); + const getSelectedCallNodeIndex: Selector = createSelector( getCallNodeInfo, @@ -220,6 +255,24 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getLowerWingSelectedCallNodeIndex: Selector = + createSelector( + getLowerWingCallNodeInfo, + getLowerWingSelectedCallNodePath, + (callNodeInfo, callNodePath) => { + return callNodeInfo.getCallNodeIndexFromPath(callNodePath); + } + ); + + const getUpperWingSelectedCallNodeIndex: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingSelectedCallNodePath, + (callNodeInfo, callNodePath) => { + return callNodeInfo.getCallNodeIndexFromPath(callNodePath); + } + ); + const getExpandedCallNodePaths: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -229,6 +282,16 @@ export function getStackAndSampleSelectorsPerThread( : threadViewOptions.expandedNonInvertedCallNodePaths ); + const getLowerWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedLowerWingCallNodePaths + ); + + const getUpperWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedUpperWingCallNodePaths + ); + const getExpandedCallNodeIndexes: Selector< Array > = createSelector( @@ -240,6 +303,28 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getLowerWingExpandedCallNodeIndexes: Selector< + Array + > = createSelector( + getLowerWingCallNodeInfo, + getLowerWingExpandedCallNodePaths, + (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + + const getUpperWingExpandedCallNodeIndexes: Selector< + Array + > = createSelector( + getUpperWingCallNodeInfo, + getUpperWingExpandedCallNodePaths, + (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + const _getSampleIndexToNonInvertedCallNodeIndexForPreviewFilteredCtssThread: Selector< Array > = createSelector( @@ -259,6 +344,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( @@ -286,6 +392,19 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSampleSelectedStatesForFunctionListTab: Selector = + createSelector( + getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, + _getCallNodeTable, + getSelectedFunctionIndex, + (sampleCallNodes, callNodeTable, selectedFunctionIndex) => + ProfileData.getSamplesSelectedStatesForFunction( + sampleCallNodes, + selectedFunctionIndex, + callNodeTable + ) + ); + const getTreeOrderComparatorInFilteredThread: Selector< ( sampleIndexA: IndexIntoSamplesTable, @@ -329,12 +448,57 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingCallNodeSelfAndSummary: Selector = + createSelector( + threadSelectors.getPreviewFilteredCtssSamples, + _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex, + getUpperWingCallNodeInfo, + getCallNodeSelfAndSummary, + ( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo, + regularTreeSelfAndSummary + ) => { + const { rootTotalSummary } = regularTreeSelfAndSummary; + const upperWingSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + 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 }; + } + ); + const getCallTreeTimings: Selector = createSelector( getCallNodeInfo, getCallNodeSelfAndSummary, CallTree.computeCallTreeTimings ); + const _getLowerWingCallTreeTimings: Selector = + createSelector( + _getInvertedCallNodeInfo, + getCallNodeSelfAndSummary, + getSelectedFunctionIndex, + CallTree.computeLowerWingTimings + ); + + const _getUpperWingCallTreeTimings: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimings + ); + const getCallTreeTimingsNonInverted: Selector = createSelector( getCallNodeInfo, @@ -388,6 +552,26 @@ 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, + ProfileSelectors.getCategories, + threadSelectors.getPreviewFilteredCtssSamples, + _getLowerWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + const getSourceViewLineTimings: Selector = createSelector( getSourceViewStackLineInfo, threadSelectors.getPreviewFilteredCtssSamples, @@ -474,6 +658,153 @@ 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 + ); + + // 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, + 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 = + 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, @@ -482,6 +813,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, + getLowerWingCallNodeInfo, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.threadsKey === threadsKey && + rightClickedCallNodeInfo.area === 'LOWER_WING' ) { return callNodeInfo.getCallNodeIndexFromPath( rightClickedCallNodeInfo.callNodePath @@ -492,23 +847,103 @@ 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 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, + (profileViewOptions) => { + const rightClickedFunctionInfo = + profileViewOptions.rightClickedFunction; + if ( + rightClickedFunctionInfo !== null && + threadsKey === rightClickedFunctionInfo.threadsKey + ) { + return rightClickedFunctionInfo.functionIndex; + } + + return null; + } + ); + return { unfilteredSamplesRange, getWeightTypeForCallTree, getCallNodeInfo, + getLowerWingCallNodeInfo, + getUpperWingCallNodeInfo, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getLowerWingSelectedCallNodePath, + getLowerWingSelectedCallNodeIndex, + getUpperWingSelectedCallNodePath, + getUpperWingSelectedCallNodeIndex, + getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, + getLowerWingExpandedCallNodePaths, + getLowerWingExpandedCallNodeIndexes, + getUpperWingExpandedCallNodePaths, + getUpperWingExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, + getSampleSelectedStatesForFunctionListTab, getTreeOrderComparatorInFilteredThread, getCallTree, getFunctionListTree, getFunctionListTimings, + getLowerWingCallTree, + getUpperWingCallTree, + getUpperWingFlameGraphTiming, + getSelfWingThread, + getSelfWingCallNodeInfo, + getSelfWingCallTree, + getSelfWingFlameGraphTiming, + getSelfWingCallNodeMaxDepthPlusOne, + getSelfWingCtssSamples, + getSelfWingCtssSampleCategoriesAndSubcategories, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, @@ -518,5 +953,9 @@ export function getStackAndSampleSelectorsPerThread( getFilteredCallNodeMaxDepthPlusOne, getFlameGraphTiming, getRightClickedCallNodeIndex, + getRightClickedFunctionIndex, + getLowerWingRightClickedCallNodeIndex, + getLowerWingRightClickedFuncIndex, + getUpperWingRightClickedCallNodeIndex, }; } diff --git a/src/selectors/right-clicked-call-node.tsx b/src/selectors/right-clicked-call-node.tsx index a72dc73d35..a9d03cff46 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/selectors/url-state.ts b/src/selectors/url-state.ts index bf2bc8fe65..7c6b4a3e4f 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -32,10 +32,13 @@ import type { TabID, IndexIntoSourceTable, MarkerIndex, + IndexIntoFuncTable, + FunctionListSectionsOpenState, } from 'firefox-profiler/types'; 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 +120,13 @@ 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 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) => @@ -245,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/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/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/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'); + }); + }); +}); diff --git a/src/test/components/MarkerTable.test.tsx b/src/test/components/MarkerTable.test.tsx index 9f9f16b6df..34be6a8d1f 100644 --- a/src/test/components/MarkerTable.test.tsx +++ b/src/test/components/MarkerTable.test.tsx @@ -476,9 +476,11 @@ describe('MarkerTable', function () { ); let dividerForFirstColumn = ensureExists( - document.querySelector('.treeViewColumnDivider') + document.querySelector('.treeViewHeaderColumnDivider') + ); + let firstColumn = ensureExists( + document.querySelector('.treeViewHeaderColumn.start') ); - let firstColumn = screen.getByText('Start'); expect(firstColumn).toHaveStyle({ width: '90px' }); fireEvent.mouseDown(dividerForFirstColumn, { clientX: 90 }); @@ -505,12 +507,14 @@ 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. dividerForFirstColumn = ensureExists( - document.querySelector('.treeViewColumnDivider') + document.querySelector('.treeViewHeaderColumnDivider') ); fireEvent.dblClick(dividerForFirstColumn, { detail: 2 }); expect(firstColumn).toHaveStyle({ width: '90px' }); 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, + }; + }); + }); + } +}); 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`] = ` +
+ +
+`; diff --git a/src/test/components/__snapshots__/MarkerTable.test.tsx.snap b/src/test/components/__snapshots__/MarkerTable.test.tsx.snap index 9e9856f6e5..23540016d5 100644 --- a/src/test/components/__snapshots__/MarkerTable.test.tsx.snap +++ b/src/test/components/__snapshots__/MarkerTable.test.tsx.snap @@ -205,34 +205,43 @@ exports[`MarkerTable renders some basic markers and updates when needed 1`] = `
- Start - + - Duration - + - Name - +
@@ -534,7 +543,7 @@ exports[`MarkerTable renders some basic markers and updates when needed 1`] = ` class="treeRowToggleButton collapsed leaf" /> foobar @@ -555,7 +564,7 @@ exports[`MarkerTable renders some basic markers and updates when needed 1`] = ` class="treeRowToggleButton collapsed leaf" />