The file /root/traccar-geocoder/builder/OPTIMIZATION_LOG.md has been updated successfully.

# Build Optimization Log

## Summary

| Round | Total Time | Change | Key Change |
|---|---|---|---|
| Baseline | 54m 1.7s | — | Sequential everything |
| Round 1 | 28m 36.6s | **-47%** | Parallel continents (4 concurrent) + parallel cell map rebuild |
| Round 2 | 28m 19.6s | -0.5% | Parallel remap in filter_by_bbox (minimal gain) |
| Round 3 | ~33m | **+16%** | Cell pre-tagging (REVERTED — hash map overhead too high) |
| Current best | **28m 19.6s** | **-48% from baseline** | |

## Baseline Timings (54m 1.7s total)
Build: `--multi-output --continents --multi-quality` (planet PBF → S3 layout)
- 379 files, 93 GiB output
- 32-core / 174 GiB RAM machine

### Phase Breakdown
| Phase | Time | Notes |
|---|---|---|
| Pass 1: relation scanning | 46.6s | Single-threaded PBF read |
| Pass 2: node processing | 184.4s | 28 threads |
| Pass 2b: way processing | 132.3s | 28 threads |
| Admin assembly (all) | 141.7s | Mixed parallel/sequential |
| S2 cell computation | 251.5s | 28 threads |
| Rebuild cell maps | 224.2s | 2 async tasks |
| Planet write + qualities | 42.0s | Mixed |
| Continent processing (all) | 2087.5s | Sequential |
| **Total** | **54m 1.7s** | |

---

## Round 1: Parallel continents + parallel cell map rebuild ✅
**Commit**: `d2901da`

### Changes
1. Bounded concurrent continents: 4 at a time on 32-core machine
2. Parallel cell map rebuild: split sorted pairs at cell boundaries into N chunks

### Results: 28m 36.6s (47% faster)
| Phase | Baseline | Round 1 | Change |
|---|---|---|---|
| Rebuild cell maps | 224.2s | 169.8s | -24% |
| Continent processing | 2087.5s | 621.7s | **-70%** |
| **Total** | **54m 1.7s** | **28m 36.6s** | **-47%** |

---

## Round 2: Parallel remap in filter_by_bbox + parallel mode writes ✅
**Commits**: `5a7f9d7`, `49c4b81`

### Changes
1. 4 remap phases (ways, addrs, interps, admins) run in parallel
2. 3 mode writes (full, no-addresses, admin) run in parallel per region

### Results: 28m 19.6s (marginal improvement)
Same as Round 1 — the remap parallelism helps within each continent but doesn't reduce wall time significantly since continents are already bounded at 4 concurrent.

---

## Round 3: Cell pre-tagging — REVERTED ❌
**Commit**: `f17eb6c` (reverted in `996c374`)

Pre-computing continent membership for 300M+ cells into a hash map took 434s — worse than the savings from faster per-continent lookups. Hash map build + lookup overhead exceeded the S2 cell_in_bbox cost.

---

## Remaining Bottlenecks (in current best: 28m 19.6s)

### Cannot easily optimize
- **Pass 1** (46.6s): Single-threaded PBF relation scanning — osmium limitation
- **S2 covering drain** (134.4s): Already fully parallel thread pool
- **S2 street ways** (136.9s): Already fully parallel
- **Admin S2 append** (2.9s): Sequential vector append, trivial time

### Potential optimizations
1. **Rebuild cell maps** (169.8s): The merge phase after parallel build is sequential hash map insertion. Could use a different data structure.
2. **Continent filter** (~150s per continent): Dominated by hash map iteration + cell_in_bbox. Already parallelized internally (8 threads). Main cost is scanning ALL cells for each continent.
3. **Continent write** (~20-170s per continent): Quality variant simplification is already parallel.

### Architecture note
The continent filter phase is fundamentally O(total_cells × num_continents) because each continent must scan all cell maps. This is hard to avoid without changing the data structure (e.g., spatial index on cells).

+| Round | Total Time | Change | Key Change |

+| Baseline | 54m 1.7s | — | Sequential everything |

+| Round 1 | 28m 36.6s | **-47%** | Parallel continents (4 concurrent) + parallel cell map rebuild |

+| Round 2 | 28m 19.6s | -0.5% | Parallel remap in filter_by_bbox (minimal gain) |

+| Round 3 | ~33m | **+16%** | Cell pre-tagging (REVERTED — hash map overhead too high) |

+| Current best | **28m 19.6s** | **-48% from baseline** | |

-| Rebuild cell maps | 224.2s | 2 async tasks (~15 cores) |

+| Rebuild cell maps | 224.2s | 2 async tasks |

-| Continent filter (sum) | 1621.8s | Sequential, ~8 threads each |

-| Continent write (sum) | 465.7s | Sequential |

+| Continent processing (all) | 2087.5s | Sequential |

-## Round 1: Parallel continents + parallel cell map rebuild

+## Round 1: Parallel continents + parallel cell map rebuild ✅

-1. **Bounded concurrent continents**: Run up to `hardware_concurrency / 8` continents simultaneously (4 on 32-core). Each uses 8 threads for filter_by_bbox.

-2. **Parallel cell map rebuild**: Split sorted pairs at cell boundaries into N chunks, each thread builds its own sub-map, then merge with move semantics.

+1. Bounded concurrent continents: 4 at a time on 32-core machine

+2. Parallel cell map rebuild: split sorted pairs at cell boundaries into N chunks

-### Results (28m 36.6s total — 47% faster)

-| Admin assembly | 141.7s | 142.2s | same |

-| Planet write + qualities | 42.0s | 51.6s | +10s |

-### Continent Detail (concurrent, 4 at a time)

-| Continent | Baseline filter | Baseline write | Round 1 total |

+## Round 2: Parallel remap in filter_by_bbox + parallel mode writes ✅

+1. 4 remap phases (ways, addrs, interps, admins) run in parallel

+2. 3 mode writes (full, no-addresses, admin) run in parallel per region

+### Results: 28m 19.6s (marginal improvement)

+Same as Round 1 — the remap parallelism helps within each continent but doesn't reduce wall time significantly since continents are already bounded at 4 concurrent.

-1. **Rebuild cell maps**: 169.8s — still the largest single phase. The hash map merge step is sequential.

-2. **Pass 1**: 91.6s — inflated by disk cache; normally ~45s. Single-threaded, unavoidable.

-3. **S2 covering drain**: 135.9s — fully parallel, hard to optimize.

-4. **S2 street ways**: 136.0s — fully parallel, hard to optimize.

-5. **Continent filter**: overlapped but each still ~150-400s. Could the filter itself be faster?

+## Round 3: Cell pre-tagging — REVERTED ❌

+**Commit**: `f17eb6c` (reverted in `996c374`)

+Pre-computing continent membership for 300M+ cells into a hash map took 434s — worse than the savings from faster per-continent lookups. Hash map build + lookup overhead exceeded the S2 cell_in_bbox cost.

+## Remaining Bottlenecks (in current best: 28m 19.6s)

+- **Pass 1** (46.6s): Single-threaded PBF relation scanning — osmium limitation

+- **S2 covering drain** (134.4s): Already fully parallel thread pool

+- **S2 street ways** (136.9s): Already fully parallel

+- **Admin S2 append** (2.9s): Sequential vector append, trivial time

+1. **Rebuild cell maps** (169.8s): The merge phase after parallel build is sequential hash map insertion. Could use a different data structure.

+2. **Continent filter** (~150s per continent): Dominated by hash map iteration + cell_in_bbox. Already parallelized internally (8 threads). Main cost is scanning ALL cells for each continent.

+3. **Continent write** (~20-170s per continent): Quality variant simplification is already parallel.

+The continent filter phase is fundamentally O(total_cells × num_continents) because each continent must scan all cell maps. This is hard to avoid without changing the data structure (e.g., spatial index on cells).

# Build Optimization Log

## Baseline Timings (54m 1.7s total)
Build: `--multi-output --continents --multi-quality` (planet PBF → S3 layout)
- 379 files, 93 GiB output
- 32-core / 174 GiB RAM machine

### Phase Breakdown
| Phase | Time | Notes |
|---|---|---|
| Pass 1: relation scanning | 46.6s | Single-threaded PBF read |
| Pass 2: node processing | 184.4s | 28 threads |
| Pass 2b: way processing | 132.3s | 28 threads |
| Admin assembly (all) | 141.7s | Mixed parallel/sequential |
| S2 cell computation | 251.5s | 28 threads |
| Rebuild cell maps | 224.2s | 2 async tasks (~15 cores) |
| Planet write + qualities | 42.0s | Mixed |
| Continent filter (sum) | 1621.8s | Sequential, ~8 threads each |
| Continent write (sum) | 465.7s | Sequential |
| **Total** | **54m 1.7s** | |

---

## Round 1: Parallel continents + parallel cell map rebuild
**Commit**: `d2901da`

### Changes
1. **Bounded concurrent continents**: Run up to `hardware_concurrency / 8` continents simultaneously (4 on 32-core). Each uses 8 threads for filter_by_bbox.
2. **Parallel cell map rebuild**: Split sorted pairs at cell boundaries into N chunks, each thread builds its own sub-map, then merge with move semantics.

### Results (28m 36.6s total — 47% faster)
| Phase | Baseline | Round 1 | Change |
|---|---|---|---|
| Pass 1 | 46.6s | 91.6s | +45s (disk cache) |
| Pass 2 | 184.4s | 181.7s | -3s |
| Pass 2b | 132.3s | 124.9s | -7s |
| Admin assembly | 141.7s | 142.2s | same |
| S2 computation | 251.5s | 245.6s | -6s |
| Rebuild cell maps | **224.2s** | **169.8s** | **-24%** |
| Planet write + qualities | 42.0s | 51.6s | +10s |
| Continent processing | **2087.5s** | **621.7s** | **-70%** |
| **Total** | **54m 1.7s** | **28m 36.6s** | **-47%** |

### Continent Detail (concurrent, 4 at a time)
| Continent | Baseline filter | Baseline write | Round 1 total |
|---|---|---|---|
| Africa | 162.1s | 16.1s | 19.7s |
| Asia | 234.4s | 93.1s | 107.6s |
| Europe | 277.4s | 121.2s | 124.7s |
| North America | 301.0s | 172.8s | 191.2s |
| South America | 177.3s | 29.6s | 71.6s |
| Oceania | 164.9s | 22.1s | 48.4s |
| Central America | 159.5s | 10.8s | 15.3s |
| Antarctica | 145.2s | 0.0s | 0.3s |
| **Wall clock** | **2087.5s** | | **621.7s** |

---

## Round 2: [Investigating]
### Remaining bottlenecks
1. **Rebuild cell maps**: 169.8s — still the largest single phase. The hash map merge step is sequential.
2. **Pass 1**: 91.6s — inflated by disk cache; normally ~45s. Single-threaded, unavoidable.
3. **S2 covering drain**: 135.9s — fully parallel, hard to optimize.
4. **S2 street ways**: 136.0s — fully parallel, hard to optimize.
5. **Continent filter**: overlapped but each still ~150-400s. Could the filter itself be faster?


Let me think about what else can be improved. The remaining phases:

1. **Rebuild cell maps (169.8s)**: The parallel build creates N sub-maps, then merges them sequentially into one hash map. The merge step iterates all sub-maps moving entries. Could we skip the merge entirely and keep the sharded maps? The `filter_by_bbox` could iterate all shards instead of one map.

2. **S2 cell computation (245.6s)**: The `parallel_sort_and_build` does a parallel tree merge of sorted pairs. This is already well-optimized.

3. **Dedup/addr conversion**: Already async.

Let me look at whether we can avoid the rebuild entirely. The rebuild converts sorted pairs → hash map for continent filtering. But `filter_by_bbox` already iterates hash maps. If we could make `filter_by_bbox` work with sorted pairs directly, we'd save the entire 170s rebuild:


    36→    ParsedData out;
    37→
    38→    // Fast cell-in-continent test: use pre-computed bitmask if available
    39→    auto cell_matches = [&](uint64_t cell_id) -> bool {
    40→        if (cell_mask && continent_bit) {
    41→            auto it = cell_mask->find(cell_id);
    42→            return it != cell_mask->end() && (it->second & continent_bit);
    43→        }
    44→        return cell_in_bbox(cell_id, bbox);
    45→    };
    46→
    47→    // Filter cell maps in parallel — each builds an independent ID set
    48→    auto filter_cells = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
    49→                            bool mask_interior = false) {
    50→        std::unordered_set<uint32_t> ids;
    51→        for (const auto& [cell_id, cell_ids] : cell_map) {
    52→            if (cell_matches(cell_id)) {
    53→                for (uint32_t id : cell_ids)
    54→                    ids.insert(mask_interior ? (id & ID_MASK) : id);
    55→            }

    ParsedData out;

    // Fast cell-in-continent test: use pre-computed bitmask if available
    auto cell_matches = [&](uint64_t cell_id) -> bool {
        if (cell_mask && continent_bit) {
            auto it = cell_mask->find(cell_id);
            return it != cell_mask->end() && (it->second & continent_bit);
        }
        return cell_in_bbox(cell_id, bbox);
    };

    // Filter cell maps in parallel — each builds an independent ID set
    auto filter_cells = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
                            bool mask_interior = false) {
        std::unordered_set<uint32_t> ids;
        for (const auto& [cell_id, cell_ids] : cell_map) {
            if (cell_matches(cell_id)) {
                for (uint32_t id : cell_ids)
                    ids.insert(mask_interior ? (id & ID_MASK) : id);
            }


The `filter_cells` iterates a hash map and tests each cell. If we had sorted pairs instead, we could iterate them linearly (cache-friendly) and test each cell. The sorted pairs are already sorted by cell_id, so we'd just iterate once through the sorted array, testing each cell_id. This would be much faster than random hash map iteration.

But the issue is we need sorted pairs for ways AND the hash map for addrs (which are already in a hash map). The ways and interps have sorted pairs, but addrs and admin have hash maps.

Actually, we do have `data.sorted_way_cells`, `data.sorted_addr_cells`, and `data.sorted_interp_cells` as sorted pair arrays. The only thing missing is sorted admin cells. Let me check if we can make `filter_by_bbox` work with the sorted arrays directly for ways/addrs/interps:

This would be a significant refactor of `filter_by_bbox` to accept both sorted pairs and hash maps. The benefit: skip the rebuild entirely (170s savings) and faster linear iteration of sorted arrays vs random hash map iteration.

But this is a big change. Let me instead focus on a simpler win — can we reduce the `cell_in_bbox` cost? The S2CellId → lat/lng conversion involves math. What if we cache the result? Actually, the continent filter already uses `cell_matches` which falls back to `cell_in_bbox`. The pre-tagging idea was right but the implementation was wrong (hash map too expensive).

What if instead of a hash map, we use a **flat array indexed by S2 cell ID**? S2 cells at level 17 have a fixed range. Or better — since the cell IDs in our maps are at specific levels (17 for streets, 10 for admin), we could compute the bbox test directly from the cell_id without converting to lat/lng.

Actually, the simplest remaining win: **skip the rebuild entirely** by making `filter_by_bbox` accept sorted pairs for ways/interps. Only admin needs the hash map (and it already has one). Let me implement this:


Edit

// Filter with optional pre-computed cell masks for faster continent lookups.
// If cell_mask is provided, uses bitmask lookup instead of cell_in_bbox.
ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox,
                          const std::unordered_map<uint64_t, uint8_t>* cell_mask = nullptr,
                          uint8_t continent_bit = 0);

ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox);


The file /root/traccar-geocoder/builder/src/continent_filter.h has been updated successfully.

#pragma once

#include "parsed_data.h"

struct ContinentBBox {
    const char* name;
    double min_lat, max_lat, min_lng, max_lng;
};

extern const ContinentBBox kContinents[];
extern const size_t kContinentCount;

// Filter with optional pre-computed cell masks for faster continent lookups.
// If cell_mask is provided, uses bitmask lookup instead of cell_in_bbox.
ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox,
                          const std::unordered_map<uint64_t, uint8_t>* cell_mask = nullptr,
                          uint8_t continent_bit = 0);

-// Filter with optional pre-computed cell masks for faster continent lookups.

-// If cell_mask is provided, uses bitmask lookup instead of cell_in_bbox.

-ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox,

-                          const std::unordered_map<uint64_t, uint8_t>* cell_mask = nullptr,

-                          uint8_t continent_bit = 0);

+ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox);


Now update the implementation to also accept sorted pairs and skip hash maps when available. This eliminates the need for the rebuild step:


ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox,
                          const std::unordered_map<uint64_t, uint8_t>* cell_mask,
                          uint8_t continent_bit) {
    ParsedData out;

    // Fast cell-in-continent test: use pre-computed bitmask if available
    auto cell_matches = [&](uint64_t cell_id) -> bool {
        if (cell_mask && continent_bit) {
            auto it = cell_mask->find(cell_id);
            return it != cell_mask->end() && (it->second & continent_bit);
        }
        return cell_in_bbox(cell_id, bbox);
    };

    // Filter cell maps in parallel — each builds an independent ID set
    auto filter_cells = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
                            bool mask_interior = false) {
        std::unordered_set<uint32_t> ids;
        for (const auto& [cell_id, cell_ids] : cell_map) {
            if (cell_matches(cell_id)) {
                for (uint32_t id : cell_ids)
                    ids.insert(mask_interior ? (id & ID_MASK) : id);
            }
        }
        return ids;
    };

    auto f_ways = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_ways); });
    auto f_addrs = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_addrs); });
    auto f_interps = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_interps); });
    auto f_admin = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_admin, true); });

ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox) {
    ParsedData out;

    // Filter from hash map
    auto filter_cells_map = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
                                bool mask_interior = false) {
        std::unordered_set<uint32_t> ids;
        for (const auto& [cell_id, cell_ids] : cell_map) {
            if (cell_in_bbox(cell_id, bbox)) {
                for (uint32_t id : cell_ids)
                    ids.insert(mask_interior ? (id & ID_MASK) : id);
            }
        }
        return ids;
    };

    // Filter from sorted pairs (linear scan, cache-friendly — much faster than hash map)
    auto filter_cells_sorted = [&](const std::vector<CellItemPair>& sorted,
                                   bool mask_interior = false) {
        std::unordered_set<uint32_t> ids;
        for (size_t i = 0; i < sorted.size(); ) {
            uint64_t cell_id = sorted[i].cell_id;
            bool match = cell_in_bbox(cell_id, bbox);
            // Process all items in this cell
            while (i < sorted.size() && sorted[i].cell_id == cell_id) {
                if (match) {
                    uint32_t id = sorted[i].item_id;
                    ids.insert(mask_interior ? (id & ID_MASK) : id);
                }
                i++;
            }
        }
        return ids;
    };

    // Use sorted pairs where available (faster linear scan), fall back to hash maps
    auto f_ways = std::async(std::launch::async, [&]{
        return !full.sorted_way_cells.empty()
            ? filter_cells_sorted(full.sorted_way_cells)
            : filter_cells_map(full.cell_to_ways);
    });
    auto f_addrs = std::async(std::launch::async, [&]{
        return !full.sorted_addr_cells.empty()
            ? filter_cells_sorted(full.sorted_addr_cells)
            : filter_cells_map(full.cell_to_addrs);
    });
    auto f_interps = std::async(std::launch::async, [&]{
        return !full.sorted_interp_cells.empty()
            ? filter_cells_sorted(full.sorted_interp_cells)
            : filter_cells_map(full.cell_to_interps);
    });
    auto f_admin = std::async(std::launch::async, [&]{
        return filter_cells_map(full.cell_to_admin, true);
    });


The file /root/traccar-geocoder/builder/src/continent_filter.cpp has been updated successfully.

ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox) {
    ParsedData out;

    // Filter from hash map
    auto filter_cells_map = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
                                bool mask_interior = false) {
        std::unordered_set<uint32_t> ids;
        for (const auto& [cell_id, cell_ids] : cell_map) {
            if (cell_in_bbox(cell_id, bbox)) {
                for (uint32_t id : cell_ids)
                    ids.insert(mask_interior ? (id & ID_MASK) : id);
            }
        }
        return ids;
    };

    // Filter from sorted pairs (linear scan, cache-friendly — much faster than hash map)
    auto filter_cells_sorted = [&](const std::vector<CellItemPair>& sorted,
                                   bool mask_interior = false) {
        std::unordered_set<uint32_t> ids;
        for (size_t i = 0; i < sorted.size(); ) {
            uint64_t cell_id = sorted[i].cell_id;
            bool match = cell_in_bbox(cell_id, bbox);
            // Process all items in this cell
            while (i < sorted.size() && sorted[i].cell_id == cell_id) {
                if (match) {
                    uint32_t id = sorted[i].item_id;
                    ids.insert(mask_interior ? (id & ID_MASK) : id);
                }
                i++;
            }
        }
        return ids;
    };

    // Use sorted pairs where available (faster linear scan), fall back to hash maps
    auto f_ways = std::async(std::launch::async, [&]{
        return !full.sorted_way_cells.empty()
            ? filter_cells_sorted(full.sorted_way_cells)
            : filter_cells_map(full.cell_to_ways);
    });
    auto f_addrs = std::async(std::launch::async, [&]{
        return !full.sorted_addr_cells.empty()
            ? filter_cells_sorted(full.sorted_addr_cells)
            : filter_cells_map(full.cell_to_addrs);
    });
    auto f_interps = std::async(std::launch::async, [&]{
        return !full.sorted_interp_cells.empty()
            ? filter_cells_sorted(full.sorted_interp_cells)
            : filter_cells_map(full.cell_to_interps);
    });
    auto f_admin = std::async(std::launch::async, [&]{
        return filter_cells_map(full.cell_to_admin, true);
    });

#include "continent_filter.h"

#include <algorithm>
#include <cstring>
#include <future>
#include <unordered_set>

#include <s2/s2cell_id.h>
#include <s2/s2latlng.h>

const ContinentBBox kContinents[] = {
    {"africa",            -35.0,  37.5,  -25.0,  55.0},
    {"asia",              -12.0,  82.0,   25.0, 180.0},
    {"europe",             35.0,  72.0,  -25.0,  45.0},
    {"north-america",       7.0,  84.0, -170.0, -50.0},
    {"south-america",     -56.0,  13.0,  -82.0, -34.0},
    {"oceania",           -50.0,   0.0,  110.0, 180.0},
    {"central-america",     7.0,  23.5, -120.0, -57.0},
    {"antarctica",        -90.0, -60.0, -180.0, 180.0},
};

const size_t kContinentCount = sizeof(kContinents) / sizeof(kContinents[0]);

static bool cell_in_bbox(uint64_t cell_id, const ContinentBBox& bbox) {
    S2CellId cell(cell_id);
    S2LatLng center = cell.ToLatLng();
    double lat = center.lat().degrees();
    double lng = center.lng().degrees();
    return lat >= bbox.min_lat && lat <= bbox.max_lat &&
           lng >= bbox.min_lng && lng <= bbox.max_lng;
}

ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox,
                          const std::unordered_map<uint64_t, uint8_t>* cell_mask,
                          uint8_t continent_bit) {
    ParsedData out;

    // Fast cell-in-continent test: use pre-computed bitmask if available
    auto cell_matches = [&](uint64_t cell_id) -> bool {
        if (cell_mask && continent_bit) {
            auto it = cell_mask->find(cell_id);
            return it != cell_mask->end() && (it->second & continent_bit);
        }
        return cell_in_bbox(cell_id, bbox);
    };

    // Filter cell maps in parallel — each builds an independent ID set
    auto filter_cells = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
                            bool mask_interior = false) {
        std::unordered_set<uint32_t> ids;
        for (const auto& [cell_id, cell_ids] : cell_map) {
            if (cell_matches(cell_id)) {
                for (uint32_t id : cell_ids)
                    ids.insert(mask_interior ? (id & ID_MASK) : id);
            }
        }
        return ids;
    };

    auto f_ways = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_ways); });
    auto f_addrs = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_addrs); });
    auto f_interps = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_interps); });
    auto f_admin = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_admin, true); });

    auto used_way_ids = f_ways.get();
    auto used_addr_ids = f_addrs.get();
    auto used_interp_ids = f_interps.get();
    auto used_admin_ids = f_admin.get();

    // Remap all 4 data types in parallel — each builds its own vectors and remap table
    std::unordered_map<uint32_t, uint32_t> way_remap, addr_remap, interp_remap, admin_remap;

    // Ways
    auto f_remap_ways = std::async(std::launch::async, [&]() {
        std::vector<uint32_t> sorted_ids(used_way_ids.begin(), used_way_ids.end());
        std::sort(sorted_ids.begin(), sorted_ids.end());
        std::vector<WayHeader> ways;
        std::vector<NodeCoord> nodes;
        ways.reserve(sorted_ids.size());
        nodes.reserve(sorted_ids.size() * 5);
        std::unordered_map<uint32_t, uint32_t> remap;
        remap.reserve(sorted_ids.size());
        for (uint32_t old_id : sorted_ids) {
            remap[old_id] = static_cast<uint32_t>(ways.size());
            const auto& w = full.ways[old_id];
            WayHeader nw = w;
            nw.node_offset = static_cast<uint32_t>(nodes.size());
            ways.push_back(nw);
            for (uint8_t n = 0; n < w.node_count; n++)
                nodes.push_back(full.street_nodes[w.node_offset + n]);
        }
        return std::make_tuple(std::move(remap), std::move(ways), std::move(nodes));
    });

    // Addrs
    auto f_remap_addrs = std::async(std::launch::async, [&]() {
        std::vector<uint32_t> sorted_ids(used_addr_ids.begin(), used_addr_ids.end());
        std::sort(sorted_ids.begin(), sorted_ids.end());
        std::vector<AddrPoint> addrs;
        addrs.reserve(sorted_ids.size());
        std::unordered_map<uint32_t, uint32_t> remap;
        remap.reserve(sorted_ids.size());
        for (uint32_t old_id : sorted_ids) {
            remap[old_id] = static_cast<uint32_t>(addrs.size());
            addrs.push_back(full.addr_points[old_id]);
        }
        return std::make_tuple(std::move(remap), std::move(addrs));
    });

    // Interps
    auto f_remap_interps = std::async(std::launch::async, [&]() {
        std::vector<uint32_t> sorted_ids(used_interp_ids.begin(), used_interp_ids.end());
        std::sort(sorted_ids.begin(), sorted_ids.end());
        std::vector<InterpWay> iways;
        std::vector<NodeCoord> inodes;
        iways.reserve(sorted_ids.size());
        std::unordered_map<uint32_t, uint32_t> remap;
        remap.reserve(sorted_ids.size());
        for (uint32_t old_id : sorted_ids) {
            remap[old_id] = static_cast<uint32_t>(iways.size());
            const auto& iw = full.interp_ways[old_id];
            InterpWay niw = iw;
            niw.node_offset = static_cast<uint32_t>(inodes.size());
            iways.push_back(niw);
            for (uint8_t n = 0; n < iw.node_count; n++)
                inodes.push_back(full.interp_nodes[iw.node_offset + n]);
        }
        return std::make_tuple(std::move(remap), std::move(iways), std::move(inodes));
    });

    // Admins
    auto f_remap_admins = std::async(std::launch::async, [&]() {
        std::vector<uint32_t> sorted_ids(used_admin_ids.begin(), used_admin_ids.end());
        std::sort(sorted_ids.begin(), sorted_ids.end());
        std::vector<AdminPolygon> polys;
        std::vector<NodeCoord> verts;
        polys.reserve(sorted_ids.size());
        std::unordered_map<uint32_t, uint32_t> remap;
        remap.reserve(sorted_ids.size());
        for (uint32_t old_id : sorted_ids) {
            remap[old_id] = static_cast<uint32_t>(polys.size());
            const auto& ap = full.admin_polygons[old_id];
            AdminPolygon nap = ap;
            nap.vertex_offset = static_cast<uint32_t>(verts.size());
            polys.push_back(nap);
            for (uint32_t v = 0; v < ap.vertex_count; v++)
                verts.push_back(full.admin_vertices[ap.vertex_offset + v]);
        }
        return std::make_tuple(std::move(remap), std::move(polys), std::move(verts));
    });

    // Collect results
    {
        auto [wr, ways, nodes] = f_remap_ways.get();
        way_remap = std::move(wr);
        out.ways = std::move(ways);
        out.street_nodes = std::move(nodes);
    }
    {
        auto [ar, addrs] = f_remap_addrs.get();
        addr_remap = std::move(ar);
        out.addr_points = std::move(addrs);
    }
    {
        auto [ir, iways, inodes] = f_remap_interps.get();
        interp_remap = std::move(ir);
        out.interp_ways = std::move(iways);
        out.interp_nodes = std::move(inodes);
    }
    {
        auto [ar, polys, verts] = f_remap_admins.get();
        admin_remap = std::move(ar);
        out.admin_polygons = std::move(polys);
        out.admin_vertices = std::move(verts);
    }

    // Remap cell maps in parallel (each builds an independent output map)
    auto remap_cells = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& src,
                           const std::unordered_map<uint32_t, uint32_t>& remap,
                           std::unordered_map<uint64_t, std::vector<uint32_t>>& dst,
                           bool handle_flags = false) {
        for (const auto& [cell_id, ids] : src) {
            if (!cell_matches(cell_id)) continue;
            std::vector<uint32_t> new_ids;
            for (uint32_t id : ids) {
                uint32_t raw_id = handle_flags ? (id & ID_MASK) : id;
                uint32_t flags = handle_flags ? (id & INTERIOR_FLAG) : 0;
                auto it = remap.find(raw_id);
                if (it != remap.end()) new_ids.push_back(it->second | flags);
            }
            if (!new_ids.empty()) dst[cell_id] = std::move(new_ids);
        }
    };

    {
        auto f1 = std::async(std::launch::async, [&]{ remap_cells(full.cell_to_ways, way_remap, out.cell_to_ways); });
        auto f2 = std::async(std::launch::async, [&]{ remap_cells(full.cell_to_addrs, addr_remap, out.cell_to_addrs); });
        auto f3 = std::async(std::launch::async, [&]{ remap_cells(full.cell_to_interps, interp_remap, out.cell_to_interps); });
        auto f4 = std::async(std::launch::async, [&]{ remap_cells(full.cell_to_admin, admin_remap, out.cell_to_admin, true); });
        f1.get(); f2.get(); f3.get(); f4.get();
    }

    // Rebuild compact string pool
    std::unordered_set<uint32_t> used_offsets;
    for (const auto& w : out.ways) used_offsets.insert(w.name_id);
    for (const auto& a : out.addr_points) { used_offsets.insert(a.housenumber_id); used_offsets.insert(a.street_id); }
    for (const auto& iw : out.interp_ways) used_offsets.insert(iw.street_id);
    for (const auto& ap : out.admin_polygons) used_offsets.insert(ap.name_id);

    const auto& old_sp = full.string_pool.data();
    std::unordered_map<uint32_t, uint32_t> string_remap;
    auto& new_sp = out.string_pool.mutable_data();
    new_sp.clear();

    std::vector<uint32_t> sorted_offsets(used_offsets.begin(), used_offsets.end());
    std::sort(sorted_offsets.begin(), sorted_offsets.end());
    for (uint32_t old_off : sorted_offsets) {
        uint32_t new_off = static_cast<uint32_t>(new_sp.size());
        string_remap[old_off] = new_off;
        const char* str = old_sp.data() + old_off;
        size_t len = std::strlen(str);
        new_sp.insert(new_sp.end(), str, str + len + 1);
    }

    for (auto& w : out.ways) w.name_id = string_remap[w.name_id];
    for (auto& a : out.addr_points) { a.housenumber_id = string_remap[a.housenumber_id]; a.street_id = string_remap[a.street_id]; }
    for (auto& iw : out.interp_ways) iw.street_id = string_remap[iw.street_id];
    for (auto& ap : out.admin_polygons) ap.name_id = string_remap[ap.name_id];

    return out;
}

-                          const std::unordered_map<uint64_t, uint8_t>* cell_mask,

-                          uint8_t continent_bit) {

+ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox) {

-    // Fast cell-in-continent test: use pre-computed bitmask if available

-    auto cell_matches = [&](uint64_t cell_id) -> bool {

-        if (cell_mask && continent_bit) {

-            auto it = cell_mask->find(cell_id);

-            return it != cell_mask->end() && (it->second & continent_bit);

+    auto filter_cells_map = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,

+                                bool mask_interior = false) {

+        std::unordered_set<uint32_t> ids;

+        for (const auto& [cell_id, cell_ids] : cell_map) {

+            if (cell_in_bbox(cell_id, bbox)) {

+                for (uint32_t id : cell_ids)

+                    ids.insert(mask_interior ? (id & ID_MASK) : id);

-        return cell_in_bbox(cell_id, bbox);

-    // Filter cell maps in parallel — each builds an independent ID set

-    auto filter_cells = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,

-                            bool mask_interior = false) {

+    // Filter from sorted pairs (linear scan, cache-friendly — much faster than hash map)

+    auto filter_cells_sorted = [&](const std::vector<CellItemPair>& sorted,

+                                   bool mask_interior = false) {

-        for (const auto& [cell_id, cell_ids] : cell_map) {

-            if (cell_matches(cell_id)) {

-                for (uint32_t id : cell_ids)

+        for (size_t i = 0; i < sorted.size(); ) {

+            uint64_t cell_id = sorted[i].cell_id;

+            bool match = cell_in_bbox(cell_id, bbox);

+            // Process all items in this cell

+            while (i < sorted.size() && sorted[i].cell_id == cell_id) {

+                    uint32_t id = sorted[i].item_id;

-    auto f_ways = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_ways); });

-    auto f_addrs = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_addrs); });

-    auto f_interps = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_interps); });

-    auto f_admin = std::async(std::launch::async, [&]{ return filter_cells(full.cell_to_admin, true); });

+    // Use sorted pairs where available (faster linear scan), fall back to hash maps

+    auto f_ways = std::async(std::launch::async, [&]{

+        return !full.sorted_way_cells.empty()

+            ? filter_cells_sorted(full.sorted_way_cells)

+            : filter_cells_map(full.cell_to_ways);

+    auto f_addrs = std::async(std::launch::async, [&]{

+        return !full.sorted_addr_cells.empty()

+            ? filter_cells_sorted(full.sorted_addr_cells)

+            : filter_cells_map(full.cell_to_addrs);

+    auto f_interps = std::async(std::launch::async, [&]{

+        return !full.sorted_interp_cells.empty()

+            ? filter_cells_sorted(full.sorted_interp_cells)

+            : filter_cells_map(full.cell_to_interps);

+    auto f_admin = std::async(std::launch::async, [&]{

+        return filter_cells_map(full.cell_to_admin, true);


Also update the cell remap section to remove the cell_matches reference:


if (!cell_in_bbox(cell_id, bbox)) continue;


#include "continent_filter.h"

#include <algorithm>
#include <cstring>
#include <future>
#include <unordered_set>

#include <s2/s2cell_id.h>
#include <s2/s2latlng.h>

const ContinentBBox kContinents[] = {
    {"africa",            -35.0,  37.5,  -25.0,  55.0},
    {"asia",              -12.0,  82.0,   25.0, 180.0},
    {"europe",             35.0,  72.0,  -25.0,  45.0},
    {"north-america",       7.0,  84.0, -170.0, -50.0},
    {"south-america",     -56.0,  13.0,  -82.0, -34.0},
    {"oceania",           -50.0,   0.0,  110.0, 180.0},
    {"central-america",     7.0,  23.5, -120.0, -57.0},
    {"antarctica",        -90.0, -60.0, -180.0, 180.0},
};

const size_t kContinentCount = sizeof(kContinents) / sizeof(kContinents[0]);

static bool cell_in_bbox(uint64_t cell_id, const ContinentBBox& bbox) {
    S2CellId cell(cell_id);
    S2LatLng center = cell.ToLatLng();
    double lat = center.lat().degrees();
    double lng = center.lng().degrees();
    return lat >= bbox.min_lat && lat <= bbox.max_lat &&
           lng >= bbox.min_lng && lng <= bbox.max_lng;
}

ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox) {
    ParsedData out;

    // Filter from hash map
    auto filter_cells_map = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
                                bool mask_interior = false) {
        std::unordered_set<uint32_t> ids;
        for (const auto& [cell_id, cell_ids] : cell_map) {
            if (cell_in_bbox(cell_id, bbox)) {
                for (uint32_t id : cell_ids)
                    ids.insert(mask_interior ? (id & ID_MASK) : id);
            }
        }
        return ids;
    };

    // Filter from sorted pairs (linear scan, cache-friendly — much faster than hash map)
    auto filter_cells_sorted = [&](const std::vector<CellItemPair>& sorted,
                                   bool mask_interior = false) {
        std::unordered_set<uint32_t> ids;
        for (size_t i = 0; i < sorted.size(); ) {
            uint64_t cell_id = sorted[i].cell_id;
            bool match = cell_in_bbox(cell_id, bbox);
            // Process all items in this cell
            while (i < sorted.size() && sorted[i].cell_id == cell_id) {
                if (match) {
                    uint32_t id = sorted[i].item_id;
                    ids.insert(mask_interior ? (id & ID_MASK) : id);
                }
                i++;
            }
        }
        return ids;
    };

    // Use sorted pairs where available (faster linear scan), fall back to hash maps
    auto f_ways = std::async(std::launch::async, [&]{
        return !full.sorted_way_cells.empty()
            ? filter_cells_sorted(full.sorted_way_cells)
            : filter_cells_map(full.cell_to_ways);
    });
    auto f_addrs = std::async(std::launch::async, [&]{
        return !full.sorted_addr_cells.empty()
            ? filter_cells_sorted(full.sorted_addr_cells)
            : filter_cells_map(full.cell_to_addrs);
    });
    auto f_interps = std::async(std::launch::async, [&]{
        return !full.sorted_interp_cells.empty()
            ? filter_cells_sorted(full.sorted_interp_cells)
            : filter_cells_map(full.cell_to_interps);
    });
    auto f_admin = std::async(std::launch::async, [&]{
        return filter_cells_map(full.cell_to_admin, true);
    });

    auto used_way_ids = f_ways.get();
    auto used_addr_ids = f_addrs.get();
    auto used_interp_ids = f_interps.get();
    auto used_admin_ids = f_admin.get();

    // Remap all 4 data types in parallel — each builds its own vectors and remap table
    std::unordered_map<uint32_t, uint32_t> way_remap, addr_remap, interp_remap, admin_remap;

    // Ways
    auto f_remap_ways = std::async(std::launch::async, [&]() {
        std::vector<uint32_t> sorted_ids(used_way_ids.begin(), used_way_ids.end());
        std::sort(sorted_ids.begin(), sorted_ids.end());
        std::vector<WayHeader> ways;
        std::vector<NodeCoord> nodes;
        ways.reserve(sorted_ids.size());
        nodes.reserve(sorted_ids.size() * 5);
        std::unordered_map<uint32_t, uint32_t> remap;
        remap.reserve(sorted_ids.size());
        for (uint32_t old_id : sorted_ids) {
            remap[old_id] = static_cast<uint32_t>(ways.size());
            const auto& w = full.ways[old_id];
            WayHeader nw = w;
            nw.node_offset = static_cast<uint32_t>(nodes.size());
            ways.push_back(nw);
            for (uint8_t n = 0; n < w.node_count; n++)
                nodes.push_back(full.street_nodes[w.node_offset + n]);
        }
        return std::make_tuple(std::move(remap), std::move(ways), std::move(nodes));
    });

    // Addrs
    auto f_remap_addrs = std::async(std::launch::async, [&]() {
        std::vector<uint32_t> sorted_ids(used_addr_ids.begin(), used_addr_ids.end());
        std::sort(sorted_ids.begin(), sorted_ids.end());
        std::vector<AddrPoint> addrs;
        addrs.reserve(sorted_ids.size());
        std::unordered_map<uint32_t, uint32_t> remap;
        remap.reserve(sorted_ids.size());
        for (uint32_t old_id : sorted_ids) {
            remap[old_id] = static_cast<uint32_t>(addrs.size());
            addrs.push_back(full.addr_points[old_id]);
        }
        return std::make_tuple(std::move(remap), std::move(addrs));
    });

    // Interps
    auto f_remap_interps = std::async(std::launch::async, [&]() {
        std::vector<uint32_t> sorted_ids(used_interp_ids.begin(), used_interp_ids.end());
        std::sort(sorted_ids.begin(), sorted_ids.end());
        std::vector<InterpWay> iways;
        std::vector<NodeCoord> inodes;
        iways.reserve(sorted_ids.size());
        std::unordered_map<uint32_t, uint32_t> remap;
        remap.reserve(sorted_ids.size());
        for (uint32_t old_id : sorted_ids) {
            remap[old_id] = static_cast<uint32_t>(iways.size());
            const auto& iw = full.interp_ways[old_id];
            InterpWay niw = iw;
            niw.node_offset = static_cast<uint32_t>(inodes.size());
            iways.push_back(niw);
            for (uint8_t n = 0; n < iw.node_count; n++)
                inodes.push_back(full.interp_nodes[iw.node_offset + n]);
        }
        return std::make_tuple(std::move(remap), std::move(iways), std::move(inodes));
    });

    // Admins
    auto f_remap_admins = std::async(std::launch::async, [&]() {
        std::vector<uint32_t> sorted_ids(used_admin_ids.begin(), used_admin_ids.end());
        std::sort(sorted_ids.begin(), sorted_ids.end());
        std::vector<AdminPolygon> polys;
        std::vector<NodeCoord> verts;
        polys.reserve(sorted_ids.size());
        std::unordered_map<uint32_t, uint32_t> remap;
        remap.reserve(sorted_ids.size());
        for (uint32_t old_id : sorted_ids) {
            remap[old_id] = static_cast<uint32_t>(polys.size());
            const auto& ap = full.admin_polygons[old_id];
            AdminPolygon nap = ap;
            nap.vertex_offset = static_cast<uint32_t>(verts.size());
            polys.push_back(nap);
            for (uint32_t v = 0; v < ap.vertex_count; v++)
                verts.push_back(full.admin_vertices[ap.vertex_offset + v]);
        }
        return std::make_tuple(std::move(remap), std::move(polys), std::move(verts));
    });

    // Collect results
    {
        auto [wr, ways, nodes] = f_remap_ways.get();
        way_remap = std::move(wr);
        out.ways = std::move(ways);
        out.street_nodes = std::move(nodes);
    }
    {
        auto [ar, addrs] = f_remap_addrs.get();
        addr_remap = std::move(ar);
        out.addr_points = std::move(addrs);
    }
    {
        auto [ir, iways, inodes] = f_remap_interps.get();
        interp_remap = std::move(ir);
        out.interp_ways = std::move(iways);
        out.interp_nodes = std::move(inodes);
    }
    {
        auto [ar, polys, verts] = f_remap_admins.get();
        admin_remap = std::move(ar);
        out.admin_polygons = std::move(polys);
        out.admin_vertices = std::move(verts);
    }

    // Remap cell maps in parallel (each builds an independent output map)
    auto remap_cells = [&](const std::unordered_map<uint64_t, std::vector<uint32_t>>& src,
                           const std::unordered_map<uint32_t, uint32_t>& remap,
                           std::unordered_map<uint64_t, std::vector<uint32_t>>& dst,
                           bool handle_flags = false) {
        for (const auto& [cell_id, ids] : src) {
            if (!cell_matches(cell_id)) continue;
            std::vector<uint32_t> new_ids;
            for (uint32_t id : ids) {
                uint32_t raw_id = handle_flags ? (id & ID_MASK) : id;
                uint32_t flags = handle_flags ? (id & INTERIOR_FLAG) : 0;
                auto it = remap.find(raw_id);
                if (it != remap.end()) new_ids.push_back(it->second | flags);
            }
            if (!new_ids.empty()) dst[cell_id] = std::move(new_ids);
        }
    };

    {
        auto f1 = std::async(std::launch::async, [&]{ remap_cells(full.cell_to_ways, way_remap, out.cell_to_ways); });
        auto f2 = std::async(std::launch::async, [&]{ remap_cells(full.cell_to_addrs, addr_remap, out.cell_to_addrs); });
        auto f3 = std::async(std::launch::async, [&]{ remap_cells(full.cell_to_interps, interp_remap, out.cell_to_interps); });
        auto f4 = std::async(std::launch::async, [&]{ remap_cells(full.cell_to_admin, admin_remap, out.cell_to_admin, true); });
        f1.get(); f2.get(); f3.get(); f4.get();
    }

    // Rebuild compact string pool
    std::unordered_set<uint32_t> used_offsets;
    for (const auto& w : out.ways) used_offsets.insert(w.name_id);
    for (const auto& a : out.addr_points) { used_offsets.insert(a.housenumber_id); used_offsets.insert(a.street_id); }
    for (const auto& iw : out.interp_ways) used_offsets.insert(iw.street_id);
    for (const auto& ap : out.admin_polygons) used_offsets.insert(ap.name_id);

    const auto& old_sp = full.string_pool.data();
    std::unordered_map<uint32_t, uint32_t> string_remap;
    auto& new_sp = out.string_pool.mutable_data();
    new_sp.clear();

    std::vector<uint32_t> sorted_offsets(used_offsets.begin(), used_offsets.end());
    std::sort(sorted_offsets.begin(), sorted_offsets.end());
    for (uint32_t old_off : sorted_offsets) {
        uint32_t new_off = static_cast<uint32_t>(new_sp.size());
        string_remap[old_off] = new_off;
        const char* str = old_sp.data() + old_off;
        size_t len = std::strlen(str);
        new_sp.insert(new_sp.end(), str, str + len + 1);
    }

    for (auto& w : out.ways) w.name_id = string_remap[w.name_id];
    for (auto& a : out.addr_points) { a.housenumber_id = string_remap[a.housenumber_id]; a.street_id = string_remap[a.street_id]; }
    for (auto& iw : out.interp_ways) iw.street_id = string_remap[iw.street_id];
    for (auto& ap : out.admin_polygons) ap.name_id = string_remap[ap.name_id];

    return out;
}

-            if (!cell_matches(cell_id)) continue;

+            if (!cell_in_bbox(cell_id, bbox)) continue;


Now I can also skip the rebuild when sorted pairs are available. The rebuild is only needed when filter_by_bbox uses hash maps:


  1244→
  1245→    // --- Rebuild hash maps from sorted pairs if needed for continent filtering ---
  1246→    if (generate_continents || !save_cache_path.empty()) {
  1247→        // Parallel rebuild: split sorted pairs into chunks, each thread builds
  1248→        // a partial map, then merge. Sorted pairs are grouped by cell_id so we
  1249→        // can split at cell boundaries for zero-conflict parallel insertion.
  1250→        auto rebuild_map_parallel = [](const std::vector<CellItemPair>& sorted,
  1251→                                        std::unordered_map<uint64_t, std::vector<uint32_t>>& map) {
  1252→            if (sorted.empty() || !map.empty()) return;
  1253→            unsigned nthreads = std::thread::hardware_concurrency();
  1254→            if (nthreads == 0) nthreads = 4;
  1255→            size_t chunk = (sorted.size() + nthreads - 1) / nthreads;
  1256→
  1257→            // Find cell boundaries for clean splits
  1258→            std::vector<size_t> boundaries = {0};

    // --- Rebuild hash maps from sorted pairs if needed for continent filtering ---
    if (generate_continents || !save_cache_path.empty()) {
        // Parallel rebuild: split sorted pairs into chunks, each thread builds
        // a partial map, then merge. Sorted pairs are grouped by cell_id so we
        // can split at cell boundaries for zero-conflict parallel insertion.
        auto rebuild_map_parallel = [](const std::vector<CellItemPair>& sorted,
                                        std::unordered_map<uint64_t, std::vector<uint32_t>>& map) {
            if (sorted.empty() || !map.empty()) return;
            unsigned nthreads = std::thread::hardware_concurrency();
            if (nthreads == 0) nthreads = 4;
            size_t chunk = (sorted.size() + nthreads - 1) / nthreads;

            // Find cell boundaries for clean splits
            std::vector<size_t> boundaries = {0};


The rebuild is only needed for `cell_to_ways` and `cell_to_interps` (which have sorted pairs but empty hash maps). Since `filter_by_bbox` now uses sorted pairs directly when available, we can skip the rebuild for continent filtering! Only keep it for cache saving:


    // --- Rebuild hash maps from sorted pairs if needed for continent filtering ---
    if (generate_continents || !save_cache_path.empty()) {

    // --- Rebuild hash maps from sorted pairs if needed for cache saving ---
    // (Continent filtering now uses sorted pairs directly, so no rebuild needed for that.)
    if (!save_cache_path.empty()) {