Skip to main content

burn_core/data/dataloader/
multithread.rs

1use burn_dataset::Dataset;
2use burn_dataset::transform::PartialDataset;
3use burn_tensor::Device;
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::{Arc, OnceLock, mpsc, mpsc::SyncSender};
11use std::thread;
12
13const MAX_QUEUED_ITEMS: usize = 100;
14
15type RngSeed = <StdRng as SeedableRng>::Seed;
16
17/// A multi-threaded data loader that can be used to iterate over a dataset.
18pub struct MultiThreadDataLoader<I, O> {
19    // Configuration parameters needed for initialization
20    strategy: Box<dyn BatchStrategy<I>>,
21    dataset: Arc<dyn Dataset<I>>,
22    batcher: Arc<dyn Batcher<I, O>>,
23    device: Device,
24    seed: Option<RngSeed>,
25    num_threads: usize,
26
27    // The lazily initialized data loaders
28    dataloaders: OnceLock<Vec<BatchDataLoader<I, O>>>,
29
30    // Spawned once and reused across every `iter()` call so each worker keeps a
31    // stable CubeCL stream (and its memory pool) instead of leaking one per epoch (#4792).
32    workers: OnceLock<WorkerPool<O>>,
33}
34
35/// A message that can be sent between threads.
36#[derive(Debug)]
37pub enum Message<O> {
38    /// A batch of items.
39    Batch(usize, O, Progress),
40
41    /// The thread is done.
42    Done,
43
44    /// The worker hit an unrecoverable error (e.g. `Dataset::get` failed) and stopped early.
45    Error(usize, String),
46}
47
48struct MultiThreadsDataloaderIterator<O> {
49    num_done: usize,
50    num_workers: usize,
51    receiver: mpsc::Receiver<Message<O>>,
52    progresses: Vec<Progress>,
53}
54
55/// Per-epoch channel a worker streams its batches into; handed to the worker to start a pass.
56type WorkerCommand<O> = SyncSender<Message<O>>;
57
58struct WorkerPool<O> {
59    /// One command channel per worker; sending a per-epoch sender starts a pass.
60    senders: Vec<mpsc::Sender<WorkerCommand<O>>>,
61    handles: Vec<thread::JoinHandle<()>>,
62    item_counts: Vec<usize>,
63}
64
65impl<O> Drop for WorkerPool<O> {
66    fn drop(&mut self) {
67        // Dropping the senders makes each worker's `recv()` return Err, ending its loop.
68        self.senders.clear();
69        for handle in self.handles.drain(..) {
70            let _ = handle.join();
71        }
72    }
73}
74
75impl<I, O> MultiThreadDataLoader<I, O>
76where
77    I: Send + Sync + Clone + 'static,
78    O: Send + 'static,
79{
80    /// Creates a new multi-threaded batch data loader.
81    ///
82    /// # Arguments
83    ///
84    /// * `strategy` - The batch strategy.
85    /// * `dataset` - The dataset.
86    /// * `batcher` - The batcher.
87    /// * `num_threads` - The number of threads.
88    /// * `device`  - The device to use when loading a batch.
89    /// * `rng`     - The rng determining if the dataset is shuffled each time a dataloader
90    ///   iterator is created.
91    ///
92    /// # Returns
93    ///
94    /// The multi-threaded batch data loader.
95    pub fn new(
96        strategy: Box<dyn BatchStrategy<I>>,
97        dataset: Arc<dyn Dataset<I>>,
98        batcher: Arc<dyn Batcher<I, O>>,
99        num_threads: usize,
100        device: Device,
101        rng: Option<rand::rngs::StdRng>,
102    ) -> Self {
103        let mut seed = None;
104        if let Some(mut rng) = rng {
105            // RNG stream splitting (not state cloning): derive a new seed from the RNG's output.
106            // This is exactly what `rng.fork()` does.
107            let mut s = RngSeed::default();
108            rng.fill_bytes(&mut s);
109
110            seed = Some(s);
111        }
112        Self::from_seed(strategy, dataset, batcher, num_threads, device, seed)
113    }
114
115    fn from_seed(
116        strategy: Box<dyn BatchStrategy<I>>,
117        dataset: Arc<dyn Dataset<I>>,
118        batcher: Arc<dyn Batcher<I, O>>,
119        num_threads: usize,
120        device: Device,
121        seed: Option<RngSeed>,
122    ) -> Self {
123        Self {
124            strategy,
125            dataset,
126            batcher,
127            num_threads,
128            device,
129            seed,
130            dataloaders: OnceLock::new(),
131            workers: OnceLock::new(),
132        }
133    }
134
135    /// Force initialization if needed.
136    fn initialize(&self) -> &[BatchDataLoader<I, O>] {
137        self.dataloaders
138            .get_or_init(|| {
139                let mut dataset = self.dataset.clone();
140                if let Some(seed) = self.seed.as_ref() {
141                    // Pre-shuffle the dataset before split if shuffle is enabled.
142                    // This ensures that each thread gets a uniform random sample of the dataset.
143                    let mut rng = StdRng::from_seed(*seed);
144                    dataset = Arc::new(burn_dataset::transform::ShuffledDataset::new(
145                        dataset, &mut rng,
146                    ));
147                }
148
149                let datasets = match self.strategy.batch_size() {
150                    Some(batch_size) => {
151                        PartialDataset::split_chunks(dataset, self.num_threads, batch_size)
152                    }
153                    None => PartialDataset::split(dataset, self.num_threads),
154                };
155
156                // Create more rngs from the first one, one for each new dataloader.
157                let mut rng = self.seed.map(StdRng::from_seed);
158                let rngs = (0..self.num_threads).map(|_| {
159                    rng.as_mut().map(|rng| {
160                        StdRng::seed_from_u64(Distribution::sample(&StandardUniform, rng))
161                    })
162                });
163
164                datasets
165                    .into_iter()
166                    .zip(rngs)
167                    .map(|(dataset, rng)| {
168                        let strategy = self.strategy.clone_dyn();
169                        BatchDataLoader::new(
170                            strategy,
171                            Arc::new(dataset),
172                            self.batcher.clone(),
173                            self.device.clone(),
174                            rng,
175                        )
176                    })
177                    .collect()
178            })
179            .as_ref()
180    }
181
182    /// Lazily spawns the persistent worker pool (once) and returns it.
183    fn workers(&self) -> &WorkerPool<O> {
184        self.workers.get_or_init(|| {
185            let dataloaders = self.initialize();
186            let item_counts: Vec<usize> = dataloaders.iter().map(|d| d.num_items()).collect();
187
188            let mut senders = Vec::with_capacity(dataloaders.len());
189            let mut handles = Vec::with_capacity(dataloaders.len());
190
191            for (index, dataloader) in dataloaders.iter().enumerate() {
192                let dataloader = dataloader.clone();
193                let (command_sender, command_receiver) = mpsc::channel::<WorkerCommand<O>>();
194
195                let handle = thread::Builder::new()
196                    .name(std::format!("dataloader-{index}"))
197                    .spawn(move || {
198                        while let Ok(sender) = command_receiver.recv() {
199                            let mut iterator = dataloader.iter();
200                            loop {
201                                match iterator.next() {
202                                    Some(Ok(item)) => {
203                                        let progress = iterator.progress();
204
205                                        if sender
206                                            .send(Message::Batch(index, item, progress))
207                                            .is_err()
208                                        {
209                                            break;
210                                        }
211                                    }
212                                    None => break,
213                                    Some(Err(dataset_err)) => {
214                                        sender
215                                            .send(Message::Error(index, dataset_err.to_string()))
216                                            .ok();
217                                        break;
218                                    }
219                                }
220                            }
221                            sender.send(Message::Done).ok();
222                        }
223                    })
224                    .unwrap();
225
226                senders.push(command_sender);
227                handles.push(handle);
228            }
229
230            WorkerPool {
231                senders,
232                handles,
233                item_counts,
234            }
235        })
236    }
237}
238
239impl<I, O> DataLoader<O> for MultiThreadDataLoader<I, O>
240where
241    I: Send + Sync + Clone + 'static,
242    O: Send + 'static + std::fmt::Debug,
243{
244    fn iter<'a>(&'a self) -> Box<dyn DataLoaderIterator<O> + 'a> {
245        let workers = self.workers();
246
247        let (sender, receiver) = mpsc::sync_channel::<Message<O>>(MAX_QUEUED_ITEMS);
248        let unit: Option<String> = Some("items".to_string());
249
250        let mut progresses = Vec::with_capacity(workers.senders.len());
251        for (command_sender, &num_items) in workers.senders.iter().zip(workers.item_counts.iter()) {
252            progresses.push(Progress::new(0, num_items, unit.clone()));
253            command_sender
254                .send(sender.clone())
255                .expect("Dataloader worker thread should be alive");
256        }
257        let num_workers = workers.senders.len();
258
259        // Drop our sender so the channel disconnects once every worker is done.
260        drop(sender);
261
262        Box::new(MultiThreadsDataloaderIterator::new(
263            receiver,
264            num_workers,
265            progresses,
266        ))
267    }
268
269    fn num_items(&self) -> usize {
270        // For num_items, we can directly use the dataset size without
271        // necessarily initializing the full loader
272        self.dataset.len()
273    }
274
275    fn to_device(&self, device: &Device) -> Arc<dyn DataLoader<O>> {
276        Arc::new(Self::from_seed(
277            self.strategy.clone_dyn(),
278            self.dataset.clone(),
279            self.batcher.clone(),
280            self.num_threads,
281            device.clone(),
282            self.seed,
283        ))
284    }
285
286    fn slice(&self, start: usize, end: usize) -> Arc<dyn DataLoader<O>> {
287        let dataloader = Self::from_seed(
288            self.strategy.clone_dyn(),
289            Arc::new(PartialDataset::new(self.dataset.clone(), start, end)),
290            self.batcher.clone(),
291            self.num_threads,
292            self.device.clone(),
293            self.seed,
294        );
295        Arc::new(dataloader)
296    }
297}
298
299impl<O> MultiThreadsDataloaderIterator<O> {
300    pub fn new(
301        receiver: mpsc::Receiver<Message<O>>,
302        num_workers: usize,
303        progresses: Vec<Progress>,
304    ) -> Self {
305        MultiThreadsDataloaderIterator {
306            num_done: 0,
307            num_workers,
308            receiver,
309            progresses,
310        }
311    }
312}
313impl<O: std::fmt::Debug> DataLoaderIterator<O> for MultiThreadsDataloaderIterator<O> {
314    fn progress(&self) -> Progress {
315        let mut items_total = 0;
316        let mut items_processed = 0;
317        let unit: Option<String> = Some("items".to_string());
318
319        for progress in self.progresses.iter() {
320            items_total += progress.items_total;
321            items_processed += progress.items_processed;
322        }
323
324        Progress::new(items_processed, items_total, unit)
325    }
326}
327
328impl<O: std::fmt::Debug> Iterator for MultiThreadsDataloaderIterator<O> {
329    type Item = Result<O, burn_dataset::DatasetError>;
330
331    fn next(&mut self) -> Option<Self::Item> {
332        if self.num_workers == 0 {
333            return None;
334        }
335
336        loop {
337            match self.receiver.recv() {
338                Ok(Message::Batch(index, item, progress)) => {
339                    if let Some(current) = self.progresses.get_mut(index) {
340                        *current = progress;
341                    }
342                    return Some(Ok(item));
343                }
344                Ok(Message::Done) => {
345                    self.num_done += 1;
346                    if self.num_done == self.num_workers {
347                        // Workers stay alive for the next epoch; nothing to join.
348                        return None;
349                    }
350                }
351                Ok(Message::Error(index, msg)) => {
352                    return Some(Err(burn_dataset::DatasetError::new(std::io::Error::other(
353                        format!("dataloader worker {index} failed: {msg}"),
354                    ))));
355                }
356                Err(_) => return None,
357            }
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::data::dataloader::FixBatchStrategy;
366    use crate::data::dataloader::batcher::TestBatcher;
367    use crate::data::dataset::FakeDataset;
368    use burn_dataset::DatasetError;
369    use burn_dataset::InMemDataset;
370    use std::collections::HashSet;
371    use std::sync::mpsc;
372    use std::time::Duration;
373
374    /// A dataset that returns a real error (not an out-of-bounds panic) at one index,
375    /// to exercise the worker-error-propagation path below.
376    struct FlakyDataset {
377        len: usize,
378        fail_at: usize,
379    }
380
381    impl Dataset<usize> for FlakyDataset {
382        fn get(&self, index: usize) -> Result<usize, DatasetError> {
383            assert!(index < self.len, "index out of bounds");
384            if index == self.fail_at {
385                return Err(DatasetError::new(std::io::Error::other(
386                    "simulated dataset failure",
387                )));
388            }
389            Ok(index)
390        }
391
392        fn len(&self) -> usize {
393            self.len
394        }
395    }
396
397    #[test]
398    fn test_multi_thread_batch_dataloader() {
399        let batcher = Arc::new(TestBatcher::new());
400        let dataset = Arc::new(FakeDataset::<String>::new(27));
401        let dataloader_single_thread = BatchDataLoader::new(
402            Box::new(FixBatchStrategy::new(5)),
403            dataset.clone(),
404            batcher.clone(),
405            Default::default(),
406            None,
407        );
408        let dataloader_multi_thread = MultiThreadDataLoader::new(
409            Box::new(FixBatchStrategy::new(5)),
410            dataset,
411            batcher,
412            4,
413            Default::default(),
414            None,
415        );
416
417        let mut items_single_thread = HashSet::new();
418        let mut items_multi_thread = HashSet::new();
419
420        for items in dataloader_single_thread.iter().map(Result::unwrap) {
421            for item in items {
422                items_single_thread.insert(item);
423            }
424        }
425
426        for items in dataloader_multi_thread.iter().map(Result::unwrap) {
427            for item in items {
428                items_multi_thread.insert(item);
429            }
430        }
431
432        assert_eq!(items_single_thread, items_multi_thread);
433    }
434
435    #[test]
436    fn test_multi_thread_batch_dataloader_shuffle() {
437        let num_classes = 2;
438        let class_size = 100;
439        let batch_size = 10;
440
441        // Items is a deliberately ordered dataset.
442        let mut items = Vec::new();
443        for class in 0..num_classes {
444            items.extend(vec![class; class_size]);
445        }
446
447        {
448            // Unshuffled multithreaded loader
449            let dataset = Arc::new(InMemDataset::new(items.clone()));
450            let batcher = Arc::new(TestBatcher::new());
451
452            let loader = MultiThreadDataLoader::new(
453                Box::new(FixBatchStrategy::new(batch_size)),
454                dataset,
455                batcher,
456                num_classes,
457                Default::default(),
458                // No rng means no shuffling.
459                None,
460            );
461
462            for batch in loader.iter().map(Result::unwrap) {
463                let mut batch_items = HashSet::new();
464                for item in batch {
465                    batch_items.insert(item);
466                }
467
468                // Since the dataset is not shuffled, we expect each batch to contain the same item.
469                assert_eq!(batch_items.len(), 1);
470            }
471        }
472
473        {
474            // Shuffled multithreaded loader
475            let dataset = Arc::new(InMemDataset::new(items.clone()));
476            let batcher = Arc::new(TestBatcher::new());
477
478            let loader = MultiThreadDataLoader::new(
479                Box::new(FixBatchStrategy::new(batch_size)),
480                dataset.clone(),
481                batcher.clone(),
482                num_classes,
483                Default::default(),
484                // The rng enables shuffling.
485                Some(StdRng::seed_from_u64(42)),
486            );
487
488            for batch in loader.iter().map(Result::unwrap) {
489                let mut batch_items = HashSet::new();
490                for item in batch {
491                    batch_items.insert(item);
492                }
493
494                // Since the dataset is shuffled, we expect to see all items.
495                assert_eq!(batch_items.len(), num_classes);
496            }
497        }
498    }
499
500    #[test]
501    fn test_multi_thread_batch_dataloader_incomplete_batches() {
502        let batcher = Arc::new(TestBatcher::new());
503        let dataset = Arc::new(FakeDataset::<String>::new(27));
504        let dataloader_single_thread = BatchDataLoader::new(
505            Box::new(FixBatchStrategy::new(5)),
506            dataset.clone(),
507            batcher.clone(),
508            Default::default(),
509            None,
510        );
511        let dataloader_multi_thread = MultiThreadDataLoader::new(
512            Box::new(FixBatchStrategy::new(5)),
513            dataset,
514            batcher,
515            4,
516            Default::default(),
517            None,
518        );
519
520        let mut items_single_thread = HashSet::new();
521        let mut items_multi_thread = HashSet::new();
522
523        let mut single_thread_cnt = 0;
524        let mut multi_thread_cnt = 0;
525        for items in dataloader_single_thread.iter().map(Result::unwrap) {
526            items_single_thread.insert(items);
527            single_thread_cnt += 1;
528        }
529
530        for items in dataloader_multi_thread.iter().map(Result::unwrap) {
531            items_multi_thread.insert(items);
532            multi_thread_cnt += 1;
533        }
534
535        assert_eq!(single_thread_cnt, multi_thread_cnt);
536        assert_eq!(items_single_thread, items_multi_thread);
537    }
538
539    // Iterating the same loader over several epochs must keep yielding the full dataset (#4792).
540    #[test]
541    fn test_multi_thread_batch_dataloader_multiple_epochs() {
542        let batcher = Arc::new(TestBatcher::new());
543        let dataset = Arc::new(FakeDataset::<String>::new(27));
544
545        let expected: HashSet<_> = dataset.iter().map(Result::unwrap).collect();
546
547        let dataloader = MultiThreadDataLoader::new(
548            Box::new(FixBatchStrategy::new(5)),
549            dataset,
550            batcher,
551            4,
552            Default::default(),
553            None,
554        );
555
556        for _epoch in 0..3 {
557            let mut items = HashSet::new();
558            for batch in dataloader.iter().map(Result::unwrap) {
559                for item in batch {
560                    items.insert(item);
561                }
562            }
563            assert_eq!(items, expected);
564        }
565    }
566
567    // Dropping an iterator early must not kill the workers; the next pass still yields everything.
568    #[test]
569    fn test_multi_thread_batch_dataloader_resumes_after_early_drop() {
570        let batcher = Arc::new(TestBatcher::new());
571        let dataset = Arc::new(FakeDataset::<String>::new(27));
572
573        let expected: HashSet<_> = dataset.iter().map(Result::unwrap).collect();
574
575        let dataloader = MultiThreadDataLoader::new(
576            Box::new(FixBatchStrategy::new(5)),
577            dataset,
578            batcher,
579            4,
580            Default::default(),
581            None,
582        );
583
584        // Consume a single batch then drop the iterator early.
585        {
586            let mut iterator = dataloader.iter();
587            let _ = iterator.next();
588        }
589
590        let mut items = HashSet::new();
591        for batch in dataloader.iter().map(Result::unwrap) {
592            for item in batch {
593                items.insert(item);
594            }
595        }
596        assert_eq!(items, expected);
597    }
598
599    #[test]
600    fn test_multi_thread_batch_dataloader_propagates_worker_error_instead_of_hanging() {
601        let batcher = Arc::new(TestBatcher::new());
602        let dataset = Arc::new(FlakyDataset {
603            len: 40,
604            fail_at: 20,
605        });
606        let dataloader = MultiThreadDataLoader::new(
607            Box::new(FixBatchStrategy::new(1)),
608            dataset,
609            batcher,
610            4,
611            Default::default(),
612            None,
613        );
614
615        let (done_tx, done_rx) = mpsc::channel();
616        let handle = thread::spawn(move || {
617            let saw_error = dataloader.iter().any(|batch| batch.is_err());
618            done_tx.send(saw_error).ok();
619        });
620
621        let saw_error = done_rx
622            .recv_timeout(Duration::from_secs(10))
623            .expect("dataloader hung instead of reporting the worker error");
624        assert!(
625            saw_error,
626            "expected the dataloader to yield an Err on a worker error"
627        );
628        let _ = handle.join();
629    }
630}