From e6cbf452b94b724bf3a187bbe6a00e4bba922631 Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 6 Aug 2026 16:38:36 +0200 Subject: [PATCH] Show allocation data in the cli The samples and functions commands only summarized sample timing, so JS and native allocation data was invisible from the cli. Add --strategy to the samples, functions and annotate commands to pick a data source per invocation, and "thread strategy" to set it for the session. Allocation sources weigh the call tree in bytes, and "thread info" lists the sources each thread has. Closes #6183 --- profiler-cli/guide.txt | 26 +++ profiler-cli/schemas.txt | 31 ++- profiler-cli/src/commands/function.ts | 52 +++-- profiler-cli/src/commands/shared.ts | 35 ++- profiler-cli/src/commands/thread.ts | 79 +++++-- profiler-cli/src/daemon.ts | 23 +- profiler-cli/src/formatters.ts | 155 +++++++++---- profiler-cli/src/output.ts | 3 + profiler-cli/src/protocol.ts | 11 +- .../src/test/integration/basic.test.ts | 108 +++++++++ .../allocation-formatting.test.ts.snap | 65 ++++++ .../test/unit/allocation-formatting.test.ts | 219 ++++++++++++++++++ .../test/unit/call-tree-formatting.test.ts | 5 + .../src/test/unit/counter-formatting.test.ts | 1 + .../src/test/unit/marker-formatting.test.ts | 1 + .../src/test/unit/meta-formatting.test.ts | 1 + .../src/test/unit/network-formatting.test.ts | 2 + src/profile-query/call-tree-strategy.ts | 84 +++++++ src/profile-query/formatters/thread-info.ts | 26 ++- src/profile-query/function-annotate.ts | 55 +++-- src/profile-query/index.ts | 142 ++++++++++-- src/profile-query/types.ts | 24 ++ 22 files changed, 1011 insertions(+), 137 deletions(-) create mode 100644 profiler-cli/src/test/unit/__snapshots__/allocation-formatting.test.ts.snap create mode 100644 profiler-cli/src/test/unit/allocation-formatting.test.ts create mode 100644 src/profile-query/call-tree-strategy.ts 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?: {