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 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.
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.
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
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
- Use-after-destroy on a fast pan. Calling
destroy()inline inevictToLowinstead of deferring throughreclaimfrees a buffer a queued draw still references, and the validator reports a destroyed-resource error on the nextsubmit. Detection: aGPUValidationErrornaming a destroyed buffer, typically one frame after a rapid pan. Fix: keep destruction inreclaim, gated onqueue.onSubmittedWorkDone(), as written. - Eviction thrash from a narrow watermark gap. A
highWatermarkandlowWatermarkset too close force one eviction per admit, sosetdestroys and the loop immediately rebuilds the same tiles. Detection: a sawtooth insizeBytesand 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
residentByteson evict but not symmetrically onset, or passing abytesvalue that does not match the buffers actually allocated, lets the accounting diverge from real VRAM until eviction either never fires or fires constantly. Detection:sizeBytesdisagrees with GPU memory reported by the browser’s task manager. Fix: accountbyteson both insert and replace, and derive it from the aligned buffer sizes. - Protected tile evicted mid-frame. Passing a stale or empty
protectedKeysset letsevictToLowfree 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: rebuildprotectedKeysfrom the live viewport every frame before callingset.
Related
- VRAM Budget Management Across Tile Zoom Levels — the working-set policy that drives this cache and defines the ceiling it enforces.
- Estimating VRAM Footprint Per Zoom Level — how to compute the
capacityBytesand per-tilebytesthis cache consumes. - Reducing GPU Memory Fragmentation During Spatial Aggregation — recycling evicted buffers into a size-bucketed pool instead of destroying them.
- Memory Alignment for Spatial Data Buffers — the stride rules behind an accurate per-tile
bytesvalue.