Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f3b04fc
Fix tooltip coordinates when scrolled down
mstange May 8, 2026
f50f4c8
Add profiler-edit --canonicalize-js-location.
mstange Jun 1, 2026
e53cb90
Add Speedometer benchmark analysis scripts and comparison view.
mstange May 6, 2025
9db5016
Only non-negligible changes
mstange Jun 4, 2026
91ca797
Simplify mannWhitneyPValue signature: take input arrays directly.
mstange Jul 3, 2026
662399b
Store benchmark per-iteration totals as Float64Array subarrays.
mstange Jul 3, 2026
eb25b58
Speed up mannWhitneyU with sort + two-pointer merge.
mstange Jul 3, 2026
bdb98da
Concatenate stream output manually.
mstange Jul 3, 2026
90df333
Fix dark mode
mstange Jul 7, 2026
94190fc
Add bottom spacer.
mstange Jul 7, 2026
a3b1612
Expand the overall row into a global-buckets table, and add threshold…
mstange Jul 8, 2026
1c0c6bd
Add open-in-profiler-tab link, and drop the p-value slider.
mstange Jul 8, 2026
ebd01a9
Key expanded bucket rows by bucket key, not row index.
mstange Jul 9, 2026
9318b3e
Include URL version in benchmark comparison deep links.
mstange Jul 23, 2026
3522ea7
Make overall change detection less sensitive; pick one geomean weight…
mstange Aug 13, 2026
4e02d42
Add a design report on automatic call-tree bucketing.
mstange Aug 13, 2026
14f8b88
Replace Mann-Whitney with Welch + permutation, and report the MDE.
mstange Aug 13, 2026
fb69a4f
Raise the default effect-size cutoff from 0.2 to 0.4.
mstange Aug 13, 2026
fbbb3b8
Filter buckets by impact and significance, not by effect size.
mstange Aug 13, 2026
d7a70b4
Document the multiple-comparisons problem, for a future session.
mstange Aug 13, 2026
1be8a8c
Correct the per-bucket p-values for multiple comparisons.
mstange Aug 13, 2026
113929b
Say what the comparison means, in words, and correct the subtest scores.
mstange Aug 13, 2026
6d80b59
Let compare-benchmark-stats take profiles, not just extracted stats.
mstange Aug 13, 2026
25a9f9e
Show how many functions each subtest expansion holds, as a badge.
mstange Aug 13, 2026
995cfbf
Fix the type error in buildDerivedThread's derived-table plumbing.
mstange Aug 13, 2026
8be23bd
Name the two profiles, and let them be edited in place.
mstange Aug 13, 2026
bc16dd5
Word the report in terms of the two profiles' names.
mstange Aug 13, 2026
22d4bb2
Add a direction filter to the bucket lists.
mstange Aug 13, 2026
5a98b54
Say what each expanded bucket is worth, in a sentence.
mstange Aug 13, 2026
94a476f
Make the comparison header readable at the app's 11px base size.
mstange Aug 13, 2026
71a8a40
Expand shortlinks before building bucket-flame-graph deep links.
mstange Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
969 changes: 969 additions & 0 deletions docs-developer/auto-bucketing-prototype.mjs

Large diffs are not rendered by default.

480 changes: 480 additions & 0 deletions docs-developer/benchmark-auto-bucketing.md

Large diffs are not rendered by default.

420 changes: 420 additions & 0 deletions docs-developer/benchmark-compare-fdr.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions scripts/build-node-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,43 @@ const profilerEditConfig = {
outfile: 'node-tools-dist/profiler-edit.js',
};

const analyzeBenchmarkConfig = {
...nodeBaseConfig,
entryPoints: ['src/node-tools/analyze-benchmark.ts'],
outfile: 'node-tools-dist/analyze-benchmark.js',
};

const extractBenchmarkStatsConfig = {
...nodeBaseConfig,
entryPoints: ['src/node-tools/extract-benchmark-stats.ts'],
outfile: 'node-tools-dist/extract-benchmark-stats.js',
};

const compareBenchmarkStatsConfig = {
...nodeBaseConfig,
entryPoints: ['src/node-tools/compare-benchmark-stats.ts'],
outfile: 'node-tools-dist/compare-benchmark-stats.js',
};

// Dumps the call subtree below one benchmark bucket as per-iteration weight
// vectors, for docs-developer/auto-bucketing-prototype.mjs to consume.
const dumpBucketSubtreeConfig = {
...nodeBaseConfig,
entryPoints: ['src/node-tools/dump-bucket-subtree.ts'],
outfile: 'node-tools-dist/dump-bucket-subtree.js',
};

async function build() {
await esbuild.build(profilerEditConfig);
console.log('✅ profiler-edit build completed');
await esbuild.build(analyzeBenchmarkConfig);
console.log('✅ analyze-benchmark build completed');
await esbuild.build(extractBenchmarkStatsConfig);
console.log('✅ extract-benchmark-stats build completed');
await esbuild.build(compareBenchmarkStatsConfig);
console.log('✅ compare-benchmark-stats build completed');
await esbuild.build(dumpBucketSubtreeConfig);
console.log('✅ dump-bucket-subtree build completed');
}

build().catch(console.error);
24 changes: 24 additions & 0 deletions scripts/generate-known-functions-toml.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { execSync } from 'child_process';
import { writeFileSync } from 'fs';

const jsCode = execSync(
'node_modules/.bin/esbuild src/node-tools/profile-insert-labels/known-functions.ts --platform=node --format=esm'
).toString();

const dataUrl = 'data:text/javascript,' + encodeURIComponent(jsCode);
const { BREAK_OUT_BUCKETS } = await import(dataUrl);

let toml = '';
for (const bucket of BREAK_OUT_BUCKETS) {
toml += `[[buckets]]\n`;
toml += `name = ${JSON.stringify(bucket.name)}\n`;
toml += `funcPrefixes = [\n`;
for (const prefix of bucket.funcPrefixes) {
toml += ` ${JSON.stringify(prefix)},\n`;
}
toml += `]\n\n`;
}

const outPath = 'src/node-tools/profile-insert-labels/known-functions.toml';
writeFileSync(outPath, toml.trimEnd() + '\n');
console.log(`Wrote ${outPath}`);
11 changes: 11 additions & 0 deletions src/actions/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ export function changeProfilesToCompare(profiles: string[]): Action {
};
}

export function changeProfilesToCompareBenchmark(
profiles: string[],
profileNames: string[]
): Action {
return {
type: 'CHANGE_PROFILES_TO_COMPARE_BENCHMARK',
profiles,
profileNames,
};
}

export function startFetchingProfiles(): Action {
return { type: 'START_FETCHING_PROFILES' };
}
Expand Down
3 changes: 2 additions & 1 deletion src/actions/receive-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1256,7 +1256,7 @@ export function viewProfileFromPostMessage(
// Given a profile view URL, extract the raw URL needed to fetch the profile
// data. This mirrors the manual pathname splitting done in retrieveProfileForRawUrl,
// so we can fetch the profile before calling stateFromLocation.
function getProfileFetchUrl(urlString: string): string {
export function getProfileFetchUrl(urlString: string): string {
const pathParts = new URL(urlString).pathname.split('/').filter((d) => d);
const dataSource = ensureIsValidDataSource(pathParts[0]);
switch (dataSource) {
Expand Down Expand Up @@ -1461,6 +1461,7 @@ export function retrieveProfileForRawUrl(
case 'uploaded-recordings':
case 'none':
case 'local':
case 'compare-benchmark':
// There is no profile to download for these datasources.
break;
default:
Expand Down
16 changes: 16 additions & 0 deletions src/app-logic/url-handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ function getPathParts(urlState: UrlState): string[] {
return ['compare'];
}
return ['compare', urlState.selectedTab];
case 'compare-benchmark':
return ['compare-benchmark'];
case 'uploaded-recordings':
return ['uploaded-recordings'];
case 'from-browser':
Expand Down Expand Up @@ -172,6 +174,7 @@ type BaseQuery = {
file: string; // Path into a zip file.
transforms: string;
profiles: string[];
names: string[]; // Display names for `profiles`, e.g. ["Chrome", "Firefox"]
profileName: string;
symbolServer: string;
view: string;
Expand Down Expand Up @@ -270,6 +273,17 @@ export function getQueryStringFromUrlState(urlState: UrlState): string {
return '';
}
break;
case 'compare-benchmark':
if (urlState.profilesToCompare === null) {
return '';
}
return queryString.stringify(
{
profiles: urlState.profilesToCompare,
names: urlState.profileNamesToCompare ?? undefined,
},
{ arrayFormat: 'bracket' }
);
case 'public':
case 'local':
case 'from-browser':
Expand Down Expand Up @@ -463,6 +477,7 @@ export function ensureIsValidDataSource(
case 'public':
case 'from-url':
case 'compare':
case 'compare-benchmark':
case 'uploaded-recordings':
return coercedDataSource;
default:
Expand Down Expand Up @@ -602,6 +617,7 @@ export function stateFromLocation(
hash: hasProfileHash ? pathParts[1] : '',
profileUrl: hasProfileUrl ? decodeURIComponent(pathParts[1]) : '',
profilesToCompare: query.profiles || null,
profileNamesToCompare: query.names || null,
selectedTab,
pathInZipFile: query.file || null,
profileName: query.profileName,
Expand Down
6 changes: 6 additions & 0 deletions src/components/app/AppViewRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ProfileViewer } from './ProfileViewer';
import { ZipFileViewer } from './ZipFileViewer';
import { Home } from './Home';
import { CompareHome } from './CompareHome';
import { BenchmarkCompareViewer } from './BenchmarkCompareViewer';
import { ProfileRootMessage } from './ProfileRootMessage';
import { getView } from 'firefox-profiler/selectors/app';
import { getHasZipFile } from 'firefox-profiler/selectors/zipped-profiles';
Expand All @@ -34,6 +35,7 @@ const ERROR_MESSAGES_L10N_ID: { [key: string]: string } = Object.freeze({
public: 'AppViewRouter--error-public',
'from-url': 'AppViewRouter--error-from-url',
compare: 'AppViewRouter--error-compare',
'compare-benchmark': 'AppViewRouter--error-compare',
});

type AppViewRouterStateProps = {
Expand Down Expand Up @@ -61,6 +63,10 @@ class AppViewRouterImpl extends PureComponent<AppViewRouterProps> {
return <CompareHome />;
}
break;
case 'compare-benchmark':
// The viewer shows its own input form when there is nothing to compare
// yet, so that the URLs and names stay editable once a report is up.
return <BenchmarkCompareViewer />;
case 'uploaded-recordings':
return <UploadedRecordingsHome />;
case 'from-browser':
Expand Down
143 changes: 143 additions & 0 deletions src/components/app/BenchmarkCompareForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/* 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 { useState, useCallback } from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import { useDispatch } from 'react-redux';

import { changeProfilesToCompareBenchmark } from 'firefox-profiler/actions/app';
import { DEFAULT_BENCHMARK_PROFILE_NAMES } from './BenchmarkProfileNames';

type Props = {
/** Pre-filled values, i.e. what is currently being compared. */
initialUrls: [string, string];
initialNames: [string, string];
/** Label for the submit button — "Compare" when nothing is loaded yet,
* "Update comparison" when this form is sitting above a loaded report. */
submitLabel: string;
};

/**
* The input form for the benchmark comparison view: two profile URLs and the
* names to call them by.
*
* It doubles as the empty state of `/compare-benchmark` and as an editable
* header above a loaded report, because the thing a reader most often wants
* after reading one comparison is a neighbouring one — the same pair the other
* way round, or one side swapped for a third build. Making them go back to a
* separate form page to do that loses the URLs they already had.
*/
export function BenchmarkCompareForm({
initialUrls,
initialNames,
submitLabel,
}: Props) {
const dispatch = useDispatch();
const [urls, setUrls] = useState<[string, string]>(initialUrls);
const [names, setNames] = useState<[string, string]>(initialNames);

const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.currentTarget;
const index = name.endsWith('2') ? 1 : 0;
const setter = name.startsWith('url') ? setUrls : setNames;
setter((prev) => {
const next: [string, string] = [prev[0], prev[1]];
next[index] = value;
return next;
});
}, []);

const handleSwap = useCallback(() => {
setUrls(([a, b]) => [b, a]);
setNames(([a, b]) => [b, a]);
}, []);

const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
dispatch(
changeProfilesToCompareBenchmark(
[urls[0].trim(), urls[1].trim()],
[
names[0].trim() || DEFAULT_BENCHMARK_PROFILE_NAMES[0],
names[1].trim() || DEFAULT_BENCHMARK_PROFILE_NAMES[1],
]
)
);
},
[dispatch, urls, names]
);

return (
<form className="benchmarkCompareForm" onSubmit={handleSubmit}>
<span className="benchmarkCompareForm__heading">Name</span>
<span className="benchmarkCompareForm__heading">Profile URL</span>

{([0, 1] as const).map((i) => (
<Row
key={i}
index={i}
url={urls[i]}
name={names[i]}
onChange={handleChange}
/>
))}

<div className="benchmarkCompareForm__buttons">
<button
type="button"
className="photon-button photon-button-default"
onClick={handleSwap}
title="Swap the two sides. Every percentage in the report is relative to the first one, so this is how you ask the opposite question."
>
⇅ Swap
</button>
<button
type="submit"
className="photon-button photon-button-primary"
disabled={urls[0].trim() === '' || urls[1].trim() === ''}
>
{submitLabel}
</button>
</div>
</form>
);
}

function Row({
index,
url,
name,
onChange,
}: {
index: 0 | 1;
url: string;
name: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}) {
const n = index + 1;
return (
<>
<input
name={`name${n}`}
aria-label={`Name of profile ${n}`}
className="photon-input benchmarkCompareForm__nameInput"
type="text"
placeholder={DEFAULT_BENCHMARK_PROFILE_NAMES[index]}
onChange={onChange}
value={name}
/>
<input
name={`url${n}`}
aria-label={`URL of profile ${n}`}
className="photon-input"
type="url"
required
placeholder="https://"
onChange={onChange}
value={url}
/>
</>
);
}
Loading
Loading