Tile baking performance improvements#17
Merged
Merged
Conversation
The S-101 portrayer ran the whole cell through the rule engine three times per cell — the default pass plus the PlainBoundaries and SimplifiedSymbols variant passes — but Passes only ever reads the plain variant for polygons and the simplified variant for non-SOUNDG points. Lines and soundings were portrayed three times and their variant builds discarded. Add BuildBatchFiltered, which portrays only the features a predicate selects while still deriving cross-feature context (danger depths, co-located topmarks) from the full feature set, and drive the two override passes with geometry-type predicates that mirror Passes' consumption exactly. Output is unchanged; lines and soundings are now portrayed once. On the golden cell US4MD81M.000 the build/portrayal phase drops ~8.7s→~4.7s (~1.85x), allocations 42.4M→23.7M (-44%), bytes 6.40GB→3.24GB (-49%). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NewEngineFS re-parsed and re-compiled the whole S-101 framework (and every per-class rule file) from Lua source on every engine construction — three times per cell, plus once per cell again for each distinct rule file. Add a ProtoCache that memoizes *lua.FunctionProto by require name; the builder owns one cache and shares it across every engine it creates, so the framework compiles once per bake instead of ~3x per cell. Each engine still gets a fresh LState (per-cell catalogue caches are freed on Close); only the immutable compiled prototypes are shared. NewFunctionFromProto is an exact drop-in for Load here — top-level chunks have no upvalues and bind globals via ls.Env. Measurement reset expectations: profiling shows compilation is NOT the bottleneck. On an 8-cell district the saving is only ~1-2% time / ~2% allocs, because the dominant cost is per-feature rule *execution* and the Lua-table allocation it drives (gopher-lua RawSetString is 68% of bytes allocated), not chunk compilation. Kept because it's correct, low-risk, and helps the server's live/incremental portrayal and many-small-cell districts more than one big cell. Adds BenchmarkBuildBakerGolden to cover the build/portrayal phase that BenchmarkBakeGoldenParallel skips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildFeature ran textAnchor unconditionally for every feature, and for areas that means areaLabelPoint — the Mapbox polylabel pole-of-inaccessibility search, which scans every edge of every ring per candidate cell. CPU profiling the build/portrayal phase put it at ~21% of total, the single biggest cost, even though most area features (depth areas, land, sea areas) emit only fills and boundary lines that never read the anchor — and areas now run it twice (default + plain-boundary passes). Reduce the draw commands first, then compute the anchor only when a command actually consumes it (point symbols, text, or sector/augmented figures, per commandsNeedAnchor — the anchored ops emitPrimitives reads geom.Anchor for). The centred-area-symbol placement only fires when a SymbolCall exists, i.e. when an OpPoint was emitted and the anchor was computed, so it's unaffected. Golden archive is byte-identical (sha256 3a9566e0…, 3221767 bytes, verified before/after). Build/portrayal phase on the golden cell drops ~4.8s→~3.7s (~22%). Cumulative over this branch the phase is ~8.7s→~3.6s (~2.4x), allocations 42.4M→22.3M (-47%), bytes 6.40GB→3.12GB (-51%). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Profiling established the bake's dominant phase (BuildBaker) is single-threaded: cells were parsed and portrayed in a serial loop, so a district used ~one core for the expensive S-101 rule engine while GC ran on the rest (a CPU profile looked ~50% GC-bound, but that was mark workers on idle cores — GOMAXPROCS=1 floored the phase at ~6-8s with no overlap, =8 at ~4s). Per-cell parse and portrayal are independent and pure; only the route/merge into the Baker is stateful and order-dependent. Split portrayal from routing: s101Portrayer gains a pure, concurrency-safe portray() (returns the three pass maps without touching active state) plus install(); Begin = install(portray()). Baker exposes PortrayCell (parallel-safe, mutates nothing) and AddCellPortrayed (the existing serial route, replaying a precomputed portrayal). BuildBaker / BuildBakerWithUpdates now drive a bounded ordered pipeline (addCellsParallel): NumCPU workers parse+portray ahead while the main goroutine routes in sorted cell order. The entire stateful merge stays serial and ordered, so output is byte-for-byte identical. Verified: golden archive sha256 unchanged (3a9566e0…, single cell == serial); multi-cell builds deterministic run-to-run (new TestMultiCellParallelDeterministic); clean under -race across baker/bake/portrayal. 8-cell district build ~25.1s→~10.0s (~2.5x on 8 cores) with identical allocations; scales with district size. Memory: at most ~NumCPU cells are resident at once (the trade for the speedup). The per-band streaming bake remains the path for memory-bounded huge districts. This also makes the next lever — cutting the gopher-lua per-feature table churn (~70% of allocations) — pay off in wall-clock, since GC now contends for cores. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dominant remaining bake cost is the gopher-lua rule execution — not its raw interpreter cycles (~7% of CPU) but the per-operation LTable/LValue heap allocation its design forces (LTable.RawSetString is ~70% of all bytes allocated, driving ~45% of CPU into GC). We can't cut that without abandoning cgo-free (no LuaJIT) or editing the vendored IHO catalogue Lua. So instead skip the rule entirely for duplicate inputs. The rule produces a geometry-INDEPENDENT instruction stream — buildFeature attaches each feature's own geometry afterward — so two features with identical inputs (class, primitive, simple + derived + topmark attributes, multipoint vertices) portray identically. Derived (location-aware danger depth) and Topmark are in the key, so depth/context-dependent features never wrongly merge. BuildBatchFiltered now dedupes the batch by portrayalSignature before eng.Portray and shares each representative's stream across its duplicates. ENC cells repeat inputs heavily: the golden cell has 7334 features but only 1530 distinct signatures — 79% of rule runs skipped. Output is byte-identical (golden sha256 3a9566e0…, verified) and the bake content tests + portrayal -race pass. Single-cell build/portrayal phase ~3.6s→~2.28s (~37%), allocations 22.3M→14.8M (-34%), bytes 3.12GB→1.82GB (-42%) — so it cuts memory as well as time. Cumulative over the branch the phase is ~8.7s→~2.28s (~3.8x), allocations -65%, bytes -72%, plus ~2.5x from parallelism on multi-cell districts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The portrayal wins reach every bake path (they live in the portrayer, behind AddCell), but the parallel parse+portray only covered the merged BuildBaker path. The UI import bake (POST /api/import) and `chartplotter bake --bands` use BakeToPMTilesBandsStreaming, whose two passes routed cells in serial loops — so they stayed single-threaded on the dominant cost. Extract the bounded ordered pipeline into a generic parseInOrder (parse + parallel-safe precompute in the worker; serial, ordered consume on the caller), and build addCellsParallel on it. The streaming bake now uses it for both passes: pass 1 parses in parallel and merges coverage serially; pass 2 (per band) parses+portrays in parallel via addCellsParallel and routes serially. The route/merge stays serial and ordered, so every band archive is byte-identical. Verified: streaming archives byte-identical to the serial path (combined sha256 cef08076…, 4 cells across both passes); deterministic run-to-run (new TestStreamingBakeDeterministic); clean under -race; server import tests pass. 8-cell streaming bake ~26.4s→~15.3s (~1.7x — lower than the merged path's 2.5x because the streaming path parses each cell twice and pass 1 is parse-only). Peak memory rises to ~NumCPU cells in flight per band (the chosen speed/memory trade); the per-band working set still bounds total residency far below the merged path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The streaming bake parses every cell twice — pass 1 to build the global covMeta (best-available suppression + scale boundaries need coverage for ALL bands before any band emits, since a band's suppression looks at finer cells and its scale boundaries look at coarser ones — no single processing order satisfies both, so the global pre-pass can't be folded into the per-band loop). But pass 1 only ever reads M_COVR (extractCoverage ignores everything else), so its full parse built the geometry of thousands of features it never looks at. Add ParseCellCoverage — ParseCellWithUpdates with ObjectClassFilter=["M_COVR"], which the parser applies BEFORE geometry construction, so non-coverage features are skipped entirely (the band still comes from the header scale; updates still apply). Use it for pass 1. Byte-identical (the coverage pass consumed only M_COVR already). On the 8-cell streaming bake: allocations 173M→135M (-22%, ~38M fewer from the skipped geometry construction), bytes 20.6GB→18.8GB (-9%), ~5% faster. The second pass remains the one real full parse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.