From cc539d1f13060c373cc3665bbd2f9321d46c1015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 21 May 2026 13:24:18 +0200 Subject: [PATCH] Annotate inlined frames in CLI call trees and stacks Some frames in a profile are inlined into their caller's machine code by the compiler. The profiler frontend marks these with a small "inl" badge and a tooltip naming the outer function, but the CLI previously gave no hint, so consumers could not tell that an inlined frame's body actually executes inside another function and would treat it as an independent call site. Each call tree node and heaviest-stack frame now carries an `inlineStatus` of `'inlined'` or `'divergent'`, derived from `CallNodeInfo.sourceFramesInlinedIntoSymbolForNode` The CLI formatter appends `(inl)` or `(inl?)` after the function name and, when at least one frame in the output is inlined, prints a one-line legend above the tree / heaviest stack explaining the markers. --- profiler-cli/src/formatters.ts | 80 ++++++++++++------- profiler-cli/src/protocol.ts | 1 + .../test/unit/call-tree-formatting.test.ts | 67 ++++++++++++++++ src/profile-query/formatters/call-tree.ts | 32 +++++++- src/profile-query/formatters/thread-info.ts | 52 +++++++----- src/profile-query/types.ts | 17 ++++ 6 files changed, 199 insertions(+), 50 deletions(-) diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 53838b8595..f62da72cd5 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -30,6 +30,7 @@ import type { NetworkPhaseTimings, MarkerGroupData, CallTreeNode, + InlineStatus, FilterEntry, SampleFilterSpec, ProfileLogsResult, @@ -42,6 +43,24 @@ import { formatTimestamp as formatDuration } from 'firefox-profiler/utils/format // Maximum display width for function names in call-tree and sample views. const FUNC_NAME_WIDTH = 120; +/** + * Suffix appended to a function name to indicate inline status. + * Empty string if the frame is not inlined. + */ +function inlineSuffix(status: InlineStatus | undefined): string { + if (status === 'inlined') { + return ' (inl)'; + } + if (status === 'divergent') { + return ' (inl?)'; + } + return ''; +} + +const INLINE_LEGEND = + 'Note: (inl) = inlined by the compiler into the nearest non-inlined ancestor above. ' + + '(inl?) = some calls were inlined by the compiler.'; + /** * Format a SessionContext as a compact header line. * Shows current thread selection, zoom range, and full profile duration. @@ -466,9 +485,10 @@ function formatCallTreeNode( // Add function handle prefix if available const handlePrefix = node.functionHandle ? `${node.functionHandle}. ` : ''; + const inlineMark = inlineSuffix(node.inlineStatus); lines.push( - `${linePrefix}${handlePrefix}${displayName} [total: ${totalPct}%, self: ${selfPct}%]` + `${linePrefix}${handlePrefix}${displayName}${inlineMark} [total: ${totalPct}%, self: ${selfPct}%]` ); // Handle children and truncation @@ -535,6 +555,11 @@ function formatCallTree( ): string { const lines: string[] = [`${title} Call Tree:`]; + if (tree.hasInlinedFrames) { + lines.push(INLINE_LEGEND); + lines.push(''); + } + // The root node is virtual, so format its children if (tree.children && tree.children.length > 0) { for (let i = 0; i < tree.children.length; i++) { @@ -630,35 +655,21 @@ export function formatThreadSamplesResult( const stack = result.heaviestStack; output += `Heaviest stack (${stack.selfSamples.toFixed(1)} samples, ${stack.frameCount} frames):\n`; + if (stack.hasInlinedFrames) { + output += ` ${INLINE_LEGEND}\n\n`; + } + if (stack.frames.length === 0) { output += ' (empty)\n'; } else if (stack.frameCount <= 200) { // Show all frames for (let i = 0; i < stack.frames.length; i++) { - const frame = stack.frames[i]; - const displayName = truncateFunctionName( - frame.nameWithLibrary, - FUNC_NAME_WIDTH - ); - const totalCount = Math.round(frame.totalSamples); - const totalPct = frame.totalPercentage.toFixed(1); - const selfCount = Math.round(frame.selfSamples); - const selfPct = frame.selfPercentage.toFixed(1); - output += ` ${i + 1}. ${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; + output += formatHeaviestStackFrame(stack.frames[i], i); } } else { // Show first 100 for (let i = 0; i < 100; i++) { - const frame = stack.frames[i]; - const displayName = truncateFunctionName( - frame.nameWithLibrary, - FUNC_NAME_WIDTH - ); - const totalCount = Math.round(frame.totalSamples); - const totalPct = frame.totalPercentage.toFixed(1); - const selfCount = Math.round(frame.selfSamples); - const selfPct = frame.selfPercentage.toFixed(1); - output += ` ${i + 1}. ${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; + output += formatHeaviestStackFrame(stack.frames[i], i); } // Show placeholder for skipped frames @@ -667,22 +678,29 @@ export function formatThreadSamplesResult( // Show last 100 for (let i = stack.frameCount - 100; i < stack.frameCount; i++) { - const frame = stack.frames[i]; - const displayName = truncateFunctionName( - frame.nameWithLibrary, - FUNC_NAME_WIDTH - ); - const totalCount = Math.round(frame.totalSamples); - const totalPct = frame.totalPercentage.toFixed(1); - const selfCount = Math.round(frame.selfSamples); - const selfPct = frame.selfPercentage.toFixed(1); - output += ` ${i + 1}. ${displayName} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; + output += formatHeaviestStackFrame(stack.frames[i], i); } } return output; } +function formatHeaviestStackFrame( + frame: ThreadSamplesResult['heaviestStack']['frames'][number], + i: number +): string { + const displayName = truncateFunctionName( + frame.nameWithLibrary, + FUNC_NAME_WIDTH + ); + const inlineMark = inlineSuffix(frame.inlineStatus); + const totalCount = Math.round(frame.totalSamples); + const totalPct = frame.totalPercentage.toFixed(1); + const selfCount = Math.round(frame.selfSamples); + const selfPct = frame.selfPercentage.toFixed(1); + return ` ${i + 1}. ${displayName}${inlineMark} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; +} + /** * Format a ThreadSamplesTopDownResult as plain text. */ diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index df510dff91..330dfe479f 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -29,6 +29,7 @@ export type { ThreadSamplesBottomUpResult, CallTreeNode, CallTreeScoringStrategy, + InlineStatus, ThreadMarkersResult, ThreadNetworkResult, NetworkRequestEntry, diff --git a/profiler-cli/src/test/unit/call-tree-formatting.test.ts b/profiler-cli/src/test/unit/call-tree-formatting.test.ts index 23a5dce71d..db77920db4 100644 --- a/profiler-cli/src/test/unit/call-tree-formatting.test.ts +++ b/profiler-cli/src/test/unit/call-tree-formatting.test.ts @@ -597,4 +597,71 @@ describe('call tree formatting', function () { }); }); }); + + describe('inlined frames', function () { + it('shows (inl) suffix and legend for inlined frames in top-down view', function () { + // funC is inlined into funB's native symbol (symB). + const result = buildTopDownResult( + ` + funA[lib:XUL][address:1005][sym:symA:1000:] + funB[lib:XUL][address:2007][sym:symB:2000:] + funC[lib:XUL][address:2007][sym:symB:2000:][inl:1] + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + + expect(formatted).toContain('(inl)'); + expect(formatted).toContain( + 'Note: (inl) = inlined by the compiler into the nearest non-inlined ancestor above.' + ); + + // Only funC should be marked inlined. + const lines = formatted.split('\n'); + const funcCLine = lines.find((l) => l.includes('funC')); + const funcBLine = lines.find( + (l) => l.includes('funB') && !l.includes('symB') + ); + expect(funcCLine).toBeDefined(); + expect(funcCLine).toContain('(inl)'); + expect(funcBLine).toBeDefined(); + expect(funcBLine).not.toContain('(inl)'); + }); + + it('does not show legend when no frames are inlined', function () { + const result = buildTopDownResult( + ` + A + B + C + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesTopDownResult(result); + + expect(formatted).not.toContain('(inl)'); + expect(formatted).not.toContain('Note: (inl)'); + }); + + it('shows (inl?) for inverted call nodes with conflicting inlining', function () { + // funC is inlined into two different native symbols (symB and symD) at + // different sites. In the inverted tree, the root funC call node merges + // both, resulting in divergent inlining. + const result = buildBottomUpResult( + ` + funA[lib:XUL][address:1005][sym:symA:1000:] funA[lib:XUL][address:1005][sym:symA:1000:] + funB[lib:XUL][address:2007][sym:symB:2000:] funD[lib:XUL][address:4007][sym:symD:4000:] + funC[lib:XUL][address:2007][sym:symB:2000:][inl:1] funC[lib:XUL][address:4007][sym:symD:4000:][inl:1] + `, + { maxNodes: 10 } + ); + + const formatted = formatThreadSamplesBottomUpResult(result); + + expect(formatted).toContain('(inl?)'); + expect(formatted).toContain('Note: (inl) ='); + }); + }); }); diff --git a/src/profile-query/formatters/call-tree.ts b/src/profile-query/formatters/call-tree.ts index 18353efd98..5113b81607 100644 --- a/src/profile-query/formatters/call-tree.ts +++ b/src/profile-query/formatters/call-tree.ts @@ -5,10 +5,33 @@ import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; import type { IndexIntoCallNodeTable, Lib } from 'firefox-profiler/types'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; -import type { CallTreeNode, CallTreeScoringStrategy } from '../types'; +import type { + CallTreeNode, + CallTreeScoringStrategy, + InlineStatus, +} from '../types'; import { getFunctionHandle } from '../function-map'; import { formatFunctionNameWithLibrary } from '../function-list'; +/** + * Map a call node's sourceFramesInlinedIntoSymbol value to our InlineStatus. + * -2 → undefined (not inlined), -1 → 'divergent', otherwise → 'inlined'. + */ +export function inlineStatusForNode( + tree: CallTree, + callNodeIndex: IndexIntoCallNodeTable +): InlineStatus | undefined { + const inlinedInto = + tree._callNodeInfo.sourceFramesInlinedIntoSymbolForNode(callNodeIndex); + if (inlinedInto === -2) { + return undefined; + } + if (inlinedInto === -1) { + return 'divergent'; + } + return 'inlined'; +} + /** * Compute inclusion score for a call tree node. * The score determines priority for node budget selection. @@ -244,6 +267,7 @@ function buildTreeStructure( selfSamples: 0, selfPercentage: 0, originalDepth: -1, + hasInlinedFrames: false, children: [], }; @@ -279,6 +303,11 @@ function buildTreeStructure( libs ); + const inlineStatus = inlineStatusForNode(tree, callNodeIndex); + if (inlineStatus !== undefined) { + rootNode.hasInlinedFrames = true; + } + const childNode: CallTreeNode = { callNodeIndex, functionHandle: getFunctionHandle(funcIndex), @@ -290,6 +319,7 @@ function buildTreeStructure( selfSamples: childNodeData.self, selfPercentage: childNodeData.selfRelative * 100, originalDepth: childrenDepth, + inlineStatus, children: [], }; diff --git a/src/profile-query/formatters/thread-info.ts b/src/profile-query/formatters/thread-info.ts index 4c3bc484f9..912100061a 100644 --- a/src/profile-query/formatters/thread-info.ts +++ b/src/profile-query/formatters/thread-info.ts @@ -26,7 +26,7 @@ import { extractFunctionData, formatFunctionNameWithLibrary, } from '../function-list'; -import { collectCallTree } from './call-tree'; +import { collectCallTree, inlineStatusForNode } from './call-tree'; import type { CallTreeCollectionOptions } from './call-tree'; import { computeCallTreeTimings, @@ -157,6 +157,7 @@ export function collectThreadSamples( let heaviestStack: ThreadSamplesResult['heaviestStack'] = { selfSamples: 0, frameCount: 0, + hasInlinedFrames: false, frames: [], }; @@ -187,26 +188,41 @@ export function collectThreadSamples( if (leafNodeIndex !== null) { const leafNodeData = callTree.getNodeData(leafNodeIndex); + let hasInlinedFrames = false; + const frames = heaviestPath.map((funcIndex, depth) => { + const funcName = formatFunctionNameWithLibrary( + funcIndex, + thread, + libs + ); + const funcData = funcMap.get(funcIndex); + const prefixPath = heaviestPath.slice(0, depth + 1); + const frameCallNodeIndex = + callNodeInfo.getCallNodeIndexFromPath(prefixPath); + const inlineStatus = + frameCallNodeIndex !== null + ? inlineStatusForNode(callTree, frameCallNodeIndex) + : undefined; + if (inlineStatus !== undefined) { + hasInlinedFrames = true; + } + return { + funcIndex, + name: funcName, + nameWithLibrary: funcName, + totalSamples: funcData?.total ?? 0, + totalPercentage: (funcData?.totalRelative ?? 0) * 100, + selfSamples: funcData?.self ?? 0, + selfPercentage: (funcData?.selfRelative ?? 0) * 100, + inlineStatus, + }; + }); + heaviestStack = { selfSamples: leafNodeData.self, frameCount: heaviestPath.length, - frames: heaviestPath.map((funcIndex) => { - const funcName = formatFunctionNameWithLibrary( - funcIndex, - thread, - libs - ); - const funcData = funcMap.get(funcIndex); - return { - funcIndex, - name: funcName, - nameWithLibrary: funcName, - totalSamples: funcData?.total ?? 0, - totalPercentage: (funcData?.totalRelative ?? 0) * 100, - selfSamples: funcData?.self ?? 0, - selfPercentage: (funcData?.selfRelative ?? 0) * 100, - }; - }), + hasInlinedFrames, + frames, }; } } diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 5c1dcfd2f4..5cc2983a99 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -329,17 +329,27 @@ export type ThreadSamplesResult = { heaviestStack: { selfSamples: number; frameCount: number; + hasInlinedFrames: boolean; frames: Array< FunctionDisplayInfo & { totalSamples: number; totalPercentage: number; selfSamples: number; selfPercentage: number; + inlineStatus?: InlineStatus; } >; }; }; +/** + * Inline status of a frame / call node. + * - 'inlined': all calls to this function at this call site were inlined by the + * compiler into the nearest non-inlined ancestor's native function. + * - 'divergent': some calls were inlined, some were not. + */ +export type InlineStatus = 'inlined' | 'divergent'; + export type ThreadSamplesTopDownResult = { type: 'thread-samples-top-down'; threadHandle: string; @@ -385,6 +395,13 @@ export type CallTreeNode = FunctionDisplayInfo & { selfPercentage: number; /** Original depth in tree before collapsing single-child chains */ originalDepth: number; + /** Whether this call node represents an inlined frame. Unset on the virtual root. */ + inlineStatus?: InlineStatus; + /** + * Only set on the synthetic root. True if any node in the tree has an + * inlineStatus. Lets consumers skip a full tree walk to detect inlining. + */ + hasInlinedFrames?: boolean; children: CallTreeNode[]; /** Information about truncated children, if any were omitted */ childrenTruncated?: {