There are mainly two aspects that developers usually consider regarding the performance of any computer programs: the time and the memory consumption, both of which obviously depends on the specs of the hardware deck.gl is ultimately running on.
On 2015 MacBook Pros with dual graphics cards, most basic layers
(like ScatterplotLayer
) renders fluidly at 60 FPS during pan and zoom
operations up to about 1M (one million) data items, with framerates dropping into low double digits (10-20FPS) when the data sets approach 10M items.
Even if interactivity is not an issue, browser limitations on how big chunks of contiguous memory can be allocated (e.g. Chrome caps individual allocations at 1GB) will cause most layers to crash during WebGL buffer generation somewhere between 10M and 100M items. You would need to break up your data into chunks and use multiple deck.gl layers to get past this limit.
Modern phones (recent iPhones and higher-end Android phones) are surprisingly capable in terms of rendering performance, but are considerably more sensitive to memory pressure than laptops, resulting in browser restarts or page reloads. They also tend to load data significantly slower than desktop computers, so some tuning is usually needed to ensure a good overall user experience on mobile.
Layer update happens when the layer is first created, or when some layer props change. During an update, deck.gl may load necessary resources (e.g. image textures), generate WebGL buffers, and upload them to the GPU, all of which may take some time to complete, depending on the number of items in your data
prop. Therefore, the key to performant deck.gl applications is to minimize layer updates wherever possible.
When the data
prop changes, the layer will recalculate all of its WebGL buffers. The time required for this is proportional to the number of items in your
data
prop.
This step is the most expensive operation that a layer does - also on CPU - potentially affecting the responsiveness of the application. It may take
multiple seconds for multi-million item layers, and if your data
prop is updated
frequently (e.g. animations), "stutter" can be visible even for layers with just a few thousand items.
Some good places to check for performance improvements are:
-
Has
data
really changed?The layer does a shallow comparison between renders to determine if it needs to regenerate buffers. If nothing has changed, make sure you supply the same data object every time you render. If the data object has to change shallowly for some reason, consider using the
dataComparator
prop to supply a custom comparison logic.// Bad const DATA = [...]; const filters = {minTime: -1, maxTime: Infinity}; function setFilters(minTime, maxTime) { filters.minTime = minTime; filters.maxTime = maxTime; render(); } function render() { const layer = new ScatterplotLayer({ // `filter` creates a new array every time `render` is called, even if the filters have not changed data: DATA.filter(d => d.time >= filters.minTime && d.time <= filters.maxTime), ... }); deck.setProps({layers: [layer]}); }
// Good const DATA = [...]; let filteredData = DATA; const filters = {minTime: -1, maxTime: Infinity}; function setFilters(minTime, maxTime) { filters.minTime = minTime; filters.maxTime = maxTime; // filtering is performed only once when the filters change filteredData = DATA.filter(d => d.time >= minTime && d.time <= maxTime); render(); } function render() { const layer = new ScatterplotLayer({ data: filteredData, ... }); deck.setProps({layers: [layer]}); }
-
Is the change internal to each object?
So
data
has indeed changed. Do we have an entirely new collection of objects? Or did just certain fields changed in each row? Remember that changingdata
will update all buffers, so if, for example, object positions have not changed, it will be a waste of time to recalculate them.// Bad const DATA = [...]; let currentYear = null; let currentData = DATA; function selectYear(year) { currentYear = year; currentData = DATA.map(d => ({ position: d.position, population: d.populationsByYear[year] })); render(); } function render() { const layer = new ScatterplotLayer({ // `data` changes every time year changed, but positions don't need to update data: currentData, getPosition: d => d.position, getRadius: d => Math.sqrt(d.population), ... }); deck.setProps({layers: [layer]}); }
In this case, it is more efficient to use
updateTriggers
to invalidate only the selected attributes:// Good const DATA = [...]; let currentYear = null; function selectYear(year) { currentYear = year; render(); } function render() { const layer = new ScatterplotLayer({ // `data` never changes data: DATA, getPosition: d => d.position, // radius depends on `currentYear` getRadius: d => Math.sqrt(d.populationsByYear[currentYear]), updateTriggers: { // This tells deck.gl to recalculat radius when `currentYear` changes getRadius: currentYear }, ... }); deck.setProps({layers: [layer]}); }
-
Is the data change incremental?
A common technique for handling big datasets on the client side is to load data in chunks. We want to update the visualization whenever a new chunk comes in. If we append the new chunk to an existing data array, deck.gl will recalculate the whole buffers, even for the previously loaded chunks where nothing have changed:
// Bad let loadedData = []; function onNewDataArrive(chunk) { loadedData = loadedData.concat(chunk); render(); } function render() { const layer = new ScatterplotLayer({ // If we have 1 million rows loaded and 100,000 new rows arrive, // we end up recalculating the buffers for all 1,100,000 rows data: loadedData, ... }); deck.setProps({layers: [layer]}); }
To avoid doing this, we instead generate one layer for each chunk:
// Good const dataChunks = []; function onNewDataArrive(chunk) { dataChunks.push(chunk); render(); } function render() { const layers = dataChunks.map((chunk, chunkIndex) => new ScatterplotLayer({ // Important: each layer must have a consistent & unique id id: `chunk-${chunkIndex}`, // If we have 10 100,000-row chunks already loaded and a new one arrive, // the first 10 layers will see no prop change // only the 11th layer's buffers need to be generated data: chunk, ... })); deck.setProps({layers}); }
Starting v7.2.0, support for async iterables is added to efficiently update layers with incrementally loaded data:
// Create an async iterable async function* getData() { for (let i = 0; i < 10; i++) { await const chunk = fetchChunk(...); yield chunk; } } function render() { const layer = new ScatterplotLayer({ // When a new chunk arrives, deck.gl only updates the sub buffers for the new rows data: getData(), ... }); deck.setProps({layers: [layer]}); }
See Layer properties for details.
-
When to remove a layer
Removing a layer will lose all of its internal states, including generated buffers. If the layer is added back later, all the WebGL resources need to be regenerated again. In the use cases where layers need to be toggled frequently (e.g. via a control panel), there might be a significant perf penalty:
// Bad const DATA = [...]; const layerVisibility = {circles: true, labels: true} function toggleLayer(key) { layerVisibility[key] = !layerVisibility[key]; render(); } function render() { const layers = [ // when visibility goes from on to off to on, this layer will be completely removed and then regenerated layerVisibility.circles && new ScatterplotLayer({ data: DATA, ... }), layerVisibility.labels && new TextLayer({ data: DATA, ... }) ]; deck.setProps({layers}); }
The
visible
prop is a cheap way to temporarily disable a layer:// Good const DATA = [...]; const layerVisibility = {circles: true, labels: true} function toggleLayer(key) { layerVisibility[key] = !layerVisibility[key]; render(); } function render() { const layers = [ // when visibility is off, this layer's internal states will be retained in memory, making turning it back on instant new ScatterplotLayer({ data: DATA, visible: layerVisibility.circles, ... }), new TextLayer({ data: DATA, visible: layerVisibility.labels, ... }) ]; deck.setProps({layers}); }
99% of the CPU time that deck.gl spends in updating buffers is calling the accessors you supply to the layer. Since they are called on every data object, any performance issue in the accessors is amplified by the size of your data.
-
Use constants before callback functions
Most accessors accept constant values as well as functions. Constant props are extremely cheap to update in comparison. Use
ScatterplotLayer
as an example, the following two prop settings yield exactly the same visual outcome:getFillColor: [255, 0, 0, 128]
- deck.gl uploads 4 numbers to the GPU.getFillColor: d => [255, 0, 0, 128]
- deck.gl first builds a typed array of4 * data.length
elements, call the accessordata.length
times to fill it, then upload it to the GPU.
Aside from accessors, most layers also offer one or more
*Scale
props that are uniform multipliers on top of the per-object value. Always consider using them before invoking the accessors:// Bad const DATA = [...]; function animate() { render(); requestAnimationFrame(animate); } function render() { const scale = Date.now() % 2000; const layer = new ScatterplotLayer({ data: DATA, getRadius: object => object.size * scale, // deck.gl will call `getRadius` for ALL data objects every animation frame, which will likely choke the app updateTriggers: { getRadius: scale }, ... }); deck.setProps({layers: [layer]}); }
// Good const DATA = [...]; function animate() { render(); requestAnimationFrame(animate); } function render() { const scale = Date.now() % 2000; const layer = new ScatterplotLayer({ data: DATA, getRadius: object => object.size, // This has virtually no cost to update, easily getting 60fps animation radiusScale: scale, ... }); deck.setProps({layers: [layer]}); }
-
Use trivial functions as accessors
Whenever possible, make the accessors trivial functions and utilize pre-defined and/or pre-computed data.
// Bad const DATA = [...]; function render() { const layer = new ScatterplotLayer({ data: DATA, getFillColor: object => { // This line creates a new values array from each object // which can incur significant cost in garbage collection const maxPopulation = Math.max.apply(null, Object.values(object.populationsByYear)); // This switch case creates a new color array for each object // which can also incur significant cost in garbage collection if (maxPopulation > 1000000) { return [255, 0, 0]; } else if (maxPopulation > 100000) { return [0, 255, 0]; } else { return [0, 0, 255]; } }, getRadius: object => { // This line duplicates what's done in `getFillColor` and doubles the cost const maxPopulation = Math.max.apply(null, Object.values(object.populationsByYear)); return Math.sqrt(maxPopulation); } ... }); deck.setProps({layers: [layer]}); }
// Good const DATA = [...]; // Use a for loop to avoid creating new objects function getMaxPopulation(populationsByYear) { let maxPopulation = 0; for (const year in populationsByYear) { const population = populationsByYear[year]; if (population > maxPopulation) { maxPopulation = population; } } return maxPopulation; } // Calculate max population once and store it in the data DATA.forEach(d => { d.maxPopulation = getMaxPopulation(d.populationsByYear); }); // Use constant color values to avoid generating new arrays const COLORS = { ONE_MILLION: [255, 0, 0], HUNDRED_THOUSAND: [0, 255, 0], OTHER: [0, 0, 255] }; function render() { const layer = new ScatterplotLayer({ data: DATA, getFillColor: object => { if (object.maxPopulation > 1000000) { return COLORS.ONE_MILLION; } else if (maxPopulation > 100000) { return COLORS.HUNDRED_THOUSAND; } else { return COLORS.OTHER; } }, getRadius: object => Math.sqrt(object.maxPopulation), ... }); deck.setProps({layers: [layer]}); }
When creating data-intensive applications, it is often desirable to offload client-side data processing to the server or web workers. To transfer data efficiently between threads, some binary format is likely involved.
-
Supply binary data to the
data
propAssume we have the data source encoded in the following format:
// lon1, lat1, radius1, red1, green1, blue1, lon2, lat2, ... const binaryData = new Float32Array([-122.4, 37.78, 1000, 255, 200, 0, -122.41, 37.775, 500, 200, 0, 0, -122.39, 37.8, 500, 0, 40, 200]);
Upon receiving the typed arrays, the application can of course re-construct a classic JavaScript array:
// Bad const data = []; for (let i = 0; i < binaryData.length; i += 6) { data.push({ position: binaryData.subarray(i, i + 2), radius: binaryData[i + 2], color: binaryData.subarray(i + 3, i + 6) }); } new ScatterplotLayer({ data, getPosition: d => d.position, getRadius: d => d.radius, getFillColor: d => d.color });
However, in addition to requiring custom repacking code, this array will take valuable CPU time to create, and significantly more memory to store than its binary form. In performance-sensitive applications that constantly push a large volumn of data (e.g. animations), this method will not be efficient enough.
Alternatively, one may supply a non-iterable object (not Array or TypedArray) to the
data
object. In this case, it must contain alength
field that specifies the total number of objects. Sincedata
is not iterable, each accessor will not receive a validobject
argument, and therefore responsible of interpreting the input data's buffer layout:// Good // Note: binaryData.length does not equal the number of items, // which is why we need to wrap it in an object that contains a custom `length` field const DATA = {src: binaryData, length: binaryData.length / 6} new ScatterplotLayer({ data: DATA, getPosition: (object, {index, data}) => { return data.src.subarray(index * 6, index * 6 + 2); }, getRadius: (object, {index, data}) => { return data.src[index * 6 + 2]; }, getFillColor: (object, {index, data, target}) => { return data.src.subarray(index * 6 + 3, index * 6 + 6); } })
Optionally, the accessors can utilize the pre-allocated
target
array in the second argument to further avoid creating new objects:// Good const DATA = {src: binaryData, length: binaryData.length / 6} new ScatterplotLayer({ data: DATA, getPosition: (object, {index, data, target}) => { target[0] = data.src[index * 6]; target[1] = data.src[index * 6 + 1]; target[2] = 0; return target; }, getRadius: (object, {index, data}) => { return data.src[index * 6 + 2]; }, getFillColor: (object, {index, data, target}) => { target[0] = data.src[index * 6 + 3]; target[1] = data.src[index * 6 + 4]; target[2] = data.src[index * 6 + 5]; target[3] = 255; return target; } })
-
Supplying attributes directly
While the built-in attribute generation functionality is a major part of a
Layer
s functionality, it is possible for applications to bypass it, and supply the layer with precalculated attributes.Some deck.gl applications use workers to load data and generate attributes to get the processing off the main thread. Modern worker implementations allow ownership of typed arrays to be transferred between threads which takes care of about half of the biggest performance problem with workers (deserialization of calculated data when transferring it between threads).
To generate attributes for the
PointCloudLayer
:// Worker // point[0].x, point[0].y, point[0].z, point[1].x, point[1].y, point[1].z, ... const positions = new Float32Array(...); // point[0].r, point[0].g, point[0].b, point[0].a, point[1].r, point[1].g, point[1].b, point[1].a, ... const colors = new Uint8ClampedArray(...); // send back to main thread postMessage({pointCount, positions, colors}, [positions.buffer, colors.buffer]);
// Main thread // `data` is received from the worker new PointCloudLayer({ data: { // this is required so that the layer knows how many points to draw length: data.pointCount, attributes: { instancePositions: data.positions, instanceColors: data.colors, } }, // constant accessor works without raw data getNormal: [0, 0, 1] });
It is also possible to use interleaved or custom layout external buffers by supplying a descriptor instead of typed array to each attribute:
new PointCloudLayer({ data: { length : data.pointCount, attributes: { instancePositions: data.positions, // point[0].r, point[0].g, point[0].b, point[1].r, point[1].g, point[1].b, ... instanceColors: {value: data.colors, size: 3, stride: 3}, } }, // tell the layer that `instanceColors` does not contain alpha channel colorFormat: 'RGB', // constant accessor works without raw data getNormal: [0, 0, 1] });
Each value in
data.attributes
may be one of the following formats:- luma.gl
Buffer
instance - A typed array
- An object containing the following optional fields. For more information, see WebGL vertex attribute API.
buffer
(Buffer)value
(TypedArray)size
(Number) - the number of elements per vertex attribute.offset
(Number) - offset of the first vertex attribute into the buffer, in bytesstride
(Number) - the offset between the beginning of consecutive vertex attributes, in bytes
- luma.gl
Layer rendering time (for large data sets) is essentially proportional to:
- The number of vertex shader invocations,
which corresponds to the number of items in the layer's
data
prop - The number of fragment shader invocations, which corresponds to the total number of pixels drawn.
Thus it is possible to render a scatterplot layer with 10M items with reasonable frame rates on recent GPUs, provided that the radius (number of pixels) of each point is small.
It is good to be aware that excessive overdraw (drawing many objects/pixels on top of each other) can generate very high fragment counts and thus hurt performance. As an example, a Scatterplot
radius of 5 pixels generates ~ 100 pixels per point. If you have a Scatterplot
layer with 10 million points, this can result in up to 1 billion fragment shader invocations per frame. While dependent on zoom levels (clipping will improve performance to some extent) this many fragments will certainly strain even a recent MacBook Pro GPU.
deck.gl performs picking by drawing the layer into an off screen picking buffer. This essentially means that every layer that supports picking will be drawn off screen when panning and hovering. The picking is performed using the same GPU code that does the visual rendering, so the performance should be easy to predict.
Picking limitations:
- The picking system can only distinguish between 16M items per layer.
- The picking system can only handle 256 layers with the pickable flag set to true.
The layer count of an advanced deck.gl application tends to gradually increase, especially when using composite layers. We have built and optimized a highly complex application using close to 100 deck.gl layers (this includes hierarchies of sublayers rendered by custom composite layers rendering other composite layers) without seeing any performance issues related to the number of layers. If you really need to, it is probably possible to go a little higher (a few hundred layers). Just keep in mind that deck.gl was not designed to be used with thousands of layers.
A couple of particular things to watch out for that tend to have a big impact on performance:
- If not needed disable Retina/High DPI rendering. It generetes 4x the number of pixels (fragments) and can have a big performance impact that depends on which computer or monitor is being used. This feature can be controlled using
useDevicePixels
prop ofDeckGL
component and it is on by default. - Avoid using luma.gl debug mode in production. It queries the GPU error status after each operation which has a big impact on performance.
Smaller considerations:
- Enabling picking can have a small performance penalty so make sure the
pickable
property isfalse
in layers that do not need picking (this is the default value).