From a19808faa6fe35c7a5e24ef1b1e76408bbdeb7f2 Mon Sep 17 00:00:00 2001 From: fatadel Date: Tue, 11 Aug 2026 19:04:47 +0200 Subject: [PATCH 1/2] Extract the sidebar's category breakdown sorting into a helper The call tree sidebar sorted its category breakdown, dropped the empty entries and worked out each row's share of the total inline in its render method. Moving that to sortCategoryBreakdown lets other views present a breakdown the same way without reimplementing the ordering or the percentage denominator. No visible change: the sidebar renders exactly what it did before. --- src/components/sidebar/CallTreeSidebar.tsx | 142 +++++++++------------ src/profile-logic/category-breakdown.ts | 85 ++++++++++++ src/test/unit/category-breakdown.test.ts | 61 +++++++++ 3 files changed, 207 insertions(+), 81 deletions(-) create mode 100644 src/profile-logic/category-breakdown.ts create mode 100644 src/test/unit/category-breakdown.test.ts 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/test/unit/category-breakdown.test.ts b/src/test/unit/category-breakdown.test.ts new file mode 100644 index 0000000000..db10ec4a8b --- /dev/null +++ b/src/test/unit/category-breakdown.test.ts @@ -0,0 +1,61 @@ +/* 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 { sortCategoryBreakdown } from '../../profile-logic/category-breakdown'; + +import type { CategoryList } from 'firefox-profiler/types'; + +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]); + }); +}); From 9aa144856632b143b27ba939b1b9dc05ccb15d13 Mon Sep 17 00:00:00 2001 From: fatadel Date: Tue, 11 Aug 2026 19:06:10 +0200 Subject: [PATCH 2/2] Show a category breakdown in profiler-cli The CLI had no way to tell whether a hot region of a profile was Layout, GC, JavaScript or Graphics, which the call tree sidebar shows in the web UI. `thread samples` now breaks the samples currently in view down by category and subcategory, and `function info` does the same for one function's running and self time. Closes #6204 --- profiler-cli/README.md | 4 +- profiler-cli/schemas.txt | 24 +++ profiler-cli/src/formatters.ts | 113 +++++++++-- profiler-cli/src/protocol.ts | 5 + .../src/test/integration/basic.test.ts | 35 ++++ .../category-formatting.test.ts.snap | 48 +++++ .../src/test/unit/category-formatting.test.ts | 157 +++++++++++++++ src/profile-logic/profile-data.ts | 181 +++++++++++++++--- .../formatters/category-breakdown.ts | 151 +++++++++++++++ src/profile-query/formatters/thread-info.ts | 2 + src/profile-query/index.ts | 7 + src/profile-query/types.ts | 38 ++++ src/test/unit/category-breakdown.test.ts | 180 +++++++++++++++++ .../profile-query/category-breakdown.test.ts | 130 +++++++++++++ 14 files changed, 1031 insertions(+), 44 deletions(-) create mode 100644 profiler-cli/src/test/unit/__snapshots__/category-formatting.test.ts.snap create mode 100644 profiler-cli/src/test/unit/category-formatting.test.ts create mode 100644 src/profile-query/formatters/category-breakdown.ts create mode 100644 src/test/unit/profile-query/category-breakdown.test.ts 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/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 index db10ec4a8b..7f1f37f129 100644 --- a/src/test/unit/category-breakdown.test.ts +++ b/src/test/unit/category-breakdown.test.ts @@ -2,10 +2,190 @@ * 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'] }, 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); + }); +});