Skip to content

Commit b9229f6

Browse files
committed
Add flame graphs.
1 parent 4124efd commit b9229f6

7 files changed

Lines changed: 652 additions & 25 deletions

File tree

src/components/app/BenchmarkCompareViewer.css

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.benchmarkCompareViewer {
2-
flex: 1;
2+
/* this element is a child in a horizontal flex parent */
3+
align-self: flex-start; /* allow extending beyond the parent's height */
34
box-sizing: border-box;
45
width: 100%;
56
min-height: 100%;
@@ -216,3 +217,75 @@
216217
.benchmarkCell--effect-moderate {
217218
font-weight: 600;
218219
}
220+
221+
/* Expandable bucket rows (inner table). Mirror the suite-row styling. */
222+
223+
.benchmarkRow--bucket-expandable {
224+
cursor: pointer;
225+
}
226+
227+
.benchmarkRow--bucket-expandable:hover {
228+
background: var(--grey-10);
229+
}
230+
231+
.benchmarkRow--bucket-expansion > td {
232+
padding: 0.5em 0;
233+
background: var(--grey-10);
234+
white-space: normal;
235+
}
236+
237+
/* Stacked base / new flame graphs for one expanded bucket. The base flame
238+
* graph is on top, the new one below, so they can be visually compared by
239+
* scanning vertically. Each side's width is set inline (as a percentage)
240+
* proportional to its total sample count, so 1 sample takes up the same
241+
* pixel width on both sides. */
242+
243+
.bucketFlameGraphPair {
244+
display: flex;
245+
flex-direction: column;
246+
gap: 0.5em;
247+
padding: 0.5em 1em;
248+
}
249+
250+
.bucketFlameGraphSide {
251+
display: flex;
252+
overflow: hidden;
253+
min-width: 0;
254+
height: 280px;
255+
flex-direction: column;
256+
border: 1px solid var(--grey-30);
257+
border-radius: 4px;
258+
background: var(--base-background-color);
259+
}
260+
261+
.bucketFlameGraphSide__label {
262+
padding: 4px 8px;
263+
border-bottom: 1px solid var(--grey-30);
264+
background: var(--grey-20);
265+
color: var(--grey-70);
266+
font-size: 0.85em;
267+
font-weight: bold;
268+
text-transform: uppercase;
269+
}
270+
271+
.bucketFlameGraphSide__sampleCount {
272+
color: var(--grey-60);
273+
font-weight: normal;
274+
text-transform: none;
275+
}
276+
277+
.bucketFlameGraphSide__chart {
278+
display: flex;
279+
overflow: hidden;
280+
flex: 1;
281+
flex-direction: column;
282+
}
283+
284+
.bucketFlameGraphSide__empty {
285+
display: flex;
286+
flex: 1;
287+
align-items: center;
288+
justify-content: center;
289+
color: var(--grey-60);
290+
font-style: italic;
291+
}

src/components/app/BenchmarkCompareViewer.tsx

Lines changed: 106 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import { Fragment, useState, useEffect } from 'react';
5+
import { Fragment, useState, useEffect, useMemo } from 'react';
66
import { useSelector } from 'react-redux';
77

88
import { AppHeader } from './AppHeader';
@@ -25,11 +25,24 @@ import type {
2525
ConfidenceRating,
2626
EffectSize,
2727
} from 'firefox-profiler/profile-logic/benchmark/perf-compare-stats';
28+
import type { Profile } from 'firefox-profiler/types';
29+
import { BucketFlameGraphPair } from './BucketFlameGraphPair';
30+
import type { BucketProfileBundle } from './BucketFlameGraphPair';
31+
import {
32+
buildDerivedThread,
33+
getCategoriesForProfile,
34+
getDefaultCategoryIndex,
35+
} from 'firefox-profiler/profile-logic/benchmark/bucket-flame-graph-data';
36+
import { getBenchmarkInfo } from 'firefox-profiler/profile-logic/benchmark/benchmark-stuff';
2837
import './BenchmarkCompareViewer.css';
2938

3039
type ComparisonData = {
3140
baseUrl: string;
3241
newUrl: string;
42+
/** The loaded source profiles, retained so we can render flame graphs of
43+
* individual buckets on demand (focusSelf on a bucket's representative func). */
44+
baseProfile: Profile;
45+
newProfile: Profile;
3346
overallScore: ScoreComparison;
3447
suiteScores: ScoreComparison[];
3548
suiteComparisons: Array<{
@@ -121,6 +134,8 @@ async function computeComparison(
121134
newSuite.buckets,
122135
baseStats.bucketNames,
123136
newStats.bucketNames,
137+
baseStats.bucketFuncs,
138+
newStats.bucketFuncs,
124139
baseSuite.iterationCount
125140
);
126141
return [{ suiteName: baseSuite.suiteName, comparisons }];
@@ -130,6 +145,8 @@ async function computeComparison(
130145
return {
131146
baseUrl,
132147
newUrl,
148+
baseProfile,
149+
newProfile,
133150
overallScore,
134151
suiteScores,
135152
suiteComparisons,
@@ -224,10 +241,14 @@ function ScoreTable({
224241
overallScore,
225242
suiteScores,
226243
suiteComparisonsByName,
244+
baseBundle,
245+
newBundle,
227246
}: {
228247
overallScore: ScoreComparison;
229248
suiteScores: ScoreComparison[];
230249
suiteComparisonsByName: Map<string, BucketComparison[]>;
250+
baseBundle: BucketProfileBundle;
251+
newBundle: BucketProfileBundle;
231252
}) {
232253
const [expanded, setExpanded] = useState<Set<string>>(new Set());
233254
const numSuites = suiteScores.length;
@@ -313,6 +334,8 @@ function ScoreTable({
313334
label={row.label}
314335
baseSubtestMean={row.baseMean}
315336
numSuites={numSuites}
337+
baseBundle={baseBundle}
338+
newBundle={newBundle}
316339
/>
317340
</td>
318341
</tr>
@@ -330,16 +353,31 @@ function BucketTable({
330353
label,
331354
baseSubtestMean,
332355
numSuites,
356+
baseBundle,
357+
newBundle,
333358
}: {
334359
comparisons: BucketComparison[];
335360
label: string;
336361
/** When provided together with numSuites, two percent columns are shown
337362
* (Δ% overall and Δ% subtest) instead of the bucket-relative Δ%. */
338363
baseSubtestMean?: number;
339364
numSuites?: number;
365+
baseBundle: BucketProfileBundle;
366+
newBundle: BucketProfileBundle;
340367
}) {
341368
const showSubtestColumns =
342369
baseSubtestMean !== undefined && numSuites !== undefined;
370+
const columnCount = showSubtestColumns ? 6 : 5;
371+
372+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
373+
const toggle = (bucketName: string) => {
374+
setExpanded((prev) => {
375+
const next = new Set(prev);
376+
if (next.has(bucketName)) next.delete(bucketName);
377+
else next.add(bucketName);
378+
return next;
379+
});
380+
};
343381

344382
const significant = comparisons
345383
.filter((c) => c.confidence !== 'LOW' && c.effectSize !== 'Negligible')
@@ -403,23 +441,70 @@ function BucketTable({
403441
</td>
404442
);
405443
}
444+
// A bucket can be expanded if at least one side has a func index.
445+
// (If both are null it's a degenerate "appeared/disappeared with no
446+
// attributable func" case.)
447+
const expandable = c.baseFunc !== null || c.newFunc !== null;
448+
const isExpanded = expanded.has(c.bucketName);
406449
return (
407-
<tr key={i}>
408-
<td className="benchmarkCell--bucketName" title={c.bucketName}>
409-
{c.bucketName}
410-
</td>
411-
<td className="benchmarkCell--number">{c.baseMean.toFixed(2)}</td>
412-
<td className="benchmarkCell--number">{c.newMean.toFixed(2)}</td>
413-
<td className="benchmarkCell--number">{absDiffStr}</td>
414-
{pctCells}
415-
</tr>
450+
<Fragment key={i}>
451+
<tr
452+
className={
453+
expandable ? 'benchmarkRow--bucket-expandable' : undefined
454+
}
455+
onClick={expandable ? () => toggle(c.bucketName) : undefined}
456+
>
457+
<td className="benchmarkCell--bucketName" title={c.bucketName}>
458+
{expandable && (
459+
<span className="benchmarkDisclosure" aria-hidden="true">
460+
{isExpanded ? '▼' : '▶'}
461+
</span>
462+
)}
463+
{c.bucketName}
464+
</td>
465+
<td className="benchmarkCell--number">
466+
{c.baseMean.toFixed(2)}
467+
</td>
468+
<td className="benchmarkCell--number">{c.newMean.toFixed(2)}</td>
469+
<td className="benchmarkCell--number">{absDiffStr}</td>
470+
{pctCells}
471+
</tr>
472+
{expandable && isExpanded && (
473+
<tr className="benchmarkRow--bucket-expansion">
474+
<td colSpan={columnCount}>
475+
<BucketFlameGraphPair
476+
baseBundle={baseBundle}
477+
newBundle={newBundle}
478+
baseFunc={c.baseFunc}
479+
newFunc={c.newFunc}
480+
/>
481+
</td>
482+
</tr>
483+
)}
484+
</Fragment>
416485
);
417486
})}
418487
</tbody>
419488
</table>
420489
);
421490
}
422491

492+
/** Build the (profile, derivedThread, categories) bundle once per profile.
493+
* Computing the derived thread is expensive, so we memoize on profile identity
494+
* and reuse the same bundle across every bucket the user expands. */
495+
function makeBucketProfileBundle(profile: Profile): BucketProfileBundle {
496+
const categories = getCategoriesForProfile(profile);
497+
const defaultCategory = getDefaultCategoryIndex(categories);
498+
const benchmarkInfo = getBenchmarkInfo(profile, 'speedometer');
499+
const thread = buildDerivedThread(
500+
profile,
501+
benchmarkInfo.threadIndex,
502+
categories,
503+
defaultCategory
504+
);
505+
return { profile, thread, categories, defaultCategory };
506+
}
507+
423508
function ComparisonResults({ data }: { data: ComparisonData }) {
424509
const suiteComparisonsByName = new Map(
425510
data.suiteComparisons.map(({ suiteName, comparisons }) => [
@@ -428,6 +513,15 @@ function ComparisonResults({ data }: { data: ComparisonData }) {
428513
])
429514
);
430515

516+
const baseBundle = useMemo(
517+
() => makeBucketProfileBundle(data.baseProfile),
518+
[data.baseProfile]
519+
);
520+
const newBundle = useMemo(
521+
() => makeBucketProfileBundle(data.newProfile),
522+
[data.newProfile]
523+
);
524+
431525
return (
432526
<div className="benchmarkResults">
433527
<div className="benchmarkProfileUrls">
@@ -450,6 +544,8 @@ function ComparisonResults({ data }: { data: ComparisonData }) {
450544
overallScore={data.overallScore}
451545
suiteScores={data.suiteScores}
452546
suiteComparisonsByName={suiteComparisonsByName}
547+
baseBundle={baseBundle}
548+
newBundle={newBundle}
453549
/>
454550
</div>
455551
);

0 commit comments

Comments
 (0)