Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 49 additions & 31 deletions profiler-cli/src/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
NetworkPhaseTimings,
MarkerGroupData,
CallTreeNode,
InlineStatus,
FilterEntry,
SampleFilterSpec,
ProfileLogsResult,
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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++) {
Expand Down Expand Up @@ -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
Expand All @@ -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.
*/
Expand Down
1 change: 1 addition & 0 deletions profiler-cli/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type {
ThreadSamplesBottomUpResult,
CallTreeNode,
CallTreeScoringStrategy,
InlineStatus,
ThreadMarkersResult,
ThreadNetworkResult,
NetworkRequestEntry,
Expand Down
67 changes: 67 additions & 0 deletions profiler-cli/src/test/unit/call-tree-formatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =');
});
});
});
32 changes: 31 additions & 1 deletion src/profile-query/formatters/call-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -244,6 +267,7 @@ function buildTreeStructure(
selfSamples: 0,
selfPercentage: 0,
originalDepth: -1,
hasInlinedFrames: false,
children: [],
};

Expand Down Expand Up @@ -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),
Expand All @@ -290,6 +319,7 @@ function buildTreeStructure(
selfSamples: childNodeData.self,
selfPercentage: childNodeData.selfRelative * 100,
originalDepth: childrenDepth,
inlineStatus,
children: [],
};

Expand Down
52 changes: 34 additions & 18 deletions src/profile-query/formatters/thread-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -157,6 +157,7 @@ export function collectThreadSamples(
let heaviestStack: ThreadSamplesResult['heaviestStack'] = {
selfSamples: 0,
frameCount: 0,
hasInlinedFrames: false,
frames: [],
};

Expand Down Expand Up @@ -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,
};
}
}
Expand Down
17 changes: 17 additions & 0 deletions src/profile-query/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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?: {
Expand Down
Loading