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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/profile-logic/bottom-box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
}
13 changes: 12 additions & 1 deletion src/profile-logic/call-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down Expand Up @@ -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.
*
Expand Down
41 changes: 41 additions & 0 deletions src/profile-logic/profile-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this is squashed to the incorrect commit. It should be squashed to the first one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

oops, fixed

}
}
return funcFramePerStack;
}

/**
* Take a samples table, and return an array that contain indexes that point to the
* leaf most call node, or null.
Expand Down
51 changes: 50 additions & 1 deletion src/profile-query/function-annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
import {
getNativeSymbolInfo,
getNativeSymbolsForFunc,
getFunctionFramePerStack,
getTotalNativeSymbolTimingsForCallNode,
findAddressProofForFile,
getOriginalPositionForFrame,
computeFuncTableFromRawFuncTable,
Expand Down Expand Up @@ -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<IndexIntoNativeSymbolTable>,
thread: Thread,
samples: SamplesLikeTable
): Set<IndexIntoNativeSymbolTable> {
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<IndexIntoNativeSymbolTable>,
thread: Thread,
Expand All @@ -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,
Expand Down Expand Up @@ -408,6 +456,7 @@ export async function functionAnnotate(
const asmPromise: Promise<AsmAnnotationsResult> =
mode === 'asm' || mode === 'all'
? fetchAsmAnnotations(
funcIndex,
functionHandle,
nativeSymbolsForFunc,
thread,
Expand Down
56 changes: 55 additions & 1 deletion src/test/store/bottom-box.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
33 changes: 33 additions & 0 deletions src/test/unit/profile-query/function-annotate.test.ts
Original file line number Diff line number Diff line change
@@ -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}`));
});
Loading