diff --git a/profiler-cli/README.md b/profiler-cli/README.md index 8d367ffd61..29a7119401 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -35,7 +35,7 @@ profiler-cli profile meta # Print profile metadata (application profiler-cli profile logs # Print Log markers in MOZ_LOG format [--thread] [--module] [--level] [--search] [--limit] profiler-cli thread info # Print detailed thread information profiler-cli thread select # Select a thread (e.g., t-0, t-1) -profiler-cli thread samples # Show hot functions list for current thread +profiler-cli thread samples # Show hot functions list and category breakdown for current thread profiler-cli thread samples-top-down # Show top-down call tree (where CPU time is spent) profiler-cli thread samples-bottom-up # Show bottom-up call tree (what calls hot functions) profiler-cli thread markers # List markers with aggregated statistics [--list for flat per-marker view] @@ -45,7 +45,7 @@ profiler-cli thread page-load # Show page load summary (navigation profiler-cli marker info # Show detailed marker information (e.g., m-1234) profiler-cli marker stack # Show full stack trace for a marker profiler-cli function expand # Show full untruncated function name (e.g., f-123) -profiler-cli function info # Show detailed function information +profiler-cli function info # Show detailed function information and category breakdown profiler-cli function annotate # Show annotated source/assembly with timing data [--mode src|asm|all] [--context 2|file|N] [--symbol-server ] profiler-cli zoom push # Push a zoom range (e.g., 2.7,3.1 or ts-g,ts-G or m-158) profiler-cli zoom pop # Pop the most recent zoom range diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index b5d830ea88..6a458e4669 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -130,10 +130,20 @@ profiler-cli thread samples --json frames: [{ name, nameWithLibrary, library?, selfSamples, selfPercentage, totalSamples, totalPercentage }] }, + categoryBreakdown?: CategoryBreakdown, activeFilters?, ephemeralFilters?, context: SessionContext } +CategoryBreakdown: + { + totalSamples, + categories: [{ + name, categoryIndex, samples, percentage, + subcategories: [{ name, subcategoryIndex, samples, percentage }] + }] + } + profiler-cli thread samples-top-down --json { type: "thread-samples-top-down", @@ -216,6 +226,20 @@ profiler-cli thread network --json context: SessionContext } +profiler-cli function info --json + { + type: "function-info", + functionHandle, funcIndex, name, fullName, isJS, relevantForJS, + resource?: { name, index }, + library?: { name, path, debugName?, debugPath?, breakpadId? }, + categoryBreakdown?: { + threadHandle, friendlyThreadName, threadSamples, + running: CategoryBreakdown & { samples, percentageOfThread }, + self: CategoryBreakdown & { samples, percentageOfThread } + }, + context: SessionContext + } + profiler-cli marker info --json { type: "marker-info", diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 02c74104c1..e6637f21bd 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -34,6 +34,7 @@ import type { ProfileNetworkSummary, MarkerGroupData, CallTreeNode, + CategoryBreakdown, InlineStatus, FilterEntry, SampleFilterSpec, @@ -76,6 +77,68 @@ const INLINE_LEGEND = 'Note: (inl) = inlined by the compiler into the nearest non-inlined ancestor above. ' + '(inl?) = some calls were inlined by the compiler.'; +const BAR_WIDTH = 28; + +/** + * Render aligned `label / bar / count / percentage` rows. `barRatio` is the + * fraction of the bar's full width to fill, which is not always the same as + * `percentage`; some tables scale their bars against the largest row instead. + */ +function formatBarRows( + rows: Array<{ + label: string; + count: number; + percentage: number; + barRatio: number; + }> +): string[] { + const maxLabelLen = Math.max(...rows.map((row) => row.label.length)); + + return rows.map((row) => { + const barLen = Math.round(row.barRatio * BAR_WIDTH); + const bar = '█'.repeat(barLen).padEnd(BAR_WIDTH); + const label = row.label.padEnd(maxLabelLen); + const countStr = String(row.count).padStart(6); + const pctStr = row.percentage.toFixed(1).padStart(5); + return ` ${label} ${bar} ${countStr} ${pctStr}%`; + }); +} + +/** + * Render a category breakdown as a `──── title ────` section, with each + * category followed by its non-empty subcategories, indented. + */ +function formatCategoryBreakdown( + title: string, + breakdown: CategoryBreakdown, + emptyMessage: string +): string[] { + const lines = [`──── ${title} ────`, '']; + + if (breakdown.categories.length === 0) { + lines.push(` ${emptyMessage}`); + return lines; + } + + const rows = breakdown.categories.flatMap((category) => [ + { + label: category.name, + count: Math.round(category.samples), + percentage: category.percentage, + barRatio: Math.abs(category.samples) / breakdown.totalSamples, + }, + ...category.subcategories.map((subcategory) => ({ + label: ` ${subcategory.name}`, + count: Math.round(subcategory.samples), + percentage: subcategory.percentage, + barRatio: Math.abs(subcategory.samples) / breakdown.totalSamples, + })), + ]); + + lines.push(...formatBarRows(rows)); + return lines; +} + /** * Format a SessionContext as a compact header line. * Shows current thread selection, zoom range, and full profile duration. @@ -227,6 +290,28 @@ Function ${result.functionHandle}: } } + const { running, self, threadHandle, friendlyThreadName } = + result.categoryBreakdown; + + if (running.samples === 0) { + output += `\n\n No samples for this function on ${threadHandle} (${friendlyThreadName}) in the current view.`; + } else { + for (const [kind, breakdown] of [ + ['running', running], + ['self', self], + ] as const) { + const count = Math.round(breakdown.samples); + const share = breakdown.percentageOfThread.toFixed(1); + output += + '\n\n' + + formatCategoryBreakdown( + `Categories: ${kind} (${count} samples, ${share}% of thread)`, + breakdown, + `No ${kind} samples for this function in the current view.` + ).join('\n'); + } + } + return output; } @@ -985,6 +1070,13 @@ export function formatThreadSamplesResult( return output; } + output += + formatCategoryBreakdown( + `Categories (${Math.round(result.categoryBreakdown.totalSamples)} running samples)`, + result.categoryBreakdown, + 'No samples in the current view.' + ).join('\n') + '\n\n'; + // Top functions by total time output += 'Top Functions (by total time):\n'; output += @@ -2028,19 +2120,18 @@ export function formatThreadPageLoadResult( if (result.categories.length === 0) { lines.push(' No sample data available during page load.'); } else { - const BAR_WIDTH = 28; const maxCount = result.categories[0].count; - const maxNameLen = Math.max(...result.categories.map((c) => c.name.length)); - for (const cat of result.categories) { - const barLen = - maxCount > 0 ? Math.round((cat.count / maxCount) * BAR_WIDTH) : 0; - const bar = '█'.repeat(barLen).padEnd(BAR_WIDTH); - const name = cat.name.padEnd(maxNameLen); - const countStr = String(cat.count).padStart(6); - const pctStr = cat.percentage.toFixed(1).padStart(5); - lines.push(` ${name} ${bar} ${countStr} ${pctStr}%`); - } + lines.push( + ...formatBarRows( + result.categories.map((cat) => ({ + label: cat.name, + count: cat.count, + percentage: cat.percentage, + barRatio: maxCount > 0 ? cat.count / maxCount : 0, + })) + ) + ); } lines.push(''); diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 98aa1b4986..445097dccc 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -29,6 +29,11 @@ export type { ThreadSamplesBottomUpResult, CallTreeNode, CallTreeScoringStrategy, + CategoryBreakdown, + CategoryBreakdownEntry, + CategorySubBreakdownEntry, + FunctionCategoryBreakdown, + FunctionCategoryBreakdowns, InlineStatus, ThreadMarkersResult, ThreadNetworkResult, diff --git a/profiler-cli/src/test/integration/basic.test.ts b/profiler-cli/src/test/integration/basic.test.ts index 598d729833..ad2aa33b4c 100644 --- a/profiler-cli/src/test/integration/basic.test.ts +++ b/profiler-cli/src/test/integration/basic.test.ts @@ -18,6 +18,7 @@ import { import type { FilterStackResult, + FunctionInfoResult, ProfileMetaResult, SessionMetadata, StatusResult, @@ -308,6 +309,40 @@ describe('profiler-cli basic functionality', () => { expect(status.filterStacks).toHaveLength(0); }); + it('thread samples breaks the samples down by category', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const textResult = await cli(ctx, ['thread', 'samples']); + expect(textResult.stdout).toContain('──── Categories ('); + + const jsonResult = await cli(ctx, ['thread', 'samples', '--json']); + const samples = JSON.parse( + jsonResult.stdout + ) as WithContext; + const breakdown = samples.categoryBreakdown; + + expect(breakdown.categories.length).toBeGreaterThan(0); + const summed = breakdown.categories.reduce( + (accum, category) => accum + category.samples, + 0 + ); + expect(summed).toBe(breakdown.totalSamples); + }); + + it('function info reports running and self breakdowns', async () => { + await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); + + const result = await cli(ctx, ['function', 'info', 'f-5', '--json']); + const info = JSON.parse(result.stdout) as WithContext; + const breakdowns = info.categoryBreakdown; + + expect(breakdowns.threadHandle).toBe('t-2'); + expect(breakdowns.running.samples).toBeGreaterThan(0); + expect(breakdowns.running.samples).toBeGreaterThanOrEqual( + breakdowns.self.samples + ); + }); + it('max-lines=0 is rejected instead of silently falling back to the default', async () => { await cli(ctx, ['load', 'src/test/fixtures/upgrades/processed-1.json']); diff --git a/profiler-cli/src/test/unit/__snapshots__/category-formatting.test.ts.snap b/profiler-cli/src/test/unit/__snapshots__/category-formatting.test.ts.snap new file mode 100644 index 0000000000..319de70ba7 --- /dev/null +++ b/profiler-cli/src/test/unit/__snapshots__/category-formatting.test.ts.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`category breakdown formatting renders categories with their subcategories in thread samples 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Thread: Test Thread + +──── Categories (100 running samples) ──── + + Layout █████████████████ 60 60.0% + Reflow █████████████ 45 45.0% + Other ████ 15 15.0% + JavaScript ███████████ 40 40.0% + +Top Functions (by total time): + (For a call tree starting from these functions, use: profiler-cli thread samples-top-down) + + f-0. A - total: 100 (100.0%) + +Top Functions (by self time): + (For a call tree showing what calls these functions, use: profiler-cli thread samples-bottom-up) + + +Heaviest stack (0.0 samples, 0 frames): + (empty) +" +`; + +exports[`category breakdown formatting renders the running and self breakdowns in function info 1`] = ` +"[Thread: t-0 (Test Thread) | View: Full profile | Full: 1s] + +Function f-12: + Full name: libxul.so!nsBlockFrame::Reflow + Short name: nsBlockFrame::Reflow + Is JS: false + Relevant for JS: false + +──── Categories: running (100 samples, 50.0% of thread) ──── + + Layout █████████████████ 60 60.0% + Reflow █████████████ 45 45.0% + Other ████ 15 15.0% + JavaScript ███████████ 40 40.0% + +──── Categories: self (0 samples, 0.0% of thread) ──── + + No self samples for this function in the current view." +`; diff --git a/profiler-cli/src/test/unit/category-formatting.test.ts b/profiler-cli/src/test/unit/category-formatting.test.ts new file mode 100644 index 0000000000..0a99b10dd2 --- /dev/null +++ b/profiler-cli/src/test/unit/category-formatting.test.ts @@ -0,0 +1,157 @@ +/* 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 type { + CategoryBreakdown, + FunctionInfoResult, + SessionContext, + ThreadSamplesResult, + WithContext, +} from 'firefox-profiler/profile-query/types'; +import { + formatFunctionInfoResult, + formatThreadSamplesResult, +} from '../../formatters'; + +function createMockContext(): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'Test Thread' }], + currentViewRange: null, + rootRange: { start: 0, end: 1000 }, + }; +} + +const BREAKDOWN: CategoryBreakdown = { + totalSamples: 100, + categories: [ + { + name: 'Layout', + categoryIndex: 3, + samples: 60, + percentage: 60, + subcategories: [ + { + name: 'Reflow', + subcategoryIndex: 1, + samples: 45, + percentage: 45, + }, + { name: 'Other', subcategoryIndex: 0, samples: 15, percentage: 15 }, + ], + }, + { + name: 'JavaScript', + categoryIndex: 2, + samples: 40, + percentage: 40, + subcategories: [], + }, + ], +}; + +const EMPTY_BREAKDOWN: CategoryBreakdown = { + totalSamples: 0, + categories: [], +}; + +function makeSamplesResult( + categoryBreakdown: CategoryBreakdown +): WithContext { + return { + type: 'thread-samples', + threadHandle: 't-0', + friendlyThreadName: 'Test Thread', + categoryBreakdown, + topFunctionsByTotal: [ + { + functionHandle: 'f-0', + functionIndex: 0, + name: 'A', + nameWithLibrary: 'A', + totalSamples: 100, + totalPercentage: 100, + selfSamples: 40, + selfPercentage: 40, + }, + ], + topFunctionsBySelf: [], + heaviestStack: { + selfSamples: 0, + frameCount: 0, + hasInlinedFrames: false, + frames: [], + }, + context: createMockContext(), + }; +} + +function makeFunctionInfoResult( + categoryBreakdown: FunctionInfoResult['categoryBreakdown'] +): WithContext { + return { + type: 'function-info', + functionHandle: 'f-12', + funcIndex: 12, + name: 'nsBlockFrame::Reflow', + fullName: 'libxul.so!nsBlockFrame::Reflow', + isJS: false, + relevantForJS: false, + categoryBreakdown, + context: createMockContext(), + }; +} + +describe('category breakdown formatting', function () { + it('renders categories with their subcategories in thread samples', function () { + expect( + formatThreadSamplesResult(makeSamplesResult(BREAKDOWN)) + ).toMatchSnapshot(); + }); + + it('renders the running and self breakdowns in function info', function () { + expect( + formatFunctionInfoResult( + makeFunctionInfoResult({ + threadHandle: 't-0', + friendlyThreadName: 'Test Thread', + threadSamples: 200, + running: { + ...BREAKDOWN, + samples: 100, + percentageOfThread: 50, + }, + self: { + ...EMPTY_BREAKDOWN, + samples: 0, + percentageOfThread: 0, + }, + }) + ) + ).toMatchSnapshot(); + }); + + it('renders an empty breakdown without any category rows', function () { + expect( + formatThreadSamplesResult(makeSamplesResult(EMPTY_BREAKDOWN)) + ).toContain('No samples in the current view.'); + }); + + it('names the thread it looked at when a function has no samples', function () { + const output = formatFunctionInfoResult( + makeFunctionInfoResult({ + threadHandle: 't-0', + friendlyThreadName: 'Test Thread', + threadSamples: 200, + running: { ...EMPTY_BREAKDOWN, samples: 0, percentageOfThread: 0 }, + self: { ...EMPTY_BREAKDOWN, samples: 0, percentageOfThread: 0 }, + }) + ); + + expect(output).toContain( + 'No samples for this function on t-0 (Test Thread) in the current view.' + ); + expect(output).not.toContain('Categories:'); + }); +}); diff --git a/src/components/sidebar/CallTreeSidebar.tsx b/src/components/sidebar/CallTreeSidebar.tsx index 8e0fac7817..e93028c5a0 100644 --- a/src/components/sidebar/CallTreeSidebar.tsx +++ b/src/components/sidebar/CallTreeSidebar.tsx @@ -17,7 +17,7 @@ import { toggleOpenCategoryInSidebar } from 'firefox-profiler/actions/app'; import { getSidebarOpenCategories } from 'firefox-profiler/selectors/app'; import { getCategories } from 'firefox-profiler/selectors/profile'; import { getFunctionName } from 'firefox-profiler/profile-logic/function-info'; -import { shouldDisplaySubcategoryInfoForCategory } from 'firefox-profiler/profile-logic/profile-data'; +import { sortCategoryBreakdown } from 'firefox-profiler/profile-logic/category-breakdown'; import { CanSelectContent } from './CanSelectContent'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; @@ -108,91 +108,71 @@ class CategoryBreakdownImpl extends React.PureComponent { - const category = categoryList[categoryIndex]; - return { - categoryIndex, - category, - value: oneCategoryBreakdown.entireCategoryValue || 0, - subcategories: category.subcategories - .map((subcategoryName, subcategoryIndex) => ({ - index: subcategoryIndex, - name: subcategoryName, - value: - oneCategoryBreakdown.subcategoryBreakdown[subcategoryIndex], - })) - // sort subcategories in descending order - .sort(({ value: valueA }, { value: valueB }) => valueB - valueA) - .filter(({ value }) => value), - }; - }) - // sort categories in descending order - .sort(({ value: valueA }, { value: valueB }) => valueB - valueA) - .filter(({ value }) => value); - - // Values can be negative for diffing tracks, that's why we use the absolute - // value to compute the total time. Indeed even if all values average out, - // we want to display a sensible percentage. - const totalTime = data.reduce( - (accum, { value }) => accum + Math.abs(value), - 0 - ); + const { categories } = sortCategoryBreakdown(breakdown, categoryList); return ( <> - {data.map(({ category, value, subcategories, categoryIndex }) => { - const hasSubcategory = - shouldDisplaySubcategoryInfoForCategory(category); - const openCats = sidebarOpenCategories.get(kind); - const expanded = - openCats !== undefined && openCats.has(categoryIndex); - return ( - - - {category.name} - - ) : ( - category.name - ) - } - value={number(value)} - percentage={formatPercent(value / totalTime)} - /> + {categories.map( + ({ + category, + value, + ratio, + hasSubcategories, + subcategories, + categoryIndex, + }) => { + const openCats = sidebarOpenCategories.get(kind); + const expanded = + openCats !== undefined && openCats.has(categoryIndex); + return ( + + + {category.name} + + ) : ( + category.name + ) + } + value={number(value)} + percentage={formatPercent(ratio)} + /> - {/* Draw a histogram bar, colored by the category. */} -
-
-
+ {/* Draw a histogram bar, colored by the category. */} +
+
+
- {hasSubcategory && expanded - ? subcategories.map(({ index, name, value }) => ( - - )) - : null} -
- ); - })} + {hasSubcategories && expanded + ? subcategories.map( + ({ subcategoryIndex, name, value, ratio }) => ( + + ) + ) + : null} +
+ ); + } + )} ); } diff --git a/src/profile-logic/category-breakdown.ts b/src/profile-logic/category-breakdown.ts new file mode 100644 index 0000000000..0a0500e27c --- /dev/null +++ b/src/profile-logic/category-breakdown.ts @@ -0,0 +1,85 @@ +/* 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 { shouldDisplaySubcategoryInfoForCategory } from './profile-data'; + +import type { BreakdownByCategory } from './profile-data'; +import type { + Category, + CategoryList, + IndexIntoCategoryList, + IndexIntoSubcategoryListForCategory, + Milliseconds, +} from 'firefox-profiler/types'; + +export type SortedSubcategoryEntry = { + subcategoryIndex: IndexIntoSubcategoryListForCategory; + name: string; + value: Milliseconds; + ratio: number; +}; + +export type SortedCategoryEntry = { + categoryIndex: IndexIntoCategoryList; + category: Category; + value: Milliseconds; + ratio: number; + hasSubcategories: boolean; + subcategories: SortedSubcategoryEntry[]; +}; + +export type SortedCategoryBreakdown = { + total: Milliseconds; // Sum of the absolute category values, i.e. the ratios' denominator + categories: SortedCategoryEntry[]; +}; + +/** + * Turn a raw category breakdown into the shape used for display: categories and + * subcategories sorted in descending order with the empty ones removed, each + * with its ratio of the total. + */ +export function sortCategoryBreakdown( + breakdown: BreakdownByCategory, + categoryList: CategoryList +): SortedCategoryBreakdown { + const data = breakdown + .map((oneCategoryBreakdown, categoryIndex) => { + const category = categoryList[categoryIndex]; + return { + categoryIndex, + category, + value: oneCategoryBreakdown.entireCategoryValue || 0, + hasSubcategories: shouldDisplaySubcategoryInfoForCategory(category), + subcategories: category.subcategories + .map((subcategoryName, subcategoryIndex) => ({ + subcategoryIndex, + name: subcategoryName, + value: oneCategoryBreakdown.subcategoryBreakdown[subcategoryIndex], + })) + // sort subcategories in descending order + .sort(({ value: valueA }, { value: valueB }) => valueB - valueA) + .filter(({ value }) => value), + }; + }) + // sort categories in descending order + .sort(({ value: valueA }, { value: valueB }) => valueB - valueA) + .filter(({ value }) => value); + + // Values can be negative for diffing tracks, that's why we use the absolute + // value to compute the total time. Indeed even if all values average out, + // we want to display a sensible percentage. + const total = data.reduce((accum, { value }) => accum + Math.abs(value), 0); + + return { + total, + categories: data.map((entry) => ({ + ...entry, + ratio: entry.value / total, + subcategories: entry.subcategories.map((subcategory) => ({ + ...subcategory, + ratio: subcategory.value / total, + })), + })), + }; +} diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index be33781b78..0de2cb324e 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -1184,6 +1184,39 @@ export type TimingsForPath = { rootTime: Milliseconds; // time for all the samples in the current tree }; +/** + * This is a small utility function to more easily add data to breakdowns. + */ +function accumulateSampleToTimings( + timings: { + breakdownByCategory: BreakdownByCategory | null; + value: number; + }, + categories: CategoryList, + { sampleCategories, sampleSubcategories }: SampleCategoriesAndSubcategories, + sampleIndex: IndexIntoSamplesTable, + duration: Milliseconds +): void { + // Step 1: increment the total value + timings.value += duration; + + // step 2: find the category value for this stack. + const categoryIndex = sampleCategories[sampleIndex]; + const subcategoryIndex = sampleSubcategories[sampleIndex]; + + // step 3: increment the right value in the category breakdown + if (timings.breakdownByCategory === null) { + timings.breakdownByCategory = categories.map((category) => ({ + entireCategoryValue: 0, + subcategoryBreakdown: Array(category.subcategories.length).fill(0), + })); + } + timings.breakdownByCategory[categoryIndex].entireCategoryValue += duration; + timings.breakdownByCategory[categoryIndex].subcategoryBreakdown[ + subcategoryIndex + ] += duration; +} + /** * This function is the same as getTimingsForCallNodeIndex, but accepts a CallNodePath * instead of an IndexIntoCallNodeTable. @@ -1207,10 +1240,6 @@ export function getTimingsForPath( /** * This function returns the timings for a specific call node. The algorithm is * adjusted when the call tree is inverted. - * Note that the unfilteredThread should be the original thread before any filtering - * (by range or other) happens. Also sampleIndexOffset needs to be properly - * specified and is the offset to be applied on thread's indexes to access - * the same samples in unfilteredThread. */ export function getTimingsForCallNodeIndex( needleNodeIndex: IndexIntoCallNodeTable | null, @@ -1221,9 +1250,6 @@ export function getTimingsForCallNodeIndex( ): TimingsForPath { /* ------------ Variables definitions ------------*/ - const { sampleCategories, sampleSubcategories } = - sampleCategoriesAndSubcategories; - // This object holds the timings for the current call node path, specified by // needleNodeIndex. const pathTimings: ItemTimings = { @@ -1247,36 +1273,21 @@ export function getTimingsForCallNodeIndex( * We define functions here so that they have easy access to the variables and * the algorithm's parameters. */ - /** - * This is a small utility function to more easily add data to breakdowns. - */ - function accumulateDataToTimings( + const accumulateDataToTimings = ( timings: { breakdownByCategory: BreakdownByCategory | null; value: number; }, sampleIndex: IndexIntoSamplesTable, duration: Milliseconds - ): void { - // Step 1: increment the total value - timings.value += duration; - - // step 2: find the category value for this stack. - const categoryIndex = sampleCategories[sampleIndex]; - const subcategoryIndex = sampleSubcategories[sampleIndex]; - - // step 3: increment the right value in the category breakdown - if (timings.breakdownByCategory === null) { - timings.breakdownByCategory = categories.map((category) => ({ - entireCategoryValue: 0, - subcategoryBreakdown: Array(category.subcategories.length).fill(0), - })); - } - timings.breakdownByCategory[categoryIndex].entireCategoryValue += duration; - timings.breakdownByCategory[categoryIndex].subcategoryBreakdown[ - subcategoryIndex - ] += duration; - } + ): void => + accumulateSampleToTimings( + timings, + categories, + sampleCategoriesAndSubcategories, + sampleIndex, + duration + ); /* ------------- End of function definitions ------------- */ /* ------------ Start of the algorithm itself ------------ */ @@ -1364,6 +1375,114 @@ export function getTimingsForCallNodeIndex( return { forPath: pathTimings, rootTime }; } +/** + * Compute the total and the category breakdown over an entire set of samples, + * which no single call node covers when the call tree has multiple roots. + */ +export function getTimingsForAllSamples( + categories: CategoryList, + samples: SamplesLikeTable, + sampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories +): { value: Milliseconds; breakdownByCategory: BreakdownByCategory | null } { + const timings: { + value: Milliseconds; + breakdownByCategory: BreakdownByCategory | null; + } = { value: 0, breakdownByCategory: null }; + + for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex++) { + if (samples.stack[sampleIndex] === null) { + continue; + } + const weight = samples.weight ? samples.weight[sampleIndex] : 1; + accumulateSampleToTimings( + timings, + categories, + sampleCategoriesAndSubcategories, + sampleIndex, + weight + ); + } + + return timings; +} + +export type TimingsForFunc = { + forFunc: ItemTimings; + rootTime: Milliseconds; // time for all the samples in the current tree +}; + +/** + * Compute the self and running timings, with their category breakdowns, for a + * function across all the call paths it appears in. + * + * A sample counts once towards the running time even if the function recurses + * in its stack, which matches how the function list computes its totals. + * + * `callNodeInfo` must be the non-inverted one. + */ +export function getTimingsForFuncIndex( + needleFuncIndex: IndexIntoFuncTable, + callNodeInfo: CallNodeInfo, + categories: CategoryList, + samples: SamplesLikeTable, + sampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories +): TimingsForFunc { + const funcTimings: ItemTimings = { + selfTime: { value: 0, breakdownByCategory: null }, + totalTime: { value: 0, breakdownByCategory: null }, + }; + let rootTime = 0; + + const callNodeTable = callNodeInfo.getCallNodeTable(); + const stackIndexToCallNodeIndex = + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(); + + // Whether the needle function is on the path from the root to each call node, + // inclusive. The call node table is ordered so that a node's prefix always + // has a smaller index, so a single forward pass is enough. + const funcIsOnPath = makeBitSet(callNodeTable.length); + for (let nodeIndex = 0; nodeIndex < callNodeTable.length; nodeIndex++) { + const prefix = callNodeTable.prefix[nodeIndex]; + if ( + callNodeTable.func[nodeIndex] === needleFuncIndex || + (prefix !== -1 && checkBit(funcIsOnPath, prefix)) + ) { + setBit(funcIsOnPath, nodeIndex); + } + } + + for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex++) { + const thisStackIndex = samples.stack[sampleIndex]; + if (thisStackIndex === null) { + continue; + } + const thisNodeIndex = stackIndexToCallNodeIndex[thisStackIndex]; + const weight = samples.weight ? samples.weight[sampleIndex] : 1; + rootTime += Math.abs(weight); + + if (callNodeTable.func[thisNodeIndex] === needleFuncIndex) { + accumulateSampleToTimings( + funcTimings.selfTime, + categories, + sampleCategoriesAndSubcategories, + sampleIndex, + weight + ); + } + if (checkBit(funcIsOnPath, thisNodeIndex)) { + accumulateSampleToTimings( + funcTimings.totalTime, + categories, + sampleCategoriesAndSubcategories, + sampleIndex, + weight + ); + } + } + + return { forFunc: funcTimings, rootTime }; +} + /** * For every call node in CallNodeTable, compute whether the node's function is * already present in one of the node's ancestors. diff --git a/src/profile-query/formatters/category-breakdown.ts b/src/profile-query/formatters/category-breakdown.ts new file mode 100644 index 0000000000..b8f5a17447 --- /dev/null +++ b/src/profile-query/formatters/category-breakdown.ts @@ -0,0 +1,151 @@ +/* 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 { getCategories } from 'firefox-profiler/selectors/profile'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + getTimingsForAllSamples, + getTimingsForFuncIndex, +} from 'firefox-profiler/profile-logic/profile-data'; +import { sortCategoryBreakdown } from 'firefox-profiler/profile-logic/category-breakdown'; + +import type { + State, + ThreadIndex, + IndexIntoFuncTable, +} from 'firefox-profiler/types'; +import type { + BreakdownByCategory, + ItemTimings, +} from 'firefox-profiler/profile-logic/profile-data'; +import type { SortedCategoryBreakdown } from 'firefox-profiler/profile-logic/category-breakdown'; +import type { Store } from '../../types/store'; +import type { ThreadMap } from '../thread-map'; +import type { + CategoryBreakdown, + FunctionCategoryBreakdown, + FunctionCategoryBreakdowns, +} from '../types'; + +const EMPTY_BREAKDOWN: CategoryBreakdown = { totalSamples: 0, categories: [] }; + +function toCategoryBreakdown( + sorted: SortedCategoryBreakdown +): CategoryBreakdown { + return { + totalSamples: sorted.total, + categories: sorted.categories.map((entry) => ({ + name: entry.category.name, + categoryIndex: entry.categoryIndex, + samples: entry.value, + percentage: entry.ratio * 100, + subcategories: entry.hasSubcategories + ? entry.subcategories.map((subcategory) => ({ + name: subcategory.name, + subcategoryIndex: subcategory.subcategoryIndex, + samples: subcategory.value, + percentage: subcategory.ratio * 100, + })) + : [], + })), + }; +} + +function breakdownFromTimings( + state: State, + breakdownByCategory: BreakdownByCategory | null +): CategoryBreakdown { + if (breakdownByCategory === null) { + return EMPTY_BREAKDOWN; + } + return toCategoryBreakdown( + sortCategoryBreakdown(breakdownByCategory, getCategories(state)) + ); +} + +/** + * The samples and their per-sample categories have to come from the same + * preview-filtered selectors, otherwise they aren't index-aligned and samples + * get attributed to the wrong category. + */ +function getSamplesAndCategories( + state: State, + threadIndexes: Set +) { + const threadSelectors = getThreadSelectors(threadIndexes); + return { + threadSelectors, + samples: threadSelectors.getPreviewFilteredCtssSamples(state), + sampleCategoriesAndSubcategories: + threadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + }; +} + +/** + * Collect the category breakdown of every sample currently in view for a thread. + */ +export function collectThreadCategoryBreakdown( + store: Store, + threadIndexes: Set +): CategoryBreakdown { + const state = store.getState(); + const { samples, sampleCategoriesAndSubcategories } = getSamplesAndCategories( + state, + threadIndexes + ); + const { breakdownByCategory } = getTimingsForAllSamples( + getCategories(state), + samples, + sampleCategoriesAndSubcategories + ); + return breakdownFromTimings(state, breakdownByCategory); +} + +function toFunctionBreakdown( + state: State, + timings: ItemTimings['selfTime'], + threadSamples: number +): FunctionCategoryBreakdown { + return { + ...breakdownFromTimings(state, timings.breakdownByCategory), + samples: timings.value, + percentageOfThread: + threadSamples === 0 ? 0 : (timings.value / threadSamples) * 100, + }; +} + +/** + * Collect the running and self category breakdowns for a function, across all + * the call paths it appears in. + */ +export function collectFunctionCategoryBreakdowns( + store: Store, + threadMap: ThreadMap, + threadIndexes: Set, + funcIndex: IndexIntoFuncTable +): FunctionCategoryBreakdowns { + const state = store.getState(); + const { threadSelectors, samples, sampleCategoriesAndSubcategories } = + getSamplesAndCategories(state, threadIndexes); + + // Use the non-inverted call node info so the result doesn't depend on whether + // the session has the call stack inverted. + const { forFunc, rootTime } = getTimingsForFuncIndex( + funcIndex, + threadSelectors.getNonInvertedCallNodeInfo(state), + getCategories(state), + samples, + sampleCategoriesAndSubcategories + ); + + return { + threadHandle: threadMap.handleForThreadIndexes(threadIndexes), + friendlyThreadName: threadSelectors.getFriendlyThreadName(state), + threadSamples: rootTime, + running: toFunctionBreakdown(state, forFunc.totalTime, rootTime), + self: toFunctionBreakdown(state, forFunc.selfTime, rootTime), + }; +} diff --git a/src/profile-query/formatters/thread-info.ts b/src/profile-query/formatters/thread-info.ts index 96095e4ffc..d87a706598 100644 --- a/src/profile-query/formatters/thread-info.ts +++ b/src/profile-query/formatters/thread-info.ts @@ -27,6 +27,7 @@ import { extractFunctionData, formatFunctionNameWithLibrary, } from '../function-list'; +import { collectThreadCategoryBreakdown } from './category-breakdown'; import { collectCallTree, inlineStatusForNode } from './call-tree'; import type { CallTreeCollectionOptions } from './call-tree'; import { @@ -243,6 +244,7 @@ export function collectThreadSamples( type: 'thread-samples', threadHandle: threadHandleDisplay, friendlyThreadName, + categoryBreakdown: collectThreadCategoryBreakdown(store, threadIndexes), topFunctionsByTotal, topFunctionsBySelf, heaviestStack, diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 11d58a0cf8..b3e2d405c3 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -73,6 +73,7 @@ import { collectThreadSamplesBottomUp, collectThreadFunctions, } from './formatters/thread-info'; +import { collectFunctionCategoryBreakdowns } from './formatters/category-breakdown'; import { collectThreadMarkers, collectThreadNetwork, @@ -1184,6 +1185,12 @@ export class ProfileQuerier { relevantForJS, resource, library, + categoryBreakdown: collectFunctionCategoryBreakdowns( + this._store, + this._threadMap, + getSelectedThreadIndexes(state), + funcIndex + ), context: this._getContext(), }; } diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 1263ec84b5..857f36657f 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -160,6 +160,42 @@ export type StatusResult = { }>; }; +// ===== Category Breakdown ===== + +export type CategorySubBreakdownEntry = { + name: string; + subcategoryIndex: number; + samples: number; + percentage: number; // Of the breakdown's totalSamples, like the parent category row +}; + +export type CategoryBreakdownEntry = { + name: string; + categoryIndex: number; + samples: number; + percentage: number; + subcategories: CategorySubBreakdownEntry[]; // Empty unless the category has more than one +}; + +/** Categories sorted descending, with the empty ones removed. */ +export type CategoryBreakdown = { + totalSamples: number; // Sum of the absolute category values, i.e. the percentage denominator + categories: CategoryBreakdownEntry[]; +}; + +export type FunctionCategoryBreakdown = CategoryBreakdown & { + samples: number; // Signed, and unlike totalSamples only counts this function + percentageOfThread: number; +}; + +export type FunctionCategoryBreakdowns = { + threadHandle: string; + friendlyThreadName: string; + threadSamples: number; + running: FunctionCategoryBreakdown; + self: FunctionCategoryBreakdown; +}; + // ===== Function Commands ===== export type FunctionExpandResult = { @@ -190,6 +226,7 @@ export type FunctionInfoResult = { debugPath?: string; breakpadId?: string; }; + categoryBreakdown: FunctionCategoryBreakdowns; }; // ===== Function Annotate ===== @@ -331,6 +368,7 @@ export type ThreadSamplesResult = { search?: string; activeFilters?: FilterEntry[]; ephemeralFilters?: SampleFilterSpec[]; + categoryBreakdown: CategoryBreakdown; topFunctionsByTotal: TopFunctionInfo[]; topFunctionsBySelf: TopFunctionInfo[]; heaviestStack: { diff --git a/src/test/unit/category-breakdown.test.ts b/src/test/unit/category-breakdown.test.ts new file mode 100644 index 0000000000..7f1f37f129 --- /dev/null +++ b/src/test/unit/category-breakdown.test.ts @@ -0,0 +1,241 @@ +/* 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 { + getTimingsForAllSamples, + getTimingsForCallNodeIndex, + getTimingsForFuncIndex, +} from '../../profile-logic/profile-data'; +import { sortCategoryBreakdown } from '../../profile-logic/category-breakdown'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../fixtures/stores'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { getCategories } from 'firefox-profiler/selectors/profile'; + +import type { BreakdownByCategory } from '../../profile-logic/profile-data'; +import type { CategoryList } from 'firefox-profiler/types'; + +function setupWithTextSamples(textSamples: string) { + const { profile, funcNamesDictPerThread } = + getProfileFromTextSamples(textSamples); + const store = storeWithProfile(profile); + const state = store.getState(); + const threadSelectors = getThreadSelectors(0); + const categories = getCategories(state); + + return { + state, + threadSelectors, + categories, + funcNames: funcNamesDictPerThread[0], + samples: threadSelectors.getPreviewFilteredCtssSamples(state), + sampleCategoriesAndSubcategories: + threadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + }; +} + +/** The value of one category in a breakdown, by category name. */ +function valuesByCategoryName( + breakdown: BreakdownByCategory | null, + categories: CategoryList +): { [name: string]: number } { + const values: { [name: string]: number } = {}; + if (breakdown === null) { + return values; + } + breakdown.forEach((oneCategoryBreakdown, categoryIndex) => { + if (oneCategoryBreakdown.entireCategoryValue !== 0) { + values[categories[categoryIndex].name] = + oneCategoryBreakdown.entireCategoryValue; + } + }); + return values; +} + +describe('getTimingsForAllSamples', function () { + it('counts every sample in its own category', function () { + const { categories, samples, sampleCategoriesAndSubcategories } = + setupWithTextSamples(` + A[cat:Layout] A[cat:Layout] A[cat:Layout] B[cat:Graphics] + C[cat:Layout] C[cat:Layout] D[cat:GC / CC] + `); + + const { value, breakdownByCategory } = getTimingsForAllSamples( + categories, + samples, + sampleCategoriesAndSubcategories + ); + + expect(value).toBe(4); + expect(valuesByCategoryName(breakdownByCategory, categories)).toEqual({ + Layout: 2, + 'GC / CC': 1, + Graphics: 1, + }); + }); + + it('matches the root time of getTimingsForCallNodeIndex', function () { + const { + state, + threadSelectors, + categories, + samples, + sampleCategoriesAndSubcategories, + } = setupWithTextSamples(` + A[cat:Layout] A[cat:Layout] E[cat:Graphics] + B[cat:Layout] C[cat:GC / CC] + `); + + const { rootTime } = getTimingsForCallNodeIndex( + 0, + threadSelectors.getCallNodeInfo(state), + categories, + samples, + sampleCategoriesAndSubcategories + ); + + expect( + getTimingsForAllSamples( + categories, + samples, + sampleCategoriesAndSubcategories + ).value + ).toBe(rootTime); + }); +}); + +describe('getTimingsForFuncIndex', function () { + it('splits self and running time by category', function () { + const { + state, + threadSelectors, + categories, + funcNames, + samples, + sampleCategoriesAndSubcategories, + } = setupWithTextSamples(` + A[cat:Layout] A[cat:Layout] A[cat:Layout] + B[cat:Layout] B[cat:GC / CC] + C[cat:Graphics] + `); + + const { forFunc, rootTime } = getTimingsForFuncIndex( + funcNames.B, + threadSelectors.getNonInvertedCallNodeInfo(state), + categories, + samples, + sampleCategoriesAndSubcategories + ); + + expect(rootTime).toBe(3); + expect(forFunc.totalTime.value).toBe(2); + expect( + valuesByCategoryName(forFunc.totalTime.breakdownByCategory, categories) + ).toEqual({ Layout: 1, Graphics: 1 }); + + // Only the first sample has B as its leaf; in the second one B calls C. + expect(forFunc.selfTime.value).toBe(1); + expect( + valuesByCategoryName(forFunc.selfTime.breakdownByCategory, categories) + ).toEqual({ Layout: 1 }); + }); + + it('counts a recursive function once per sample, like the function list', function () { + const { + state, + threadSelectors, + categories, + funcNames, + samples, + sampleCategoriesAndSubcategories, + } = setupWithTextSamples(` + A A A A + B B B + A A A + B B + A + `); + + const functionListTree = threadSelectors.getFunctionListTree(state); + const callNodeInfo = threadSelectors.getNonInvertedCallNodeInfo(state); + + for (const funcName of ['A', 'B']) { + const funcIndex = funcNames[funcName]; + const { forFunc } = getTimingsForFuncIndex( + funcIndex, + callNodeInfo, + categories, + samples, + sampleCategoriesAndSubcategories + ); + const nodeData = functionListTree.getNodeData(funcIndex); + + expect({ + funcName, + total: forFunc.totalTime.value, + self: forFunc.selfTime.value, + }).toEqual({ + funcName, + total: nodeData.total, + self: nodeData.self, + }); + } + }); +}); + +describe('sortCategoryBreakdown', function () { + const categoryList: CategoryList = [ + { name: 'Idle', color: 'transparent', subcategories: ['Other'] }, + { + name: 'Layout', + color: 'purple', + subcategories: ['Other', 'Reflow', 'Restyle'], + }, + { name: 'Graphics', color: 'green', subcategories: ['Other'] }, + ]; + + it('sorts descending, drops the empty entries and computes ratios', function () { + const sorted = sortCategoryBreakdown( + [ + { entireCategoryValue: 0, subcategoryBreakdown: [0] }, + { entireCategoryValue: 30, subcategoryBreakdown: [10, 20, 0] }, + { entireCategoryValue: 10, subcategoryBreakdown: [10] }, + ], + categoryList + ); + + expect(sorted.total).toBe(40); + expect( + sorted.categories.map(({ category, value, ratio, hasSubcategories }) => ({ + name: category.name, + value, + ratio, + hasSubcategories, + })) + ).toEqual([ + { name: 'Layout', value: 30, ratio: 0.75, hasSubcategories: true }, + { name: 'Graphics', value: 10, ratio: 0.25, hasSubcategories: false }, + ]); + expect(sorted.categories[0].subcategories).toEqual([ + { subcategoryIndex: 1, name: 'Reflow', value: 20, ratio: 0.5 }, + { subcategoryIndex: 0, name: 'Other', value: 10, ratio: 0.25 }, + ]); + }); + + it('uses absolute values as the denominator, so diff profiles still add up', function () { + const sorted = sortCategoryBreakdown( + [ + { entireCategoryValue: 0, subcategoryBreakdown: [0] }, + { entireCategoryValue: 30, subcategoryBreakdown: [30, 0, 0] }, + { entireCategoryValue: -10, subcategoryBreakdown: [-10] }, + ], + categoryList + ); + + expect(sorted.total).toBe(40); + expect(sorted.categories.map(({ value }) => value)).toEqual([30, -10]); + }); +}); diff --git a/src/test/unit/profile-query/category-breakdown.test.ts b/src/test/unit/profile-query/category-breakdown.test.ts new file mode 100644 index 0000000000..1164cbcd8c --- /dev/null +++ b/src/test/unit/profile-query/category-breakdown.test.ts @@ -0,0 +1,130 @@ +/* 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 { + collectFunctionCategoryBreakdowns, + collectThreadCategoryBreakdown, +} from '../../../profile-query/formatters/category-breakdown'; +import { ThreadMap } from '../../../profile-query/thread-map'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../../fixtures/stores'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { commitRange } from 'firefox-profiler/actions/profile-view'; + +function setup() { + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[cat:Layout] A[cat:Layout] A[cat:Layout] A[cat:Graphics] + B[cat:Layout] B[cat:GC / CC] B[cat:Graphics] + C[cat:GC / CC] + `); + const store = storeWithProfile(profile); + return { + store, + threadMap: new ThreadMap(), + threadIndexes: new Set([0]), + funcNames: funcNamesDictPerThread[0], + }; +} + +describe('collectThreadCategoryBreakdown', function () { + it('breaks the samples in view down by category', function () { + const { store, threadIndexes } = setup(); + + const breakdown = collectThreadCategoryBreakdown(store, threadIndexes); + + expect(breakdown.totalSamples).toBe(4); + expect( + breakdown.categories.map(({ name, samples, percentage }) => ({ + name, + samples, + percentage, + })) + ).toEqual([ + { name: 'Layout', samples: 2, percentage: 50 }, + { name: 'GC / CC', samples: 1, percentage: 25 }, + { name: 'Graphics', samples: 1, percentage: 25 }, + ]); + }); + + it('only counts the samples inside a committed range', function () { + const { store, threadIndexes } = setup(); + + // The samples are one millisecond apart, so this keeps the first two. + store.dispatch(commitRange(0, 1.5)); + + const breakdown = collectThreadCategoryBreakdown(store, threadIndexes); + + expect(breakdown.totalSamples).toBe(2); + expect(breakdown.categories.map(({ name }) => name)).toEqual([ + 'Layout', + 'GC / CC', + ]); + }); +}); + +describe('collectFunctionCategoryBreakdowns', function () { + it('reports running and self timings matching the function list', function () { + const { store, threadMap, threadIndexes, funcNames } = setup(); + + const breakdowns = collectFunctionCategoryBreakdowns( + store, + threadMap, + threadIndexes, + funcNames.B + ); + const nodeData = getThreadSelectors(threadIndexes) + .getFunctionListTree(store.getState()) + .getNodeData(funcNames.B); + + expect(breakdowns.threadHandle).toBe('t-0'); + expect(breakdowns.threadSamples).toBe(4); + expect(breakdowns.running.samples).toBe(nodeData.total); + expect(breakdowns.self.samples).toBe(nodeData.self); + expect(breakdowns.running.percentageOfThread).toBe(75); + expect(breakdowns.self.percentageOfThread).toBe(50); + + expect( + breakdowns.running.categories.map(({ name, samples }) => ({ + name, + samples, + })) + ).toEqual([ + { name: 'Layout', samples: 1 }, + { name: 'GC / CC', samples: 1 }, + { name: 'Graphics', samples: 1 }, + ]); + expect( + breakdowns.self.categories.map(({ name, samples }) => ({ + name, + samples, + })) + ).toEqual([ + { name: 'Layout', samples: 1 }, + { name: 'Graphics', samples: 1 }, + ]); + }); + + it('returns empty breakdowns for a function without samples in view', function () { + const { store, threadMap, threadIndexes, funcNames } = setup(); + + // C only appears in the second sample, which this range leaves out. + store.dispatch(commitRange(1.5, 4)); + + const breakdowns = collectFunctionCategoryBreakdowns( + store, + threadMap, + threadIndexes, + funcNames.C + ); + + const empty = { + totalSamples: 0, + categories: [], + samples: 0, + percentageOfThread: 0, + }; + expect(breakdowns.running).toEqual(empty); + expect(breakdowns.self).toEqual(empty); + }); +});