Bash

git add builder/src/build_index.cpp && git commit -m "$(cat <<'EOF'
perf: sweep-line self-intersection check + k-way merge for S2 cell maps

1. Self-intersection check: for rings >32 vertices, sort segments by
   min-x and skip non-overlapping pairs. Reduces O(n²) to O(n log n + k)
   where k is the number of overlapping segment pairs.

2. S2 cell map building: sort each thread's pairs individually, then
   k-way merge into the output map. Avoids concatenating 86M+ items
   into a single vector and re-sorting. Per-thread sorts are faster
   (smaller arrays, better cache behavior, already partially sorted).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)" && cat builder/src/build_index.cpp | ssh node3 "cat > /tmp/traccar-geocoder/builder/src/build_index.cpp" && ssh node3 "cd /tmp/traccar-geocoder/builder/build && make -j\$(nproc) 2>&1 | tail -3" 2>&1

Commit, upload, build


[main 6277cad] perf: sweep-line self-intersection check + k-way merge for S2 cell maps
 1 file changed, 83 insertions(+), 25 deletions(-)
      |           ^~~~~~~~
[100%] Linking CXX executable build-index
[100%] Built target build-index


ssh node3 "rm -rf /tmp/de-opt && mkdir -p /tmp/de-opt && /tmp/traccar-geocoder/builder/build/build-index /tmp/de-opt ~/pbf/germany.osm.pbf --parallel-admin 2>&1 | grep -F 's]' | grep -v DEBUG" 2>&1

Germany test


  [2.0s] Pass 1: relation scanning
  [19.8s] Pass 2: node processing
  [14.7s] Pass 3: way processing
  [9.2s] Admin assembly
  [2.5s]   S2: street ways (parallel)
  [0.0s]   S2: interp ways (parallel)


Hmm, only partial output. Let me wait.


sleep 30 && ssh node3 "ps aux | grep 'build-index.*de-opt' | grep -v grep | wc -l" 2>&1

Check if running


ssh node3 "ls /tmp/de-opt/ | wc -l" 2>&1

Check output files


0 files — it crashed. The k-way merge probably has a bug. Let me check.


ssh node3 "/tmp/traccar-geocoder/builder/build/build-index /tmp/de-opt ~/pbf/germany.osm.pbf --parallel-admin 2>&1 | tail -10" 2>&1

Run again and capture errors


  [3.2s] Deduplication
Writing index files to /tmp/de-opt...
  [4.6s]   Write: entry files
geo index: 9577038 cells (3312673 ways, 19951156 addrs, 78 interps)
  [0.0s]   Write: geo_cells.bin
  [0.0s]   Write: parallel data files launched
admin index: 6103 cells, 43223 polygons
strings.bin: 7556982 bytes
  [4.8s] Index file writing
Done.


It did finish! The first run's output was just truncated by the pipe. Let me get the full timings:


ssh node3 "rm -rf /tmp/de-opt2 && /tmp/traccar-geocoder/builder/build/build-index /tmp/de-opt2 ~/pbf/germany.osm.pbf --parallel-admin 2>&1 | grep -F 's]' | grep -v DEBUG" 2>&1

Clean Germany run


  [2.9s] Pass 1: relation scanning
  [18.5s] Pass 2: node processing
  [14.5s] Pass 3: way processing
  [9.6s] Admin assembly
  [3.0s]   S2: street ways (parallel)
  [0.0s]   S2: interp ways (parallel)


Still truncated — the k-way merge output is being buffered. Let me capture to file:


ssh node3 "rm -rf /tmp/de-opt3 && /tmp/traccar-geocoder/builder/build/build-index /tmp/de-opt3 ~/pbf/germany.osm.pbf --parallel-admin > /tmp/de-opt3.log 2>&1 && grep -F 's]' /tmp/de-opt3.log | grep -v DEBUG" 2>&1

Capture to file


  [2.1s] Pass 1: relation scanning
  [17.2s] Pass 2: node processing
  [14.5s] Pass 3: way processing
  [9.4s] Admin assembly
  [3.4s]   S2: street ways (parallel)
  [0.0s]   S2: interp ways (parallel)
  [3.7s]   S2: sort + group into cell maps
  [16.2s] S2 cell computation
  [3.0s] Deduplication
  [4.3s]   Write: entry files
  [0.0s]   Write: geo_cells.bin
  [0.0s]   Write: parallel data files launched
  [4.5s] Index file writing


**Germany timing with all optimizations:**

| Phase | Previous | Now | Change |
|---|---|---|---|
| Admin assembly | 28.9s | **9.4s** | **3.1x faster** |
| S2: street ways | 3.4s | 3.4s | same |
| S2: sort+group | 2.7s | **3.7s** | 37% slower |
| S2 total | 15.2s | 16.2s | 7% slower |
| Write total | 4.4s | 4.5s | same |
| **Total** | **~86s** | **~67s** | **22% faster** |

The admin assembly went from 28.9s to **9.4s** (3.1x faster!) — the sweep-line self-intersection check is much faster for large rings. But S2 sort+group is slightly slower (3.7 vs 2.7s) — the k-way merge overhead isn't worth it for Germany's small dataset. For planet with 28 threads × larger data, it should help.

Let me verify correctness and run Europe:


ssh node3 "/tmp/dedup_compare /tmp/de-opt3 ~/de-baseline-n3 2>&1 | head -5" 2>&1

Quick verify Germany


A (/tmp/de-opt3): 37035 unique name|level
B (/home/michtest/de-baseline-n3): 37034 unique name|level
Both: 37034  Only_A: 1  Only_B: 0

Only in A (EXTRA):


0 missing. Now Europe:


ssh node3 "rm -rf ~/europe-opt && mkdir -p ~/europe-opt && /tmp/traccar-geocoder/builder/build/build-index ~/europe-opt ~/pbf/europe.osm.pbf --parallel-admin > ~/europe-opt.log 2>&1 && grep -F 's]' ~/europe-opt.log | grep -v DEBUG" 2>&1

Europe with all optimizations


Command running in background with ID: b9o70o68w. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b9o70o68w.output


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


1851% CPU 40317MB 11:33


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


1881% CPU 111558MB 24:00


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


1564% CPU 114445MB 29:36


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


1378% CPU 121350MB 34:29


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


1275% CPU 126993MB 39:44


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


1312% CPU 127433MB 49:02


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


1444% CPU 116566MB 63:19


RSS dropping — past the heavy phase.


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


1319% CPU 121711MB 65:59


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


1185% CPU 125975MB 66:36


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


1081% CPU 20889.4MB 67:17


21GB — past the dense array. S2/write phase.


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


994% CPU 20051.8MB 68:06


sleep 30 && ssh node3 "ps aux | grep 'build-index.*europe-opt' | grep -v grep | awk '{print \$3\"% CPU\", \$6/1024\"MB\", \$10}'" 2>&1

30s check


(Bash completed with no output)


Done!


cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b9o70o68w.output 2>/dev/null | grep -F 's]' | grep -v DEBUG

Europe timings


  [15.7s] Pass 1: relation scanning
  [82.3s] Pass 2: node processing
  [70.4s] Pass 3: way processing
  [71.9s] Admin assembly
  [27.1s]   S2: street ways (parallel)
  [0.0s]   S2: interp ways (parallel)
  [31.1s]   S2: sort + group into cell maps
  [104.0s] S2 cell computation
  [16.2s] Deduplication
  [44.2s]   Write: entry files
  [5.4s]   Write: geo_cells.bin
  [0.0s]   Write: parallel data files launched
  [50.2s] Index file writing


**Europe full comparison across all optimization rounds:**

| Phase | Original | + flat-array | + fast S2 | + sweep-line + k-merge | Total gain |
|---|---|---|---|---|---|
| Admin assembly | 165.3s | 165.3s | 160.1s | **71.9s** | **2.3x** |
| S2: street ways | 98.9s | 94.2s | 29.8s | **27.1s** | **3.6x** |
| S2: sort/merge | 84.4s | 28.4s | 27.0s | **31.1s** | **2.7x** |
| S2 total | 223.3s | 163.6s | 97.8s | **104.0s** | **2.1x** |
| Write: entries | 90.5s | 54.0s | 43.3s | **44.2s** | **2.0x** |
| Write total | 97.5s | 60.5s | 49.0s | **50.2s** | **1.9x** |
| **Total** | **943s (15.7m)** | **555s** | **487s** | **~411s (6.8m)** | **2.3x** |

**Europe: 15.7 min → 6.8 min** — 2.3x total speedup. The sweep-line self-intersection check halved admin assembly time (165s → 72s).

The S2 sort+group (31.1s) is slightly worse than the flat-array approach (28.4s) for Europe — the k-way merge overhead isn't a win at this scale. But the admin assembly gain (93s saved) more than compensates.


<task-notification>
<task-id>b9o70o68w</task-id>
<tool-use-id>toolu_01RFLKaQrDcvcq5Xb6gWwNGR</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b9o70o68w.output</output-file>
<status>completed</status>
<summary>Background command "Europe with all optimizations" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b9o70o68w.output


**Europe: 6.8 minutes** (down from 15.7 min original). Admin assembly was the big win: 165s → 72s from the sweep-line optimization.

The k-way merge is slightly slower than flat-array sort for Europe (31s vs 28s). Let me revert to the simpler flat-array concat+sort — it's faster and simpler:


Edit

            auto build_cell_map = [](std::vector<std::vector<CellItem>>& thread_pairs,
                                      std::unordered_map<uint64_t, std::vector<uint32_t>>& out) {
                // Sort each thread-local vector (they're mostly sorted already
                // since ways are processed in PBF geographic order)
                for (auto& v : thread_pairs) {
                    std::sort(v.begin(), v.end(), [](const CellItem& a, const CellItem& b) {
                        return a.cell_id < b.cell_id;
                    });
                }

                // K-way merge of pre-sorted runs (much faster than sorting entire array)
                size_t total = 0;
                for (auto& v : thread_pairs) total += v.size();

                // Use iterators for each thread's sorted data
                struct RunState { size_t idx; size_t end; size_t run; };
                std::vector<RunState> runs;
                std::vector<CellItem*> bases;
                for (size_t r = 0; r < thread_pairs.size(); r++) {
                    if (!thread_pairs[r].empty()) {
                        runs.push_back({0, thread_pairs[r].size(), r});
                        bases.push_back(thread_pairs[r].data());
                    }
                }

                // Simple sequential merge into cell map (k-way merge is I/O bound anyway)
                out.reserve(total / 2);
                while (!runs.empty()) {
                    // Find minimum across all run heads
                    size_t min_r = 0;
                    uint64_t min_key = bases[runs[0].run][runs[0].idx].cell_id;
                    for (size_t r = 1; r < runs.size(); r++) {
                        uint64_t k = bases[runs[r].run][runs[r].idx].cell_id;
                        if (k < min_key) { min_key = k; min_r = r; }
                    }

                    // Collect all items with this cell_id from all runs
                    auto& vec = out[min_key];
                    for (size_t r = 0; r < runs.size(); r++) {
                        auto* base = bases[runs[r].run];
                        while (runs[r].idx < runs[r].end && base[runs[r].idx].cell_id == min_key) {
                            vec.push_back(base[runs[r].idx].item_id);
                            runs[r].idx++;
                        }
                    }

                    // Remove exhausted runs
                    runs.erase(std::remove_if(runs.begin(), runs.end(),
                        [](const RunState& r) { return r.idx >= r.end; }), runs.end());
                }

                // Free thread-local data
                for (auto& v : thread_pairs) { v.clear(); v.shrink_to_fit(); }
            };

            auto build_cell_map = [](std::vector<std::vector<CellItem>>& thread_pairs,
                                      std::unordered_map<uint64_t, std::vector<uint32_t>>& out) {
                // Concatenate all thread-local vectors
                size_t total = 0;
                for (auto& v : thread_pairs) total += v.size();
                std::vector<CellItem> all;
                all.reserve(total);
                for (auto& v : thread_pairs) {
                    all.insert(all.end(), v.begin(), v.end());
                    v.clear(); v.shrink_to_fit();
                }
                // Sort by cell_id (cache-friendly sequential access)
                std::sort(all.begin(), all.end(), [](const CellItem& a, const CellItem& b) {
                    return a.cell_id < b.cell_id;
                });
                // Group into cell map
                out.reserve(total / 2);
                for (size_t i = 0; i < all.size(); ) {
                    size_t j = i;
                    while (j < all.size() && all[j].cell_id == all[i].cell_id) j++;
                    auto& vec = out[all[i].cell_id];
                    vec.reserve(j - i);
                    for (size_t k = i; k < j; k++) vec.push_back(all[k].item_id);
                    i = j;
                }
            };