Further optimize cross tile symbol index findMatches - #6641
Further optimize cross tile symbol index findMatches#6641bradymadden97 wants to merge 35 commits into
findMatches#6641Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6641 +/- ##
==========================================
+ Coverage 92.46% 92.47% +0.01%
==========================================
Files 288 288
Lines 23856 23890 +34
Branches 5063 5071 +8
==========================================
+ Hits 22058 22092 +34
Misses 1798 1798 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
findMatches
|
Thanks for taking the time to solve this! |
|
The explanation in the PR is just amazing! Thanks for all the work on this, it really helps reviewing this change. |
…//github.com/bradymadden97/maplibre-gl-js into bmadden/cross-tile-symbol-index-optimization
… bmadden/cross-tile-symbol-index-optimization
Found a small bug in the logic that the updates to the test caught. Fixed in the latest version. Test also passes on |
|
Can you add a changelog entry please? |
|
I've added a few minor comments. the map of maps is a bit confusing to understand so I would recommend adding some docs and export some part of the method to smaller methods with good documentation. |
…//github.com/bradymadden97/maplibre-gl-js into bmadden/cross-tile-symbol-index-optimization
|
|
||
| // does not match Windsor because of different key | ||
| const windsorInstance = childInstances.find(i => i.key === 'Windsor'); | ||
| expect(windsorInstance.crossTileID).toEqual(131); |
There was a problem hiding this comment.
This test has too many expect calls, I would recommend splitting this test and use beforeEach to arrange all the relevant information if it is similar between the tests.
The comments above each paragraph is a good indication that this needs to be split and the comment should probably be the test name.
There was a problem hiding this comment.
Was copying the structure of the section of tests above, which do have interleaved expects and do not use beforeEach. I would suggest the structures remain the same actually, in my opinion. Do you agree / would you prefer me to include beforeEach refactor for the existing tests in the other decribe block as well?
There was a problem hiding this comment.
I think it would be better to make the file similar to other files and also make sure it is consistent within the same file, meaning change to use beforeEach etc.
Having said that, I don't want to force you to rewrite the entire file, so at least change the tests you added, it would be great if you would change the other tests as well, but I would understand if you decide not to.
There was a problem hiding this comment.
After re-looking at it, most of these tests require a good bit of set up to get into the state we ultimately are testing. I'm not sure it makes sense / is easily possible to split them up, and I don't see a lot of shared setup across tests that can be pulled into a beforeEach.
If you wouldn't mind could you take another look at a few of these and see if you think otherwise?
There was a problem hiding this comment.
This test specific has a lot of steps and logic.
I would recommend move the initial 20-30 lines into a beforeEach and then test only the part according to each test.
This would mean that you would split this test every time you wrote a comment like:
matched Detroit children tiles from parent for example.
This would make the definition of the test more readable and avoid the need to have out of sync comments inside the test.
If this is not possible to split this test up, then a re-write of this test in needed as it just has too many logic and tests "too much".
I hope this is clearer now.
| } | ||
| const xMap = instancesByScaledCoordinate.get(scaledSymbolCoord.x); | ||
| if (xMap) { | ||
| const yMap = xMap.get(scaledSymbolCoord.y); |
There was a problem hiding this comment.
is yMap a map or an array? I don't see method related to Map when using yMap, am I missing anything?
There was a problem hiding this comment.
Does this rename improve it or not? 5c2c793
It's a doubly nested map (since I need to group instances by their x,y coordinates). Happy to change to other suggestions, or clarify if the reasoning still doesn't make sense
There was a problem hiding this comment.
instancesByScaledCoordinate is a map, xMap is a map, but yMap is an array, am I reading it wrong?
There was a problem hiding this comment.
I don't think 5c2c793 make it more readable unfortunately.
maybe just rename yMap to instancesArray? or simply instances?
Man this is so confusing...
I think the names should match the below for loops, but I'm not sure they do...
|
Sorry for dragging this PR, I just really want to understand what you did here... |
Not a problem at all, I similarly want to make the code understandable so thank you for the feedback. Happy to keep iterating until it's in a good place. |
| [crossTileID: number]: boolean; | ||
| }) { | ||
| const tolerance = this.tileID.canonical.z < newTileID.canonical.z ? 1 : Math.pow(2, this.tileID.canonical.z - newTileID.canonical.z); | ||
| const instancesByKey = this.groupSymbolInstancesByKey(symbolInstances); |
There was a problem hiding this comment.
If the map returned by groupSymbolInstancesByKey doesn't change for the same symbolInstances, the groupSymbolInstancesByKey can moved out of findMatches
|
The need for optimization stems from the fact that symbols don't have IDs unique across tiles. If they did, then cross tile symbol index would be a simple map. There is a mechanism that assigns IDs to features at least for GeoJSON source and symbols are unique per feature/layer combo. |
| expect(childInstances.some(i => i.crossTileID === 0)).toBeFalsy(); | ||
|
|
||
| // Overwrites the old id to match the already-added tile | ||
| index.addLayer(styleLayer, [mainTile, childTile], 0); |
There was a problem hiding this comment.
If this addLayer can happen earlier it would be great in order to keep the test in arrange-act-assert (AAA) style.
If this is not possible, consider splitting this test into two tests.
| } | ||
|
|
||
| // copies parent ids without duplicate ids in this tile | ||
| index.addLayer(styleLayer, [childTile], 0); |
Here's the commit that makes up cross tile ids for symbols from feature and layer ids. For features without ids it'll fall back to the code this PR is optimizing. For GeoJSON sources with features without I modified the bin from this PR to use the cross tile ids from the commit above and In my ad hoc testing it doesn't stall and I am curious to see the level of actual improvement if @bradymadden97 can run the benchmark with the change from the commit above. |
|
Tried to get the benchmarks above running again so I could test out the above suggestion, but running into errors even trying to build the benchmarks on main. Perhaps benchmarks has been broken? |
|
Are you using a different version of the libraries somehow? I think this error message if from a recent breaking change of the roll-up plugin. |
|
Any updates on this? What do we need in order to push this forward? |
|
Any news on PR? |
|
Seems a bit abandoned... Do you want to copy the code over to a different PR and continue the work @NeluQi? |
Issue
When zooming in or out over a source that has a large number of symbols with the same label (or no label), maplibre will hit an extremely slow path trying to find and pair symbols across tiles (for fading behavior). This will result in noticable stalls and frame drops while zooming. This is also documented in the linked issues below.
Demo of issue
Screen.Recording.2025-10-31.at.3.10.30.PM.1.mov
(this is using https://jsbin.com/girobohihu/1/edit?html,console,output which is the simplest repro I could create. I've seen much worse stalls in production and the linked issues have some examples as well). The TLDR of the issue is:
Background
Essentially, as I understand it, what
findMatchesis doing is iterating through all the symbols on the current tile, and all the symbols on a future tile (at a different zoom level, depending on if we're zooming in or out) and trying to pair as many symbols from the starting tile and the destination tile as possible. When symbols are "paired", the symbol on the destination tile will not "fade in". If the symbols did always "fade in" it gives a weird experience while zooming. The video below mocks what it would look like if we never paired any symbols while zooming:Screen.Recording.2025-10-31.at.2.17.04.PM.1.mov
You can see the symbol flash every time we enter a new zoom level, as it's reloaded and unpaired/transitioned from the previous zoom level.
Compare that to the current state:
Screen.Recording.2025-10-31.at.2.18.43.PM.1.mov
So I think we established the purpose of the code, now let's talk about the pathologically slow case. The slow case happens when we have a bunch of symbols on the origin tile that need to be matched with a bunch of symbols on the destination tile. This happens when all symbols have the same label, as the label is what is used to match symbols. However, unlabeled symbols are also essentially treated as being "the same label" for this matching purpose, so a simple pathologically slow case can occur any time we just have a bunch of symbols with no label in the same tile.
We tried to optimize this exact case in #1755 by building a spatial index per unique symbol key (aka label, or no label) (source). But then we ultimately re-run the
rangequery - which is the slow path as highlighted in some of the linked issues - for every single symbol instance (of which there are many) [source].There was a PR to attempt to exit early from these range queries after we've found 1 result (see here), but I actually don't think this solution would solve this optimally. You wouldn't necessarily want to only fetch 1 result from your range query, as it's possible that first result would have already been paired with a different symbol instance at the same zoom level with the same key. So then the question becomes: "How many do you fetch?", and there is no answer that can be correct besides "All of them", which is what we're currently doing, and is currently slow.
Changes in this PR
That brings us to the changes in this PR. The idea here is to find a way to avoid running the slow range query
Ntimes, whereNis the number of symbol instances with the same symbol key. We want to run the range query for a given symbol key once, and then pair as many of the results with as many symbol instances as possible.Effectively the pseduo code changes from:
to
Before vs. After
Running performance profiles on this example https://jsbin.com/girobohihu/1/edit?html,console,output
The code to run locally (which is where the screenshots themselves came from) is here:
Manual Regression Test
I performed a manual regression test with labels to confirm our matching behavior still works with this new algorithm. You can see
52does not fade in/out which implies it is being matched across tiles:Screen.Recording.2025-10-31.at.3.15.05.PM.2.mov
Benchmark
Wrote a small benchmark to demonstrate the speedup. There is a third comparison, to an internal fork we have that had some additional optimizations beyond what is currently in main, but this improvement exceeds the improvement on that fork.
This was ran last month, against 5.7, but there have been no changes to this file since then, besides #6635 which was just a rename, so they should still be valid.
Pal: 20x
Pal: 55x
Pal: 111x
Related issues
Launch Checklist
CHANGELOG.mdunder the## mainsection.