Partition, compose and view-build performance, plus the Android cross-build - #36
Merged
Conversation
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.
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.
Partition build
plane: the subtraction sweep runs across cells in parallel, claiming worklongest-chain-first so one fat cell cannot tail the whole build.
plane: coverage is hoisted into a build-wideCoverageIndexinstead of beingrecomputed 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 ratherthan process globals, so a retry is a property of the geometry and not of
whatever ran before it.
boolean:addOperandandconnectEdgesshare one sweep scratch arena(
retain_with_limit) instead of mapping and unmapping their own per call, andtheir 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 thatgained 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 silentfall back to a full rebuild is now visible.
partition:TILE57_PARTITION_STATSbuild profiler.compose-tile:--save-partitiondumps the resident partition.Serving a composed tile
compose:maxZoomAtindexes cells by descending reach and rejects by aper-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 insteadof the full face.
compose: coarser takers are enumerated before the fill residual is computed.compose: the renderer is handed decoded layers, and clipping happens onlywhere the boundary actually is.
Building a view
compose: each face's lon/lat bbox is computed once perBandMap. It wasre-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:assemblereserves exact totals before concatenating. Its workinglists 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, andthey were done one after another on a single core.
Rendering correctness
render: the safety-contour snap now only ever goes deeper. The olddeepest-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=0depth areasnapped 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-ndkcross-compileslibtile57.a, with the NDK host-toolchaindirectory auto-detected and probed OS-default-first.
capialso takes onestd.Iofor 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.Readerdecodes its directorieslazily 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_WORKERSoverrides.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.