Skip to content

Commit 9c1f0d3

Browse files
committed
Ability to take multiple measures on the same page
1 parent 29d75d2 commit 9c1f0d3

18 files changed

+308
-141
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ project adheres to [Semantic Versioning](http://semver.org/).
77

88
## Unreleased
99

10+
- Add ability to specify multiple measurements from the same page load by
11+
setting the `measurement` property in the JSON config file to an array. For
12+
example, you can now use the performance API to define two intervals on the
13+
same page, and compare them to each other or to other pages.
14+
1015
- Add ability to pull measurements from the browser performance measurement API,
1116
e.g.:
1217

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ confidence in them.
8686
Tachometer supports four kinds of time interval measurements, controlled with
8787
the `measurement` config file property, or the `--measure` flag.
8888

89+
If `measurement` is an array, then all of the given measurements will be
90+
retrieved from each page load. Each measurement from a page is treated as its
91+
own benchmark.
92+
8993
#### Performance API
9094

9195
Retrieve a measure, mark, or paint timing from the

config.schema.json

+16
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,22 @@
113113
{
114114
"$ref": "#/definitions/ExpressionMeasurement"
115115
},
116+
{
117+
"items": {
118+
"anyOf": [
119+
{
120+
"$ref": "#/definitions/CallbackMeasurement"
121+
},
122+
{
123+
"$ref": "#/definitions/PerformanceEntryMeasurement"
124+
},
125+
{
126+
"$ref": "#/definitions/ExpressionMeasurement"
127+
}
128+
]
129+
},
130+
"type": "array"
131+
},
116132
{
117133
"enum": [
118134
"callback",

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,4 @@
9292
"typescript": "^3.6.4",
9393
"typescript-json-schema": "^0.42.0"
9494
}
95-
}
95+
}

src/automatic.ts

+65-43
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ import ansi = require('ansi-escape-sequences');
1717

1818
import {jsonOutput, legacyJsonOutput} from './json-output';
1919
import {browserSignature, makeDriver, openAndSwitchToNewTab} from './browser';
20-
import {measure} from './measure';
20+
import {measure, measurementName} from './measure';
2121
import {BenchmarkResult, BenchmarkSpec} from './types';
2222
import {formatCsvStats, formatCsvRaw} from './csv';
23-
import {ResultStats, ResultStatsWithDifferences, horizonsResolved, summaryStats, computeDifferences} from './stats';
23+
import {ResultStatsWithDifferences, horizonsResolved, summaryStats, computeDifferences} from './stats';
2424
import {verticalTermResultTable, horizontalTermResultTable, verticalHtmlResultTable, horizontalHtmlResultTable, automaticResultTable, spinner, benchmarkOneLiner} from './format';
2525
import {Config} from './config';
2626
import * as github from './github';
@@ -41,7 +41,7 @@ export class AutomaticMode {
4141
private browsers = new Map<string, Browser>();
4242
private bar: ProgressBar;
4343
private completeGithubCheck?: (markdown: string) => void;
44-
private specResults = new Map<BenchmarkSpec, BenchmarkResult[]>();
44+
private results = new Map<BenchmarkSpec, BenchmarkResult[]>();
4545
private hitTimeout = false;
4646

4747
constructor(config: Config, servers: Map<BenchmarkSpec, Server>) {
@@ -62,9 +62,6 @@ export class AutomaticMode {
6262
}
6363
console.log('Running benchmarks\n');
6464
await this.warmup();
65-
for (const spec of this.specs) {
66-
this.specResults.set(spec, []);
67-
}
6865
await this.takeMinimumSamples();
6966
await this.takeAdditionalSamples();
7067
await this.closeBrowsers();
@@ -111,22 +108,39 @@ export class AutomaticMode {
111108
bar.tick(0, {
112109
status: `warmup ${i + 1}/${specs.length} ${benchmarkOneLiner(spec)}`,
113110
});
114-
await this.takeSample(spec);
111+
await this.takeSamples(spec);
115112
bar.tick(1);
116113
}
117114
}
118115

116+
private recordSamples(spec: BenchmarkSpec, newResults: BenchmarkResult[]) {
117+
let specResults = this.results.get(spec);
118+
if (specResults === undefined) {
119+
specResults = [];
120+
this.results.set(spec, specResults);
121+
}
122+
123+
for (const newResult of newResults) {
124+
const primary = specResults[newResult.measurementIdx];
125+
if (primary === undefined) {
126+
specResults[newResult.measurementIdx] = newResult;
127+
} else {
128+
primary.millis.push(...newResult.millis);
129+
}
130+
}
131+
}
132+
119133
private async takeMinimumSamples() {
120134
// Always collect our minimum number of samples.
121-
const {config, specs, bar, specResults} = this;
135+
const {config, specs, bar} = this;
122136
const numRuns = specs.length * config.sampleSize;
123137
let run = 0;
124138
for (let sample = 0; sample < config.sampleSize; sample++) {
125139
for (const spec of specs) {
126140
bar.tick(0, {
127141
status: `${++run}/${numRuns} ${benchmarkOneLiner(spec)}`,
128142
});
129-
specResults.get(spec)!.push(await this.takeSample(spec));
143+
this.recordSamples(spec, await this.takeSamples(spec));
130144
if (bar.curr === bar.total - 1) {
131145
// Note if we tick with 0 after we've completed, the status is
132146
// rendered on the next line for some reason.
@@ -139,7 +153,7 @@ export class AutomaticMode {
139153
}
140154

141155
private async takeAdditionalSamples() {
142-
const {config, specs, specResults} = this;
156+
const {config, specs} = this;
143157
if (config.timeout > 0) {
144158
console.log();
145159
const timeoutMs = config.timeout * 60 * 1000; // minutes -> millis
@@ -170,14 +184,14 @@ export class AutomaticMode {
170184
process.stdout.write(
171185
`\r${spinner[run % spinner.length]} Auto-sample ${sample} ` +
172186
`(timeout in ${mins}m${secs}s)` + ansi.erase.inLine(0));
173-
specResults.get(spec)!.push(await this.takeSample(spec));
187+
this.recordSamples(spec, await this.takeSamples(spec));
174188
}
175189
}
176190
}
177191
}
178192
}
179193

180-
private async takeSample(spec: BenchmarkSpec): Promise<BenchmarkResult> {
194+
private async takeSamples(spec: BenchmarkSpec): Promise<BenchmarkResult[]> {
181195
const {servers, config, browsers} = this;
182196

183197
let server;
@@ -192,19 +206,31 @@ export class AutomaticMode {
192206
const {driver, initialTabHandle} =
193207
browsers.get(browserSignature(spec.browser))!;
194208

195-
let millis: number|undefined;
196209
let bytesSent = 0;
197210
let userAgent = '';
198211
// TODO(aomarks) Make maxAttempts and timeouts configurable.
199212
const maxAttempts = 3;
213+
const measurements = spec.measurement;
214+
let millis: number[];
215+
let numPending: number;
200216
for (let attempt = 1;; attempt++) {
217+
millis = [];
218+
numPending = measurements.length;
201219
await openAndSwitchToNewTab(driver, spec.browser);
202220
await driver.get(url);
203-
for (let waited = 0; millis === undefined && waited <= 10000;
204-
waited += 50) {
221+
for (let waited = 0; numPending > 0 && waited <= 10000; waited += 50) {
205222
// TODO(aomarks) You don't have to wait in callback mode!
206223
await wait(50);
207-
millis = await measure(driver, spec.measurement, server);
224+
for (let i = 0; i < measurements.length; i++) {
225+
if (millis[i] !== undefined) {
226+
continue;
227+
}
228+
const result = await measure(driver, measurements[i], server);
229+
if (result !== undefined) {
230+
millis[i] = result;
231+
numPending--;
232+
}
233+
}
208234
}
209235

210236
// Close the active tab (but not the whole browser, since the
@@ -218,7 +244,7 @@ export class AutomaticMode {
218244
userAgent = session.userAgent;
219245
}
220246

221-
if (millis !== undefined || attempt >= maxAttempts) {
247+
if (numPending === 0 || attempt >= maxAttempts) {
222248
break;
223249
}
224250

@@ -228,44 +254,40 @@ export class AutomaticMode {
228254
`in ${spec.browser.name} from ${url}. Retrying.`);
229255
}
230256

231-
if (millis === undefined) {
257+
if (numPending > 0) {
232258
console.log();
233259
throw new Error(
234260
`\n\nFailed ${maxAttempts}/${maxAttempts} times ` +
235261
`to get a measurement ` +
236262
`in ${spec.browser.name} from ${url}. Retrying.`);
237263
}
238264

239-
return {
240-
name: spec.name,
241-
queryString: spec.url.kind === 'local' ? spec.url.queryString : '',
242-
version: spec.url.kind === 'local' && spec.url.version !== undefined ?
243-
spec.url.version.label :
244-
'',
245-
millis: [millis],
246-
bytesSent,
247-
browser: spec.browser,
248-
userAgent,
249-
};
265+
return measurements.map(
266+
(measurement, measurementIdx) => ({
267+
name: measurements.length === 1 ?
268+
spec.name :
269+
`${spec.name} [${measurementName(measurement)}]`,
270+
measurementIdx,
271+
queryString: spec.url.kind === 'local' ? spec.url.queryString : '',
272+
version: spec.url.kind === 'local' && spec.url.version !== undefined ?
273+
spec.url.version.label :
274+
'',
275+
millis: [millis[measurementIdx]],
276+
bytesSent,
277+
browser: spec.browser,
278+
userAgent,
279+
}));
250280
}
251281

252282
makeResults() {
253-
const results: BenchmarkResult[] = [];
254-
for (const sr of this.specResults.values()) {
255-
const combined: BenchmarkResult = {
256-
...sr[0],
257-
millis: [],
258-
};
259-
for (const result of sr) {
260-
combined.millis.push(...result.millis);
283+
const resultStats = [];
284+
for (const results of this.results.values()) {
285+
for (let r = 0; r < results.length; r++) {
286+
const result = results[r];
287+
resultStats.push({result, stats: summaryStats(result.millis)});
261288
}
262-
results.push(combined);
263289
}
264-
const withStats = results.map((result): ResultStats => ({
265-
result,
266-
stats: summaryStats(result.millis),
267-
}));
268-
return computeDifferences(withStats);
290+
return computeDifferences(resultStats);
269291
}
270292

271293
private async outputResults(withDifferences: ResultStatsWithDifferences[]) {

src/config.ts

+8-6
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,14 @@ export async function makeConfig(opts: Opts): Promise<Config> {
123123
}
124124

125125
for (const spec of config.benchmarks) {
126-
if (spec.measurement.kind === 'performance' &&
127-
spec.measurement.entryName === 'first-contentful-paint' &&
128-
!fcpBrowsers.has(spec.browser.name)) {
129-
throw new Error(
130-
`Browser ${spec.browser.name} does not support the ` +
131-
`first contentful paint (FCP) measurement`);
126+
for (const measurement of spec.measurement) {
127+
if (measurement.kind === 'performance' &&
128+
measurement.entryName === 'first-contentful-paint' &&
129+
!fcpBrowsers.has(spec.browser.name)) {
130+
throw new Error(
131+
`Browser ${spec.browser.name} does not support the ` +
132+
`first contentful paint (FCP) measurement`);
133+
}
132134
}
133135
}
134136

src/configfile.ts

+12-9
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ interface ConfigFileBenchmark {
136136
expand?: ConfigFileBenchmark[];
137137
}
138138

139-
type ConfigFileMeasurement = 'callback'|'fcp'|'global'|Measurement;
139+
type ConfigFileMeasurement =
140+
'callback'|'fcp'|'global'|Measurement|Array<Measurement>;
140141

141142
type BrowserConfigs =
142143
ChromeConfig|FirefoxConfig|SafariConfig|EdgeConfig|IEConfig;
@@ -327,22 +328,24 @@ async function parseBenchmark(benchmark: ConfigFileBenchmark, root: string):
327328
}
328329

329330
if (benchmark.measurement === 'callback') {
330-
spec.measurement = {
331+
spec.measurement = [{
331332
kind: 'callback',
332-
};
333+
}];
333334
} else if (benchmark.measurement === 'fcp') {
334-
spec.measurement = {
335+
spec.measurement = [{
335336
kind: 'performance',
336337
entryName: 'first-contentful-paint',
337-
};
338+
}];
338339
} else if (benchmark.measurement === 'global') {
339-
spec.measurement = {
340+
spec.measurement = [{
340341
kind: 'expression',
341342
expression:
342343
benchmark.measurementExpression || defaults.measurementExpression,
343-
};
344-
} else {
344+
}];
345+
} else if (Array.isArray(benchmark.measurement)) {
345346
spec.measurement = benchmark.measurement;
347+
} else if (benchmark.measurement !== undefined) {
348+
spec.measurement = [benchmark.measurement];
346349
}
347350

348351
const url = benchmark.url;
@@ -457,7 +460,7 @@ function applyDefaults(partialSpec: Partial<BenchmarkSpec>): BenchmarkSpec {
457460
};
458461
}
459462
if (measurement === undefined) {
460-
measurement = defaults.measurement(url);
463+
measurement = [defaults.measurement(url)];
461464
}
462465
return {name, url, browser, measurement};
463466
}

src/measure.ts

+21
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,25 @@ async function queryForExpression(
108108
}
109109
return result;
110110
}
111+
}
112+
113+
/**
114+
* Return a good-enough label for the given measurement, to disambiguate cases
115+
* where there are multiple measurements on the same page.
116+
*/
117+
export function measurementName(measurement: Measurement): string {
118+
switch (measurement.kind) {
119+
case 'callback':
120+
return 'callback';
121+
case 'expression':
122+
return measurement.expression;
123+
case 'performance':
124+
return measurement.entryName === 'first-contentful-paint' ?
125+
'fcp' :
126+
measurement.entryName;
127+
}
128+
throwUnreachable(
129+
measurement,
130+
`Internal error: unknown measurement type ` +
131+
JSON.stringify(measurement));
111132
}

src/specs.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,9 @@ export async function specsFromOpts(opts: Opts): Promise<BenchmarkSpec[]> {
106106
const spec: BenchmarkSpec = {
107107
name: arg.alias || arg.url,
108108
browser,
109-
measurement: measurement === undefined ? defaults.measurement(url) :
110-
measurement,
109+
measurement:
110+
[measurement === undefined ? defaults.measurement(url) :
111+
measurement],
111112
url,
112113
};
113114
specs.push(spec);
@@ -132,8 +133,9 @@ export async function specsFromOpts(opts: Opts): Promise<BenchmarkSpec[]> {
132133
const spec: BenchmarkSpec = {
133134
name,
134135
browser,
135-
measurement: measurement === undefined ? defaults.measurement(url) :
136-
measurement,
136+
measurement:
137+
[measurement === undefined ? defaults.measurement(url) :
138+
measurement],
137139
url,
138140
};
139141
specs.push(spec);

0 commit comments

Comments
 (0)