use candle_core::{Device, Result, Tensor};
use candle_nn::{Optimizer, VarMap};
use crate::config::{BlazrConfig, CheckpointMeta, ModelConfig, TrainerConfig};
use crate::data::DataLoader;
use crate::distributed::{
GradientSynchronizer, DistributedAdamW, AdamWConfig,
create_synchronizer, compute_grad_norm,
};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::collections::VecDeque;
use std::path::Path;
use std::time::Instant;
use std::io::Write;
pub struct LitTrainer<'a> {
config: TrainerConfig,
varmap: &'a VarMap,
optimizer: candle_nn::AdamW,
#[allow(dead_code)] device: Device,
global_step: usize,
dataset_size: usize,
total_steps: usize,
batch_size: usize,
seq_len: usize,
steps_json_path: String,
headless: bool,
prev_grad_norm: Option<f64>,
start_step: usize,
model_config: ModelConfig,
data_file_path: Option<String>,
synchronizer: Option<Box<dyn GradientSynchronizer>>,
distributed_optimizer: Option<DistributedAdamW>,
}
impl<'a> LitTrainer<'a> {
pub fn new(
config: TrainerConfig,
varmap: &'a VarMap,
device: Device,
dataset_size: usize,
batch_size: usize,
seq_len: usize,
steps_json_path: String,
headless: bool,
start_step: usize,
model_config: ModelConfig,
data_file_path: Option<String>,
) -> Result<Self> {
let params = candle_nn::ParamsAdamW {
lr: config.learning_rate,
..Default::default()
};
let optimizer = candle_nn::AdamW::new(varmap.all_vars(), params)?;
let effective_batch_size = batch_size * seq_len * config.gradient_accumulation;
let steps_per_epoch = (dataset_size + effective_batch_size - 1) / effective_batch_size; let total_steps = steps_per_epoch * config.num_epochs;
let (synchronizer, distributed_optimizer) = if config.distributed.enabled {
let sync = create_synchronizer(&config.distributed)?;
let dist_opt = DistributedAdamW::new(
varmap,
AdamWConfig::with_lr(config.learning_rate),
)?;
(Some(sync), Some(dist_opt))
} else {
(None, None)
};
Ok(Self {
config,
varmap,
optimizer,
device,
global_step: start_step,
dataset_size,
total_steps,
batch_size,
seq_len,
steps_json_path,
headless,
prev_grad_norm: None,
start_step,
model_config,
data_file_path,
synchronizer,
distributed_optimizer,
})
}
pub fn training_step<D: DataLoader>(
&mut self,
model: &crate::model::LitGPT,
data_loader: &mut D
) -> Result<(f64, f64)> {
let mut total_loss = 0.0;
let mut last_grad_norm = 0.0;
let accum_steps = self.config.gradient_accumulation;
let load_balance_alpha = self.config.load_balance_alpha;
let max_grad_norm = 1.0;
let loss_prescale = if let Some(prev_norm) = self.prev_grad_norm {
if prev_norm > max_grad_norm {
max_grad_norm / (prev_norm + 1e-6)
} else {
1.0
}
} else {
1.0 };
for _ in 0..accum_steps {
let (input, target) = data_loader.next_batch()?;
let output = model.forward(&input)?;
let logits = output.logits;
let aux_losses = output.aux_losses;
if let Ok(logits_vec) = logits.flatten_all()?.to_vec1::<f32>() {
if logits_vec.iter().any(|x| x.is_nan() || x.is_infinite()) {
eprintln!("\n❌ NaN/Inf in model logits! This indicates:");
eprintln!(" 1. Numerical instability in forward pass");
eprintln!(" 2. RoPE or attention computation overflow");
eprintln!(" 3. Mamba SSM state explosion");
return Err(candle_core::Error::Msg("NaN in logits".to_string()));
}
}
let (b, s, v) = logits.dims3()?;
let logits_flat = logits.reshape((b * s, v))?;
let target_flat = target.reshape(b * s)?;
let ce_loss_per_token = candle_nn::loss::cross_entropy(&logits_flat, &target_flat)?;
let ce_loss = ce_loss_per_token.mean_all()?;
let mut total_aux_loss = 0.0;
for aux_loss in &aux_losses {
let mut loss_t = aux_loss.clone();
while loss_t.rank() > 0 && loss_t.elem_count() == 1 {
loss_t = loss_t.squeeze(0)?;
}
let loss_val = loss_t.to_vec0::<f32>()? as f64;
total_aux_loss += loss_val;
}
let avg_aux_loss = if !aux_losses.is_empty() {
total_aux_loss / aux_losses.len() as f64
} else {
0.0
};
let ce_loss_val = ce_loss.to_vec0::<f32>()? as f64;
if ce_loss_val.is_nan() || ce_loss_val.is_infinite() {
eprintln!("\n❌ NaN/Inf detected in CE loss! This usually indicates:");
eprintln!(" 1. Learning rate too high (try 1e-4 or lower)");
eprintln!(" 2. Gradient explosion (sequence too long)");
eprintln!(" 3. Numerical instability in model");
eprintln!(" Current CE loss: {}", ce_loss_val);
return Err(candle_core::Error::Msg("NaN loss detected".to_string()));
}
let aux_tensor = Tensor::new(avg_aux_loss as f32, ce_loss.device())?.to_dtype(ce_loss.dtype())?;
let alpha_tensor = Tensor::new(load_balance_alpha as f32, ce_loss.device())?.to_dtype(ce_loss.dtype())?;
let weighted_aux = aux_tensor.mul(&alpha_tensor)?;
let loss = ce_loss.clone().add(&weighted_aux)?;
let total_loss_val = loss.to_vec0::<f32>()? as f64;
if total_loss_val.is_nan() || total_loss_val.is_infinite() {
eprintln!("\n❌ NaN/Inf in combined loss! CE: {:.4}, Aux: {:.4}, Alpha: {:.4}",
ce_loss_val, avg_aux_loss, load_balance_alpha);
return Err(candle_core::Error::Msg("NaN loss detected".to_string()));
}
let scaled_loss = ((loss.clone() / accum_steps as f64)? * loss_prescale)?;
let grads = scaled_loss.backward()?;
if let (Some(ref sync), Some(ref mut dist_opt)) =
(&self.synchronizer, &mut self.distributed_optimizer)
{
let synced_grads = sync.synchronize_gradients(&grads, self.varmap)?;
let grad_norm = compute_grad_norm(&synced_grads)?;
last_grad_norm = grad_norm;
dist_opt.step(&synced_grads)?;
} else {
let (grads, grad_norm) = self.clip_gradients(grads, max_grad_norm)?;
last_grad_norm = grad_norm;
self.optimizer.step(&grads)?;
}
total_loss += ce_loss.to_vec0::<f32>()? as f64;
}
self.prev_grad_norm = Some(last_grad_norm);
self.global_step += 1;
Ok((total_loss / accum_steps as f64, last_grad_norm))
}
pub fn fit<D: DataLoader>(
&mut self,
model: &crate::model::LitGPT,
data_loader: &mut D
) -> Result<()> {
self.save_blazr_config_if_needed();
if self.headless {
self.fit_headless(model, data_loader)
} else {
self.fit_normal(model, data_loader)
}
}
fn save_blazr_config_if_needed(&self) {
if !Path::new(&self.config.checkpoint_dir).exists() {
if let Err(e) = std::fs::create_dir_all(&self.config.checkpoint_dir) {
if !self.headless {
eprintln!("Warning: Failed to create checkpoint directory: {}", e);
}
return;
}
}
if !BlazrConfig::exists(&self.config.checkpoint_dir) {
let blazr_config = BlazrConfig::from_model_config(&self.model_config);
if let Err(e) = blazr_config.save(&self.config.checkpoint_dir) {
if !self.headless {
eprintln!("Warning: Failed to save blazr config.json: {}", e);
}
} else if !self.headless {
println!("📝 Saved config.json for blazr inference compatibility");
}
}
}
fn fit_headless<D: DataLoader>(
&mut self,
model: &crate::model::LitGPT,
data_loader: &mut D
) -> Result<()> {
let tokens_per_step = self.batch_size * self.seq_len * self.config.gradient_accumulation;
let steps_per_epoch = (self.dataset_size + tokens_per_step - 1) / tokens_per_step;
let training_start = Instant::now();
let effective_steps = if self.config.max_steps == 0 {
self.total_steps
} else {
self.config.max_steps.min(self.total_steps)
};
for step in self.start_step..effective_steps {
let (loss, grad_norm) = self.training_step(model, data_loader)?;
let current_epoch = (step + 1) as f64 / steps_per_epoch as f64;
if step % self.config.log_interval == 0 || step == 0 {
let elapsed = training_start.elapsed().as_secs_f64();
let samples_processed = (step + 1) * self.batch_size * self.config.gradient_accumulation;
let it_s = samples_processed as f64 / elapsed;
let vram = std::process::Command::new("nvidia-smi")
.args(&["--query-compute-apps=used_memory", "--format=csv,noheader,nounits"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
.unwrap_or_else(|| "0".to_string());
println!("{{'step': {}, 'loss': {:.4}, 'grad_norm': {:.4}, 'learning_rate': {:.6e}, 'epoch': {:.2}, 'it/s': {:.2}, 'vram': '{}'}}",
step + 1, loss, grad_norm, self.config.learning_rate, current_epoch, it_s, vram);
}
}
let prefix = self.model_config.checkpoint_prefix();
println!("Saving final checkpoint to {}/{}_final.safetensors", self.config.checkpoint_dir, prefix);
self.save_checkpoint_named("final")?;
Ok(())
}
fn fit_normal<D: DataLoader>(
&mut self,
model: &crate::model::LitGPT,
data_loader: &mut D
) -> Result<()> {
let tokens_per_step = self.batch_size * self.seq_len * self.config.gradient_accumulation;
let steps_per_epoch = (self.dataset_size + tokens_per_step - 1) / tokens_per_step;
let effective_steps = if self.config.max_steps == 0 {
self.total_steps
} else {
self.config.max_steps.min(self.total_steps)
};
let mp = MultiProgress::new();
let sty = ProgressStyle::with_template(
"{bar:40.cyan/dim} {pos:>7}/{len:7} [{elapsed}<{msg}"
)
.unwrap()
.progress_chars("━━─");
let pb = mp.add(ProgressBar::new(effective_steps as u64));
pb.set_style(sty);
if self.start_step > 0 {
pb.set_position(self.start_step as u64);
mp.println(format!("📍 Resuming from step {}", self.start_step)).ok();
}
let mut last_loss = 0.0;
let mut last_grad_norm = 0.0;
const ROLLING_WINDOW: usize = 5;
let mut step_times: VecDeque<f64> = VecDeque::with_capacity(ROLLING_WINDOW);
let mut step_start = Instant::now();
for step in self.start_step..effective_steps {
let (loss, grad_norm) = self.training_step(model, data_loader)?;
last_loss = loss;
last_grad_norm = grad_norm;
let step_duration = step_start.elapsed().as_secs_f64();
step_times.push_back(step_duration);
if step_times.len() > ROLLING_WINDOW {
step_times.pop_front();
}
step_start = Instant::now();
let current_epoch = (step + 1) as f64 / steps_per_epoch as f64;
let avg_step_time: f64 = step_times.iter().sum::<f64>() / step_times.len() as f64;
let it_s = if avg_step_time > 0.0 { 1.0 / avg_step_time } else { 0.0 };
if step % self.config.log_interval == 0 || step == 0 {
let step_json = serde_json::json!({
"step": step + 1,
"loss": loss,
"grad_norm": grad_norm,
"learning_rate": self.config.learning_rate,
"epoch": current_epoch,
});
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.steps_json_path)
{
let _ = writeln!(file, "{}", step_json);
}
}
let remaining_steps = effective_steps - step - 1;
let eta_secs = avg_step_time * remaining_steps as f64;
let eta_str = format_duration(eta_secs);
pb.set_position((step + 1) as u64);
pb.set_message(format!(
"{}] loss: {:.4} | grad: {:.4} | {:.2} it/s",
eta_str, loss, grad_norm, it_s
));
if step > 0 && step % self.config.save_interval == 0 {
mp.println(format!("💾 Checkpoint saved: step {}", step)).ok();
self.save_checkpoint(step)?;
}
}
pb.finish_with_message(format!(
"loss: {:.4} | grad: {:.4} | done ✓",
last_loss, last_grad_norm
));
let prefix = self.model_config.checkpoint_prefix();
mp.println(format!("\n💾 Saving final checkpoint to {}/{}_final.safetensors", self.config.checkpoint_dir, prefix)).ok();
self.save_checkpoint_named("final")?;
mp.println(format!(
"✅ Training complete | final loss: {:.4} | grad: {:.4}",
last_loss, last_grad_norm
)).ok();
Ok(())
}
fn save_checkpoint(&self, step: usize) -> Result<()> {
let prefix = self.model_config.checkpoint_prefix();
let checkpoint_path = format!("{}/{}_step_{}.safetensors", self.config.checkpoint_dir, prefix, step);
if !Path::new(&self.config.checkpoint_dir).exists() {
std::fs::create_dir_all(&self.config.checkpoint_dir).map_err(candle_core::Error::wrap)?;
}
self.varmap.save(&checkpoint_path)?;
if let Some(ref data_path) = self.data_file_path {
if let Ok(meta) = CheckpointMeta::new(
step,
&self.model_config,
data_path,
&checkpoint_path,
) {
if let Err(e) = meta.save(&self.config.checkpoint_dir) {
eprintln!("Warning: Failed to save checkpoint metadata: {}", e);
}
}
}
if self.config.max_checkpoints > 0 {
self.cleanup_old_checkpoints()?;
}
Ok(())
}
fn cleanup_old_checkpoints(&self) -> Result<()> {
let dir = Path::new(&self.config.checkpoint_dir);
if !dir.exists() {
return Ok(());
}
let mut checkpoints: Vec<(usize, std::path::PathBuf)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if filename.contains("_step_") && filename.ends_with(".safetensors") {
if let Some(step_part) = filename.split("_step_").last() {
if let Some(step_str) = step_part.strip_suffix(".safetensors") {
if let Ok(step) = step_str.parse::<usize>() {
checkpoints.push((step, path));
}
}
}
}
}
}
}
checkpoints.sort_by_key(|(step, _)| *step);
let to_remove = checkpoints.len().saturating_sub(self.config.max_checkpoints);
for (step, path) in checkpoints.into_iter().take(to_remove) {
if let Err(e) = std::fs::remove_file(&path) {
eprintln!("Warning: Failed to remove old checkpoint {:?}: {}", path, e);
}
let meta_path = format!("{}/checkpoint_step_{}.meta.json", self.config.checkpoint_dir, step);
let _ = std::fs::remove_file(&meta_path); }
Ok(())
}
fn save_checkpoint_named(&self, name: &str) -> Result<()> {
let prefix = self.model_config.checkpoint_prefix();
let path = format!("{}/{}_{}.safetensors", self.config.checkpoint_dir, prefix, name);
if !Path::new(&self.config.checkpoint_dir).exists() {
std::fs::create_dir_all(&self.config.checkpoint_dir).map_err(candle_core::Error::wrap)?;
}
self.varmap.save(&path)?;
Ok(())
}
fn compute_grad_norm_from_grads(&self, grads: &candle_core::backprop::GradStore) -> Result<f64> {
let all_vars = self.varmap.all_vars();
let mut total_norm_sq = 0.0f64;
for var in &all_vars {
if let Some(grad) = grads.get(var) {
let grad_norm_sq = grad.powf(2.0)?
.sum_all()?
.to_vec0::<f32>()? as f64;
total_norm_sq += grad_norm_sq;
}
}
Ok(total_norm_sq.sqrt())
}
fn clip_gradients(&self, grads: candle_core::backprop::GradStore, _max_norm: f64) -> Result<(candle_core::backprop::GradStore, f64)> {
let total_norm = self.compute_grad_norm_from_grads(&grads)?;
Ok((grads, total_norm))
}
}
fn format_duration(secs: f64) -> String {
let total_secs = secs.round() as u64;
let days = total_secs / 86400;
let hours = (total_secs % 86400) / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if days > 0 {
format!("{}d{:02}h", days, hours)
} else if hours > 0 {
format!("{}h{:02}m", hours, minutes)
} else if minutes > 0 {
format!("{}m{:02}s", minutes, seconds)
} else {
format!("{}s", seconds)
}
}