File size: 12,514 Bytes
3b2d368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
import time
import torch
import re
import json
import shutil  # 新增:用于删除文件夹
from pathlib import Path
from glob import glob
from lmr.utils.logger import Logger
from lmr.utils.parsing import int_to_formatted_string
from lmr.ddp import unwrap_model

class Checkpointing:
    
    def __init__(self, model, checkpoint_dir, optimizer=None, scheduler=None, scaler=None, map_device="cpu"):
        
        self.model = model
        self.optimizer = optimizer
        self.scheduler = scheduler
        self.scaler = scaler
        self.map_device = map_device
        
        # State fields
        self.epoch = 0
        self.step = 0
        self.train_loss = float("inf")
        self.val_loss = float("inf")
        self.tokens_trained = 0

        # DDP flags
        self.use_ddp = torch.distributed.is_available() and torch.distributed.is_initialized()
        self.is_main_process = not self.use_ddp or torch.distributed.get_rank() == 0

        self.checkpoint_dir = Path(checkpoint_dir)
        
        if not self.is_main_process:
            return

        self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
        self.log_path = self.checkpoint_dir / "_checkpoint_log.tsv"
        self._create_log()

        self.best_val_loss = self._get_best_val_loss()

    # ---------- ddp ----------

    def _barrier(self):
        if self.use_ddp:
            torch.distributed.barrier()

    # ---------- logging + util ----------
    
    def _remove_old(self, pattern):
        """
        删除旧的 Checkpoint 文件夹。
        pattern 例如: "recent_epoch*_step*"
        """
        if not self.is_main_process:
            return
        
        # 查找匹配的文件夹
        candidates = sorted(glob(str(self.checkpoint_dir / pattern)))
        for path_str in candidates:
            path = Path(path_str)
            try:
                if path.is_dir():
                    shutil.rmtree(path) # 递归删除文件夹
                    print(f"🗑️ Removed old checkpoint dir: {path.name}")
                else:
                    path.unlink()
            except Exception as e:
                print(f"⚠️ Failed to remove {path}: {e}")

    def _get_best_val_loss(self):
        # 扫描文件夹名称来获取 best loss
        # 文件夹格式通常是: best_epoch_010_step_5000_val=0.4500
        best_dirs = glob(str(self.checkpoint_dir / "best_epoch*_val=*"))
        best_val = float("inf")
        for dir_path in best_dirs:
            match = re.search(r"val=([0-9.]+)", dir_path)
            if match:
                try:
                    best_val = min(best_val, float(match.group(1)))
                except ValueError:
                    pass
        return best_val

    def _checkpoint_dirname(self, epoch, step=None, val_loss=None, tokens_trained=None, prefix=None):
        """生成文件夹名称"""
        name = f"epoch_{epoch:03d}"
        
        if prefix is not None:
            name = f"{prefix}_{name}"
        if step is not None:
            name += f"_step_{step:09d}"
        if tokens_trained is not None:
            name += f"_tokens_{int_to_formatted_string(tokens_trained)}"
        if val_loss is not None:
            name += f"_val={val_loss:.4f}"
        
        return name

    def _create_log(self):
        if self.log_path.exists():
            return
        header = "Time\tCheckpoint_Type\tEpoch\tStep\tTrain_Loss\tVal_Loss\tTokens_Trained\n"
        with open(self.log_path, "w", encoding="utf-8") as f:
            f.write(header)

    def _update_log(self, kind, dirname):
        ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
        epoch_str = f"{self.epoch:03d}"
        step_str = f"{self.step:09d}"
        
        line = (
            f"{ts}\t{kind}\t{epoch_str}\t{step_str}\t"
            f"{'' if self.train_loss is None else self.train_loss}\t"
            f"{'' if self.val_loss is None else self.val_loss}\t"
            f"{dirname}\n"
        )
        
        with open(self.log_path, "a", encoding="utf-8") as f:
            f.write(line)

    # ---------- state saving ----------

    def _save_state(self, dirname, include_training_states=False):
        """
        创建文件夹并使用 save_pretrained 保存分片模型。
        """
        save_path = self.checkpoint_dir / dirname
        save_path.mkdir(parents=True, exist_ok=True)
        
        # 1. 保存模型权重 (使用 HF 原生分片逻辑)
        model = unwrap_model(self.model)
        
        if hasattr(model, "save_pretrained"):
            # 这是生成 model.safetensors.index.json 和 model-000xx.safetensors 的关键
            # max_shard_size 可以控制切分大小,默认通常是 5GB 或 10GB
            model.save_pretrained(save_path, safe_serialization=False, max_shard_size="5GB")
            
            # 同时也保存 tokenizer (如果有的话,建议在 Trainer 里传入 tokenizer 并挂载到 self.model 上,或者手动调 tokenizer.save_pretrained)
            # if hasattr(self, 'tokenizer') and self.tokenizer:
            #     self.tokenizer.save_pretrained(save_path)
            
        else:
            # 如果不是 HF 模型,回退到存单文件
            Logger.log("⚠️ Model does not have save_pretrained method. Saving single file.")
            from safetensors.torch import save_file
            state_dict = {k: v.cpu().contiguous() for k, v in model.state_dict().items()}
            save_file(state_dict, str(save_path / "model.safetensors"))

        # 2. 保存自定义 Metadata
        metadata = {
            "epoch": self.epoch,
            "step": self.step,
            "train_loss": self.train_loss,
            "val_loss": self.val_loss,
            "tokens_trained": self.tokens_trained,
        }
        with open(save_path / "trainer_state.json", 'w') as f:
            json.dump(metadata, f, indent=4)

        # 3. 保存 Optimizer/Scheduler (放在同一个文件夹里)
        if include_training_states:
            train_state = {}
            if self.optimizer is not None:
                train_state["optimizer"] = self.optimizer.state_dict()
            if self.scheduler is not None and hasattr(self.scheduler, "state_dict"):
                train_state["scheduler"] = self.scheduler.state_dict()
            if self.scaler is not None and hasattr(self.scaler, "state_dict"):
                train_state["scaler"] = self.scaler.state_dict()
            
            torch.save(train_state, save_path / "optimizer.pt")
            
        Logger.log(f"💾 Checkpoint saved to: {save_path}")

    def _update_state(self, epoch, step=0, train_loss=None, val_loss=None, tokens_trained=None):
        self.epoch = int(epoch)
        self.step = int(step) if step is not None else 0
        if train_loss is not None:
            self.train_loss = train_loss
        if val_loss is not None:
            self.val_loss = val_loss
        if tokens_trained is not None:
            self.tokens_trained = tokens_trained

    def _save_best(self):
        # 移除旧的 best 文件夹
        self._remove_old("best_epoch*") 
        
        dirname = self._checkpoint_dirname(self.epoch, self.step, self.val_loss, self.tokens_trained, prefix="best")
        self._save_state(dirname, include_training_states=False) # Best 通常只存权重,不存 optimizer 以节省空间
        self._update_log("best", dirname)
        
    def _save_recent(self):
        # 移除旧的 recent 文件夹
        self._remove_old("recent_epoch*")
        
        dirname = self._checkpoint_dirname(self.epoch, self.step, self.val_loss, self.tokens_trained, prefix="recent")
        self._save_state(dirname, include_training_states=True) # Recent 必须存 optimizer 用于恢复
        self._update_log("recent", dirname)

    def _save_epoch(self):
        dirname = self._checkpoint_dirname(self.epoch, None, self.val_loss, self.tokens_trained)
        self._save_state(dirname, include_training_states=False)
        self._update_log("epoch", dirname)
    
    def _save_step(self):
        dirname = self._checkpoint_dirname(self.epoch, self.step, self.val_loss, self.tokens_trained)
        self._save_state(dirname, include_training_states=False)
        self._update_log("step", dirname)

    # ---------- checkpoint saving ----------

    def save_checkpoint(self, epoch, step=None, train_loss=None, val_loss=None, tokens_trained=None):
        
        if self.is_main_process:
            self._update_state(epoch, step=step, train_loss=train_loss, val_loss=val_loss, tokens_trained=tokens_trained)
            
            # 保存 recent (用于断点续训)
            self._save_recent()

            # 保存 epoch 存档 (可选)
            if step is None:
                self.step = 0
                self._save_epoch()
            # 只有当你想每隔多少步存一个永久档时才打开这个
            # else:
            #     self._save_step()

            # 保存 best (用于推理)
            if (self.val_loss is not None) and (self.val_loss < self.best_val_loss):
                self.best_val_loss = self.val_loss
                self._save_best()
        
        self._barrier()

    # ---------- loading (适配文件夹结构) ----------

    def _get_checkpoint_path(self, checkpoint_type):
        """寻找对应的文件夹"""
        if checkpoint_type == "best":
            pattern = "best_epoch*"
        elif checkpoint_type == "recent":
            pattern = "recent_epoch*"
        elif checkpoint_type.startswith("epoch_"):
            pattern = f"{checkpoint_type}*"
        else:
            # 用户可能直接传了绝对路径
            path = self.checkpoint_dir / checkpoint_type
            if path.exists(): return path
            return None

        candidates = sorted(glob(str(self.checkpoint_dir / pattern)))
        if not candidates:
            Logger.log(f"No checkpoint found for {checkpoint_type}")
            return None
        # 返回最新的一个文件夹
        return Path(candidates[-1])

    def load_model_states(self, checkpoint_type="recent"):
        ckpt_dir = self._get_checkpoint_path(checkpoint_type)
        if not ckpt_dir: return

        Logger.log(f"📂 Loading model from dir: {ckpt_dir}")
        
        # 这里的加载逻辑其实在 Trainer.load_only_model_weights 里有更详细的实现
        # 这里主要是为了让 Checkpointing 类也能独立运作
        model = unwrap_model(self.model)
        
        # 优先尝试 HF 原生加载 (支持分片)
        if hasattr(model, "from_pretrained"):
            # 注意:from_pretrained 是类方法,但我们已经有实例了。
            # 对于已有实例,通常没有直接的 "load_pretrained" 方法来处理分片。
            # 所以我们还是依赖 Trainer 里的那个能够处理 index.json 的 load_only_model_weights 函数。
            # 这里我们只负责加载 metadata。
            pass 
        
        # 读取 Metadata
        meta_path = ckpt_dir / "trainer_state.json"
        if meta_path.exists():
            with open(meta_path, 'r') as f:
                state = json.load(f)
            self.epoch = int(state.get("epoch", 0))
            self.step = int(state.get("step", 0))
            self.train_loss = state.get("train_loss", None)
            self.val_loss = state.get("val_loss", None)
            self.tokens_trained = state.get("tokens_trained", 0)
        
        self._barrier()
        return ckpt_dir # 返回路径给 Trainer 用

    def load_training_states(self, checkpoint_type="recent"):
        ckpt_dir = self._get_checkpoint_path(checkpoint_type)
        if not ckpt_dir: return

        # Optimizer 在文件夹里面
        opt_path = ckpt_dir / "optimizer.pt"
        
        if not opt_path.exists():
            Logger.log(f"⚠️ Optimizer state not found in {ckpt_dir}")
            return

        state = torch.load(opt_path, map_location=self.map_device)

        if self.optimizer is not None and "optimizer" in state:
            self.optimizer.load_state_dict(state["optimizer"])
        if self.scheduler is not None and "scheduler" in state:
            self.scheduler.load_state_dict(state["scheduler"])
        if self.scaler is not None and "scaler" in state:
            self.scaler.load_state_dict(state["scaler"])
            
        Logger.log(f"✅ Loaded training states from {opt_path}")
        self._barrier()