diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index e6e06749b4..c931857da5 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" @@ -279,6 +282,29 @@ SOURCE MAPS "function annotate f-N" can show it with per-line sample counts. +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 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 e7cf6bc43f..16d1e4c568 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -8,9 +8,17 @@ 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" profiler-cli profile info --json { @@ -105,6 +113,7 @@ profiler-cli thread info --json cpuActivity: [{ startTime, startTimeName, startTimeStr, endTime, endTimeName, endTimeStr, cpuMs, depthLevel }] | null, networkActivity: ThreadNetworkSummary | null, + availableStrategies: [CallTreeSummaryStrategy], context: SessionContext } @@ -121,6 +130,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 }], @@ -148,6 +158,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 @@ -157,6 +168,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 @@ -195,6 +207,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, @@ -251,6 +264,15 @@ profiler-cli marker info --json stack?: { frames: [{ name, nameWithLibrary }], truncated } } +profiler-cli strategy --json + { + type: "strategy-select", + threadHandle, + strategy: CallTreeSummaryStrategy, + availableStrategies: [CallTreeSummaryStrategy], + context: SessionContext + } + profiler-cli status --json { type: "status", @@ -258,7 +280,8 @@ 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 } profiler-cli sourcemap sources --json 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..9f8a3f7367 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-logic/profile-data'; +import type { ClientCommand, CallTreeSummaryStrategy } from '../protocol'; /** * Options shared by every command action via `addGlobalOptions`. @@ -77,6 +78,43 @@ export function parseFloatArg( return v; } +/** + * Parse a strategy name and exit with an error if it is not a valid strategy. + */ +export function parseStrategyArg( + flagName: string, + value: string +): CallTreeSummaryStrategy { + if (!(CALL_TREE_SUMMARY_STRATEGIES as string[]).includes(value)) { + console.error( + `Error: ${flagName} 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('--strategy', 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/strategy.ts b/profiler-cli/src/commands/strategy.ts new file mode 100644 index 0000000000..b3c8fc3b42 --- /dev/null +++ b/profiler-cli/src/commands/strategy.ts @@ -0,0 +1,30 @@ +/* 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/. */ + +/** + * `profiler-cli strategy` command. + */ + +import type { Command } from 'commander'; +import { CALL_TREE_SUMMARY_STRATEGIES } from 'firefox-profiler/profile-logic/profile-data'; +import { addGlobalOptions, parseStrategyArg, runCommand } from './shared'; + +export function registerStrategyCommand( + program: Command, + sessionDir: string +): void { + addGlobalOptions( + program + .command('strategy ') + .description( + `Set the data source that the samples and functions commands summarize: ${CALL_TREE_SUMMARY_STRATEGIES.join(', ')}` + ) + ).action(async (nameArg: string, opts) => { + await runCommand( + sessionDir, + { command: 'strategy', strategy: parseStrategyArg('strategy', nameArg) }, + opts + ); + }); +} diff --git a/profiler-cli/src/commands/thread.ts b/profiler-cli/src/commands/thread.ts index dcc267f7c2..27fe9a6d18 100644 --- a/profiler-cli/src/commands/thread.ts +++ b/profiler-cli/src/commands/thread.ts @@ -11,8 +11,10 @@ import { parseEphemeralFilters, parseLimitArg } from '../utils/parse'; import { addGlobalOptions, addSampleFilterOptions, + addStrategyOption, parseIntArg, parseFloatArg, + parseOptionalStrategyArg, runCommand, } from './shared'; import type { @@ -32,15 +34,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 +52,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(', ')}` ); } @@ -131,6 +135,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 +157,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 +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, }, @@ -438,22 +445,24 @@ Examples: }); // 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 (0 = no limit)' - ) - .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 (0 = no limit)' + ) + .option('--include-idle', 'Include idle samples in percentages') + ) ) ).action(async (opts) => { let functionFilters: FunctionFilterOptions | undefined; @@ -488,6 +497,7 @@ Examples: 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 af22fa2c4c..f04d51d9a6 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -419,7 +419,8 @@ export class Daemon { command.thread, command.includeIdle, command.search, - command.sampleFilters + command.sampleFilters, + command.strategy ); case 'samples-top-down': return this.querier.threadSamplesTopDown( @@ -427,7 +428,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 +437,8 @@ export class Daemon { command.callTreeOptions, command.includeIdle, command.search, - command.sampleFilters + command.sampleFilters, + command.strategy ); case 'markers': return this.querier.threadMarkers( @@ -447,7 +450,8 @@ export class Daemon { command.thread, command.functionFilters, command.includeIdle, - command.sampleFilters + command.sampleFilters, + command.strategy ); case 'network': return this.querier.threadNetwork( @@ -522,11 +526,14 @@ export class Daemon { command.function, command.annotateMode ?? 'src', command.symbolServerUrl, - command.annotateContext ?? '2' + command.annotateContext ?? '2', + command.strategy ); default: throw assertExhaustiveCheck(command); } + case 'strategy': + return this.querier.strategySelect(command.strategy); case 'zoom': switch (command.subcommand) { case 'push': diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 48c171bd23..720cb82fde 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -40,6 +40,9 @@ import type { SampleFilterSpec, ProfileLogsResult, ThreadSelectResult, + StrategySelectResult, + CallTreeSummaryStrategy, + WeightType, CounterSummary, CounterListResult, CounterInfoResult, @@ -139,6 +142,38 @@ function formatCategoryBreakdown( return lines; } +/** + * 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 value < 0 ? `-${formatBytes(-value)}` : 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. @@ -222,7 +257,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}`; } /** @@ -362,6 +398,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:`; @@ -1029,15 +1066,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}`) ?? @@ -1046,7 +1090,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}`; } /** @@ -1073,12 +1117,14 @@ export function formatThreadSamplesResult( 'No samples in the current view.' ).join('\n') + '\n\n'; - // 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, @@ -1089,12 +1135,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, @@ -1107,7 +1152,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`; @@ -1118,12 +1167,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 @@ -1132,7 +1181,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); } } @@ -1141,16 +1190,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`; } @@ -1420,7 +1470,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' ); @@ -1467,11 +1524,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 @@ -1846,11 +1902,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}`); @@ -1863,14 +1927,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` @@ -1886,12 +1948,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( @@ -1917,20 +1979,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}`); } } @@ -2276,3 +2338,15 @@ export function formatApplySourceMapResult( throw assertExhaustiveCheck(result); } } + +/** + * 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/index.ts b/profiler-cli/src/index.ts index aa5590f993..3670626c78 100644 --- a/profiler-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -38,6 +38,7 @@ import { registerThreadCommand } from './commands/thread'; import { registerMarkerCommand } from './commands/marker'; import { registerFunctionCommand } from './commands/function'; import { registerCounterCommand } from './commands/counter'; +import { registerStrategyCommand } from './commands/strategy'; import { registerZoomCommand } from './commands/zoom'; import { registerFilterCommand } from './commands/filter'; import { registerSourcemapCommand } from './commands/sourcemap'; @@ -194,6 +195,7 @@ Examples: registerMarkerCommand(program, SESSION_DIR); registerFunctionCommand(program, SESSION_DIR); registerCounterCommand(program, SESSION_DIR); + registerStrategyCommand(program, SESSION_DIR); registerZoomCommand(program, SESSION_DIR); registerFilterCommand(program, SESSION_DIR); registerSourcemapCommand(program, SESSION_DIR); diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index da258a7a29..9fa06543ac 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -29,6 +29,7 @@ import { formatProfileLogsResult, formatThreadPageLoadResult, formatThreadSelectResult, + formatStrategySelectResult, formatCounterListResult, formatCounterInfoResult, formatSourceMapSourcesResult, @@ -95,6 +96,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 445097dccc..88b45bc36e 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -34,6 +34,9 @@ export type { CategorySubBreakdownEntry, FunctionCategoryBreakdown, FunctionCategoryBreakdowns, + CallTreeSummaryStrategy, + WeightType, + StrategySelectResult, InlineStatus, ThreadMarkersResult, ThreadNetworkResult, @@ -84,6 +87,8 @@ import type { AnnotateMode, ViewRangeResult, ThreadInfoResult, + StrategySelectResult, + CallTreeSummaryStrategy, MarkerStackResult, MarkerInfoResult, ProfileInfoResult, @@ -148,6 +153,7 @@ export type ClientCommand = thread?: string; includeIdle?: boolean; search?: string; + strategy?: CallTreeSummaryStrategy; markerFilters?: MarkerFilterOptions; functionFilters?: FunctionFilterOptions; callTreeOptions?: CallTreeCollectionOptions; @@ -184,6 +190,11 @@ export type ClientCommand = symbolServerUrl?: string; /** "file", "function", or a number of context lines (e.g. "2") */ annotateContext?: string; + strategy?: CallTreeSummaryStrategy; + } + | { + command: 'strategy'; + strategy: CallTreeSummaryStrategy; } | { command: 'zoom'; @@ -239,6 +250,7 @@ export type CommandResult = | WithContext | 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 366ca83b6e..d36c29d69c 100644 --- a/profiler-cli/src/test/integration/basic.test.ts +++ b/profiler-cli/src/test/integration/basic.test.ts @@ -22,6 +22,8 @@ import type { ProfileMetaResult, SessionMetadata, StatusResult, + StrategySelectResult, + ThreadInfoResult, ThreadMarkersResult, ThreadNetworkResult, ThreadPageLoadResult, @@ -29,6 +31,9 @@ import type { 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; @@ -500,6 +505,108 @@ describe('profiler-cli basic functionality', () => { expect(result.stdout).toContain('--limit 0'); }); + 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('native-allocations'); + }); + + 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('native-allocations'); + }); + + it('the strategy command persists across commands', async () => { + await cli(ctx, ['load', ALLOCATION_PROFILE]); + + const selectResult = await cli(ctx, [ + '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..063d38e260 --- /dev/null +++ b/profiler-cli/src/test/unit/__snapshots__/allocation-formatting.test.ts.snap @@ -0,0 +1,70 @@ +// 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 (0 = no 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 + +──── Categories (15 running samples) ──── + + JavaScript ██████████████████████ 12 80.0% + Other ██████ 3 20.0% + +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..ee0a129df5 --- /dev/null +++ b/profiler-cli/src/test/unit/allocation-formatting.test.ts @@ -0,0 +1,359 @@ +/* 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, + collectThreadSamplesBottomUp, + collectThreadSamplesTopDown, + collectThreadFunctions, + collectThreadInfo, +} from 'firefox-profiler/profile-query/formatters/thread-info'; +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, + FunctionFilterOptions, + 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 { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { changeCallTreeSummaryStrategy } from 'firefox-profiler/actions/profile-view'; +import { ensureExists } from 'firefox-profiler/utils/types'; +import type { CallTreeCollectionOptions } from 'firefox-profiler/profile-query/formatters/call-tree'; +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, + filterOptions?: FunctionFilterOptions +) { + const store = createStore(profile, strategy); + return withMockContext( + { + ...collectThreadFunctions(store, threadMap(), 't-0', filterOptions), + activeOnly: true, + }, + strategy + ); +} + +function topDownResult( + profile: Profile, + strategy: CallTreeSummaryStrategy, + callTreeOptions?: CallTreeCollectionOptions +) { + const store = createStore(profile, strategy); + return collectThreadSamplesTopDown( + store, + threadMap(), + 't-0', + callTreeOptions + ); +} + +function bottomUpResult(profile: Profile, strategy: CallTreeSummaryStrategy) { + const store = createStore(profile, strategy); + return collectThreadSamplesBottomUp(store, threadMap(), 't-0'); +} + +function availableStrategiesFor(profile: Profile): CallTreeSummaryStrategy[] { + const store = storeWithProfile(profile); + return getThreadSelectors(0).getAvailableCallTreeSummaryStrategies( + store.getState() + ); +} + +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('negative weights from a deallocation strategy', function () { + // The balanced fixture deallocates 3B at E, 5B at Gjs and 7B at I, for 15B. + function deallocationProfile() { + return getProfileWithBalancedNativeAllocations().profile; + } + + it('orders the top functions by magnitude rather than by signed value', function () { + const result = samplesResult( + deallocationProfile(), + 'native-deallocations-memory' + ); + + expect( + result.topFunctionsByTotal.slice(0, 4).map((f) => f.totalSamples) + ).toEqual([-15, -15, -12, -12]); + expect( + result.topFunctionsBySelf.slice(0, 3).map((f) => f.selfSamples) + ).toEqual([-7, -5, -3]); + }); + + it('picks the heaviest stack by magnitude', function () { + const result = samplesResult( + deallocationProfile(), + 'native-deallocations-memory' + ); + + expect(result.heaviestStack.selfSamples).toBe(-7); + expect(result.heaviestStack.frames.map((f) => f.name)).toEqual([ + 'A', + 'B', + 'Fjs', + 'Gjs', + 'jQuery.js!Hjs', + 'libI.so!I', + ]); + }); + + it('applies --min-self to the magnitude', function () { + const result = functionsResult( + deallocationProfile(), + 'native-deallocations-memory', + { minSelf: 30 } + ); + + expect(result.functions.map((f) => f.selfSamples)).toEqual([-7, -5]); + }); + + it('spends the call tree node budget on the heaviest magnitudes', function () { + const result = topDownResult( + deallocationProfile(), + 'native-deallocations-memory', + { maxNodes: 4 } + ); + + const names = []; + for ( + let node = result.regularCallTree.children[0]; + node !== undefined; + node = node.children[0] + ) { + names.push(node.name); + } + // The -12B branch through Fjs, not the -3B one through C. + expect(names).toEqual(['A', 'B', 'Fjs', 'Gjs']); + + const truncated = ensureExists( + result.regularCallTree.children[0].children[0].childrenTruncated + ); + expect(truncated.maxSamples).toBe(-3); + }); + + it('formats negative byte counts by magnitude so the unit survives', function () { + const result = functionsResult( + deallocationProfile(), + 'native-deallocations-memory' + ); + const megabytes = { + ...result, + functions: result.functions.map((f) => ({ + ...f, + selfSamples: f.selfSamples * 1e6, + totalSamples: f.totalSamples * 1e6, + })), + }; + + expect(formatThreadFunctionsResult(megabytes)).toContain('self: -7.00MB'); + }); +}); + +describe('bottom-up with an allocation strategy', function () { + it('weighs the inverted tree by the allocation table, not the timing samples', function () { + const { profile } = getProfileWithBalancedNativeAllocations(); + const result = bottomUpResult(profile, 'native-deallocations-memory'); + + const root = ensureExists(result.invertedCallTree); + // The three matched deallocations, attributed to their allocation sites. + expect(root.totalSamples).toBe(3 + 5 + 7); + expect( + root.children.map((child) => [child.name, child.selfSamples]) + ).toEqual([ + ['I', -7], + ['Gjs', -5], + ['E', -3], + ]); + }); +}); + +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 28f0da1343..a178f4201b 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', }; } @@ -59,6 +60,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(), }; @@ -126,6 +129,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/category-formatting.test.ts b/profiler-cli/src/test/unit/category-formatting.test.ts index 0a99b10dd2..80df0a1a97 100644 --- a/profiler-cli/src/test/unit/category-formatting.test.ts +++ b/profiler-cli/src/test/unit/category-formatting.test.ts @@ -20,6 +20,7 @@ function createMockContext(): SessionContext { selectedThreads: [{ threadIndex: 0, name: 'Test Thread' }], currentViewRange: null, rootRange: { start: 0, end: 1000 }, + callTreeSummaryStrategy: 'timing', }; } @@ -64,6 +65,8 @@ function makeSamplesResult( threadHandle: 't-0', friendlyThreadName: 'Test Thread', categoryBreakdown, + callTreeSummaryStrategy: 'timing', + weightType: 'samples', topFunctionsByTotal: [ { functionHandle: 'f-0', diff --git a/profiler-cli/src/test/unit/counter-formatting.test.ts b/profiler-cli/src/test/unit/counter-formatting.test.ts index 462c452373..7e46e4580a 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 7f4675fd27..a2dc26fd37 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 30ad511ade..b0ac9a9d0e 100644 --- a/profiler-cli/src/test/unit/network-formatting.test.ts +++ b/profiler-cli/src/test/unit/network-formatting.test.ts @@ -26,6 +26,7 @@ function createContext(): SessionContext { selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], currentViewRange: null, rootRange: { start: 0, end: 1000 }, + callTreeSummaryStrategy: 'timing', }; } @@ -466,6 +467,7 @@ function makeThreadInfoResult( markerCount: 0, cpuActivity: null, networkActivity, + availableStrategies: ['timing'], }; } diff --git a/profiler-cli/src/test/unit/sourcemap-formatting.test.ts b/profiler-cli/src/test/unit/sourcemap-formatting.test.ts index 2a7ac53bee..fc714bed25 100644 --- a/profiler-cli/src/test/unit/sourcemap-formatting.test.ts +++ b/profiler-cli/src/test/unit/sourcemap-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/src/components/shared/CallTreeStrategySetting.tsx b/src/components/shared/CallTreeStrategySetting.tsx index 5667fbf0af..39f11b155e 100644 --- a/src/components/shared/CallTreeStrategySetting.tsx +++ b/src/components/shared/CallTreeStrategySetting.tsx @@ -27,10 +27,7 @@ type OwnProps = { type StateProps = { readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; - readonly hasUsefulTimingSamples: boolean; - readonly hasUsefulJsAllocations: boolean; - readonly hasUsefulNativeAllocations: boolean; - readonly canShowRetainedMemory: boolean; + readonly availableCallTreeSummaryStrategies: CallTreeSummaryStrategy[]; }; type DispatchProps = { @@ -39,36 +36,30 @@ type DispatchProps = { type Props = ConnectedProps; +const STRATEGY_L10N_IDS: Record = { + timing: 'StackSettings--call-tree-strategy-timing', + 'js-allocations': 'StackSettings--call-tree-strategy-js-allocations', + 'native-retained-allocations': + 'StackSettings--call-tree-strategy-native-retained-allocations', + 'native-allocations': 'StackSettings--call-tree-native-allocations', + 'native-deallocations-memory': + 'StackSettings--call-tree-strategy-native-deallocations-memory', + 'native-deallocations-sites': + 'StackSettings--call-tree-strategy-native-deallocations-sites', +}; + class CallTreeStrategySettingImpl extends PureComponent { _onCallTreeSummaryStrategyChange = ( e: React.ChangeEvent ) => { this.props.changeCallTreeSummaryStrategy( - // This function is here to satisfy Flow that we are getting a valid - // implementation filter. toValidCallTreeSummaryStrategy(e.currentTarget.value) ); }; - _renderCallTreeStrategyOption( - labelL10nId: string, - strategy: CallTreeSummaryStrategy - ) { - return ( - - - - ); - } - override render() { - const { - hasUsefulTimingSamples, - hasUsefulJsAllocations, - hasUsefulNativeAllocations, - canShowRetainedMemory, - callTreeSummaryStrategy, - } = this.props; + const { availableCallTreeSummaryStrategies, callTreeSummaryStrategy } = + this.props; return ( <> @@ -79,42 +70,15 @@ class CallTreeStrategySettingImpl extends PureComponent { onChange={this._onCallTreeSummaryStrategyChange} value={callTreeSummaryStrategy} > - {hasUsefulTimingSamples - ? this._renderCallTreeStrategyOption( - 'StackSettings--call-tree-strategy-timing', - 'timing' - ) - : null} - {hasUsefulJsAllocations - ? this._renderCallTreeStrategyOption( - 'StackSettings--call-tree-strategy-js-allocations', - 'js-allocations' - ) - : null} - {canShowRetainedMemory - ? this._renderCallTreeStrategyOption( - 'StackSettings--call-tree-strategy-native-retained-allocations', - 'native-retained-allocations' - ) - : null} - {hasUsefulNativeAllocations - ? this._renderCallTreeStrategyOption( - 'StackSettings--call-tree-native-allocations', - 'native-allocations' - ) - : null} - {canShowRetainedMemory - ? this._renderCallTreeStrategyOption( - 'StackSettings--call-tree-strategy-native-deallocations-memory', - 'native-deallocations-memory' - ) - : null} - {hasUsefulNativeAllocations - ? this._renderCallTreeStrategyOption( - 'StackSettings--call-tree-strategy-native-deallocations-sites', - 'native-deallocations-sites' - ) - : null} + {availableCallTreeSummaryStrategies.map((strategy) => ( + + + + ))} @@ -128,14 +92,8 @@ export const CallTreeStrategySetting = explicitConnect< DispatchProps >({ mapStateToProps: (state) => ({ - hasUsefulTimingSamples: - selectedThreadSelectors.getHasUsefulTimingSamples(state), - hasUsefulJsAllocations: - selectedThreadSelectors.getHasUsefulJsAllocations(state), - hasUsefulNativeAllocations: - selectedThreadSelectors.getHasUsefulNativeAllocations(state), - canShowRetainedMemory: - selectedThreadSelectors.getCanShowRetainedMemory(state), + availableCallTreeSummaryStrategies: + selectedThreadSelectors.getAvailableCallTreeSummaryStrategies(state), callTreeSummaryStrategy: selectedThreadSelectors.getCallTreeSummaryStrategy(state), }), diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 4207dfa7e8..29d7d90023 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -1719,6 +1719,18 @@ export function toValidImplementationFilter( } } +/** + * The order here is the order of the data source dropdown. + */ +export const CALL_TREE_SUMMARY_STRATEGIES: CallTreeSummaryStrategy[] = [ + 'timing', + 'js-allocations', + 'native-retained-allocations', + 'native-allocations', + 'native-deallocations-memory', + 'native-deallocations-sites', +]; + export function toValidCallTreeSummaryStrategy( strategy: string | undefined ): CallTreeSummaryStrategy { diff --git a/src/profile-query/call-tree-strategy.ts b/src/profile-query/call-tree-strategy.ts new file mode 100644 index 0000000000..cfa545cc90 --- /dev/null +++ b/src/profile-query/call-tree-strategy.ts @@ -0,0 +1,36 @@ +/* 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 { getLastSelectedCallTreeSummaryStrategy } from 'firefox-profiler/selectors/url-state'; +import { changeCallTreeSummaryStrategy } from '../actions/profile-view'; +import type { Store } from '../types/store'; +import type { CallTreeSummaryStrategy } from './types'; + +/** + * 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)); + } +} diff --git a/src/profile-query/formatters/call-tree.ts b/src/profile-query/formatters/call-tree.ts index fb3a96b26b..6cc0cc3b84 100644 --- a/src/profile-query/formatters/call-tree.ts +++ b/src/profile-query/formatters/call-tree.ts @@ -42,21 +42,22 @@ function computeInclusionScore( depth: number, strategy: CallTreeScoringStrategy ): number { + const weight = Math.abs(totalPercentage); switch (strategy) { case 'exponential-0.95': - return totalPercentage * Math.pow(0.95, depth); + return weight * Math.pow(0.95, depth); case 'exponential-0.9': - return totalPercentage * Math.pow(0.9, depth); + return weight * Math.pow(0.9, depth); case 'exponential-0.8': - return totalPercentage * Math.pow(0.8, depth); + return weight * Math.pow(0.8, depth); case 'harmonic-0.1': - return totalPercentage / (1 + 0.1 * depth); + return weight / (1 + 0.1 * depth); case 'harmonic-0.5': - return totalPercentage / (1 + 0.5 * depth); + return weight / (1 + 0.5 * depth); case 'harmonic-1.0': - return totalPercentage / (1 + depth); + return weight / (1 + depth); case 'percentage-only': - return totalPercentage; + return weight; default: throw assertExhaustiveCheck(strategy); } @@ -333,7 +334,9 @@ function buildTreeStructure( for (const childIdx of elidedChildren) { const childData = tree.getNodeData(childIdx); combinedSamples += childData.total; - maxSamples = Math.max(maxSamples, childData.total); + if (Math.abs(childData.total) > Math.abs(maxSamples)) { + maxSamples = childData.total; + } } const combinedRelative = combinedSamples / totalSampleCount; diff --git a/src/profile-query/formatters/thread-info.ts b/src/profile-query/formatters/thread-info.ts index fd3ae2055e..2956c2cee1 100644 --- a/src/profile-query/formatters/thread-info.ts +++ b/src/profile-query/formatters/thread-info.ts @@ -33,8 +33,12 @@ import { computeCallTreeTimings, getCallTree, computeCallNodeSelfAndSummary, + extractSamplesLikeTable, } from 'firefox-profiler/profile-logic/call-tree'; -import { getInvertedCallNodeInfo } from 'firefox-profiler/profile-logic/profile-data'; +import { + getInvertedCallNodeInfo, + getSampleIndexToCallNodeIndex, +} from 'firefox-profiler/profile-logic/profile-data'; import type { Store } from '../../types/store'; import type { TimestampManager } from '../timestamps'; import type { ThreadMap } from '../thread-map'; @@ -94,6 +98,8 @@ export function collectThreadInfo( markerCount: thread.markers.length, cpuActivity, networkActivity, + availableStrategies: + threadSelectors.getAvailableCallTreeSummaryStrategies(state), }; } @@ -125,13 +131,13 @@ export function collectThreadSamples( // Sort by total and take top 50 const sortedByTotal = functions .slice() - .sort((a, b) => b.total - a.total) + .sort((a, b) => Math.abs(b.total) - Math.abs(a.total)) .slice(0, 50); // Sort by self and take top 50 const sortedBySelf = functions .slice() - .sort((a, b) => b.self - a.self) + .sort((a, b) => Math.abs(b.self) - Math.abs(a.self)) .slice(0, 50); // Convert top functions to structured format @@ -173,7 +179,7 @@ export function collectThreadSamples( if (roots.length > 0) { let heaviestPath: CallNodePath = []; - let maxSelfSamples = Number.NEGATIVE_INFINITY; + let maxAbsSelfSamples = -1; for (const root of roots) { const candidatePath = callTree._internal.findHeaviestPathInSubtree(root); @@ -184,10 +190,12 @@ export function collectThreadSamples( continue; } - const candidateSelfSamples = callTree.getNodeData(leafNodeIndex).self; - if (candidateSelfSamples > maxSelfSamples) { + const candidateSelfSamples = Math.abs( + callTree.getNodeData(leafNodeIndex).self + ); + if (candidateSelfSamples > maxAbsSelfSamples) { heaviestPath = candidatePath; - maxSelfSamples = candidateSelfSamples; + maxAbsSelfSamples = candidateSelfSamples; } } @@ -239,6 +247,8 @@ export function collectThreadSamples( threadHandle: threadHandleDisplay, friendlyThreadName, categoryBreakdown: collectThreadCategoryBreakdown(store, threadIndexes), + callTreeSummaryStrategy: threadSelectors.getCallTreeSummaryStrategy(state), + weightType: threadSelectors.getWeightTypeForCallTree(state), topFunctionsByTotal, topFunctionsBySelf, heaviestStack, @@ -272,10 +282,10 @@ export function collectThreadSamplesBottomUp( const weightType = threadSelectors.getWeightTypeForCallTree(state); const samples = threadSelectors.getPreviewFilteredCtssSamples(state); - const sampleIndexToCallNodeIndex = - threadSelectors.getSampleIndexToNonInvertedCallNodeIndexForFilteredThread( - state - ); + const sampleIndexToCallNodeIndex = getSampleIndexToCallNodeIndex( + samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ); const callNodeSelfAndSummary = computeCallNodeSelfAndSummary( samples, @@ -309,6 +319,8 @@ export function collectThreadSamplesBottomUp( type: 'thread-samples-bottom-up', threadHandle: threadHandleDisplay, friendlyThreadName, + callTreeSummaryStrategy: threadSelectors.getCallTreeSummaryStrategy(state), + weightType, invertedCallTree, }; } @@ -340,13 +352,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, @@ -379,16 +393,19 @@ 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; + // Use the same weighting as the call tree: sum absolute weights, exclude null-stack 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; + total += weight ? Math.abs(weight[i] ?? 1) : 1; } } fullProfileTotalSamples = total; @@ -405,16 +422,14 @@ export function collectThreadFunctions( ); } - // Filter by minimum self time percentage if (filterOptions?.minSelf !== undefined) { const minSelfFraction = filterOptions.minSelf / 100; filteredFunctions = filteredFunctions.filter( - (func) => func.selfRelative >= minSelfFraction + (func) => Math.abs(func.selfRelative) >= minSelfFraction ); } - // Sort by self time (descending) - filteredFunctions.sort((a, b) => b.self - a.self); + filteredFunctions.sort((a, b) => Math.abs(b.self) - Math.abs(a.self)); // Apply limit const limit = filterOptions?.limit ?? filteredFunctions.length; @@ -462,6 +477,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 715e18a22b..fec5c6b289 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 2355e0edb1..c2565a1b9d 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -32,6 +32,7 @@ import { getSelectedThreadIndexes, getTransformStack, getCurrentSearchString, + getLastSelectedCallTreeSummaryStrategy, getProfileSpecificState, getSymbolServerUrl, } from 'firefox-profiler/selectors/url-state'; @@ -40,6 +41,7 @@ import { popCommittedRanges, changeSelectedThreads, changeCallTreeSearchString, + changeCallTreeSummaryStrategy, changeIncludeIdleSamples, popTransformsFromStackForThreads, } from '../actions/profile-view'; @@ -89,6 +91,7 @@ import { import { parseTimeValue } from './time-range-parser'; import { describeTransformGroup, pushSpecTransforms } from './filter-stack'; import { functionAnnotate as computeFunctionAnnotate } from './function-annotate'; +import { withCallTreeSummaryStrategy } from './call-tree-strategy'; import type { IndexIntoSourceTable, StartEndRange, @@ -105,6 +108,8 @@ import type { AnnotateMode, ViewRangeResult, ThreadSelectResult, + StrategySelectResult, + CallTreeSummaryStrategy, ThreadInfoResult, MarkerStackResult, MarkerInfoResult, @@ -298,13 +303,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) ); } @@ -314,13 +321,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, @@ -336,13 +345,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, @@ -602,6 +613,28 @@ export class ProfileQuerier { }; } + /** + * Set the session's call tree summary strategy, i.e. which data source + * later commands summarize. + */ + async strategySelect( + strategy: CallTreeSummaryStrategy + ): Promise> { + const threadIndexes = getSelectedThreadIndexes(this._store.getState()); + this._assertStrategyAvailable(threadIndexes, strategy); + this._store.dispatch(changeCallTreeSummaryStrategy(strategy)); + + return { + type: 'strategy-select', + threadHandle: this._threadMap.handleForThreadIndexes(threadIndexes), + strategy, + availableStrategies: getThreadSelectors( + threadIndexes + ).getAvailableCallTreeSummaryStrategies(this._store.getState()), + context: this._getContext(), + }; + } + /** * List every bundle source that carries a `sourceMapURL` and is therefore * eligible for `sourcemap apply`. Read-only. @@ -897,7 +930,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. */ @@ -906,6 +939,7 @@ export class ProfileQuerier { includeIdle: boolean, search: string | undefined, sampleFilters: SampleFilterSpec[] | undefined, + strategy: CallTreeSummaryStrategy | undefined, collect: () => T ): WithContext< T & { @@ -926,10 +960,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) ); @@ -940,7 +980,7 @@ export class ProfileQuerier { activeFilters: activeFilters.length > 0 ? activeFilters : undefined, ephemeralFilters: sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, - context: this._getContext(), + context: this._getContext(strategy), }; } @@ -1006,6 +1046,36 @@ 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 = getThreadSelectors( + threadIndexes + ).getAvailableCallTreeSummaryStrategies(this._store.getState()); + 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); @@ -1048,8 +1118,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); @@ -1060,9 +1135,21 @@ export class ProfileQuerier { selectedThreads, currentViewRange, rootRange, + callTreeSummaryStrategy: + effectiveStrategy ?? this._getEffectiveStrategy(state), }; } + private _getEffectiveStrategy( + state: ReturnType + ): CallTreeSummaryStrategy { + const threadIndexes = getSelectedThreadIndexes(state); + if (threadIndexes.size === 0) { + return getLastSelectedCallTreeSummaryStrategy(state); + } + return getThreadSelectors(threadIndexes).getCallTreeSummaryStrategy(state); + } + /** * Get current session status including selected threads and view ranges. */ @@ -1095,6 +1182,7 @@ export class ProfileQuerier { viewRanges, rootRange, filterStacks, + callTreeSummaryStrategy: this._getEffectiveStrategy(state), }; } @@ -1271,14 +1359,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 = @@ -1295,10 +1384,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) ); @@ -1308,7 +1403,7 @@ export class ProfileQuerier { activeFilters: activeFilters.length > 0 ? activeFilters : undefined, ephemeralFilters: sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, - context: this._getContext(), + context: this._getContext(strategy), }; } @@ -1340,7 +1435,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, @@ -1350,10 +1445,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, @@ -1361,8 +1463,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 ce44db98ff..21857dbb87 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 = { @@ -127,6 +131,7 @@ export type SessionContext = { start: number; end: number; }; + callTreeSummaryStrategy: CallTreeSummaryStrategy; }; /** @@ -159,6 +164,7 @@ export type StatusResult = { threadHandle: string; filters: FilterEntry[]; }>; + callTreeSummaryStrategy: CallTreeSummaryStrategy; }; // ===== Category Breakdown ===== @@ -293,6 +299,8 @@ export type FunctionAnnotateResult = { friendlyThreadName: string; totalSelfSamples: number; totalTotalSamples: number; + callTreeSummaryStrategy: CallTreeSummaryStrategy; + weightType: WeightType; mode: AnnotateMode; srcAnnotation: FunctionSourceAnnotation | null; asmAnnotations: FunctionAsmAnnotation[]; @@ -332,6 +340,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; @@ -355,6 +370,7 @@ export type ThreadInfoResult = { depthLevel: number; }> | null; networkActivity: ThreadNetworkSummary | null; + availableStrategies: CallTreeSummaryStrategy[]; }; export type TopFunctionInfo = FunctionDisplayInfo & { @@ -375,6 +391,8 @@ export type ThreadSamplesResult = { activeFilters?: FilterEntry[]; ephemeralFilters?: SampleFilterSpec[]; categoryBreakdown: CategoryBreakdown; + callTreeSummaryStrategy: CallTreeSummaryStrategy; + weightType: WeightType; topFunctionsByTotal: TopFunctionInfo[]; topFunctionsBySelf: TopFunctionInfo[]; heaviestStack: { @@ -409,6 +427,8 @@ export type ThreadSamplesTopDownResult = { search?: string; activeFilters?: FilterEntry[]; ephemeralFilters?: SampleFilterSpec[]; + callTreeSummaryStrategy: CallTreeSummaryStrategy; + weightType: WeightType; regularCallTree: CallTreeNode; }; @@ -420,6 +440,8 @@ export type ThreadSamplesBottomUpResult = { search?: string; activeFilters?: FilterEntry[]; ephemeralFilters?: SampleFilterSpec[]; + callTreeSummaryStrategy: CallTreeSummaryStrategy; + weightType: WeightType; invertedCallTree: CallTreeNode | null; }; @@ -692,6 +714,8 @@ export type ThreadFunctionsResult = { activeOnly?: boolean; activeFilters?: FilterEntry[]; ephemeralFilters?: SampleFilterSpec[]; + callTreeSummaryStrategy: CallTreeSummaryStrategy; + weightType: WeightType; totalFunctionCount: number; filteredFunctionCount: number; filters?: { diff --git a/src/selectors/per-thread/thread.tsx b/src/selectors/per-thread/thread.tsx index f83961bbbe..2dbd9620a7 100644 --- a/src/selectors/per-thread/thread.tsx +++ b/src/selectors/per-thread/thread.tsx @@ -395,6 +395,44 @@ export function getBasicThreadSelectorsPerThread( return 'memoryAddress' in nativeAllocations; }; + /** + * Retained memory and deallocated memory need to pair each deallocation with its + * allocation, which is only possible when the allocations carry memory addresses. + */ + const getAvailableCallTreeSummaryStrategies: Selector< + CallTreeSummaryStrategy[] + > = createSelector( + getHasUsefulTimingSamples, + getHasUsefulJsAllocations, + getHasUsefulNativeAllocations, + getCanShowRetainedMemory, + ( + hasUsefulTimingSamples, + hasUsefulJsAllocations, + hasUsefulNativeAllocations, + canShowRetainedMemory + ) => + ProfileData.CALL_TREE_SUMMARY_STRATEGIES.filter((strategy) => { + switch (strategy) { + case 'timing': + return hasUsefulTimingSamples; + case 'js-allocations': + return hasUsefulJsAllocations; + case 'native-allocations': + case 'native-deallocations-sites': + return hasUsefulNativeAllocations; + case 'native-retained-allocations': + case 'native-deallocations-memory': + return canShowRetainedMemory; + default: + throw assertExhaustiveCheck( + strategy, + 'Unhandled call tree summary strategy.' + ); + } + }) + ); + /** * The JS tracer selectors are placed in the thread selectors since there are * not many of them. If this section grows, then consider breaking them out @@ -475,6 +513,7 @@ export function getBasicThreadSelectorsPerThread( getHasUsefulJsAllocations, getHasUsefulNativeAllocations, getCanShowRetainedMemory, + getAvailableCallTreeSummaryStrategies, getProcessedEventDelays, getCallTreeSummaryStrategy, };