Skip to content

perf: 3.4x faster planet build (2h10 -> 39 min), less RAM, deterministic output - #939

Merged
systemed merged 3 commits into
systemed:masterfrom
dktue:perf-optimizations
Oct 28, 2026
Merged

systemed merged 3 commits into
systemed:masterfrom
dktue:perf-optimizations

Conversation

@dktue

@dktue dktue commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

perf: 3.4x faster on the full planet, 3-4.5x on extracts, much less RAM, deterministic output

Full planet: 2 h 10 min -> 38 min 38 s (3.4x faster), 4x less CPU time, 96 GB less peak RAM. On extracts: 3-4.5x faster and 11-20x less peak memory. The tiles are the same apart from small differences in simplified landcover/landuse/water polygons (see Output).

Full planet

Planet (planet-261005, 89 GB PBF) Before (parent commit) After Improvement
Wall time 2 h 10 min 08 s 38 min 38 s 3.4x faster
CPU time (user + system) 190 h 47.6 h 4.0x less
Peak RSS 270 GB 174 GB -96 GB (-36%)
Output size 75.470 GB 75.454 GB -0.02%

Setup: OpenMapTiles profile (resources/config-openmaptiles.json + process-openmaptiles.lua) with coastline and landcover shapefiles, PMTiles output, in memory (no --store), default thread count. Server: AMD EPYC 9454P (48 cores / 96 threads), 503 GB RAM, Ubuntu 22.04, GCC 11.4. Measured with /usr/bin/time -v, one run per version, back to back on an otherwise idle machine. Both runs wrote all 345,907,442 tiles and exited cleanly.

Part of the planet RAM saving comes from closed ways that are only wrongly oriented. These are now kept as reversed references into the way store, instead of being repaired and stored as separate generated polygons: "Generated polygons" in the log falls from 300M to 7.6M.

Extracts

Extract Wall time before -> after Speedup Peak RAM before -> after RAM reduction
Luxembourg 7.8 s -> 1.8 s 4.4x faster 5.22 GB -> 0.25 GB 20x less
Stuttgart (Regierungsbezirk) 21.3 s -> 6.7 s 3.2x faster 6.19 GB -> 0.54 GB 11x less

Total CPU time also falls 1.5-1.8x, so the speedup is not just better use of cores; the work itself shrank.

Measurement: interleaved A/B runs (6 pairs per extract) against the parent commit, after a discarded warm-up run. Setup: OpenMapTiles profile, gzip MVT to .mbtiles, 22-thread laptop (Core Ultra 7 165H). Times and RAM are medians.

Output

  • Tile output no longer depends on thread count or scheduling. Previously, runs with different --threads values differed in up to ~200 Luxembourg tiles, because what the ClipCache held depended on scheduling. Tiles are now split into fixed batches, each with its own clip cache.
  • Because tiles are now always clipped from the full geometry instead of from whichever ancestor clip happened to be cached, simplified landcover/landuse/water polygons at z9-12 (rarely z14) differ slightly from before: on Luxembourg 69 of 2081 tiles (3.3%), typically 2-20 units of 4096 (max 110). The original already differed from itself between runs in the same way (about 4 tiles per run, up to 45 units); the new output is identical on every run.
  • Every other change gives identical output. This was checked with a semantic tile diff on Monaco and Luxembourg, plus Monaco with: maxzoom 16, minzoom 13, --materialize-geometries, --store, --shard-stores and --threads 1.

Main changes

  • Memory
    • mmap_allocator: per-thread regions use anonymous mmap instead of zero-filled vectors. This is the largest RSS win.
    • UsedWays is a sparse sorted vector, with a dense-bitmap fallback for planet-scale inputs.
    • On glibc, M_TRIM_THRESHOLD is set unless the user tunes malloc through the environment.
    • Subdivided relation blocks are decompressed once and shared between chunks.
    • Per-batch cache resets.
  • Tiles
    • The batch partition and per-batch ClipCache (with per-zoom levels) described under Output.
    • A conservative sweep-based "definitely valid" precheck runs before boost::geometry::is_valid. Boost still runs whenever the precheck is unsure.
    • Visvalingam: rings that are wrong only in orientation are reversed instead of dissolved, the heap is indexed, and MVT attribute writing is memoized.
    • Tile coordinate filtering scans words instead of calling test() per tile.
  • Ingest
    • Lua: Find/Holds/Attribute*/MinZoom/ZOrder are native lua_CFunctions that keep kaguya's argument-type rules. Each lua_State gets a size-class pool allocator.
    • Ways whose only fault is wrong orientation are reversed instead of being sent through make_valid.
    • SortedNodeStore: a decoded-chunk cache and batched lookups.
    • TagMap uses flat shards, and AttributePairStore has a thread-local lookup table.
    • Shapefiles: records outside the bbox are skipped using the bbox in each record header.
  • polylabel: provably no-op cells are pruned, and an SSE2 distance kernel is used on x86-64. Both give bit-identical results.

Fast paths were cross-checked against the original code paths during development (that verification code has been removed from this PR).

Not tested

  • MSVC/Windows and macOS builds. Portable fallbacks exist for the SIMD and popcount code.
  • A tile-by-tile comparison of the planet output; only extracts were diffed. Planet --store mode (only in-memory planet runs were timed).

🤖 Generated with Claude Code

@dktue dktue changed the title perf: deterministic tile batching, lower memory, faster ingest and tile output perf: 3-4.5x faster, 11-20x less RAM, deterministic output Oct 14, 2026
@dktue dktue changed the title perf: 3-4.5x faster, 11-20x less RAM, deterministic output perf: 3.4x faster planet build (2h10 -> 39 min), less RAM, deterministic output Oct 14, 2026
@systemed

systemed commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Very neat set of changes, thank you.

I observed less of a speedup but then my machine is rather less powerful and fairly IO-constrained: the UK (RAM-resident) went from 4m33 to 3m14, Europe (RAM-resident) went from 1hr11 to 58m, the planet (with --store) went from 5hr28 to 5hr11. All very much worth having. Better still, RAM usage fell by 40% for Europe and 63% (!) for the UK.

Could you take out the code guarded by TMOPT_VERIFY? I don't think we need this in the codebase and the commit message suggests it shouldn't have been included. I've merged the MLT PR so you can rebase on top of that.

@laem @etienneJr If you have time to check out this PR I'd be really interested to hear your experiences - it looks like it could be a handy speedup for cartes.app.

@laem

laem commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

These two PR are great ! We get slightly faster tiles to transfer with MLT, faster decoding, and faster generation with less RAM.

This is especially cool because RAM is on shortage. Our cloud provider (Scaleway) has a shortage of 384 Gb or RAM compute machines.

Will test as soon as possible, thanks for the mention.

@dktue

dktue commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for testing, and for the numbers!

  • Removed all TMOPT_VERIFY code (separate commit, -796 lines). Rebuilt and re-checked: tile output is unchanged on Monaco and Luxembourg, also with --store, --shard-stores, --materialize-geometries, --threads 1, minzoom 13 and maxzoom 16.
  • Rebased onto master now that Add MapLibre Tile (MLT) output #938 is merged, so the PR only contains the perf changes.

On the smaller speedup: my numbers were in-memory runs on a 96-thread EPYC, where the work is CPU-bound. Most of these changes cut CPU work, so on an IO-constrained machine, and especially with --store, the disk becomes the limit and less of the gain shows. I didn't look at --store performance specifically; that would be a good follow-up.

I also corrected the output section of the description: the geometry differences from the deterministic clipping are a bit larger than I first wrote (details there). They are the same kind of difference the original already produced between runs.

@systemed
systemed marked this pull request as ready for review October 28, 2026 11:09
@systemed
systemed merged commit 0944e72 into systemed:master Oct 28, 2026
11 checks passed
@systemed

Copy link
Copy Markdown
Owner

Thank you - this is an amazing speedup. I'm still looking through the changes in detail, but I'm confident enough in what I've seen in outline that I'm happy to merge now and we can push any further adjustments if needed. The ability to run tilemaker on the planet in less than an hour is pretty impressive!

@dktue
dktue deleted the perf-optimizations branch October 28, 2026 11:15
@laem

laem commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

The ability to run tilemaker on the planet in less than an hour is pretty impressive!

Yes, that's great. If only we had daily osm.pbf releases :/ To my knownledge, they're only weekly, even with torrents.

@laem

laem commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Just ran the planet build with the latest master version, compiled with boost 1.90 and MLT.

Interesting, after using all the cores, it's been using only one (2% of the overall) at 168 Gb of RAM.

Machine : EPYC 32 cores 380 Gb of ram.

image

Looks like there is a single-core process that takes a significant portion of the process's timeline.

Edit : 40 minutes after, it comes back at 100 % CPUS used.

dktue added a commit to dktue/tilemaker that referenced this pull request Oct 28, 2026
Gain: callgrind on Luxembourg --store drops from 125.7 to 121.7 G instructions (-3.2%); output identical (all 273,080 Germany tiles). ShardedNodeStore didn't override the batched at(ids, n, out) from systemed#939, so --store looked up every node of every way on its own plus a contains() check, the top entry in a --store profile. Now the whole batch is looked up in the shard of its first node, with per-node lookups only for ways crossing a shard boundary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
dktue added a commit to dktue/tilemaker that referenced this pull request Oct 28, 2026
…re output now matches in-memory)

With shards, a relation was read only in the pass for the shard of its first member way; if that way wasn't in the extract, every pass skipped it. Germany --store vs in-memory: 119 missing tiles and ~5,700 missing features (Szczecin Lagoon, Usedom, border nature reserves) -> 0 differences in 273,199 tiles. Decide the shard by the first member way that is in the store; relations with no stored member ways are read in the first pass. Bug predates systemed#939.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
geoneutrino added a commit to geoneutrino/tilemaker that referenced this pull request Oct 30, 2026
Large clipped polygons such as coastline/ocean shapefiles are written to tiles invalid: scaling them onto the integer tile grid introduces self-intersections. Until now only SIMPLIFIED geometry was repaired, so those layers stayed invalid and can be mis-rendered by strict renderers - as missing water, or as land hidden behind an ocean polygon.

New layer option invalid_polygon_repair_scope: "simplified_only" (default, unchanged behaviour) repairs only invalid polygons that simplification may have broken; "all" is a superset and additionally repairs polygons that were merely clipped and quantised. For those, repair_multi_polygon runs with a strict area guard that also rejects any repair GROWING a polygon - growing means a hole was filled, which would make an island disappear. That bound must not apply to simplified geometry, where legitimate repairs do change area; applying it globally measurably made things worse (5 -> 20 invalid features in one tile).

The bound is passed as a scoped enum, AreaBound, rather than a bool: other changes also append parameters to repair_multi_polygon (systemed#939 a bool, systemed#949 a bool*), and with a bool here a merge that puts the parameters in the wrong order would still compile, silently.

Verified: with the default the output is unchanged (Bangladesh z7 96/55: 166 features, 5 invalid, 319 holes). With "all" on the ocean layers, that tile goes to 4 invalid / 318 holes and the ocean feature behind a reported rendering artefact becomes valid; Denmark z5 16/10 improves 5 -> 1 invalid while Funen correctly stays land; Philippines z5 26/14 improves 7 -> 1 invalid with no land wrongly covered by ocean.
systemed pushed a commit that referenced this pull request Oct 31, 2026
…ht, 67x less disk writing, 3.2% less CPU (#943)

* fix(store): 12x faster --store under memory pressure, 67x less disk writes

Sorting output objects scattered writes into the mmap'd store files, so the kernel rewrote the same dirty pages over and over. Germany with an 8 GB limit: 69+ min (1.3 TB written) -> 5:36 (20 GB written), identical tile content. Scatter into the existing heap buffer and copy back sequentially instead. Also release store file handles after mapping, so many-threaded --store runs no longer abort with 'Failed to open mmap file'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* perf(store): 3.2% less CPU with --store via batched node lookups

Gain: callgrind on Luxembourg --store drops from 125.7 to 121.7 G instructions (-3.2%); output identical (all 273,080 Germany tiles). ShardedNodeStore didn't override the batched at(ids, n, out) from #939, so --store looked up every node of every way on its own plus a contains() check, the top entry in a --store profile. Now the whole batch is looked up in the shard of its first node, with per-node lookups only for ways crossing a shard boundary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(store): don't drop relations crossing the extract boundary (--store output now matches in-memory)

With shards, a relation was read only in the pass for the shard of its first member way; if that way wasn't in the extract, every pass skipped it. Germany --store vs in-memory: 119 missing tiles and ~5,700 missing features (Szczecin Lagoon, Usedom, border nature reserves) -> 0 differences in 273,199 tiles. Decide the shard by the first member way that is in the store; relations with no stored member ways are read in the first pass. Bug predates #939.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: dktue <2878623+dktue@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@dktue

dktue commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

@laem, sorry that this went unanswered. Thanks for the report and the screenshot.

Since then we went looking for single-threaded stretches in the planet build and fixed the ones we found (planet, PMTiles, 32 threads):

Stretch on one or a few cores Before After PR
Shapefile loading (one giant ocean polygon) 95 s 21 s #970
End of the relations phase (Lake Huron, Guri Reservoir, Lake Nasser…) ~46 s at 1–3 cores ~15 s #974, #975, #976
Collecting tiles 25 s on one core 1 s #972
Finalizing z6 tiles 26 s 7 s #972
Process exit with --store 30 s 8 s #973

At 168 GB, an in-memory planet run has usually finished reading, which is where most of these sit: the end of the relations phase (Lake Huron alone took 103 s on one core), then collecting tiles and finalizing. So there's a good chance this is what you saw, and that it's much shorter now. To be sure, could you share:

  1. The last progress line tilemaker printed during the single-core stretch. It names the phase (e.g. Block …/…, finalizing z6 tile, writing tile …).
  2. The output format (.pmtiles or .mbtiles), and whether you used --store.
  3. The profile and config (the bundled OpenMapTiles one, or cartes.app's own?).
  4. The command line, and the Boost version you built with (we use 1.74; 1.90 is newer than anything we tested).

If it turns out to be something else, we're happy to reproduce it on our 96-thread EPYC with your setup. If you'd like to try the current state in the meantime, dktue/tilemaker → perf/all-open-prs has all open performance PRs together (numbers in #977).

dktue pushed a commit to dktue/tilemaker that referenced this pull request Nov 2, 2026
Setting any malloc parameter freezes glibc's mmap threshold at 128 KB, so
every larger buffer is a fresh mmap with zeroed pages. Boost's is_valid
grows such vectors over and over for a huge polygon: the Tongass National
Forest relation (1.9M points) took 3x as long as before systemed#939.

Tongass relation 677 -> 232 s; planet -2.6% wall for +0.85 GB peak heap;
planet on 4 CPUs / 16 GB unchanged within noise, same heap peak.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
geoneutrino added a commit to geoneutrino/tilemaker that referenced this pull request Nov 2, 2026
Large clipped polygons such as coastline/ocean shapefiles are written to tiles invalid: scaling them onto the integer tile grid introduces self-intersections. Until now only SIMPLIFIED geometry was repaired, so those layers stayed invalid and can be mis-rendered by strict renderers - as missing water, or as land hidden behind an ocean polygon.

New layer option invalid_polygon_repair_scope: "simplified_only" (default, unchanged behaviour) repairs only invalid polygons that simplification may have broken; "all" is a superset and additionally repairs polygons that were merely clipped and quantised. For those, repair_multi_polygon runs with a strict area guard that also rejects any repair GROWING a polygon - growing means a hole was filled, which would make an island disappear. That bound must not apply to simplified geometry, where legitimate repairs do change area; applying it globally measurably made things worse (5 -> 20 invalid features in one tile).

The bound is passed as a scoped enum, AreaBound, rather than a bool: other changes also append parameters to repair_multi_polygon (systemed#939 a bool, systemed#949 a bool*), and with a bool here a merge that puts the parameters in the wrong order would still compile, silently.

Verified: with the default the output is unchanged (Bangladesh z7 96/55: 166 features, 5 invalid, 319 holes). With "all" on the ocean layers, that tile goes to 4 invalid / 318 holes and the ocean feature behind a reported rendering artefact becomes valid; Denmark z5 16/10 improves 5 -> 1 invalid while Funen correctly stays land; Philippines z5 26/14 improves 7 -> 1 invalid with no land wrongly covered by ocean.
geoneutrino added a commit to geoneutrino/tilemaker that referenced this pull request Nov 2, 2026
Large clipped polygons such as coastline/ocean shapefiles are written to tiles invalid: scaling them onto the integer tile grid introduces self-intersections. Until now only SIMPLIFIED geometry was repaired, so those layers stayed invalid and can be mis-rendered by strict renderers - as missing water, or as land hidden behind an ocean polygon.

New layer option invalid_polygon_repair_scope: "simplified_only" (default, unchanged behaviour) repairs only invalid polygons that simplification may have broken; "all" is a superset and additionally repairs polygons that were merely clipped and quantised. For those, repair_multi_polygon runs with a strict area guard that also rejects any repair GROWING a polygon - growing means a hole was filled, which would make an island disappear. That bound must not apply to simplified geometry, where legitimate repairs do change area; applying it globally measurably made things worse (5 -> 20 invalid features in one tile).

The bound is passed as a scoped enum, AreaBound, rather than a bool: other changes also append parameters to repair_multi_polygon (systemed#939 a bool, systemed#949 a bool*), and with a bool here a merge that puts the parameters in the wrong order would still compile, silently.

Verified: with the default the output is unchanged (Bangladesh z7 96/55: 166 features, 5 invalid, 319 holes). With "all" on the ocean layers, that tile goes to 4 invalid / 318 holes and the ocean feature behind a reported rendering artefact becomes valid; Denmark z5 16/10 improves 5 -> 1 invalid while Funen correctly stays land; Philippines z5 26/14 improves 7 -> 1 invalid with no land wrongly covered by ocean.
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.

3 participants