-
-
Notifications
You must be signed in to change notification settings - Fork 977
Description
User Story
As a developer working with maps that contain a lot of vector tile data and are used on underresourced machines, I would like to introduce the ability for the vector tile loading process to respect etag matches such that it will skip parsing and rendering when a tile that was loaded hasn’t changed.
Rationale
- Currently, Maplibre will respect the max-age header and refetch tiles at the given interval via the TileManager (expiry data is set via max-age headers here and here). However, vector tile loading, parsing, and rendering works the same way regardless of whether the tile has changed since we last requested it or not.
- Even when there is a local or remote cache hit (304), such that the tile is exactly the same, all of the work that happens after getting the response from loadVectorTile is repeated. This includes WorkerTile#parse() and firing a data event in TileManager to trigger repaint and rendering.
- If we maintained a mapping of tile ID to etag and compared the existing etag for a given tile with the etag from the tile fetch response, we could skip all of the
WorkerTile#parse()work for that tile, and instead immediately set the loaded tile to the cached tile for that ID. This would reduce recomputation when the tile hasn’t changed. - If we additionally add a way for vector_tile_source#loadTile to communicate to the TileManager that the tile was served from cache, e.g. an optional field on Tile
isLastLoadedFromCache?: boolean, we could additionally skip emitting the data event in TileManager#_tileLoaded() for etag matches, reducing the GPU burden.
My proposal would look something like adding the mapping to VectorTileWorkerSource class:
tileETags: {[_: string]: string};
In loadVectorTile, we fetch the tile data. We’d want to modify this to forward the etag header:
const response = await getArrayBuffer(params.request, abortController);
Then we can use that to check if the etag matches a stored etag and return a new WorkerTileResult type if it does:
const storedEtag = tileETags[tileUid];
if (storedEtag && response.etag && storedEtag === response.etag) {
return {
type: 'etag_match',
etag: response.etag,
cacheControl: response.cacheControl,
expires: response.expires
};
}
If it doesn’t match, we can store it for the future and return the data as usual:
if (response.etag) {
this.tileETags[tileId] = response.etag;
}
A level higher up in loadTile, if we encounter an etag_match, we can now short-circuit early by setting the loaded tile to the cached one. This skips all of the parsing work.
if (response.type === 'etag_match') {
this.loaded[tileUid] = workerTile;
const result: WorkerTileResult = {
type: 'etag_match',
...cacheControl
};
return result;
}
const rawTileData = response.rawData;
workerTile.vectorTile = response.vectorTile;
// We only do this if we didn't have an etag_match above!
const parsePromise = workerTile.parse(response.vectorTile, this.layerIndex, this.availableImages, this.actor, params.subdivisionGranularity);
this.loaded[tileUid] = workerTile;
...
We can also add a way to communicate to the TileManager that the tile was loaded from cached and doesn’t need to be rerendered, for example adding an optional boolean flag to the Tile class and updating it in VectorTileSource#_afterTileLoadWorkerResponse:
if (data && data.type === 'processed' && tile.isLastLoadedFromCache) {
tile.isLastLoadedFromCache = false;
} else if (data && data.type === 'etag_match' && !tile.isLastLoadedFromCache) {
tile.isLastLoadedFromCache = true;
}
The TileManager can now skip unnecessary work in _tileLoaded:
_tileLoaded(tile: Tile, id: string, previousState: TileState) {
...
// Skip backfill DEM, initialize tile state, and firing data event for an unchanged tile
// This will prevent repainting and rendering a new frame when the tile is the same
if (tile.isLastLoadedFromCache) {
return;
}
if (this.getSource().type === 'raster-dem' && tile.dem) {
backfillDEM(tile, this._inViewTiles);
}
this._state.initializeTileState(tile, this.map ? this.map.painter : null);
if (!tile.aborted) {
this._source.fire(new Event('data', {dataType: 'source', tile, coord: tile.tileID}));
}
}
Impact
Without this feature, there is significant GPU pressure whenever we fetch tiles because a given tile source’s max-age has been reached. This results in noticeable GPU spikes that cause slowdowns and frequent crashes for users.