Skip to content

perf(precheck): locate holes with a band index, validate shapefile polygons once - #970

Open
dktue wants to merge 1 commit into
systemed:masterfrom
dktue:perf/precheck-index
Open

dktue wants to merge 1 commit into
systemed:masterfrom
dktue:perf/precheck-index

Conversation

@dktue

@dktue dktue commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Gain (planet, PMTiles, 32 threads, one run each): shapefile phase 95 s → 21 s (−78%); total wall 1855 → 1784 s (−3.9%), user CPU −5.7%, memory unchanged. Output geometry unchanged.

Follow-up to #961 (merged). On the planet, loading the ocean shapefile (water_polygons.shp, 53,324 records) takes about 95 s, and for most of that time only a few of the 32 threads are busy. Handing the records to the thread pool takes 4.6 s; the rest is waiting for a few dozen huge ocean polygons. Each has thousands of islands as holes, and the slowest (record 1123, 4,085 holes) takes 91 s on its own, so the phase lasts as long as that one polygon.

All of these polygons are valid. Their time goes into proving it: boost::geometry::is_valid in processShapeGeometry (~70%), and a second is_valid on the clipped copy in ShpMemTiles::StoreGeometry (~25%). The cheap precheck from #961 gives up on them because of its work caps. It locates each hole by walking the whole outer ring, which is O(holes × vertices), so it bails out above 2^26 vertex visits. It also checks for nested holes pairwise, with a similar cap.

This PR changes two things.

Precheck without work caps (src/geom.cpp). After the existing test that no two rings touch, the segments of all rings of the polygon are bucketed into horizontal bands. For each hole, a ray from one of its vertices is tested only against the segments in that vertex's band, counting crossings per ring. Odd parity for the outer ring and even for every other hole means the hole is inside the outer ring and not nested. That replaces both the per-hole outer-ring walk and the pairwise hole sweep, in near-linear time. Whether a vertex is above the ray is now decided with exact comparisons (the half-open rule), so the crossing parity is exact; only the side test near a segment keeps its tolerance and answers "unsure". With the old tolerant y-test, a ray across a big ocean polygon almost always met some vertex within the tolerance of its latitude and gave up. As before, any "unsure" falls back to Boost.

No second validity check for shapefile polygons (src/shp_processor.cpp, src/shp_mem_tiles.cpp). processShapeGeometry already knows whether a polygon is valid. When it is valid and its envelope lies inside the clip box (every polygon on a planet run), it skips the clip and passes knownValid to StoreGeometry, which then skips its own check. Polygons that are clipped or were repaired by make_valid are checked as before.

A new unit test, test_precheck_large_polygon, covers a polygon above the old cap (40,000-vertex outer ring, 3,600 holes) plus a hole outside the outer ring and a nested hole. It fails with the old precheck and passes with the new one.

Shapefile phase on the planet (32 threads, run stopped before reading the .pbf)

before after
shapefile phase 90–96 s (4 runs) 20.9 s (−78%)
polygons stored 65,807 65,807

The remaining 21 s are 35 valid polygons that the precheck still rejects as nearly degenerate (near-spikes, near-duplicate points, near-touching segments). Clearing them would mean loosening the precheck's tolerances, so they are left to Boost.

Planet, PMTiles, 32 threads, --store --fast

before after change
wall 1855 s 1784 s −72 s (−3.9%)
user CPU 41,551 s 39,165 s −5.7%
sys CPU 7,786 s 8,509 s +9%
anon memory peak 13.48 GB 13.48 GB same
tile phase 1054 s 1052 s same
output 76.62 GB 76.60 GB −0.02%

All of the gain comes before the tile phase, where the shapefiles load. The output is 14 MB smaller because shapefile polygons inside the clip box are now stored as read instead of going through boost::geometry::intersection, which changes ring start points and vertex order but not the geometry (see the Germany comparison below).

Correctness

  • A debug build re-checked every polygon the precheck accepts with boost::geometry::is_valid. Planet shapefiles: 65,637 accepted, 0 disagreements. Full Germany run (OSM multipolygons in the tile phase too): 63.4 million accepted, 0 disagreements.
  • Germany output, compared with compare_area (per-tile area/length per layer and tag group): all layers identical. Only poi differs, which already differs between identical runs.

Baseline for all measurements: master + #961 + #966 + #968 + the correct.hpp ring-combine fix (separate PR).

🤖 Generated with Claude Code

…lygons once

The validity precheck located every hole by walking the whole outer ring
and tested hole nesting pairwise, both capped at 2^26 vertex visits. Huge
ocean polygons with thousands of islands exceeded the caps and fell back
to boost::geometry::is_valid, twice (processShapeGeometry and again in
ShpMemTiles::StoreGeometry), which made the planet shapefile phase last
as long as its slowest polygon (91 s).

- holesClearlyPlaced: bucket all ring segments into horizontal bands and
  cast one ray per hole, counting crossing parity per ring; no work cap
- rayClearlyCrosses: exact half-open y comparisons, so rays across large
  polygons are no longer "unsure" at nearby vertices
- shapefile polygons that are valid and inside the clip box skip the clip
  and pass knownValid to StoreGeometry

Planet shapefile phase 95 s -> 21 s (-78%), total wall -3.9%;
precheck re-verified against is_valid on planet shapefiles and Germany
with 0 mismatches.
@dktue
dktue force-pushed the perf/precheck-index branch from 7afff5e to ffd911d Compare November 1, 2026 21:00
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.

1 participant