Skip to content

Partition, compose and view-build performance, plus the Android cross-build - #36

Merged
beetlebugorg merged 29 commits into
mainfrom
feat/perf
Jul 29, 2026
Merged

Partition, compose and view-build performance, plus the Android cross-build#36
beetlebugorg merged 29 commits into
mainfrom
feat/perf

Conversation

@beetlebugorg

Copy link
Copy Markdown
Owner

Partition build

  • plane: the subtraction sweep runs across cells in parallel, claiming work
    longest-chain-first so one fat cell cannot tail the whole build.
  • plane: coverage is hoisted into a build-wide CoverageIndex instead of being
    recomputed per tier, diff subtrahends are enumerated through a uniform bbox
    grid, and faces are reused across tiers when the subtrahend list repeats.
  • boolean: the robust-retry decision comes from the current call's walk rather
    than process globals, so a retry is a property of the geometry and not of
    whatever ran before it.
  • boolean: addOperand and connectEdges share one sweep scratch arena
    (retain_with_limit) instead of mapping and unmapping their own per call, and
    their edge lists reserve from known bounds rather than doubling from empty on
    an arena. Kept for the syscall and memory behaviour, not as a speedup: the
    timing win did not survive repeat measurement on the device, which has roughly
    12% run-to-run variance on this workload.

Sidecar and diagnostics

  • partition: v4 sidecar carrying per-face input digests, so a library that
    gained or lost one chart adopts the faces that did not change instead of
    rebuilding the partition. Adding or removing a chart drops to sub-second.
  • capi: sidecar discovery, refresh and their failures are logged, so a silent
    fall back to a full rebuild is now visible.
  • partition: TILE57_PARTITION_STATS build profiler.
  • compose-tile: --save-partition dumps the resident partition.

Serving a composed tile

  • compose: maxZoomAt indexes cells by descending reach and rejects by a
    per-cell coverage bbox before the ray cast. On a device pinch it went from
    74.6% of the render thread's native work to 6.7%.
  • compose: features far from a boundary clip against a bbox stand-in instead
    of the full face.
  • compose: coarser takers are enumerated before the fill residual is computed.
  • compose: the renderer is handed decoded layers, and clipping happens only
    where the boundary actually is.

Building a view

  • compose: each face's lon/lat bbox is computed once per BandMap. It was
    re-derived by walking every point of every ring, once per tile per candidate
    face, on every build, and measured 3% of all native time on device.
  • render: assemble reserves exact totals before concatenating. Its working
    lists grew by doubling in an arena, where every doubling strands the buffer it
    copied out of; a 400k-vertex view abandoned its whole vertex stream a dozen
    times over, the largest single memcpy site in the device profile.
  • chart: a view's tiles are portrayed in parallel. They do not interact, and
    they were done one after another on a single core.

Rendering correctness

  • render: the safety-contour snap now only ever goes deeper. The old
    deepest-rung fallback could only fire when every rung on the ladder was
    shallower than the mariner's contour, so it always moved the shading split
    down and painted water in a safer shade than asked for. Combined with the
    ladder being scanned per tile, a tile holding only a drval1=0 depth area
    snapped safety to 0 and filled the whole tile in the wrong band, producing
    tile-shaped boxes of wrong-coloured water over open sounds.
  • render: fill-down symbols claim their space through the declutter pool.

Bake and tools

  • bake: a chart's fill-down zooms generalize to display resolution.
  • tools: attribute a tile's vertices by class and a scene's by count.
  • tools: render a whole baked library to png/pdf, not just one chart.

Android cross-build

-Dandroid-ndk cross-compiles libtile57.a, with the NDK host-toolchain
directory auto-detected and probed OS-default-first. capi also takes one
std.Io for the library instead of one per call.

Correctness and verification

Parallel tile portrayal keeps the cache and the paint order serial around the
fan-out: take cache hits, portray only the misses into their own slots, then
publish in tile order. Publishing in the original walk order is what keeps the
result identical to the one-thread build. Scene digests were verified equal at 1
and 4 workers, and z5 went from 1153-1236 ms to 669-672 ms over three
alternating rounds on the same view in one session. Device cold builds roughly
halved.

Two things had to become thread-safe. pmtiles.Reader decodes its directories
lazily and one cell commonly owns ground in several of a view's tiles, so two
workers reach the same reader; a lock now covers the directory walk only, and
the tile's gunzip runs outside it. The geometry cache stays the calling thread's
alone, so a worker that runs out of memory reports it and the serial phase
retries that tile with reclaim. Worker count leaves a core for the frame-critical
render thread and caps at 4, since each worker holds a whole tile's compose
working set; TILE57_COMPOSE_WORKERS overrides.

Behaviour change to note when reviewing

The safety-contour fix changes water shading on any chart whose contour ladder
holds nothing at or deeper than the mariner's safety contour: such water now
takes the mariner's own value and reads as unsafe, where before it took the
deepest available rung and could read as safe. Every performance commit here is
digest-preserving; this one is deliberately not.

std.Io.Threaded.init installs process-global SIGIO/SIGPIPE handlers and deinit
restores them, so standing one up per entry point cost two sigaction
round-trips and a getCpuCount on every call — tile57_chart_open runs once per
cell, thousands of times to open a library.

Worse, it made concurrent opens unsafe: two threads interleaving those
save/restores can capture each other's temporary handler as "old" and reinstall
it permanently. Sharing one instance, stood up on first use, removes both.
Threaded carries its own mutex for concurrent use.
The byte-comparison oracle for partition-build changes: save the sidecar
exactly as a bake write would, cmp against a golden capture.
compute() snapshotted the shared open_chain_walks counter and retried when
it moved — under concurrent computes another thread's dead end triggered
(or its reset masked) a retry here, so a parallel partition build produced
different bytes per run and could skip a needed retry entirely. Walk stats
are now per-call; the shared counters stay as diagnostics, published
atomically at the end of each compute. Serial output is byte-unchanged.
Coverage and bboxes are inputs computed before any face, and no iteration
reads another's result, so cells sweep in parallel with results placed by
index — byte-identical to the serial walk now that the boolean retry
decision is per-call. Workers claim from the coarse end first: a cell's
cost grows with how many finer cells overlap it, and longest-first stops
one worker from finishing the whole tail alone. TILE57_PARTITION_WORKERS
overrides the count so parallel==serial can be forced over one input.
Per-tier phase timings (coverage loop vs sweep vs whole call), the top
per-cell diff chains with subtrahend counts, and an index→name decoder
ring from compose. Aim optimization work at what the profile says: the
sweep's diff chains dominate; the serial coverage loop does not.
cellCoverage is tier-invariant, but each tier recomputed it for every
cell — serially. One parallel pass now computes every cell's coverage
and bbox keyed by global index; the per-tier walk maps order positions
onto it. ownedAtTierIndexed keeps its signature via a throwaway index.
Global-index-keyed coverage is also what makes a face a pure function
of its subtrahend index list, which the cross-tier reuse pass needs.
Output is byte-identical (golden sidecar cmp + suite).
Each cell tested every earlier cell's bbox inside the sweep — ~26M
comparisons per tier of pure waste. A 256×256 grid over the coverage
bboxes (whole-extent cells on a linear whale list) yields the same
ascending subtrahend lists output-sensitively, before the workers spawn.
The lists are asserted element-for-element equal to the all-pairs scan,
and having them up front is what the cross-tier reuse pass keys on.
Output byte-identical (golden cmp + suite).
A face is a pure function of the cell's own coverage (shared via the
CoverageIndex) and its ordered subtrahend list as GLOBAL indices — same
list at an earlier tier means the same diff chain, so ownedTiers dupes
the earlier face instead of recomputing it. 9.2k of 40k faces reuse on
the NOAA library, mostly gap-fillers at the coarse tiers; faces that
genuinely differ (a coastal cell whose harbor holes come and go with
the pool) correctly miss. TILE57_PARTITION_NO_REUSE=1 is the A/B
switch; a 120-trial fuzz asserts reuse-on == reuse-off byte-for-byte.
Golden sidecar unchanged.
The coarse-end heuristic approximated cost by position; the subtrahend
lists are computed up front now, so claim order can follow the actual
chain length. Pure scheduling — results land by index, bytes unchanged.
Staleness was all-or-nothing: one input_key over every cell, so a single
changed chart invalidated the whole sidecar and every open rebuilt ~40k
faces from scratch. v4 stamps each face with a digest of what its diff
chain read — its cell's content and every subtrahend's, in order — with
cells identified by (name, date) rather than the order rank, which
shifts when cells come and go. A loader adopts every face whose stamp
still matches and sweeps only the rest; dropped-empty fillers are
recorded too, so a clean reload adopts everything and sweeps nothing.
This also makes a one-pack sidecar seed a multi-pack union build — the
chart-packs merge case.

Adoption changes how a face is produced, never its bytes: incremental
== fresh is fuzz-asserted, and on the NOAA library removing or re-adding
a 348-chart cluster sweeps only its neighborhood (~200-1700 slots,
sub-second) with byte-identical output. A pre-v4 sidecar reads as
UnsupportedVersion and rebuilds once.
A library without a partition.tpart silently rebuilt the full partition
on every open — nothing in the log said why. Discovery now logs the
adopted path and size, non-ENOENT read failures, and whether the refresh
wrote or failed; a field device's per-open rebuild is one logcat line to
diagnose instead of a guessing game.
maxZoomAt walked every cell and ray-cast the point against every ring of
each one. A host that calls it per frame to cap the zoom paid for the whole
partition on every frame: 74% of the render thread's native work during an
Android pinch-zoom.

Cells are now indexed once beside the partition, ordered by descending
reach, so the first covering cell is the answer and the walk stops there;
a per-cell cov1 bbox rejects the rest without a ray cast.
A cold low-zoom compose spent 44% of its native time in allocation and
copying. Two causes, both avoidable:

addOperand and connectEdges each mapped and unmapped their own scratch
arena on every call, making munmap the second hottest libc frame in the
profile. They now share one arena owned by compute — their scratch
lifetimes do not overlap — reset with retain_with_limit so an ordinary
tile reuses the pages while a monster tile still hands its peak back.

Their edge lists also grew from empty a doubling at a time, stranding the
old buffer in the arena on every growth. Both bounds are known up front.

Output is unchanged: same scene digests before and after.
…face

Every polygon feature ran a full boolean sweep against the contributor's
whole projected face, re-sweeping the face's edges per feature; lines
rescanned every face edge per segment. Now composeSeamTile builds one
EdgeGrid over the face per contributor, and a feature whose bbox (grown by
FAR_TEST) meets no face edge clips against a 4-edge rect at bbox+FAR_RECT
instead — same subject edge set, zero subject/clip interaction either way,
so the bytes are identical. The one exception is a self-degenerate subject,
whose sweep resolution is path-dependent even with a far clip: the new
boolean.computeTracked reports whether the sweep stayed on its clean path
(no edge-pair interactions, first walk closed), and the few non-clean
features redo against the real face.

Byte-verified: 441 composed tiles (z4-z10, 9x7 blocks, Straits of Mackinac,
7217-chart library) identical before/after; 20k-seed degenerate-ring fuzz of
the stand-in substitution diverges 0 times with the fallback. The z4-z5
open-chain scan drops from 4 flagged tiles to 2 (both pre-existing). Cold
z4: the fat 2 MB tile serves in 508 ms vs 1009 ms, a 5x5 block in 47 vs 82
ms/tile (M-series mac).
The cross-band fill computed the bare-ground residual first — a chain of
per-contributor diff sweeps, hundreds on a fat coarse tile — and only then
walked the coarser bands to discover whether anyone could take it. At the
coarsest populated band (every Great Lakes tile at z4) there are no takers
and the whole chain was discovery cost for an empty answer. Now the same
face-bbox + deep-tile filters run FIRST, none of the geometry, and no
candidates skips the block; with candidates everything runs unchanged, so
the output is identical by construction.

Byte-verified: the same 441-tile z4-z10 corpus is identical. The fat z4
tile serves in 293 ms vs 508 (652 before the bbox stand-in), the cold 5x5
z4 block in 42 vs 47 ms/tile (M-series mac).
The low-zoom cost question is "where do the vertices live", and neither tool
could answer it: tiledump counted features, gpudbg counted quads. tiledump
--verts now buckets every feature by (layer, class) and reports features,
rings/parts and VERTICES sorted by vertex count, plus how many polygon rings
fall under one display pixel squared — the rings that tessellate to triangles
nobody can see. The property histogram takes numeric values too, so --prop
scamin reads a tile's scale gating instead of silently skipping it. gpudbg
leads with the scene's vertex/index/range totals, the numbers a scene-build
cost is actually proportional to.

Measured with them, the Straits of Mackinac z4 composed tile (7217-chart
library, 45.60 N 83.10 W): 93,336 tile vertices, of which areas/DEPARE holds
46,072 across 5,945 rings and 5,360 of those rings (90%) are under one display
px^2; LNDARE 9,741 over 1,416 rings, 85% sub-pixel; the M_QUAL pattern 13,462
over 2,307. The GPU scene there is 427,423 verts / 271,682 tris / 109,044
quads. Not one feature in that tile carries a SCAMIN property — the sub-band
SCAMIN cull has already taken every gated feature, so what remains is
SCAMIN-less geometry.
A chart bakes tiles for every zoom below its band window, and down there it is
drawn at a scale it was never compiled for: a Great Lakes harbour coastline
digitized for 1:20,000 keeps every half-pixel wiggle at 1:35,000,000, and the
depth areas arrive as thousands of slivers that tessellate to triangles under a
pixel. Measured on the z4 composed tile at 45.60 N 83.10 W: 5,945 DEPARE rings
of which 90% enclose less than one display px^2 (75% less than a quarter of
one), 1,416 LNDARE rings with 85% the same. That geometry costs a decode, a
tessellation and a GPU upload each to draw nothing.

tile.Detail now says how hard a tile is generalized, and appendCellFeatures
picks it per cell from the cell's own compilation scale: inside the chart's band
window, Detail.native — Go's ½-display-px Douglas-Peucker and every ring kept,
byte for byte what the baker has always emitted. Below it, Detail.fill_down —
one display px of DP and a 2 display px^2 floor on polygon rings. Both knobs are
integer extent units against the 512 CSS-px tile; the native pixel surfaces draw
256 px/tile, so the floor is worth a quarter of that display area there and errs
toward keeping geometry. The ring cull is monotone in |area| and a hole is
always smaller than the ring containing it, so dropping an outer ring drops its
holes and no orphan hole can be re-wound into a spurious fill. Simplification
still runs after the clip, so tile-edge runs stay on the tile edge and seams
stay crack-free at the coarser tolerance.

The scope is per chart, not per zoom: a chart whose band window opens at z9
generalizes its z7 tiles too, and an overview chart (window from z0) is never
generalized at all. Verified by rebaking 1,604 Great Lakes charts twice —
US2GRLBC (1:700,000, window from z7) composes byte-identical at z7/z8/z9 and
changes only at z4-z6.

Measured over that pair of libraries, 45.60 N 83.10 W:

  composed tile        z4                z5                z6
  bytes           2113546 -> 1813071  1233846 -> 1064203  352108 -> 331084
  tile verts        94103 -> 36792      74093 -> 29563     17616 -> 10267
  DEPARE verts      46072 -> 8090
  LNDARE verts       9741 -> 2019
  scene verts       96952 -> 37508     140842 -> 66360    172952 -> 100289
  scene tris        71894 -> 24731     103654 -> 45086    121920 -> 66990
  scene ranges       1363 -> 225         1913 -> 464       2349 -> 796
  ms/tile (3x3)      38.4 -> 16.7        47.1 -> 20.6       51.2 -> 22.8

Draw calls fall 6.1x at z4, triangles 2.9x, vertices 2.6x. The residue is point
symbols (8,974 of them, out of scope here) and one-line-per-edge COALNE/SLCONS
features, neither of which a ring floor reaches. Bytes move least because a
tile's size is dominated by the per-feature pick attributes, not coordinates.

Visually: 1024x768 day renders over the test location differ in 0.15% of pixels
at z4, 0.72% at z5, 0.99% at z6, with no pixel past a 128/255 delta at z4 and
seven at z6 — an amplified diff shows isolated specks along coastlines, no
shifted band anywhere. z8 differs in five pixels (a 1:500,000 chart is
fill-down there too). Depth areas and contours around the Straits are
indistinguishable. compose-tile --scan 4..6 flags 0 open chains either way.

A separate SCAMIN gate for these tiles turned out to be unnecessary: the
sub-band SCAMIN cull already takes every gated feature, and the z4 tile has no
feature carrying a SCAMIN property at all.
`tile57 png <dir>` answered "ENC_ROOT live rendering removed" for every
directory, so the only way to see what a baked library actually looks like was
one chart at a time — no view of the composite, which is the thing that ships.
chart.renderComposeView has served that view since the compositor landed; it
just had no CLI. A directory holding *.pmtiles now opens the compositor over
every archive under it and renders the view through the same pixel path, so
`png` and `pdf` cover all three sources a chart can come from: a source cell, a
single baked archive, a baked library. A directory with no archives keeps the
"bake first" hint.

This is the visual gate for any change to baked tile content: render the same
view over two libraries and flip between them.
…tion

The tile schema explains a chart's own band but not the zooms it bakes below
it, which is where a fill-down tile's content now differs from its source — say
so where a reader asks what is in a tile. The CLI page gains the two ways to
answer that question: `png`/`pdf` over a baked library directory (the composite,
not one chart), and `tiledump --verts` / `--prop KEY`.
…undary is

The compositor and the renderer spoke through the wire format inside one
process: composeTile built decoded, clipped features, encoded them to MLT,
and renderComposeTileGpuScene decoded those bytes right back before
portraying — about a fifth of a cold z4 scene build spent converting
between representations it already had. composeTileContent now returns the
layers themselves for the scene and query paths (verbatim single-owner
blobs stay bytes; the C ABI tile contract is unchanged), and the encode
happens only where bytes actually leave the process.

The clip also stops doing needless geometry:
- A feature whose grown bbox meets no face edge is decided by one parity
  test: outside drops, inside passes through UNTOUCHED — clipping a
  contained feature to its face is the identity; the sweep it used to run
  there was only a canonicalizer.
- A boundary-near polygon sweeps against the face rect-clipped to its bbox
  (poly inside rect makes poly ∩ (face ∩ rect) = poly ∩ face): dozens of
  face edges instead of hundreds.
- Boundary-near lines split and parity-test through the face's EdgeGrid
  (plane.clipLineInsidePolysGrid): per-segment cost is the geometry NEAR
  the segment, never the whole coverage. The grid ray walk counts each
  crossing exactly once — in the one bucket column containing its x — and
  is property-tested equal to the brute scan.
- The cross-band fill's per-contributor diff chain collapses to one
  governing-band diff plus one per coarser band (disjoint faces: their
  concatenation is already the even-odd union).
- SplitPt ordering is total now (key, then point): near-tangent crossings
  share a projection key, and the key-only sort interleaved duplicately
  gathered points instead of letting the dedup collapse them.

boolean.computeTracked is reverted; its only caller was the stand-in-rect
path this replaces.

Composed bytes change (ring canonicalization, snap-scale fill seams): day
renders at z4/z5/z6 over the Straits differ in 23/12/57 of 786k pixels
(max 0.008%); scene quads/ranges identical, verts +89 from pass-through
duplicate vertices. Cold Mac scene builds (888x1488, the generalized
library): z4 1015 -> 910 ms, z5 515 -> 364, z6 393 -> 277, z7 410 -> 291,
z8 159 -> 117. The open-chain scan stays at the 2 known tiles. tri=
digests re-baseline.
At overview zooms the drawn scene carried every point-symbol sprite of every
overscaled harbour chart — 107k quads at z4 on the target device, ~80% of
them symbols, vertex-shaded by a Mali-G52 every frame and held in every
cached scene. S-52's no-suppression rule for symbols is scoped to content
displayed within its usage band's window; below it — ground the spec would
never put that symbol on — unmanaged overplot buries the chart.

A non-base point symbol displayed below its band's window now becomes a
collision candidate instead of unconditional geometry: symbols compete ONLY
with each other in their own dc.Pool (never with or against text — the
declutter header's model is unchanged at chart scale), ranked by S-52
display priority then emission order, resolved with repeat 0 so only true
overlap suppresses. An uncontested symbol always draws; display base never
enters the pool; soundings are untouched; native-window zooms are untouched.
Kept sprites coalesce into one draw range per paint/atlas run so pooling
cannot multiply draw calls. FeatureMeta.band gains a BAND_UNKNOWN sentinel —
a feature carrying no band (foreign tile, test surface) never counts as
fill-down; 0 is a real band (berthing) and must not double as the default.

Mac defaults, cold scenes: z4 quads 39,876 -> 31,572, z8 22,236 -> 16,962,
build time unchanged (the win is per-frame vertex load and scene memory,
not build latency — measured before building: portrayal of points/text/
soundings is ~10% of a cold z4 build). Scene verts identical; ranges within
+6 of the pre-pool count after coalescing.
The deepest-rung fallback could only fire when every rung on the ladder was
shallower than the mariner's contour — if any were deeper, the next-deeper
snap would have taken it — so it always moved the split DOWN and shaded water
as safer than asked for.

The ladder is scanned per tile, which made that misfire constantly: a tile
holding only a drval1=0 depth area, its contours all in the neighbouring
tiles, snapped safety to 0 and painted every fill DEPMD — a tile-shaped box
of wrong shade against its neighbours, all over Pamlico Sound.

The per-tile scope is still wrong; S-52 means the ladder per dataset.
faceTileBBox walked every point of every ring and ran two libm projections to
derive a cull box — once per tile, per candidate face, on every build. At 35
tiles a view against every face of every coarser map that re-walk was 3% of all
native time on device, and it recomputed the same answer every time: the rings
never change.

Compute it once per BandMap, beside the faces it describes.
assemble's working lists grew by doubling from empty, and they grow in an
ARENA — every doubling strands the buffer it copied out of. A 400k-vertex view
re-copied and abandoned its whole vertex stream a dozen times over, which made
this the largest single memcpy site in the device profile.

Every size is known from the scenes before the first append.
A coarse view portrays 28-35 tiles, they do not interact, and it did them one
after another on a single core — the whole cost of a zoom-out (885/1101/1442 ms
for z7/z6/z4 on a Tab M9, which has eight).

The misses now fan out. The cache and the paint order stay serial around the
fan-out: walk ViewTiles and take the hits, portray only the misses into their
own slots, then publish in TILE order. Publishing in the original walk order is
what keeps the scene digest identical to the one-thread build — verified equal
at 1 and 4 workers, and z5 goes 1153-1236 ms to 669-672 ms over three
alternating rounds.

Two things had to become thread-safe on the way:

- pmtiles.Reader decodes its directories lazily, and one cell commonly owns
  ground in several of a view's tiles, so two workers reach the same reader and
  raced on the leaf map and its arena. A lock now covers the directory walk
  only; the bytes it yields point into the read-only mmap and the tile's gunzip
  runs outside it.
- The geometry cache stays the calling thread's alone. A worker that runs out
  of memory cannot reclaim from it, so it reports the failure and the serial
  phase retries that tile with reclaim.

Worker count leaves a core for the frame-critical render thread and caps at 4:
each worker holds a whole tile's compose working set, and this workload has had
the app lmkd-killed before. TILE57_COMPOSE_WORKERS overrides.
plane.zig reads its partition tuning and stats valves through std.c.getenv,
so the geometry test no longer builds without libc — the same reason the
compose test binaries link it. The shipped library already does.
pmtiles.zig's reader lock drops to raw pthread externs on POSIX, so the
tiles test needs libc for the same reason geometry's and compose's do.

Cross-compiling the whole test step to x86_64-linux compiles every test
binary clean, which is the only way to catch this class here: macOS always
links libSystem, so a missing link_libc never fails on the dev machine.
@beetlebugorg
beetlebugorg merged commit 92a0d40 into main Jul 29, 2026
5 checks passed
@beetlebugorg
beetlebugorg deleted the feat/perf branch July 29, 2026 01:16
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