Skip to main content

ruda_model/data/dataloader/
multithread.rs

1use ruda_dataset::Dataset;
2use ruda_dataset::transform::PartialDataset;
3use ruda_tensor::api::backend::Backend;
4use rand::distr::{Distribution, StandardUniform};
5use rand::rngs::StdRng;
6use rand::{Rng, SeedableRng};
7
8use super::batcher::Batcher;
9use super::{BatchDataLoader, BatchStrategy, DataLoader, DataLoaderIterator, Progress};
10use std::sync::{
11    Arc, Mutex, OnceLock,
12    atomic::{AtomicBool, Ordering},
13    mpsc,
14};
15use std::thread;
16
17const MAX_QUEUED_ITEMS: usize = 100;
18
19type RngSeed = <StdRng as SeedableRng>::Seed;
20
21/// A multi-threaded data loader that can be used to iterate over a dataset.
22///
23/// Dropping an iterator stops prefetching and waits for any dataset read or batch
24/// construction already in progress to return. Workers are joined unless reuse
25/// is explicitly enabled with [`Self::reuse_workers`].
26pub struct MultiThreadDataLoader<B: Backend, I, O> {
27    // Configuration parameters needed for initialization
28    strategy: Box<dyn BatchStrategy<I>>,
29    dataset: Arc<dyn Dataset<I>>,
30    batcher: Arc<dyn Batcher<B, I, O>>,
31    device: B::Device,
32    seed: Option<RngSeed>,
33    num_threads: usize,
34    reuse_workers: bool,
35    batch_reading: bool,
36
37    // The lazily initialized data loaders
38    dataloaders: OnceLock<Vec<BatchDataLoader<B, I, O>>>,
39    worker_pool: OnceLock<Arc<Mutex<WorkerPoolState<O>>>>,
40}
41
42/// A message that can be sent between threads.
43#[derive(Debug, crate::record::Record)]
44pub enum Message<O> {
45    /// A batch of items.
46    Batch(usize, O, Progress),
47
48    /// The thread is done.
49    Done,
50}
51
52struct MultiThreadsDataloaderIterator<O> {
53    num_done: usize,
54    num_workers: usize,
55    workers: Vec<thread::JoinHandle<()>>,
56    reused_workers: Option<WorkerEpoch<O>>,
57    receiver: Option<mpsc::Receiver<Message<O>>>,
58    progresses: Vec<Progress>,
59    items_total: usize,
60    items_processed: usize,
61    cancelled: Arc<AtomicBool>,
62}
63
64enum WorkerPoolState<O> {
65    Uninitialized,
66    Idle(WorkerPool<O>),
67    InUse,
68}
69
70struct WorkerJob<O> {
71    sender: mpsc::SyncSender<Message<O>>,
72    cancelled: Arc<AtomicBool>,
73    finished: mpsc::Sender<bool>,
74    load: Box<dyn FnOnce(&mpsc::SyncSender<Message<O>>, &AtomicBool) + Send>,
75}
76
77struct WorkerCompletion(mpsc::Sender<bool>);
78
79impl Drop for WorkerCompletion {
80    fn drop(&mut self) {
81        // Preserve the original panic in the JoinHandle rather than catching it
82        // and continuing to use the failed worker's device/thread-local state.
83        let _ = self.0.send(thread::panicking());
84    }
85}
86
87struct WorkerPool<O> {
88    senders: Vec<mpsc::Sender<WorkerJob<O>>>,
89    workers: Vec<thread::JoinHandle<()>>,
90}
91
92impl<O> WorkerPool<O> {
93    fn shutdown(&mut self) -> thread::Result<()> {
94        self.senders.clear();
95        let mut result = Ok(());
96        for worker in self.workers.drain(..) {
97            if let Err(payload) = worker.join() {
98                if result.is_ok() {
99                    result = Err(payload);
100                }
101            }
102        }
103        result
104    }
105}
106
107impl<O> Drop for WorkerPool<O> {
108    fn drop(&mut self) {
109        let _ = self.shutdown();
110    }
111}
112
113struct WorkerLease<O> {
114    pool: Option<WorkerPool<O>>,
115    cache: Option<Arc<Mutex<WorkerPoolState<O>>>>,
116}
117
118impl<O> WorkerLease<O> {
119    fn release(mut self) {
120        let pool = self.pool.take().expect("leased worker pool");
121        let cache = self.cache.take().expect("worker pool cache");
122        *cache.lock().unwrap_or_else(|error| error.into_inner()) = WorkerPoolState::Idle(pool);
123    }
124}
125
126impl<O> Drop for WorkerLease<O> {
127    fn drop(&mut self) {
128        if let Some(cache) = self.cache.take() {
129            // This also handles partial initialization/dispatch. Close and join
130            // outside the cache lock before allowing a new pool to be created.
131            drop(self.pool.take());
132            *cache.lock().unwrap_or_else(|error| error.into_inner()) =
133                WorkerPoolState::Uninitialized;
134        }
135    }
136}
137
138struct WorkerEpoch<O> {
139    lease: WorkerLease<O>,
140    finished: mpsc::Receiver<bool>,
141    dispatched: usize,
142    failed: bool,
143}
144
145impl<O> WorkerEpoch<O> {
146    fn finish(mut self) -> thread::Result<()> {
147        for _ in 0..self.dispatched {
148            match self.finished.recv() {
149                Ok(panicked) => self.failed |= panicked,
150                Err(_) => {
151                    self.failed = true;
152                    break;
153                }
154            }
155        }
156        if self.failed {
157            let result = self
158                .lease
159                .pool
160                .as_mut()
161                .expect("leased worker pool")
162                .shutdown();
163            return match result {
164                Err(payload) => Err(payload),
165                Ok(()) => Err(Box::new(String::from("data loader worker failed to complete"))),
166            };
167        }
168        self.lease.release();
169        Ok(())
170    }
171}
172
173fn run_worker<B: Backend, I, O>(
174    dataloader: &BatchDataLoader<B, I, O>,
175    index: usize,
176    sender: &mpsc::SyncSender<Message<O>>,
177    cancelled: &AtomicBool,
178) where
179    I: Send + Sync + Clone + 'static,
180    O: Send + 'static,
181{
182    if cancelled.load(Ordering::Relaxed) {
183        return;
184    }
185    let mut iterator = dataloader.iter();
186    while !cancelled.load(Ordering::Relaxed) {
187        let Some(item) = iterator.next() else {
188            break;
189        };
190        let progress = iterator.progress();
191        if sender.send(Message::Batch(index, item, progress)).is_err() {
192            return;
193        }
194    }
195    sender.send(Message::Done).ok();
196}
197
198impl<B: Backend, I, O> MultiThreadDataLoader<B, I, O>
199where
200    I: Send + Sync + Clone + 'static,
201    O: Send + 'static,
202{
203    /// Creates a new multi-threaded batch data loader.
204    ///
205    /// # Arguments
206    ///
207    /// * `strategy` - The batch strategy.
208    /// * `dataset` - The dataset.
209    /// * `batcher` - The batcher.
210    /// * `num_threads` - The number of threads.
211    /// * `device`  - The device to use when loading a batch.
212    /// * `rng`     - The rng determining if the dataset is shuffled each time a dataloader
213    ///   iterator is created.
214    ///
215    /// # Returns
216    ///
217    /// The multi-threaded batch data loader.
218    pub fn new(
219        strategy: Box<dyn BatchStrategy<I>>,
220        dataset: Arc<dyn Dataset<I>>,
221        batcher: Arc<dyn Batcher<B, I, O>>,
222        num_threads: usize,
223        device: B::Device,
224        rng: Option<rand::rngs::StdRng>,
225    ) -> Self {
226        let mut seed = None;
227        if let Some(mut rng) = rng {
228            // RNG stream splitting (not state cloning): derive a new seed from the RNG's output.
229            // This is exactly what `rng.fork()` does.
230            let mut s = RngSeed::default();
231            rng.fill_bytes(&mut s);
232
233            seed = Some(s);
234        }
235        Self::from_seed(strategy, dataset, batcher, num_threads, device, seed)
236    }
237
238    fn from_seed(
239        strategy: Box<dyn BatchStrategy<I>>,
240        dataset: Arc<dyn Dataset<I>>,
241        batcher: Arc<dyn Batcher<B, I, O>>,
242        num_threads: usize,
243        device: B::Device,
244        seed: Option<RngSeed>,
245    ) -> Self {
246        Self {
247            strategy,
248            dataset,
249            batcher,
250            num_threads,
251            device,
252            seed,
253            dataloaders: OnceLock::new(),
254            reuse_workers: false,
255            batch_reading: false,
256            worker_pool: OnceLock::new(),
257        }
258    }
259
260    /// Enables reuse of idle workers across iterations (disabled by default).
261    ///
262    /// Finishing or dropping an iterator still waits for its in-flight reads and
263    /// batch construction. Concurrent iterators use separate transient workers
264    /// when the reusable pool is busy, rather than waiting behind another epoch.
265    /// A panicked worker is joined; failed batches are never automatically replayed.
266    pub fn reuse_workers(mut self, enabled: bool) -> Self {
267        self.reuse_workers = enabled;
268        if !enabled {
269            drop(self.worker_pool.take());
270        }
271        self
272    }
273
274    /// Enables batch dataset reads for every worker (default: false).
275    ///
276    /// Missing items or a mismatched result length panic without rereading; the
277    /// iterator propagates worker failures using its existing shutdown behavior.
278    pub fn batch_reading(mut self, enabled: bool) -> Self {
279        self.batch_reading = enabled;
280        if let Some(dataloaders) = self.dataloaders.get_mut() {
281            for dataloader in dataloaders {
282                dataloader.set_batch_reading(enabled);
283            }
284        }
285        self
286    }
287
288    fn lease_workers(&self, num_workers: usize) -> Option<WorkerLease<O>> {
289        if !self.reuse_workers {
290            return None;
291        }
292        let worker_pool = self
293            .worker_pool
294            .get_or_init(|| Arc::new(Mutex::new(WorkerPoolState::Uninitialized)));
295        let pool = {
296            let mut cache = worker_pool
297                .lock()
298                .unwrap_or_else(|error| error.into_inner());
299            if matches!(*cache, WorkerPoolState::InUse) {
300                return None;
301            }
302            match std::mem::replace(&mut *cache, WorkerPoolState::InUse) {
303                WorkerPoolState::Idle(pool) => Some(pool),
304                WorkerPoolState::Uninitialized => None,
305                WorkerPoolState::InUse => unreachable!(),
306            }
307        };
308        let mut lease = WorkerLease {
309            pool,
310            cache: Some(worker_pool.clone()),
311        };
312        if lease.pool.is_none() {
313            let mut pool = WorkerPool {
314                senders: Vec::with_capacity(num_workers),
315                workers: Vec::with_capacity(num_workers),
316            };
317            for index in 0..num_workers {
318                let (sender, receiver) = mpsc::channel::<WorkerJob<O>>();
319                let worker = thread::Builder::new()
320                    .name(std::format!("dataloader-{index}"))
321                    .spawn(move || {
322                        while let Ok(job) = receiver.recv() {
323                            let completion = WorkerCompletion(job.finished);
324                            (job.load)(&job.sender, &job.cancelled);
325                            drop(job.sender);
326                            drop(job.cancelled);
327                            drop(completion);
328                        }
329                    })
330                    .expect("failed to spawn reusable data loader worker");
331                pool.senders.push(sender);
332                pool.workers.push(worker);
333            }
334            lease.pool = Some(pool);
335        }
336        Some(lease)
337    }
338
339    /// Force initialization if needed.
340    fn initialize(&self) -> &[BatchDataLoader<B, I, O>] {
341        self.dataloaders
342            .get_or_init(|| {
343                let mut dataset = self.dataset.clone();
344                if let Some(seed) = self.seed.as_ref() {
345                    // Pre-shuffle the dataset before split if shuffle is enabled.
346                    // This ensures that each thread gets a uniform random sample of the dataset.
347                    let mut rng = StdRng::from_seed(*seed);
348                    dataset = Arc::new(ruda_dataset::transform::ShuffledDataset::new(
349                        dataset, &mut rng,
350                    ));
351                }
352
353                let datasets = match self.strategy.batch_size() {
354                    Some(batch_size) => {
355                        let split_size = if self.batch_reading { batch_size.max(1) } else { batch_size };
356                        PartialDataset::split_chunks(dataset, self.num_threads, split_size)
357                    }
358                    None => PartialDataset::split(dataset, self.num_threads),
359                };
360
361                // Create more rngs from the first one, one for each new dataloader.
362                let mut rng = self.seed.map(StdRng::from_seed);
363                let rngs = (0..self.num_threads).map(|_| {
364                    rng.as_mut().map(|rng| {
365                        StdRng::seed_from_u64(Distribution::sample(&StandardUniform, rng))
366                    })
367                });
368
369                datasets
370                    .into_iter()
371                    .zip(rngs)
372                    .map(|(dataset, rng)| {
373                        let strategy = self.strategy.clone_dyn();
374                        BatchDataLoader::new(
375                            strategy,
376                            Arc::new(dataset),
377                            self.batcher.clone(),
378                            self.device.clone(),
379                            rng,
380                        )
381                        .batch_reading(self.batch_reading)
382                    })
383                    .collect()
384            })
385            .as_ref()
386    }
387}
388
389impl<B: Backend, I, O> DataLoader<B, O> for MultiThreadDataLoader<B, I, O>
390where
391    I: Send + Sync + Clone + 'static,
392    O: Send + 'static + std::fmt::Debug,
393{
394    fn iter<'a>(&'a self) -> Box<dyn DataLoaderIterator<O> + 'a> {
395        // This will initialize the loader if it hasn't been initialized yet
396        let dataloaders = self.initialize();
397
398        let (sender, receiver) = mpsc::sync_channel::<Message<O>>(MAX_QUEUED_ITEMS);
399
400        let progresses = dataloaders
401            .iter()
402            .map(|dataloader| Progress::new(0, dataloader.num_items()))
403            .collect();
404        let mut iterator = MultiThreadsDataloaderIterator::new(
405            receiver,
406            Vec::with_capacity(dataloaders.len()),
407            progresses,
408        );
409
410        if let Some(lease) = self.lease_workers(dataloaders.len()) {
411            let (finished, receiver) = mpsc::channel();
412            iterator.reused_workers = Some(WorkerEpoch {
413                lease,
414                finished: receiver,
415                dispatched: 0,
416                failed: true,
417            });
418            let epoch = iterator.reused_workers.as_mut().expect("worker epoch");
419            for (index, (worker, dataloader)) in epoch
420                .lease
421                .pool
422                .as_ref()
423                .expect("leased worker pool")
424                .senders
425                .iter()
426                .zip(dataloaders)
427                .enumerate()
428            {
429                let dataloader = dataloader.clone();
430                worker
431                    .send(WorkerJob {
432                        sender: sender.clone(),
433                        cancelled: iterator.cancelled.clone(),
434                        finished: finished.clone(),
435                        load: Box::new(move |sender, cancelled| {
436                            run_worker(&dataloader, index, sender, cancelled);
437                        }),
438                    })
439                    .unwrap_or_else(|_| panic!("reusable data loader worker disconnected"));
440                epoch.dispatched += 1;
441            }
442            epoch.failed = false;
443            drop(finished);
444        } else {
445            for (index, dataloader) in dataloaders.iter().enumerate() {
446                let dataloader_cloned = dataloader.clone();
447                let sender_cloned = sender.clone();
448                let cancelled = iterator.cancelled.clone();
449                let worker = std::thread::Builder::new()
450                    .name(std::format!("dataloader-{index}"))
451                    .spawn(move || {
452                        run_worker(&dataloader_cloned, index, &sender_cloned, &cancelled);
453                    })
454                    .expect("failed to spawn data loader worker");
455                iterator.workers.push(worker);
456            }
457        }
458        drop(sender);
459
460        Box::new(iterator)
461    }
462
463    fn num_items(&self) -> usize {
464        // For num_items, we can directly use the dataset size without
465        // necessarily initializing the full loader
466        self.dataset.len()
467    }
468
469    fn to_device(&self, device: &B::Device) -> Arc<dyn DataLoader<B, O>> {
470        Arc::new(
471            Self::from_seed(
472                self.strategy.clone_dyn(),
473                self.dataset.clone(),
474                self.batcher.clone(),
475                self.num_threads,
476                device.clone(),
477                self.seed,
478            )
479            .reuse_workers(self.reuse_workers)
480            .batch_reading(self.batch_reading),
481        )
482    }
483
484    fn slice(&self, start: usize, end: usize) -> Arc<dyn DataLoader<B, O>> {
485        let dataloader = Self::from_seed(
486            self.strategy.clone_dyn(),
487            Arc::new(PartialDataset::new(self.dataset.clone(), start, end)),
488            self.batcher.clone(),
489            self.num_threads,
490            self.device.clone(),
491            self.seed,
492        )
493        .reuse_workers(self.reuse_workers)
494        .batch_reading(self.batch_reading);
495        Arc::new(dataloader)
496    }
497}
498
499impl<O> MultiThreadsDataloaderIterator<O> {
500    pub fn new(
501        receiver: mpsc::Receiver<Message<O>>,
502        workers: Vec<thread::JoinHandle<()>>,
503        progresses: Vec<Progress>,
504    ) -> Self {
505        MultiThreadsDataloaderIterator {
506            num_done: 0,
507            num_workers: progresses.len(),
508            workers,
509            reused_workers: None,
510            receiver: Some(receiver),
511            items_total: progresses.iter().map(|progress| progress.items_total).sum(),
512            items_processed: progresses.iter().map(|progress| progress.items_processed).sum(),
513            progresses,
514            cancelled: Arc::new(AtomicBool::new(false)),
515        }
516    }
517
518    fn shutdown(&mut self) -> thread::Result<()> {
519        self.cancelled.store(true, Ordering::Relaxed);
520        // Disconnect before joining so workers blocked on a full queue can exit.
521        drop(self.receiver.take());
522        let mut result = match self.reused_workers.take() {
523            Some(epoch) => epoch.finish(),
524            None => Ok(()),
525        };
526        for worker in self.workers.drain(..) {
527            if let Err(payload) = worker.join() {
528                if result.is_ok() {
529                    result = Err(payload);
530                }
531            }
532        }
533        result
534    }
535}
536
537impl<O> Drop for MultiThreadsDataloaderIterator<O> {
538    fn drop(&mut self) {
539        // Worker panics are propagated by next(), not during unwinding in Drop.
540        let _ = self.shutdown();
541    }
542}
543impl<O: std::fmt::Debug> DataLoaderIterator<O> for MultiThreadsDataloaderIterator<O> {
544    fn progress(&self) -> Progress {
545        Progress::new(self.items_processed, self.items_total)
546    }
547}
548
549impl<O: std::fmt::Debug> Iterator for MultiThreadsDataloaderIterator<O> {
550    type Item = O;
551
552    fn next(&mut self) -> Option<O> {
553        if self.receiver.is_none() || self.num_workers == 0 {
554            return None;
555        }
556
557        loop {
558            let item = match self.receiver.as_ref()?.recv() {
559                Ok(item) => item,
560                Err(_) => {
561                    if let Err(payload) = self.shutdown() {
562                        std::panic::resume_unwind(payload);
563                    }
564                    panic!("data loader workers disconnected before reporting completion");
565                }
566            };
567
568            match item {
569                Message::Batch(index, item, progress) => {
570                    if let Some(current) = self.progresses.get_mut(index) {
571                        self.items_processed =
572                            self.items_processed - current.items_processed + progress.items_processed;
573                        self.items_total =
574                            self.items_total - current.items_total + progress.items_total;
575                        *current = progress;
576                    }
577                    return Some(item);
578                }
579                Message::Done => {
580                    self.num_done += 1;
581                }
582            };
583
584            if self.num_done == self.num_workers {
585                if let Err(payload) = self.shutdown() {
586                    std::panic::resume_unwind(payload);
587                }
588                return None;
589            }
590        }
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::data::dataloader::FixBatchStrategy;
598    use crate::data::dataloader::batcher::TestBatcher;
599    use crate::data::dataset::FakeDataset;
600    use ruda_dataset::InMemDataset;
601    use std::collections::HashSet;
602
603    #[test]
604    fn test_multi_thread_batch_dataloader() {
605        let batcher = Arc::new(TestBatcher::new());
606        let dataset = Arc::new(FakeDataset::<String>::new(27));
607        let dataloader_single_thread = BatchDataLoader::new(
608            Box::new(FixBatchStrategy::new(5)),
609            dataset.clone(),
610            batcher.clone(),
611            Default::default(),
612            None,
613        );
614        let dataloader_multi_thread = MultiThreadDataLoader::new(
615            Box::new(FixBatchStrategy::new(5)),
616            dataset,
617            batcher,
618            4,
619            Default::default(),
620            None,
621        );
622
623        let mut items_single_thread = HashSet::new();
624        let mut items_multi_thread = HashSet::new();
625
626        for items in dataloader_single_thread.iter() {
627            for item in items {
628                items_single_thread.insert(item);
629            }
630        }
631
632        for items in dataloader_multi_thread.iter() {
633            for item in items {
634                items_multi_thread.insert(item);
635            }
636        }
637
638        assert_eq!(items_single_thread, items_multi_thread);
639    }
640
641    #[test]
642    fn test_multi_thread_batch_dataloader_shuffle() {
643        let num_classes = 2;
644        let class_size = 100;
645        let batch_size = 10;
646
647        // Items is a deliberately ordered dataset.
648        let mut items = Vec::new();
649        for class in 0..num_classes {
650            items.extend(vec![class; class_size]);
651        }
652
653        {
654            // Unshuffled multithreaded loader
655            let dataset = Arc::new(InMemDataset::new(items.clone()));
656            let batcher = Arc::new(TestBatcher::new());
657
658            let loader = MultiThreadDataLoader::new(
659                Box::new(FixBatchStrategy::new(batch_size)),
660                dataset,
661                batcher,
662                num_classes,
663                Default::default(),
664                // No rng means no shuffling.
665                None,
666            );
667
668            for batch in loader.iter() {
669                let mut batch_items = HashSet::new();
670                for item in batch {
671                    batch_items.insert(item);
672                }
673
674                // Since the dataset is not shuffled, we expect each batch to contain the same item.
675                assert_eq!(batch_items.len(), 1);
676            }
677        }
678
679        {
680            // Shuffled multithreaded loader
681            let dataset = Arc::new(InMemDataset::new(items.clone()));
682            let batcher = Arc::new(TestBatcher::new());
683
684            let loader = MultiThreadDataLoader::new(
685                Box::new(FixBatchStrategy::new(batch_size)),
686                dataset.clone(),
687                batcher.clone(),
688                num_classes,
689                Default::default(),
690                // The rng enables shuffling.
691                Some(StdRng::seed_from_u64(42)),
692            );
693
694            for batch in loader.iter() {
695                let mut batch_items = HashSet::new();
696                for item in batch {
697                    batch_items.insert(item);
698                }
699
700                // Since the dataset is shuffled, we expect to see all items.
701                assert_eq!(batch_items.len(), num_classes);
702            }
703        }
704    }
705
706    #[test]
707    fn test_multi_thread_batch_dataloader_incomplete_batches() {
708        let batcher = Arc::new(TestBatcher::new());
709        let dataset = Arc::new(FakeDataset::<String>::new(27));
710        let dataloader_single_thread = BatchDataLoader::new(
711            Box::new(FixBatchStrategy::new(5)),
712            dataset.clone(),
713            batcher.clone(),
714            Default::default(),
715            None,
716        );
717        let dataloader_multi_thread = MultiThreadDataLoader::new(
718            Box::new(FixBatchStrategy::new(5)),
719            dataset,
720            batcher,
721            4,
722            Default::default(),
723            None,
724        );
725
726        let mut items_single_thread = HashSet::new();
727        let mut items_multi_thread = HashSet::new();
728
729        let mut single_thread_cnt = 0;
730        let mut multi_thread_cnt = 0;
731        for items in dataloader_single_thread.iter() {
732            items_single_thread.insert(items);
733            single_thread_cnt += 1;
734        }
735
736        for items in dataloader_multi_thread.iter() {
737            items_multi_thread.insert(items);
738            multi_thread_cnt += 1;
739        }
740
741        assert_eq!(single_thread_cnt, multi_thread_cnt);
742        assert_eq!(items_single_thread, items_multi_thread);
743    }
744}