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?: {