Skip to content

Tile baking performance improvements#17

Merged
beetlebugorg merged 7 commits into
mainfrom
perf/bake-portrayal
Jun 25, 2026
Merged

Tile baking performance improvements#17
beetlebugorg merged 7 commits into
mainfrom
perf/bake-portrayal

Conversation

@beetlebugorg

Copy link
Copy Markdown
Owner

No description provided.

beetlebugorg and others added 7 commits June 25, 2026 18:02
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>
@beetlebugorg beetlebugorg merged commit 4383b4e into main Jun 25, 2026
4 checks passed
@beetlebugorg beetlebugorg deleted the perf/bake-portrayal branch June 25, 2026 23:55
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