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::cache::{Cache, CacheKey, Hash128};
9use crate::eval::{AssetLoader, CanvasInfo, EvalCtx, EvalError, ParamValues, TileId};
10use crate::graph::{Graph, NodeIx};
11use crate::port::CoordSpace;
12use crate::value::PortValue;
13
14/// Entry point: evaluate a `Graph` for one tile.
15pub struct Evaluator<'a> {
16    pub graph: &'a Graph,
17    pub cache: &'a Cache,
18    pub assets: &'a dyn AssetLoader,
19}
20
21#[derive(Debug, thiserror::Error)]
22pub enum RenderError {
23    #[error(transparent)]
24    Eval(#[from] EvalError),
25}
26
27impl<'a> Evaluator<'a> {
28    pub fn new(graph: &'a Graph, cache: &'a Cache, assets: &'a dyn AssetLoader) -> Self {
29        Self {
30            graph,
31            cache,
32            assets,
33        }
34    }
35
36    /// Evaluate the graph and return the value at the output node.
37    /// Source nodes pull host data through `self.assets`; tile-scoped
38    /// bindings (MVT/GeoJSON layers, …) live under `tile.<name>` keys.
39    pub fn render(
40        &self,
41        tile: TileId,
42        canvas: CanvasInfo,
43        params: &ParamValues,
44        rng_seed: u64,
45    ) -> Result<PortValue, RenderError> {
46        let ctx = EvalCtx {
47            tile,
48            canvas,
49            assets: self.assets,
50            params,
51            rng_seed,
52        };
53        let n = self.graph.len();
54        let mut hashes: Vec<Hash128> = vec![0; n];
55        let mut values: Vec<Option<PortValue>> = vec![None; n];
56
57        for &ix in self.graph.topo_order() {
58            let (value, hash) = {
59                let upstream = |src: NodeIx| -> (Hash128, PortValue) {
60                    (
61                        hashes[src],
62                        values[src]
63                            .clone()
64                            .expect("upstream evaluated earlier in topo order"),
65                    )
66                };
67                self.eval_one(ix, &ctx, &upstream)?
68            };
69            hashes[ix] = hash;
70            values[ix] = Some(value);
71        }
72        Ok(values[self.graph.output()].clone().expect("output unset"))
73    }
74
75    /// Like [`render`] but evaluates nodes concurrently on Rayon, firing
76    /// each node the moment its last input resolves rather than waiting
77    /// on a topological-level barrier. A slow node (e.g. text) no longer
78    /// stalls unrelated branches, so the wall time tracks the graph's
79    /// critical path instead of the sum of per-level maxima.
80    ///
81    /// Falls back to sequential evaluation transparently when the
82    /// `parallel` feature is disabled, so callers don't need to branch.
83    pub fn render_parallel(
84        &self,
85        tile: TileId,
86        canvas: CanvasInfo,
87        params: &ParamValues,
88        rng_seed: u64,
89    ) -> Result<PortValue, RenderError> {
90        #[cfg(not(feature = "parallel"))]
91        {
92            self.render(tile, canvas, params, rng_seed)
93        }
94        #[cfg(feature = "parallel")]
95        {
96            use std::sync::atomic::AtomicUsize;
97            use std::sync::{Mutex, OnceLock};
98
99            let ctx = EvalCtx {
100                tile,
101                canvas,
102                assets: self.assets,
103                params,
104                rng_seed,
105            };
106            let n = self.graph.len();
107            let state = ParState {
108                slots: (0..n).map(|_| OnceLock::new()).collect(),
109                pending: (0..n)
110                    .map(|ix| AtomicUsize::new(self.graph.indegree(ix)))
111                    .collect(),
112                first_err: Mutex::new(None),
113                ctx,
114            };
115
116            // Bind a reference first: a `move` closure that mentions
117            // `state` would otherwise capture it by value.
118            let state = &state;
119            rayon::scope(|scope| {
120                for ix in 0..n {
121                    if self.graph.indegree(ix) == 0 {
122                        scope.spawn(move |s| self.schedule(s, state, ix));
123                    }
124                }
125            });
126
127            if let Some(e) = state
128                .first_err
129                .lock()
130                .unwrap_or_else(|p| p.into_inner())
131                .take()
132            {
133                return Err(e);
134            }
135            Ok(state.slots[self.graph.output()]
136                .get()
137                .expect("output unset")
138                .0
139                .clone())
140        }
141    }
142
143    /// Evaluate one node and, on success, release its downstream nodes:
144    /// each dependent's pending-input count is decremented, and the node
145    /// that drives a count to zero spawns that dependent on the same
146    /// Rayon scope. On error the first failure is recorded and no further
147    /// nodes are released, draining the scope so the caller can surface it.
148    #[cfg(feature = "parallel")]
149    fn schedule<'scope>(
150        &'scope self,
151        scope: &rayon::Scope<'scope>,
152        state: &'scope ParState<'scope>,
153        ix: NodeIx,
154    ) {
155        use std::sync::atomic::Ordering;
156
157        if state
158            .first_err
159            .lock()
160            .unwrap_or_else(|p| p.into_inner())
161            .is_some()
162        {
163            return;
164        }
165
166        let upstream = |src: NodeIx| -> (Hash128, PortValue) {
167            let (v, h) = state.slots[src]
168                .get()
169                .expect("upstream resolved before dependent is scheduled");
170            (*h, v.clone())
171        };
172
173        match self.eval_one(ix, &state.ctx, &upstream) {
174            Ok((v, h)) => {
175                let _ = state.slots[ix].set((v, h));
176            }
177            Err(e) => {
178                let mut slot = state.first_err.lock().unwrap_or_else(|p| p.into_inner());
179                if slot.is_none() {
180                    *slot = Some(e);
181                }
182                return;
183            }
184        }
185
186        for &dst in self.graph.downstream_unique(ix) {
187            if state.pending[dst].fetch_sub(1, Ordering::AcqRel) == 1 {
188                scope.spawn(move |s| self.schedule(s, state, dst));
189            }
190        }
191    }
192
193    /// Evaluate one node given the current intermediate state. Pulled
194    /// out so the serial and parallel paths share the cache lookup and
195    /// hashing logic; the paths differ only in how upstream results are
196    /// fetched (`upstream(src) -> (input hash, input value)`).
197    fn eval_one(
198        &self,
199        ix: NodeIx,
200        ctx: &EvalCtx<'_>,
201        upstream: &dyn Fn(NodeIx) -> (Hash128, PortValue),
202    ) -> Result<(PortValue, Hash128), RenderError> {
203        let node = self.graph.node(ix);
204
205        // Hash this node's own params, plus any asset bindings it samples.
206        let mut h = Xxh3::new();
207        node.param_hash(&mut h);
208        for name in node.asset_inputs() {
209            h.update(name.as_bytes());
210            h.update(&ctx.assets.hash(&name).to_le_bytes());
211        }
212        // Runtime values of `$param` references read at eval time —
213        // overriding a param invalidates exactly the nodes that read it.
214        for name in node.param_refs() {
215            h.update(name.as_bytes());
216            match ctx.params.get(&name) {
217                Some(v) => v.hash_into(&mut h),
218                None => h.update(b"\0default"),
219            }
220        }
221        let params_hash: Hash128 = h.digest128();
222
223        // Collect input hashes (in port order) and input values.
224        let input_specs = node.inputs();
225        let mut input_hashes: Vec<Hash128> = Vec::with_capacity(input_specs.len());
226        let mut input_vals: Vec<Option<PortValue>> = Vec::with_capacity(input_specs.len());
227        for port_ix in 0..input_specs.len() {
228            match self.graph.incoming(ix, port_ix) {
229                Some(src) => {
230                    let (h, v) = upstream(src);
231                    input_hashes.push(h);
232                    input_vals.push(Some(v));
233                }
234                None => {
235                    input_hashes.push(0);
236                    input_vals.push(None);
237                }
238            }
239        }
240
241        // World-anchored nodes drop the tile id from their key so
242        // adjacent tiles can share intermediates.
243        let tile_for_key = match node.coord_space() {
244            CoordSpace::World => None,
245            _ => Some(ctx.tile),
246        };
247        let key = CacheKey::build(ctx.canvas, tile_for_key, params_hash, &input_hashes);
248
249        if let Some(v) = self.cache.get(key) {
250            tracing::debug!(
251                target: "ezu_graph::eval",
252                node = self.graph.node_id(ix),
253                op = node.op_name(),
254                cache = "hit",
255                output = %describe_value(&v),
256                tile = %format!("{}/{}/{}", ctx.tile.z, ctx.tile.x, ctx.tile.y),
257                "cache hit",
258            );
259            return Ok((v, key.0));
260        }
261        // `wasm32-unknown-unknown` has no monotonic clock — `Instant::now()`
262        // panics ("time not implemented") — so the per-node timing is
263        // host-only. Traces there report `elapsed_us = 0`.
264        #[cfg(not(target_arch = "wasm32"))]
265        let t0 = Instant::now();
266        let value = node.eval(ctx, &input_vals)?;
267        #[cfg(not(target_arch = "wasm32"))]
268        let elapsed_us = t0.elapsed().as_micros();
269        #[cfg(target_arch = "wasm32")]
270        let elapsed_us = 0u128;
271        tracing::debug!(
272            target: "ezu_graph::eval",
273            node = self.graph.node_id(ix),
274            op = node.op_name(),
275            cache = "miss",
276            output = %describe_value(&value),
277            tile = %format!("{}/{}/{}", ctx.tile.z, ctx.tile.x, ctx.tile.y),
278            elapsed_us,
279            "evaluated",
280        );
281        self.cache.insert(key, value.clone());
282        Ok((value, key.0))
283    }
284}
285
286/// Shared, thread-safe scratch for one parallel render: per-node result
287/// slots (written once), per-node remaining-input counters, and the first
288/// error seen. Each field is `Sync`, so `&ParState` is shared freely
289/// across the Rayon scope without further locking of the results.
290#[cfg(feature = "parallel")]
291struct ParState<'a> {
292    slots: Vec<std::sync::OnceLock<(PortValue, Hash128)>>,
293    pending: Vec<std::sync::atomic::AtomicUsize>,
294    first_err: std::sync::Mutex<Option<RenderError>>,
295    ctx: EvalCtx<'a>,
296}
297
298/// One-line human-readable summary of a `PortValue` for debug logs.
299/// Keeps the format dense so node lines stay readable in a tail.
300fn describe_value(v: &PortValue) -> String {
301    match v {
302        PortValue::Raster(r) => format!("raster {}x{}", r.width, r.height),
303        PortValue::Sprite(s) => format!("sprite {}x{}", s.width, s.height),
304        PortValue::ScalarField(f) => format!(
305            "scalar-field {}x{} (mpp~{:.2})",
306            f.width,
307            f.height,
308            f.metres_per_pixel_x(),
309        ),
310        PortValue::Features(_) => "features".to_string(),
311        PortValue::Brush(_) => "brush".to_string(),
312        PortValue::Labels(_) => "labels".to_string(),
313        PortValue::Scalar(s) => format!("scalar {}({:?})", s.kind_name(), s),
314    }
315}