Skip to content

Commit e928146

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 afd9b08 commit e928146

File tree

7 files changed

+259
-6
lines changed

7 files changed

+259
-6
lines changed

locales/en-US/app.ftl

+10
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ CallNodeContextMenu--transform-merge-call-node = Merge node only
6565
function’s node that called it. It only removes the function from that
6666
specific part of the tree. Any other places the function was called from
6767
will remain in the profile.
68+
CallNodeContextMenu--transform-merge-unaccounted-native-functions = Merge unaccounted native frames
69+
.title =
70+
Removes stack frames such as unsymbolicated JIT frames from the call tree,
71+
and make their callers absorb their cost.
72+
Specifically, this transform merges any native stack frames which were
73+
not accounted to a shared library.
6874
6975
# This is used as the context menu item title for "Focus on function" and "Focus
7076
# on function (inverted)" transforms.
@@ -1081,6 +1087,10 @@ TransformNavigator--merge-call-node = Merge Node: { $item }
10811087
# $item (String) - Name of the function that transform applied to.
10821088
TransformNavigator--merge-function = Merge: { $item }
10831089
1090+
# "Merge unaccounted native frames" transform.
1091+
# See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=merge
1092+
TransformNavigator--merge-unaccounted-native-functions = Merge unaccounted native
1093+
10841094
# "Drop function" transform.
10851095
# See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=drop
10861096
# Variables:

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: 'J',
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

+143-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 = 'munfs';
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,96 @@ export function mergeFunction(
859916
});
860917
}
861918

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

8711011
// Go through each stack, and label it as containing the function or not.
@@ -1774,6 +1914,8 @@ export function applyTransform(
17741914
);
17751915
case 'merge-function':
17761916
return mergeFunction(thread, transform.funcIndex);
1917+
case 'merge-unaccounted-native-functions':
1918+
return mergeUnaccountedNativeFunctions(thread);
17771919
case 'drop-function':
17781920
return dropFunction(thread, transform.funcIndex);
17791921
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)