diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 6c7a6c8c76..4e0bd4b276 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -48,6 +48,9 @@ CORE WORKFLOW All samples commands exclude idle by default so percentages reflect active CPU time. Use --include-idle to include idle samples (e.g. to see what fraction of wall time is idle). + Use --strategy to summarize allocated bytes instead of CPU time on profiles with + allocation tracking. See DATA SOURCES below. + Use --search to focus the call tree on paths containing a specific function: profiler-cli thread samples-top-down --search GC profiler-cli thread samples-bottom-up --search "JS::Compile" @@ -249,6 +252,29 @@ COUNTERS profiler-cli counter info c-0 +DATA SOURCES + + By default the samples and functions commands summarize sample timing. Profiles + recorded with allocation tracking can be summarized by allocated bytes instead. + + timing CPU sample counts (default) + js-allocations Bytes of JavaScript allocated + native-retained-allocations Bytes allocated and never freed + native-allocations Bytes allocated, freed or not + native-deallocations-memory Bytes freed, attributed to the allocation site + native-deallocations-sites Bytes freed, attributed to the free site + + Allocation sources report bytes, so "total" and "self" read as sizes (e.g. 1.2MB) + rather than sample counts. + + profiler-cli thread info Which sources this thread has + profiler-cli thread strategy native-allocations Sticky: applies to later commands + profiler-cli thread samples --strategy js-allocations Ephemeral: one command only + + Asking for a source the thread has no data for is an error, so byte-free output + never gets mistaken for allocation data. + + JSON OUTPUT Add --json to any command to get structured JSON output, suitable for piping to jq diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index 9aff7cae56..47923635fb 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -8,9 +8,21 @@ SessionContext (present on all command results): selectedThreadHandle, selectedThreads: [{ threadIndex, name }], currentViewRange: { start, startName, end, endName } | null, - rootRange: { start, end } + rootRange: { start, end }, + callTreeSummaryStrategy: CallTreeSummaryStrategy } +CallTreeSummaryStrategy: + "timing" | "js-allocations" | "native-retained-allocations" | + "native-allocations" | "native-deallocations-memory" | + "native-deallocations-sites" + +WeightType: + "samples" | "tracing-ms" | "bytes" + selfSamples and totalSamples keep their names under every strategy; read + weightType to know whether they hold samples or bytes. A result's own + callTreeSummaryStrategy is the one it used, which can differ from the + session's in context. profiler-cli profile info --json { @@ -105,6 +117,16 @@ profiler-cli thread info --json cpuActivity: [{ startTime, startTimeName, startTimeStr, endTime, endTimeName, endTimeStr, cpuMs, depthLevel }] | null, networkActivity: ThreadNetworkSummary | null, + availableStrategies: [CallTreeSummaryStrategy], + context: SessionContext + } + +profiler-cli thread strategy --json + { + type: "strategy-select", + threadHandle, + strategy: CallTreeSummaryStrategy, + availableStrategies: [CallTreeSummaryStrategy], context: SessionContext } @@ -121,6 +143,7 @@ profiler-cli thread samples --json { type: "thread-samples", threadHandle, friendlyThreadName, activeOnly?, + callTreeSummaryStrategy: CallTreeSummaryStrategy, weightType: WeightType, topFunctionsBySelf: [{ functionHandle, functionIndex, name, nameWithLibrary, library?, selfSamples, selfPercentage, totalSamples, totalPercentage }], @@ -138,6 +161,7 @@ profiler-cli thread samples-top-down --json { type: "thread-samples-top-down", threadHandle, friendlyThreadName, activeOnly?, + callTreeSummaryStrategy: CallTreeSummaryStrategy, weightType: WeightType, regularCallTree: CallTreeNode, activeFilters?, ephemeralFilters?, context: SessionContext @@ -147,6 +171,7 @@ profiler-cli thread samples-bottom-up --json { type: "thread-samples-bottom-up", threadHandle, friendlyThreadName, activeOnly?, + callTreeSummaryStrategy: CallTreeSummaryStrategy, weightType: WeightType, invertedCallTree: CallTreeNode | null, activeFilters?, ephemeralFilters?, context: SessionContext @@ -185,6 +210,7 @@ profiler-cli thread functions --json { type: "thread-functions", threadHandle, friendlyThreadName, activeOnly?, + callTreeSummaryStrategy: CallTreeSummaryStrategy, weightType: WeightType, totalFunctionCount, filteredFunctionCount, functions: [{ functionHandle, name, nameWithLibrary, library?, selfSamples, selfPercentage, totalSamples, totalPercentage, @@ -233,5 +259,6 @@ profiler-cli status --json selectedThreads: [{ threadIndex, name }], viewRanges: [{ start, startName, end, endName }], rootRange: { start, end }, - filterStacks: [{ threadHandle, filters: FilterEntry[] }] + filterStacks: [{ threadHandle, filters: FilterEntry[] }], + callTreeSummaryStrategy: CallTreeSummaryStrategy } diff --git a/profiler-cli/src/commands/function.ts b/profiler-cli/src/commands/function.ts index 770ea5d80e..a57c4f4222 100644 --- a/profiler-cli/src/commands/function.ts +++ b/profiler-cli/src/commands/function.ts @@ -7,7 +7,12 @@ */ import type { Command } from 'commander'; -import { addGlobalOptions, runCommand } from './shared'; +import { + addGlobalOptions, + addStrategyOption, + parseOptionalStrategyArg, + runCommand, +} from './shared'; export function registerFunctionCommand( program: Command, @@ -43,27 +48,29 @@ export function registerFunctionCommand( ); }); - addGlobalOptions( - fn - .command('annotate [handle]') - .description( - 'Show annotated source/assembly with timing data (e.g. f-123)' - ) - .option('--function ', 'Function handle') - .option( - '--mode ', - 'Annotation mode: src, asm, or all (default: src)', - 'src' - ) - .option( - '--symbol-server ', - 'Symbol server URL for asm mode. Defaults to the ?symbolServer= value from the loaded URL, or the Mozilla symbol server.' - ) - .option( - '--context ', - 'Source context: number of lines around annotated lines, or "file" for the whole file (default: 2)', - '2' - ) + addStrategyOption( + addGlobalOptions( + fn + .command('annotate [handle]') + .description( + 'Show annotated source/assembly with timing data (e.g. f-123)' + ) + .option('--function ', 'Function handle') + .option( + '--mode ', + 'Annotation mode: src, asm, or all (default: src)', + 'src' + ) + .option( + '--symbol-server ', + 'Symbol server URL for asm mode. Defaults to the ?symbolServer= value from the loaded URL, or the Mozilla symbol server.' + ) + .option( + '--context ', + 'Source context: number of lines around annotated lines, or "file" for the whole file (default: 2)', + '2' + ) + ) ).action(async (handleArg: string | undefined, opts) => { const funcHandle = handleArg ?? opts.function; await runCommand( @@ -75,6 +82,7 @@ export function registerFunctionCommand( annotateMode: opts.mode, symbolServerUrl: opts.symbolServer, annotateContext: opts.context, + strategy: parseOptionalStrategyArg(opts.strategy), }, opts ); diff --git a/profiler-cli/src/commands/shared.ts b/profiler-cli/src/commands/shared.ts index 357ff3d5da..31d7982c28 100644 --- a/profiler-cli/src/commands/shared.ts +++ b/profiler-cli/src/commands/shared.ts @@ -11,7 +11,8 @@ import { Option } from 'commander'; import { collectStrings } from '../utils/parse'; import { sendCommand } from '../client'; import { formatOutput } from '../output'; -import type { ClientCommand } from '../protocol'; +import { CALL_TREE_SUMMARY_STRATEGIES } from 'firefox-profiler/profile-query/call-tree-strategy'; +import type { ClientCommand, CallTreeSummaryStrategy } from '../protocol'; /** * Options shared by every command action via `addGlobalOptions`. @@ -77,6 +78,38 @@ export function parseFloatArg( return v; } +/** + * Parse a --strategy value and exit with an error if it is not a valid strategy. + */ +export function parseStrategyArg(value: string): CallTreeSummaryStrategy { + if (!(CALL_TREE_SUMMARY_STRATEGIES as string[]).includes(value)) { + console.error( + `Error: --strategy must be one of: ${CALL_TREE_SUMMARY_STRATEGIES.join(', ')}` + ); + process.exit(1); + } + return value as CallTreeSummaryStrategy; +} + +/** + * As parseStrategyArg, but for the optional --strategy flag. + */ +export function parseOptionalStrategyArg( + value: string | undefined +): CallTreeSummaryStrategy | undefined { + return value === undefined ? undefined : parseStrategyArg(value); +} + +/** + * Add the --strategy option to a command. + */ +export function addStrategyOption(cmd: Command): Command { + return cmd.option( + '--strategy ', + `Data source to summarize: ${CALL_TREE_SUMMARY_STRATEGIES.join(', ')}. Allocation strategies report bytes instead of samples.` + ); +} + /** * Returns true if the given subcommand was explicitly typed by the user. * Used to decide whether to print a "other subcommands" hint after a default action. diff --git a/profiler-cli/src/commands/thread.ts b/profiler-cli/src/commands/thread.ts index 0bbf432e31..80a9723ee3 100644 --- a/profiler-cli/src/commands/thread.ts +++ b/profiler-cli/src/commands/thread.ts @@ -11,10 +11,14 @@ import { parseEphemeralFilters } from '../utils/parse'; import { addGlobalOptions, addSampleFilterOptions, + addStrategyOption, parseIntArg, parseFloatArg, + parseOptionalStrategyArg, + parseStrategyArg, runCommand, } from './shared'; +import { CALL_TREE_SUMMARY_STRATEGIES } from 'firefox-profiler/profile-query/call-tree-strategy'; import type { CallTreeScoringStrategy, MarkerFilterOptions, @@ -32,15 +36,17 @@ const VALID_SCORING_STRATEGIES: CallTreeScoringStrategy[] = [ ]; function addSamplesOptions(cmd: Command): Command { - return addSampleFilterOptions( - addGlobalOptions(cmd) - .option('--thread ', 'Thread handle (e.g. t-0)') - .option('--include-idle', 'Include idle samples in percentages') - .option( - '--search ', - 'Keep samples containing this substring in any frame. Comma-separates multiple terms, all must match (AND).' - ) - .option('--limit ', 'Limit the number of results shown') + return addStrategyOption( + addSampleFilterOptions( + addGlobalOptions(cmd) + .option('--thread ', 'Thread handle (e.g. t-0)') + .option('--include-idle', 'Include idle samples in percentages') + .option( + '--search ', + 'Keep samples containing this substring in any frame. Comma-separates multiple terms, all must match (AND).' + ) + .option('--limit ', 'Limit the number of results shown') + ) ); } @@ -48,7 +54,7 @@ function addCallTreeOptions(cmd: Command): Command { return addSamplesOptions(cmd) .option('--max-lines ', 'Maximum nodes in call tree (default: 100)') .option( - '--scoring ', + '--scoring ', `Call tree scoring strategy: ${VALID_SCORING_STRATEGIES.join(', ')}` ); } @@ -116,6 +122,27 @@ export function registerThreadCommand( ); }); + // thread strategy + addGlobalOptions( + thread + .command('strategy ') + .description( + `Set the data source for samples and functions commands: ${CALL_TREE_SUMMARY_STRATEGIES.join(', ')}` + ) + .option('--thread ', 'Thread handle (e.g. t-0)') + ).action(async (nameArg: string, opts) => { + await runCommand( + sessionDir, + { + command: 'thread', + subcommand: 'strategy', + thread: opts.thread, + strategy: parseStrategyArg(nameArg), + }, + opts + ); + }); + // thread samples addSamplesOptions( thread @@ -131,6 +158,7 @@ export function registerThreadCommand( thread: opts.thread, includeIdle: opts.includeIdle || undefined, search: opts.search, + strategy: parseOptionalStrategyArg(opts.strategy), sampleFilters: sampleFilters.length ? sampleFilters : undefined, }, opts @@ -152,6 +180,7 @@ export function registerThreadCommand( thread: opts.thread, includeIdle: opts.includeIdle || undefined, search: opts.search, + strategy: parseOptionalStrategyArg(opts.strategy), callTreeOptions: parseCallTreeOptions(opts), sampleFilters: sampleFilters.length ? sampleFilters : undefined, }, @@ -174,6 +203,7 @@ export function registerThreadCommand( thread: opts.thread, includeIdle: opts.includeIdle || undefined, search: opts.search, + strategy: parseOptionalStrategyArg(opts.strategy), callTreeOptions: parseCallTreeOptions(opts), sampleFilters: sampleFilters.length ? sampleFilters : undefined, }, @@ -418,19 +448,21 @@ export function registerThreadCommand( }); // thread functions - addSampleFilterOptions( - addGlobalOptions( - thread - .command('functions') - .description('List all functions with CPU percentages') - .option('--thread ', 'Thread handle (e.g. t-0)') - .option('--search ', 'Filter by substring') - .option( - '--min-self ', - 'Filter by minimum self time percentage' - ) - .option('--limit ', 'Limit the number of results shown') - .option('--include-idle', 'Include idle samples in percentages') + addStrategyOption( + addSampleFilterOptions( + addGlobalOptions( + thread + .command('functions') + .description('List all functions with CPU percentages') + .option('--thread ', 'Thread handle (e.g. t-0)') + .option('--search ', 'Filter by substring') + .option( + '--min-self ', + 'Filter by minimum self time percentage' + ) + .option('--limit ', 'Limit the number of results shown') + .option('--include-idle', 'Include idle samples in percentages') + ) ) ).action(async (opts) => { let functionFilters: FunctionFilterOptions | undefined; @@ -467,6 +499,7 @@ export function registerThreadCommand( subcommand: 'functions', thread: opts.thread, includeIdle: opts.includeIdle || undefined, + strategy: parseOptionalStrategyArg(opts.strategy), functionFilters, sampleFilters: sampleFilters.length ? sampleFilters : undefined, }, diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 109fa51f8d..d8730208ff 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -414,12 +414,21 @@ export class Daemon { throw new Error('thread handle required for thread select'); } return this.querier.threadSelect(command.thread); + case 'strategy': + if (!command.strategy) { + throw new Error('strategy name required for thread strategy'); + } + return this.querier.strategySelect( + command.strategy, + command.thread + ); case 'samples': return this.querier.threadSamples( command.thread, command.includeIdle, command.search, - command.sampleFilters + command.sampleFilters, + command.strategy ); case 'samples-top-down': return this.querier.threadSamplesTopDown( @@ -427,7 +436,8 @@ export class Daemon { command.callTreeOptions, command.includeIdle, command.search, - command.sampleFilters + command.sampleFilters, + command.strategy ); case 'samples-bottom-up': return this.querier.threadSamplesBottomUp( @@ -435,7 +445,8 @@ export class Daemon { command.callTreeOptions, command.includeIdle, command.search, - command.sampleFilters + command.sampleFilters, + command.strategy ); case 'markers': return this.querier.threadMarkers( @@ -447,7 +458,8 @@ export class Daemon { command.thread, command.functionFilters, command.includeIdle, - command.sampleFilters + command.sampleFilters, + command.strategy ); case 'network': return this.querier.threadNetwork( @@ -522,7 +534,8 @@ export class Daemon { command.function, command.annotateMode ?? 'src', command.symbolServerUrl, - command.annotateContext ?? '2' + command.annotateContext ?? '2', + command.strategy ); default: throw assertExhaustiveCheck(command); diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index a3f0c08fc3..acbad65333 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -39,10 +39,14 @@ import type { SampleFilterSpec, ProfileLogsResult, ThreadSelectResult, + StrategySelectResult, + CallTreeSummaryStrategy, + WeightType, CounterSummary, CounterListResult, CounterInfoResult, } from './protocol'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import { truncateFunctionName } from '../../src/profile-query/function-list'; import { describeSpec } from '../../src/profile-query/filter-stack'; import { @@ -71,6 +75,38 @@ 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 call tree weight in the unit the current data source measures in. + */ +function formatWeight(value: number, weightType: WeightType): string { + switch (weightType) { + case 'bytes': + return formatBytes(value); + case 'tracing-ms': + return formatDuration(value); + case 'samples': + return String(Math.round(value)); + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } +} + +/** + * As formatWeight, but with a trailing unit word where the number alone would be + * ambiguous. formatBytes and formatDuration already embed their units. + */ +function formatWeightWithUnit(value: number, weightType: WeightType): string { + const formatted = formatWeight(value, weightType); + return weightType === 'samples' ? `${formatted} samples` : formatted; +} + +/** + * The noun for a weight in headings like "Top Functions (by self bytes)". + */ +function weightHeadingNoun(weightType: WeightType): string { + return weightType === 'bytes' ? 'bytes' : 'time'; +} + /** * Format a SessionContext as a compact header line. * Shows current thread selection, zoom range, and full profile duration. @@ -154,7 +190,8 @@ export function formatStatusResult(result: StatusResult): string { return `\ Session Status: Selected thread: ${threadInfo} - View range: ${rangesInfo}${filterSection}`; + View range: ${rangesInfo} + Data source: ${result.callTreeSummaryStrategy}${filterSection}`; } /** @@ -272,6 +309,7 @@ Created at: ${result.createdAtName} Ended at: ${endedAtStr} This thread contains ${result.sampleCount} samples and ${result.markerCount} markers. +Data sources: ${result.availableStrategies.join(', ') || 'none'} CPU activity over time:`; @@ -943,15 +981,22 @@ function formatSamplesPreamble(result: { activeOnly?: boolean; search?: string; friendlyThreadName: string; + callTreeSummaryStrategy: CallTreeSummaryStrategy; }): string { const contextHeader = formatContextHeader( result.context, result.activeFilters, result.ephemeralFilters ); - const activeOnlyNote = result.activeOnly - ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' - : ''; + const strategy = result.callTreeSummaryStrategy; + const isTiming = strategy === 'timing'; + const dataSourceNote = isTiming ? '' : `Data source: ${strategy}\n\n`; + // Idle samples only exist in the timing table, so the note would be + // meaningless under an allocation strategy. + const activeOnlyNote = + result.activeOnly && isTiming + ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' + : ''; const searchNote = result.search ? `Search: "${result.search}"\n\n` : ''; const filtersParts: string[] = [ ...(result.activeFilters?.map((f) => `[${f.index}] ${f.description}`) ?? @@ -960,7 +1005,7 @@ function formatSamplesPreamble(result: { ]; const filtersNote = filtersParts.length > 0 ? `Filters: ${filtersParts.join(', ')}\n\n` : ''; - return `${contextHeader}\n\nThread: ${result.friendlyThreadName}\n\n${activeOnlyNote}${searchNote}${filtersNote}`; + return `${contextHeader}\n\nThread: ${result.friendlyThreadName}\n\n${dataSourceNote}${activeOnlyNote}${searchNote}${filtersNote}`; } /** @@ -980,12 +1025,14 @@ export function formatThreadSamplesResult( return output; } - // Top functions by total time - output += 'Top Functions (by total time):\n'; + const { weightType } = result; + const weightNoun = weightHeadingNoun(weightType); + + output += `Top Functions (by total ${weightNoun}):\n`; output += ' (For a call tree starting from these functions, use: profiler-cli thread samples-top-down)\n\n'; for (const func of result.topFunctionsByTotal) { - const totalCount = Math.round(func.totalSamples); + const totalCount = formatWeight(func.totalSamples, weightType); const totalPct = func.totalPercentage.toFixed(1); const displayName = truncateFunctionName( func.nameWithLibrary, @@ -996,12 +1043,11 @@ export function formatThreadSamplesResult( output += '\n'; - // Top functions by self time - output += 'Top Functions (by self time):\n'; + output += `Top Functions (by self ${weightNoun}):\n`; output += ' (For a call tree showing what calls these functions, use: profiler-cli thread samples-bottom-up)\n\n'; for (const func of result.topFunctionsBySelf) { - const selfCount = Math.round(func.selfSamples); + const selfCount = formatWeight(func.selfSamples, weightType); const selfPct = func.selfPercentage.toFixed(1); const displayName = truncateFunctionName( func.nameWithLibrary, @@ -1014,7 +1060,11 @@ export function formatThreadSamplesResult( // Heaviest stack const stack = result.heaviestStack; - output += `Heaviest stack (${stack.selfSamples.toFixed(1)} samples, ${stack.frameCount} frames):\n`; + const heaviestSelf = + weightType === 'samples' + ? `${stack.selfSamples.toFixed(1)} samples` + : formatWeight(stack.selfSamples, weightType); + output += `Heaviest stack (${heaviestSelf}, ${stack.frameCount} frames):\n`; if (stack.hasInlinedFrames) { output += ` ${INLINE_LEGEND}\n\n`; @@ -1025,12 +1075,12 @@ export function formatThreadSamplesResult( } else if (stack.frameCount <= 200) { // Show all frames for (let i = 0; i < stack.frames.length; i++) { - output += formatHeaviestStackFrame(stack.frames[i], i); + output += formatHeaviestStackFrame(stack.frames[i], i, weightType); } } else { // Show first 100 for (let i = 0; i < 100; i++) { - output += formatHeaviestStackFrame(stack.frames[i], i); + output += formatHeaviestStackFrame(stack.frames[i], i, weightType); } // Show placeholder for skipped frames @@ -1039,7 +1089,7 @@ export function formatThreadSamplesResult( // Show last 100 for (let i = stack.frameCount - 100; i < stack.frameCount; i++) { - output += formatHeaviestStackFrame(stack.frames[i], i); + output += formatHeaviestStackFrame(stack.frames[i], i, weightType); } } @@ -1048,16 +1098,17 @@ export function formatThreadSamplesResult( function formatHeaviestStackFrame( frame: ThreadSamplesResult['heaviestStack']['frames'][number], - i: number + i: number, + weightType: WeightType ): string { const displayName = truncateFunctionName( frame.nameWithLibrary, FUNC_NAME_WIDTH ); const inlineMark = inlineSuffix(frame.inlineStatus); - const totalCount = Math.round(frame.totalSamples); + const totalCount = formatWeight(frame.totalSamples, weightType); const totalPct = frame.totalPercentage.toFixed(1); - const selfCount = Math.round(frame.selfSamples); + const selfCount = formatWeight(frame.selfSamples, weightType); const selfPct = frame.selfPercentage.toFixed(1); return ` ${i + 1}. ${displayName}${inlineMark} - total: ${totalCount} (${totalPct}%), self: ${selfCount} (${selfPct}%)\n`; } @@ -1314,7 +1365,14 @@ export function formatThreadFunctionsResult( `Functions in thread ${result.threadHandle} (${result.friendlyThreadName}) — ${result.filteredFunctionCount} functions${filterSuffix}\n` ); - if (result.activeOnly) { + const { weightType } = result; + const isTiming = result.callTreeSummaryStrategy === 'timing'; + + if (!isTiming) { + lines.push(`Data source: ${result.callTreeSummaryStrategy}\n`); + } + + if (result.activeOnly && isTiming) { lines.push( 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n' ); @@ -1361,11 +1419,10 @@ export function formatThreadFunctionsResult( lines.push(`Filters: ${filterParts.join(', ')}\n`); } - // List functions sorted by self time - lines.push('Functions (by self time):'); + lines.push(`Functions (by self ${weightHeadingNoun(weightType)}):`); for (const func of result.functions) { - const selfCount = Math.round(func.selfSamples); - const totalCount = Math.round(func.totalSamples); + const selfCount = formatWeight(func.selfSamples, weightType); + const totalCount = formatWeight(func.totalSamples, weightType); const displayName = truncateFunctionName( func.nameWithLibrary, FUNC_NAME_WIDTH @@ -1735,11 +1792,19 @@ export function formatFunctionAnnotateResult( out.push(contextHeader, ''); out.push(`Function ${result.functionHandle}: ${result.name}`); out.push(`Thread: ${result.friendlyThreadName} (${result.threadHandle})`, ''); + const { weightType } = result; + const weightNoun = weightHeadingNoun(weightType); + // Wider columns for byte sizes, which read as "123.4KB" rather than "1234". + const W_SELF = weightType === 'bytes' ? 9 : 6; + const W_TOTAL = weightType === 'bytes' ? 10 : 7; out.push( - `Self time: ${Math.round(result.totalSelfSamples)} samples, ` + - `Total time: ${Math.round(result.totalTotalSamples)} samples` + `Self ${weightNoun}: ${formatWeightWithUnit(result.totalSelfSamples, weightType)}, ` + + `Total ${weightNoun}: ${formatWeightWithUnit(result.totalTotalSamples, weightType)}` ); out.push(`Mode: ${result.mode}`); + if (result.callTreeSummaryStrategy !== 'timing') { + out.push(`Data source: ${result.callTreeSummaryStrategy}`); + } for (const w of result.warnings) { out.push('', `Warning: ${w}`); @@ -1752,14 +1817,12 @@ export function formatFunctionAnnotateResult( src.totalFileLines !== null ? ` (${src.totalFileLines} lines)` : ''; out.push('', `Source file: ${src.filename}${fileSuffix}`); out.push( - ` ${Math.round(src.samplesWithLineInfo)} of ${Math.round(src.samplesWithFunction)} ` + - `samples have line number information` + ` ${formatWeight(src.samplesWithLineInfo, weightType)} of ` + + `${formatWeightWithUnit(src.samplesWithFunction, weightType)} have line number information` ); out.push(` Showing: ${src.contextMode}`, ''); const W_LINE = 5; - const W_SELF = 6; - const W_TOTAL = 7; out.push( `${'Line'.padStart(W_LINE)} ${'Self'.padStart(W_SELF)} ${'Total'.padStart(W_TOTAL)} Source` @@ -1775,12 +1838,12 @@ export function formatFunctionAnnotateResult( prevLine = line.lineNumber; const selfStr = - line.selfSamples > 0 - ? String(Math.round(line.selfSamples)).padStart(W_SELF) + line.selfSamples !== 0 + ? formatWeight(line.selfSamples, weightType).padStart(W_SELF) : ' '.repeat(W_SELF); const totalStr = - line.totalSamples > 0 - ? String(Math.round(line.totalSamples)).padStart(W_TOTAL) + line.totalSamples !== 0 + ? formatWeight(line.totalSamples, weightType).padStart(W_TOTAL) : ' '.repeat(W_TOTAL); const srcText = line.sourceText !== null ? ` ${line.sourceText}` : ''; out.push( @@ -1806,20 +1869,20 @@ export function formatFunctionAnnotateResult( out.push(''); out.push( - ` ${'Address'.padEnd(18)}${'Self'.padStart(6)} ${'Total'.padStart(7)} Instruction` + ` ${'Address'.padEnd(18)}${'Self'.padStart(W_SELF)} ${'Total'.padStart(W_TOTAL)} Instruction` ); out.push(' ' + '─'.repeat(70)); for (const instr of asm.instructions) { const addrStr = `0x${instr.address.toString(16)}`.padEnd(18); const selfStr = - instr.selfSamples > 0 - ? String(Math.round(instr.selfSamples)).padStart(6) - : ' '.repeat(6); + instr.selfSamples !== 0 + ? formatWeight(instr.selfSamples, weightType).padStart(W_SELF) + : ' '.repeat(W_SELF); const totalStr = - instr.totalSamples > 0 - ? String(Math.round(instr.totalSamples)).padStart(7) - : ' '.repeat(7); + instr.totalSamples !== 0 + ? formatWeight(instr.totalSamples, weightType).padStart(W_TOTAL) + : ' '.repeat(W_TOTAL); out.push(` ${addrStr}${selfStr} ${totalStr} ${instr.decodedString}`); } } @@ -2092,3 +2155,15 @@ export function formatThreadSelectResult( } return `Selected ${count} threads: ${result.threadHandle} (${names})`; } + +/** + * Format a StrategySelectResult as plain text. + */ +export function formatStrategySelectResult( + result: WithContext +): string { + return ( + `Data source: ${result.strategy}\n` + + `Available in ${result.threadHandle}: ${result.availableStrategies.join(', ')}` + ); +} diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index 601ba0a426..c10681d196 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -29,6 +29,7 @@ import { formatProfileLogsResult, formatThreadPageLoadResult, formatThreadSelectResult, + formatStrategySelectResult, formatCounterListResult, formatCounterInfoResult, } from './formatters'; @@ -93,6 +94,8 @@ export function formatOutput( return formatThreadPageLoadResult(result); case 'thread-select': return formatThreadSelectResult(result); + case 'strategy-select': + return formatStrategySelectResult(result); case 'counter-list': return formatCounterListResult(result); case 'counter-info': diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 3cc416d002..0ab67a125d 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -29,6 +29,9 @@ export type { ThreadSamplesBottomUpResult, CallTreeNode, CallTreeScoringStrategy, + CallTreeSummaryStrategy, + WeightType, + StrategySelectResult, InlineStatus, ThreadMarkersResult, ThreadNetworkResult, @@ -75,6 +78,8 @@ import type { AnnotateMode, ViewRangeResult, ThreadInfoResult, + StrategySelectResult, + CallTreeSummaryStrategy, MarkerStackResult, MarkerInfoResult, ProfileInfoResult, @@ -133,10 +138,12 @@ export type ClientCommand = | 'markers' | 'functions' | 'network' - | 'page-load'; + | 'page-load' + | 'strategy'; thread?: string; includeIdle?: boolean; search?: string; + strategy?: CallTreeSummaryStrategy; markerFilters?: MarkerFilterOptions; functionFilters?: FunctionFilterOptions; callTreeOptions?: CallTreeCollectionOptions; @@ -173,6 +180,7 @@ export type ClientCommand = symbolServerUrl?: string; /** "file", "function", or a number of context lines (e.g. "2") */ annotateContext?: string; + strategy?: CallTreeSummaryStrategy; } | { command: 'zoom'; @@ -220,6 +228,7 @@ export type CommandResult = | WithContext | WithContext | WithContext + | WithContext | WithContext | WithContext; diff --git a/profiler-cli/src/test/integration/basic.test.ts b/profiler-cli/src/test/integration/basic.test.ts index 598d729833..3b8767e1c5 100644 --- a/profiler-cli/src/test/integration/basic.test.ts +++ b/profiler-cli/src/test/integration/basic.test.ts @@ -21,10 +21,15 @@ import type { ProfileMetaResult, SessionMetadata, StatusResult, + StrategySelectResult, + ThreadInfoResult, ThreadSamplesResult, WithContext, } from '../../protocol'; +/** A DHAT heap profile, i.e. native allocations with no timing samples. */ +const ALLOCATION_PROFILE = 'src/test/fixtures/upgrades/dhat.json.gz'; + describe('profiler-cli basic functionality', () => { let ctx: CliTestContext; @@ -323,6 +328,109 @@ describe('profiler-cli basic functionality', () => { expect(output).toContain('--max-lines must be a positive integer'); }); + it('an unknown --strategy is rejected with the list of valid ones', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await cliFail(ctx, [ + 'thread', + 'samples', + '--strategy', + 'bogus', + ]); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('--strategy must be one of:'); + expect(output).toContain('native-retained-allocations'); + }); + + it('a strategy with no data in the thread is an error, not a fallback to timing', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await cliFail(ctx, [ + 'thread', + 'samples', + '--strategy', + 'js-allocations', + ]); + + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain("Strategy 'js-allocations' has no data"); + expect(output).toContain('Available: timing'); + }); + + it('an allocation profile reports bytes and lists its available strategies', async () => { + await cli(ctx, ['load', ALLOCATION_PROFILE]); + + const infoResult = await cli(ctx, ['thread', 'info', '--json']); + const info = JSON.parse(infoResult.stdout) as WithContext; + expect(info.availableStrategies).toEqual([ + 'native-allocations', + 'native-deallocations-sites', + ]); + + const samplesResult = await cli(ctx, ['thread', 'samples', '--json']); + const samples = JSON.parse( + samplesResult.stdout + ) as WithContext; + expect(samples.weightType).toBe('bytes'); + // The thread has no timing samples, so the call tree falls forward to + // native allocations even though the session setting is still timing. + expect(samples.callTreeSummaryStrategy).toBe('native-allocations'); + expect(samples.context.callTreeSummaryStrategy).toBe('timing'); + }); + + it('an ephemeral --strategy does not persist into session state', async () => { + await cli(ctx, ['load', ALLOCATION_PROFILE]); + + const samplesResult = await cli(ctx, [ + 'thread', + 'samples', + '--json', + '--strategy', + 'native-deallocations-sites', + ]); + const samples = JSON.parse( + samplesResult.stdout + ) as WithContext; + expect(samples.callTreeSummaryStrategy).toBe('native-deallocations-sites'); + + const statusResult = await cli(ctx, ['status', '--json']); + const status = JSON.parse(statusResult.stdout) as StatusResult; + expect(status.callTreeSummaryStrategy).toBe('timing'); + }); + + it('thread strategy persists across commands', async () => { + await cli(ctx, ['load', ALLOCATION_PROFILE]); + + const selectResult = await cli(ctx, [ + 'thread', + 'strategy', + 'native-deallocations-sites', + '--json', + ]); + const selected = JSON.parse( + selectResult.stdout + ) as WithContext; + expect(selected.type).toBe('strategy-select'); + expect(selected.strategy).toBe('native-deallocations-sites'); + expect(selected.availableStrategies).toEqual([ + 'native-allocations', + 'native-deallocations-sites', + ]); + + const statusResult = await cli(ctx, ['status', '--json']); + const status = JSON.parse(statusResult.stdout) as StatusResult; + expect(status.callTreeSummaryStrategy).toBe('native-deallocations-sites'); + + const samplesResult = await cli(ctx, ['thread', 'samples', '--json']); + const samples = JSON.parse( + samplesResult.stdout + ) as WithContext; + expect(samples.callTreeSummaryStrategy).toBe('native-deallocations-sites'); + }); + it('build hash mismatch stops the daemon before cleaning up the session', async () => { const loadResult = await cli(ctx, [ 'load', diff --git a/profiler-cli/src/test/unit/__snapshots__/allocation-formatting.test.ts.snap b/profiler-cli/src/test/unit/__snapshots__/allocation-formatting.test.ts.snap new file mode 100644 index 0000000000..7712681bed --- /dev/null +++ b/profiler-cli/src/test/unit/__snapshots__/allocation-formatting.test.ts.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`functions formatting with an allocation strategy reports bytes rather than sample counts 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Functions in thread t-0 (Empty) — 9 functions + +Data source: js-allocations + +Functions (by self bytes): + f-8. libI.so!I - self: 7B (46.7%), total: 7B (46.7%) + f-6. Gjs - self: 5B (33.3%), total: 12B (80.0%) + f-4. E - self: 3B (20.0%), total: 3B (20.0%) + f-0. A - self: 0B (0.0%), total: 15B (100.0%) + f-1. B - self: 0B (0.0%), total: 15B (100.0%) + f-5. Fjs - self: 0B (0.0%), total: 12B (80.0%) + f-7. jQuery.js!Hjs - self: 0B (0.0%), total: 7B (46.7%) + f-2. C - self: 0B (0.0%), total: 3B (20.0%) + f-3. D - self: 0B (0.0%), total: 3B (20.0%) + +Use --search , --min-self , or --limit to filter functions, or f- handles to inspect individual functions." +`; + +exports[`samples formatting with an allocation strategy reports bytes rather than sample counts 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Empty + +Data source: js-allocations + +Top Functions (by total bytes): + (For a call tree starting from these functions, use: profiler-cli thread samples-top-down) + + f-0. A - total: 15B (100.0%) + f-1. B - total: 15B (100.0%) + f-5. Fjs - total: 12B (80.0%) + f-6. Gjs - total: 12B (80.0%) + f-7. jQuery.js!Hjs - total: 7B (46.7%) + f-8. libI.so!I - total: 7B (46.7%) + f-2. C - total: 3B (20.0%) + f-3. D - total: 3B (20.0%) + f-4. E - total: 3B (20.0%) + +Top Functions (by self bytes): + (For a call tree showing what calls these functions, use: profiler-cli thread samples-bottom-up) + + f-8. libI.so!I - self: 7B (46.7%) + f-6. Gjs - self: 5B (33.3%) + f-4. E - self: 3B (20.0%) + f-0. A - self: 0B (0.0%) + f-1. B - self: 0B (0.0%) + f-5. Fjs - self: 0B (0.0%) + f-7. jQuery.js!Hjs - self: 0B (0.0%) + f-2. C - self: 0B (0.0%) + f-3. D - self: 0B (0.0%) + +Heaviest stack (7B, 6 frames): + 1. A - total: 15B (100.0%), self: 0B (0.0%) + 2. B - total: 15B (100.0%), self: 0B (0.0%) + 3. Fjs - total: 12B (80.0%), self: 0B (0.0%) + 4. Gjs - total: 12B (80.0%), self: 5B (33.3%) + 5. jQuery.js!Hjs - total: 7B (46.7%), self: 0B (0.0%) + 6. libI.so!I - total: 7B (46.7%), self: 7B (46.7%) +" +`; diff --git a/profiler-cli/src/test/unit/allocation-formatting.test.ts b/profiler-cli/src/test/unit/allocation-formatting.test.ts new file mode 100644 index 0000000000..63a554025a --- /dev/null +++ b/profiler-cli/src/test/unit/allocation-formatting.test.ts @@ -0,0 +1,219 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + collectThreadSamples, + collectThreadFunctions, + collectThreadInfo, +} from 'firefox-profiler/profile-query/formatters/thread-info'; +import { getAvailableStrategies } from 'firefox-profiler/profile-query/call-tree-strategy'; +import { ThreadMap } from 'firefox-profiler/profile-query/thread-map'; +import { MarkerMap } from 'firefox-profiler/profile-query/marker-map'; +import { TimestampManager } from 'firefox-profiler/profile-query/timestamps'; +import type { + CallTreeSummaryStrategy, + SessionContext, + WithContext, +} from 'firefox-profiler/profile-query/types'; +import { + getProfileFromTextSamples, + getProfileWithJsAllocations, + getProfileWithUnbalancedNativeAllocations, + getProfileWithBalancedNativeAllocations, +} from 'firefox-profiler/test/fixtures/profiles/processed-profile'; +import { storeWithProfile } from 'firefox-profiler/test/fixtures/stores'; +import { changeCallTreeSummaryStrategy } from 'firefox-profiler/actions/profile-view'; +import type { Profile } from 'firefox-profiler/types'; +import type { Store } from 'firefox-profiler/types/store'; +import { + formatThreadSamplesResult, + formatThreadFunctionsResult, + formatThreadInfoResult, +} from '../../formatters'; + +function createStore( + profile: Profile, + strategy: CallTreeSummaryStrategy +): Store { + const store = storeWithProfile(profile); + store.dispatch(changeCallTreeSummaryStrategy(strategy)); + return store; +} + +function threadMap(): ThreadMap { + const map = new ThreadMap(); + map.handleForThreadIndex(0); + return map; +} + +function mockContext(strategy: CallTreeSummaryStrategy): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'Test Thread' }], + currentViewRange: null, + rootRange: { start: 0, end: 1000 }, + callTreeSummaryStrategy: strategy, + }; +} + +function withMockContext( + result: T, + strategy: CallTreeSummaryStrategy +): WithContext { + return { ...result, context: mockContext(strategy) }; +} + +function samplesResult(profile: Profile, strategy: CallTreeSummaryStrategy) { + const store = createStore(profile, strategy); + return withMockContext( + { ...collectThreadSamples(store, threadMap(), 't-0'), activeOnly: true }, + strategy + ); +} + +function functionsResult(profile: Profile, strategy: CallTreeSummaryStrategy) { + const store = createStore(profile, strategy); + return withMockContext( + { ...collectThreadFunctions(store, threadMap(), 't-0'), activeOnly: true }, + strategy + ); +} + +function availableStrategiesFor(profile: Profile): CallTreeSummaryStrategy[] { + const store = storeWithProfile(profile); + return getAvailableStrategies(store.getState(), new Set([0])); +} + +describe('available strategies', function () { + it('lists only timing for a profile without allocations', function () { + const { profile } = getProfileFromTextSamples(` + A + B + `); + expect(availableStrategiesFor(profile)).toEqual(['timing']); + }); + + it('lists js-allocations for a profile with JS allocations', function () { + const { profile } = getProfileWithJsAllocations(); + expect(availableStrategiesFor(profile)).toEqual([ + 'timing', + 'js-allocations', + ]); + }); + + it('omits the memory-address strategies for unbalanced native allocations', function () { + const { profile } = getProfileWithUnbalancedNativeAllocations(); + expect(availableStrategiesFor(profile)).toEqual([ + 'timing', + 'native-allocations', + 'native-deallocations-sites', + ]); + }); + + it('lists every native strategy for balanced native allocations', function () { + const { profile } = getProfileWithBalancedNativeAllocations(); + expect(availableStrategiesFor(profile)).toEqual([ + 'timing', + 'native-retained-allocations', + 'native-allocations', + 'native-deallocations-memory', + 'native-deallocations-sites', + ]); + }); + + it('reports the available strategies in thread info output', function () { + const { profile } = getProfileWithJsAllocations(); + const store = storeWithProfile(profile); + const result = withMockContext( + collectThreadInfo( + store, + new TimestampManager({ start: 0, end: 1000 }), + threadMap(), + new MarkerMap(), + 't-0' + ), + 'timing' + ); + expect(result.availableStrategies).toEqual(['timing', 'js-allocations']); + expect(formatThreadInfoResult(result)).toContain( + 'Data sources: timing, js-allocations' + ); + }); +}); + +describe('samples formatting with an allocation strategy', function () { + it('reports bytes rather than sample counts', function () { + const { profile } = getProfileWithJsAllocations(); + const result = samplesResult(profile, 'js-allocations'); + + expect(result.weightType).toBe('bytes'); + + const formatted = formatThreadSamplesResult(result); + expect(formatted).toContain('Data source: js-allocations'); + expect(formatted).toContain('Top Functions (by total bytes)'); + expect(formatted).toContain('Top Functions (by self bytes)'); + // The fixture allocates 3B at E, 5B at Gjs and 7B at I, for 15B total. + expect(formatted).toContain('A - total: 15B (100.0%)'); + expect(formatted).toContain('I - self: 7B (46.7%)'); + expect(formatted).toMatchSnapshot(); + }); + + it('drops the idle-samples note, which has no meaning for allocations', function () { + const { profile } = getProfileWithJsAllocations(); + const timing = formatThreadSamplesResult(samplesResult(profile, 'timing')); + const allocations = formatThreadSamplesResult( + samplesResult(profile, 'js-allocations') + ); + + expect(timing).toContain('active samples only (idle excluded)'); + expect(allocations).not.toContain('active samples only'); + }); + + it('reports negative byte totals for a deallocation strategy', function () { + const { profile } = getProfileWithBalancedNativeAllocations(); + const result = samplesResult(profile, 'native-deallocations-sites'); + + expect(result.weightType).toBe('bytes'); + expect(result.topFunctionsByTotal[0].totalSamples).toBeLessThan(0); + expect(formatThreadSamplesResult(result)).toContain( + 'Data source: native-deallocations-sites' + ); + }); + + it('attributes retained memory only to allocations that were never freed', function () { + const { profile } = getProfileWithBalancedNativeAllocations(); + const retained = samplesResult(profile, 'native-retained-allocations'); + const allocated = samplesResult(profile, 'native-allocations'); + + expect(retained.topFunctionsByTotal[0].totalSamples).toBeLessThan( + allocated.topFunctionsByTotal[0].totalSamples + ); + }); +}); + +describe('functions formatting with an allocation strategy', function () { + it('reports bytes rather than sample counts', function () { + const { profile } = getProfileWithJsAllocations(); + const result = functionsResult(profile, 'js-allocations'); + + expect(result.weightType).toBe('bytes'); + + const formatted = formatThreadFunctionsResult(result); + expect(formatted).toContain('Data source: js-allocations'); + expect(formatted).toContain('Functions (by self bytes)'); + expect(formatted).toContain('self: 7B'); + expect(formatted).toMatchSnapshot(); + }); + + it('keeps sample counts under the timing strategy', function () { + const { profile } = getProfileWithJsAllocations(); + const result = functionsResult(profile, 'timing'); + + expect(result.weightType).toBe('samples'); + + const formatted = formatThreadFunctionsResult(result); + expect(formatted).toContain('Functions (by self time)'); + expect(formatted).not.toContain('Data source:'); + }); +}); 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 db77920db4..d66f833a01 100644 --- a/profiler-cli/src/test/unit/call-tree-formatting.test.ts +++ b/profiler-cli/src/test/unit/call-tree-formatting.test.ts @@ -37,6 +37,7 @@ function createMockContext(): SessionContext { selectedThreads: [{ threadIndex: 0, name: 'Test Thread' }], currentViewRange: null, rootRange: { start: 0, end: 1000 }, + callTreeSummaryStrategy: 'timing', }; } @@ -60,6 +61,8 @@ function buildTopDownResult( type: 'thread-samples-top-down', threadHandle: 't-0', friendlyThreadName: 'Test Thread', + callTreeSummaryStrategy: threadSelectors.getCallTreeSummaryStrategy(state), + weightType: threadSelectors.getWeightTypeForCallTree(state), regularCallTree, context: createMockContext(), }; @@ -128,6 +131,8 @@ function buildBottomUpResult( type: 'thread-samples-bottom-up', threadHandle: 't-0', friendlyThreadName: 'Test Thread', + callTreeSummaryStrategy: threadSelectors.getCallTreeSummaryStrategy(state), + weightType: threadSelectors.getWeightTypeForCallTree(state), invertedCallTree: collectedInvertedTree, context: createMockContext(), }; diff --git a/profiler-cli/src/test/unit/counter-formatting.test.ts b/profiler-cli/src/test/unit/counter-formatting.test.ts index 7c1e4a6714..9bd00aa779 100644 --- a/profiler-cli/src/test/unit/counter-formatting.test.ts +++ b/profiler-cli/src/test/unit/counter-formatting.test.ts @@ -20,6 +20,7 @@ function createContext(): SessionContext { selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], currentViewRange: null, rootRange: { start: 0, end: 3000 }, + callTreeSummaryStrategy: 'timing', }; } diff --git a/profiler-cli/src/test/unit/marker-formatting.test.ts b/profiler-cli/src/test/unit/marker-formatting.test.ts index d45353e509..b4b1dbf92e 100644 --- a/profiler-cli/src/test/unit/marker-formatting.test.ts +++ b/profiler-cli/src/test/unit/marker-formatting.test.ts @@ -16,6 +16,7 @@ function createContext(): SessionContext { selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], currentViewRange: null, rootRange: { start: 0, end: 3000 }, + callTreeSummaryStrategy: 'timing', }; } diff --git a/profiler-cli/src/test/unit/meta-formatting.test.ts b/profiler-cli/src/test/unit/meta-formatting.test.ts index 07ac42bd6d..c536b4a585 100644 --- a/profiler-cli/src/test/unit/meta-formatting.test.ts +++ b/profiler-cli/src/test/unit/meta-formatting.test.ts @@ -15,6 +15,7 @@ function createContext(): SessionContext { selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], currentViewRange: null, rootRange: { start: 0, end: 3000 }, + callTreeSummaryStrategy: 'timing', }; } diff --git a/profiler-cli/src/test/unit/network-formatting.test.ts b/profiler-cli/src/test/unit/network-formatting.test.ts index 346680439e..63dfbbbe3c 100644 --- a/profiler-cli/src/test/unit/network-formatting.test.ts +++ b/profiler-cli/src/test/unit/network-formatting.test.ts @@ -23,6 +23,7 @@ function createContext(): SessionContext { selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], currentViewRange: null, rootRange: { start: 0, end: 1000 }, + callTreeSummaryStrategy: 'timing', }; } @@ -398,6 +399,7 @@ function makeThreadInfoResult( markerCount: 0, cpuActivity: null, networkActivity, + availableStrategies: ['timing'], }; } diff --git a/src/profile-query/call-tree-strategy.ts b/src/profile-query/call-tree-strategy.ts new file mode 100644 index 0000000000..5d6f8856a5 --- /dev/null +++ b/src/profile-query/call-tree-strategy.ts @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Helpers for the call tree summary strategy, i.e. which data source a call + * tree summarizes: sample timing, or one of the allocation-based views. + */ + +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { getLastSelectedCallTreeSummaryStrategy } from 'firefox-profiler/selectors/url-state'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { changeCallTreeSummaryStrategy } from '../actions/profile-view'; +import type { State, ThreadIndex } from 'firefox-profiler/types'; +import type { Store } from '../types/store'; +import type { CallTreeSummaryStrategy } from './types'; + +export const CALL_TREE_SUMMARY_STRATEGIES: CallTreeSummaryStrategy[] = [ + 'timing', + 'js-allocations', + 'native-retained-allocations', + 'native-allocations', + 'native-deallocations-memory', + 'native-deallocations-sites', +]; + +/** + * Set the call tree summary strategy around a computation, then restore the + * previous value. `fn` must be synchronous: the store is shared across a + * daemon's connections, so the mutated window has to close before any other + * command can observe it. + */ +export function withCallTreeSummaryStrategy( + store: Store, + strategy: CallTreeSummaryStrategy | undefined, + fn: () => T +): T { + const previous = getLastSelectedCallTreeSummaryStrategy(store.getState()); + if (strategy === undefined || strategy === previous) { + return fn(); + } + store.dispatch(changeCallTreeSummaryStrategy(strategy)); + try { + return fn(); + } finally { + store.dispatch(changeCallTreeSummaryStrategy(previous)); + } +} + +/** + * Retained memory and deallocated memory need to pair each deallocation with its + * allocation, which is only possible when the allocations carry memory addresses. + */ +export function getAvailableStrategies( + state: State, + threadIndexes: Set +): CallTreeSummaryStrategy[] { + const threadSelectors = getThreadSelectors(threadIndexes); + const hasTiming = threadSelectors.getHasUsefulTimingSamples(state); + const hasJsAllocations = threadSelectors.getHasUsefulJsAllocations(state); + const hasNativeAllocations = + threadSelectors.getHasUsefulNativeAllocations(state); + const canShowRetainedMemory = threadSelectors.getCanShowRetainedMemory(state); + + return CALL_TREE_SUMMARY_STRATEGIES.filter((strategy) => { + switch (strategy) { + case 'timing': + return hasTiming; + case 'js-allocations': + return hasJsAllocations; + case 'native-allocations': + case 'native-deallocations-sites': + return hasNativeAllocations; + case 'native-retained-allocations': + case 'native-deallocations-memory': + return canShowRetainedMemory; + default: + throw assertExhaustiveCheck( + strategy, + 'Unhandled call tree summary strategy.' + ); + } + }); +} diff --git a/src/profile-query/formatters/thread-info.ts b/src/profile-query/formatters/thread-info.ts index 96095e4ffc..a22c9960fc 100644 --- a/src/profile-query/formatters/thread-info.ts +++ b/src/profile-query/formatters/thread-info.ts @@ -33,7 +33,9 @@ import { computeCallTreeTimings, getCallTree, computeCallNodeSelfAndSummary, + extractSamplesLikeTable, } from 'firefox-profiler/profile-logic/call-tree'; +import { getAvailableStrategies } from '../call-tree-strategy'; import { getInvertedCallNodeInfo } from 'firefox-profiler/profile-logic/profile-data'; import type { Store } from '../../types/store'; import type { TimestampManager } from '../timestamps'; @@ -94,6 +96,7 @@ export function collectThreadInfo( markerCount: thread.markers.length, cpuActivity, networkActivity, + availableStrategies: getAvailableStrategies(state, threadIndexes), }; } @@ -243,6 +246,8 @@ export function collectThreadSamples( type: 'thread-samples', threadHandle: threadHandleDisplay, friendlyThreadName, + callTreeSummaryStrategy: threadSelectors.getCallTreeSummaryStrategy(state), + weightType: threadSelectors.getWeightTypeForCallTree(state), topFunctionsByTotal, topFunctionsBySelf, heaviestStack, @@ -314,6 +319,8 @@ export function collectThreadSamplesBottomUp( type: 'thread-samples-bottom-up', threadHandle: threadHandleDisplay, friendlyThreadName, + callTreeSummaryStrategy: threadSelectors.getCallTreeSummaryStrategy(state), + weightType, invertedCallTree, }; } @@ -346,13 +353,15 @@ export function collectThreadSamplesTopDown( type: 'thread-samples-top-down', threadHandle: threadHandleDisplay, friendlyThreadName, + callTreeSummaryStrategy: threadSelectors.getCallTreeSummaryStrategy(state), + weightType: threadSelectors.getWeightTypeForCallTree(state), regularCallTree, }; } /** * Collect thread functions data in structured format. - * Lists all functions with their CPU percentages, supporting search and filtering. + * Lists all functions with their weight percentages, supporting search and filtering. */ export function collectThreadFunctions( store: Store, @@ -386,14 +395,17 @@ export function collectThreadFunctions( // We can compute this from any function in allFunctions that has a non-zero totalRelative // Formula: fullTotalSamples = total / totalRelative // But since totalRelative is based on current view, we need the UNzoomed totalRelative - // Simpler approach: The raw thread has all samples - count them directly + // Simpler approach: The unzoomed strategy table has all samples - count them directly let fullProfileTotalSamples: number | null = null; if (isZoomed) { // Use the same weighting as the call tree: sum weights, exclude null-stack samples - const rawThread = threadSelectors.getRawThread(state); - const { weight, stack } = rawThread.samples; + const unzoomedSamples = extractSamplesLikeTable( + threadSelectors.getThread(state), + threadSelectors.getCallTreeSummaryStrategy(state) + ); + const { weight, stack } = unzoomedSamples; let total = 0; - for (let i = 0; i < rawThread.samples.length; i++) { + for (let i = 0; i < unzoomedSamples.length; i++) { if (stack[i] !== null) { total += weight ? (weight[i] ?? 1) : 1; } @@ -412,7 +424,6 @@ export function collectThreadFunctions( ); } - // Filter by minimum self time percentage if (filterOptions?.minSelf !== undefined) { const minSelfFraction = filterOptions.minSelf / 100; filteredFunctions = filteredFunctions.filter( @@ -420,7 +431,6 @@ export function collectThreadFunctions( ); } - // Sort by self time (descending) filteredFunctions.sort((a, b) => b.self - a.self); // Apply limit @@ -469,6 +479,8 @@ export function collectThreadFunctions( type: 'thread-functions', threadHandle: threadHandleDisplay, friendlyThreadName, + callTreeSummaryStrategy: threadSelectors.getCallTreeSummaryStrategy(state), + weightType: threadSelectors.getWeightTypeForCallTree(state), totalFunctionCount, filteredFunctionCount: filteredFunctions.length, filters: filterOptions diff --git a/src/profile-query/function-annotate.ts b/src/profile-query/function-annotate.ts index 20c86a4444..6f880027f5 100644 --- a/src/profile-query/function-annotate.ts +++ b/src/profile-query/function-annotate.ts @@ -30,15 +30,18 @@ import type { Profile, IndexIntoFuncTable, IndexIntoNativeSymbolTable, + SamplesLikeTable, Thread, } from 'firefox-profiler/types'; import type { FunctionAnnotateResult, AnnotateMode, + CallTreeSummaryStrategy, FunctionAsmAnnotation, SourceAnnotationResult, AsmAnnotationsResult, } from './types'; +import { withCallTreeSummaryStrategy } from './call-tree-strategy'; import type { Store } from '../types/store'; class NodeExternalCommunicationDelegate implements ExternalCommunicationDelegate { @@ -71,6 +74,7 @@ async function fetchSourceAnnotation( functionHandle: string, mode: AnnotateMode, thread: Thread, + samples: SamplesLikeTable, profile: Profile, symbolServerUrl: string, archiveCache: Map>, @@ -91,7 +95,6 @@ async function fetchSourceAnnotation( stackTable, frameTable, funcTable: threadFuncTable, - samples, sourceLocationTable, } = thread; @@ -224,6 +227,7 @@ async function fetchAsmAnnotations( functionHandle: string, nativeSymbolsForFunc: Set, thread: Thread, + samples: SamplesLikeTable, profile: Profile, symbolServerUrl: string ): Promise { @@ -235,12 +239,7 @@ async function fetchAsmAnnotations( ); } - const { - stackTable, - frameTable, - funcTable: threadFuncTable, - samples, - } = thread; + const { stackTable, frameTable, funcTable: threadFuncTable } = thread; const nativeSymbolCount = nativeSymbolsForFunc.size; const results = await Promise.all( @@ -334,7 +333,8 @@ export async function functionAnnotate( functionHandle: string, mode: AnnotateMode, symbolServerUrl: string, - contextOption: string + contextOption: string, + strategy?: CallTreeSummaryStrategy ): Promise { const state = store.getState(); const profile = getProfile(state); @@ -352,21 +352,40 @@ export async function functionAnnotate( const fullName = libraryName ? `${libraryName}!${funcName}` : funcName; const threadIndexes = getSelectedThreadIndexes(state); - const threadSelectors = getThreadSelectors(threadIndexes); - const thread = threadSelectors.getFilteredThread(state); - - const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); const threadHandle = threadMap.handleForThreadIndexes(threadIndexes); + // Every strategy-dependent read happens in this synchronous block, so the + // strategy is restored before the fetches below start awaiting. + const { + thread, + ctssSamples, + weightType, + callTreeSummaryStrategy, + friendlyThreadName, + totalSelfSamples, + totalTotalSamples, + } = withCallTreeSummaryStrategy(store, strategy, () => { + const strategyState = store.getState(); + const threadSelectors = getThreadSelectors(threadIndexes); + const { funcSelf, funcTotal } = + threadSelectors.getFunctionListTimings(strategyState); + return { + thread: threadSelectors.getFilteredThread(strategyState), + ctssSamples: threadSelectors.getFilteredCtssSamples(strategyState), + weightType: threadSelectors.getWeightTypeForCallTree(strategyState), + callTreeSummaryStrategy: + threadSelectors.getCallTreeSummaryStrategy(strategyState), + friendlyThreadName: threadSelectors.getFriendlyThreadName(strategyState), + totalSelfSamples: funcSelf[funcIndex], + totalTotalSamples: funcTotal[funcIndex], + }; + }); + const nativeSymbolsForFunc = getNativeSymbolsForFunc( funcIndex, thread.frameTable ); - const { funcSelf, funcTotal } = threadSelectors.getFunctionListTimings(state); - const totalSelfSamples = funcSelf[funcIndex]; - const totalTotalSamples = funcTotal[funcIndex]; - const srcPromise: Promise = mode === 'src' || mode === 'all' ? fetchSourceAnnotation( @@ -374,6 +393,7 @@ export async function functionAnnotate( functionHandle, mode, thread, + ctssSamples, profile, symbolServerUrl, archiveCache, @@ -387,6 +407,7 @@ export async function functionAnnotate( functionHandle, nativeSymbolsForFunc, thread, + ctssSamples, profile, symbolServerUrl ) @@ -407,6 +428,8 @@ export async function functionAnnotate( friendlyThreadName, totalSelfSamples, totalTotalSamples, + callTreeSummaryStrategy, + weightType, mode, srcAnnotation, asmAnnotations, diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 164b524403..3a22c00d2c 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -28,6 +28,7 @@ import { getSelectedThreadIndexes, getTransformStack, getCurrentSearchString, + getLastSelectedCallTreeSummaryStrategy, getProfileSpecificState, getSymbolServerUrl, } from 'firefox-profiler/selectors/url-state'; @@ -36,6 +37,7 @@ import { popCommittedRanges, changeSelectedThreads, changeCallTreeSearchString, + changeCallTreeSummaryStrategy, changeIncludeIdleSamples, popTransformsFromStackForThreads, } from '../actions/profile-view'; @@ -70,6 +72,10 @@ import { import { parseTimeValue } from './time-range-parser'; import { describeTransformGroup, pushSpecTransforms } from './filter-stack'; import { functionAnnotate as computeFunctionAnnotate } from './function-annotate'; +import { + getAvailableStrategies, + withCallTreeSummaryStrategy, +} from './call-tree-strategy'; import type { StartEndRange, ThreadIndex, @@ -85,6 +91,8 @@ import type { AnnotateMode, ViewRangeResult, ThreadSelectResult, + StrategySelectResult, + CallTreeSummaryStrategy, ThreadInfoResult, MarkerStackResult, MarkerInfoResult, @@ -266,13 +274,15 @@ export class ProfileQuerier { threadHandle?: string, includeIdle: boolean = false, search?: string, - sampleFilters?: SampleFilterSpec[] + sampleFilters?: SampleFilterSpec[], + strategy?: CallTreeSummaryStrategy ): Promise> { return this._runWithSampleFilters( threadHandle, includeIdle, search, sampleFilters, + strategy, () => collectThreadSamples(this._store, this._threadMap, threadHandle) ); } @@ -282,13 +292,15 @@ export class ProfileQuerier { callTreeOptions?: CallTreeCollectionOptions, includeIdle: boolean = false, search?: string, - sampleFilters?: SampleFilterSpec[] + sampleFilters?: SampleFilterSpec[], + strategy?: CallTreeSummaryStrategy ): Promise> { return this._runWithSampleFilters( threadHandle, includeIdle, search, sampleFilters, + strategy, () => collectThreadSamplesTopDown( this._store, @@ -304,13 +316,15 @@ export class ProfileQuerier { callTreeOptions?: CallTreeCollectionOptions, includeIdle: boolean = false, search?: string, - sampleFilters?: SampleFilterSpec[] + sampleFilters?: SampleFilterSpec[], + strategy?: CallTreeSummaryStrategy ): Promise> { return this._runWithSampleFilters( threadHandle, includeIdle, search, sampleFilters, + strategy, () => collectThreadSamplesBottomUp( this._store, @@ -570,6 +584,33 @@ export class ProfileQuerier { }; } + /** + * Set the session's call tree summary strategy, i.e. which data source + * later commands summarize. + */ + async strategySelect( + strategy: CallTreeSummaryStrategy, + threadHandle?: string + ): Promise> { + const threadIndexes = + threadHandle !== undefined + ? this._threadMap.threadIndexesForHandle(threadHandle) + : getSelectedThreadIndexes(this._store.getState()); + this._assertStrategyAvailable(threadIndexes, strategy); + this._store.dispatch(changeCallTreeSummaryStrategy(strategy)); + + return { + type: 'strategy-select', + threadHandle: this._threadMap.handleForThreadIndexes(threadIndexes), + strategy, + availableStrategies: getAvailableStrategies( + this._store.getState(), + threadIndexes + ), + context: this._getContext(), + }; + } + /** * Map the current Redux transform stack for `threadsKey` to FilterEntry[], * grouping consecutive transforms that came from the same `filter push` @@ -738,7 +779,7 @@ export class ProfileQuerier { } /** - * Resolve thread indexes, apply idle/search/ephemeral-filter wrappers, collect, + * Resolve thread indexes, apply strategy/idle/search/ephemeral-filter wrappers, collect, * and attach common metadata. Shared by threadSamples, threadSamplesTopDown, * and threadSamplesBottomUp. */ @@ -747,6 +788,7 @@ export class ProfileQuerier { includeIdle: boolean, search: string | undefined, sampleFilters: SampleFilterSpec[] | undefined, + strategy: CallTreeSummaryStrategy | undefined, collect: () => T ): WithContext< T & { @@ -767,10 +809,16 @@ export class ProfileQuerier { const withSearch = search ? () => this._withCallTreeSearch(search, withIdle) : withIdle; - const result = + const withFilters = sampleFilters && sampleFilters.length > 0 - ? this._withEphemeralFilters(threadIndexes, sampleFilters, withSearch) - : withSearch(); + ? () => + this._withEphemeralFilters(threadIndexes, sampleFilters, withSearch) + : withSearch; + const result = this._withValidatedStrategy( + threadIndexes, + strategy, + withFilters + ); const activeFilters = this._collectFilterEntries( getThreadsKey(threadIndexes) ); @@ -781,7 +829,7 @@ export class ProfileQuerier { activeFilters: activeFilters.length > 0 ? activeFilters : undefined, ephemeralFilters: sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, - context: this._getContext(), + context: this._getContext(strategy), }; } @@ -847,6 +895,37 @@ export class ProfileQuerier { } } + /** + * The per-thread `getCallTreeSummaryStrategy` selector silently falls back to + * timing, which would make timing output look like allocation output. + */ + private _withValidatedStrategy( + threadIndexes: Set, + strategy: CallTreeSummaryStrategy | undefined, + fn: () => T + ): T { + if (strategy !== undefined) { + this._assertStrategyAvailable(threadIndexes, strategy); + } + return withCallTreeSummaryStrategy(this._store, strategy, fn); + } + + private _assertStrategyAvailable( + threadIndexes: Set, + strategy: CallTreeSummaryStrategy + ): void { + const available = getAvailableStrategies( + this._store.getState(), + threadIndexes + ); + if (!available.includes(strategy)) { + const handle = this._threadMap.handleForThreadIndexes(threadIndexes); + throw new Error( + `Strategy '${strategy}' has no data in ${handle}. Available: ${available.join(', ') || 'none'}` + ); + } + } + private _buildBaseStatus(state: ReturnType) { const profile = getProfile(state); const rootRange = getProfileRootRange(state); @@ -889,8 +968,13 @@ export class ProfileQuerier { * Get current session context for display in command outputs. * This is a lightweight version of getStatus() that includes only * the current view range (not the full stack). + * + * Commands given a one-shot --strategy pass it as `effectiveStrategy`: the + * store has already been restored to the session value by then. */ - private _getContext(): SessionContext { + private _getContext( + effectiveStrategy?: CallTreeSummaryStrategy + ): SessionContext { const state = this._store.getState(); const { selectedThreadHandle, selectedThreads, viewRanges, rootRange } = this._buildBaseStatus(state); @@ -901,6 +985,8 @@ export class ProfileQuerier { selectedThreads, currentViewRange, rootRange, + callTreeSummaryStrategy: + effectiveStrategy ?? getLastSelectedCallTreeSummaryStrategy(state), }; } @@ -936,6 +1022,7 @@ export class ProfileQuerier { viewRanges, rootRange, filterStacks, + callTreeSummaryStrategy: getLastSelectedCallTreeSummaryStrategy(state), }; } @@ -1110,14 +1197,15 @@ export class ProfileQuerier { } /** - * List all functions for a thread with their CPU percentages. - * Supports filtering by search string, minimum self time, and limit. + * List all functions for a thread with their weight percentages. + * Supports filtering by search string, minimum self weight, and limit. */ async threadFunctions( threadHandle?: string, filterOptions?: FunctionFilterOptions, includeIdle: boolean = false, - sampleFilters?: SampleFilterSpec[] + sampleFilters?: SampleFilterSpec[], + strategy?: CallTreeSummaryStrategy ): Promise> { const activeOnly = !includeIdle; const threadIndexes = @@ -1134,10 +1222,16 @@ export class ProfileQuerier { const withIdle = includeIdle ? () => this._withIncludedIdle(collect) : collect; - const result = + const withFilters = sampleFilters && sampleFilters.length > 0 - ? this._withEphemeralFilters(threadIndexes, sampleFilters, withIdle) - : withIdle(); + ? () => + this._withEphemeralFilters(threadIndexes, sampleFilters, withIdle) + : withIdle; + const result = this._withValidatedStrategy( + threadIndexes, + strategy, + withFilters + ); const activeFilters = this._collectFilterEntries( getThreadsKey(threadIndexes) ); @@ -1147,7 +1241,7 @@ export class ProfileQuerier { activeFilters: activeFilters.length > 0 ? activeFilters : undefined, ephemeralFilters: sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, - context: this._getContext(), + context: this._getContext(strategy), }; } @@ -1179,7 +1273,7 @@ export class ProfileQuerier { } /** - * Annotate a function with per-line source or per-instruction assembly timing data. + * Annotate a function with per-line source or per-instruction assembly weights. * * If `symbolServerUrl` is omitted, falls back to the symbol server resolved * from the loaded profile's URL state (the ?symbolServer= query parameter, @@ -1189,10 +1283,17 @@ export class ProfileQuerier { functionHandle: string, mode: AnnotateMode, symbolServerUrl: string | undefined, - contextOption: string = '2' + contextOption: string = '2', + strategy?: CallTreeSummaryStrategy ): Promise> { const resolvedSymbolServerUrl = symbolServerUrl ?? getSymbolServerUrl(this._store.getState()); + if (strategy !== undefined) { + this._assertStrategyAvailable( + getSelectedThreadIndexes(this._store.getState()), + strategy + ); + } const result = await computeFunctionAnnotate( this._store, this._threadMap, @@ -1200,8 +1301,9 @@ export class ProfileQuerier { functionHandle, mode, resolvedSymbolServerUrl, - contextOption + contextOption, + strategy ); - return { ...result, context: this._getContext() }; + return { ...result, context: this._getContext(strategy) }; } } diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 3fba1cdf44..9f6de10843 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -9,12 +9,16 @@ import type { Transform, + CallTreeSummaryStrategy, CounterGraphType, CounterTooltipDataSource, NetworkStatus, SampleUnits, + WeightType, } from 'firefox-profiler/types'; +export type { CallTreeSummaryStrategy, WeightType }; + // ===== Utility types ===== export type TopMarker = { @@ -126,6 +130,7 @@ export type SessionContext = { start: number; end: number; }; + callTreeSummaryStrategy: CallTreeSummaryStrategy; }; /** @@ -158,6 +163,7 @@ export type StatusResult = { threadHandle: string; filters: FilterEntry[]; }>; + callTreeSummaryStrategy: CallTreeSummaryStrategy; }; // ===== Function Commands ===== @@ -250,6 +256,8 @@ export type FunctionAnnotateResult = { friendlyThreadName: string; totalSelfSamples: number; totalTotalSamples: number; + callTreeSummaryStrategy: CallTreeSummaryStrategy; + weightType: WeightType; mode: AnnotateMode; srcAnnotation: FunctionSourceAnnotation | null; asmAnnotations: FunctionAsmAnnotation[]; @@ -289,6 +297,13 @@ export type ThreadSelectResult = { threadNames: string[]; }; +export type StrategySelectResult = { + type: 'strategy-select'; + threadHandle: string; + strategy: CallTreeSummaryStrategy; + availableStrategies: CallTreeSummaryStrategy[]; +}; + export type ThreadInfoResult = { type: 'thread-info'; threadHandle: string; @@ -312,6 +327,7 @@ export type ThreadInfoResult = { depthLevel: number; }> | null; networkActivity: ThreadNetworkSummary | null; + availableStrategies: CallTreeSummaryStrategy[]; }; export type TopFunctionInfo = FunctionDisplayInfo & { @@ -331,6 +347,8 @@ export type ThreadSamplesResult = { search?: string; activeFilters?: FilterEntry[]; ephemeralFilters?: SampleFilterSpec[]; + callTreeSummaryStrategy: CallTreeSummaryStrategy; + weightType: WeightType; topFunctionsByTotal: TopFunctionInfo[]; topFunctionsBySelf: TopFunctionInfo[]; heaviestStack: { @@ -365,6 +383,8 @@ export type ThreadSamplesTopDownResult = { search?: string; activeFilters?: FilterEntry[]; ephemeralFilters?: SampleFilterSpec[]; + callTreeSummaryStrategy: CallTreeSummaryStrategy; + weightType: WeightType; regularCallTree: CallTreeNode; }; @@ -376,6 +396,8 @@ export type ThreadSamplesBottomUpResult = { search?: string; activeFilters?: FilterEntry[]; ephemeralFilters?: SampleFilterSpec[]; + callTreeSummaryStrategy: CallTreeSummaryStrategy; + weightType: WeightType; invertedCallTree: CallTreeNode | null; }; @@ -645,6 +667,8 @@ export type ThreadFunctionsResult = { activeOnly?: boolean; activeFilters?: FilterEntry[]; ephemeralFilters?: SampleFilterSpec[]; + callTreeSummaryStrategy: CallTreeSummaryStrategy; + weightType: WeightType; totalFunctionCount: number; filteredFunctionCount: number; filters?: {