Skip to content

Commit fe3ba38

Browse files
committed
Add a "Merge unaccounted native frames" transform.
This makes it easier to analyze profiles with JIT frames. This transform gets rid of 0x12345 frames in the call tree - but only those that haven't been accounted to a shared library, i.e. only frames which we consider to be "probably JIT" frames. This transform does not merge unsymbolicated frames for which we know what library they belong to. Ideally, we wouldn't have those stray hex addresses in the profiles; either Firefox would filter them out during stack merging, or it should give us the correct memory ranges for JIT functions so that we can account these addresses to the right JS function / JIT trampoline etc. But it's not doing that correctly today. https://bugzilla.mozilla.org/show_bug.cgi?id=1463559 is one of the bugs about this. For now it seems easier to just offer a transform which gets rid of these stray JIT address frames. This new transform doesn't really clutter anything up; users will only know about it if they right-click such a frame / call node. Even if Firefox starts giving us better information for JIT frames, this transform can still be useful. For example, you could see this type of stack frame in profiles that are gathered with samply, on applications that use other JITs or applications which don't have Jitdump instrumentation enabled. Another use case would be if Firefox uses a system library that makes use of jitting, for example a software OpenGL implementation. Those libraries can't be expected to expose their JIT details to our profiler.
1 parent 0b76c99 commit fe3ba38

File tree

8 files changed

+264
-6
lines changed

8 files changed

+264
-6
lines changed

locales/en-US/app.ftl

+12
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ CallNodeContextMenu--transform-merge-call-node = Merge node only
6666
function’s node that called it. It only removes the function from that
6767
specific part of the tree. Any other places the function was called from
6868
will remain in the profile.
69+
CallNodeContextMenu--transform-merge-unaccounted-native-functions = Merge unaccounted native frames
70+
.title =
71+
Removes stack frames such as unsymbolicated JIT frames from the call tree,
72+
and make their callers absorb their cost.
73+
Specifically, this transform merges any native stack frames which were
74+
not accounted to a shared library.
6975
7076
# This is used as the context menu item title for "Focus on function" and "Focus
7177
# on function (inverted)" transforms.
@@ -1083,6 +1089,12 @@ TransformNavigator--merge-call-node = Merge Node: { $item }
10831089
# $item (String) - Name of the function that transform applied to.
10841090
TransformNavigator--merge-function = Merge: { $item }
10851091
1092+
# "Merge unaccounted native frames" transform.
1093+
# See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=merge
1094+
# Corresponds to the context menu item with the l10n ID
1095+
# CallNodeContextMenu--transform-merge-unaccounted-native-functions
1096+
TransformNavigator--merge-unaccounted-native-functions = Merge unaccounted native
1097+
10861098
# "Drop function" transform.
10871099
# See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=drop
10881100
# Variables:

src/actions/profile-view.js

+7
Original file line numberDiff line numberDiff line change
@@ -2078,6 +2078,13 @@ export function handleCallNodeTransformShortcut(
20782078
})
20792079
);
20802080
break;
2081+
case 'U':
2082+
dispatch(
2083+
addTransformToStack(threadsKey, {
2084+
type: 'merge-unaccounted-native-functions',
2085+
})
2086+
);
2087+
break;
20812088
case 'd':
20822089
dispatch(
20832090
addTransformToStack(threadsKey, {

src/components/shared/CallNodeContextMenu.js

+25-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { parseFileNameFromSymbolication } from 'firefox-profiler/utils/special-p
1313
import {
1414
funcHasDirectRecursiveCall,
1515
funcHasRecursiveCall,
16+
isUnaccountedNativeFunction,
1617
} from 'firefox-profiler/profile-logic/transforms';
1718
import { getFunctionName } from 'firefox-profiler/profile-logic/function-info';
1819
import {
@@ -333,6 +334,11 @@ class CallNodeContextMenuImpl extends React.PureComponent<Props> {
333334
funcIndex: selectedFunc,
334335
});
335336
break;
337+
case 'merge-unaccounted-native-functions':
338+
addTransformToStack(threadsKey, {
339+
type: 'merge-unaccounted-native-functions',
340+
});
341+
break;
336342
case 'drop-function':
337343
addTransformToStack(threadsKey, {
338344
type: 'drop-function',
@@ -484,7 +490,7 @@ class CallNodeContextMenuImpl extends React.PureComponent<Props> {
484490

485491
const {
486492
callNodeIndex,
487-
thread: { funcTable },
493+
thread: { funcTable, stringTable },
488494
callNodeInfo,
489495
} = rightClickedCallNodeInfo;
490496

@@ -504,6 +510,11 @@ class CallNodeContextMenuImpl extends React.PureComponent<Props> {
504510
const fileName =
505511
filePath &&
506512
parseFileNameFromSymbolication(filePath).path.match(/[^\\/]+$/)?.[0];
513+
const isProbablyJIT = isUnaccountedNativeFunction(
514+
funcIndex,
515+
funcTable,
516+
stringTable
517+
);
507518
return (
508519
<>
509520
{fileName ? (
@@ -545,6 +556,19 @@ class CallNodeContextMenuImpl extends React.PureComponent<Props> {
545556
content: 'Merge node only',
546557
})}
547558

559+
{isProbablyJIT
560+
? this.renderTransformMenuItem({
561+
l10nId:
562+
'CallNodeContextMenu--transform-merge-unaccounted-native-functions',
563+
shortcut: 'U',
564+
icon: 'Merge',
565+
onClick: this._handleClick,
566+
transform: 'merge-unaccounted-native-functions',
567+
title: '',
568+
content: 'Merge unaccounted native frames',
569+
})
570+
: null}
571+
548572
{this.renderTransformMenuItem({
549573
l10nId: inverted
550574
? 'CallNodeContextMenu--transform-focus-function-inverted'

src/profile-logic/transforms.js

+139-1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const SHORT_KEY_TO_TRANSFORM: { [string]: TransformType } = {};
6565
'focus-category',
6666
'merge-call-node',
6767
'merge-function',
68+
'merge-unaccounted-native-functions',
6869
'drop-function',
6970
'collapse-resource',
7071
'collapse-direct-recursion',
@@ -91,6 +92,9 @@ const SHORT_KEY_TO_TRANSFORM: { [string]: TransformType } = {};
9192
case 'merge-function':
9293
shortKey = 'mf';
9394
break;
95+
case 'merge-unaccounted-native-functions':
96+
shortKey = 'mu';
97+
break;
9498
case 'drop-function':
9599
shortKey = 'df';
96100
break;
@@ -235,6 +239,12 @@ export function parseTransforms(transformString: string): TransformStack {
235239
}
236240
break;
237241
}
242+
case 'merge-unaccounted-native-functions': {
243+
transforms.push({
244+
type: 'merge-unaccounted-native-functions',
245+
});
246+
break;
247+
}
238248
case 'focus-category': {
239249
// e.g. "fg-3"
240250
const [, categoryRaw] = tuple;
@@ -348,6 +358,8 @@ export function stringifyTransforms(transformStack: TransformStack): string {
348358
case 'collapse-function-subtree':
349359
case 'focus-function':
350360
return `${shortKey}-${transform.funcIndex}`;
361+
case 'merge-unaccounted-native-functions':
362+
return shortKey;
351363
case 'focus-category':
352364
return `${shortKey}-${transform.category}`;
353365
case 'collapse-resource':
@@ -432,6 +444,13 @@ export function getTransformLabelL10nIds(
432444
}
433445
}
434446

447+
if (transform.type === 'merge-unaccounted-native-functions') {
448+
return {
449+
l10nId: 'TransformNavigator--merge-unaccounted-native-functions',
450+
item: '',
451+
};
452+
}
453+
435454
// Lookup function name.
436455
let funcIndex;
437456
switch (transform.type) {
@@ -517,6 +536,12 @@ export function applyTransformToCallNodePath(
517536
return _mergeNodeInCallNodePath(transform.callNodePath, callNodePath);
518537
case 'merge-function':
519538
return _mergeFunctionInCallNodePath(transform.funcIndex, callNodePath);
539+
case 'merge-unaccounted-native-functions':
540+
return _mergeUnaccountedNativeFunctionsInCallNodePath(
541+
callNodePath,
542+
transformedThread.funcTable,
543+
transformedThread.stringTable
544+
);
520545
case 'drop-function':
521546
return _dropFunctionInCallNodePath(transform.funcIndex, callNodePath);
522547
case 'collapse-resource':
@@ -588,6 +613,38 @@ function _mergeFunctionInCallNodePath(
588613
return callNodePath.filter((nodeFunc) => nodeFunc !== funcIndex);
589614
}
590615

616+
function _mergeUnaccountedNativeFunctionsInCallNodePath(
617+
callNodePath: CallNodePath,
618+
funcTable: FuncTable,
619+
stringTable: UniqueStringArray
620+
): CallNodePath {
621+
return callNodePath.filter((nodeFunc) =>
622+
isUnaccountedNativeFunction(nodeFunc, funcTable, stringTable)
623+
);
624+
}
625+
626+
// Returns true if funcIndex is probably a JIT frame outside of any known JIT
627+
// address mappings.
628+
export function isUnaccountedNativeFunction(
629+
funcIndex: IndexIntoFuncTable,
630+
funcTable: FuncTable,
631+
stringTable: UniqueStringArray
632+
): boolean {
633+
if (funcTable.resource[funcIndex] !== -1) {
634+
// This function has a resource. That means it's not "unaccounted".
635+
return false;
636+
}
637+
if (funcTable.isJS[funcIndex]) {
638+
// This function is a JS function. That means it's not a "native" function.
639+
return false;
640+
}
641+
// Ok, so now we either have a native function without a library (otherwise it
642+
// would have a "lib" resource), or we have a label frame. Assume that label
643+
// frames don't start with "0x".
644+
const locationString = stringTable.getString(funcTable.name[funcIndex]);
645+
return locationString.startsWith('0x');
646+
}
647+
591648
function _dropFunctionInCallNodePath(
592649
funcIndex: IndexIntoFuncTable,
593650
callNodePath: CallNodePath
@@ -859,13 +916,92 @@ export function mergeFunction(
859916
});
860917
}
861918

919+
/**
920+
* Returns a Uint8Array filled with zeros and ones.
921+
* result[funcIndex] === 1 if and only if isUnaccountedNativeFunction(funcIndex)
922+
*/
923+
function getUnaccountedNativeFunctions(thread: Thread): Uint8Array {
924+
const { funcTable, stringTable } = thread;
925+
const funcCount = funcTable.length;
926+
const isUnaccountedNativeFunctionArr = new Uint8Array(funcCount);
927+
for (let i = 0; i < funcCount; i++) {
928+
if (isUnaccountedNativeFunction(i, funcTable, stringTable)) {
929+
isUnaccountedNativeFunctionArr[i] = 1;
930+
}
931+
}
932+
return isUnaccountedNativeFunctionArr;
933+
}
934+
935+
export function mergeUnaccountedNativeFunctions(thread: Thread): Thread {
936+
const isUnaccountedNativeFunctionArr = getUnaccountedNativeFunctions(thread);
937+
return mergeFunctions(thread, isUnaccountedNativeFunctionArr);
938+
}
939+
940+
export function mergeFunctions(
941+
thread: Thread,
942+
shouldMergeFunction: Uint8Array
943+
): Thread {
944+
const { stackTable, frameTable } = thread;
945+
946+
// A map oldStack -> newStack+1, implemented as a Uint32Array for performance.
947+
// If newStack+1 is zero it means "null", i.e. this stack was filtered out.
948+
// Typed arrays are initialized to zero, which we interpret as null.
949+
//
950+
// For each old stack, the new stack is computed as follows (with `oldStackFun`
951+
// being the old stack's frame's function):
952+
// - If shouldMergeFunction[oldStackFun] === 0, then the new stack is the same
953+
// as the old stack.
954+
// - If shouldMergeFunction[oldStackFun] === 1, then the new stack is the closest
955+
// ancestor for which shouldMergeFunction[ancestorFunc] is 0, or null if no
956+
// such ancestor exists.
957+
//
958+
// We only compute a new prefix column; the other columns are copied from the
959+
// old stack table. The skipped stacks are "orphaned"; they'll still be present
960+
// in the new stack table but not referenced by samples or other stacks.
961+
const oldStackToNewStackPlusOne = new Uint32Array(stackTable.length);
962+
963+
const stackTableFrameCol = stackTable.frame;
964+
const frameTableFuncCol = frameTable.func;
965+
const oldPrefixCol = stackTable.prefix;
966+
const newPrefixCol = new Array(stackTable.length);
967+
968+
for (let stackIndex = 0; stackIndex < stackTable.length; stackIndex++) {
969+
const oldPrefix = oldPrefixCol[stackIndex];
970+
const newPrefixPlusOne =
971+
oldPrefix === null ? 0 : oldStackToNewStackPlusOne[oldPrefix];
972+
973+
const frameIndex = stackTableFrameCol[stackIndex];
974+
const funcIndex = frameTableFuncCol[frameIndex];
975+
if (shouldMergeFunction[funcIndex] === 1) {
976+
oldStackToNewStackPlusOne[stackIndex] = newPrefixPlusOne;
977+
} else {
978+
oldStackToNewStackPlusOne[stackIndex] = stackIndex + 1;
979+
}
980+
const newPrefix = newPrefixPlusOne === 0 ? null : newPrefixPlusOne - 1;
981+
newPrefixCol[stackIndex] = newPrefix;
982+
}
983+
984+
const newStackTable = {
985+
...stackTable,
986+
prefix: newPrefixCol,
987+
};
988+
989+
return updateThreadStacks(thread, newStackTable, (oldStack) => {
990+
if (oldStack === null) {
991+
return null;
992+
}
993+
const newStackPlusOne = oldStackToNewStackPlusOne[oldStack];
994+
return newStackPlusOne === 0 ? null : newStackPlusOne - 1;
995+
});
996+
}
997+
862998
/**
863999
* Drop any samples that contain the given function.
8641000
*/
8651001
export function dropFunction(
8661002
thread: Thread,
8671003
funcIndexToDrop: IndexIntoFuncTable
868-
) {
1004+
): Thread {
8691005
const { stackTable, frameTable } = thread;
8701006

8711007
// Go through each stack, and label it as containing the function or not.
@@ -1774,6 +1910,8 @@ export function applyTransform(
17741910
);
17751911
case 'merge-function':
17761912
return mergeFunction(thread, transform.funcIndex);
1913+
case 'merge-unaccounted-native-functions':
1914+
return mergeUnaccountedNativeFunctions(thread);
17771915
case 'drop-function':
17781916
return dropFunction(thread, transform.funcIndex);
17791917
case 'focus-function':

src/test/store/transforms.test.js

+64
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,70 @@ describe('"merge-function" transform', function () {
497497
});
498498
});
499499

500+
describe('"merge-unaccounted-native-functions" transform', function () {
501+
describe('on a call tree', function () {
502+
/**
503+
* Assert this transformation:
504+
*
505+
* A:3,0 A:3,0
506+
* | |
507+
* v merge 0xC,0xE v
508+
* B:3,0 --> B:3,0
509+
* / \ / | \
510+
* v v v v v
511+
* C:2,0 H:1,0 D:1,0 F:1,0 H:1,1
512+
* / \ \ |
513+
* v v v v
514+
* D:1,0 F:1,0 C:1,1 G:1,1
515+
* | |
516+
* v v
517+
* 0xE:1,1 G:1,1
518+
*/
519+
const { profile } = getProfileFromTextSamples(`
520+
A[lib:one][address:20][sym:Asym:20:] A[lib:one][address:21][sym:Asym:20:] A[lib:one][address:20][sym:Asym:20:]
521+
B B B
522+
0xC 0xC H.js
523+
D.js F[lib:two][address:11][sym:Fsym:10:] 0xC
524+
0xE G[lib:one][address:51][sym:Dsym:40:]
525+
`);
526+
const threadIndex = 0;
527+
528+
const { dispatch, getState } = storeWithProfile(profile);
529+
const originalCallTree = selectedThreadSelectors.getCallTree(getState());
530+
531+
it('starts as an unfiltered call tree', function () {
532+
expect(formatTree(originalCallTree)).toEqual([
533+
'- A (total: 3, self: —)',
534+
' - B (total: 3, self: —)',
535+
' - 0xC (total: 2, self: —)',
536+
' - D.js (total: 1, self: —)',
537+
' - 0xE (total: 1, self: 1)',
538+
' - F (total: 1, self: —)',
539+
' - G (total: 1, self: 1)',
540+
' - H.js (total: 1, self: —)',
541+
' - 0xC (total: 1, self: 1)',
542+
]);
543+
});
544+
545+
it('functions 0xC and 0xE are merged into callers', function () {
546+
dispatch(
547+
addTransformToStack(threadIndex, {
548+
type: 'merge-unaccounted-native-functions',
549+
})
550+
);
551+
const callTree = selectedThreadSelectors.getCallTree(getState());
552+
expect(formatTree(callTree)).toEqual([
553+
'- A (total: 3, self: —)',
554+
' - B (total: 3, self: —)',
555+
' - D.js (total: 1, self: 1)',
556+
' - F (total: 1, self: —)',
557+
' - G (total: 1, self: 1)',
558+
' - H.js (total: 1, self: 1)',
559+
]);
560+
});
561+
});
562+
});
563+
500564
describe('"drop-function" transform', function () {
501565
describe('on a call tree', function () {
502566
const {

0 commit comments

Comments
 (0)