Skip to content

Commit 8982709

Browse files
committed
content: update Go optimization results and add round 2/3 details
1 parent c1b4fab commit 8982709

1 file changed

Lines changed: 80 additions & 22 deletions

File tree

content/posts/2026/2026-05-03-bicycle-route-heterogeneity.md

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,15 @@ The initial Go rewrite was correct but slow — ~36 seconds for a cold run, only
205205

206206
### Results
207207

208-
| | Python | Go (initial) | Go (optimized) |
209-
|---|---|---|---|
210-
| **Cold run** | 118 s | 36 s | **17 s** |
211-
| **Warm run** | 60 s | 25 s | **6 s** |
212-
| **Speedup** ||| **7–10×** |
208+
| | Python | Go (initial) | Go (after round 1) | Go (round 2) | **Go (today)** |
209+
|---|---|---|---|---|---|
210+
| **Cold run** | 121 s | 36 s | 17 s | 16 s | **37 s** |
211+
| **Warm run** | 60 s | 25 s | 6 s | 5 s | **2.9 s** |
212+
| **Speedup vs Python** ||| 7–10× | 7.5× cold, 12× warm | **3.3× cold, 21× warm** |
213213

214-
The warm run is now dominated by graph deserialization (~1 s) and A* routing (~0.04 s). The remaining ~5 s is osmium OPL extraction from the PBF, which is a fixed external cost.
214+
*Cold run went from 16 s → 37 s because we moved from a tile-level msgpack cache to a bbox-level cache. The first cold run for a new bbox still needs osmium extraction (~29 s). This is expected — the win is on warm runs.*
215+
216+
The warm run is now dominated by graph deserialization (~1 s) and weight preparation (~2.5 s). A* routing itself is ~40 ms — negligible.
215217

216218
### Test Route Details
217219

@@ -224,45 +226,101 @@ All benchmarks use the same real-world test case:
224226
- **Hardware**: Apple M4 Pro, macOS, Go 1.24
225227
- **Profile**: gravel, adventurousness 0.5
226228

227-
### Detailed Timing Breakdown (Go optimized, warm run)
229+
### Detailed Timing Breakdown (Go final, warm run)
228230

229231
| Stage | Time | % of total |
230232
|---|---|---|
231233
| OPL extraction (osmium) | ~0 s (cached) | 0% |
232-
| Graph deserialization | 1.0 s | 17% |
233-
| Weight preparation (parallel) | 4.5 s | 75% |
234-
| A* routing | 0.04 s | <1% |
235-
| Grid scoring | 1.5 s | 25% |
236-
| **Total warm** | **~6 s** | **100%** |
234+
| Graph deserialization (msgpack) | 1.3 s | 45% |
235+
| Weighted graph cache load | 0.1 s | 3% |
236+
| A* routing | 0.04 s | 1% |
237+
| Output generation (GPX/GeoJSON/JSON) | 0.5 s | 17% |
238+
| Grid scoring + candidate generation | ~1.0 s | 34% |
239+
| **Total warm** | **~2.9 s** | **100%** |
240+
241+
*Weighted graph cache skips `PrepareWeights` entirely on repeat requests. The 1.3 s msgpack deserialization is now the bottleneck.*
237242

238243
The A* router explores ~130k nodes (26.6% of the 490k-node graph) to find the 62 km adventurous path. With scenic weights, edge costs vary by up to 33% from physical length, so the Haversine heuristic is quite optimistic — but the route quality is worth the exploration cost.
239244

245+
### Optimization history
246+
247+
**Round 1 — correctness + quick wins:**
248+
- Dense node index (`[]float64` gScore instead of `map[int64]float64`)
249+
- Parallel `PrepareWeights` (8 goroutines, 23 s → 4.5 s)
250+
- Precomputed A* heuristic (eliminates 300k+ repeated Haversine calls)
251+
- Cache key fix (bbox hash instead of temp file path)
252+
253+
**Round 2 — three targeted optimizations:**
254+
255+
1. **Grid-based scenic precomputation.** Instead of 5 R-tree queries per edge at request time (5M queries total), we precompute scenic density on a 100 m grid once after graph build. `PrepareWeights` becomes a single bilinear lookup per edge. Route quality impact: negligible — the adventurous route changed by only 40 m (60.16 km vs 60.20 km).
256+
257+
2. **Skip scenic weights for base route.** When `adventurousness=0`, we skip `PrepareWeights` entirely and use `PenaltyWeight` (highway penalty only). In compare mode this saves the full weight-prep time for the base route.
258+
259+
3. **Nearest-node R-tree.** `nearestNode()` was a linear scan over all 490k nodes. We now build a node R-tree in `BuildIndex()` and do an expanding-radius search. Cuts nearest-node time from ~0.2 s to ~1 ms.
260+
261+
**Round 3 — caching layers + visualization:**
262+
263+
4. **Route result cache.** Hash `(start_lat, start_lon, end_lat, end_lon, adventurousness, profile)` → cache all output files. Repeat queries: **<100 ms**. Invalidated when PBF or code changes.
264+
265+
5. **Weighted graph cache.** Cache `edge.Weight` values after `PrepareWeights` (~9 MB msgpack). On hit, skip the entire 1.5 s scenic scoring step. Key includes graph hash + scoring params.
266+
267+
6. **Bidirectional A*.** Search from both ends simultaneously. On a 490k-node graph: **2.2× faster** (66 ms → 30 ms per route). Added lazy `ReverseEdgeList` — only built when bidirectional search is actually used.
268+
269+
7. **PNG candidate maps.** Each candidate route gets a static PNG map with base + adventurous + candidate overlay. Viewer shows "🗺️ View map" links per candidate.
270+
271+
### The Native PBF Parser Experiment (What We Learned)
272+
273+
We tried replacing the osmium subprocess with a native Go PBF parser using `github.com/paulmach/osm`. The parser worked for basic routing (~14 s, 2× faster than osmium) but **failed to extract scenic data**:
274+
275+
| | Osmium (default) | Native Go PBF |
276+
|---|---|---|
277+
| Cold time | ~37 s | ~14 s |
278+
| Nature features | 28,428 | 0 |
279+
| Scenic points | 751 | 0 |
280+
| Cities/settlements | 1,004 | 0 |
281+
| Route quality | Full adventurous routing | Basic shortest path |
282+
283+
**Why:** Osmium has a C++ spatial index that can look up any node by ID in O(1) when resolving way geometries. Go streaming parsers read the file sequentially — to find 100 nodes referenced by a way, they must scan all 235M nodes or do multiple passes. Extracting full scenic data (nature areas, POIs, cities) requires 3+ passes over a 1.9 GB file, which exceeds 5 minutes.
284+
285+
**Decision:** Osmium stays as default. Native parser is available as `-native-pbf` for environments without osmium installed. For eliminating the subprocess, pre-extracting OPL once per region is the practical path.
286+
287+
### The Profiling Lesson
288+
289+
We added graph pruning (removing disconnected components) and `ReverseEdgeList` building to `BuildIndex()`, thinking they were "free" optimizations. Profiling revealed they added **2–3 seconds to every single run**:
290+
291+
- Pruning BFS on 490k nodes: ~2 s per run
292+
- ReverseEdgeList scan over 1M edges: ~1 s per run
293+
- Combined: warm run went from 2.9 s → 5.5 s
294+
295+
**Rule:** Never add O(n) or O(m) work to the hot path without measuring. Both were reverted. Pruning is only useful when building from raw OSM data (where disconnected ways exist), not when loading from a clean osmium extract.
296+
240297
### What the Go rewrite replaced
241298

242299
| Component | Python | Go |
243300
|---|---|---|
244301
| Parser | Custom OPL parser | Same, rewritten |
245302
| Graph | NetworkX MultiDiGraph | Custom adjacency list + dense index |
246303
| Spatial index | Shapely STRtree | `tidwall/rtree` |
247-
| Cache | gzipped pickle (tile-level) | msgpack (bbox-level) |
248-
| Router | NetworkX A* | Custom A* with slice heap |
304+
| Scenic scoring | Per-edge R-tree queries | 100 m precomputed grid + bilinear lookup |
305+
| Cache | gzipped pickle (tile-level) | msgpack (bbox-level) + route result cache + weighted graph cache |
306+
| Router | NetworkX A* | Custom A* + Bidirectional A* with slice heap |
307+
| Visualization | None | PNG per candidate, Leaflet viewer |
249308
| Webapp | Flask + ThreadPool | stdlib `net/http` + goroutine workers |
250309
| Binary size || ~7 MB single binary |
251-
| Tests | ~112 | **135** + 10 benchmarks |
310+
| Tests | ~112 | **138** + 10 benchmarks |
252311

253312
### What's next
254313

255-
The remaining warm-run time is dominated by `PrepareWeights` (~4.5 s). The next optimization would be **grid-based scenic precomputation**: instead of doing 5 R-tree queries per edge at request time, precompute scenic density on a 100 m grid once after graph build. Edge weights become simple grid lookups. This would cut warm runs from ~6 s to ~2 s.
256-
257-
Other possibilities:
258-
- Native Go PBF parser (eliminate 8 s osmium subprocess on cold runs)
259-
- Skip scenic weight prep entirely for base/shortest routes
260-
- k-d tree for nearest-node lookup (eliminates 490k-node linear scans)
314+
- **Bidirectional A* as default** — 2.2× routing speedup, zero overhead. Wire into CLI.
315+
- **Skip non-road ways during parse** — filter buildings/barriers during OPL build. Estimated 60% graph shrink.
316+
- **zstd compression for msgpack cache** — 220 MB → ~80 MB, faster I/O.
317+
- **WebSocket/SSE progress** — replace polling with server-sent events for real-time job progress.
318+
- **Native PBF parser** — available as `-native-pbf` for environments without osmium. Not default.
261319

262320
## Summary
263321

264322
The core idea is simple: **interesting cycling happens at the boundaries between ecosystems**. By measuring nature heterogeneity from OSM data, turning it into a continuous density field, and letting the router flow through it like water through a watershed, we generate routes that are measurably more varied than shortest-path alternatives — without forcing artificial detours.
265323

266-
The algorithm is fully offline (no APIs), runs in **~6 seconds on a warm cache** (was ~60 s in Python), and produces routes that cyclists actually want to ride.
324+
The algorithm is fully offline (no APIs), runs in **~2.9 seconds on a warm cache** (was ~60 s in Python), and produces routes that cyclists actually want to ride.
267325

268326
<!-- TODO: screenshot — the "Plan a route" panel in viewer.html. Show red A marker, green B marker, the slider for adventurousness, and the loop-mode toggle. Caption: "Click anywhere on the map to set start (A) and end (B), then hit Generate." -->

0 commit comments

Comments
 (0)