-
-
Notifications
You must be signed in to change notification settings - Fork 924
Further optimize cross tile symbol index findMatches
#6641
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Further optimize cross tile symbol index findMatches
#6641
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6641 +/- ##
==========================================
+ Coverage 92.21% 92.22% +0.01%
==========================================
Files 285 285
Lines 23663 23697 +34
Branches 5022 5030 +8
==========================================
+ Hits 21820 21854 +34
Misses 1843 1843 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
findMatches
|
Thanks for taking the time to solve this! |
| // used to key indexes. For each symbol index key, build a reverse map | ||
| // of the scaled coordinates back to a set of SymbolInstance that | ||
| // resolve to that coordinate. | ||
| const scaledCoordinateId = scaledSymbolCoord.x << 16 | scaledSymbolCoord.y; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have to use numbers as keys?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couldn't figure out another way to use the coordinates themselves as keys in a relatively efficient way
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's the tileID.key that is usually used, can it be used here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This coordinate is within a given tile, so not equivalent to tileID.key I don't think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use strings maybe? I'm terrified of number overflows when I see this kind of code? How worse is the performance if this is string instead of number?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed to doubly nested map by x/y coordinates if that's better 964916c
|
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
|
Ok I refactored as suggested to try and pull the indexed and non-indexed codepaths apart from each other. Let me know if it's a bit clearer what's going on now. Will follow up with some tests specifically for indexed path tomorrow as suggested. |
…//github.com/bradymadden97/maplibre-gl-js into bmadden/cross-tile-symbol-index-optimization
|
Added a test suite that mimics the non-indexed code paths but should hit the indexed code paths. |
|
|
||
| // 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| } | ||
| const xMap = instancesByScaledCoordinate.get(scaledSymbolCoord.x); | ||
| if (xMap) { | ||
| const yMap = xMap.get(scaledSymbolCoord.y); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think 5c2c793 make is 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...
| // at the same location. | ||
| for (const [x, yMap] of instancesByScaledCoordinate.entries()) { | ||
| for (const [y, instances] of yMap.entries()) { | ||
| const indexes = entry.index.range( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to compare this to the previous code and I'm not sure I understand why there will be less calls to range, can you elaborate on this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Absolutely! So the situation where this is faster is because we're now running a range query only once for each coordinate position that has any symbol instances within it. Then we are matching all symbol instances at that position with any results from the range query.
Previously, we would independently run a range query for every single symbol instance, and then match a result from that range query with the symbol instance.
A pathological case would be 10,000 symbol instances at the exact same coordinate. Previously that would be 10,000 range queries. Now that would be 1 range query.
Based on that algorithm, it appears to never be more range queries than before, but in bad cases it can be significantly less.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But this is for x and y that are exactly the same, right? If all the x and y are in slightly different places then it would behave as before, right?
I'm just trying to make sure I understand what you mean and where this will improve performance.
|
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. |
| "dev": true, | ||
| "license": "MIT", | ||
| "peer": true | ||
| "license": "MIT" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure what's going on with these generated changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would advise to simply revet this file...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably different version of node...
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.