Skip to content

Conversation

@bradymadden97
Copy link
Contributor

@bradymadden97 bradymadden97 commented Oct 31, 2025

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:

  • Many symbols near eachother
  • All with same label or no label at all
  • Zoom in/ out quickly
  • See stallling

Background

Essentially, as I understand it, what findMatches is 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 range query - 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 N times, where N is 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:

// assuming all symbols have the same key

for symbol in symbols:
  let entries = index.range(symbol.location);
  for entry in entries:
    if entry.isNotAlreadyPaired:
      symbol.pair = entry
      break;

to

// assuming all symbols have the same key

for location in locationToSymbols:
  let entries = index.range(location);
  loop until entries or location.symbols is exhausted:
    location.symbols[index] = entries[index];
    index++

Before vs. After

Running performance profiles on this example https://jsbin.com/girobohihu/1/edit?html,console,output

Screenshot 2025-10-31 at 1 19 59 PM Screenshot 2025-10-31 at 1 26 15 PM

The code to run locally (which is where the screenshots themselves came from) is here:

pnpm run dev:repro slow-cross-tile-symbol-index

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 52 does 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.

let X = 10 // 100, 1000, 2000, 3000, 10_000, etc.
export class CrossTileSymbolIndexX extends Benchmark {

    bench() {
        const index = new Index();

        const mainID = new OverscaledTileID(6, 0, 6, 8, 8);
        const childID = new OverscaledTileID(7, 0, 7, 16, 16);

        const mainInstances: any[] = [];
        const childInstances: any[] = [];

        for (let i = 0; i < X; i++) {
            mainInstances.push(makeSymbolInstance(0, 0, ''));
            childInstances.push(makeSymbolInstance(0, 0, ''));
        }
        const mainTile = makeTile(mainID, mainInstances);
        const childTile = makeTile(childID, childInstances);
        index.addLayer(styleLayer as any, [mainTile], 0);
        index.addLayer(styleLayer as any, [childTile], 0);
    }
};
Iterations
Maplibre 5.7 Palantir Internal Fork Maplibre 5.7 + PR Speedup
1k Screenshot 2025-09-23 at 4 06 32 PM Screenshot 2025-09-23 at 4 52 58 PM Screenshot 2025-09-23 at 4 25 17 PM 5.7: 233x

Pal: 20x
2k Screenshot 2025-09-23 at 4 17 36 PM Screenshot 2025-09-23 at 4 54 35 PM Not run, see 3,000 See 3,000
3k Screenshot 2025-09-23 at 4 22 41 PM Screenshot 2025-09-23 at 4 54 51 PM Screenshot 2025-09-23 at 4 24 17 PM 5.7: 661x

Pal: 55x
10k Times out / never finishes Screenshot 2025-09-23 at 4 57 15 PM Screenshot 2025-09-23 at 4 25 24 PM 5.7: n/a

Pal: 111x

Related issues

Launch Checklist

  • 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.
  • Link to related issues.
  • Include before/after visuals or gifs if this PR includes visual changes.
  • Write tests for all new functionality.
  • Document any changes to public APIs.
  • Post benchmark scores.
  • Add an entry to CHANGELOG.md under the ## main section.

@codecov
Copy link

codecov bot commented Oct 31, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.22%. Comparing base (d0a0839) to head (3d218f3).

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.
📢 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.

@bradymadden97 bradymadden97 changed the title Bmadden/cross tile symbol index optimization Further optimize cross tile symbol index findMatches Oct 31, 2025
@bradymadden97 bradymadden97 marked this pull request as ready for review October 31, 2025 19:18
@HarelM
Copy link
Collaborator

HarelM commented Nov 1, 2025

Thanks for taking the time to solve this!
Is there a chance to write a test to cover this scenario or this is a "proper" refactoring so no changes to logic besides performance optimization?

// 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;
Copy link
Collaborator

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?

Copy link
Contributor Author

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

Copy link
Collaborator

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?

Copy link
Contributor Author

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.

Copy link
Collaborator

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?

Copy link
Contributor Author

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

@HarelM
Copy link
Collaborator

HarelM commented Nov 1, 2025

The explanation in the PR is just amazing! Thanks for all the work on this, it really helps reviewing this change.

@bradymadden97
Copy link
Contributor Author

bradymadden97 commented Nov 1, 2025

Is there a chance to write a test to cover this scenario or this is a "proper" refactoring so no changes to logic besides performance optimization?

Found a small bug in the logic that the updates to the test caught. Fixed in the latest version. Test also passes on main. I think this update to the test should cover the refactor logic.

@bradymadden97 bradymadden97 requested a review from HarelM November 1, 2025 20:35
@HarelM
Copy link
Collaborator

HarelM commented Nov 3, 2025

Can you add a changelog entry please?

@HarelM
Copy link
Collaborator

HarelM commented Nov 3, 2025

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.
Other than that, this looks good!
Thanks for all the work on this!

@bradymadden97
Copy link
Contributor Author

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.

@bradymadden97
Copy link
Contributor Author

Added a test suite that mimics the non-indexed code paths but should hit the indexed code paths.

@bradymadden97 bradymadden97 requested a review from HarelM November 10, 2025 17:19

// does not match Windsor because of different key
const windsorInstance = childInstances.find(i => i.key === 'Windsor');
expect(windsorInstance.crossTileID).toEqual(131);
Copy link
Collaborator

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.

Copy link
Contributor Author

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?

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 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);
Copy link
Collaborator

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?

Copy link
Contributor Author

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

Copy link
Collaborator

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?

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 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(
Copy link
Collaborator

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?

Copy link
Contributor Author

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.

Copy link
Collaborator

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.

@HarelM
Copy link
Collaborator

HarelM commented Nov 10, 2025

Sorry for dragging this PR, I just really want to understand what you did here...

@bradymadden97
Copy link
Contributor Author

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"
Copy link
Contributor Author

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.

Copy link
Collaborator

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...

Copy link
Collaborator

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...

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