Skip to content

Memory-map snapshots (MAPSNAP2) with a stored Hilbert R-tree - #2

Merged
kensac merged 4 commits into
mainfrom
feat/mmap-snapshot
Nov 1, 2026
Merged

kensac merged 4 commits into
mainfrom
feat/mmap-snapshot

Conversation

@kensac

@kensac kensac commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

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 'static lifetime. Sections are cast with bytemuck::try_cast_slice.

  • What opening checks:

    • the header, and every section's bounds and alignment;
    • the kind-count total;
    • ring starts are in order and end at the last point (the whole section is read);
    • the index is exactly the shape the builder writes: every internal node's child id equals its computed value, and every leaf id is below the feature count (the whole id section is read).

    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::ring returns an empty ring when out of range, and the new Map::first_point falls back to the bbox corner. A damaged feature draws nothing.

  • Panic containment: renders run under catch_unwind (server::contained, used by /tiles, /geo and /v1/static), so a panic fails that request. The rayon pool also has a panic_handler, so no pool panic aborts the process.

  • Zero-copy storage (src/store.rs): Store<T> is Owned(Vec<T>) (pbf build, v1 load) or Mapped(&'static [T]), and derefs to [T], so call sites stay as they were.

  • Fixed records: Feature is repr(C) Pod with the exact v1 56-byte layout. kind is a private u8 read through Feature::kind(), which is a checked conversion. Details are stored as DetailRec (the v1 24-byte layout), and roof/across are decoded by DetailRec::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 below floor(zoom) skip the vis_zoom check, which cannot fail for them, so queries touch fewer feature pages. rstar stays for place_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 build writes 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/kinds no longer scans every feature (284 ms down to 0.4 ms on US Northeast).

  • Metrics: maps_geo_cache_entries and maps_geo_cache_bytes, next to maps_cache_*.

  • --cache-mb stays 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 give query() 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 NaN vis_zoom features.
  • 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).
    • It loads with the exact counts the old binary's maps info printed.
    • It converts to identical arrays, and queries match rstar.
    • This branch's v1 writer, run on the same extract, reproduces the old binary's file byte for byte.
  • damaged_files_are_errors: each of these is refused at open, with the expected reason:
    • truncated file, header-only file, misaligned section, unknown kind byte (v1);
    • leaf id past the features;
    • internal-node id past the end;
    • internal node pointing at itself (this used to hang 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.
  • Each test's temp files are removed when it ends.
  • real_extract_v1_and_v2_agree (needs MAPS_TEST_PBF, skipped in CI): builds a real extract, then loads it as v1 and as converted v2. Result: 20,000 random query()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_rollup and cgroup v2 memory.stat; this binary was built without LTO):

v1 on bb62e44 (prod today) v1 on this branch v2, --memory 6g v2, --memory 2g
Time to /healthz 35-67 s (prod) 11.5 s 0.4 s 0.2 s
Anonymous after load 5.2-6.1 GiB (prod) 4.7 GiB 8 MiB 8 MiB
After 180 tile requests: anonymous / file-backed Rss 4.3 GiB / 0 134 MiB / 1.47 GiB 120 MiB / 1.46 GiB
cgroup file pages (reclaimable) 3.3 GiB 1.9 GiB 1.9 GiB, at the limit and reclaiming
Geo tile mean / p95: first touch (cold) 44 / 98 ms 72 / 208 ms 136 / 468 ms
Geo tile mean / p95: same data, new cache key (warm) 38 / 69 ms 36 / 57 ms 53 / 128 ms

macOS (M-series, 10 cores, LTO release build; memory is vmmap physical footprint, which excludes clean file pages; the file was still in the OS cache):

v1 on bb62e44 v1 on this branch v2
Time to /healthz 5.0 s 3.0 s 0.02 s (0.04 s with the open-time checks)
Footprint after load 5.2 GB 4.7 GB 3.5 MB (48 KB of the file resident)
Footprint / file resident after 300 requests 5.3 GB 4.2 GB 137 MB / 847 MB
Geo tile mean / p95 / max, cold 27 / 61 / 91 ms 26 / 51 / 77 ms 84 / 290 / 1142 ms
Geo tile mean / p95, warm 25 / 48 ms 26 / 44 ms 24 / 41 ms
PNG tile mean / p95, cold 34 / 69 ms 33 / 65 ms 45 / 122 ms
PNG tile mean / p95, warm 34 / 66 ms 33 / 65 ms 34 / 71 ms
Cache hit 0.4 ms 0.4 ms 0.4 ms
/v1/kinds 284 ms 1.7 ms 0.4 ms

NYC 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

  • Kind counts are a file section (u64 per 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.kind became a private u8 with a kind() 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.
  • The v1 path also uses the packed tree, not rstar, so there is one query implementation. That is why v1 on this branch uses 4.7 GiB rather than 5.2+. rstar is the test reference.
  • Features with a NaN vis_zoom (never visible) are left out of the index, so skipping the zoom check for lower buckets stays exactly equivalent.

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.
@kensac
kensac merged commit ec588f2 into main Nov 1, 2026
2 checks passed
@kensac
kensac deleted the feat/mmap-snapshot branch November 1, 2026 16:03
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