Skip to main content

ferrotensor_trainer/
checkpoint.rs

1//! Saving and restoring LoRA adapters and training state.
2//!
3//! Adapters are written as safetensors (F32) with their LoRA settings in
4//! the file's metadata, so `ferrotensor-cli generate --lora` can rebuild the same
5//! adapter shapes. [`TrainingCheckpoint`] (JSON) holds the step counter,
6//! optimizer moments and loss-scaler state for resuming.
7
8use ferrotensor_autograd::{OptimizerCheckpoint, Tensor};
9use ferrotensor_backend::WgpuContext;
10use ferrotensor_loader::{LoaderError, WeightsContext};
11use std::collections::HashMap;
12use std::path::Path;
13use std::sync::Arc;
14
15use crate::scaler::GradScalerCheckpoint;
16
17/// Overwrite each tensor in `tensors` with the weight of the same name in
18/// `weights_ctx`; names that are missing are logged and skipped.
19pub fn load_weights(
20    ctx: &Arc<WgpuContext>,
21    weights_ctx: &WeightsContext,
22    tensors: &HashMap<String, Tensor>,
23) -> Result<(), LoaderError> {
24    for (name, tensor) in tensors {
25        if let Err(e) = weights_ctx.read_bytes(name, |data, _shape| {
26            tensor.data.write_data(ctx, data);
27        }) {
28            log::warn!("Could not load weight {}: {:?}", name, e);
29        }
30    }
31    Ok(())
32}
33
34/// LoRA settings stored in an adapter file's metadata.
35#[derive(Clone, Debug, PartialEq)]
36pub struct LoraMetadata {
37    /// Adapter rank.
38    pub rank: usize,
39    /// Scale numerator (the update is scaled by `alpha / rank`).
40    pub alpha: f32,
41    /// Projection names that carry adapters.
42    pub target_modules: Vec<String>,
43}
44
45impl LoraMetadata {
46    fn to_map(&self) -> HashMap<String, String> {
47        HashMap::from([
48            ("format".to_string(), "ferrotensor-lora".to_string()),
49            ("rank".to_string(), self.rank.to_string()),
50            ("alpha".to_string(), self.alpha.to_string()),
51            ("target_modules".to_string(), self.target_modules.join(",")),
52        ])
53    }
54
55    fn from_map(map: &HashMap<String, String>) -> Option<Self> {
56        Some(Self {
57            rank: map.get("rank")?.parse().ok()?,
58            alpha: map.get("alpha")?.parse().ok()?,
59            target_modules: map
60                .get("target_modules")?
61                .split(',')
62                .filter(|s| !s.is_empty())
63                .map(str::to_string)
64                .collect(),
65        })
66    }
67}
68
69/// Tensors and metadata read from an adapter file.
70#[derive(Clone, Debug)]
71pub struct LoraCheckpoint {
72    /// Tensor name to `(shape, values)`.
73    pub tensors: HashMap<String, (Vec<usize>, Vec<f32>)>,
74    /// LoRA settings, when the file was written with them.
75    pub metadata: Option<LoraMetadata>,
76}
77
78impl LoraCheckpoint {
79    /// Number of tensors.
80    pub fn len(&self) -> usize {
81        self.tensors.len()
82    }
83
84    /// Whether the file held no tensors.
85    pub fn is_empty(&self) -> bool {
86        self.tensors.is_empty()
87    }
88}
89
90/// Save LoRA adapter weights (F32 safetensors) to `path`, with `metadata`
91/// in the header. The file is written to a temporary name and renamed, so a
92/// crash never leaves a truncated adapter behind.
93pub async fn save_lora_checkpoint<P: AsRef<Path>>(
94    ctx: &Arc<WgpuContext>,
95    path: P,
96    tensors: &HashMap<String, Tensor>,
97    metadata: Option<&LoraMetadata>,
98) -> Result<(), Box<dyn std::error::Error>> {
99    let mut data_to_save = Vec::with_capacity(tensors.len());
100    for (name, tensor) in tensors {
101        let values = tensor.data.to_vec(ctx).await;
102        let bytes: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
103        data_to_save.push((name.clone(), tensor.data.shape.clone(), bytes));
104    }
105
106    let mut tensor_map = HashMap::new();
107    for (name, shape, bytes) in &data_to_save {
108        tensor_map.insert(
109            name.clone(),
110            safetensors::tensor::TensorView::new(safetensors::Dtype::F32, shape.clone(), bytes)?,
111        );
112    }
113
114    let data = safetensors::tensor::serialize(tensor_map, &metadata.map(LoraMetadata::to_map))?;
115    let path = path.as_ref();
116    let tmp = path.with_extension("safetensors.tmp");
117    std::fs::write(&tmp, data)?;
118    std::fs::rename(&tmp, path)?;
119    Ok(())
120}
121
122/// Load LoRA adapter weights from a safetensors file (F32, F16 or BF16
123/// tensors are accepted and returned as F32).
124pub fn load_lora_checkpoint<P: AsRef<Path>>(
125    path: P,
126) -> Result<LoraCheckpoint, Box<dyn std::error::Error>> {
127    let path = path.as_ref();
128    let data = std::fs::read(path)
129        .map_err(|e| format!("cannot read adapter file {}: {e}", path.display()))?;
130    let (_, header) = safetensors::tensor::SafeTensors::read_metadata(&data)
131        .map_err(|e| format!("{} is not a safetensors file: {e}", path.display()))?;
132    let metadata = header.metadata().as_ref().and_then(LoraMetadata::from_map);
133    let tensors = safetensors::tensor::SafeTensors::deserialize(&data)?;
134
135    let mut result = HashMap::new();
136    for (name, view) in tensors.tensors() {
137        let bytes = view.data();
138        let values: Vec<f32> = match view.dtype() {
139            safetensors::Dtype::F32 => bytes
140                .as_chunks::<4>()
141                .0
142                .iter()
143                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
144                .collect(),
145            safetensors::Dtype::F16 => bytes
146                .as_chunks::<2>()
147                .0
148                .iter()
149                .map(|c| half::f16::from_le_bytes([c[0], c[1]]).to_f32())
150                .collect(),
151            safetensors::Dtype::BF16 => bytes
152                .as_chunks::<2>()
153                .0
154                .iter()
155                .map(|c| half::bf16::from_le_bytes([c[0], c[1]]).to_f32())
156                .collect(),
157            other => return Err(format!("tensor {name} has unsupported dtype {other:?}").into()),
158        };
159        result.insert(name.to_string(), (view.shape().to_vec(), values));
160    }
161
162    Ok(LoraCheckpoint {
163        tensors: result,
164        metadata,
165    })
166}
167
168/// Copy checkpoint values into the matching `tensors`.
169///
170/// Every tensor in `tensors` must be present in the checkpoint with the
171/// same shape; otherwise nothing is written and the error names the first
172/// problem (a different rank or target set usually causes it). Returns the
173/// number of tensors restored.
174pub fn apply_checkpoint(
175    ctx: &Arc<WgpuContext>,
176    checkpoint: &LoraCheckpoint,
177    tensors: &HashMap<String, Tensor>,
178) -> Result<usize, String> {
179    let mut names: Vec<&String> = tensors.keys().collect();
180    names.sort();
181    for name in &names {
182        let tensor = &tensors[*name];
183        match checkpoint.tensors.get(*name) {
184            None => return Err(format!("adapter file has no tensor {name}")),
185            Some((shape, _)) if *shape != tensor.data.shape => {
186                return Err(format!(
187                    "tensor {name}: adapter file has shape {shape:?}, model expects {:?}",
188                    tensor.data.shape
189                ))
190            }
191            Some(_) => {}
192        }
193    }
194    for name in names {
195        if let Some((_, values)) = checkpoint.tensors.get(name) {
196            let bytes: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
197            tensors[name].data.write_data(ctx, &bytes);
198        }
199    }
200    Ok(tensors.len())
201}
202
203/// Progress and settings of a training run (also read by `ferrotensor-monitor`
204/// from `training_state.json`).
205#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
206pub struct TrainingState {
207    /// Optimizer steps completed.
208    pub step: usize,
209    /// Planned optimizer steps.
210    pub total_steps: usize,
211    /// Lowest logged training loss so far.
212    pub best_loss: Option<f32>,
213    /// Peak learning rate.
214    pub lr: f32,
215    /// Epochs completed.
216    pub epoch: usize,
217    /// Sequences per batch.
218    pub batch_size: usize,
219    /// Batches per optimizer step.
220    pub grad_accum_steps: usize,
221    /// Maximum tokens per example.
222    pub sequence_length: usize,
223    /// Whether loss scaling was enabled.
224    pub fp16: bool,
225    /// What was trained (`lora`, `all`, `attn`, ...).
226    pub train_modules: String,
227    /// Objective (`sft` or `pretrain`).
228    #[serde(default = "default_task")]
229    pub task: String,
230    /// Model directory.
231    #[serde(default)]
232    pub model_path: String,
233    /// Training data file.
234    #[serde(default)]
235    pub dataset_path: String,
236    /// Data-order seed of the run, so a resume replays the same order.
237    #[serde(default)]
238    pub seed: u64,
239}
240
241fn default_task() -> String {
242    "sft".to_string()
243}
244
245impl TrainingState {
246    /// A fresh state for a run of `total_steps` steps.
247    pub fn new(total_steps: usize) -> Self {
248        Self {
249            step: 0,
250            total_steps,
251            best_loss: None,
252            lr: 0.0,
253            epoch: 0,
254            batch_size: 1,
255            grad_accum_steps: 1,
256            sequence_length: 128,
257            fp16: false,
258            train_modules: "lora".to_string(),
259            task: "sft".to_string(),
260            model_path: String::new(),
261            dataset_path: String::new(),
262            seed: 0,
263        }
264    }
265
266    /// Write as pretty JSON.
267    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), Box<dyn std::error::Error>> {
268        let json = serde_json::to_string_pretty(self)?;
269        std::fs::write(path, json)?;
270        Ok(())
271    }
272
273    /// Read a state written by [`save`](Self::save).
274    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
275        let json = std::fs::read_to_string(path)?;
276        let state: Self = serde_json::from_str(&json)?;
277        Ok(state)
278    }
279
280    /// Validate that a resume is compatible with the current run configuration.
281    pub fn validate_resume(
282        &self,
283        train_modules: &str,
284        task: &str,
285        sequence_length: usize,
286        model_path: &str,
287    ) -> Result<(), String> {
288        if !self.train_modules.is_empty() && self.train_modules != train_modules {
289            return Err(format!(
290                "checkpoint train_modules `{}` != requested `{}`",
291                self.train_modules, train_modules
292            ));
293        }
294        if !self.task.is_empty() && self.task != task {
295            return Err(format!(
296                "checkpoint task `{}` != requested `{}`",
297                self.task, task
298            ));
299        }
300        if self.sequence_length != 0 && self.sequence_length != sequence_length {
301            return Err(format!(
302                "checkpoint sequence_length `{}` != requested `{}`",
303                self.sequence_length, sequence_length
304            ));
305        }
306        if !self.model_path.is_empty() && self.model_path != model_path {
307            return Err(format!(
308                "checkpoint model_path `{}` != requested `{}`",
309                self.model_path, model_path
310            ));
311        }
312        Ok(())
313    }
314}
315
316/// Everything besides the adapter weights needed to resume a run.
317#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
318pub struct TrainingCheckpoint {
319    /// Progress and settings.
320    pub training_state: TrainingState,
321    /// Optimizer moments keyed by parameter name.
322    pub optimizer_state: Option<OptimizerCheckpoint>,
323    /// Loss-scaler state, if a scaler was used.
324    pub grad_scaler_state: Option<GradScalerCheckpoint>,
325}
326
327impl TrainingCheckpoint {
328    /// Write as JSON (via a temporary file, so a crash never leaves a
329    /// truncated checkpoint).
330    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), Box<dyn std::error::Error>> {
331        let path = path.as_ref();
332        let json = serde_json::to_string_pretty(self)?;
333        let tmp = path.with_extension("json.tmp");
334        std::fs::write(&tmp, json)?;
335        std::fs::rename(&tmp, path)?;
336        Ok(())
337    }
338
339    /// Read a checkpoint written by [`save`](Self::save).
340    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
341        let json = std::fs::read_to_string(path)?;
342        let state: Self = serde_json::from_str(&json)?;
343        Ok(state)
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::{TrainingCheckpoint, TrainingState};
350    use crate::scaler::GradScalerCheckpoint;
351    use ferrotensor_autograd::{AdamWCheckpoint, OptimizerCheckpoint};
352    use std::collections::HashMap;
353
354    #[test]
355    fn resume_validation_rejects_task_mismatch() {
356        let mut state = TrainingState::new(10);
357        state.task = "sft".into();
358        state.train_modules = "lora".into();
359        state.sequence_length = 128;
360        state.model_path = "/models/a".into();
361        let err = state
362            .validate_resume("lora", "pretrain", 128, "/models/a")
363            .unwrap_err();
364        assert!(err.contains("task"));
365    }
366
367    #[test]
368    fn resume_validation_rejects_seq_len_and_modules() {
369        let mut state = TrainingState::new(10);
370        state.task = "sft".into();
371        state.train_modules = "lora".into();
372        state.sequence_length = 128;
373        state.model_path = "/models/a".into();
374        assert!(state
375            .validate_resume("all", "sft", 128, "/models/a")
376            .unwrap_err()
377            .contains("train_modules"));
378        assert!(state
379            .validate_resume("lora", "sft", 256, "/models/a")
380            .unwrap_err()
381            .contains("sequence_length"));
382        assert!(state
383            .validate_resume("lora", "sft", 128, "/models/b")
384            .unwrap_err()
385            .contains("model_path"));
386    }
387
388    #[test]
389    fn resume_validation_accepts_matching_config() {
390        let mut state = TrainingState::new(10);
391        state.task = "sft".into();
392        state.train_modules = "lora".into();
393        state.sequence_length = 128;
394        state.model_path = "/models/a".into();
395        assert!(state
396            .validate_resume("lora", "sft", 128, "/models/a")
397            .is_ok());
398    }
399
400    #[test]
401    fn training_checkpoint_roundtrip_preserves_resume_state() {
402        let path = std::env::temp_dir().join("ferrotensor_resume_roundtrip_test.json");
403        let mut training_state = TrainingState::new(100);
404        training_state.step = 42;
405        training_state.epoch = 1;
406        training_state.task = "pretrain".into();
407        training_state.train_modules = "lora".into();
408        training_state.sequence_length = 128;
409        training_state.model_path = "/models/tiny".into();
410        training_state.dataset_path = "./data/train.bin".into();
411        training_state.grad_accum_steps = 8;
412        training_state.fp16 = true;
413
414        let checkpoint = TrainingCheckpoint {
415            training_state: training_state.clone(),
416            optimizer_state: Some(OptimizerCheckpoint::AdamW(AdamWCheckpoint {
417                lr: 1e-4,
418                beta1: 0.9,
419                beta2: 0.999,
420                eps: 1e-8,
421                weight_decay: 0.01,
422                t: 42.0,
423                m_states: HashMap::new(),
424                v_states: HashMap::new(),
425            })),
426            grad_scaler_state: Some(GradScalerCheckpoint {
427                scale: 1024.0,
428                growth_factor: 2.0,
429                backoff_factor: 0.5,
430                growth_interval: 2000,
431                consecutive_good_steps: 3,
432                found_overflow: false,
433                enabled: true,
434            }),
435        };
436        checkpoint.save(&path).expect("save checkpoint");
437        let loaded = TrainingCheckpoint::load(&path).expect("load checkpoint");
438        let _ = std::fs::remove_file(&path);
439
440        assert_eq!(loaded.training_state.step, 42);
441        assert_eq!(loaded.training_state.task, "pretrain");
442        assert!(loaded
443            .training_state
444            .validate_resume("lora", "pretrain", 128, "/models/tiny")
445            .is_ok());
446        match loaded.optimizer_state.as_ref().unwrap() {
447            OptimizerCheckpoint::AdamW(state) => assert_eq!(state.t, 42.0),
448        }
449        assert_eq!(loaded.grad_scaler_state.as_ref().unwrap().scale, 1024.0);
450    }
451
452    /// Simulated continuous vs resume-to-end: remaining steps after resume
453    /// should equal an uninterrupted schedule.
454    #[test]
455    fn resume_remaining_steps_match_uninterrupted_schedule() {
456        let total_steps = 20usize;
457        let interrupt_at = 7usize;
458
459        let continuous_end = total_steps;
460        let mut resumed = TrainingState::new(total_steps);
461        resumed.step = interrupt_at;
462        resumed.task = "sft".into();
463        resumed.train_modules = "lora".into();
464        resumed.sequence_length = 64;
465        resumed.model_path = "m".into();
466        resumed.validate_resume("lora", "sft", 64, "m").unwrap();
467
468        let remaining = resumed.total_steps.saturating_sub(resumed.step);
469        assert_eq!(interrupt_at + remaining, continuous_end);
470        assert_eq!(remaining, total_steps - interrupt_at);
471    }
472}