Skip to content

Caching and parallelism

Every node’s output is cached under a 128-bit xxh3 folded from:

(canvas geometry, tile id, the node's own param hash + its asset hashes, its input hashes)

That makes it a Merkle-style hash: an input that changes changes every key downstream, and nothing else. Hits are cheap to clone because the heavy payloads sit behind Arc.

let cache = ezu::graph::Cache::with_capacity(4096); // entries, not bytes

One cache per style, shared across every tile rendered with it, thrown away when the style changes. In ezu serve that is exactly what happens on each edit.

Two rules do most of the work:

World-anchored nodes drop the tile id from the key. Anything whose output is a function of world coordinates — fill-dabs, line, noise — computes the same result for the same ground regardless of which tile asked, so adjacent tiles hit the same entry. If such a node samples a tile-scoped asset, that binding’s hash brings the tile distinction back implicitly, so correctness does not depend on getting the annotation right.

Asset hashes are folded in automatically. Source-style nodes declare what they sample, and the evaluator folds each binding’s AssetLoader::hash into the consuming node’s key. Rebind a layer, and every dependent entry invalidates — without the node threading the tile id into its own hash.

This is why ezu tiles over a pyramid is much cheaper per tile than the same tiles rendered by separate processes: the shared parts of the painting are computed once.

A param change does not invalidate the graph. Each node’s key includes only the param values it actually reads, so moving one slider re-evaluates that node’s subtree and nothing else. That is what makes the live editor’s params panel feel immediate on a style that takes 200 ms to render from cold.

Evaluator::render walks the topological order sequentially.

render_parallel (feature parallel) groups nodes by longest-path depth into level buckets. Within a bucket no two nodes share an edge, so the whole bucket fans out across Rayon’s global pool; joins between buckets are cheap, and the cache and hashing logic is shared with the serial path.

The win scales with graph width, not node count. On a six-node-wide watercolour graph, four Tokyo tiles go from 6.3 s to 1.3 s.

ezu = { version = "0.6", features = ["parallel"] }

Leave it off for wasm — use the threads build there, which drives the same evaluator across Web Workers (use in the browser).

Rendering a pyramid, you can parallelise across tiles, within a tile, or both. ezu tiles --concurrency N does the former and the parallel evaluator the latter. Across-tiles usually wins on throughput for many small tiles; within-tile wins on latency for one expensive tile, which is what a tile server serving cold requests cares about. Both together oversubscribe the CPU — pick per workload.

Terminal window
ezu --verbose tile --style style.json --tile 13/7276/3225 --out t.png

Per-node logs: op name, cache hit or miss, output shape, evaluation duration. This is the first thing to look at when a style is slower than expected — a node you thought was cached, missing on every tile, usually means an asset hash that changes when it should not.

Memory: Cache::with_capacity counts entries, and a padded 512 px RGBA canvas is about 1.2 MB, so a 4096-entry cache of full canvases is not a small number. In a constrained host, size it deliberately; in wasm, memoryUsage() reports the cache’s own budget and usage.

Map renders on this site are made fromOpenStreetMap data viaProtomaps (© OpenStreetMap contributors), elevation from Re:Earth Terrain,Mapterhorn andEGM2008 (NGA), and aerial imagery from GSI Japan(© 国土地理院). The painterly styles use CC0 brushes byDavid Revoy.