1#![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#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct StepStats {
33 pub loss: f32,
35 pub tokens: usize,
37 pub optimizer_step: bool,
40 pub grad_norm: Option<f32>,
43 pub lr: Option<f32>,
45}
46
47#[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
59pub struct Trainer {
61 pub model: Lfm2Model,
63 pub optimizer: Box<dyn Optimizer>,
65 pub ctx: Arc<WgpuContext>,
67 pub engine: Arc<ComputeEngine>,
69 pub clip_grad_norm: Option<f32>,
71 pub grad_accum_steps: usize,
73 pub step_in_accum: usize,
75 pub scaler: Option<GradScaler>,
77 pub distributed_grad_sync: Option<FileGradSyncConfig>,
79 pub lr_scheduler: Option<Box<dyn LRScheduler>>,
82 pub length_bucket: usize,
87 optimizer_step_count: usize,
88}
89
90impl Trainer {
91 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 pub fn with_length_bucket(mut self, tokens: usize) -> Self {
117 self.length_bucket = tokens.max(1);
118 self
119 }
120
121 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 pub fn with_grad_accum_steps(mut self, steps: usize) -> Self {
131 self.grad_accum_steps = steps.max(1);
132 self
133 }
134
135 pub fn with_grad_scaler(mut self, scaler: GradScaler) -> Self {
137 self.scaler = Some(scaler);
138 self
139 }
140
141 pub fn with_file_grad_sync(mut self, cfg: FileGradSyncConfig) -> Self {
143 self.distributed_grad_sync = Some(cfg);
144 self
145 }
146
147 pub fn with_lr_scheduler(mut self, scheduler: Box<dyn LRScheduler>) -> Self {
149 self.lr_scheduler = Some(scheduler);
150 self
151 }
152
153 pub fn optimizer_steps(&self) -> usize {
155 self.optimizer_step_count
156 }
157
158 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 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 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 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 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 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 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
338fn 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
352pub struct MaskedLoss {
354 pub tensor: Tensor,
356 pub value: f32,
358 pub tokens: usize,
360}
361
362struct 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#[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
441pub 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 let logits = vec![
493 0.0, 5.0, 5.0, 0.0, ];
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 #[test]
515 fn grad_accum_mean_matches_larger_batch_within_tolerance() {
516 let logits = vec![
518 5.0, 0.0, 5.0, 0.0, 5.0, 0.0, 5.0, 0.0, ];
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 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 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 let (unmasked, _) =
565 masked_cross_entropy_from_logits(&logits, 2, &targets, &[1, 1]).expect("loss");
566 assert!((masked - unmasked).abs() < 1e-6);
567 }
568}