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