diff --git a/src/profile-logic/bottom-box.ts b/src/profile-logic/bottom-box.ts index 95831f996f..f857ca95a2 100644 --- a/src/profile-logic/bottom-box.ts +++ b/src/profile-logic/bottom-box.ts @@ -8,12 +8,14 @@ import type { Thread, IndexIntoStackTable, IndexIntoCallNodeTable, + IndexIntoFuncTable, BottomBoxInfo, SamplesLikeTable, } from 'firefox-profiler/types'; import type { CallNodeInfo } from './call-node-info'; import { getCallNodeFramePerStack, + getFunctionFramePerStack, getNativeSymbolInfo, getNativeSymbolsForCallNode, getOriginalPositionForFrame, @@ -176,3 +178,97 @@ export function getBottomBoxInfoForStackFrame( highlightedInstructionAddress: instructionAddress, }; } + +/** + * 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, nativeSymbols } = + thread; + + const { source: sourceIndex, line: funcLine } = getOriginalPositionForFrame( + null, + funcIndex, + frameTable, + funcTable, + thread.sourceLocationTable + ); + + 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 lineTimings = getTotalLineTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + funcTable, + funcLine, + thread.sourceLocationTable + ); + const hottestLine = mapGetKeyWithMaxValue(lineTimings); + const addressTimings = getTotalAddressTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + initialNativeSymbol + ); + const hottestInstructionAddress = mapGetKeyWithMaxValue(addressTimings); + + return { + 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 c4a6360119..f870e0f703 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -39,7 +39,10 @@ import * as ProfileData from './profile-data'; import type { CallTreeSummaryStrategy } from '../types/actions'; import type { CallNodeInfo, CallNodeInfoInverted } from './call-node-info'; import type { SortableColumn } from '../components/shared/TreeView'; -import { getBottomBoxInfoForCallNode } from './bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from './bottom-box'; type CallNodeChildren = IndexIntoCallNodeTable[]; @@ -652,6 +655,14 @@ export class CallTree { ); } + getBottomBoxInfoForFunction(funcIndex: IndexIntoFuncTable): BottomBoxInfo { + return getBottomBoxInfoForFunction( + funcIndex, + this._thread, + this._previewFilteredCtssSamples + ); + } + /** * Take a IndexIntoCallNodeTable, and compute an inverted path for it. * diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index baed4507e9..01ebf66d3c 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -969,6 +969,47 @@ 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 !== -1 ? funcFramePerStack[prefix] : -1; + } + } + return funcFramePerStack; +} + /** * Take a samples table, and return an array that contain indexes that point to the * leaf most call node, or null. diff --git a/src/profile-query/function-annotate.ts b/src/profile-query/function-annotate.ts index 48c5ce012f..c14765e4ab 100644 --- a/src/profile-query/function-annotate.ts +++ b/src/profile-query/function-annotate.ts @@ -19,6 +19,8 @@ import { import { getNativeSymbolInfo, getNativeSymbolsForFunc, + getFunctionFramePerStack, + getTotalNativeSymbolTimingsForCallNode, findAddressProofForFile, getOriginalPositionForFrame, computeFuncTableFromRawFuncTable, @@ -226,7 +228,40 @@ async function fetchSourceAnnotation( }; } +// Computing address timings and fetching assembly is done per native symbol, +// and costs a pass over the whole stack table each. Functions that are inlined +// in many places can have tens of thousands of native symbols, so only the +// heaviest ones are annotated. +const MAX_ASM_NATIVE_SYMBOLS = 10; + +export function selectHeaviestNativeSymbols( + funcIndex: IndexIntoFuncTable, + nativeSymbolsForFunc: Set, + thread: Thread, + samples: SamplesLikeTable +): Set { + const { stackTable, frameTable } = thread; + const funcFramePerStack = getFunctionFramePerStack( + funcIndex, + stackTable, + frameTable + ); + const weightPerSymbol = getTotalNativeSymbolTimingsForCallNode( + samples, + funcFramePerStack, + frameTable + ); + return new Set( + Array.from(nativeSymbolsForFunc) + .sort( + (a, b) => (weightPerSymbol.get(b) ?? 0) - (weightPerSymbol.get(a) ?? 0) + ) + .slice(0, MAX_ASM_NATIVE_SYMBOLS) + ); +} + async function fetchAsmAnnotations( + funcIndex: IndexIntoFuncTable, functionHandle: string, nativeSymbolsForFunc: Set, thread: Thread, @@ -245,8 +280,21 @@ async function fetchAsmAnnotations( const { stackTable, frameTable, funcTable: threadFuncTable } = thread; const nativeSymbolCount = nativeSymbolsForFunc.size; + let symbolsToAnnotate = nativeSymbolsForFunc; + if (nativeSymbolCount > MAX_ASM_NATIVE_SYMBOLS) { + symbolsToAnnotate = selectHeaviestNativeSymbols( + funcIndex, + nativeSymbolsForFunc, + thread, + samples + ); + warnings.push( + `Function ${functionHandle} has ${nativeSymbolCount} native symbols; showing only the ${MAX_ASM_NATIVE_SYMBOLS} with the most samples.` + ); + } + const results = await Promise.all( - Array.from(nativeSymbolsForFunc).map(async (nsIndex) => { + Array.from(symbolsToAnnotate).map(async (nsIndex) => { const nativeSymbolInfo = getNativeSymbolInfo( nsIndex, thread.nativeSymbols, @@ -408,6 +456,7 @@ export async function functionAnnotate( const asmPromise: Promise = mode === 'asm' || mode === 'all' ? fetchAsmAnnotations( + funcIndex, functionHandle, nativeSymbolsForFunc, thread, diff --git a/src/test/store/bottom-box.test.ts b/src/test/store/bottom-box.test.ts index 64037f2faa..7d3ef96ae3 100644 --- a/src/test/store/bottom-box.test.ts +++ b/src/test/store/bottom-box.test.ts @@ -8,7 +8,10 @@ import * as UrlStateSelectors from '../../selectors/url-state'; import * as ProfileSelectors from '../../selectors/profile'; import { selectedThreadSelectors } from '../../selectors/per-thread'; import { emptyAddressTimings } from '../../profile-logic/address-timings'; -import { getBottomBoxInfoForCallNode } from '../../profile-logic/bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from '../../profile-logic/bottom-box'; import { changeSelectedCallNode, updateBottomBoxContentsAndMaybeOpen, @@ -372,3 +375,54 @@ describe('bottom box', function () { // - A test with multiple threads: Open the assembly view for a symbol, switch // to a different thread, check timings }); + +describe('getBottomBoxInfoForFunction', function () { + it('uses the innermost frame when a function appears multiple times due to recursion (A->B->A->B)', function () { + // Stack: A[line:20] -> B[line:30] -> A[line:21] -> B[line:31] + // B appears at line 30 (outer) and line 31 (inner/leaf). + // Double-clicking B in the function list should use line 31 (innermost). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + A[file:a.js][line:21] + B[file:b.js][line:31] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction( + B, + thread, + thread.samples + ); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); + + it('uses the innermost frame when a function appears multiple times due to recursion via another function (A->B->C->B->D)', function () { + // Stack: A[line:20] -> B[line:30] -> C[line:40] -> B[line:31] -> D[line:50] + // B appears at line 30 (outer) and line 31 (inner), with C and D in between. + // Double-clicking B in the function list should use line 31 (innermost B). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + C[file:c.js][line:40] + B[file:b.js][line:31] + D[file:d.js][line:50] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction( + B, + thread, + thread.samples + ); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); +}); diff --git a/src/test/unit/profile-query/function-annotate.test.ts b/src/test/unit/profile-query/function-annotate.test.ts new file mode 100644 index 0000000000..d16fbbd8b7 --- /dev/null +++ b/src/test/unit/profile-query/function-annotate.test.ts @@ -0,0 +1,33 @@ +/* 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 { selectHeaviestNativeSymbols } from '../../../profile-query/function-annotate'; +import { getNativeSymbolsForFunc } from '../../../profile-logic/profile-data'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; + +it('selects the ten native symbols with the highest sample weights', function () { + const frames = Array.from( + { length: 11 }, + (_, i) => `A[lib:one][sym:s${i}:${i.toString(16)}:1]` + ); + const { derivedThreads, funcNamesDictPerThread } = getProfileFromTextSamples( + [...frames, 'Unrelated'].join(' ') + ); + const [thread] = derivedThreads; + const [{ A }] = funcNamesDictPerThread; + thread.samples.weight = [...frames.map((_, i) => i + 1), 100]; + + const selected = selectHeaviestNativeSymbols( + A, + getNativeSymbolsForFunc(A, thread.frameTable), + thread, + thread.samples + ); + + expect( + [...selected].map((i) => + thread.stringTable.getString(thread.nativeSymbols.name[i]) + ) + ).toEqual(Array.from({ length: 10 }, (_, i) => `s${10 - i}`)); +});