diff --git a/build/rollup_plugins.js b/build/rollup_plugins.js index 65f4a54e974..6647a3795b7 100644 --- a/build/rollup_plugins.js +++ b/build/rollup_plugins.js @@ -7,8 +7,8 @@ import unassert from 'rollup-plugin-unassert'; import json from 'rollup-plugin-json'; import {terser} from 'rollup-plugin-terser'; import minifyStyleSpec from './rollup_plugin_minify_style_spec'; -import strip from '@rollup/plugin-strip'; import {createFilter} from 'rollup-pluginutils'; +import strip from '@rollup/plugin-strip'; // Common set of plugins/transformations shared across different rollup // builds (main mapboxgl bundle, style-spec package, benchmarks bundle) @@ -18,7 +18,8 @@ export const plugins = (minified, production) => [ minifyStyleSpec(), json(), production ? strip({ - functions: ['Debug.*'] + sourceMap: true, + functions: ['PerformanceUtils.*', 'Debug.*'] }) : false, glsl('./src/shaders/*.glsl', production), buble({transforms: {dangerousForOf: true}, objectAssign: "Object.assign"}), diff --git a/src/index.js b/src/index.js index 55c9e5eeedd..719485a3120 100644 --- a/src/index.js +++ b/src/index.js @@ -24,6 +24,7 @@ import {isSafari} from './util/util'; import {setRTLTextPlugin, getRTLTextPluginStatus} from './source/rtl_text_plugin'; import WorkerPool from './util/worker_pool'; import {clearTileCache} from './util/tile_request_cache'; +import {PerformanceUtils} from './util/performance'; const exported = { version, @@ -133,7 +134,7 @@ const exported = { }; //This gets automatically stripped out in production builds. -Debug.extend(exported, {isSafari}); +Debug.extend(exported, {isSafari, getPerformanceMetrics: PerformanceUtils.getPerformanceMetrics}); /** * The version of Mapbox GL JS in use as specified in `package.json`, diff --git a/src/source/geojson_worker_source.js b/src/source/geojson_worker_source.js index be29d9a2c61..550fecdf5ab 100644 --- a/src/source/geojson_worker_source.js +++ b/src/source/geojson_worker_source.js @@ -2,7 +2,7 @@ import {getJSON} from '../util/ajax'; -import performance from '../util/performance'; +import {RequestPerformance} from '../util/performance'; import rewind from '@mapbox/geojson-rewind'; import GeoJSONWrapper from './geojson_wrapper'; import vtpbf from 'vt-pbf'; @@ -161,7 +161,7 @@ class GeoJSONWorkerSource extends VectorTileWorkerSource { delete this._pendingLoadDataParams; const perf = (params && params.request && params.request.collectResourceTiming) ? - new performance.Performance(params.request) : false; + new RequestPerformance(params.request) : false; this.loadGeoJSON(params, (err: ?Error, data: ?Object) => { if (err || !data) { diff --git a/src/source/vector_tile_worker_source.js b/src/source/vector_tile_worker_source.js index cfd597fa0aa..9cc29ac5890 100644 --- a/src/source/vector_tile_worker_source.js +++ b/src/source/vector_tile_worker_source.js @@ -6,7 +6,7 @@ import vt from '@mapbox/vector-tile'; import Protobuf from 'pbf'; import WorkerTile from './worker_tile'; import {extend} from '../util/util'; -import performance from '../util/performance'; +import {RequestPerformance} from '../util/performance'; import type { WorkerSource, @@ -104,7 +104,7 @@ class VectorTileWorkerSource implements WorkerSource { this.loading = {}; const perf = (params && params.request && params.request.collectResourceTiming) ? - new performance.Performance(params.request) : false; + new RequestPerformance(params.request) : false; const workerTile = this.loading[uid] = new WorkerTile(params); workerTile.abort = this.loadVectorData(params, (err, response) => { diff --git a/src/ui/map.js b/src/ui/map.js index 88f63d740e8..ba375ad25f3 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -26,6 +26,8 @@ import {Event, ErrorEvent} from '../util/evented'; import {MapMouseEvent} from './events'; import TaskQueue from '../util/task_queue'; import webpSupported from '../util/webp_supported'; +import {PerformanceMarkers, PerformanceUtils} from '../util/performance'; + import {setCacheLimits} from '../util/tile_request_cache'; import type {PointLike} from '@mapbox/point-geometry'; @@ -279,6 +281,8 @@ class Map extends Camera { _sourcesDirty: ?boolean; _placementDirty: ?boolean; _loaded: boolean; + // accounts for placement finishing as well + _fullyLoaded: boolean; _trackResize: boolean; _preserveDrawingBuffer: boolean; _failIfMajorPerformanceCaveat: boolean; @@ -341,6 +345,8 @@ class Map extends Camera { touchZoomRotate: TouchZoomRotateHandler; constructor(options: MapOptions) { + PerformanceUtils.mark(PerformanceMarkers.create); + options = extend({}, defaultOptions, options); if (options.minZoom != null && options.maxZoom != null && options.minZoom > options.maxZoom) { @@ -2138,6 +2144,7 @@ class Map extends Camera { if (this.loaded() && !this._loaded) { this._loaded = true; + PerformanceUtils.mark(PerformanceMarkers.load); this.fire(new Event('load')); } @@ -2184,9 +2191,14 @@ class Map extends Camera { // Even though `_styleDirty` and `_sourcesDirty` are reset in this // method, synchronous events fired during Style#update or // Style#_updateSources could have caused them to be set again. - if (this._sourcesDirty || this._repaint || this._styleDirty || this._placementDirty) { + const somethingDirty = this._sourcesDirty || this._styleDirty || this._placementDirty; + if (somethingDirty || this._repaint) { this.triggerRepaint(); } else if (!this.isMoving() && this.loaded()) { + if (!this._fullyLoaded) { + this._fullyLoaded = true; + PerformanceUtils.mark(PerformanceMarkers.fullLoad); + } this.fire(new Event('idle')); } @@ -2225,6 +2237,8 @@ class Map extends Camera { removeNode(this._controlContainer); removeNode(this._missingCSSCanary); this._container.classList.remove('mapboxgl-map'); + + PerformanceUtils.clearMetrics(); this.fire(new Event('remove')); } @@ -2235,7 +2249,8 @@ class Map extends Camera { */ triggerRepaint() { if (this.style && !this._frame) { - this._frame = browser.frame(() => { + this._frame = browser.frame((paintStartTimestamp: number) => { + PerformanceUtils.frame(paintStartTimestamp); this._frame = null; this._render(); }); diff --git a/src/util/browser.js b/src/util/browser.js index 8ae14d1906e..710841b0d13 100755 --- a/src/util/browser.js +++ b/src/util/browser.js @@ -31,7 +31,7 @@ const exported = { */ now, - frame(fn: () => void): Cancelable { + frame(fn: (paintStartTimestamp: number) => void): Cancelable { const frame = raf(fn); return {cancel: () => cancel(frame)}; }, diff --git a/src/util/image.js b/src/util/image.js index 5e14d15f751..2ef55d01391 100644 --- a/src/util/image.js +++ b/src/util/image.js @@ -76,7 +76,6 @@ function copyImage(srcImg: *, dstImg: *, srcPt: Point, dstPt: Point, size: Size, dstData[dstOffset + i] = srcData[srcOffset + i]; } } - return dstImg; } diff --git a/src/util/performance.js b/src/util/performance.js index 053ed28d8f8..0b6a26c377b 100644 --- a/src/util/performance.js +++ b/src/util/performance.js @@ -1,45 +1,74 @@ // @flow +import window from '../util/window'; import type {RequestParameters} from '../util/ajax'; -// Wraps performance to facilitate testing -// Not incorporated into browser.js because the latter is poisonous when used outside the main thread -const performanceExists = typeof performance !== 'undefined'; -const wrapper = {}; +const performance = window.performance; -wrapper.getEntriesByName = (url: string) => { - if (performanceExists && performance && performance.getEntriesByName) - return performance.getEntriesByName(url); - else - return false; -}; - -wrapper.mark = (name: string) => { - if (performanceExists && performance && performance.mark) - return performance.mark(name); - else - return false; -}; - -wrapper.measure = (name: string, startMark: string, endMark: string) => { - if (performanceExists && performance && performance.measure) - return performance.measure(name, startMark, endMark); - else - return false; -}; +export type PerformanceMetrics = { + loadTime: number, + fullLoadTime: number, + fps: number, + percentDroppedFrames: number +} -wrapper.clearMarks = (name: string) => { - if (performanceExists && performance && performance.clearMarks) - return performance.clearMarks(name); - else - return false; +export const PerformanceMarkers = { + create: 'create', + load: 'load', + fullLoad: 'fullLoad' }; -wrapper.clearMeasures = (name: string) => { - if (performanceExists && performance && performance.clearMeasures) - return performance.clearMeasures(name); - else - return false; +let lastFrameTime = null; +let frameTimes = []; + +const minFramerateTarget = 30; +const frameTimeTarget = 1000 / minFramerateTarget; + +export const PerformanceUtils = { + mark(marker: $Keys) { + performance.mark(marker); + }, + frame(timestamp: number) { + const currTimestamp = timestamp; + if (lastFrameTime != null) { + const frameTime = currTimestamp - lastFrameTime; + frameTimes.push(frameTime); + } + lastFrameTime = currTimestamp; + }, + clearMetrics() { + lastFrameTime = null; + frameTimes = []; + performance.clearMeasures('loadTime'); + performance.clearMeasures('fullLoadTime'); + + for (const marker in PerformanceMarkers) { + performance.clearMarks(PerformanceMarkers[marker]); + } + }, + getPerformanceMetrics(): PerformanceMetrics { + const loadTime = performance.measure('loadTime', PerformanceMarkers.create, PerformanceMarkers.load).duration; + const fullLoadTime = performance.measure('fullLoadTime', PerformanceMarkers.create, PerformanceMarkers.fullLoad).duration; + const totalFrames = frameTimes.length; + + const avgFrameTime = frameTimes.reduce((prev, curr) => prev + curr, 0) / totalFrames / 1000; + const fps = 1 / avgFrameTime; + + // count frames that missed our framerate target + const droppedFrames = frameTimes + .filter((frameTime) => frameTime > frameTimeTarget) + .reduce((acc, curr) => { + return acc + (curr - frameTimeTarget) / frameTimeTarget; + }, 0); + const percentDroppedFrames = (droppedFrames / (totalFrames + droppedFrames)) * 100; + + return { + loadTime, + fullLoadTime, + fps, + percentDroppedFrames + }; + } }; /** @@ -48,7 +77,7 @@ wrapper.clearMeasures = (name: string) => { * @param {RequestParameters} request * @private */ -class Performance { +export class RequestPerformance { _marks: {start: string, end: string, measure: string}; constructor (request: RequestParameters) { @@ -58,28 +87,26 @@ class Performance { measure: request.url.toString() }; - wrapper.mark(this._marks.start); + performance.mark(this._marks.start); } finish() { - wrapper.mark(this._marks.end); - let resourceTimingData = wrapper.getEntriesByName(this._marks.measure); + performance.mark(this._marks.end); + let resourceTimingData = performance.getEntriesByName(this._marks.measure); // fallback if web worker implementation of perf.getEntriesByName returns empty if (resourceTimingData.length === 0) { - wrapper.measure(this._marks.measure, this._marks.start, this._marks.end); - resourceTimingData = wrapper.getEntriesByName(this._marks.measure); + performance.measure(this._marks.measure, this._marks.start, this._marks.end); + resourceTimingData = performance.getEntriesByName(this._marks.measure); // cleanup - wrapper.clearMarks(this._marks.start); - wrapper.clearMarks(this._marks.end); - wrapper.clearMeasures(this._marks.measure); + performance.clearMarks(this._marks.start); + performance.clearMarks(this._marks.end); + performance.clearMeasures(this._marks.measure); } return resourceTimingData; } } -wrapper.Performance = Performance; - -export default wrapper; +export default performance; diff --git a/src/util/window.js b/src/util/window.js index 684a170f7cd..00dcd87b213 100644 --- a/src/util/window.js +++ b/src/util/window.js @@ -87,6 +87,12 @@ function restore(): Window { window.restore = restore; + window.performance.getEntriesByName = function() {}; + window.performance.mark = function() {}; + window.performance.measure = function() {}; + window.performance.clearMarks = function() {}; + window.performance.clearMeasures = function() {}; + window.ImageData = window.ImageData || function() { return false; }; window.ImageBitmap = window.ImageBitmap || function() { return false; }; window.WebGLFramebuffer = window.WebGLFramebuffer || Object;