diff --git a/docs-user/guide-filtering-call-trees.md b/docs-user/guide-filtering-call-trees.md
index c0b3133970..10b3b6da04 100644
--- a/docs-user/guide-filtering-call-trees.md
+++ b/docs-user/guide-filtering-call-trees.md
@@ -74,6 +74,18 @@ Merging takes a call node and removes it from the call tree. Any self time for t
Focusing on a function or call node removes all of the ancestor call nodes—the children call nodes remain. If a stack does not contain that function or node, then it is removed. This effectively focuses on a subtree or a set of subtrees on the call tree.
+### Focus on Function Self
+
+Focus on function self is similar to focus on function, but more restrictive: it only keeps samples where the focused function is the innermost implementation-filtered frame. This helps you analyze where within a function the self time is being spent, by removing samples where the function is calling other code.
+
+For example, if you focus-self on a JavaScript function with the JS implementation filter, you'll only see samples where that JS function has self time, and any native (C++) calls below it will be shown as descendants. This is particularly useful for:
+
+- Finding which parts of a function are slow (by looking at the assembly or source lines)
+- Understanding what engine internals are being called by a JS function (by switching implementation filter after focusing)
+- Eliminating noise from code your function calls, to concentrate on the function's own execution
+
+If the same function appears multiple times in a stack (recursion), only the innermost instance is kept as the root.
+
### Focus on Category
Focusing on the nodes that belong to the same category as the selected node, thereby merging all nodes that belong to another category.
diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl
index 8139f27e05..15dac59458 100644
--- a/locales/en-US/app.ftl
+++ b/locales/en-US/app.ftl
@@ -86,6 +86,21 @@ CallNodeContextMenu--transform-focus-function = Focus on function
.title = { CallNodeContextMenu--transform-focus-function-title }
CallNodeContextMenu--transform-focus-function-inverted = Focus on function (inverted)
.title = { CallNodeContextMenu--transform-focus-function-title }
+
+## The translation for "self" in these strings should match the translation used
+## in CallTree--samples-self and CallTree--bytes-self. Alternatively it can be
+## translated as "self values" or "self time" (though "self time" is less desirable
+## because this menu item is also shown in "bytes" mode).
+
+CallNodeContextMenu--transform-focus-self-title =
+ Focusing on self is similar to focusing on a function, but only keeps samples
+ that contribute to the function’s self time. Samples in callees
+ are dropped, and the call tree is re-rooted to the focused function.
+CallNodeContextMenu--transform-focus-self = Focus on self only
+ .title = { CallNodeContextMenu--transform-focus-self-title }
+
+##
+
CallNodeContextMenu--transform-focus-subtree = Focus on subtree only
.title =
Focusing on a subtree will remove any sample that does not include that
@@ -1126,6 +1141,13 @@ TransformNavigator--focus-subtree = Focus Node: { $item }
# $item (String) - Name of the function that transform applied to.
TransformNavigator--focus-function = Focus: { $item }
+# "Focus self" transform.
+# See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=focus-on-function-self
+# Also see the translation note above CallNodeContextMenu--transform-focus-self.
+# Variables:
+# $item (String) - Name of the function that transform applied to.
+TransformNavigator--focus-self = Focus Self: { $item }
+
# "Focus category" transform. The word "Focus" has the meaning of an adjective here.
# See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=focus-category
# Variables:
diff --git a/res/img/svg/focus-self-icon.svg b/res/img/svg/focus-self-icon.svg
new file mode 100644
index 0000000000..661d1fa5c6
--- /dev/null
+++ b/res/img/svg/focus-self-icon.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts
index f6782d7a93..3c427a9aa0 100644
--- a/src/app-logic/url-handling.ts
+++ b/src/app-logic/url-handling.ts
@@ -49,7 +49,7 @@ import {
import { tabSlugs } from '../app-logic/tabs-handling';
import { StringTable } from 'firefox-profiler/utils/string-table';
-export const CURRENT_URL_VERSION = 12;
+export const CURRENT_URL_VERSION = 13;
/**
* This static piece of state might look like an anti-pattern, but it's a relatively
@@ -1193,6 +1193,9 @@ const _upgraders: {
// Remove the old sourceView parameter regardless of whether we found a match
delete query.sourceView;
},
+ [13]: (_) => {
+ // just added the focus-self transform
+ },
};
for (let destVersion = 1; destVersion <= CURRENT_URL_VERSION; destVersion++) {
diff --git a/src/components/shared/CallNodeContextMenu.css b/src/components/shared/CallNodeContextMenu.css
index 78f5b4e0e6..28f67e061c 100644
--- a/src/components/shared/CallNodeContextMenu.css
+++ b/src/components/shared/CallNodeContextMenu.css
@@ -27,6 +27,10 @@
background-image: url(../../../res/img/svg/focus-icon.svg);
}
+.callNodeContextMenuIconFocusSelf {
+ background-image: url(../../../res/img/svg/focus-self-icon.svg);
+}
+
.callNodeContextMenuIconDrop {
background-image: url(../../../res/img/svg/drop-icon.svg);
}
diff --git a/src/components/shared/CallNodeContextMenu.tsx b/src/components/shared/CallNodeContextMenu.tsx
index ebbe889971..1a360f01c6 100644
--- a/src/components/shared/CallNodeContextMenu.tsx
+++ b/src/components/shared/CallNodeContextMenu.tsx
@@ -357,6 +357,13 @@ class CallNodeContextMenuImpl extends React.PureComponent {
funcIndex: selectedFunc,
});
break;
+ case 'focus-self':
+ addTransformToStack(threadsKey, {
+ type: 'focus-self',
+ funcIndex: selectedFunc,
+ implementation,
+ });
+ break;
case 'merge-call-node':
addTransformToStack(threadsKey, {
type: 'merge-call-node',
@@ -681,6 +688,16 @@ class CallNodeContextMenuImpl extends React.PureComponent {
content: 'Focus on subtree only',
})}
+ {this.renderTransformMenuItem({
+ l10nId: 'CallNodeContextMenu--transform-focus-self',
+ shortcut: 'S',
+ icon: 'FocusSelf',
+ onClick: this._handleClick,
+ transform: 'focus-self',
+ title: '',
+ content: 'Focus on self only',
+ })}
+
{hasCategory
? this.renderTransformMenuItem({
l10nId: 'CallNodeContextMenu--transform-focus-category',
diff --git a/src/profile-logic/transforms.ts b/src/profile-logic/transforms.ts
index 6de0e218a7..63d626f077 100644
--- a/src/profile-logic/transforms.ts
+++ b/src/profile-logic/transforms.ts
@@ -58,6 +58,7 @@ import type { StringTable } from 'firefox-profiler/utils/string-table';
const TRANSFORM_OBJ: { [key in TransformType]: true } = {
'focus-subtree': true,
'focus-function': true,
+ 'focus-self': true,
'merge-call-node': true,
'merge-function': true,
'drop-function': true,
@@ -86,6 +87,9 @@ ALL_TRANSFORM_TYPES.forEach((transform: TransformType) => {
case 'focus-function':
shortKey = 'ff';
break;
+ case 'focus-self':
+ shortKey = 'ffs';
+ break;
case 'focus-category':
shortKey = 'fg';
break;
@@ -239,6 +243,20 @@ export function parseTransforms(transformString: string): TransformStack {
}
break;
}
+ case 'focus-self': {
+ // e.g. "ffs-js-325"
+ const [, implementation, funcIndexRaw] = tuple;
+ const funcIndex = parseInt(funcIndexRaw, 10);
+ if (isNaN(funcIndex) || funcIndex < 0) {
+ break;
+ }
+ transforms.push({
+ type: 'focus-self',
+ funcIndex,
+ implementation: toValidImplementationFilter(implementation),
+ });
+ break;
+ }
case 'focus-category': {
// e.g. "fg-3"
const [, categoryRaw] = tuple;
@@ -360,6 +378,8 @@ export function stringifyTransforms(transformStack: TransformStack): string {
return `${shortKey}-${transform.funcIndex}`;
case 'collapse-direct-recursion':
return `${shortKey}-${transform.implementation}-${transform.funcIndex}`;
+ case 'focus-self':
+ return `${shortKey}-${transform.implementation}-${transform.funcIndex}`;
case 'focus-subtree':
case 'merge-call-node': {
let string = [
@@ -444,6 +464,7 @@ export function getTransformLabelL10nIds(
funcIndex = transform.callNodePath[transform.callNodePath.length - 1];
break;
case 'focus-function':
+ case 'focus-self':
case 'merge-function':
case 'drop-function':
case 'collapse-direct-recursion':
@@ -462,6 +483,8 @@ export function getTransformLabelL10nIds(
return { l10nId: 'TransformNavigator--focus-subtree', item: funcName };
case 'focus-function':
return { l10nId: 'TransformNavigator--focus-function', item: funcName };
+ case 'focus-self':
+ return { l10nId: 'TransformNavigator--focus-self', item: funcName };
case 'merge-call-node':
return {
l10nId: 'TransformNavigator--merge-call-node',
@@ -511,6 +534,8 @@ export function applyTransformToCallNodePath(
);
case 'focus-function':
return _startCallNodePathWithFunction(transform.funcIndex, callNodePath);
+ case 'focus-self':
+ return _startCallNodePathWithFunction(transform.funcIndex, callNodePath);
case 'focus-category':
return _removeOtherCategoryFunctionsInNodePathWithFunction(
transform.category,
@@ -1443,6 +1468,53 @@ export function focusFunction(
});
}
+export function focusSelf(
+ thread: Thread,
+ funcIndexToFocus: IndexIntoFuncTable,
+ implementation: ImplementationFilter
+): Thread {
+ return timeCode('focusSelf', () => {
+ const { stackTable, frameTable } = thread;
+
+ const funcMatchesImplementation = FUNC_MATCHES[implementation];
+
+ const shouldKeepStack = new Uint8Array(stackTable.length);
+
+ const newPrefixCol = stackTable.prefix.slice();
+
+ for (let stackIndex = 0; stackIndex < stackTable.length; stackIndex++) {
+ const frameIndex = stackTable.frame[stackIndex];
+ const funcIndex = frameTable.func[frameIndex];
+
+ if (funcIndex === funcIndexToFocus) {
+ shouldKeepStack[stackIndex] = 1;
+ newPrefixCol[stackIndex] = null;
+ } else {
+ const prefix = newPrefixCol[stackIndex];
+ if (
+ prefix !== null &&
+ shouldKeepStack[prefix] === 1 &&
+ !funcMatchesImplementation(thread, funcIndex)
+ ) {
+ shouldKeepStack[stackIndex] = 1;
+ }
+ }
+ }
+
+ const newStackTable = {
+ ...stackTable,
+ prefix: newPrefixCol,
+ };
+
+ return updateThreadStacks(thread, newStackTable, (oldStack) => {
+ if (oldStack === null || shouldKeepStack[oldStack] === 0) {
+ return null;
+ }
+ return oldStack;
+ });
+ });
+}
+
export function focusCategory(thread: Thread, category: IndexIntoCategoryList) {
return timeCode('focusCategory', () => {
const { stackTable } = thread;
@@ -1859,6 +1931,8 @@ export function applyTransform(
return dropFunction(thread, transform.funcIndex);
case 'focus-function':
return focusFunction(thread, transform.funcIndex);
+ case 'focus-self':
+ return focusSelf(thread, transform.funcIndex, transform.implementation);
case 'focus-category':
return focusCategory(thread, transform.category);
case 'collapse-resource':
diff --git a/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap b/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap
index a9b65b7663..083afe2a7b 100644
--- a/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap
+++ b/src/test/components/__snapshots__/CallNodeContextMenu.test.tsx.snap
@@ -92,6 +92,27 @@ exports[`calltree/CallNodeContextMenu basic rendering renders a full context men
F
+
+
+
+ Focus on self only
+
+
+ S
+
+
X, A->B->X, A->B->X->Y
+ *
+ * - First sample (A->X): innermost filtered frame is X, keep it → X (with self)
+ * - Second sample (A->B->X): innermost filtered frame is X, keep it → X (with self)
+ * - Third sample (A->B->X->Y): innermost filtered frame is Y ≠ X, drop it
+ *
+ * Result:
+ * X:2,2 (self time from the two kept samples)
+ */
+ const {
+ profile,
+ funcNamesPerThread: [funcNames],
+ } = getProfileFromTextSamples(`
+ A A A
+ X B B
+ X X
+ Y
+ `);
+
+ const threadIndex = 0;
+ const X = funcNames.indexOf('X');
+
+ it('starts as an unfiltered call tree', function () {
+ const { getState } = storeWithProfile(profile);
+ expect(
+ formatTree(selectedThreadSelectors.getCallTree(getState()))
+ ).toEqual([
+ '- A (total: 3, self: —)',
+ ' - B (total: 2, self: —)',
+ ' - X (total: 2, self: 1)',
+ ' - Y (total: 1, self: 1)',
+ ' - X (total: 1, self: 1)',
+ ]);
+ });
+
+ it('can focus-self on a function', function () {
+ const { dispatch, getState } = storeWithProfile(profile);
+ dispatch(
+ addTransformToStack(threadIndex, {
+ type: 'focus-self',
+ funcIndex: X,
+ implementation: 'combined',
+ })
+ );
+ expect(
+ formatTree(selectedThreadSelectors.getCallTree(getState()))
+ ).toEqual(['- X (total: 2, self: 2)']);
+ });
+ });
+
+ describe('with implementation filter and descendants', function () {
+ /**
+ * Test that non-filtered frames are kept as descendants to show where self time goes.
+ *
+ * Samples:
+ * A.js -> X.js -> Y.cpp -> Z.cpp (3 samples)
+ *
+ * With implementation='js':
+ * - Implementation-filtered view shows: A.js -> X.js
+ * - Innermost filtered frame is X.js
+ * - X.js == focused function ✓
+ * - Keep it, result: X.js -> Y.cpp -> Z.cpp
+ * (Y.cpp and Z.cpp are kept to show where X's JS self time goes)
+ */
+ const {
+ profile,
+ funcNamesPerThread: [funcNames],
+ } = getProfileFromTextSamples(`
+ A.js A.js A.js
+ X.js X.js X.js
+ Y.cpp Y.cpp Y.cpp
+ Z.cpp Z.cpp Z.cpp
+ `);
+
+ const threadIndex = 0;
+ const X = funcNames.indexOf('X.js');
+
+ it('keeps non-filtered descendants to show where self time goes', function () {
+ const { dispatch, getState } = storeWithProfile(profile);
+ dispatch(
+ addTransformToStack(threadIndex, {
+ type: 'focus-self',
+ funcIndex: X,
+ implementation: 'js',
+ })
+ );
+ expect(
+ formatTree(selectedThreadSelectors.getCallTree(getState()))
+ ).toEqual([
+ '- X.js (total: 3, self: —)',
+ ' - Y.cpp (total: 3, self: —)',
+ ' - Z.cpp (total: 3, self: 3)',
+ ]);
+ });
+ });
+
+ describe('with recursion', function () {
+ /**
+ * Test that recursion is handled correctly.
+ *
+ * Samples: A->X->Y->X, A->X->Y->X, A->X->Y->X
+ *
+ * For each sample A->X->Y->X:
+ * - Innermost filtered frame is the second X
+ * - Second X == focused function ✓
+ * - Keep it, and make the innermost X the root
+ *
+ * Result:
+ * X:3,3 (the innermost X instances, all with self time)
+ */
+ const {
+ profile,
+ funcNamesPerThread: [funcNames],
+ } = getProfileFromTextSamples(`
+ A A A
+ X X X
+ Y Y Y
+ X X X
+ `);
+
+ const threadIndex = 0;
+ const X = funcNames.indexOf('X');
+
+ it('handles recursion correctly', function () {
+ const { dispatch, getState } = storeWithProfile(profile);
+ dispatch(
+ addTransformToStack(threadIndex, {
+ type: 'focus-self',
+ funcIndex: X,
+ implementation: 'combined',
+ })
+ );
+ expect(
+ formatTree(selectedThreadSelectors.getCallTree(getState()))
+ ).toEqual(['- X (total: 3, self: 3)']);
+ });
+ });
+});
+
describe('"focus-category" transform', function () {
function setup(textSamples: string) {
const {
diff --git a/src/types/transforms.ts b/src/types/transforms.ts
index 4487a0c4ab..0837ced0d7 100644
--- a/src/types/transforms.ts
+++ b/src/types/transforms.ts
@@ -127,6 +127,43 @@ export type TransformDefinitions = {
readonly funcIndex: IndexIntoFuncTable;
};
+ /**
+ * The FocusSelf transform focuses on the self time of a specific function by filtering
+ * out samples where the function is not executing its own code (after implementation
+ * filtering). Unlike focus-function which keeps callees, focus-self removes them to
+ * highlight where within the function time is spent.
+ *
+ * This transform is especially useful when combined with an implementation filter.
+ * For example, when focusing on a JS function with implementation='js', the combined
+ * call tree will show native functions that contributed to the JS function's "JS self
+ * time".
+ *
+ * Example with implementation='js', focusing on X.js:
+ *
+ * A.js:4,0 X.js:3,1
+ * / \ / \
+ * v v Focus-self X.js with v v
+ * X.js:1,0 B.js:3,0 implementation='js' Y.cpp:1,0 W.cpp:1,0
+ * | / \ -> | |
+ * v v v v v
+ * Y.cpp:1,0 X.js:2,1 C.cpp,1,1 Z.cpp:1,1 Q.cpp:1,1
+ * | |
+ * v v
+ * Z.cpp:1,1 W.cpp:1,0
+ * |
+ * v
+ * Q.cpp:1,1
+ *
+ * If the focused function is recursive, every instance of it becomes a root. In other
+ * words, recursion is collapsed and you see the self time for the "innermost" instance
+ * of the focused function in a stack.
+ */
+ 'focus-self': {
+ readonly type: 'focus-self';
+ readonly funcIndex: IndexIntoFuncTable;
+ readonly implementation: ImplementationFilter;
+ };
+
/**
* The MergeCallNode transform represents merging a CallNode into the parent CallNode. The
* CallNode must match the given CallNodePath. In the call tree below, if the CallNode
diff --git a/src/utils/types.ts b/src/utils/types.ts
index c02b68e4fb..1d6df05f5e 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -86,6 +86,7 @@ export function convertToTransformType(type: string): TransformType | null {
case 'focus-subtree':
case 'focus-function':
case 'focus-category':
+ case 'focus-self':
case 'collapse-resource':
case 'collapse-direct-recursion':
case 'collapse-recursion':