From f0f6c786d5f0203e76ec57edd9e8f8045aeb08ab Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 22 Jan 2026 14:07:12 -0500 Subject: [PATCH 1/2] Add a Focus Self transform. This transform avoids the need to drop callees manually if you want to see a flame graph for a single JS function but no other JS functions; with this transform, you can focus on the function's self while you're in the JS-only view, and then go back to the combined view to see any native functions involved in the execution of that JS function. --- docs-user/guide-filtering-call-trees.md | 12 ++ locales/en-US/app.ftl | 12 ++ res/img/svg/focus-self-icon.svg | 15 ++ src/app-logic/url-handling.ts | 5 +- src/components/shared/CallNodeContextMenu.css | 4 + src/components/shared/CallNodeContextMenu.tsx | 17 +++ src/profile-logic/transforms.ts | 74 +++++++++ .../CallNodeContextMenu.test.tsx.snap | 21 +++ src/test/store/transforms.test.ts | 144 ++++++++++++++++++ src/types/transforms.ts | 37 +++++ src/utils/types.ts | 1 + 11 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 res/img/svg/focus-self-icon.svg 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..0970ba13f9 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -86,6 +86,12 @@ 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 } +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 +1132,12 @@ TransformNavigator--focus-subtree = Focus Node: { $item } # $item (String) - Name of the function that transform applied to. TransformNavigator--focus-function = Focus: { $item } +# "Focus self" transform. +# Focuses on the self time of a function by removing ancestors and showing only descendants. +# 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 +
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': From 7299b85f66436c87f5cd04c21735248f55154771 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 23 Jan 2026 10:25:49 -0500 Subject: [PATCH 2/2] Add translation notes for "focus self" strings. --- locales/en-US/app.ftl | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 0970ba13f9..15dac59458 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -86,12 +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 @@ -1133,7 +1142,8 @@ TransformNavigator--focus-subtree = Focus Node: { $item } TransformNavigator--focus-function = Focus: { $item } # "Focus self" transform. -# Focuses on the self time of a function by removing ancestors and showing only descendants. +# 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 }