Repository navigation
Memory-map snapshots (MAPSNAP2) with a stored Hilbert R-tree - #2
Merged
Merged
Conversation
Snapshots are now mapped from disk and used in place: arrays are 64-byte aligned and borrowed from the file, features and roof details are fixed repr(C) records whose enum bytes are read through checked accessors, and the spatial index is a packed Hilbert R-tree per zoom bucket stored in the file. Kind counts are stored too, so /v1/kinds no longer scans every feature. MAPSNAP1 is still read (onto the heap, index rebuilt), and `maps convert` turns one into the other without a rebuild. /metrics reports the geometry tile cache size.
Opening a v2 snapshot checks that the index is exactly the shape the builder writes, with every item id in range, and that ring starts are in order and in bounds. Feature records are not scanned (1.4 GB for US Northeast); their ring, detail, elevation and group indices are checked where they are used. Render panics fail their own request, and the rayon pool has a panic handler. write() removes the partial file on failure and syncs the directory after the rename. The v1 loader unmaps its input before building the index. Tests: damaged index and ring starts are refused at open, damaged feature indices render without panicking, NaN zooms are left out of the index, temp dirs are removed, and a v1 snapshot written by bb62e44 (25 KB, with its 18 KB extract) reads, converts and is reproduced byte for byte.
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.
Snapshots are memory-mapped and used in place, so the server holds only the pages tiles touch instead of copying 5-6 GB into the heap.
What changes
MAPSNAP2 (
src/snapshot.rs): fixed header plus 64-byte-aligned sections (points, ring starts, features, details, elevations, groups, per-kind counts, spatial index). Mapped with memmap2 and leaked for a'staticlifetime. Sections are cast withbytemuck::try_cast_slice.What opening checks:
For US Northeast that is about 200 MB of reads, 40 ms from the page cache (3 MB footprint). A damaged index can therefore no longer panic or loop at query time.
What opening does not check: the 1.4 GB of feature records are not scanned. Kind and roof bytes go through checked accessors. Ring, detail, elevation and group indices are bounds-checked where they are used:
Map::ringreturns an empty ring when out of range, and the newMap::first_pointfalls back to the bbox corner. A damaged feature draws nothing.Panic containment: renders run under
catch_unwind(server::contained, used by/tiles,/geoand/v1/static), so a panic fails that request. The rayon pool also has apanic_handler, so no pool panic aborts the process.Zero-copy storage (
src/store.rs):Store<T>isOwned(Vec<T>)(pbf build, v1 load) orMapped(&'static [T]), and derefs to[T], so call sites stay as they were.Fixed records:
Featureisrepr(C)Pod with the exact v1 56-byte layout.kindis a privateu8read throughFeature::kind(), which is a checked conversion. Details are stored asDetailRec(the v1 24-byte layout), androof/acrossare decoded byDetailRec::get(). No invalid enum or bool is ever materialised. Writers check every kind and roof byte, so the fallback (Land / Flat) only matters for a damaged file.Persisted index (
src/index.rs): a packed Hilbert R-tree per zoom bucket in the Flatbush style (node size 16, f32 boxes, u32 ids), 21 B/feature against rstar's 49.7.query()keeps its signature and its sorted output. Buckets belowfloor(zoom)skip thevis_zoomcheck, which cannot fail for them, so queries touch fewer feature pages. rstar stays forplace_structures(build only) and as the test reference.MAPSNAP1 still loads: it is copied to the heap, the input is unmapped, then the index is rebuilt. A new image that reaches prod before the conversion keeps serving.
maps convert <v1> <v2>: copies the arrays straight from the mapped v1 file and builds only the index and kind counts.maps buildwrites v2. Both write<out>.partial, fsync it, rename it into place, then fsync the directory. The partial file is removed on any error.Kind counts are stored in the file, so
stats()//v1/kindsno longer scans every feature (284 ms down to 0.4 ms on US Northeast).Metrics:
maps_geo_cache_entriesandmaps_geo_cache_bytes, next tomaps_cache_*.--cache-mbstays at 1024. After 180-300 tile requests the caches held about 6 MB of raster and 12 MB of geo tiles, which gives nothing to tune against.Tests
cargo fmt --check,cargo clippy --all-targets -D warnings,cargo test: 55 tests pass.index::search_matches_brute_force: the packed tree against brute force over random boxes, including zero-size boxes and sizes around the node size.snapshot::convert_matches_save_and_reads_back: a synthetic map is saved directly as v2 and also as v1 then converted, and the two files are byte-identical. v1-loaded, v2-mapped and in-memory maps givequery()results equal to an rstar reference (the old index code) over 2000 random boxes at whole, fractional, negative and >20 zooms. The synthetic map includes NaNvis_zoomfeatures.old_v1_snapshot_reads_and_converts: uses a committed v1 snapshot written by bb62e44 (tests/fixtures/times-square.v1.snap, 25 KB) and its extract (18 KB, a Midtown block; ODbL).maps infoprinted.damaged_files_are_errors: each of these is refused at open, with the expected reason:query());ring_starts[1] = u32::MAX.damaged_features_render_without_panicking: every feature's ring, detail, elevation and group index is set out of range. Raster tiles (flat and extruded, pitch 0 and 45) and geometry tiles then render without a panic.server::panics_become_errors.real_extract_v1_and_v2_agree(needsMAPS_TEST_PBF, skipped in CI): builds a real extract, then loads it as v1 and as converted v2. Result: 20,000 randomquery()s match rstar, and 240 raster PNGs (light/dark × flat/extruded, z10-18, all with features) plus 120 geometry tiles are byte-identical between v1 and v2. Passed on jfk (60 k features) and city (373 k features), and re-run on city after these changes.Measurements
US Northeast, 25.3 M features (the prod extract). The base binary is bb62e44.
Linux (Docker Desktop VM, snapshot on a virtiofs bind mount,
/proc/1/smaps_rollupand cgroup v2memory.stat; this binary was built without LTO):--memory 6g--memory 2gmacOS (M-series, 10 cores, LTO release build; memory is
vmmapphysical footprint, which excludes clean file pages; the file was still in the OS cache):/v1/kindsNYC metro (3.9 M features, macOS): footprint after load went from 824 MB to 3.5 MB, and to 69 MB after 300 requests. Tile latency was unchanged.
v1 load peak (
/usr/bin/time -l maps info, macOS peak footprint), US Northeast: bb62e44 5.65 GB; this branch 5.32 GB (4.13 GB after loading).Conversion (
maps convert), US Northeast: 3.5 s, 1.31 GB peak footprint. Output 4.33 GB (v1: 3.79 GB; the index adds 0.54 GB).Tradeoff: the first request to touch a region pays page faults. Features are stored in draw order, not spatially, so 180 tiles across five metros touched about 1.5-1.9 GB of the file. Warm latency matches v1. Without a page cache, the first tiles after a cold boot will be slower than today's in-heap renders.
Deviations from the plan
u64per kind) that the header points to, rather than inline header fields. The kind list can grow without a header change, and a file with more kinds than the binary knows is refused.Feature.kindbecame a privateu8with akind()accessor (mechanical.kind→.kind()across render/vtile/api), instead of an enum field checked by a full scan on open. Validating 1.4 GB of features at startup would read the whole section into memory and defeat on-demand loading.vis_zoom(never visible) are left out of the index, so skipping the zoom check for lower buckets stays exactly equivalent.