Skip to main content

ferrotensor_trainer/
data.rs

1//! Data Pipeline for Training
2//!
3//! Provides Dataset trait and DataLoader for efficient training data loading.
4//! Supports SFT (response-masked) and pretraining (next-token) sample formats.
5
6use ferrotensor_loader::Tokenizer;
7use serde::Deserialize;
8use std::fs;
9use std::path::Path;
10use std::sync::Arc;
11
12/// Trait for datasets that provide training samples
13pub trait Dataset: Send + Sync {
14    /// Number of samples in the dataset
15    fn len(&self) -> usize;
16
17    /// Get a single sample by index
18    /// Returns (input_ids, target_ids) as token vectors
19    fn get(&self, idx: usize) -> (Vec<u32>, Vec<u32>);
20
21    /// Optional per-token loss mask aligned with targets (1 = include, 0 = ignore)
22    fn get_loss_mask(&self, idx: usize) -> Option<Vec<u8>> {
23        let (_, targets) = self.get(idx);
24        Some(vec![1u8; targets.len()])
25    }
26
27    /// Check if dataset is empty
28    fn is_empty(&self) -> bool {
29        self.len() == 0
30    }
31}
32
33/// One training example with optional response-only loss mask.
34#[derive(Clone, Debug)]
35pub struct TrainingExample {
36    /// Tokens fed to the model.
37    pub input_ids: Vec<u32>,
38    /// Next-token targets, aligned with `input_ids`.
39    pub target_ids: Vec<u32>,
40    /// 1 = compute loss, 0 = ignore. Same length as `target_ids`.
41    pub loss_mask: Vec<u8>,
42}
43
44impl TrainingExample {
45    /// Next-token example over a whole sequence: inputs are all but the last
46    /// token, targets all but the first, every target supervised.
47    pub fn from_next_token(sequence: Vec<u32>) -> Self {
48        if sequence.len() < 2 {
49            let len = sequence.len();
50            return Self {
51                input_ids: sequence.clone(),
52                target_ids: sequence,
53                loss_mask: vec![1u8; len],
54            };
55        }
56        let input = sequence[..sequence.len() - 1].to_vec();
57        let target = sequence[1..].to_vec();
58        let loss_mask = vec![1u8; target.len()];
59        Self {
60            input_ids: input,
61            target_ids: target,
62            loss_mask,
63        }
64    }
65
66    /// Build an SFT example where loss is only computed on response tokens.
67    ///
68    /// `prompt_tokens` are the tokens up to (and including) the assistant prefix,
69    /// `response_tokens` are the generated / supervised response ids.
70    pub fn from_sft_prompt_response(prompt_tokens: Vec<u32>, response_tokens: Vec<u32>) -> Self {
71        let mut full = prompt_tokens.clone();
72        full.extend(response_tokens.iter().copied());
73        if full.len() < 2 {
74            return Self::from_next_token(full);
75        }
76
77        let input = full[..full.len() - 1].to_vec();
78        let target = full[1..].to_vec();
79
80        // Positions in `target` whose corresponding prediction recovers a response token.
81        // The first response token appears at index `prompt_tokens.len()` in `full`,
82        // so its target index is `prompt_tokens.len() - 1`.
83        let mut loss_mask = vec![0u8; target.len()];
84        if !prompt_tokens.is_empty() && !response_tokens.is_empty() {
85            let first_response_target = prompt_tokens.len().saturating_sub(1);
86            loss_mask[first_response_target..].fill(1);
87        } else {
88            loss_mask.fill(1);
89        }
90
91        Self {
92            input_ids: input,
93            target_ids: target,
94            loss_mask,
95        }
96    }
97}
98
99/// Simple in-memory text dataset for LLM training
100#[derive(Clone)]
101pub struct TextDataset {
102    samples: Vec<TrainingExample>,
103}
104
105impl TextDataset {
106    /// An empty dataset.
107    pub fn new() -> Self {
108        Self {
109            samples: Vec::new(),
110        }
111    }
112
113    /// Add a sample to the dataset
114    pub fn add_sample(&mut self, input_ids: Vec<u32>, target_ids: Vec<u32>) {
115        let loss_mask = vec![1u8; target_ids.len()];
116        self.samples.push(TrainingExample {
117            input_ids,
118            target_ids,
119            loss_mask,
120        });
121    }
122
123    /// Add a prepared example (with its loss mask).
124    pub fn add_example(&mut self, example: TrainingExample) {
125        self.samples.push(example);
126    }
127
128    /// Create from list of token sequences (next-token prediction)
129    pub fn from_sequences(sequences: Vec<Vec<u32>>) -> Self {
130        let samples = sequences
131            .into_iter()
132            .map(TrainingExample::from_next_token)
133            .collect();
134        Self { samples }
135    }
136
137    /// Create from a JSONL instruction dataset with `{prompt,response}` objects.
138    /// Uses chat-template encoding and response-only loss masks when `use_chat_template`.
139    pub fn from_jsonl_sft<P: AsRef<Path>>(
140        path: P,
141        tokenizer: &Tokenizer,
142        use_chat_template: bool,
143        max_len: usize,
144    ) -> Result<Self, std::io::Error> {
145        #[derive(Deserialize)]
146        struct JsonlSample {
147            prompt: String,
148            response: String,
149            #[serde(default)]
150            system: Option<String>,
151        }
152
153        let content = fs::read_to_string(path)?;
154        let mut samples = Vec::new();
155        for (line_no, line) in content.lines().enumerate() {
156            if line.trim().is_empty() {
157                continue;
158            }
159            let sample = serde_json::from_str::<JsonlSample>(line).map_err(|e| {
160                std::io::Error::new(
161                    std::io::ErrorKind::InvalidData,
162                    format!(
163                        "line {}: expected {{\"prompt\": ..., \"response\": ...}}: {e}",
164                        line_no + 1
165                    ),
166                )
167            })?;
168            if let Some(example) = build_sft_example(
169                tokenizer,
170                &sample.prompt,
171                &sample.response,
172                sample.system.as_deref(),
173                use_chat_template,
174                max_len,
175            ) {
176                samples.push(example);
177            }
178        }
179        Ok(Self { samples })
180    }
181
182    /// Create from a JSONL instruction dataset with `{prompt,response}` objects.
183    pub fn from_jsonl<P: AsRef<Path>>(
184        path: P,
185        tokenizer: &Tokenizer,
186    ) -> Result<Self, std::io::Error> {
187        Self::from_jsonl_sft(path, tokenizer, true, usize::MAX)
188    }
189
190    /// Create from a plain text file, one training sample per non-empty line.
191    pub fn from_text_file<P: AsRef<Path>>(
192        path: P,
193        tokenizer: &Tokenizer,
194    ) -> Result<Self, std::io::Error> {
195        let content = fs::read_to_string(path)?;
196        let sequences = content
197            .lines()
198            .filter(|line| !line.trim().is_empty())
199            .map(|line| tokenizer.encode(line))
200            .collect();
201        Ok(Self::from_sequences(sequences))
202    }
203
204    /// Create from a flat binary file of little-endian u32 token ids.
205    pub fn from_token_bin<P: AsRef<Path>>(
206        path: P,
207        sequence_length: usize,
208    ) -> Result<Self, std::io::Error> {
209        let bytes = fs::read(path)?;
210        let mut tokens = Vec::with_capacity(bytes.len() / 4);
211        for chunk in bytes.as_chunks::<4>().0 {
212            tokens.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
213        }
214        Ok(Self::from_packed_tokens(tokens, sequence_length))
215    }
216
217    /// Create fixed-length training windows from a packed token stream.
218    pub fn from_packed_tokens(tokens: Vec<u32>, sequence_length: usize) -> Self {
219        if sequence_length < 2 || tokens.len() < sequence_length {
220            return Self::new();
221        }
222
223        let mut samples = Vec::new();
224        let stride = sequence_length.saturating_sub(1).max(1);
225        let mut start = 0usize;
226        while start + sequence_length <= tokens.len() {
227            let seq = tokens[start..start + sequence_length].to_vec();
228            samples.push(TrainingExample::from_next_token(seq));
229            start += stride;
230        }
231
232        Self { samples }
233    }
234
235    /// Greedily pack short sequences into longer windows up to `max_len`.
236    pub fn pack_sequences(sequences: Vec<Vec<u32>>, max_len: usize) -> Self {
237        if max_len < 2 {
238            return Self::new();
239        }
240
241        let mut packed = Vec::new();
242        let mut current = Vec::new();
243        for seq in sequences {
244            if seq.is_empty() {
245                continue;
246            }
247            if current.len() + seq.len() > max_len && current.len() >= 2 {
248                packed.push(current);
249                current = Vec::new();
250            }
251            current.extend(seq);
252        }
253        if current.len() >= 2 {
254            packed.push(current);
255        }
256
257        Self::from_sequences(packed)
258    }
259
260    /// All examples, in insertion order.
261    pub fn examples(&self) -> &[TrainingExample] {
262        &self.samples
263    }
264}
265
266/// Tokenize one SFT pair into a response-masked example of at most
267/// `max_len` input tokens.
268///
269/// With `use_chat_template` the prompt is wrapped in the ChatML template
270/// and the response is followed by the end-of-turn token, so the model also
271/// learns where to stop. The response is tokenized without special tokens.
272/// Long examples are truncated at the end (the prompt is kept whole);
273/// examples whose prompt leaves no room for a response token are dropped.
274pub fn build_sft_example(
275    tokenizer: &Tokenizer,
276    prompt: &str,
277    response: &str,
278    system: Option<&str>,
279    use_chat_template: bool,
280    max_len: usize,
281) -> Option<TrainingExample> {
282    let (prompt_tokens, response_tokens) = if use_chat_template {
283        let mut response_part = tokenizer.encode_continuation(response);
284        response_part.push(tokenizer.eos_token_id);
285        (tokenizer.encode_chat(system, prompt), response_part)
286    } else {
287        (
288            tokenizer.encode(prompt),
289            tokenizer.encode_continuation(response),
290        )
291    };
292
293    if prompt_tokens.is_empty() || response_tokens.is_empty() {
294        return None;
295    }
296    // The first supervised target sits at index prompt_len - 1.
297    if max_len < 2 || prompt_tokens.len() > max_len {
298        return None;
299    }
300
301    let mut example = TrainingExample::from_sft_prompt_response(prompt_tokens, response_tokens);
302    if example.input_ids.len() > max_len {
303        example.input_ids.truncate(max_len);
304        example.target_ids.truncate(max_len);
305        example.loss_mask.truncate(max_len);
306    }
307    if example.input_ids.len() < 2 || !example.loss_mask.contains(&1) {
308        return None;
309    }
310    Some(example)
311}
312
313impl Default for TextDataset {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319impl Dataset for TextDataset {
320    fn len(&self) -> usize {
321        self.samples.len()
322    }
323
324    fn get(&self, idx: usize) -> (Vec<u32>, Vec<u32>) {
325        let sample = &self.samples[idx];
326        (sample.input_ids.clone(), sample.target_ids.clone())
327    }
328
329    fn get_loss_mask(&self, idx: usize) -> Option<Vec<u8>> {
330        Some(self.samples[idx].loss_mask.clone())
331    }
332}
333
334/// Batch of training data
335pub struct Batch {
336    /// Unpadded inputs, one per sequence.
337    pub input_ids: Vec<Vec<u32>>,
338    /// Unpadded targets, one per sequence.
339    pub target_ids: Vec<Vec<u32>>,
340    /// Inputs right-padded to `max_seq_len` with the pad token.
341    pub padded_input_ids: Vec<Vec<u32>>,
342    /// Targets right-padded to `max_seq_len` with the pad token.
343    pub padded_target_ids: Vec<Vec<u32>>,
344    /// 1 for real tokens, 0 for padding.
345    pub attention_mask: Vec<Vec<u8>>,
346    /// Per-sample loss masks aligned with padded targets (1 = train, 0 = ignore).
347    pub loss_masks: Vec<Vec<u8>>,
348    /// Number of sequences.
349    pub batch_size: usize,
350    /// Length of the longest sequence.
351    pub max_seq_len: usize,
352}
353
354/// DataLoader for iterating over datasets with batching
355pub struct DataLoader<D: Dataset> {
356    dataset: Arc<D>,
357    batch_size: usize,
358    pad_token_id: u32,
359    shuffle: bool,
360    base_indices: Vec<usize>,
361    indices: Vec<usize>,
362    current_idx: usize,
363    seed: Option<u64>,
364    epoch: u64,
365}
366
367impl<D: Dataset> DataLoader<D> {
368    /// A loader over the whole dataset, padding with token 0.
369    pub fn new(dataset: D, batch_size: usize, shuffle: bool) -> Self {
370        Self::new_with_pad_token(dataset, batch_size, shuffle, 0)
371    }
372
373    /// A loader over only `indices` of the dataset (e.g. one rank's shard).
374    pub fn new_with_indices(
375        dataset: D,
376        batch_size: usize,
377        shuffle: bool,
378        pad_token_id: u32,
379        indices: Vec<usize>,
380    ) -> Self {
381        Self {
382            dataset: Arc::new(dataset),
383            batch_size: batch_size.max(1),
384            pad_token_id,
385            shuffle,
386            base_indices: indices.clone(),
387            indices,
388            current_idx: 0,
389            seed: None,
390            epoch: 0,
391        }
392    }
393
394    /// A loader over the whole dataset, padding with `pad_token_id`.
395    pub fn new_with_pad_token(
396        dataset: D,
397        batch_size: usize,
398        shuffle: bool,
399        pad_token_id: u32,
400    ) -> Self {
401        let indices: Vec<usize> = (0..dataset.len()).collect();
402        Self::new_with_indices(dataset, batch_size, shuffle, pad_token_id, indices)
403    }
404
405    /// Make shuffling reproducible: epoch `e` uses an order derived from
406    /// `seed` and `e` only (see [`set_epoch`](Self::set_epoch)).
407    pub fn with_seed(mut self, seed: u64) -> Self {
408        self.seed = Some(seed);
409        self
410    }
411
412    /// Select the epoch whose order the next [`reset`](Self::reset) produces.
413    pub fn set_epoch(&mut self, epoch: u64) {
414        self.epoch = epoch;
415    }
416
417    /// Rewind to the first batch, reshuffling when shuffling is on. With a
418    /// seed the order depends only on the seed and the epoch; without one it
419    /// is seeded from the clock.
420    pub fn reset(&mut self) {
421        self.current_idx = 0;
422        self.indices.clone_from(&self.base_indices);
423        if self.shuffle {
424            let seed = match self.seed {
425                Some(seed) => seed ^ self.epoch.wrapping_mul(0x9E37_79B9_7F4A_7C15),
426                None => {
427                    use std::time::{SystemTime, UNIX_EPOCH};
428                    SystemTime::now()
429                        .duration_since(UNIX_EPOCH)
430                        .map(|d| d.as_nanos() as u64)
431                        .unwrap_or(0)
432                }
433            };
434            let mut rng = SimpleRng::new(seed);
435            // Fisher-Yates
436            for i in (1..self.indices.len()).rev() {
437                let j = (rng.next() >> 33) as usize % (i + 1);
438                self.indices.swap(i, j);
439            }
440        }
441    }
442
443    /// Number of batches per epoch
444    pub fn num_batches(&self) -> usize {
445        self.indices.len().div_ceil(self.batch_size)
446    }
447
448    /// Total number of samples
449    pub fn len(&self) -> usize {
450        self.indices.len()
451    }
452
453    /// Check if empty
454    pub fn is_empty(&self) -> bool {
455        self.indices.is_empty()
456    }
457}
458
459impl<D: Dataset> Iterator for DataLoader<D> {
460    type Item = Batch;
461
462    fn next(&mut self) -> Option<Self::Item> {
463        if self.current_idx >= self.indices.len() {
464            return None;
465        }
466
467        let end = std::cmp::min(self.current_idx + self.batch_size, self.indices.len());
468        let batch_indices = &self.indices[self.current_idx..end];
469
470        let mut input_ids = Vec::with_capacity(batch_indices.len());
471        let mut target_ids = Vec::with_capacity(batch_indices.len());
472        let mut loss_masks_raw = Vec::with_capacity(batch_indices.len());
473        let mut max_seq_len = 0usize;
474
475        for &idx in batch_indices {
476            let (inp, tgt) = self.dataset.get(idx);
477            let mask = self
478                .dataset
479                .get_loss_mask(idx)
480                .unwrap_or_else(|| vec![1u8; tgt.len()]);
481            max_seq_len = max_seq_len.max(inp.len()).max(tgt.len());
482            input_ids.push(inp);
483            target_ids.push(tgt);
484            loss_masks_raw.push(mask);
485        }
486
487        let mut padded_input_ids = Vec::with_capacity(batch_indices.len());
488        let mut padded_target_ids = Vec::with_capacity(batch_indices.len());
489        let mut attention_mask = Vec::with_capacity(batch_indices.len());
490        let mut loss_masks = Vec::with_capacity(batch_indices.len());
491        for ((inp, tgt), loss_mask) in input_ids.iter().zip(&target_ids).zip(&loss_masks_raw) {
492            let valid_len = inp.len().min(tgt.len());
493            let mut padded_inp = inp.clone();
494            padded_inp.resize(max_seq_len, self.pad_token_id);
495            let mut padded_tgt = tgt.clone();
496            padded_tgt.resize(max_seq_len, self.pad_token_id);
497            let mut attn = vec![0u8; max_seq_len];
498            attn.iter_mut().take(valid_len).for_each(|m| *m = 1);
499            let mut padded_loss = vec![0u8; max_seq_len];
500            for (dst, src) in padded_loss.iter_mut().zip(loss_mask.iter()).take(valid_len) {
501                *dst = *src;
502            }
503            padded_input_ids.push(padded_inp);
504            padded_target_ids.push(padded_tgt);
505            attention_mask.push(attn);
506            loss_masks.push(padded_loss);
507        }
508
509        self.current_idx = end;
510
511        Some(Batch {
512            batch_size: batch_indices.len(),
513            input_ids,
514            target_ids,
515            padded_input_ids,
516            padded_target_ids,
517            attention_mask,
518            loss_masks,
519            max_seq_len,
520        })
521    }
522}
523
524/// Simple LCG random number generator (no external deps)
525struct SimpleRng {
526    state: u64,
527}
528
529impl SimpleRng {
530    fn new(seed: u64) -> Self {
531        Self {
532            state: seed.wrapping_add(1),
533        }
534    }
535
536    fn next(&mut self) -> u64 {
537        // LCG constants from Numerical Recipes
538        self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1);
539        self.state
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn test_text_dataset() {
549        let dataset = TextDataset::from_sequences(vec![vec![1, 2, 3, 4], vec![5, 6, 7, 8]]);
550        assert_eq!(dataset.len(), 2);
551
552        let (inp, tgt) = dataset.get(0);
553        assert_eq!(inp, vec![1, 2, 3]);
554        assert_eq!(tgt, vec![2, 3, 4]);
555        assert_eq!(dataset.get_loss_mask(0).unwrap(), vec![1, 1, 1]);
556    }
557
558    #[test]
559    fn sft_example_masks_only_response_tokens() {
560        let prompt = vec![10, 11, 12];
561        let response = vec![20, 21];
562        let example = TrainingExample::from_sft_prompt_response(prompt, response);
563        // full = [10,11,12,20,21]
564        // input = [10,11,12,20]
565        // target = [11,12,20,21]
566        assert_eq!(example.input_ids, vec![10, 11, 12, 20]);
567        assert_eq!(example.target_ids, vec![11, 12, 20, 21]);
568        assert_eq!(example.loss_mask, vec![0, 0, 1, 1]);
569    }
570
571    #[test]
572    fn test_dataloader_batching() {
573        let dataset = TextDataset::from_sequences(vec![
574            vec![1, 2],
575            vec![3, 4],
576            vec![5, 6],
577            vec![7, 8],
578            vec![9, 10],
579        ]);
580        let loader = DataLoader::new(dataset, 2, false);
581        assert_eq!(loader.num_batches(), 3);
582    }
583
584    #[test]
585    fn test_dataloader_pads_and_masks_batches() {
586        let dataset = TextDataset::from_sequences(vec![vec![1, 2, 3, 4], vec![5, 6, 7]]);
587        let mut loader = DataLoader::new_with_pad_token(dataset, 2, false, 99);
588        let batch = loader.next().expect("expected batch");
589        assert_eq!(batch.max_seq_len, 3);
590        assert_eq!(batch.padded_input_ids[0], vec![1, 2, 3]);
591        assert_eq!(batch.padded_input_ids[1], vec![5, 6, 99]);
592        assert_eq!(batch.padded_target_ids[1], vec![6, 7, 99]);
593        assert_eq!(batch.attention_mask[0], vec![1, 1, 1]);
594        assert_eq!(batch.attention_mask[1], vec![1, 1, 0]);
595        assert_eq!(batch.loss_masks[1], vec![1, 1, 0]);
596    }
597
598    #[test]
599    fn packed_bin_and_txt_share_next_token_mask_semantics() {
600        // Pretrain windows from packed tokens should mark every target as supervised.
601        let packed = TextDataset::from_packed_tokens(vec![1, 2, 3, 4, 5, 6], 4);
602        assert!(!packed.is_empty());
603        for idx in 0..packed.len() {
604            let mask = packed.get_loss_mask(idx).unwrap();
605            assert!(mask.iter().all(|&m| m == 1));
606            let (inp, tgt) = packed.get(idx);
607            assert_eq!(inp.len(), tgt.len());
608            assert_eq!(mask.len(), tgt.len());
609        }
610
611        // Raw short sequences (txt line analogue) use the same next-token mask rule.
612        let lines = TextDataset::from_sequences(vec![vec![9, 8, 7, 6]]);
613        assert_eq!(lines.get_loss_mask(0).unwrap(), vec![1, 1, 1]);
614    }
615
616    #[test]
617    fn sft_dataloader_preserves_response_mask_under_padding() {
618        let mut dataset = TextDataset::new();
619        dataset.add_example(TrainingExample::from_sft_prompt_response(
620            vec![1, 2, 3],
621            vec![4, 5],
622        ));
623        dataset.add_example(TrainingExample::from_sft_prompt_response(
624            vec![10, 11],
625            vec![12],
626        ));
627        let mut loader = DataLoader::new_with_pad_token(dataset, 2, false, 0);
628        let batch = loader.next().expect("batch");
629        // First example mask was [0,0,1,1]; padding should keep prompt=0 and pad=0.
630        assert_eq!(batch.loss_masks[0].iter().filter(|&&m| m == 1).count(), 2);
631        assert!(batch.loss_masks[0].iter().take(2).all(|&m| m == 0));
632        // Second example response-only positions stay marked.
633        assert!(batch.loss_masks[1].contains(&1));
634        assert!(batch.loss_masks[1].contains(&0));
635    }
636}