Skip to content

Conversation

@wayofthefuture
Copy link
Collaborator

Since ES6, Map has made simple in-memory caching far easier, providing fast average-case O(1) access. Our previous tile cache relied heavily on O(n) array operations. This PR replaces that implementation with the existing BoundedLRUCache and wires it into TileCache. I evaluated several npm caching libraries, but none fit well with the specifics of MapLibre’s tile loading model or offered a good balance of size and simplicity.

The most critical operation used in TileManager, getAndRemove, previously incurred three O(n) operations per call. It is now implemented as take, which performs the same behavior in O(1). Additionally, the old tile cache maintained many concurrent timers for tiles sitting idle in the cache. The new cache no longer tracks per-tile timers; instead, expired tiles are evicted/unloaded lazily on access, avoiding the overhead of a large number of active timers.

O(N) Comparison Table

Operation Complexity (v1 → v2) Potential Speedup
Get O(1) → O(1) ~1×
Set O(1) → O(1) ~1×
getAndRemove / Take O(N)O(1) Up to N× faster
Remove (by key) O(N)O(1) Up to N× faster
Filter O(N²)O(N) Up to N× faster
Resize (setMaxSize) O(N²)O(N) Up to N× faster
Reset / Clear O(N) → O(N) ~1×

Timers Comparison Table

Aspect v1 (Timer-based cache) v2 (LRU cache, no timers)
Timers per tile 1 per entry (hundreds–thousands) 0
Event-loop overhead High (timer scheduling + callbacks) None
GC pressure High (many closures + timer objects) Low
Callback churn Thousands of callbacks None
Memory per tile Large (timeout + closure) Small (Map entry only)
Latency/jank risk High Low
  • Confirm your changes do not include backports from Mapbox projects (unless with compliant license) - if you are not sure about this, please ask!
  • Briefly describe the changes in this PR.
  • Write tests for all new functionality.
  • Add an entry to CHANGELOG.md under the ## main section.

@codecov
Copy link

codecov bot commented Nov 17, 2025

Codecov Report

❌ Patch coverage is 97.95918% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.42%. Comparing base (a2add46) to head (6b1d576).

Files with missing lines Patch % Lines
src/tile/tile_manager.ts 93.54% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6731      +/-   ##
==========================================
+ Coverage   92.40%   92.42%   +0.02%     
==========================================
  Files         288      288              
  Lines       23775    23761      -14     
  Branches     5055     5050       -5     
==========================================
- Hits        21969    21961       -8     
+ Misses       1806     1800       -6     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

}

// Updating same key with a new tile - remove old tile and add new one
this.remove(key);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this call onRemove? I'm honestly asking, I don't know the right answer.

Copy link
Collaborator Author

@wayofthefuture wayofthefuture Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe so:

https://github.com/maplibre/maplibre-gl-js/blob/main/src/tile/tile_cache.ts#L82-L85

Eventually on remove would be called for the same id.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onRemove propagates back to source.unloadTile which then goes back to the worker_source.removeTile. In here, loaded is used to store tiles by uid in the worker. uid is created in tile.js using uniqueId() which is an integer count++ on each function run, so every tile object has a new and unique uid. Even though the new tile may have the same tileID, it will absolutely have a unique uid, which means it still needs to be removed from the this.loaded cache in the worker. In other words, onRemove needs to be called otherwise the tiles would collect in the worker. Do some tracing and let me know if you agree...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think I know what should be the right approach here in order to get confident in the current solution.

  1. We need to cover the current cache with tests so that we know exactly how it operates, which callbacks are called when and how it behaves in general (assuming this is not the tests that currently exists, if the tests cover the current functionality then we can skip this step).
  2. After we have the tests in place, we can simply replace the implementation under the hood - no changes to logic (besides maybe the timers), without even changing the public API (take vs getAndRemove, clear vs reset etc)
  3. After this is passing and we know that we haven't changed any logic, we can rename stuff.

I think this is the safest way to handle this.
If this is how you did it, then there's very low risk in introducing bugs, as the process above is fairly safe.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've read the cache code start to end and a few questions rose:

  1. Why does it have an array? Most of the usage is referring to the first element, but not all.
  2. If the timeout were to be removed, how will this affect the code that listens to onRemove? What will happen if the tile's onRemove is not called when the tile expiries?
  3. The functionally as covered in the current tests (not those you added, but those that exist) is far from ideal, there's even a comment about how the cache should behave but it's not covered in a test, only in a comment, so not a great start...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are good with keeping both 'raster' and 'vector' handled in tile_manager, then we could extract all retainment logic into tile_retainment.ts... versus having separate managers. This would cut out about 30% of the file. Just another idea...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you'll need to pass "this" into this file, which is an anti-pattern.
Also regarding the above name I think there are simply two "storage" types: in-view-tiles and out-of-view-tiles, the current cache us the out of view as far as I understand.
But I want to better understand other parts as well.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can just pass this._tiles and retain, and change getLoadedTile to tile.hasData(), then you don't need to pass this...

Current cache is tiles that were loaded for a previous view... this._tiles is the current view. As tiles move out of view they are added to the cache...

Covering tiles -> add tile -> check the cache -> remove other this._tiles that aren't covering/substitute -> place them into cache -> repeat...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is also my understanding.
I wonder why there is a usage counter for a tile though...
In any case, a first step can be to move these parts to separate files as you suggested, onne for raster and one for vector.
Also renaming _tiles to inViewTiles and cache to outOfViewCache so that is clear why these two variables are needed and how they are different.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'uses' to me seems like a hack that should be deprecated... if it doesn't work without it then we are missing something...

@HarelM
Copy link
Collaborator

HarelM commented Nov 23, 2025

An initial stab at moving some code around. Not a big change overall...

@wayofthefuture
Copy link
Collaborator Author

wayofthefuture commented Nov 28, 2025

Well I resolved the merge conflicts and merged your split-tile-manager branch but now the 'files changed' page looks polluted. I guess after you merge your branch into main and I update this one it will be easier to read the changes...

@HarelM
Copy link
Collaborator

HarelM commented Nov 28, 2025

I've merged it to main, so you'd have to resolve conflicts again, sorry...

# Conflicts:
#	src/style/style.ts
#	src/tile/tile_manager.test.ts
#	src/tile/tile_manager.ts
@wayofthefuture
Copy link
Collaborator Author

wayofthefuture commented Nov 28, 2025

Got it merged... but still needs a little thought regarding remove/onRemove...

@wayofthefuture wayofthefuture marked this pull request as ready for review November 28, 2025 17:33
}

this._outOfViewCache.reset();
// Clear the tile cache
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this comment is needed...

}

this._outOfViewCache.reset();
// Clear the tile cache
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed...

@HarelM
Copy link
Collaborator

HarelM commented Nov 28, 2025

I still think adding tests to current implementation is necessary to make sure we don't miss on a use case, for example maybe the cache array is used to store different wrap instances of a tile on the same key? IDK...

@wayofthefuture
Copy link
Collaborator Author

Ok.

  1. Different wrap instance of tile on same key.
  2. Tile is actually expired on removal and not used (done).
  3. Any others?

@HarelM
Copy link
Collaborator

HarelM commented Nov 28, 2025

Current implementation, as in the old one, I know I don't fully understand the old behavior, so it's important to make sure we understand it before replacing it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants