Caching and parallelism
The cache is content-addressed
Section titled “The cache is content-addressed”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 bytesOne 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.
What is shared between tiles
Section titled “What is shared between tiles”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.
Params and partial invalidation
Section titled “Params and partial invalidation”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.
Parallel evaluation
Section titled “Parallel evaluation”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).
Two levels of parallelism
Section titled “Two levels of parallelism”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.
Watching it
Section titled “Watching it”ezu --verbose tile --style style.json --tile 13/7276/3225 --out t.pngPer-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.