Building an LRU VRAM Cache for Tile Buffers

The exact sub-problem here is the data structure that holds resident tiles and decides which one dies when VRAM is tight. A tiled map admits new GPUBuffers constantly — every pan and every zoom step makes a fresh batch of tiles resident — so the cache needs two operations to be genuinely O(1): touch, which marks a tile most-recently-used when it is drawn, and evict, which removes the least-recently-used tile when the byte total crosses a watermark. A naive implementation that re-sorts the resident set on every frame turns the eviction policy itself into a frame-time cost; at a few hundred resident tiles the sort starts showing up in a profile. The structure below avoids that with a hash map for lookup and a doubly linked list for recency, so both touch and evict are constant time, and it calls GPUBuffer.destroy() on the evicted tile to actually reclaim the VRAM. This is the eviction engine behind the working-set policy in VRAM budget management across tile zoom levels; the byte ceiling it enforces comes from estimating VRAM footprint per zoom level.

Runnable reference implementation

The four operations an LRU tile cache has to get right Four operations in the order they run. A lookup on a cache hit moves the entry to the most-recently-used end and returns its buffer, touching no GPU state. A miss allocates a buffer, and this is the point at which the budget is checked. Eviction, triggered by that check, walks from the least-recently-used end and destroys entries until enough bytes are free — and must skip any entry still referenced by an in-flight submission. Insertion records the new entry with its byte size, because the budget is counted in bytes and not in entries. LRU CACHE · FOUR OPERATIONS 1 Lookup — hit move to most-recently-used no GPU work at all 2 Lookup — miss check the byte budget first before allocating, not after 3 Evict from the LRU end destroy until bytes fit skip entries still in flight 4 Insert with its byte size budget counts bytes, not entries tiles differ in size by 10× Destroying a buffer that a submitted command buffer still references is a use-after-free the validator will catch.
The last note is the one that catches people. An entry-count cache holding sixty tiles can be holding anywhere from 40 to 400 mebibytes depending on which tiles they are, which means the budget it enforces is not a budget.

The cache is a Map<string, Node> for lookup plus an intrusive doubly linked list whose head is the most-recently-used tile and whose tail is the least. Touching a tile unlinks it and relinks it at the head; evicting removes the tail. Eviction runs to a low watermark rather than to exactly the capacity, so a burst of admits does not force a destroy on every single insert. Destruction is deferred behind queue.onSubmittedWorkDone() so it never frees a buffer a queued pass still reads.

typescript
export interface TileBuffers {
  vertex: GPUBuffer;
  index: GPUBuffer;
  uniform: GPUBuffer;
}

interface Node {
  key: string;
  buffers: TileBuffers;
  bytes: number;      // aligned resident footprint of this tile
  prev: Node | null;
  next: Node | null;
}

export interface LruOptions {
  capacityBytes: number;   // hard ceiling for resident tile buffers
  highWatermark?: number;  // fraction of capacity that triggers eviction (default 0.9)
  lowWatermark?: number;   // fraction eviction drains down to (default 0.7)
}

// Canonical z/x/y key so two code paths that build the same tile share a slot.
export const tileKey = (z: number, x: number, y: number): string => `${z}/${x}/${y}`;

export class TileBufferCache {
  private map = new Map<string, Node>();
  private head: Node | null = null;   // most-recently-used
  private tail: Node | null = null;   // least-recently-used
  private residentBytes = 0;
  private readonly high: number;
  private readonly low: number;
  private retiring: TileBuffers[] = []; // evicted, awaiting safe destroy()

  constructor(private device: GPUDevice, private opts: LruOptions) {
    this.high = (opts.highWatermark ?? 0.9) * opts.capacityBytes;
    this.low = (opts.lowWatermark ?? 0.7) * opts.capacityBytes;
  }

  get sizeBytes(): number { return this.residentBytes; }
  get count(): number { return this.map.size; }

  // Look up a tile, marking it most-recently-used if present.
  get(key: string): TileBuffers | undefined {
    const node = this.map.get(key);
    if (!node) return undefined;
    this.moveToHead(node);   // touch: O(1) relink, no sort
    return node.buffers;
  }

  // Insert or replace a tile, then evict down to the low watermark if needed.
  // `protectedKeys` are the current working set that must never be evicted.
  set(key: string, buffers: TileBuffers, bytes: number, protectedKeys: Set<string>): void {
    const existing = this.map.get(key);
    if (existing) {
      this.residentBytes -= existing.bytes;
      this.retire(existing.buffers);        // replacing: retire the old backing
      existing.buffers = buffers;
      existing.bytes = bytes;
      this.residentBytes += bytes;
      this.moveToHead(existing);
    } else {
      const node: Node = { key, buffers, bytes, prev: null, next: null };
      this.map.set(key, node);
      this.residentBytes += bytes;
      this.linkAtHead(node);
    }
    if (this.residentBytes > this.high) this.evictToLow(protectedKeys);
  }

  // Evict least-recently-used tiles until under the low watermark, skipping
  // the protected working set. Walks from the tail (oldest) toward the head.
  private evictToLow(protectedKeys: Set<string>): void {
    let node = this.tail;
    while (node && this.residentBytes > this.low) {
      const prev = node.prev;               // capture before unlink
      if (!protectedKeys.has(node.key)) {
        this.unlink(node);
        this.map.delete(node.key);
        this.residentBytes -= node.bytes;
        this.retire(node.buffers);          // defer destroy to reclaim()
      }
      node = prev;
    }
  }

  // Hold evicted buffers until the GPU is provably done reading them.
  private retire(b: TileBuffers): void {
    this.retiring.push(b);
  }

  // Call once per frame after queue.submit(): the safe point to free VRAM.
  async reclaim(): Promise<void> {
    if (this.retiring.length === 0) return;
    const batch = this.retiring;
    this.retiring = [];
    await this.device.queue.onSubmittedWorkDone(); // no in-flight pass reads these
    for (const b of batch) {
      b.vertex.destroy();
      b.index.destroy();
      b.uniform.destroy();
    }
  }

  // ---- intrusive doubly linked list helpers (all O(1)) ----

  private linkAtHead(node: Node): void {
    node.prev = null;
    node.next = this.head;
    if (this.head) this.head.prev = node;
    this.head = node;
    if (!this.tail) this.tail = node;
  }

  private unlink(node: Node): void {
    if (node.prev) node.prev.next = node.next; else this.head = node.next;
    if (node.next) node.next.prev = node.prev; else this.tail = node.prev;
    node.prev = node.next = null;
  }

  private moveToHead(node: Node): void {
    if (node === this.head) return;
    this.unlink(node);
    this.linkAtHead(node);
  }

  // Tear down the whole cache, e.g. on device loss or unmount.
  destroy(): void {
    for (const node of this.map.values()) {
      node.buffers.vertex.destroy();
      node.buffers.index.destroy();
      node.buffers.uniform.destroy();
    }
    this.map.clear();
    this.head = this.tail = null;
    this.residentBytes = 0;
    this.retiring = [];
  }
}

Wiring it into a render loop is three calls: try get for each visible tile, set the ones that miss after building their buffers, and reclaim once after the frame’s submit. The protected set is exactly the tiles in the current viewport, so eviction can free anything the camera has moved away from but never a tile it is drawing this frame.

typescript
function renderFrame(
  device: GPUDevice,
  cache: TileBufferCache,
  visible: { z: number; x: number; y: number }[],
  buildTile: (z: number, x: number, y: number) => { buffers: TileBuffers; bytes: number },
): void {
  const protectedKeys = new Set(visible.map((t) => tileKey(t.z, t.x, t.y)));

  for (const t of visible) {
    const key = tileKey(t.z, t.x, t.y);
    let buffers = cache.get(key);        // touch on hit
    if (!buffers) {
      const built = buildTile(t.z, t.x, t.y);      // uploads via writeBuffer
      cache.set(key, built.buffers, built.bytes, protectedKeys);
      buffers = built.buffers;
    }
    // ... encode a draw that binds buffers.vertex / index / uniform ...
  }

  // ... device.queue.submit([encoder.finish()]) ...
  void cache.reclaim();   // free evicted VRAM after work is submitted
}

Parameter reference

Cache pathologies and what causes each one A table of three cache pathologies with cause and fix. Thrashing, where the same tiles are evicted and re-fetched every frame, means the working set is larger than the budget, and the fix is to lower the maximum zoom level held rather than to raise the budget. A cache that grows past its limit means eviction is counting entries while the budget is in bytes, and the fix is to track bytes per entry. A validation error on destroy means an evicted buffer was still referenced by an in-flight submission, and the fix is to defer the destroy until the fence for that submission resolves. CACHE PATHOLOGY · CAUSE · FIX Cause Fix thrashing every frame working set > budget cap the zoom depth grows past the limit counting entries, not bytes track bytes per entry validation error on destroy still in flight defer to the fence Log evictions per second — a healthy cache evicts in bursts after a zoom, not continuously.
Thrashing is the one worth naming carefully, because raising the budget makes it disappear on the developer’s machine and reappear on everyone else’s. The real fix is to make the working set smaller.

Every tunable value in the cache, with guidance for tiled spatial workloads. These tables scroll horizontally on narrow viewports.

Parameter Typical value Spatial-workload guidance
capacityBytes 128–512 MiB The hard ceiling for tile buffers only, not total VRAM. Leave headroom for textures and the swap chain; derive the number from estimating VRAM footprint per zoom level.
highWatermark 0.9 Fraction of capacity that triggers eviction. Lower it if zoom transitions spike VRAM before eviction can react.
lowWatermark 0.7 Fraction eviction drains down to. The gap between high and low sets the eviction batch size; too small a gap causes thrash.
bytes per tile aligned footprint Must be the driver-aligned buffer total, not logical vertex count, or residentBytes drifts below real VRAM use.
protectedKeys current viewport Tiles that must never be evicted. Include the one-tile prefetch ring so a pan does not evict a tile about to reappear.
Recency stamp linked-list position Position in the list is the recency order; no numeric tick is needed, so there is nothing to overflow.
reclaim cadence once per frame Call after every submit. Skipping it leaks evicted buffers until the next call; calling it before submit risks freeing an in-flight buffer.

The high/low watermark gap is the parameter that bites first: set high and low too close and every admit past the ceiling triggers an eviction of roughly one tile, so the border tiles of a steady pan are destroyed and rebuilt every frame. A 0.2 gap (0.9/0.7) lets each eviction pass reclaim a batch, amortizing the cost across many subsequent admits.

Failure modes

One eviction pass across the recency list A strip of twelve cache entries ordered from least recently used on the left to most recently used on the right. The budget is exceeded by 38 mebibytes, so eviction walks from the left. The first three entries are evicted, freeing 44 mebibytes. The fourth entry is skipped because it is still referenced by an in-flight submission, and the walk stops as soon as enough bytes are free, leaving the remaining eight entries untouched. ENTRY 0 1 2 3 4 5 6 7 8 9 10 11 12 e1 e2 e3 skip untouched — budget satisfied Entries LRU → MRU e1–e3 free 44 MiB; e4 is skipped because a submission still references it Skipped entries stay at the LRU end and are the first candidates on the next pass.
The walk stops the moment the budget is met, which keeps eviction proportional to the overage rather than to the cache size — and it skips rather than blocks on in-flight entries, so a busy frame never stalls the allocator.
  • Use-after-destroy on a fast pan. Calling destroy() inline in evictToLow instead of deferring through reclaim frees a buffer a queued draw still references, and the validator reports a destroyed-resource error on the next submit. Detection: a GPUValidationError naming a destroyed buffer, typically one frame after a rapid pan. Fix: keep destruction in reclaim, gated on queue.onSubmittedWorkDone(), as written.
  • Eviction thrash from a narrow watermark gap. A highWatermark and lowWatermark set too close force one eviction per admit, so set destroys and the loop immediately rebuilds the same tiles. Detection: a sawtooth in sizeBytes and a spike in per-frame buffer builds. Fix: widen the gap to at least 0.15–0.2 of capacity so each eviction reclaims a batch.
  • Byte-counter drift. Adjusting residentBytes on evict but not symmetrically on set, or passing a bytes value that does not match the buffers actually allocated, lets the accounting diverge from real VRAM until eviction either never fires or fires constantly. Detection: sizeBytes disagrees with GPU memory reported by the browser’s task manager. Fix: account bytes on both insert and replace, and derive it from the aligned buffer sizes.
  • Protected tile evicted mid-frame. Passing a stale or empty protectedKeys set lets evictToLow free a tile the current frame is about to draw, leaving a one-frame hole. Detection: transient blank tiles under the cursor during heavy zoom. Fix: rebuild protectedKeys from the live viewport every frame before calling set.

Up: VRAM Budget Management Across Tile Zoom Levels