Skip to main content

ferrotensor_trainer/
lib.rs

1//! Training loop for Ferrotensor language models.
2//!
3//! [`Trainer`] runs forward, a response-masked cross-entropy loss
4//! ([`masked_cross_entropy`]), backward and an optimizer step on an
5//! [`Lfm2Model`] (Llama-style or LFM2), with gradient accumulation, global
6//! gradient-norm clipping, an optional learning-rate schedule, a loss scaler
7//! and file-based multi-process gradient averaging. [`data`] builds SFT and
8//! pretraining datasets, [`checkpoint`] saves and restores LoRA adapters and
9//! optimizer state.
10
11#![warn(missing_docs)]
12
13use ferrotensor_autograd::{AutogradNode, LRScheduler, Optimizer, Tensor};
14use ferrotensor_backend::{ComputeEngine, UnifiedTensor, WgpuContext};
15use ferrotensor_graph::lfm::Lfm2Model;
16use std::sync::Arc;
17
18pub mod checkpoint;
19pub mod data;
20pub mod demo;
21pub mod distributed;
22pub mod scaler;
23
24pub use data::{build_sft_example, Batch, DataLoader, Dataset, TextDataset, TrainingExample};
25pub use demo::{print_banner, run_demo, DemoConfig};
26pub use scaler::{GradScaler, GradScalerStats};
27
28pub use distributed::FileGradSyncConfig;
29
30/// Result of one [`Trainer::train_step_masked`] call.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct StepStats {
33    /// Mean cross-entropy over the supervised tokens of this sequence.
34    pub loss: f32,
35    /// Number of supervised (unmasked) target tokens.
36    pub tokens: usize,
37    /// Whether this call completed an accumulation window and updated the
38    /// parameters (`false` while accumulating or after an fp16 overflow).
39    pub optimizer_step: bool,
40    /// Global L2 norm of the gradients before clipping, when the optimizer
41    /// stepped.
42    pub grad_norm: Option<f32>,
43    /// Learning rate used for the optimizer step.
44    pub lr: Option<f32>,
45}
46
47/// Error from a training step.
48#[derive(Debug)]
49pub struct TrainError(pub String);
50
51impl std::fmt::Display for TrainError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.write_str(&self.0)
54    }
55}
56
57impl std::error::Error for TrainError {}
58
59/// Runs optimization steps on a model.
60pub struct Trainer {
61    /// The model being trained.
62    pub model: Lfm2Model,
63    /// Optimizer applied to the trainable parameters.
64    pub optimizer: Box<dyn Optimizer>,
65    /// GPU context.
66    pub ctx: Arc<WgpuContext>,
67    /// Kernel engine.
68    pub engine: Arc<ComputeEngine>,
69    /// Clip the global gradient norm to this value before each step.
70    pub clip_grad_norm: Option<f32>,
71    /// Micro-batches (sequences) per optimizer step.
72    pub grad_accum_steps: usize,
73    /// Micro-batches accumulated so far in the current window.
74    pub step_in_accum: usize,
75    /// Dynamic loss scaler (fp16-style overflow protection).
76    pub scaler: Option<GradScaler>,
77    /// Multi-process gradient averaging through a shared directory.
78    pub distributed_grad_sync: Option<FileGradSyncConfig>,
79    /// Learning-rate schedule, advanced once per optimizer step (before it
80    /// is read, so step `k` uses the schedule's rate at `k`).
81    pub lr_scheduler: Option<Box<dyn LRScheduler>>,
82    /// Sequences are right-padded to a multiple of this many tokens (with
83    /// the padding excluded from the loss). Causal attention keeps real
84    /// tokens from seeing the padding, so results are unchanged, but GPU
85    /// buffer sizes repeat and can be reused. 1 disables padding.
86    pub length_bucket: usize,
87    optimizer_step_count: usize,
88}
89
90impl Trainer {
91    /// A trainer with no clipping, accumulation, scaler or schedule.
92    pub fn new(
93        model: Lfm2Model,
94        optimizer: Box<dyn Optimizer>,
95        ctx: Arc<WgpuContext>,
96        engine: Arc<ComputeEngine>,
97    ) -> Self {
98        Self {
99            model,
100            optimizer,
101            ctx,
102            engine,
103            clip_grad_norm: None,
104            grad_accum_steps: 1,
105            step_in_accum: 0,
106            scaler: None,
107            distributed_grad_sync: None,
108            lr_scheduler: None,
109            length_bucket: 32,
110            optimizer_step_count: 0,
111        }
112    }
113
114    /// Pad sequences to a multiple of `tokens` (see
115    /// [`length_bucket`](Self::length_bucket)).
116    pub fn with_length_bucket(mut self, tokens: usize) -> Self {
117        self.length_bucket = tokens.max(1);
118        self
119    }
120
121    /// Clip the global gradient L2 norm to `max_norm`.
122    pub fn with_clip_grad_norm(mut self, max_norm: f32) -> Self {
123        self.clip_grad_norm = Some(max_norm);
124        self
125    }
126
127    /// Accumulate gradients over `steps` sequences per optimizer step. Each
128    /// sequence's loss is divided by `steps`, so the update matches the mean
129    /// over the window.
130    pub fn with_grad_accum_steps(mut self, steps: usize) -> Self {
131        self.grad_accum_steps = steps.max(1);
132        self
133    }
134
135    /// Scale the loss before backward and skip steps whose gradients overflow.
136    pub fn with_grad_scaler(mut self, scaler: GradScaler) -> Self {
137        self.scaler = Some(scaler);
138        self
139    }
140
141    /// Average gradients across processes before each optimizer step.
142    pub fn with_file_grad_sync(mut self, cfg: FileGradSyncConfig) -> Self {
143        self.distributed_grad_sync = Some(cfg);
144        self
145    }
146
147    /// Set the learning rate from `scheduler` before each optimizer step.
148    pub fn with_lr_scheduler(mut self, scheduler: Box<dyn LRScheduler>) -> Self {
149        self.lr_scheduler = Some(scheduler);
150        self
151    }
152
153    /// Optimizer steps taken so far.
154    pub fn optimizer_steps(&self) -> usize {
155        self.optimizer_step_count
156    }
157
158    /// Restore the optimizer-step counter (and advance the LR schedule to
159    /// match) when resuming from a checkpoint.
160    pub fn set_optimizer_steps(&mut self, steps: usize) {
161        if let Some(s) = self.lr_scheduler.as_mut() {
162            while s.current_step() < steps {
163                s.step();
164            }
165        }
166        self.optimizer_step_count = steps;
167    }
168
169    /// Train on one sequence with every target supervised.
170    pub fn train_step(
171        &mut self,
172        input_ids: &[u32],
173        target_ids: &[u32],
174        train_params: &[Tensor],
175    ) -> Result<StepStats, TrainError> {
176        self.train_step_masked(input_ids, target_ids, None, train_params)
177    }
178
179    /// Train on one sequence, optionally ignoring target positions via `loss_mask`.
180    /// `loss_mask` is aligned with `target_ids` (`1` = include, `0` = ignore).
181    ///
182    /// Gradients accumulate into `train_params`; every `grad_accum_steps`
183    /// calls they are (optionally) synced, clipped and applied, then cleared.
184    pub fn train_step_masked(
185        &mut self,
186        input_ids: &[u32],
187        target_ids: &[u32],
188        loss_mask: Option<&[u8]>,
189        train_params: &[Tensor],
190    ) -> Result<StepStats, TrainError> {
191        let len = input_ids.len().min(target_ids.len());
192        let padded_len = len.div_ceil(self.length_bucket) * self.length_bucket;
193        let mut inputs = input_ids[..len].to_vec();
194        let mut targets = target_ids[..len].to_vec();
195        let mut mask = match loss_mask {
196            Some(mask) => (0..len)
197                .map(|i| mask.get(i).copied().unwrap_or(0))
198                .collect(),
199            None => vec![1u8; len],
200        };
201        let pad = inputs.last().copied().unwrap_or(0);
202        inputs.resize(padded_len, pad);
203        targets.resize(padded_len, pad);
204        mask.resize(padded_len, 0);
205
206        let logits = self.model.forward(&self.engine, &inputs, 0, None, None);
207
208        let mut stats = StepStats {
209            loss: 0.0,
210            tokens: 0,
211            optimizer_step: false,
212            grad_norm: None,
213            lr: None,
214        };
215        if let Some(loss) = masked_cross_entropy(&logits, &targets, &mask) {
216            stats.loss = loss.value;
217            stats.tokens = loss.tokens;
218            // Seed backward with d(total)/d(loss): the accumulation average
219            // times the loss scale.
220            let mut seed = 1.0 / self.grad_accum_steps as f32;
221            if let Some(scaler) = self.scaler.as_ref() {
222                seed *= scaler.scale_factor();
223            }
224            *loss.tensor.grad.borrow_mut() = Some(UnifiedTensor::new(&self.ctx, &[seed], &[1]));
225            loss.tensor.backward(&self.engine);
226        }
227
228        self.step_in_accum += 1;
229        if self.step_in_accum >= self.grad_accum_steps {
230            self.step_in_accum = 0;
231            let applied = self.apply_gradients(train_params, &mut stats);
232            self.optimizer.zero_grad(train_params);
233            applied?;
234        }
235        Ok(stats)
236    }
237
238    /// Sync, unscale, clip and apply the accumulated gradients.
239    fn apply_gradients(
240        &mut self,
241        train_params: &[Tensor],
242        stats: &mut StepStats,
243    ) -> Result<(), TrainError> {
244        let mut grads: Vec<UnifiedTensor> = train_params
245            .iter()
246            .filter_map(|p| p.grad.borrow().clone())
247            .collect();
248        if grads.is_empty() {
249            return Ok(());
250        }
251
252        if let Some(scaler) = self.scaler.as_mut() {
253            let valid = scaler.unscale_and_check(&grads, &self.ctx, &self.engine);
254            scaler.update();
255            if !valid {
256                log::warn!("non-finite gradients; skipping optimizer step");
257                return Ok(());
258            }
259        }
260
261        if let Some(cfg) = self.distributed_grad_sync.as_ref() {
262            if cfg.world_size > 1 {
263                crate::distributed::average_grads_file_sync(
264                    &mut grads,
265                    &self.ctx,
266                    cfg,
267                    self.optimizer_step_count,
268                )
269                .map_err(|e| TrainError(format!("distributed grad sync failed: {e}")))?;
270            }
271        }
272
273        let norm = global_grad_norm(&grads, &self.ctx);
274        stats.grad_norm = Some(norm);
275        if !norm.is_finite() {
276            log::warn!("gradient norm is {norm}; skipping optimizer step");
277            return Ok(());
278        }
279        if let Some(max_norm) = self.clip_grad_norm {
280            if norm > max_norm {
281                let scale = max_norm / (norm + 1e-6);
282                for grad in &grads {
283                    self.engine.clip_grad_by_norm(&self.ctx, grad, scale);
284                }
285            }
286        }
287
288        // Step k (1-based) uses the schedule's value at k, so warmup starts
289        // above zero and the last step lands on the final rate.
290        if let Some(s) = self.lr_scheduler.as_mut() {
291            s.step();
292            let lr = s.get_lr();
293            self.optimizer.set_lr(lr);
294            stats.lr = Some(lr);
295        }
296        self.optimizer.step(train_params);
297        self.optimizer_step_count += 1;
298        stats.optimizer_step = true;
299        Ok(())
300    }
301
302    /// Train on every sequence of `batch` (one micro-batch per sequence,
303    /// padding stripped). Sequences without supervised tokens are skipped.
304    pub fn train_batch(
305        &mut self,
306        batch: &Batch,
307        train_params: &[Tensor],
308    ) -> Result<Vec<StepStats>, TrainError> {
309        let mut out = Vec::with_capacity(batch.batch_size);
310        for (((input_ids, target_ids), attn), loss_mask) in batch
311            .padded_input_ids
312            .iter()
313            .zip(&batch.padded_target_ids)
314            .zip(&batch.attention_mask)
315            .zip(&batch.loss_masks)
316        {
317            let valid_len = attn.iter().take_while(|&&flag| flag == 1).count();
318            let input_ids = &input_ids[..valid_len];
319            let target_ids = &target_ids[..valid_len];
320            let loss_mask = &loss_mask[..valid_len];
321            if input_ids.len() < 2 || target_ids.is_empty() {
322                continue;
323            }
324            if loss_mask.iter().all(|&m| m == 0) {
325                continue;
326            }
327            out.push(self.train_step_masked(
328                input_ids,
329                target_ids,
330                Some(loss_mask),
331                train_params,
332            )?);
333        }
334        Ok(out)
335    }
336}
337
338/// L2 norm over all gradient tensors (host reduction).
339fn global_grad_norm(grads: &[UnifiedTensor], ctx: &WgpuContext) -> f32 {
340    let sum_sq: f64 = grads
341        .iter()
342        .map(|g| {
343            pollster::block_on(g.to_vec(ctx))
344                .iter()
345                .map(|&x| (x as f64) * (x as f64))
346                .sum::<f64>()
347        })
348        .sum();
349    sum_sq.sqrt() as f32
350}
351
352/// A differentiable masked cross-entropy loss, see [`masked_cross_entropy`].
353pub struct MaskedLoss {
354    /// Scalar loss tensor `[1]`; call `backward` on it.
355    pub tensor: Tensor,
356    /// The loss value (mean over supervised tokens).
357    pub value: f32,
358    /// Number of supervised tokens.
359    pub tokens: usize,
360}
361
362/// Backward node of [`masked_cross_entropy`]: holds `d loss / d logits`,
363/// computed together with the loss from one host read of the logits.
364struct MaskedCrossEntropyNode {
365    logits: Tensor,
366    grad_logits: Vec<f32>,
367}
368
369impl AutogradNode for MaskedCrossEntropyNode {
370    fn inputs(&self) -> Vec<Tensor> {
371        vec![self.logits.clone()]
372    }
373
374    fn backward(&self, grad: &UnifiedTensor, _engine: &ComputeEngine) -> Vec<UnifiedTensor> {
375        let ctx = &self.logits.ctx;
376        let upstream = pollster::block_on(grad.to_vec(ctx))
377            .first()
378            .copied()
379            .unwrap_or(1.0);
380        let scaled: Vec<f32> = self.grad_logits.iter().map(|g| g * upstream).collect();
381        vec![UnifiedTensor::new(ctx, &scaled, &self.logits.data.shape)]
382    }
383}
384
385/// Mean cross-entropy of `logits` (`[.., seq, vocab]`) against `target_ids`
386/// over the positions where `loss_mask` is 1, as a differentiable scalar.
387///
388/// The logits are read to the host once; the loss and its gradient
389/// (`(softmax - onehot) / tokens` at supervised positions, zero elsewhere)
390/// are computed there. Returns `None` when no position is supervised.
391// Autograd nodes are shared through `Arc` but used on one thread.
392#[allow(clippy::arc_with_non_send_sync)]
393pub fn masked_cross_entropy(
394    logits: &Tensor,
395    target_ids: &[u32],
396    loss_mask: &[u8],
397) -> Option<MaskedLoss> {
398    let vocab = *logits.data.shape.last()?;
399    if vocab == 0 {
400        return None;
401    }
402    let host = pollster::block_on(logits.data.to_vec(&logits.ctx));
403    let seq = host.len() / vocab;
404    let positions = seq.min(target_ids.len()).min(loss_mask.len());
405    let supervised: Vec<usize> = (0..positions)
406        .filter(|&p| loss_mask[p] != 0 && (target_ids[p] as usize) < vocab)
407        .collect();
408    if supervised.is_empty() {
409        return None;
410    }
411    let inv = 1.0 / supervised.len() as f32;
412    let mut grad = vec![0.0f32; host.len()];
413    let mut loss_sum = 0.0f64;
414    for &p in &supervised {
415        let row = &host[p * vocab..(p + 1) * vocab];
416        let max = row.iter().copied().fold(f32::NEG_INFINITY, f32::max);
417        let sum_exp: f32 = row.iter().map(|x| (x - max).exp()).sum();
418        let log_z = max + sum_exp.ln();
419        let target = target_ids[p] as usize;
420        loss_sum += (log_z - row[target]) as f64;
421        let g = &mut grad[p * vocab..(p + 1) * vocab];
422        for (gi, &x) in g.iter_mut().zip(row) {
423            *gi = (x - log_z).exp() * inv;
424        }
425        g[target] -= inv;
426    }
427    let value = (loss_sum / supervised.len() as f64) as f32;
428    let ctx = logits.ctx.clone();
429    let node = Arc::new(MaskedCrossEntropyNode {
430        logits: logits.clone(),
431        grad_logits: grad,
432    });
433    let tensor = Tensor::with_creator(ctx.clone(), UnifiedTensor::new(&ctx, &[value], &[1]), node);
434    Some(MaskedLoss {
435        tensor,
436        value,
437        tokens: supervised.len(),
438    })
439}
440
441/// Average cross-entropy over masked target positions using host logits.
442pub fn masked_cross_entropy_from_logits(
443    logits: &[f32],
444    vocab_size: usize,
445    target_ids: &[u32],
446    loss_mask: &[u8],
447) -> Option<(f32, usize)> {
448    if vocab_size == 0 {
449        return None;
450    }
451    let seq_len = logits.len() / vocab_size;
452    let positions = seq_len.min(target_ids.len()).min(loss_mask.len());
453    let mut loss_sum = 0.0f32;
454    let mut count = 0usize;
455    for pos in 0..positions {
456        if loss_mask[pos] == 0 {
457            continue;
458        }
459        let target = target_ids[pos] as usize;
460        if target >= vocab_size {
461            continue;
462        }
463        let start = pos * vocab_size;
464        let end = start + vocab_size;
465        if end > logits.len() {
466            break;
467        }
468        let slice = &logits[start..end];
469        let max_logit = slice.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
470        let exp_sum: f32 = slice.iter().map(|x| (x - max_logit).exp()).sum();
471        let log_softmax = slice[target] - max_logit - exp_sum.ln();
472        loss_sum += -log_softmax;
473        count += 1;
474    }
475    if count == 0 {
476        None
477    } else {
478        Some((loss_sum / count as f32, count))
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::masked_cross_entropy_from_logits;
485    use crate::data::TrainingExample;
486
487    #[test]
488    fn sft_mask_excludes_prompt_from_host_loss() {
489        // tiny fake logits: two positions, vocab=2
490        // pos0 targets token 1 with logit preference; will be masked out
491        // pos1 targets token 0
492        let logits = vec![
493            0.0, 5.0, // pos0
494            5.0, 0.0, // pos1
495        ];
496        let targets = [1u32, 0u32];
497        let mask = [0u8, 1u8];
498        let (loss, count) =
499            masked_cross_entropy_from_logits(&logits, 2, &targets, &mask).expect("loss");
500        assert_eq!(count, 1);
501        assert!(loss < 0.1, "expected near-zero loss, got {loss}");
502    }
503
504    #[test]
505    fn sft_training_example_mask_length_matches_targets() {
506        let example = TrainingExample::from_sft_prompt_response(vec![1, 2, 3], vec![4, 5]);
507        assert_eq!(example.loss_mask.len(), example.target_ids.len());
508        assert!(example.loss_mask.contains(&0));
509        assert!(example.loss_mask.contains(&1));
510    }
511
512    /// Averaging N micro-batches then summing should match one large batch
513    /// mean when each micro-batch contributes the same token count.
514    #[test]
515    fn grad_accum_mean_matches_larger_batch_within_tolerance() {
516        // Four positions, vocab=2. Token preference is always correct.
517        let logits = vec![
518            5.0, 0.0, // 0
519            5.0, 0.0, // 1
520            5.0, 0.0, // 2
521            5.0, 0.0, // 3
522        ];
523        let targets = [0u32, 0, 0, 0];
524        let full_mask = [1u8, 1, 1, 1];
525        let (full_loss, full_count) =
526            masked_cross_entropy_from_logits(&logits, 2, &targets, &full_mask).expect("loss");
527        assert_eq!(full_count, 4);
528
529        // Simulate grad-accum of 2 microbatches of 2 tokens each.
530        let micro_a =
531            masked_cross_entropy_from_logits(&logits[..4], 2, &targets[..2], &full_mask[..2])
532                .expect("loss");
533        let micro_b =
534            masked_cross_entropy_from_logits(&logits[4..], 2, &targets[2..], &full_mask[2..])
535                .expect("loss");
536        let accum_mean = (micro_a.0 + micro_b.0) / 2.0;
537        assert!((accum_mean - full_loss).abs() < 1e-5);
538    }
539
540    #[test]
541    fn batch_size_invariance_of_token_mean_loss() {
542        // Same two token predictions evaluated as batch=1 twice vs batch=2 once.
543        let logits_a = vec![3.0, 0.0];
544        let logits_b = vec![0.0, 4.0];
545        let (loss_a, _) = masked_cross_entropy_from_logits(&logits_a, 2, &[0], &[1]).expect("loss");
546        let (loss_b, _) = masked_cross_entropy_from_logits(&logits_b, 2, &[1], &[1]).expect("loss");
547        let mean_bs1 = (loss_a + loss_b) / 2.0;
548
549        let logits_bs2 = [logits_a, logits_b].concat();
550        let (mean_bs2, count) =
551            masked_cross_entropy_from_logits(&logits_bs2, 2, &[0, 1], &[1, 1]).expect("loss");
552        assert_eq!(count, 2);
553        assert!((mean_bs1 - mean_bs2).abs() < 1e-5);
554    }
555
556    #[test]
557    fn pretrain_full_mask_equals_unmasked_host_loss() {
558        let logits = vec![1.0, 0.0, 0.0, 2.0];
559        let targets = [0u32, 1u32];
560        let mask = [1u8, 1u8];
561        let (masked, _) =
562            masked_cross_entropy_from_logits(&logits, 2, &targets, &mask).expect("loss");
563        // Same as ignoring nothing.
564        let (unmasked, _) =
565            masked_cross_entropy_from_logits(&logits, 2, &targets, &[1, 1]).expect("loss");
566        assert!((masked - unmasked).abs() < 1e-6);
567    }
568}