Skip to content

Tuning

Measure first. ezu --verbose tile … prints per-node op, cache hit/miss, output shape, and duration, which usually names the culprit before you start guessing.

The largest wins are almost always in the style, not the runtime.

  • Gate layers by zoom. min-zoom / max-zoom on the features node, and min-zoom-field where the data carries a per-feature threshold. A minor-road layer drawn at z8 costs real time and is invisible.
  • Filter early. filter-expr on the features node runs once and every consumer benefits. Filtering late, or in several places, does the work twice.
  • Paint only what is looked at. fill-dabs and line are the expensive ops. watercolor.json paints water and roads and uses flat fill-solid for landmass and landuse — that is a deliberate budget, not a shortcut.
  • Prefer stroke to line for hairlines. At 1 px the brush texture is not visible anyway.

The canvas is tile-size + 2 × pad on each side, so padding costs pixels quadratically: 512+2×64 is 1.6× the area of 512+2×16. Padding is computed from the graph, and a $param’s declared max is what gets budgeted — so a generous max on a blur sigma costs render time on every tile even when the value is small. Keep bounds as tight as the style needs.

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

render_parallel fans each depth bucket across Rayon. The win scales with graph width — a wide painterly graph gains a lot, a deep narrow one gains little.

Two levels exist, and they compete for the same cores:

Wins on Use for
across tiles (ezu tiles --concurrency N) throughput batch pyramid renders
within a tile (render_parallel) latency a server answering cold requests

Both at once oversubscribes the CPU. Pick per workload.

The cache is content-addressed over (canvas, tile, node param hash + asset hashes, input hashes) and shared across tiles within one style.

  • Keep one cache per style for that style’s life. A fresh cache per tile throws away the sharing that makes pyramids cheap.
  • Size it deliberately. Cache::with_capacity counts entries; a padded 512 px RGBA canvas is about 1.2 MB.
  • Fan out freely. Referencing one node from five consumers evaluates it once. Duplicating the node five times evaluates it five times.
  • Watch for a node that never hits. In --verbose output that is the signal of an asset hash() that changes when the data has not — the most common self-inflicted performance bug in a custom host.

PNG compression is a real share of the per-tile wall for simple styles. Lossless WebP is typically 20–40 % smaller on painterly content at comparable cost, and in wasm format: 'rgba' skips encoding entirely when the destination is a canvas. PNG also takes a compression preset — use the fast preset for live previews.

  • SIMD build helps the image-processing ops most.
  • Threads build gives you the parallel evaluator across Web Workers, if the page is cross-origin isolated.
  • Do not fetch a blind 3×3. requestedNeighborOffsets(name) is usually empty for a vector source — but bind every offset it does report, DEM and raster included, or the pad comes out clamped and the tile seams.
  • Cap glyph residency with setGlyphBudget, and watch memoryUsage().

For translated vector styles at 512 px, HTTP fetch, MVT decode, and PNG encode dominate the per-tile wall clock. If your numbers look worse than the benchmarks and the graph timings look fine, the problem is upstream of ezu: add a tile cache, use PMTiles instead of many small requests, or move the renderer closer to the data.

ezu-cli builds with a heap-profile feature that writes dhat-heap.json, viewable in dh_view, attributing peak heap to allocation sites. Reach for it when a render’s memory is not explained by its pixel buffers.

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.