Skip to content

Commit 6834c49

Browse files
goderbauertbosch
authored andcommitted
feat(benchpress): add mean frame time metric
This is useful to measure the smoothness of animations and scrolling actions. part of #821 closes #2474
1 parent 1cf807c commit 6834c49

File tree

7 files changed

+264
-12
lines changed

7 files changed

+264
-12
lines changed

Diff for: modules/benchpress/src/common_options.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export class Options {
2525
static get WRITE_FILE() { return _WRITE_FILE; }
2626
// TODO(tbosch): use static values when our transpiler supports them
2727
static get MICRO_METRICS() { return _MICRO_METRICS; }
28+
// TODO(tbosch): use static values when our transpiler supports them
29+
static get CAPTURE_FRAMES() { return _CAPTURE_FRAMES; }
2830
}
2931

3032
var _SAMPLE_ID = new OpaqueToken('Options.sampleId');
@@ -38,6 +40,7 @@ var _USER_AGENT = new OpaqueToken('Options.userAgent');
3840
var _MICRO_METRICS = new OpaqueToken('Options.microMetrics');
3941
var _NOW = new OpaqueToken('Options.now');
4042
var _WRITE_FILE = new OpaqueToken('Options.writeFile');
43+
var _CAPTURE_FRAMES = new OpaqueToken('Options.frameCapture');
4144

4245
var _DEFAULT_BINDINGS = [
4346
bind(_DEFAULT_DESCRIPTION)
@@ -46,5 +49,6 @@ var _DEFAULT_BINDINGS = [
4649
bind(_FORCE_GC).toValue(false),
4750
bind(_PREPARE).toValue(false),
4851
bind(_MICRO_METRICS).toValue({}),
49-
bind(_NOW).toValue(() => DateWrapper.now())
52+
bind(_NOW).toValue(() => DateWrapper.now()),
53+
bind(_CAPTURE_FRAMES).toValue(false)
5054
];

Diff for: modules/benchpress/src/metric/perflog_metric.ts

+78-5
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export class PerflogMetric extends Metric {
3535
* @param microMetrics Name and description of metrics provided via console.time / console.timeEnd
3636
**/
3737
constructor(private _driverExtension: WebDriverExtension, private _setTimeout: Function,
38-
private _microMetrics: StringMap<string, any>, private _forceGc: boolean) {
38+
private _microMetrics: StringMap<string, any>, private _forceGc: boolean,
39+
private _captureFrames: boolean) {
3940
super();
4041

4142
this._remainingEvents = [];
@@ -60,6 +61,11 @@ export class PerflogMetric extends Metric {
6061
res['forcedGcAmount'] = 'forced gc amount in kbytes';
6162
}
6263
}
64+
if (this._captureFrames) {
65+
res['meanFrameTime'] = this._perfLogFeatures.frameCapture ?
66+
'mean frame time in ms (target: 16.6ms for 60fps)' :
67+
'WARNING: Metric requested, but not supported by driver';
68+
}
6369
StringMapWrapper.forEach(this._microMetrics,
6470
(desc, name) => { StringMapWrapper.set(res, name, desc); });
6571
return res;
@@ -83,9 +89,13 @@ export class PerflogMetric extends Metric {
8389

8490
_endPlainMeasureAndMeasureForceGc(restartMeasure: boolean) {
8591
return this._endMeasure(true).then((measureValues) => {
92+
// disable frame capture for measurments during forced gc
93+
var originalFrameCaptureValue = this._captureFrames;
94+
this._captureFrames = false;
8695
return this._driverExtension.gc()
8796
.then((_) => this._endMeasure(restartMeasure))
8897
.then((forceGcMeasureValues) => {
98+
this._captureFrames = originalFrameCaptureValue;
8999
StringMapWrapper.set(measureValues, 'forcedGcTime', forceGcMeasureValues['gcTime']);
90100
StringMapWrapper.set(measureValues, 'forcedGcAmount', forceGcMeasureValues['gcAmount']);
91101
return measureValues;
@@ -161,13 +171,21 @@ export class PerflogMetric extends Metric {
161171
if (this._perfLogFeatures.render) {
162172
result['renderTime'] = 0;
163173
}
174+
if (this._captureFrames) {
175+
result['meanFrameTime'] = 0;
176+
}
164177
StringMapWrapper.forEach(this._microMetrics, (desc, name) => { result[name] = 0; });
165178

166179
var markStartEvent = null;
167180
var markEndEvent = null;
168181
var gcTimeInScript = 0;
169182
var renderTimeInScript = 0;
170183

184+
var frameTimestamps = [];
185+
var frameTimes = [];
186+
var frameCaptureStartEvent = null;
187+
var frameCaptureEndEvent = null;
188+
171189
var intervalStarts: StringMap<string, any> = {};
172190
var intervalStartCount: StringMap<string, number> = {};
173191
events.forEach((event) => {
@@ -185,8 +203,37 @@ export class PerflogMetric extends Metric {
185203
} else if (StringWrapper.equals(ph, 'e') && StringWrapper.equals(name, markName)) {
186204
markEndEvent = event;
187205
}
206+
188207
if (isPresent(markStartEvent) && isBlank(markEndEvent) &&
189208
event['pid'] === markStartEvent['pid']) {
209+
if (StringWrapper.equals(ph, 'b') && StringWrapper.equals(name, _MARK_NAME_FRAME_CAPUTRE)) {
210+
if (isPresent(frameCaptureStartEvent)) {
211+
throw new BaseException('can capture frames only once per benchmark run');
212+
}
213+
if (!this._captureFrames) {
214+
throw new BaseException(
215+
'found start event for frame capture, but frame capture was not requested in benchpress')
216+
}
217+
frameCaptureStartEvent = event;
218+
} else if (StringWrapper.equals(ph, 'e') &&
219+
StringWrapper.equals(name, _MARK_NAME_FRAME_CAPUTRE)) {
220+
if (isBlank(frameCaptureStartEvent)) {
221+
throw new BaseException('missing start event for frame capture');
222+
}
223+
frameCaptureEndEvent = event;
224+
}
225+
226+
if (StringWrapper.equals(ph, 'I') || StringWrapper.equals(ph, 'i')) {
227+
if (isPresent(frameCaptureStartEvent) && isBlank(frameCaptureEndEvent) &&
228+
StringWrapper.equals(name, 'frame')) {
229+
ListWrapper.push(frameTimestamps, event['ts']);
230+
if (frameTimestamps.length >= 2) {
231+
ListWrapper.push(frameTimes, frameTimestamps[frameTimestamps.length - 1] -
232+
frameTimestamps[frameTimestamps.length - 2]);
233+
}
234+
}
235+
}
236+
190237
if (StringWrapper.equals(ph, 'B') || StringWrapper.equals(ph, 'b')) {
191238
if (isBlank(intervalStarts[name])) {
192239
intervalStartCount[name] = 1;
@@ -227,8 +274,25 @@ export class PerflogMetric extends Metric {
227274
}
228275
}
229276
});
277+
if (!isPresent(markStartEvent) || !isPresent(markEndEvent)) {
278+
// not all events have been received, no further processing for now
279+
return null;
280+
}
281+
282+
if (isPresent(markEndEvent) && isPresent(frameCaptureStartEvent) &&
283+
isBlank(frameCaptureEndEvent)) {
284+
throw new BaseException('missing end event for frame capture');
285+
}
286+
if (this._captureFrames && isBlank(frameCaptureStartEvent)) {
287+
throw new BaseException(
288+
'frame capture requested in benchpress, but no start event was found');
289+
}
290+
if (frameTimes.length > 0) {
291+
result['meanFrameTime'] =
292+
ListWrapper.reduce(frameTimes, (a, b) => a + b, 0) / frameTimes.length;
293+
}
230294
result['pureScriptTime'] = result['scriptTime'] - gcTimeInScript - renderTimeInScript;
231-
return isPresent(markStartEvent) && isPresent(markEndEvent) ? result : null;
295+
return result;
232296
}
233297

234298
_markName(index) { return `${_MARK_NAME_PREFIX}${index}`; }
@@ -239,10 +303,19 @@ var _MAX_RETRY_COUNT = 20;
239303
var _MARK_NAME_PREFIX = 'benchpress';
240304
var _SET_TIMEOUT = new OpaqueToken('PerflogMetric.setTimeout');
241305

306+
var _MARK_NAME_FRAME_CAPUTRE = 'frameCapture';
307+
242308
var _BINDINGS = [
243309
bind(PerflogMetric)
244-
.toFactory((driverExtension, setTimeout, microMetrics, forceGc) =>
245-
new PerflogMetric(driverExtension, setTimeout, microMetrics, forceGc),
246-
[WebDriverExtension, _SET_TIMEOUT, Options.MICRO_METRICS, Options.FORCE_GC]),
310+
.toFactory(
311+
(driverExtension, setTimeout, microMetrics, forceGc, captureFrames) =>
312+
new PerflogMetric(driverExtension, setTimeout, microMetrics, forceGc, captureFrames),
313+
[
314+
WebDriverExtension,
315+
_SET_TIMEOUT,
316+
Options.MICRO_METRICS,
317+
Options.FORCE_GC,
318+
Options.CAPTURE_FRAMES
319+
]),
247320
bind(_SET_TIMEOUT).toValue((fn, millis) => TimerWrapper.setTimeout(fn, millis))
248321
];

Diff for: modules/benchpress/src/web_driver_extension.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,15 @@ export class WebDriverExtension {
6666
export class PerfLogFeatures {
6767
render: boolean;
6868
gc: boolean;
69+
frameCapture: boolean;
6970

70-
constructor({render = false, gc = false}: {render?: boolean, gc?: boolean} = {}) {
71+
constructor({render = false, gc = false,
72+
frameCapture = false}: {render?: boolean,
73+
gc?: boolean,
74+
frameCapture?: boolean} = {}) {
7175
this.render = render;
7276
this.gc = gc;
77+
this.frameCapture = frameCapture;
7378
}
7479
}
7580

Diff for: modules/benchpress/src/webdriver/chrome_driver_extension.ts

+32-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import {WebDriverExtension, PerfLogFeatures} from '../web_driver_extension';
1414
import {WebDriverAdapter} from '../web_driver_adapter';
1515
import {Promise} from 'angular2/src/facade/async';
1616

17+
/**
18+
* Set the following 'traceCategories' to collect metrics in Chrome:
19+
* 'v8,blink.console,disabled-by-default-devtools.timeline'
20+
*
21+
* In order to collect the frame rate related metrics, add 'benchmark'
22+
* to the list above.
23+
*/
1724
export class ChromeDriverExtension extends WebDriverExtension {
1825
// TODO(tbosch): use static values when our transpiler supports them
1926
static get BINDINGS(): List<Binding> { return _BINDINGS; }
@@ -73,12 +80,14 @@ export class ChromeDriverExtension extends WebDriverExtension {
7380
(isBlank(args) || isBlank(args['data']) ||
7481
!StringWrapper.equals(args['data']['scriptName'], 'InjectedScript'))) {
7582
ListWrapper.push(normalizedEvents, normalizeEvent(event, {'name': 'script'}));
83+
7684
} else if (StringWrapper.equals(name, 'RecalculateStyles') ||
7785
StringWrapper.equals(name, 'Layout') ||
7886
StringWrapper.equals(name, 'UpdateLayerTree') ||
7987
StringWrapper.equals(name, 'Paint') || StringWrapper.equals(name, 'Rasterize') ||
8088
StringWrapper.equals(name, 'CompositeLayers')) {
8189
ListWrapper.push(normalizedEvents, normalizeEvent(event, {'name': 'render'}));
90+
8291
} else if (StringWrapper.equals(name, 'GCEvent')) {
8392
var normArgs = {
8493
'usedHeapSize': isPresent(args['usedHeapSizeAfter']) ? args['usedHeapSizeAfter'] :
@@ -91,20 +100,42 @@ export class ChromeDriverExtension extends WebDriverExtension {
91100
ListWrapper.push(normalizedEvents,
92101
normalizeEvent(event, {'name': 'gc', 'args': normArgs}));
93102
}
103+
94104
} else if (StringWrapper.equals(cat, 'blink.console')) {
95105
ListWrapper.push(normalizedEvents, normalizeEvent(event, {'name': name}));
106+
96107
} else if (StringWrapper.equals(cat, 'v8')) {
97108
if (StringWrapper.equals(name, 'majorGC')) {
98109
if (StringWrapper.equals(ph, 'B')) {
99110
majorGCPids[pid] = true;
100111
}
101112
}
113+
114+
} else if (StringWrapper.equals(cat, 'benchmark')) {
115+
if (StringWrapper.equals(name, 'BenchmarkInstrumentation::ImplThreadRenderingStats')) {
116+
var frameCount = event['args']['data']['frame_count'];
117+
if (frameCount > 1) {
118+
throw new BaseException('multi-frame render stats not supported');
119+
}
120+
if (frameCount == 1) {
121+
ListWrapper.push(normalizedEvents, normalizeEvent(event, {'name': 'frame'}));
122+
}
123+
} else if (StringWrapper.equals(name, 'BenchmarkInstrumentation::DisplayRenderingStats') ||
124+
StringWrapper.equals(name, 'vsync_before')) {
125+
// TODO(goderbauer): If present, these events should be used instead of
126+
// BenchmarkInstrumentation::ImplThreadRenderingStats.
127+
// However, they never seem to appear in practice. Maybe they appear on a different
128+
// platform?
129+
throw new BaseException('NYI');
130+
}
102131
}
103132
});
104133
return normalizedEvents;
105134
}
106135

107-
perfLogFeatures(): PerfLogFeatures { return new PerfLogFeatures({render: true, gc: true}); }
136+
perfLogFeatures(): PerfLogFeatures {
137+
return new PerfLogFeatures({render: true, gc: true, frameCapture: true});
138+
}
108139

109140
supports(capabilities: StringMap<string, any>): boolean {
110141
return StringWrapper.equals(capabilities['browserName'].toLowerCase(), 'chrome');

Diff for: modules/benchpress/test/metric/perflog_metric_spec.ts

+97-4
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ export function main() {
3131
var commandLog;
3232
var eventFactory = new TraceEventFactory('timeline', 'pid0');
3333

34-
function createMetric(perfLogs, microMetrics = null, perfLogFeatures = null, forceGc = null) {
34+
function createMetric(perfLogs, microMetrics = null, perfLogFeatures = null, forceGc = null,
35+
captureFrames = null) {
3536
commandLog = [];
3637
if (isBlank(perfLogFeatures)) {
37-
perfLogFeatures = new PerfLogFeatures({render: true, gc: true});
38+
perfLogFeatures = new PerfLogFeatures({render: true, gc: true, frameCapture: true});
3839
}
3940
if (isBlank(microMetrics)) {
4041
microMetrics = StringMapWrapper.create();
@@ -54,6 +55,9 @@ export function main() {
5455
if (isPresent(forceGc)) {
5556
ListWrapper.push(bindings, bind(Options.FORCE_GC).toValue(forceGc));
5657
}
58+
if (isPresent(captureFrames)) {
59+
ListWrapper.push(bindings, bind(Options.CAPTURE_FRAMES).toValue(captureFrames));
60+
}
5761
return Injector.resolveAndCreate(bindings).get(PerflogMetric);
5862
}
5963

@@ -98,6 +102,20 @@ export function main() {
98102
expect(description['myMicroMetric']).toEqual('someDesc');
99103
});
100104

105+
it('should describe itself if frame capture is requested and available', () => {
106+
var description =
107+
createMetric([[]], null, new PerfLogFeatures({frameCapture: true}), null, true)
108+
.describe();
109+
expect(description['meanFrameTime']).not.toContain('WARNING');
110+
});
111+
112+
it('should describe itself if frame capture is requested and not available', () => {
113+
var description =
114+
createMetric([[]], null, new PerfLogFeatures({frameCapture: false}), null, true)
115+
.describe();
116+
expect(description['meanFrameTime']).toContain('WARNING');
117+
});
118+
101119
describe('beginMeasure', () => {
102120

103121
it('should not force gc and mark the timeline', inject([AsyncTestCompleter], (async) => {
@@ -300,13 +318,88 @@ export function main() {
300318

301319
describe('aggregation', () => {
302320

303-
function aggregate(events, microMetrics = null) {
321+
function aggregate(events, microMetrics = null, captureFrames = null) {
304322
ListWrapper.insert(events, 0, eventFactory.markStart('benchpress0', 0));
305323
ListWrapper.push(events, eventFactory.markEnd('benchpress0', 10));
306-
var metric = createMetric([events], microMetrics);
324+
var metric = createMetric([events], microMetrics, null, null, captureFrames);
307325
return metric.beginMeasure().then((_) => metric.endMeasure(false));
308326
}
309327

328+
describe('frame metrics', () => {
329+
it('should calculate mean frame time', inject([AsyncTestCompleter], (async) => {
330+
aggregate([
331+
eventFactory.markStart('frameCapture', 0),
332+
eventFactory.instant('frame', 1),
333+
eventFactory.instant('frame', 3),
334+
eventFactory.instant('frame', 4),
335+
eventFactory.markEnd('frameCapture', 5)
336+
],
337+
null, true)
338+
.then((data) => {
339+
expect(data['meanFrameTime']).toBe(((3 - 1) + (4 - 3)) / 2);
340+
async.done();
341+
});
342+
}));
343+
344+
it('should throw if no start event', inject([AsyncTestCompleter], (async) => {
345+
PromiseWrapper.catchError(
346+
aggregate(
347+
[eventFactory.instant('frame', 4), eventFactory.markEnd('frameCapture', 5)],
348+
null, true),
349+
(err) => {
350+
expect(() => { throw err; })
351+
.toThrowError('missing start event for frame capture');
352+
async.done();
353+
});
354+
}));
355+
356+
it('should throw if no end event', inject([AsyncTestCompleter], (async) => {
357+
PromiseWrapper.catchError(
358+
aggregate(
359+
[eventFactory.markStart('frameCapture', 3), eventFactory.instant('frame', 4)],
360+
null, true),
361+
(err) => {
362+
expect(() => { throw err; }).toThrowError('missing end event for frame capture');
363+
async.done();
364+
});
365+
}));
366+
367+
it('should throw if trying to capture twice', inject([AsyncTestCompleter], (async) => {
368+
PromiseWrapper.catchError(
369+
aggregate([
370+
eventFactory.markStart('frameCapture', 3),
371+
eventFactory.markStart('frameCapture', 4)
372+
],
373+
null, true),
374+
(err) => {
375+
expect(() => { throw err; })
376+
.toThrowError('can capture frames only once per benchmark run');
377+
async.done();
378+
});
379+
}));
380+
381+
it('should throw if trying to capture when frame capture is disabled',
382+
inject([AsyncTestCompleter], (async) => {
383+
PromiseWrapper.catchError(aggregate([eventFactory.markStart('frameCapture', 3)]), (err) => {
384+
expect(() => { throw err; })
385+
.toThrowError(
386+
'found start event for frame capture, but frame capture was not requested in benchpress');
387+
async.done();
388+
});
389+
}));
390+
391+
it('should throw if frame capture is enabled, but nothing is captured',
392+
inject([AsyncTestCompleter], (async) => {
393+
PromiseWrapper.catchError(aggregate([], null, true), (err) => {
394+
expect(() => { throw err; })
395+
.toThrowError(
396+
'frame capture requested in benchpress, but no start event was found');
397+
async.done();
398+
});
399+
}));
400+
401+
402+
});
310403

311404
it('should report a single interval', inject([AsyncTestCompleter], (async) => {
312405
aggregate([eventFactory.start('script', 0), eventFactory.end('script', 5)])

0 commit comments

Comments
 (0)