1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[non_exhaustive]
51pub struct SchedulerOptions {
52 pub threads: usize,
54 pub cache_budget: usize,
56 pub batch_tiles: usize,
61 pub plan: PlanOptions,
63}
64
65impl SchedulerOptions {
66 #[must_use]
71 pub const fn with_threads(mut self, threads: usize) -> Self {
72 self.threads = threads;
73 self
74 }
75
76 #[must_use]
78 pub const fn with_cache_budget(mut self, bytes: usize) -> Self {
79 self.cache_budget = bytes;
80 self
81 }
82
83 #[must_use]
85 pub const fn with_batch_tiles(mut self, tiles: usize) -> Self {
86 self.batch_tiles = tiles;
87 self
88 }
89
90 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111#[non_exhaustive]
112pub struct RunStats {
113 pub output_tiles: u64,
115 pub materialized_nodes: u64,
117 pub peak_bytes: u64,
122 pub reduction: Option<crate::Reduction>,
138}
139
140#[derive(Debug)]
144pub struct Scheduler {
145 pool: ThreadPool,
146 cache: Arc<TileCache>,
147 options: SchedulerOptions,
148}
149
150impl Scheduler {
151 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 pub fn with_defaults() -> Result<Self> {
175 Self::new(SchedulerOptions::default())
176 }
177
178 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 let built = Arc::new(Self::with_defaults()?);
201 Ok(Arc::clone(GLOBAL.get_or_init(|| built)))
202 }
203
204 #[must_use]
206 pub const fn threads(&self) -> usize {
207 self.pool.threads()
208 }
209
210 #[must_use]
212 pub fn cache(&self) -> &Arc<TileCache> {
213 &self.cache
214 }
215
216 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 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 let materialized = self.materialize(&root, &plan, &cacheable, &mut stats)?;
246
247 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 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 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 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 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 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 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
377fn materialized_bytes(materialized: &HashMap<NodeId, Arc<TileBuf>>) -> u64 {
382 materialized.values().map(|b| b.bytes().len() as u64).sum()
383}
384
385#[derive(Debug)]
387struct Context {
388 cache: Arc<TileCache>,
389 materialized: Arc<HashMap<NodeId, Arc<TileBuf>>>,
390 sources: Arc<HashMap<TileKey, Arc<TileBuf>>>,
391 cacheable: Arc<std::collections::HashSet<NodeId>>,
395}
396
397fn 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
415fn produce_uncached(node: &Arc<Node>, region: Region, context: &Context) -> Result<Arc<TileBuf>> {
417 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 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 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
466fn 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 found.sort_by_key(|(_, region)| (region.y, region.x));
484 Ok(found)
485}
486
487fn 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
517pub 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 #[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 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 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 #[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 #[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 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 #[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 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 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 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 #[test]
810 fn output_tiles_reach_the_sink_in_order() {
811 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 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 #[test]
861 fn peak_memory_does_not_grow_with_image_height() {
862 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 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 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 #[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 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 #[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}