Map Tile Serving & Distribution for AV Fleets

A validated HD map is an artefact on a build server; a served HD map is a system with a latency budget, a cache policy and a failure mode when the link drops. This stage sits at the end of the HD mapping architecture and spatial data standards pipeline, immediately after HD map version control signs a release, and its constraints come from the vehicle rather than from the map: a tile must be fetched, decoded and stitched before the planner reaches the area it describes, the resident set must stay inside a hard memory ceiling, and a vehicle that cannot reach the server must degrade to the last known-good map rather than to no map.

The naive design — one large regional file, downloaded at depot — fails on all three counts. It cannot deliver a lane closure surveyed this morning, it holds far more of the map resident than the planner will ever consult, and the first time the vehicle drives outside the downloaded region it has nothing at all.

The serving path, drawn from the signed release down to the tile the planner reads:

From a Signed Release to the Tile the Planner Reads A five-stage distribution pipeline — cut and index, delta selection, streaming transport, onboard cache admission, planner read — with a dashed fallback edge from the cache back to the planner for the offline case. signed release root digest + manifest cut + index quadkey path · per-tile digest delta selection what this vehicle lacks gRPC stream flow-controlled · ≤200 ms budget onboard cache hard ceiling in tiles, not bytes planner reads active tile + stitched ring link down → serve last known-good the vehicle never blocks on the network: an unavailable tile degrades the map's freshness, never its availability

Distribution Model Comparison #

Four models cover essentially every fleet in service. The choice is decided by how often the map changes and how reliable the vehicle's link is, not by engineering taste.

Model Freshness Bytes moved per update Works offline Fits when
Full regional file at depot Days Whole region (GB) Completely The map changes on a survey cadence and vehicles return nightly
Whole-tile pull on demand Minutes One tile per miss (MB) Only cached area Coverage is wide and the link is good
Delta pull against a held version Minutes Changed features only (kB) Only cached area The map changes continuously and links are metered
Push on release Seconds Changed features only (kB) Only cached area Safety-relevant changes must reach the fleet immediately

Delta pull is the default and the rest of this page implements it: it keeps transfer proportional to the semantic change, exactly as content-addressed map tile deltas computes them, and it degrades to whole-tile pull automatically the first time a vehicle asks for a tile it has never held.

Push on release is layered on top rather than replacing it — a safety-relevant change is announced, and vehicles that hear the announcement pull the delta immediately instead of at their next scheduled poll.

Stage-by-Stage Implementation #

Stage 1 — Cut the release into addressable tiles #

The constraint: a tile boundary must never split a lane feature in a way that requires both tiles to be present in order to interpret either. Features that cross a boundary are stored whole in the tile containing their midpoint and referenced by identifier from the neighbour, which is the same ownership rule joining lane attributes across map tiles applies at the seam.

python
import numpy as np

def quadkey(lon: float, lat: float, level: int) -> str:
    """Bing-style quadkey for a WGS84 point at the given zoom level."""
    sin_lat = np.sin(np.radians(lat))
    x = (lon + 180.0) / 360.0
    y = 0.5 - np.log((1 + sin_lat) / (1 - sin_lat)) / (4 * np.pi)
    n = 1 << level
    tx, ty = min(int(x * n), n - 1), min(int(y * n), n - 1)

    digits = []
    for i in range(level, 0, -1):
        bit = 1 << (i - 1)
        d = 0
        if tx & bit:
            d += 1
        if ty & bit:
            d += 2
        digits.append(str(d))
    return "".join(digits)

At level 15 a quadkey cell is roughly 1 km on a side at mid-latitudes and shrinks toward the poles; pick the level from the metric size you want at your operating latitude rather than assuming the equatorial figure. Expected output: a string such as "120210233012133", which is also the storage path — tiles/1/2/0/2/.../tile.bin — so a prefix query selects a whole region.

Stage 2 — Address every tile by content, not by version number #

The constraint: a vehicle must be able to establish what it is missing with one small request, and it must be able to verify what it received without trusting the transport. Both fall out of addressing tiles by digest.

python
import hashlib

def tile_digest(feature_digests: list[str]) -> str:
    """Merkle root over a tile's canonically sorted feature digests."""
    h = hashlib.sha256()
    for d in sorted(feature_digests):
        h.update(bytes.fromhex(d))
    return h.hexdigest()


def missing_tiles(held: dict[str, str], target: dict[str, str]) -> list[str]:
    """Quadkeys whose digest differs from — or is absent in — what the vehicle holds."""
    return sorted(k for k, d in target.items() if held.get(k) != d)

held is the vehicle's tile-to-digest map, a few tens of kilobytes for a city; target is the release manifest. The comparison is set arithmetic on hashes, so its cost is independent of tile size and a vehicle that is already current transfers nothing beyond the manifest.

Why the manifest exchange is cheap enough to run on every poll:

Bytes per Poll Across Three Fleet States Three stacked bars on a log axis showing the constant manifest cost plus the state-dependent payload for a current vehicle, a vehicle with one stale tile, and a vehicle entering new territory. 1 kB10 kB100 kB 1 MB10 MB bytes exchanged per poll (log scale) already current 38 kB — manifest only one edited tile + 4 kB delta entering new territory + 12 MB — four whole tiles the constant left-hand block is the manifest; only the right-hand block depends on how stale the vehicle is

Stage 3 — Stream the selected tiles under a latency budget #

The constraint: the first usable lane record must reach the decoder well before the whole tile has transferred, and a slow client must be able to slow the server rather than drop the connection. A server-streaming RPC gives both.

python
import grpc

def stream_tiles(stub, quadkeys: list[str], deadline_s: float = 0.2):
    """Yield decoded tile chunks as they arrive, honouring a hard deadline."""
    request = TileRequest(quadkeys=quadkeys, encoding="protobuf")
    try:
        for chunk in stub.StreamTiles(request, timeout=deadline_s):
            yield chunk.quadkey, chunk.payload, chunk.is_final
    except grpc.RpcError as exc:
        if exc.code() is grpc.StatusCode.DEADLINE_EXCEEDED:
            return              # keep the cached map; retry on the next poll
        raise

The deadline is on the whole call, so a partially delivered tile is discarded rather than admitted: a half-tile is worse than a stale tile because it looks complete to the planner. Chunk boundaries fall on feature boundaries, so is_final is the only signal the cache needs to promote a tile from staging to resident.

Stage 4 — Admit tiles under a hard ceiling #

The constraint: the resident tile set is bounded and the bound is expressed in tiles, so it survives a change in feature density. Admission and eviction are one operation — a tile is admitted only if something can be evicted for it, and the eviction ranking is the predicted-trajectory distance used by managing map tile boundaries in ROS2.

python
def admit(cache: dict, quadkey: str, payload: bytes,
          rank: dict[str, float], max_tiles: int = 9) -> bool:
    """Insert a tile, evicting the furthest-from-trajectory tile if needed."""
    if quadkey in cache:
        cache[quadkey] = payload
        return True
    while len(cache) >= max_tiles:
        victim = max(cache, key=lambda k: rank.get(k, float("inf")))
        if rank.get(victim, float("inf")) <= rank.get(quadkey, float("inf")):
            return False        # everything resident is more useful than this
        del cache[victim]
    cache[quadkey] = payload
    return True

Returning False rather than evicting something more useful is deliberate: a prefetch for a route the vehicle has already diverged from must not displace the tile it is currently driving on.

Validation & QC Automation #

Every gate here runs against a served endpoint, not against the build output, because the defects this stage introduces are transport and cache defects.

python
def assert_serving_contract(client, manifest: dict[str, str]) -> None:
    for quadkey, digest in manifest.items():
        payload = client.fetch(quadkey)
        assert tile_digest(feature_digests_of(payload)) == digest, \
            f"{quadkey}: served bytes do not match the manifest digest"

    resident = client.cache_state()
    assert len(resident) <= 9, f"cache ceiling breached: {len(resident)} tiles"
    assert client.p95_latency_ms() <= 200, "tile fetch p95 over the 200 ms budget"
    assert client.offline_read_ok(), "no last known-good map when the link is down"

The enforced thresholds: served digest equals the manifest digest for every tile; resident tile count never exceeds the ceiling under a simulated route with three detours; fetch p95 ≤200 ms on the reference link profile; and a link-down test that returns a complete map from cache rather than an error. Wire the whole function into the release pipeline so a change to the serving layer cannot ship without exercising all four.

Edge Cases & Failure Patterns #

A vehicle holds a tile the release no longer contains. The manifest is authoritative for presence as well as content: any resident quadkey absent from the manifest is evicted, not retained. Retaining it produces a vehicle driving on geometry that has been deliberately withdrawn.

Two vehicles at different map versions meet at a boundary. Nothing needs to be done — each is internally consistent, and consistency between vehicles is a fleet-management question, not a map one. What must not happen is one vehicle holding tiles from two versions, which the atomic manifest swap prevents.

A CDN caches a tile past its release. Tile paths include the content digest, so a changed tile is a different URL and the stale object is simply never requested again. Never serve a tile from a mutable path.

The link is fast but the decoder is slow. The 200 ms budget covers transfer and decode. Profile the decode of a dense urban tile before trusting a transfer-only measurement — on lane-dense tiles decode routinely dominates.

A partial tile is admitted after a deadline. Guard admission on is_final and stage the payload outside the resident map until it arrives. A truncated tile presents as a region with no lanes, which the planner reads as impassable rather than as missing.

Why the re-cut is incremental. A release that edits one junction has no business re-cutting a region:

Incremental Re-Cut Against a Full Region Re-Cut Two cost bars for the same release, one covering the whole region and one covering only the changed tiles, with the manifest reuse noted. full re-cut · 40 000 tiles · ~6 h incremental · 3 tiles · ~20 s the other 39 997 manifest entries carry forward byte-identical the tile set to re-cut is the digest diff, which the version-control stage has already computed so incremental cutting costs nothing to implement beyond not throwing that diff away

Performance & Scale Notes #

Serving is cheap to scale horizontally because it is stateless — the only per-vehicle state is the held-digest map, and that lives on the vehicle. A single origin behind a CDN serves a national fleet, since tiles are immutable and cacheable forever at the edge once addressed by digest.

The expensive part is the cut in stage 1, which is re-run per release over the whole region. Cut only the tiles whose feature set changed: a release that edits one junction re-cuts one tile, and the manifest for every other tile carries forward unchanged. On a 40 000-tile region this turns a 6-hour re-cut into a 20-second one.

Onboard, the dominant cost is decode. Keep tiles in a format the vehicle can memory-map rather than parse — the same argument made for tile geometry in the ROS2 boundary guide — and the resident-set ceiling becomes a page-cache concern rather than a heap one.

FAQ #

What tile size should an HD map use? #

Size the tile so that one tile plus its immediate neighbours covers the planning horizon with room to prefetch, and so that a single tile decodes inside the frame budget. For urban HD maps that lands between 250 m and 1 km on a side: smaller tiles mean more index overhead and more boundary stitching, larger tiles mean a longer stall the first time a vehicle enters an uncached area. Measure the decode time of a representative tile before fixing the number, because decode cost scales with lane density rather than with area.

Should tiles be indexed by quadkey or by H3? #

Quadkeys give a strict hierarchy, so a parent prefix selects all its children and range queries over a sorted key space are trivial — which makes them a natural fit for object storage and CDN paths. H3 gives cells with uniform neighbour distance, which makes ring queries around a pose exact rather than approximate. Fleets that serve tiles over HTTP normally use quadkeys for the storage layout and compute H3 rings onboard for the prefetch set, using each index for what it is good at rather than choosing one.

How large should the onboard tile cache be? #

Derive it from the planning horizon and the route uncertainty, not from the free space on the device. A cache holding the active tile, every tile the predicted trajectory crosses within the horizon, and one ring beyond that is enough; anything more is holding data the vehicle will not use before it is evicted anyway. Express the ceiling in tiles rather than bytes so the policy survives a change in tile density, and enforce it with a hard limit so a routing detour cannot grow the resident set without bound.

Why stream tiles rather than downloading them as files? #

A streaming transport lets the vehicle begin decoding the first lane records before the last byte of the tile has arrived, which turns a 200 ms transfer into a 200 ms pipeline rather than a 200 ms stall followed by a decode. It also gives per-message flow control, so a vehicle on a weak link applies backpressure to the server instead of timing out and retrying a whole tile. The cost is a more complex client and a server that must be able to resume a partial tile.

Up one level: HD Mapping Architecture & Spatial Data Standards — the pipeline whose signed output this stage delivers to the fleet.