blob: b63dc06a17916f84933e0f2441ee927aa346574d [file] [edit]
import { BenchmarkRunner, WarmupSuite } from "./benchmark-runner.mjs";
import * as Statistics from "./statistics.mjs";
import { renderMetricView } from "./metric-ui.mjs";
import { defaultParams, params } from "./shared/params.mjs";
import { createDeveloperModeContainer } from "./developer-mode.mjs";
const BENCHMARK_STATE = Object.freeze({
IDLE: "IDLE",
READY: "READY",
RUNNING: "RUNNING",
DONE: "DONE",
ERROR: "ERROR",
});
// FIXME(camillobruni): Add base class
class MainBenchmarkClient {
developerMode = false;
stepCount = null;
suitesCount = null;
_measuredValuesList = [];
_finishedTestCount = 0;
_progressCompleted = null;
_state = BENCHMARK_STATE.IDLE;
_developerModeContainer = null;
_metrics = Object.create(null);
_steppingPromise = null;
_steppingResolver = null;
_benchmarkConfiguratorPromise = null;
constructor() {
this._benchmarkConfiguratorPromise = import("./benchmark-configurator.mjs");
this.prepareUI();
this.evaluateParams();
this._showSection(window.location.hash);
this._benchmarkConfiguratorPromise.then(() => {
window.dispatchEvent(new Event("SpeedometerReady"));
});
}
isRunning() {
return this._state === BENCHMARK_STATE.RUNNING;
}
hasFinished() {
return this._state === BENCHMARK_STATE.DONE || this._state === BENCHMARK_STATE.ERROR;
}
async start() {
if (this._isStepping())
this._clearStepping();
else if (await this._startBenchmark())
this._showSection("#running");
}
async step() {
const currentSteppingResolver = this._steppingResolver;
this._steppingPromise = new Promise((resolve) => {
this._steppingResolver = resolve;
});
currentSteppingResolver?.();
if (!this.isRunning()) {
await this._startBenchmark();
this._showSection("#running");
}
}
_clearStepping() {
const currentSteppingResolver = this._steppingResolver;
this._steppingPromise = null;
this._steppingResolver = null;
currentSteppingResolver?.();
}
async _awaitNextStep(suite, test) {
console.log(`Next Step: ${suite.name} ${test.name}`, { suite, test });
await this._steppingPromise;
}
_isStepping() {
return this._steppingResolver !== null;
}
async _startBenchmark() {
if (this.isRunning())
return false;
const { benchmarkConfigurator } = await this._benchmarkConfiguratorPromise;
const enabledSuites = benchmarkConfigurator.suites.filter((suite) => suite.enabled);
const totalSuitesCount = enabledSuites.length;
if (totalSuitesCount === 0) {
const message = `No suites selected - "${params.suites}" does not exist.`;
alert(message);
console.error(
message,
params.suites,
"\nValid values:",
benchmarkConfigurator.suites.map((each) => each.name)
);
return false;
}
if (!this._isStepping())
this._developerModeContainer?.remove();
this._progressCompleted = document.getElementById("progress-completed");
if (params.iterationCount < 50) {
const progressNode = document.getElementById("progress");
for (let i = 1; i < params.iterationCount; i++) {
const iterationMarker = progressNode.appendChild(document.createElement("div"));
iterationMarker.className = "iteration-marker";
iterationMarker.style.left = `${(i / params.iterationCount) * 100}%`;
}
}
this._metrics = Object.create(null);
this.stepCount = params.iterationCount * totalSuitesCount;
this._progressCompleted.max = this.stepCount;
this.suitesCount = enabledSuites.length;
this._setBenchmarkState(BENCHMARK_STATE.RUNNING);
const runner = new BenchmarkRunner(benchmarkConfigurator.suites, this);
runner.runMultipleIterations(params.iterationCount);
return true;
}
get metrics() {
return this._metrics;
}
_assertNotAborted() {
if (this._state === BENCHMARK_STATE.ERROR)
throw new Error("Benchmark aborted by another process.");
}
willAddTestFrame(frame) {
this._assertNotAborted();
frame.style.left = "50%";
frame.style.top = "50%";
frame.style.transform = "translate(-50%, -50%)";
}
async willRunTest(suite, test) {
this._assertNotAborted();
document.getElementById("info-label").textContent = suite.name;
document.getElementById("info-progress").textContent = `${this._finishedTestCount} / ${this.stepCount}`;
if (this._steppingPromise)
await this._awaitNextStep(suite, test);
}
async didFinishSuite(suite) {
// The warmup suite is not part of stepCount, so counting it would overshoot the bar.
if (suite === WarmupSuite)
return;
this._finishedTestCount++;
this._progressCompleted.value = this._finishedTestCount;
if (this._steppingPromise)
await this._awaitNextStep(suite, { name: "Finished" });
}
didRunSuites(measuredValues) {
this._measuredValuesList.push(measuredValues);
}
willStartFirstIteration() {
this._measuredValuesList = [];
this._finishedTestCount = 0;
}
didFinishLastIteration(metrics) {
console.assert(this.isRunning());
this._metrics = metrics;
this._setBenchmarkState(BENCHMARK_STATE.DONE);
const scoreResults = this._computeResults(this._measuredValuesList, "score");
if (scoreResults.isValid)
this._populateValidScore(scoreResults);
else
this._populateInvalidScore();
this._populateDetailedResults(metrics);
if (params.developerMode)
this.showResultsDetails();
else
this.showResultsSummary();
globalThis.dispatchEvent(new Event("SpeedometerDone"));
}
handleError(error) {
if (this._state === BENCHMARK_STATE.ERROR)
return;
this._metrics = Object.create(null);
this._setBenchmarkState(BENCHMARK_STATE.ERROR);
this._populateErrorMessage(error.message);
this.showResultsSummary();
throw error;
}
_populateValidScore(scoreResults) {
document.getElementById("summary").className = "valid";
this._updateGaugeNeedle(scoreResults.mean);
document.getElementById("result-number").textContent = scoreResults.formattedMean;
if (scoreResults.formattedDelta)
document.getElementById("confidence-number").textContent = `\u00b1 ${scoreResults.formattedDelta}`;
}
_populateInvalidScore() {
this._populateErrorMessage(undefined);
}
_populateErrorMessage(errorText) {
document.getElementById("summary").className = "invalid";
document.getElementById("result-number").textContent = "Error";
document.getElementById("confidence-number").textContent = "";
if (errorText === undefined)
return;
const errorMessage = document.getElementById("invalid-score-text");
errorMessage.textContent = errorText;
}
_computeResults(measuredValuesList, displayUnit) {
function valueForUnit(measuredValues) {
if (displayUnit === "ms")
return measuredValues.geomean;
return measuredValues.score;
}
function sigFigFromPercentDelta(percentDelta) {
return Math.ceil(-Math.log(percentDelta) / Math.log(10)) + 3;
}
function toSigFigPrecision(number, sigFig) {
const nonDecimalDigitCount = number < 1 ? 0 : Math.floor(Math.log(number) / Math.log(10)) + 1;
return number.toPrecision(Math.max(nonDecimalDigitCount, Math.min(6, sigFig)));
}
const values = measuredValuesList.map(valueForUnit);
const sum = values.reduce((a, b) => a + b, 0);
const arithmeticMean = sum / values.length;
let meanSigFig = 4;
let formattedDelta;
let formattedPercentDelta;
const delta = Statistics.confidenceIntervalDelta(0.95, values.length, sum, Statistics.squareSum(values));
if (!isNaN(delta)) {
const percentDelta = (delta * 100) / arithmeticMean;
meanSigFig = sigFigFromPercentDelta(percentDelta);
formattedDelta = toSigFigPrecision(delta, 2);
formattedPercentDelta = `${toSigFigPrecision(percentDelta, 2)}%`;
}
const formattedMean = toSigFigPrecision(arithmeticMean, Math.max(meanSigFig, 3));
return {
formattedValues: values.map((value) => {
return `${toSigFigPrecision(value, 4)} ${displayUnit}`;
}),
mean: arithmeticMean,
formattedMean: formattedMean,
formattedDelta: formattedDelta,
formattedMeanAndDelta: formattedMean + (formattedDelta ? ` \xb1 ${formattedDelta} (${formattedPercentDelta})` : ""),
isValid: values.length > 0 && isFinite(sum) && sum > 0,
};
}
_addDetailedResultsRow(table, iterationNumber, value) {
const row = document.createElement("tr");
const th = document.createElement("th");
th.textContent = `Iteration ${iterationNumber + 1}`;
const td = document.createElement("td");
td.textContent = value;
row.appendChild(th);
row.appendChild(td);
table.appendChild(row);
}
_updateGaugeNeedle(score) {
const needleAngle = Math.max(0, Math.min(score, 140)) - 70;
const needleRotationValue = `rotate(${needleAngle}deg)`;
const gaugeNeedleElement = document.querySelector("#summary > .gauge .needle");
gaugeNeedleElement.style.setProperty("-webkit-transform", needleRotationValue);
gaugeNeedleElement.style.setProperty("-moz-transform", needleRotationValue);
gaugeNeedleElement.style.setProperty("-ms-transform", needleRotationValue);
gaugeNeedleElement.style.setProperty("transform", needleRotationValue);
}
_populateDetailedResults(metrics) {
this._populateNonStandardParams();
const trackHeight = 24;
document.documentElement.style.setProperty("--metrics-line-height", `${trackHeight}px`);
const plotWidth = (params.viewport.width - 120) / 2;
const aggregateMetrics = [metrics.Geomean];
if (params.measurePrepare)
aggregateMetrics.push(metrics.Prepare);
document.getElementById("aggregate-chart").innerHTML = renderMetricView({
metrics: aggregateMetrics,
width: plotWidth,
trackHeight,
renderChildren: false,
colors: ["white"],
});
const toplevelMetrics = Object.values(metrics).filter((each) => !each.parent && each.children.length > 0);
document.getElementById("tests-chart").innerHTML = renderMetricView({
metrics: toplevelMetrics,
width: plotWidth,
trackHeight,
renderChildren: false,
});
let html = "";
for (const metric of toplevelMetrics) {
html += renderMetricView({
metrics: metric.children,
width: plotWidth,
trackHeight,
title: metric.name,
});
}
document.getElementById("metrics-results").innerHTML = html;
const filePrefix = `speedometer-3-${new Date().toISOString()}`;
let jsonData = this._formattedJSONResult({ modern: false });
let jsonLink = document.getElementById("download-classic-json");
jsonLink.href = URL.createObjectURL(new Blob([jsonData], { type: "application/json" }));
jsonLink.setAttribute("download", `${filePrefix}.json`);
jsonLink = document.getElementById("download-full-json");
jsonData = this._formattedJSONResult({ modern: true });
jsonLink.href = URL.createObjectURL(new Blob([jsonData], { type: "application/json" }));
jsonLink.setAttribute("download", `${filePrefix}.json`);
const csvData = this._formattedCSVResult();
const csvLink = document.getElementById("download-csv");
csvLink.href = URL.createObjectURL(new Blob([csvData], { type: "text/csv" }));
csvLink.setAttribute("download", `${filePrefix}.csv`);
}
_populateNonStandardParams() {
if (params.isDefault())
return;
const paramsDiff = [];
const usedSearchparams = params.toSearchParamsObject();
const defaultSearchParams = defaultParams.toCompleteSearchParamsObject(false);
for (const [key, value] of usedSearchparams.entries()) {
if (key === "developerMode")
continue;
const defaultValue = defaultSearchParams.get(key);
if (value !== defaultValue)
paramsDiff.push({ key, value, defaultValue });
}
if (paramsDiff.length === 0)
return;
const body = document.createElement("tbody");
for (const { key, value, defaultValue } of paramsDiff) {
const row = body.insertRow();
row.insertCell().textContent = key;
row.insertCell().textContent = value;
row.insertCell().textContent = defaultValue;
}
const table = document.getElementById("non-standard-params-table");
table.replaceChild(body, table.tBodies[0]);
document.querySelector(".non-standard-params").style.display = "block";
}
prepareUI() {
window.addEventListener("hashchange", this._hashChangeHandler.bind(this));
window.addEventListener("resize", this._resizeScreeHandler.bind(this));
this._resizeScreeHandler();
document.querySelectorAll("logo").forEach((button) => {
button.onclick = this._logoClickHandler.bind(this);
});
document.getElementById("copy-full-json").onclick = this.copyJsonResults.bind(this);
document.getElementById("copy-csv").onclick = this.copyCSVResults.bind(this);
document.querySelectorAll(".start-tests-button").forEach((button) => {
button.onclick = this._startBenchmarkHandler.bind(this);
button.disabled = true;
});
}
async evaluateParams() {
const { benchmarkConfigurator } = await this._benchmarkConfiguratorPromise;
if (params.suites.length > 0 || params.tags.length > 0)
benchmarkConfigurator.enableSuites(params.suites, params.tags);
if (params.developerMode) {
this._developerModeContainer = createDeveloperModeContainer();
document.body.append(this._developerModeContainer);
}
if (params.startAutomatically)
this.start();
else
this._setBenchmarkState(BENCHMARK_STATE.READY);
}
async _setBenchmarkState(state) {
this._state = state;
document.body.setAttribute("data-benchmark-state", state);
const startButtons = document.querySelectorAll(".start-tests-button");
if (state !== BENCHMARK_STATE.RUNNING) {
startButtons.forEach((btn) => {
btn.innerHTML = "<div>Start Test</div>";
});
if (state === BENCHMARK_STATE.READY) {
startButtons.forEach((btn) => {
btn.disabled = false;
});
}
}
}
_hashChangeHandler() {
this._showSection(window.location.hash);
}
_resizeScreeHandler() {
// FIXME: Detect when the window size changes during the test.
const mainSize = document.querySelector("main").getBoundingClientRect();
const screenIsTooSmall = window.innerWidth < mainSize.width || window.innerHeight < mainSize.height;
document.getElementById("min-screen-width").textContent = `${params.viewport.width + 50}px`;
document.getElementById("min-screen-height").textContent = `${params.viewport.height + 50}px`;
document.getElementById("screen-size").textContent = `${window.innerWidth}px by ${window.innerHeight}px`;
document.getElementById("screen-size-warning").style.display = screenIsTooSmall ? null : "none";
}
_startBenchmarkHandler() {
this.start();
}
_logoClickHandler(event) {
// Prevent any accidental UI changes during benchmark runs.
if (!this.isRunning())
this._showSection("#home");
event.preventDefault();
return false;
}
showResultsSummary() {
this._showSection("#summary");
}
showResultsDetails() {
this._showSection("#details");
}
_formattedJSONResult({ modern = false }) {
const indent = " ";
if (modern)
return JSON.stringify(this._metrics, undefined, indent);
return JSON.stringify(this._measuredValuesList, undefined, indent);
}
_formattedCSVResult() {
// The CSV format is similar to the details view table. Each measurement is a row with
// the name and N columns with the measurement for each iteration:
// ```
// Measurement,#1,...,#N
// TodoMVC-JavaScript-ES5/Total,num,...,num
// TodoMVC-JavaScript-ES5/Adding100Items,num,...,num
// ...
const labels = ["Name"];
for (let i = 0; i < params.iterationCount; i++)
labels.push(`#${i + 1}`);
labels.push("Mean");
const metrics = Array.from(Object.values(this._metrics)).filter((metric) => !metric.name.startsWith("Iteration-"));
const metricsValues = metrics.map((metric) => [metric.name, ...metric.values, metric.mean].join(","));
const csv = [labels.join(","), ...metricsValues];
return csv.join("\n");
}
copyJsonResults() {
navigator.clipboard.writeText(this._formattedJSONResult({ modern: true }));
}
copyCSVResults() {
navigator.clipboard.writeText(this._formattedCSVResult());
}
_showSection(hash) {
if (this.isRunning()) {
this._setLocationHash("#running");
return;
} else if (this.hasFinished()) {
if (hash !== "#summary" && hash !== "#details") {
this._setLocationHash("#summary");
return;
}
} else if (hash !== "#home" && hash !== "") {
// Redirect invalid views to #home directly.
this._setLocationHash("#home");
return;
}
this._setLocationHash(hash);
}
_setLocationHash(hash) {
if (hash === "#home" || hash === "") {
if (window.location.hash !== hash)
window.location.hash = "#home";
hash = "#home";
this._removeLocationHash();
} else {
window.location.hash = hash;
}
this._updateVisibleSectionAttribute(hash);
this._updateDocumentTitle(hash);
}
_updateVisibleSectionAttribute(hash) {
const sectionId = hash.substring(1);
document.documentElement.setAttribute("data-visible-section", sectionId);
}
_updateDocumentTitle(hash) {
const maybeSection = document.querySelector(hash);
const sectionTitle = maybeSection?.getAttribute("data-title") ?? "";
document.title = `Speedometer 3 ${sectionTitle}`.trimEnd();
}
_removeLocationHash() {
const location = window.location;
window.history.pushState("", document.title, location.pathname + location.search);
}
}
function init() {
const rootStyle = document.documentElement.style;
rootStyle.setProperty("--viewport-width", `${params.viewport.width}px`);
rootStyle.setProperty("--viewport-height", `${params.viewport.height}px`);
globalThis.benchmarkClient = new MainBenchmarkClient();
window.addEventListener("error", (event) => {
globalThis.benchmarkClient.handleError(event.error || new Error(event.message));
});
window.addEventListener("unhandledrejection", (event) => {
globalThis.benchmarkClient.handleError(event.reason || new Error("Unhandled promise rejection"));
});
}
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", init);
else
init();