Skip to main content

otf_pixels_core/
schedule.rs

1//! The pull-based tile scheduler.
2//!
3//! This is the demand-driven evaluator ADR-0001 commits to, and the thing M1's
4//! whole-image evaluator exists to be checked against: for any pipeline the two
5//! must agree byte for byte (ROADMAP M2).
6//!
7//! # How a run proceeds
8//!
9//! 1. [`Plan`] analyses the graph once: tile shapes (ADR-0003) and
10//!    materialization points (ADR-0009). No pixels move.
11//! 2. Nodes the plan marked are materialized, in forward order.
12//! 3. Output tiles are processed in **batches**. Within a batch, tiles are
13//!    evaluated in parallel on the work-stealing pool; batches themselves run
14//!    in order, and results are handed to the sink in order so an encoder still
15//!    sees rows top to bottom.
16//!
17//! # Where the parallelism comes from
18//!
19//! A linear pipeline has no *intra*-tile parallelism — every node depends on
20//! the one below it. Parallelism therefore comes from having several output
21//! tiles in flight, which is also what bounds memory: peak usage is a batch's
22//! working set, not the image.
23//!
24//! # Why forward-only sources stay correct
25//!
26//! A [`DecodeCapability::Sequential`] producer cannot serve concurrent
27//! out-of-order requests. Source bands for a batch are therefore pulled
28//! **serially and in order** before the batch's parallel phase begins, and held
29//! for its duration. The plan has already guaranteed that order is forward.
30//!
31//! # Why the cache is only ever an optimisation
32//!
33//! Each output tile evaluates into its own working set. The shared
34//! [`TileCache`] is consulted and populated, but never depended on: an eviction
35//! between producing a tile and consuming it can cost recomputation, never
36//! correctness. That is what lets the cache be byte-budgeted without the
37//! scheduler having to pin anything.
38//!
39//! [`DecodeCapability::Sequential`]: crate::DecodeCapability::Sequential
40
41use crate::{
42    Image, Node, NodeId, PixelsError, Plan, PlanOptions, Region, Result, ThreadPool, Tile, TileBuf,
43    TileCache, TileKey,
44};
45use std::collections::HashMap;
46use std::sync::Arc;
47
48/// Tuning for a [`Scheduler`].
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[non_exhaustive]
51pub struct SchedulerOptions {
52    /// Worker threads. Zero means [`ThreadPool::default_threads`].
53    pub threads: usize,
54    /// Byte budget for the shared tile cache.
55    pub cache_budget: usize,
56    /// Output tiles evaluated concurrently.
57    ///
58    /// This is the memory/parallelism dial: peak usage scales with it, and so
59    /// does available concurrency. Zero means "one per worker thread".
60    pub batch_tiles: usize,
61    /// Tile shape negotiation options.
62    pub plan: PlanOptions,
63}
64
65impl SchedulerOptions {
66    /// Set the worker thread count; zero means one per core.
67    ///
68    /// `SchedulerOptions` is `#[non_exhaustive]`, so downstream crates cannot
69    /// use a struct literal; these setters are the only way to configure it.
70    #[must_use]
71    pub const fn with_threads(mut self, threads: usize) -> Self {
72        self.threads = threads;
73        self
74    }
75
76    /// Set the tile cache byte budget.
77    #[must_use]
78    pub const fn with_cache_budget(mut self, bytes: usize) -> Self {
79        self.cache_budget = bytes;
80        self
81    }
82
83    /// Set how many output tiles are evaluated concurrently.
84    #[must_use]
85    pub const fn with_batch_tiles(mut self, tiles: usize) -> Self {
86        self.batch_tiles = tiles;
87        self
88    }
89
90    /// Set the tile shape negotiation options.
91    #[must_use]
92    pub const fn with_plan(mut self, plan: PlanOptions) -> Self {
93        self.plan = plan;
94        self
95    }
96}
97
98impl Default for SchedulerOptions {
99    fn default() -> Self {
100        Self {
101            threads: 0,
102            cache_budget: TileCache::DEFAULT_BUDGET,
103            batch_tiles: 0,
104            plan: PlanOptions::default(),
105        }
106    }
107}
108
109/// Counters describing one run, for tests and diagnostics.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111#[non_exhaustive]
112pub struct RunStats {
113    /// Output tiles produced.
114    pub output_tiles: u64,
115    /// Nodes materialized because of ADR-0009.
116    pub materialized_nodes: u64,
117    /// Peak bytes held in working sets and materialized buffers.
118    ///
119    /// This is the number the constant-memory guarantee is about: it must not
120    /// grow with image height for a streaming pipeline.
121    pub peak_bytes: u64,
122    /// The source resolution shrink-on-load lowered, if it did.
123    ///
124    /// `None` means the source decoded at full size — either because it has
125    /// one resolution, or because the pipeline was not eligible (see
126    /// [`shrink_on_load`]). Reported rather than left silent so a pipeline
127    /// that expected the fast path and did not get it can be diagnosed
128    /// instead of merely being slow.
129    ///
130    /// Filled in by whoever applied the rewrite. [`Scheduler::run`] does not
131    /// apply it: the rewrite has to happen *above* the choice of evaluator, or
132    /// the scheduler and the reference evaluator would be handed different
133    /// graphs and stop agreeing — and their agreement is the oracle the whole
134    /// engine is checked against (ROADMAP M2).
135    ///
136    /// [`shrink_on_load`]: crate::shrink_on_load
137    pub reduction: Option<crate::Reduction>,
138}
139
140/// A demand-driven, parallel tile evaluator.
141///
142/// One scheduler can run many pipelines; the pool and cache are reused.
143#[derive(Debug)]
144pub struct Scheduler {
145    pool: ThreadPool,
146    cache: Arc<TileCache>,
147    options: SchedulerOptions,
148}
149
150impl Scheduler {
151    /// Build a scheduler with `options`.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`PixelsError::Io`] if worker threads cannot be spawned.
156    pub fn new(options: SchedulerOptions) -> Result<Self> {
157        let threads = if options.threads == 0 {
158            ThreadPool::default_threads()
159        } else {
160            options.threads
161        };
162        Ok(Self {
163            pool: ThreadPool::new(threads)?,
164            cache: Arc::new(TileCache::new(options.cache_budget)),
165            options,
166        })
167    }
168
169    /// A scheduler with default tuning.
170    ///
171    /// # Errors
172    ///
173    /// As [`Scheduler::new`].
174    pub fn with_defaults() -> Result<Self> {
175        Self::new(SchedulerOptions::default())
176    }
177
178    /// The process-wide scheduler, built on first use with default options:
179    /// one worker per core and the default cache budget.
180    ///
181    /// This is what an output runs on unless told otherwise, so every
182    /// pipeline in a process shares one pool of threads and one tile cache
183    /// however many run at once. Concurrent runs queue their tiles on the
184    /// same workers rather than each spawning a pool of their own, which is
185    /// what keeps a server handling forty requests at a time from running
186    /// forty threads per core. Its workers sleep while there is no work and
187    /// live as long as the process.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`PixelsError::Io`] if the first call cannot spawn the worker
192    /// threads. A later call tries again.
193    pub fn global() -> Result<Arc<Self>> {
194        static GLOBAL: std::sync::OnceLock<Arc<Scheduler>> = std::sync::OnceLock::new();
195        if let Some(scheduler) = GLOBAL.get() {
196            return Ok(Arc::clone(scheduler));
197        }
198        // Two first calls may race to build one; the loser's pool is dropped,
199        // which joins its idle workers, and both return the winner.
200        let built = Arc::new(Self::with_defaults()?);
201        Ok(Arc::clone(GLOBAL.get_or_init(|| built)))
202    }
203
204    /// The worker thread count.
205    #[must_use]
206    pub const fn threads(&self) -> usize {
207        self.pool.threads()
208    }
209
210    /// The shared tile cache.
211    #[must_use]
212    pub fn cache(&self) -> &Arc<TileCache> {
213        &self.cache
214    }
215
216    /// Evaluate `image`, handing each output tile to `consume` in order.
217    ///
218    /// `consume` is called on the calling thread, in tile order, so a sink may
219    /// hold non-`Send` state and an encoder still sees rows top to bottom.
220    ///
221    /// # Errors
222    ///
223    /// Propagates any producer, op or consumer error. A failure anywhere fails
224    /// the whole run; the sink is simply not given the remaining tiles
225    /// (ARCHITECTURE §Failure model).
226    pub fn run(
227        &self,
228        image: &Image,
229        mut consume: impl FnMut(Region, &Tile<'_>) -> Result<()>,
230    ) -> Result<RunStats> {
231        let plan = Plan::build(image, self.options.plan)?;
232        let root = Arc::clone(image.node());
233        let mut stats = RunStats::default();
234
235        // Which nodes are worth caching; see `NodePlan::cacheable`.
236        let cacheable: Arc<std::collections::HashSet<NodeId>> = Arc::new(
237            dependency_order(&root)
238                .iter()
239                .filter(|node| plan.node(node.id()).is_some_and(|p| p.cacheable))
240                .map(|node| node.id())
241                .collect(),
242        );
243
244        // Phase 1: realize the nodes ADR-0009 marked, in dependency order.
245        let materialized = self.materialize(&root, &plan, &cacheable, &mut stats)?;
246
247        // Phase 2: stream output tiles in batches.
248        let batch_size = if self.options.batch_tiles == 0 {
249            self.threads()
250        } else {
251            self.options.batch_tiles
252        }
253        .max(1);
254        let tiles = plan.output_tiles().to_vec();
255
256        for batch in tiles.chunks(batch_size) {
257            // Source bands first, serially and in order: a forward-only
258            // producer cannot serve concurrent out-of-order requests.
259            let sources = self.pull_sources(&root, batch, &materialized)?;
260
261            let context = Arc::new(Context {
262                cache: Arc::clone(&self.cache),
263                materialized: Arc::clone(&materialized),
264                sources: Arc::new(sources),
265                cacheable: Arc::clone(&cacheable),
266            });
267
268            // Evaluate the batch's tiles in parallel.
269            let outputs: Arc<Vec<std::sync::Mutex<Option<Arc<TileBuf>>>>> =
270                Arc::new(batch.iter().map(|_| std::sync::Mutex::new(None)).collect());
271            let tasks: Vec<_> = batch
272                .iter()
273                .enumerate()
274                .map(|(index, region)| {
275                    let (root, context, outputs, region) = (
276                        Arc::clone(&root),
277                        Arc::clone(&context),
278                        Arc::clone(&outputs),
279                        *region,
280                    );
281                    move || {
282                        let tile = produce(&root, region, &context)?;
283                        if let Some(slot) = outputs.get(index) {
284                            *slot
285                                .lock()
286                                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(tile);
287                        }
288                        Ok(())
289                    }
290                })
291                .collect();
292            self.pool.run_all(tasks)?;
293
294            // Hand results to the sink in order.
295            let mut batch_bytes = 0_u64;
296            for (index, region) in batch.iter().enumerate() {
297                let slot = outputs
298                    .get(index)
299                    .ok_or_else(|| PixelsError::graph("output slot vanished"))?;
300                let buffer = slot
301                    .lock()
302                    .unwrap_or_else(std::sync::PoisonError::into_inner)
303                    .take()
304                    .ok_or_else(|| {
305                        PixelsError::graph(format!("output tile {region} was never produced"))
306                    })?;
307                batch_bytes += buffer.bytes().len() as u64;
308                consume(*region, &buffer.as_tile()?)?;
309                stats.output_tiles += 1;
310            }
311            stats.peak_bytes = stats
312                .peak_bytes
313                .max(batch_bytes + materialized_bytes(&materialized));
314        }
315        Ok(stats)
316    }
317
318    /// Realize every node the plan marked, in dependency order.
319    fn materialize(
320        &self,
321        root: &Arc<Node>,
322        plan: &Plan,
323        cacheable: &Arc<std::collections::HashSet<NodeId>>,
324        stats: &mut RunStats,
325    ) -> Result<Arc<HashMap<NodeId, Arc<TileBuf>>>> {
326        let mut materialized: HashMap<NodeId, Arc<TileBuf>> = HashMap::new();
327        for node in dependency_order(root) {
328            let should = plan.node(node.id()).is_some_and(|p| p.materialize);
329            if !should {
330                continue;
331            }
332            // Realized in one pass, forward, so a sequential source is read in
333            // the only order it can be read.
334            let context = Context {
335                cache: Arc::clone(&self.cache),
336                materialized: Arc::new(materialized.clone()),
337                sources: Arc::new(HashMap::new()),
338                cacheable: Arc::clone(cacheable),
339            };
340            let whole = node.descriptor().region();
341            let buffer = produce_uncached(&node, whole, &context)?;
342            stats.materialized_nodes += 1;
343            materialized.insert(node.id(), buffer);
344        }
345        Ok(Arc::new(materialized))
346    }
347
348    /// Pull every source band this batch needs, serially and in order.
349    fn pull_sources(
350        &self,
351        root: &Arc<Node>,
352        batch: &[Region],
353        materialized: &HashMap<NodeId, Arc<TileBuf>>,
354    ) -> Result<HashMap<TileKey, Arc<TileBuf>>> {
355        let mut sources = HashMap::new();
356        for region in batch {
357            for (node, needed) in source_demand(root, *region)? {
358                if materialized.contains_key(&node.id()) {
359                    continue;
360                }
361                let key = TileKey::new(node.id(), needed);
362                if sources.contains_key(&key) {
363                    continue;
364                }
365                let Some(producer) = node.producer() else {
366                    continue;
367                };
368                let mut buffer = TileBuf::zeroed(needed, node.descriptor().pixel)?;
369                producer.produce(needed, &mut buffer.as_tile_mut()?)?;
370                sources.insert(key, Arc::new(buffer));
371            }
372        }
373        Ok(sources)
374    }
375}
376
377/// Total bytes held by materialized intermediates.
378///
379/// Accounted separately from the cache budget, per ADR-0009: a full
380/// intermediate is by definition not bounded by a cache budget.
381fn materialized_bytes(materialized: &HashMap<NodeId, Arc<TileBuf>>) -> u64 {
382    materialized.values().map(|b| b.bytes().len() as u64).sum()
383}
384
385/// Everything a task needs to evaluate one output tile.
386#[derive(Debug)]
387struct Context {
388    cache: Arc<TileCache>,
389    materialized: Arc<HashMap<NodeId, Arc<TileBuf>>>,
390    sources: Arc<HashMap<TileKey, Arc<TileBuf>>>,
391    /// Nodes whose tiles are worth retaining; see [`NodePlan::cacheable`].
392    ///
393    /// [`NodePlan::cacheable`]: crate::NodePlan::cacheable
394    cacheable: Arc<std::collections::HashSet<NodeId>>,
395}
396
397/// Produce `region` of `node`, consulting the cache where that can pay off.
398///
399/// Nodes with a single consumer bypass the cache entirely: their tiles are
400/// consumed exactly once, so retaining them would evict tiles that *are*
401/// reused and would make a streaming pipeline's peak memory equal to the cache
402/// budget rather than a few tiles in flight.
403fn produce(node: &Arc<Node>, region: Region, context: &Context) -> Result<Arc<TileBuf>> {
404    if !context.cacheable.contains(&node.id()) {
405        return produce_uncached(node, region, context);
406    }
407    let key = TileKey::new(node.id(), region);
408    if let Some(tile) = context.cache.get(&key) {
409        return Ok(tile);
410    }
411    let buffer = produce_uncached(node, region, context)?;
412    Ok(context.cache.insert(key, buffer))
413}
414
415/// Produce `region` of `node` without consulting the cache for `node` itself.
416fn produce_uncached(node: &Arc<Node>, region: Region, context: &Context) -> Result<Arc<TileBuf>> {
417    // A materialized ancestor short-circuits everything below it.
418    if let Some(whole) = context.materialized.get(&node.id()) {
419        let mut buffer = TileBuf::zeroed(region, node.descriptor().pixel)?;
420        crate::copy_region(&whole.as_tile()?, &mut buffer.as_tile_mut()?, region)?;
421        return Ok(Arc::new(buffer));
422    }
423
424    if let Some(producer) = node.producer() {
425        // Pre-pulled by `pull_sources` when running a batch; pulled directly
426        // when materializing, where the caller is already serial.
427        let key = TileKey::new(node.id(), region);
428        if let Some(band) = context.sources.get(&key) {
429            return Ok(Arc::clone(band));
430        }
431        let mut buffer = TileBuf::zeroed(region, node.descriptor().pixel)?;
432        producer.produce(region, &mut buffer.as_tile_mut()?)?;
433        return Ok(Arc::new(buffer));
434    }
435
436    let op = node.op().ok_or_else(|| {
437        PixelsError::graph(format!("node `{}` is neither op nor source", node.name()))
438    })?;
439    let descriptors: Vec<_> = node.inputs().iter().map(|n| n.descriptor()).collect();
440    let requested = op.input_regions(region, &descriptors)?;
441    if requested.len() != node.inputs().len() {
442        return Err(PixelsError::graph(format!(
443            "op `{}` requested {} input region(s) for {} input(s)",
444            op.name(),
445            requested.len(),
446            node.inputs().len()
447        )));
448    }
449
450    // Depth-first: each input tile is produced, then consumed immediately, so
451    // the working set is the pipeline depth rather than the whole graph.
452    let mut inputs = Vec::with_capacity(requested.len());
453    for (input, needed) in node.inputs().iter().zip(&requested) {
454        inputs.push(produce(input, *needed, context)?);
455    }
456    let mut tiles = Vec::with_capacity(inputs.len());
457    for buffer in &inputs {
458        tiles.push(buffer.as_tile()?);
459    }
460
461    let mut output = TileBuf::zeroed(region, node.descriptor().pixel)?;
462    op.compute(&tiles, &mut output.as_tile_mut()?)?;
463    Ok(Arc::new(output))
464}
465
466/// The source nodes and regions needed to produce `region` of `root`.
467fn source_demand(root: &Arc<Node>, region: Region) -> Result<Vec<(Arc<Node>, Region)>> {
468    let mut found = Vec::new();
469    let mut stack = vec![(Arc::clone(root), region)];
470    while let Some((node, region)) = stack.pop() {
471        let Some(op) = node.op() else {
472            found.push((node, region));
473            continue;
474        };
475        let descriptors: Vec<_> = node.inputs().iter().map(|n| n.descriptor()).collect();
476        let requested = op.input_regions(region, &descriptors)?;
477        for (input, needed) in node.inputs().iter().zip(requested) {
478            stack.push((Arc::clone(input), needed));
479        }
480    }
481    // Sorting by row keeps a batch's pulls forward-ordered even when the walk
482    // visits branches in an arbitrary order.
483    found.sort_by_key(|(_, region)| (region.y, region.x));
484    Ok(found)
485}
486
487/// Nodes in dependency order: inputs before the nodes that consume them.
488fn dependency_order(root: &Arc<Node>) -> Vec<Arc<Node>> {
489    enum Step {
490        Visit(Arc<Node>),
491        Emit(Arc<Node>),
492    }
493    let mut seen = std::collections::HashSet::new();
494    let mut order = Vec::new();
495    let mut stack = vec![Step::Visit(Arc::clone(root))];
496    while let Some(step) = stack.pop() {
497        match step {
498            Step::Visit(node) => {
499                if seen.contains(&node.id()) {
500                    continue;
501                }
502                stack.push(Step::Emit(Arc::clone(&node)));
503                for input in node.inputs() {
504                    stack.push(Step::Visit(Arc::clone(input)));
505                }
506            }
507            Step::Emit(node) => {
508                if seen.insert(node.id()) {
509                    order.push(node);
510                }
511            }
512        }
513    }
514    order
515}
516
517/// Evaluate `image` into one whole-image buffer using the tile scheduler.
518///
519/// Convenience for callers that want the pixels rather than a stream; the
520/// streaming path is [`Scheduler::run`].
521///
522/// # Errors
523///
524/// As [`Scheduler::run`].
525pub fn evaluate_tiled(image: &Image, options: SchedulerOptions) -> Result<TileBuf> {
526    let scheduler = Scheduler::new(options)?;
527    let descriptor = image.descriptor();
528    let mut out = TileBuf::for_image(&descriptor)?;
529    {
530        let mut view = out.as_tile_mut()?;
531        scheduler.run(image, |region, tile| {
532            crate::copy_region(tile, &mut view, region)
533        })?;
534    }
535    Ok(out)
536}
537
538#[cfg(test)]
539#[allow(
540    clippy::unwrap_used,
541    clippy::indexing_slicing,
542    clippy::panic,
543    reason = "tests operate on known-good values and assert shapes directly"
544)]
545mod tests {
546    use super::*;
547    use crate::testing::{ConstantOp, RampProducer, SumOp};
548    use crate::{
549        AccessPattern, BufferSource, DecodedSource, Decoder, Format, ImageDescriptor, Op,
550        PixelFormat, PixelsError, TileMut, evaluate,
551    };
552
553    #[test]
554    fn the_global_scheduler_is_one_instance_across_threads() {
555        let here = Scheduler::global().unwrap();
556        let there = std::thread::spawn(|| Scheduler::global().unwrap())
557            .join()
558            .unwrap();
559        assert!(Arc::ptr_eq(&here, &there));
560        assert_eq!(here.threads(), ThreadPool::default_threads());
561    }
562
563    fn ramp_image(width: u32, height: u32) -> Image {
564        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Gray8).unwrap();
565        Image::from_producer(Arc::new(RampProducer::new(descriptor)), Format::Raw)
566    }
567
568    /// A decoder emitting a row-numbered ramp, to drive a sequential source.
569    #[derive(Debug)]
570    struct RampDecoder {
571        descriptor: ImageDescriptor,
572        row: u32,
573    }
574
575    impl Decoder for RampDecoder {
576        fn descriptor(&self) -> ImageDescriptor {
577            self.descriptor
578        }
579        fn read_row(&mut self, out: &mut [u8]) -> Result<()> {
580            if self.row >= self.descriptor.height {
581                return Err(PixelsError::invalid_argument("out", "past the end"));
582            }
583            for (x, cell) in out.iter_mut().enumerate() {
584                *cell = (self.row as usize).wrapping_mul(7).wrapping_add(x) as u8;
585            }
586            self.row += 1;
587            Ok(())
588        }
589    }
590
591    /// A pipeline rooted in a forward-only streaming decoder.
592    fn streamed_image(width: u32, height: u32) -> Image {
593        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Gray8).unwrap();
594        let decoder = RampDecoder { descriptor, row: 0 };
595        Image::from_producer(Arc::new(DecodedSource::new(Box::new(decoder))), Format::Raw)
596    }
597
598    /// A pipeline rooted in a random-access memory buffer.
599    fn buffered_image(width: u32, height: u32) -> Image {
600        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Gray8).unwrap();
601        let bytes: Vec<u8> = (0..descriptor.byte_len().unwrap())
602            .map(|i| (i % 251) as u8)
603            .collect();
604        let buffer = TileBuf::from_vec(descriptor.region(), descriptor.pixel, bytes).unwrap();
605        let source = BufferSource::new(descriptor, Arc::new(buffer)).unwrap();
606        Image::from_producer(Arc::new(source), Format::Raw)
607    }
608
609    /// A vertical mirror, like `Flip`: correct demand mapping, reversed order.
610    #[derive(Debug)]
611    struct Reverse;
612    impl Op for Reverse {
613        fn name(&self) -> &'static str {
614            "reverse"
615        }
616        fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
617            inputs
618                .first()
619                .copied()
620                .ok_or_else(|| PixelsError::graph("no input"))
621        }
622        fn input_regions(&self, out: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
623            let input = inputs
624                .first()
625                .ok_or_else(|| PixelsError::graph("no input"))?;
626            let y = (u64::from(input.height) - out.bottom()) as u32;
627            Ok(vec![Region::new(out.x, y, out.width, out.height)])
628        }
629        fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
630            let input = inputs
631                .first()
632                .ok_or_else(|| PixelsError::graph("no input tile"))?;
633            let (out_region, in_region) = (output.region(), input.region());
634            for offset in 0..out_region.height {
635                let from = input
636                    .row(in_region.y + (in_region.height - 1 - offset))
637                    .ok_or_else(|| PixelsError::graph("missing input row"))?;
638                let into = output
639                    .row_mut(out_region.y + offset)
640                    .ok_or_else(|| PixelsError::graph("missing output row"))?;
641                into.copy_from_slice(from);
642            }
643            Ok(())
644        }
645    }
646
647    /// A 3-row neighbourhood op, to exercise square tiles and overlap.
648    #[derive(Debug)]
649    struct RowBlur;
650    impl Op for RowBlur {
651        fn name(&self) -> &'static str {
652            "row-blur"
653        }
654        fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
655            inputs
656                .first()
657                .copied()
658                .ok_or_else(|| PixelsError::graph("no input"))
659        }
660        fn input_regions(&self, out: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
661            let input = inputs
662                .first()
663                .ok_or_else(|| PixelsError::graph("no input"))?;
664            let top = out.y.saturating_sub(1);
665            let bottom = (out.bottom() + 1).min(u64::from(input.height));
666            Ok(vec![Region::new(
667                out.x,
668                top,
669                out.width,
670                (bottom - u64::from(top)) as u32,
671            )])
672        }
673        fn access_pattern(&self) -> AccessPattern {
674            AccessPattern::Spatial
675        }
676        fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
677            let input = inputs
678                .first()
679                .ok_or_else(|| PixelsError::graph("no input tile"))?;
680            let out_region = output.region();
681            let in_region = input.region();
682            for y in out_region.y..out_region.y.saturating_add(out_region.height) {
683                // Average this row with its available neighbours.
684                let mut sums = vec![0_u32; out_region.width as usize];
685                let mut count = 0_u32;
686                for neighbour in [y.saturating_sub(1), y, y + 1] {
687                    if neighbour < in_region.y || u64::from(neighbour) >= in_region.bottom() {
688                        continue;
689                    }
690                    let Some(row) = input.row(neighbour) else {
691                        continue;
692                    };
693                    let start = (out_region.x - in_region.x) as usize;
694                    for (i, sum) in sums.iter_mut().enumerate() {
695                        *sum += u32::from(row[start + i]);
696                    }
697                    count += 1;
698                }
699                let into = output
700                    .row_mut(y)
701                    .ok_or_else(|| PixelsError::graph("missing output row"))?;
702                for (cell, sum) in into.iter_mut().zip(&sums) {
703                    *cell = (sum / count.max(1)) as u8;
704                }
705            }
706            Ok(())
707        }
708    }
709
710    fn options(threads: usize, strip_rows: u32) -> SchedulerOptions {
711        SchedulerOptions {
712            threads,
713            plan: PlanOptions {
714                strip_rows,
715                square_size: 8,
716            },
717            ..SchedulerOptions::default()
718        }
719    }
720
721    // --- Agreement with the M1 oracle (the M2 exit criterion) ----------------
722
723    #[test]
724    fn a_bare_source_matches_the_m1_evaluator() {
725        let image = ramp_image(16, 40);
726        let expected = evaluate(&image).unwrap();
727        let actual = evaluate_tiled(&image, options(4, 8)).unwrap();
728        assert_eq!(actual.bytes(), expected.bytes());
729    }
730
731    #[test]
732    fn a_chain_matches_the_m1_evaluator() {
733        let image = buffered_image(23, 37)
734            .apply(Arc::new(ConstantOp::new(9)))
735            .unwrap()
736            .apply(Arc::new(Reverse))
737            .unwrap();
738        let expected = evaluate(&image).unwrap();
739        let actual = evaluate_tiled(&image, options(4, 8)).unwrap();
740        assert_eq!(actual.bytes(), expected.bytes());
741    }
742
743    #[test]
744    fn agreement_holds_across_tile_sizes_and_thread_counts() {
745        // The core M2 exit criterion, swept: whatever the tiling and however
746        // many workers, the answer is the M1 answer.
747        let image = buffered_image(31, 53).apply(Arc::new(Reverse)).unwrap();
748        let expected = evaluate(&image).unwrap();
749        for threads in [1, 2, 4, 8] {
750            for strip_rows in [1, 3, 8, 64, 1000] {
751                let actual = evaluate_tiled(&image, options(threads, strip_rows)).unwrap();
752                assert_eq!(
753                    actual.bytes(),
754                    expected.bytes(),
755                    "threads={threads} strip_rows={strip_rows}"
756                );
757            }
758        }
759    }
760
761    #[test]
762    fn a_spatial_op_matches_the_m1_evaluator() {
763        // Square tiles plus overlapping demand: the case where getting
764        // input_regions or the seam wrong shows up as wrong borders.
765        let image = buffered_image(20, 20).apply(Arc::new(RowBlur)).unwrap();
766        let expected = evaluate(&image).unwrap();
767        for threads in [1, 4] {
768            let actual = evaluate_tiled(&image, options(threads, 4)).unwrap();
769            assert_eq!(actual.bytes(), expected.bytes(), "threads={threads}");
770        }
771    }
772
773    #[test]
774    fn a_branching_graph_matches_the_m1_evaluator() {
775        let base = buffered_image(16, 24);
776        let left = base.apply(Arc::new(ConstantOp::new(3))).unwrap();
777        let right = base.apply(Arc::new(ConstantOp::new(4))).unwrap();
778        let joined = Image::combine(&[left, right], Arc::new(SumOp)).unwrap();
779        let expected = evaluate(&joined).unwrap();
780        let actual = evaluate_tiled(&joined, options(4, 8)).unwrap();
781        assert_eq!(actual.bytes(), expected.bytes());
782        assert_eq!(actual.bytes()[0], 7);
783    }
784
785    #[test]
786    fn a_streaming_source_matches_the_m1_evaluator() {
787        let build = || {
788            streamed_image(12, 40)
789                .apply(Arc::new(ConstantOp::new(2)))
790                .unwrap()
791        };
792        let expected = evaluate(&build()).unwrap();
793        let actual = evaluate_tiled(&build(), options(4, 8)).unwrap();
794        assert_eq!(actual.bytes(), expected.bytes());
795    }
796
797    #[test]
798    fn reversal_over_a_streaming_source_matches_the_m1_evaluator() {
799        // ADR-0009's hard case end to end: the plan materializes, and the
800        // result is still exactly what the oracle produces.
801        let build = || streamed_image(12, 40).apply(Arc::new(Reverse)).unwrap();
802        let expected = evaluate(&build()).unwrap();
803        let actual = evaluate_tiled(&build(), options(4, 8)).unwrap();
804        assert_eq!(actual.bytes(), expected.bytes());
805    }
806
807    // --- Ordering and streaming ---------------------------------------------
808
809    #[test]
810    fn output_tiles_reach_the_sink_in_order() {
811        // Encoders write rows top to bottom, so this is not negotiable.
812        let image = buffered_image(16, 50);
813        let scheduler = Scheduler::new(options(8, 4)).unwrap();
814        let mut seen = Vec::new();
815        scheduler
816            .run(&image, |region, _| {
817                seen.push(region.y);
818                Ok(())
819            })
820            .unwrap();
821        let mut sorted = seen.clone();
822        sorted.sort_unstable();
823        assert_eq!(seen, sorted, "tiles arrived out of order");
824        assert_eq!(seen.first(), Some(&0));
825    }
826
827    #[test]
828    fn a_forward_pipeline_does_not_materialize() {
829        let image = streamed_image(8, 64)
830            .apply(Arc::new(ConstantOp::new(1)))
831            .unwrap();
832        let scheduler = Scheduler::new(options(4, 8)).unwrap();
833        let stats = scheduler.run(&image, |_, _| Ok(())).unwrap();
834        assert_eq!(stats.materialized_nodes, 0);
835        assert_eq!(stats.output_tiles, 8);
836    }
837
838    #[test]
839    fn reversal_over_a_streaming_source_materializes_exactly_once() {
840        let image = streamed_image(8, 64).apply(Arc::new(Reverse)).unwrap();
841        let scheduler = Scheduler::new(options(4, 8)).unwrap();
842        let stats = scheduler.run(&image, |_, _| Ok(())).unwrap();
843        assert_eq!(stats.materialized_nodes, 1, "ADR-0009 buffer");
844    }
845
846    #[test]
847    fn reversal_over_a_buffer_source_materializes_nothing() {
848        // The ADR-0009 payoff, observed at runtime rather than in the plan.
849        let image = buffered_image(8, 64).apply(Arc::new(Reverse)).unwrap();
850        let scheduler = Scheduler::new(options(4, 8)).unwrap();
851        let stats = scheduler.run(&image, |_, _| Ok(())).unwrap();
852        assert_eq!(
853            stats.materialized_nodes, 0,
854            "a memory source needs no buffer"
855        );
856    }
857
858    // --- Memory --------------------------------------------------------------
859
860    #[test]
861    fn peak_memory_does_not_grow_with_image_height() {
862        // SPEC §Guarantees 1. The same pipeline over a 10x taller image must
863        // not cost 10x the memory.
864        let measure = |height: u32| {
865            let image = streamed_image(64, height)
866                .apply(Arc::new(ConstantOp::new(1)))
867                .unwrap();
868            let scheduler = Scheduler::new(options(2, 8)).unwrap();
869            scheduler.run(&image, |_, _| Ok(())).unwrap().peak_bytes
870        };
871        let small = measure(64);
872        let large = measure(4096);
873        assert_eq!(
874            small, large,
875            "peak memory scaled with height: {small} vs {large}"
876        );
877    }
878
879    #[test]
880    fn materializing_pipelines_report_their_buffer() {
881        // The honest accounting ADR-0009 requires: the buffer is visible, not
882        // hidden inside a cache budget it does not respect.
883        let image = streamed_image(64, 256).apply(Arc::new(Reverse)).unwrap();
884        let scheduler = Scheduler::new(options(2, 8)).unwrap();
885        let stats = scheduler.run(&image, |_, _| Ok(())).unwrap();
886        assert!(
887            stats.peak_bytes >= 64 * 256,
888            "a materialized full image should be accounted: {}",
889            stats.peak_bytes
890        );
891    }
892
893    #[test]
894    fn a_tiny_cache_budget_changes_cost_not_correctness() {
895        // The cache is an optimisation; evicting everything must not alter the
896        // pixels, only how often they are recomputed.
897        let image = buffered_image(16, 32)
898            .apply(Arc::new(ConstantOp::new(5)))
899            .unwrap();
900        let expected = evaluate(&image).unwrap();
901        for budget in [0, 1, 64, 1024, 1 << 20] {
902            let options = SchedulerOptions {
903                threads: 4,
904                cache_budget: budget,
905                plan: PlanOptions {
906                    strip_rows: 4,
907                    square_size: 8,
908                },
909                ..SchedulerOptions::default()
910            };
911            let actual = evaluate_tiled(&image, options).unwrap();
912            assert_eq!(actual.bytes(), expected.bytes(), "cache_budget={budget}");
913        }
914    }
915
916    // --- Failure -------------------------------------------------------------
917
918    #[test]
919    fn a_failing_op_fails_the_run() {
920        let image = buffered_image(16, 32)
921            .apply(Arc::new(crate::testing::FailingOp))
922            .unwrap();
923        let err = evaluate_tiled(&image, options(4, 8)).unwrap_err();
924        assert_eq!(err.code(), crate::ErrorCode::Malformed);
925    }
926
927    #[test]
928    fn a_failing_sink_stops_the_run() {
929        let image = buffered_image(16, 32);
930        let scheduler = Scheduler::new(options(4, 8)).unwrap();
931        let mut seen = 0;
932        let err = scheduler
933            .run(&image, |_, _| {
934                seen += 1;
935                Err(PixelsError::unsupported("sink refused"))
936            })
937            .unwrap_err();
938        assert_eq!(err.code(), crate::ErrorCode::Unsupported);
939        assert_eq!(seen, 1, "the sink should not be called again after failing");
940    }
941
942    #[test]
943    fn failures_are_deterministic_across_runs() {
944        // SPEC §Guarantees 2 extends to errors, not just pixels.
945        let image = buffered_image(16, 64)
946            .apply(Arc::new(crate::testing::FailingOp))
947            .unwrap();
948        let first = evaluate_tiled(&image, options(8, 4))
949            .unwrap_err()
950            .to_string();
951        for _ in 0..10 {
952            assert_eq!(
953                evaluate_tiled(&image, options(8, 4))
954                    .unwrap_err()
955                    .to_string(),
956                first
957            );
958        }
959    }
960
961    // --- Configuration -------------------------------------------------------
962
963    #[test]
964    fn defaults_produce_a_usable_scheduler() {
965        let scheduler = Scheduler::with_defaults().unwrap();
966        assert!(scheduler.threads() >= 1);
967        assert_eq!(scheduler.cache().budget(), TileCache::DEFAULT_BUDGET);
968        let image = buffered_image(8, 8);
969        let stats = scheduler.run(&image, |_, _| Ok(())).unwrap();
970        assert_eq!(stats.output_tiles, 1);
971    }
972
973    #[test]
974    fn one_scheduler_runs_many_pipelines() {
975        let scheduler = Scheduler::new(options(4, 8)).unwrap();
976        for height in [8_u32, 16, 32] {
977            let image = buffered_image(8, height);
978            let expected = evaluate(&image).unwrap();
979            let mut out = TileBuf::for_image(&image.descriptor()).unwrap();
980            {
981                let mut view = out.as_tile_mut().unwrap();
982                scheduler
983                    .run(&image, |region, tile| {
984                        crate::copy_region(tile, &mut view, region)
985                    })
986                    .unwrap();
987            }
988            assert_eq!(out.bytes(), expected.bytes(), "height={height}");
989        }
990    }
991}