1use 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
17pub 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#[derive(Clone, Debug, PartialEq)]
36pub struct LoraMetadata {
37 pub rank: usize,
39 pub alpha: f32,
41 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#[derive(Clone, Debug)]
71pub struct LoraCheckpoint {
72 pub tensors: HashMap<String, (Vec<usize>, Vec<f32>)>,
74 pub metadata: Option<LoraMetadata>,
76}
77
78impl LoraCheckpoint {
79 pub fn len(&self) -> usize {
81 self.tensors.len()
82 }
83
84 pub fn is_empty(&self) -> bool {
86 self.tensors.is_empty()
87 }
88}
89
90pub 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
122pub 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
168pub 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#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
206pub struct TrainingState {
207 pub step: usize,
209 pub total_steps: usize,
211 pub best_loss: Option<f32>,
213 pub lr: f32,
215 pub epoch: usize,
217 pub batch_size: usize,
219 pub grad_accum_steps: usize,
221 pub sequence_length: usize,
223 pub fp16: bool,
225 pub train_modules: String,
227 #[serde(default = "default_task")]
229 pub task: String,
230 #[serde(default)]
232 pub model_path: String,
233 #[serde(default)]
235 pub dataset_path: String,
236 #[serde(default)]
238 pub seed: u64,
239}
240
241fn default_task() -> String {
242 "sft".to_string()
243}
244
245impl TrainingState {
246 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 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 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 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#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
318pub struct TrainingCheckpoint {
319 pub training_state: TrainingState,
321 pub optimizer_state: Option<OptimizerCheckpoint>,
323 pub grad_scaler_state: Option<GradScalerCheckpoint>,
325}
326
327impl TrainingCheckpoint {
328 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 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 #[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}