Skip to main content

ezu_graph/
evaluator.rs

1//! Walk the DAG and evaluate one tile.
2
3#[cfg(not(target_arch = "wasm32"))]
4use std::time::Instant;
5
6use xxhash_rust::xxh3::Xxh3;
7
8use crate::buf::RasterBuf;
9use crate::cache::{Cache, CacheKey, Hash128};
10use crate::eval::{AssetLoader, CanvasInfo, EvalCtx, EvalError, ParamValues, TileId};
11use crate::graph::{Graph, NodeIx};
12use crate::port::CoordSpace;
13use crate::value::PortValue;
14
15/// Entry point: evaluate a `Graph` for one tile.
16pub struct Evaluator<'a> {
17    pub graph: &'a Graph,
18    pub cache: &'a Cache,
19    pub assets: &'a dyn AssetLoader,
20}
21
22#[derive(Debug, thiserror::Error)]
23pub enum RenderError {
24    #[error(transparent)]
25    Eval(#[from] EvalError),
26}
27
28impl<'a> Evaluator<'a> {
29    pub fn new(graph: &'a Graph, cache: &'a Cache, assets: &'a dyn AssetLoader) -> Self {
30        Self {
31            graph,
32            cache,
33            assets,
34        }
35    }
36
37    /// Evaluate the graph and return the value at the output node.
38    /// Source nodes pull host data through `self.assets`; tile-scoped
39    /// bindings (MVT/GeoJSON layers, …) live under `tile.<name>` keys.
40    pub fn render(
41        &self,
42        tile: TileId,
43        canvas: CanvasInfo,
44        params: &ParamValues,
45        rng_seed: u64,
46    ) -> Result<PortValue, RenderError> {
47        let ctx = EvalCtx {
48            tile,
49            canvas,
50            assets: self.assets,
51            params,
52            rng_seed,
53            // Replaced per node in `eval_one`; the graph-wide value is
54            // the most permissive one.
55            influence_pad: u32::MAX,
56        };
57        if crate::mem::enabled() {
58            crate::mem::reset();
59        }
60        let influence = self.graph.influence_pads(self.assets);
61        let n = self.graph.len();
62        let mut hashes: Vec<Hash128> = vec![0; n];
63        let mut values: Vec<Option<PortValue>> = vec![None; n];
64
65        // How many not-yet-evaluated consumers each node still has. Once
66        // it hits zero the node's value is dead weight, so it is dropped
67        // instead of being carried to the end of the render.
68        let mut consumers: Vec<usize> = (0..n)
69            .map(|ix| self.graph.downstream_unique(ix).len())
70            .collect();
71
72        for &ix in self.graph.topo_order() {
73            let (value, hash) = {
74                let upstream = |src: NodeIx| -> (Hash128, PortValue) {
75                    (
76                        hashes[src],
77                        values[src]
78                            .clone()
79                            .expect("upstream evaluated earlier in topo order"),
80                    )
81                };
82                self.eval_one(ix, &ctx, &influence, &upstream)?
83            };
84            hashes[ix] = hash;
85            values[ix] = Some(value);
86
87            // Safe to drop upstream values only now: `eval_one` has
88            // already cloned everything this node reads.
89            for src in self.graph.upstream(ix) {
90                consumers[src] -= 1;
91                if consumers[src] == 0 && src != self.graph.output() {
92                    if let Some(v) = values[src].take() {
93                        if crate::mem::enabled() {
94                            crate::mem::released(v.approx_bytes());
95                        }
96                    }
97                }
98            }
99        }
100        let out = values[self.graph.output()].clone().expect("output unset");
101        if crate::mem::enabled() {
102            eprintln!("{}", crate::mem::report());
103        }
104        Ok(out)
105    }
106
107    /// Like [`render`] but evaluates nodes concurrently on Rayon, firing
108    /// each node the moment its last input resolves rather than waiting
109    /// on a topological-level barrier. A slow node (e.g. text) no longer
110    /// stalls unrelated branches, so the wall time tracks the graph's
111    /// critical path instead of the sum of per-level maxima.
112    ///
113    /// Falls back to sequential evaluation transparently when the
114    /// `parallel` feature is disabled, so callers don't need to branch.
115    pub fn render_parallel(
116        &self,
117        tile: TileId,
118        canvas: CanvasInfo,
119        params: &ParamValues,
120        rng_seed: u64,
121    ) -> Result<PortValue, RenderError> {
122        #[cfg(not(feature = "parallel"))]
123        {
124            self.render(tile, canvas, params, rng_seed)
125        }
126        #[cfg(feature = "parallel")]
127        {
128            use std::sync::atomic::AtomicUsize;
129            use std::sync::{Mutex, OnceLock};
130
131            let ctx = EvalCtx {
132                tile,
133                canvas,
134                assets: self.assets,
135                params,
136                rng_seed,
137                // Replaced per node in `eval_one`; the graph-wide value
138                // is the most permissive one.
139                influence_pad: u32::MAX,
140            };
141            let influence = self.graph.influence_pads(self.assets);
142            if crate::mem::enabled() {
143                crate::mem::reset();
144            }
145            let n = self.graph.len();
146            let state = ParState {
147                slots: (0..n).map(|_| Mutex::new(None)).collect(),
148                hashes: (0..n).map(|_| OnceLock::new()).collect(),
149                pending: (0..n)
150                    .map(|ix| AtomicUsize::new(self.graph.indegree(ix)))
151                    .collect(),
152                consumers: (0..n)
153                    .map(|ix| AtomicUsize::new(self.graph.downstream_unique(ix).len()))
154                    .collect(),
155                first_err: Mutex::new(None),
156                influence,
157                ctx,
158            };
159
160            // Bind a reference first: a `move` closure that mentions
161            // `state` would otherwise capture it by value.
162            let state = &state;
163            rayon::scope(|scope| {
164                for ix in 0..n {
165                    if self.graph.indegree(ix) == 0 {
166                        scope.spawn(move |s| self.schedule(s, state, ix));
167                    }
168                }
169            });
170
171            if let Some(e) = state
172                .first_err
173                .lock()
174                .unwrap_or_else(|p| p.into_inner())
175                .take()
176            {
177                return Err(e);
178            }
179            let out = state.slots[self.graph.output()]
180                .lock()
181                .unwrap_or_else(|p| p.into_inner())
182                .take()
183                .expect("output unset");
184            if crate::mem::enabled() {
185                eprintln!("{}", crate::mem::report());
186            }
187            Ok(out)
188        }
189    }
190
191    /// Evaluate one node and, on success, release its downstream nodes:
192    /// each dependent's pending-input count is decremented, and the node
193    /// that drives a count to zero spawns that dependent on the same
194    /// Rayon scope. On error the first failure is recorded and no further
195    /// nodes are released, draining the scope so the caller can surface it.
196    #[cfg(feature = "parallel")]
197    fn schedule<'scope>(
198        &'scope self,
199        scope: &rayon::Scope<'scope>,
200        state: &'scope ParState<'scope>,
201        ix: NodeIx,
202    ) {
203        use std::sync::atomic::Ordering;
204
205        if state
206            .first_err
207            .lock()
208            .unwrap_or_else(|p| p.into_inner())
209            .is_some()
210        {
211            return;
212        }
213
214        let upstream = |src: NodeIx| -> (Hash128, PortValue) {
215            let v = state.slots[src]
216                .lock()
217                .unwrap_or_else(|p| p.into_inner())
218                .clone()
219                .expect("upstream still held while a consumer is running");
220            let h = *state.hashes[src]
221                .get()
222                .expect("upstream resolved before dependent is scheduled");
223            (h, v)
224        };
225
226        match self.eval_one(ix, &state.ctx, &state.influence, &upstream) {
227            Ok((v, h)) => {
228                let _ = state.hashes[ix].set(h);
229                *state.slots[ix].lock().unwrap_or_else(|p| p.into_inner()) = Some(v);
230                // This node has taken its copies, so any upstream whose
231                // last consumer it was can be released now rather than at
232                // the end of the render.
233                for src in self.graph.upstream(ix) {
234                    if state.consumers[src].fetch_sub(1, Ordering::AcqRel) == 1
235                        && src != self.graph.output()
236                    {
237                        let dropped = state.slots[src]
238                            .lock()
239                            .unwrap_or_else(|p| p.into_inner())
240                            .take();
241                        if crate::mem::enabled() {
242                            if let Some(v) = dropped {
243                                crate::mem::released(v.approx_bytes());
244                            }
245                        }
246                    }
247                }
248            }
249            Err(e) => {
250                let mut slot = state.first_err.lock().unwrap_or_else(|p| p.into_inner());
251                if slot.is_none() {
252                    *slot = Some(e);
253                }
254                return;
255            }
256        }
257
258        for &dst in self.graph.downstream_unique(ix) {
259            if state.pending[dst].fetch_sub(1, Ordering::AcqRel) == 1 {
260                scope.spawn(move |s| self.schedule(s, state, dst));
261            }
262        }
263    }
264
265    /// Evaluate one node given the current intermediate state. Pulled
266    /// out so the serial and parallel paths share the cache lookup and
267    /// hashing logic; the paths differ only in how upstream results are
268    /// fetched (`upstream(src) -> (input hash, input value)`).
269    fn eval_one(
270        &self,
271        ix: NodeIx,
272        ctx: &EvalCtx<'_>,
273        influence: &[u32],
274        upstream: &dyn Fn(NodeIx) -> (Hash128, PortValue),
275    ) -> Result<(PortValue, Hash128), RenderError> {
276        let node = self.graph.node(ix);
277        // What this node may drop depends on the ops *downstream* of it,
278        // whose parameters are not otherwise part of its key.
279        let ctx = &EvalCtx {
280            influence_pad: influence[ix],
281            ..*ctx
282        };
283
284        // Hash this node's own params, plus any asset bindings it samples.
285        let mut h = Xxh3::new();
286        node.param_hash(&mut h);
287        h.update(&ctx.influence_pad.to_le_bytes());
288        for name in node.asset_inputs() {
289            h.update(name.as_bytes());
290            h.update(&ctx.assets.hash(&name).to_le_bytes());
291        }
292        // Runtime values of `$param` references read at eval time —
293        // overriding a param invalidates exactly the nodes that read it.
294        for name in node.param_refs() {
295            h.update(name.as_bytes());
296            match ctx.params.get(&name) {
297                Some(v) => v.hash_into(&mut h),
298                None => h.update(b"\0default"),
299            }
300        }
301        let params_hash: Hash128 = h.digest128();
302
303        // Collect input hashes (in port order) and input values.
304        let input_specs = node.inputs();
305        let mut input_hashes: Vec<Hash128> = Vec::with_capacity(input_specs.len());
306        let mut input_vals: Vec<Option<PortValue>> = Vec::with_capacity(input_specs.len());
307        for port_ix in 0..input_specs.len() {
308            match self.graph.incoming(ix, port_ix) {
309                Some(src) => {
310                    let (h, v) = upstream(src);
311                    input_hashes.push(h);
312                    input_vals.push(Some(v));
313                }
314                None => {
315                    input_hashes.push(0);
316                    input_vals.push(None);
317                }
318            }
319        }
320
321        // World-anchored nodes drop the tile id from their key so
322        // adjacent tiles can share intermediates.
323        let tile_for_key = match node.coord_space() {
324            CoordSpace::World => None,
325            _ => Some(ctx.tile),
326        };
327        let key = CacheKey::build(ctx.canvas, tile_for_key, params_hash, &input_hashes);
328
329        if let Some(v) = self.cache.get(key) {
330            tracing::debug!(
331                target: "ezu_graph::eval",
332                node = self.graph.node_id(ix),
333                op = node.op_name(),
334                cache = "hit",
335                output = %describe_value(&v),
336                tile = %format!("{}/{}/{}", ctx.tile.z, ctx.tile.x, ctx.tile.y),
337                "cache hit",
338            );
339            return Ok((v, key.0));
340        }
341        // `wasm32-unknown-unknown` has no monotonic clock — `Instant::now()`
342        // panics ("time not implemented") — so the per-node timing is
343        // host-only. Traces there report `elapsed_us = 0`.
344        #[cfg(not(target_arch = "wasm32"))]
345        let t0 = Instant::now();
346        let (value, blank) = intern_blank(node.eval(ctx, &input_vals)?);
347        #[cfg(not(target_arch = "wasm32"))]
348        let elapsed_us = t0.elapsed().as_micros();
349        #[cfg(target_arch = "wasm32")]
350        let elapsed_us = 0u128;
351        tracing::debug!(
352            target: "ezu_graph::eval",
353            node = self.graph.node_id(ix),
354            op = node.op_name(),
355            cache = "miss",
356            output = %describe_value(&value),
357            tile = %format!("{}/{}/{}", ctx.tile.z, ctx.tile.x, ctx.tile.y),
358            elapsed_us,
359            "evaluated",
360        );
361        if crate::mem::enabled() && !blank {
362            crate::mem::acquired(node.op_name(), value.approx_bytes());
363        }
364        self.cache.insert(key, value.clone());
365        Ok((value, key.0))
366    }
367}
368
369/// Shared, thread-safe scratch for one parallel render: per-node result
370/// slots (written once), per-node remaining-input counters, and the first
371/// error seen. Each field is `Sync`, so `&ParState` is shared freely
372/// across the Rayon scope without further locking of the results.
373#[cfg(feature = "parallel")]
374struct ParState<'a> {
375    /// Per-node result, cleared once the node's last consumer has run.
376    slots: Vec<std::sync::Mutex<Option<PortValue>>>,
377    /// Per-node cache hash; kept for the whole render (it is 16 bytes).
378    hashes: Vec<std::sync::OnceLock<Hash128>>,
379    pending: Vec<std::sync::atomic::AtomicUsize>,
380    /// Consumers that have not yet read each node's slot.
381    consumers: Vec<std::sync::atomic::AtomicUsize>,
382    first_err: std::sync::Mutex<Option<RenderError>>,
383    ctx: EvalCtx<'a>,
384    /// Per-node reach, from [`Graph::influence_pads`].
385    influence: Vec<u32>,
386}
387
388/// Replace a fully transparent raster with the shared blank of the same
389/// size, dropping the freshly allocated one.
390///
391/// Layers that match nothing on a tile — most of them, on most tiles —
392/// each hand back a full padded canvas of zeros, and the evaluator holds
393/// every intermediate until its consumers have run. Collapsing them onto
394/// one interned buffer keeps the pixels identical while costing a single
395/// wide scan (`is_blank` reads 16 bytes at a time) per raster produced.
396///
397/// Returns whether the value was replaced, so memory accounting can skip
398/// the shared buffer instead of counting it once per node.
399fn intern_blank(value: PortValue) -> (PortValue, bool) {
400    match &value {
401        PortValue::Raster(r) if r.is_blank() => (
402            PortValue::Raster(RasterBuf::blank_shared(r.width, r.height)),
403            true,
404        ),
405        PortValue::Sprite(s) if s.is_blank() => (
406            PortValue::Sprite(RasterBuf::blank_shared(s.width, s.height)),
407            true,
408        ),
409        _ => (value, false),
410    }
411}
412
413/// One-line human-readable summary of a `PortValue` for debug logs.
414/// Keeps the format dense so node lines stay readable in a tail.
415fn describe_value(v: &PortValue) -> String {
416    match v {
417        PortValue::Raster(r) => format!("raster {}x{}", r.width, r.height),
418        PortValue::Sprite(s) => format!("sprite {}x{}", s.width, s.height),
419        PortValue::ScalarField(f) => format!(
420            "scalar-field {}x{} (mpp~{:.2})",
421            f.width,
422            f.height,
423            f.metres_per_pixel_x(),
424        ),
425        PortValue::Features(_) => "features".to_string(),
426        PortValue::Brush(_) => "brush".to_string(),
427        PortValue::Labels(_) => "labels".to_string(),
428        PortValue::Scalar(s) => format!("scalar {}({:?})", s.kind_name(), s),
429    }
430}