tachometer
Version:
Web benchmark runner
351 lines (319 loc) • 10.6 kB
text/typescript
/**
* @license
* Copyright (c) 2019 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at
* http://polymer.github.io/LICENSE.txt The complete set of authors may be found
* at http://polymer.github.io/AUTHORS.txt The complete set of contributors may
* be found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by
* Google as part of the polymer project is also subject to an additional IP
* rights grant found at http://polymer.github.io/PATENTS.txt
*/
import stripAnsi = require('strip-ansi');
import * as table from 'table';
import {UAParser} from 'ua-parser-js';
import ansi = require('ansi-escape-sequences');
import {Difference, ConfidenceInterval, ResultStats, ResultStatsWithDifferences} from './stats';
import {BenchmarkSpec, BenchmarkResult} from './types';
export const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'].map(
(frame) => ansi.format(`[blue]{${frame}}`));
/**
* An abstraction for the various dimensions of data we display.
*/
interface Dimension {
label: string;
format: (r: ResultStats) => string;
tableConfig?: table.TableColumns;
}
export interface ResultTable {
dimensions: Dimension[];
results: ResultStats[];
}
export interface AutomaticResults {
fixed: ResultTable;
unfixed: ResultTable;
}
/**
* Create an automatic mode result table.
*/
export function automaticResultTable(results: ResultStats[]): AutomaticResults {
// Typically most dimensions for a set of results share the same value (e.g
// because we're only running one benchmark, one browser, etc.). To save
// horizontal space and make the results easier to read, we first show the
// fixed values in one table, then the unfixed values in another.
const fixed: Dimension[] = [];
const unfixed: Dimension[] = [];
const possiblyFixed = [
benchmarkDimension,
versionDimension,
browserDimension,
sampleSizeDimension,
bytesSentDimension,
];
for (const dimension of possiblyFixed) {
const values = new Set<string>();
for (const res of results) {
values.add(dimension.format(res));
}
if (values.size === 1) {
fixed.push(dimension);
} else {
unfixed.push(dimension);
}
}
// These are the primary observed results, so they always go in the main
// result table, even if they happen to be the same in one run.
unfixed.push(
runtimeConfidenceIntervalDimension,
);
if (results.length > 1) {
// Create an NxN matrix comparing every result to every other result.
const labelFn = makeUniqueLabelFn(results.map((result) => result.result));
for (let i = 0; i < results.length; i++) {
unfixed.push({
label: `vs ${labelFn(results[i].result)}`,
tableConfig: {
alignment: 'right',
},
format: (r: ResultStats&Partial<ResultStatsWithDifferences>) => {
if (r.differences === undefined) {
return '';
}
const diff = r.differences[i];
if (diff === null) {
return ansi.format('\n[gray]{-} ');
}
return formatDifference(diff);
},
});
}
}
const fixedTable = {dimensions: fixed, results: [results[0]]};
const unfixedTable = {dimensions: unfixed, results};
return {fixed: fixedTable, unfixed: unfixedTable};
}
/**
* Format a terminal text result table where each result is a row:
*
* +--------+--------+
* | Header | Header |
* +--------+--------+
* | Value | Value |
* +--------+--------+
* | Value | Value |
* +--------+--------+
*/
export function verticalTermResultTable({dimensions, results}: ResultTable):
string {
const columns = dimensions.map((d) => d.tableConfig || {});
const rows = [
dimensions.map((d) => ansi.format(`[bold]{${d.label}}`)),
...results.map((r) => dimensions.map((d) => d.format(r))),
];
return table.table(rows, {
border: table.getBorderCharacters('norc'),
columns,
});
}
/**
* Format a terminal text result table where each result is a column:
*
* +--------+-------+-------+
* | Header | Value | Value |
* +--------+-------+-------+
* | Header | Value | Value |
* +--------+-------+-------+
*/
export function horizontalTermResultTable({dimensions, results}: ResultTable):
string {
const columns: table.TableColumns[] = [
{alignment: 'right'},
...results.map((): table.TableColumns => ({alignment: 'left'})),
];
const rows = dimensions.map((d) => {
return [
ansi.format(`[bold]{${d.label}}`),
...results.map((r) => d.format(r)),
];
});
return table.table(rows, {
border: table.getBorderCharacters('norc'),
columns,
});
}
/**
* Format an HTML result table where each result is a row:
*
* <table>
* <tr> <th>Header</th> <th>Header</th> </tr>
* <tr> <td>Value</td> <td>Value</td> </tr>
* <tr> <td>Value</td> <td>Value</td> </tr>
* </table>
*/
export function verticalHtmlResultTable({dimensions, results}: ResultTable):
string {
const headers = dimensions.map((d) => `<th>${d.label}</th>`);
const rows = [];
for (const r of results) {
const cells =
dimensions.map((d) => `<td>${ansiCellToHtml(d.format(r))}</td>`);
rows.push(`<tr>${cells.join('')}</tr>`);
}
return `<table>
<tr>${headers.join('')}</tr>
${rows.join('')}
</table>`;
}
/**
* Format an HTML result table where each result is a column:
*
* <table>
* <tr> <th>Header</th> <td>Value</td> <td>Value</td> </tr>
* <tr> <th>Header</th> <td>Value</td> <td>Value</td> </tr>
* </table>
*/
export function horizontalHtmlResultTable({dimensions, results}: ResultTable):
string {
const rows: string[] = [];
for (const d of dimensions) {
const cells = [
`<th align="right">${d.label}</th>`,
...results.map((r) => `<td>${ansiCellToHtml(d.format(r))}</td>`),
];
rows.push(`<tr>${cells.join('')}</tr>`);
}
return `<table>${rows.join('')}</table>`;
}
function ansiCellToHtml(ansi: string): string {
// For now, just remove ANSI color sequences and prevent line-breaks. We may
// want to add an htmlFormat method to each dimension object so that we can
// have more advanced control per dimension.
return stripAnsi(ansi).replace(/ /g, ' ');
}
/**
* Format a confidence interval as "[low, high]".
*/
const formatConfidenceInterval =
(ci: ConfidenceInterval, format: (n: number) => string) => {
return ansi.format(`${format(ci.low)} [gray]{-} ${format(ci.high)}`);
};
/**
* Prefix positive numbers with a red "+" and negative ones with a green "-".
*/
const colorizeSign = (n: number, format: (n: number) => string) => {
if (n > 0) {
return ansi.format(`[red bold]{+}${format(n)}`);
} else if (n < 0) {
// Negate the value so that we don't get a double negative sign.
return ansi.format(`[green bold]{-}${format(-n)}`);
} else {
return format(n);
}
};
const benchmarkDimension: Dimension = {
label: 'Benchmark',
format: (r: ResultStats) => r.result.name,
};
const versionDimension: Dimension = {
label: 'Version',
format: (r: ResultStats) => r.result.version || ansi.format('[gray]{<none>}'),
};
const browserDimension: Dimension = {
label: 'Browser',
format: (r: ResultStats) => {
const browser = r.result.browser;
let s = browser.name;
if (browser.headless) {
s += '-headless';
}
if (browser.remoteUrl) {
s += `\n@${browser.remoteUrl}`;
}
if (r.result.userAgent !== '') {
// We'll only have a user agent when using the built-in static server.
// TODO Get UA from window.navigator.userAgent so we always have it.
const ua = new UAParser(r.result.userAgent).getBrowser();
s += `\n${ua.version}`;
}
return s;
},
};
const sampleSizeDimension: Dimension = {
label: 'Sample size',
format: (r: ResultStats) => r.result.millis.length.toString(),
};
const bytesSentDimension: Dimension = {
label: 'Bytes',
format: (r: ResultStats) => (r.result.bytesSent / 1024).toFixed(2) + ' KiB',
};
const runtimeConfidenceIntervalDimension: Dimension = {
label: 'Avg time',
tableConfig: {
alignment: 'right',
},
format: (r: ResultStats) =>
formatConfidenceInterval(r.stats.meanCI, (n) => n.toFixed(2) + 'ms'),
};
function formatDifference({absolute, relative}: Difference): string {
let word, rel, abs;
if (absolute.low > 0 && relative.low > 0) {
word = `[bold red]{slower}`;
rel = `${percent(relative.low)}% [gray]{-} ${percent(relative.high)}%`;
abs =
`${absolute.low.toFixed(2)}ms [gray]{-} ${absolute.high.toFixed(2)}ms`;
} else if (absolute.high < 0 && relative.high < 0) {
word = `[bold green]{faster}`;
rel = `${percent(-relative.high)}% [gray]{-} ${percent(-relative.low)}%`;
abs = `${- absolute.high.toFixed(2)}ms [gray]{-} ${
- absolute.low.toFixed(2)}ms`;
} else {
word = `[bold blue]{unsure}`;
rel = `${colorizeSign(relative.low, (n) => percent(n))}% [gray]{-} ${
colorizeSign(relative.high, (n) => percent(n))}%`;
abs = `${colorizeSign(absolute.low, (n) => n.toFixed(2))}ms [gray]{-} ${
colorizeSign(absolute.high, (n) => n.toFixed(2))}ms`;
}
return ansi.format(`${word}\n${rel}\n${abs}`);
}
function percent(n: number): string {
return (n * 100).toFixed(0);
}
/**
* Create a function that will return the shortest unambiguous label for a
* result, given the full array of results.
*/
function makeUniqueLabelFn(results: BenchmarkResult[]):
(result: BenchmarkResult) => string {
const names = new Set<string>();
const versions = new Set<string>();
const browsers = new Set<string>();
for (const result of results) {
names.add(result.name);
versions.add(result.version);
browsers.add(result.browser.name);
}
return (result: BenchmarkResult) => {
const fields: string[] = [];
if (names.size > 1) {
fields.push(result.name);
}
if (versions.size > 1) {
fields.push(result.version);
}
if (browsers.size > 1) {
fields.push(result.browser.name);
}
return fields.join('\n');
};
}
/**
* A one-line summary of a benchmark, e.g. for a progress bar:
*
* chrome my-benchmark [@my-version]
*/
export function benchmarkOneLiner(spec: BenchmarkSpec) {
let maybeVersion = '';
if (spec.url.kind === 'local' && spec.url.version !== undefined) {
maybeVersion = ` [@${spec.url.version.label}]`;
}
return `${spec.browser.name} ${spec.name}${maybeVersion}`;
}