PRATYUSH-BHARDWAJ commited on
Commit
8d922e8
·
verified ·
1 Parent(s): 9655bba

Upload training/train.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. training/train.py +1027 -0
training/train.py ADDED
@@ -0,0 +1,1027 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Cortex A 0.5 — SFT + 4-bit QAT for Qwen3.5-0.8B on Kaggle 2x T4.
3
+
4
+ Full-parameter SFT with Unsloth QAT (4-bit weights + 4-bit dynamic activations
5
+ when TorchAO exposes it, else official Unsloth int4 / int8-int4). Designed to
6
+ resume from Hugging Face, stop cleanly at 11.5h, and push checkpoints + metrics.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import gc
11
+ import inspect
12
+ import json
13
+ import math
14
+ import os
15
+ import random
16
+ import shutil
17
+ import time
18
+ import traceback
19
+ from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+ # Each torchrun process must see a single GPU so Unsloth's 1-GPU assert is happy
23
+ # while HF Trainer still DDP-coordinates via RANK/WORLD_SIZE.
24
+ if "LOCAL_RANK" in os.environ:
25
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(os.environ["LOCAL_RANK"])
26
+
27
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
28
+ os.environ.setdefault("UNSLOTH_DISABLE_STATISTICS", "1")
29
+ os.environ.setdefault("NCCL_P2P_DISABLE", "1")
30
+ os.environ.setdefault("NCCL_IB_DISABLE", "1")
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Config
34
+ # ---------------------------------------------------------------------------
35
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
36
+ HF_REPO = os.environ.get("HF_REPO", "PRATYUSH-BHARDWAJ/Cortex_A_0.5")
37
+ MODEL_NAME = os.environ.get("MODEL_NAME", "unsloth/Qwen3.5-0.8B")
38
+ OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/kaggle/working/cortex_sft")
39
+ MAX_TRAIN_SECONDS = int(float(os.environ.get("MAX_TRAIN_HOURS", "11.5")) * 3600)
40
+ SEED = int(os.environ.get("SEED", "3407"))
41
+ MAX_SEQ = int(os.environ.get("MAX_SEQ", "8192"))
42
+ BATCH = int(os.environ.get("BATCH", "2"))
43
+ GRAD_ACCUM = int(os.environ.get("GRAD_ACCUM", "4"))
44
+ LR = float(os.environ.get("LR", "2e-5"))
45
+ EPOCHS = float(os.environ.get("EPOCHS", "3"))
46
+ SAVE_STEPS = int(os.environ.get("SAVE_STEPS", "80"))
47
+ EVAL_STEPS = int(os.environ.get("EVAL_STEPS", "80"))
48
+ LOG_STEPS = int(os.environ.get("LOG_STEPS", "5"))
49
+ WARMUP_RATIO = float(os.environ.get("WARMUP_RATIO", "0.03"))
50
+ WEIGHT_DECAY = float(os.environ.get("WEIGHT_DECAY", "0.01"))
51
+ MAX_GRAD_NORM = float(os.environ.get("MAX_GRAD_NORM", "1.0"))
52
+ VAL_SIZE = int(os.environ.get("VAL_SIZE", "256"))
53
+ PACKING = os.environ.get("PACKING", "1") != "0"
54
+ QAT_SCHEME_PREF = os.environ.get("QAT_SCHEME", "int4-dyn")
55
+ MAX_SAMPLES = os.environ.get("MAX_SAMPLES") # smoke test cap
56
+ HER_CAP = int(os.environ.get("HER_CAP", "8000"))
57
+
58
+ START_TS = time.time()
59
+ IS_MAIN = int(os.environ.get("RANK", "0")) == 0
60
+
61
+
62
+ def log(*a):
63
+ if IS_MAIN:
64
+ print(*a, flush=True)
65
+
66
+
67
+ def is_rank0() -> bool:
68
+ return int(os.environ.get("RANK", "0")) == 0
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Dataset catalog (frontier distillation + roleplay)
73
+ # ---------------------------------------------------------------------------
74
+ # Core mix is r0b0tlab (Qwen3.8-Max + GLM-5.2 + Kimi K3) which already has a
75
+ # held-out val split. Complements fill Claude Opus 4.6/4.7/4.8 reasoning,
76
+ # DeepSeek V4 coding, and character roleplay (the core set is only 1.1% dialogue).
77
+ DATASETS = [
78
+ {
79
+ "id": "r0b0tlab/qwen3.8-max-glm5.2-kimi-k3-distillation",
80
+ "config": "sft_balanced",
81
+ "split": "train",
82
+ "domain": "mixed-sota",
83
+ "weight": 1.0,
84
+ "required": True,
85
+ },
86
+ {
87
+ "id": "Jackrong/DeepSeek-V4-Distill-8000x",
88
+ "domain": "coding",
89
+ "weight": 1.0,
90
+ },
91
+ {
92
+ "id": "Jackrong/Claude-opus-4.7-TraceInversion-5000x",
93
+ "domain": "reasoning",
94
+ "weight": 0.9,
95
+ },
96
+ {
97
+ "id": "Jackrong/Claude-opus-4.6-TraceInversion-9000x",
98
+ "domain": "reasoning",
99
+ "weight": 0.7,
100
+ },
101
+ {
102
+ "id": "Roman1111111/claude-opus-4.6-10000x",
103
+ "domain": "reasoning",
104
+ "weight": 0.6,
105
+ },
106
+ {
107
+ "id": "nohurry/Opus-4.6-Reasoning-3000x-filtered",
108
+ "domain": "reasoning",
109
+ "weight": 1.0,
110
+ },
111
+ {
112
+ "id": "ansulev/claude-opus-4.8-distill-5k",
113
+ "domain": "reasoning",
114
+ "weight": 1.0,
115
+ },
116
+ {
117
+ "id": "TeichAI/Claude-Opus-4.6-Reasoning-887x",
118
+ "domain": "reasoning-long",
119
+ "weight": 1.2,
120
+ },
121
+ {
122
+ "id": "lordx64/reasoning-distill-claude-opus-4-7-max",
123
+ "domain": "reasoning",
124
+ "weight": 0.8,
125
+ },
126
+ {
127
+ "id": "Jackrong/Qwen3.5-reasoning-700x",
128
+ "domain": "reasoning",
129
+ "weight": 1.0,
130
+ },
131
+ {
132
+ "id": "beyoru/Aesir-Character-CoT-roleplay",
133
+ "domain": "roleplay",
134
+ "weight": 1.5,
135
+ },
136
+ {
137
+ "id": "ChengyuDu0123/HER-Dataset",
138
+ "config": "sft_multi_turn",
139
+ "domain": "roleplay",
140
+ "weight": 1.0,
141
+ "cap": HER_CAP,
142
+ },
143
+ ]
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Message normalization
148
+ # ---------------------------------------------------------------------------
149
+ def _as_text(content) -> Optional[str]:
150
+ if content is None:
151
+ return ""
152
+ if isinstance(content, str):
153
+ return content
154
+ if isinstance(content, list):
155
+ parts = []
156
+ for p in content:
157
+ if isinstance(p, str):
158
+ parts.append(p)
159
+ elif isinstance(p, dict):
160
+ if p.get("type") in ("image", "image_url", "video", "video_url"):
161
+ return None
162
+ parts.append(str(p.get("text") or p.get("content") or ""))
163
+ return "\n".join(x for x in parts if x)
164
+ if isinstance(content, dict):
165
+ if content.get("type") in ("image", "image_url"):
166
+ return None
167
+ return str(content.get("text") or content.get("content") or "")
168
+ return str(content)
169
+
170
+
171
+ def _role_of(turn: dict) -> str:
172
+ r = (turn.get("role") or turn.get("from") or turn.get("speaker") or "user")
173
+ r = str(r).lower().strip()
174
+ mapping = {
175
+ "human": "user",
176
+ "gpt": "assistant",
177
+ "bot": "assistant",
178
+ "model": "assistant",
179
+ "ai": "assistant",
180
+ "assistant": "assistant",
181
+ "user": "user",
182
+ "system": "system",
183
+ "tool": "tool",
184
+ "function": "tool",
185
+ "observation": "tool",
186
+ }
187
+ return mapping.get(r, "user" if r not in ("assistant", "system", "tool") else r)
188
+
189
+
190
+ def extract_messages(ex: dict) -> Optional[list]:
191
+ for key in ("messages", "conversations", "conversation", "chat"):
192
+ raw = ex.get(key)
193
+ if not raw:
194
+ continue
195
+ if isinstance(raw, str):
196
+ try:
197
+ raw = json.loads(raw)
198
+ except Exception:
199
+ continue
200
+ if not isinstance(raw, list) or not raw:
201
+ continue
202
+ out = []
203
+ for t in raw:
204
+ if not isinstance(t, dict):
205
+ continue
206
+ role = _role_of(t)
207
+ text = _as_text(t.get("content") or t.get("value") or t.get("text") or t.get("message"))
208
+ if text is None:
209
+ return None
210
+ if not str(text).strip() and role != "tool":
211
+ continue
212
+ msg = {"role": role, "content": str(text)}
213
+ if t.get("tool_calls"):
214
+ msg["tool_calls"] = t["tool_calls"]
215
+ if t.get("name"):
216
+ msg["name"] = t["name"]
217
+ out.append(msg)
218
+ if out:
219
+ return out
220
+
221
+ # instruction / input / output
222
+ instr = ex.get("instruction") or ex.get("question") or ex.get("prompt")
223
+ outp = ex.get("output") or ex.get("response") or ex.get("completion") or ex.get("answer")
224
+ if instr and outp:
225
+ msgs = []
226
+ sys = ex.get("system") or ex.get("system_prompt")
227
+ if sys:
228
+ msgs.append({"role": "system", "content": str(sys)})
229
+ user = str(instr)
230
+ inp = ex.get("input")
231
+ if inp and str(inp).strip() and str(inp).strip() not in user:
232
+ user = user + "\n\n" + str(inp)
233
+ msgs.append({"role": "user", "content": user})
234
+ msgs.append({"role": "assistant", "content": str(outp)})
235
+ return msgs
236
+
237
+ text = ex.get("text")
238
+ if isinstance(text, str) and len(text.strip()) > 32:
239
+ return [{"role": "user", "content": "Continue."}, {"role": "assistant", "content": text}]
240
+ return None
241
+
242
+
243
+ def apply_template(tokenizer, messages, tools=None) -> Optional[str]:
244
+ kwargs = {"tokenize": False, "add_generation_prompt": False}
245
+ if tools:
246
+ kwargs["tools"] = tools
247
+ attempts = [
248
+ dict(kwargs, chat_template_kwargs={"enable_thinking": False}),
249
+ dict(kwargs, enable_thinking=False),
250
+ dict(kwargs),
251
+ ]
252
+ for kw in attempts:
253
+ try:
254
+ text = tokenizer.apply_chat_template(messages, **kw)
255
+ if isinstance(text, str) and text.strip():
256
+ return text
257
+ except TypeError:
258
+ continue
259
+ except Exception:
260
+ continue
261
+ return None
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # QAT helpers — 4-bit weights + 4-bit dynamic activations when available
266
+ # ---------------------------------------------------------------------------
267
+ def _linear_filter(mod, fqn: str) -> bool:
268
+ import torch.nn as nn
269
+
270
+ if not isinstance(mod, nn.Linear):
271
+ return False
272
+ if getattr(mod, "in_features", 0) < 128:
273
+ return False
274
+ skip = ("embed", "lm_head", "norm", "visual", "vision", "patch")
275
+ f = (fqn or "").lower()
276
+ if any(s in f for s in skip):
277
+ return False
278
+ return True
279
+
280
+
281
+ def resolve_qat_scheme():
282
+ """Return (scheme_obj_or_str, label). Prefer 4-bit dynamic activations."""
283
+ pref = QAT_SCHEME_PREF.lower()
284
+ if pref in ("int4", "int8-int4", "fp8-int4", "fp8-fp8", "int8", "cactus"):
285
+ return pref, pref
286
+
287
+ # Try TorchAO 4-bit dynamic activation + 4-bit weight configs
288
+ import_paths = [
289
+ ("torchao.quantization", "Int4DynamicActivationInt4WeightConfig"),
290
+ ("torchao.quantization.quant_api", "Int4DynamicActivationInt4WeightConfig"),
291
+ ("torchao.prototype.quantization", "Int4DynamicActivationInt4WeightConfig"),
292
+ ("torchao.quantization", "IntxDynamicActivationIntxWeightConfig"),
293
+ ]
294
+ for mod_name, cls_name in import_paths:
295
+ try:
296
+ import importlib
297
+ import torch
298
+
299
+ mod = importlib.import_module(mod_name)
300
+ cls = getattr(mod, cls_name)
301
+ if cls_name == "IntxDynamicActivationIntxWeightConfig":
302
+ try:
303
+ from torchao.quantization.granularity import PerGroup, PerToken
304
+ except Exception:
305
+ from torchao.quantization import PerGroup, PerToken # type: ignore
306
+ cfg = cls(
307
+ activation_dtype=torch.int4,
308
+ weight_dtype=torch.int4,
309
+ weight_granularity=PerGroup(32),
310
+ )
311
+ else:
312
+ try:
313
+ cfg = cls(group_size=32)
314
+ except TypeError:
315
+ cfg = cls()
316
+ log(f"[QAT] using TorchAO {cls_name} (4-bit weights + 4-bit dynamic activations)")
317
+ return cfg, f"torchao:{cls_name}"
318
+ except Exception as e:
319
+ log(f"[QAT] {mod_name}.{cls_name} unavailable: {e}")
320
+
321
+ # Official Unsloth schemes. int8-int4 = dynamic int8 activations + int4 weights.
322
+ # True int4 activations are not in Unsloth's public qat_scheme list; int4 is
323
+ # 4-bit weight QAT (the accuracy-recovery path Unsloth + TorchAO ship).
324
+ if pref in ("int4-dyn", "int4-int4", "auto"):
325
+ log("[QAT] falling back to Unsloth qat_scheme='int4' (4-bit weight QAT)")
326
+ return "int4", "int4"
327
+ return "int4", "int4"
328
+
329
+
330
+ def apply_qat(model, scheme) -> Any:
331
+ if scheme is None:
332
+ return model
333
+ # Unsloth internal prepare
334
+ try:
335
+ from unsloth.models._utils import _prepare_model_for_qat
336
+
337
+ log("[QAT] _prepare_model_for_qat")
338
+ return _prepare_model_for_qat(model, scheme)
339
+ except Exception as e:
340
+ log(f"[QAT] unsloth prepare failed: {e}")
341
+
342
+ try:
343
+ from torchao.quantization import quantize_
344
+ from torchao.quantization.qat import QATConfig
345
+ from torchao.quantization import Int4WeightOnlyConfig
346
+
347
+ base = scheme if not isinstance(scheme, str) else Int4WeightOnlyConfig(group_size=128)
348
+ quantize_(model, QATConfig(base, step="prepare"), filter_fn=_linear_filter)
349
+ log("[QAT] torchao QATConfig(prepare) applied")
350
+ return model
351
+ except Exception as e:
352
+ log(f"[QAT] torchao prepare failed: {e}")
353
+ return model
354
+
355
+
356
+ def freeze_vision(model):
357
+ n_freeze = 0
358
+ keys = ("visual", "vision_tower", "vision_model", "merger", "patch_embed", "vision_encoder")
359
+ for name, p in model.named_parameters():
360
+ nl = name.lower()
361
+ if any(k in nl for k in keys):
362
+ p.requires_grad = False
363
+ n_freeze += p.numel()
364
+ log(f"[model] froze vision params: {n_freeze/1e6:.2f}M")
365
+
366
+
367
+ def count_trainable(model) -> tuple[int, int]:
368
+ t = sum(p.numel() for p in model.parameters() if p.requires_grad)
369
+ a = sum(p.numel() for p in model.parameters())
370
+ return t, a
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # Callbacks
375
+ # ---------------------------------------------------------------------------
376
+ def make_callbacks(tokenizer=None):
377
+ from transformers import TrainerCallback, TrainerControl, TrainerState, TrainingArguments
378
+
379
+ class TimeLimitCallback(TrainerCallback):
380
+ def on_step_end(self, args, state: TrainerState, control: TrainerControl, **kwargs):
381
+ elapsed = time.time() - START_TS
382
+ remaining = MAX_TRAIN_SECONDS - elapsed
383
+ if remaining <= 180: # 3 min buffer inside the 11.5h cap for the final save
384
+ log(f"[time] stopping at {elapsed/3600:.2f}h (limit {MAX_TRAIN_SECONDS/3600:.2f}h)")
385
+ control.should_training_stop = True
386
+ control.should_save = True
387
+ return control
388
+
389
+ class MetricsCallback(TrainerCallback):
390
+ def __init__(self):
391
+ self.t0 = time.time()
392
+ self.last_t = self.t0
393
+ self.last_tokens = 0
394
+ self.history = []
395
+
396
+ def on_log(self, args, state: TrainerState, control: TrainerControl, logs=None, **kwargs):
397
+ if not is_rank0() or not logs:
398
+ return
399
+ logs = dict(logs)
400
+ loss = logs.get("loss") or logs.get("train_loss")
401
+ eval_loss = logs.get("eval_loss")
402
+ ppl = math.exp(min(float(loss), 20)) if loss is not None else None
403
+ val_ppl = math.exp(min(float(eval_loss), 20)) if eval_loss is not None else None
404
+ now = time.time()
405
+ tokens = int(getattr(state, "num_input_tokens_seen", 0) or 0)
406
+ dt = max(now - self.last_t, 1e-6)
407
+ tok_s = (tokens - self.last_tokens) / dt if tokens else logs.get("train_tokens_per_second")
408
+ self.last_t, self.last_tokens = now, tokens
409
+ trainer = kwargs.get("model")
410
+ mtp = None
411
+ # pull last mtp from trainer if present
412
+ tr = kwargs.get("trainer") or None
413
+ rec = {
414
+ "step": int(state.global_step),
415
+ "epoch": float(state.epoch or 0),
416
+ "loss": None if loss is None else round(float(loss), 6),
417
+ "mtp_loss": logs.get("mtp_loss") or logs.get("aux_loss"),
418
+ "ppl": None if ppl is None else round(float(ppl), 4),
419
+ "val_loss": None if eval_loss is None else round(float(eval_loss), 6),
420
+ "val_ppl": None if val_ppl is None else round(float(val_ppl), 4),
421
+ "tok_s": None if tok_s is None else round(float(tok_s), 1),
422
+ "grad_norm": logs.get("grad_norm"),
423
+ "lr": logs.get("learning_rate"),
424
+ "tokens_seen": tokens,
425
+ "elapsed_h": round((now - START_TS) / 3600, 4),
426
+ "remaining_h": round(max(MAX_TRAIN_SECONDS - (now - START_TS), 0) / 3600, 4),
427
+ "gpu_mem_gb": None,
428
+ }
429
+ try:
430
+ import torch
431
+
432
+ if torch.cuda.is_available():
433
+ rec["gpu_mem_gb"] = round(torch.cuda.max_memory_allocated() / 1024**3, 2)
434
+ except Exception:
435
+ pass
436
+ self.history.append(rec)
437
+ log(
438
+ f"[metrics] step={rec['step']} loss={rec['loss']} mtp={rec['mtp_loss']} "
439
+ f"ppl={rec['ppl']} val_loss={rec['val_loss']} val_ppl={rec['val_ppl']} "
440
+ f"tok/s={rec['tok_s']} gnorm={rec['grad_norm']} lr={rec['lr']} "
441
+ f"mem={rec['gpu_mem_gb']}G t={rec['elapsed_h']}h"
442
+ )
443
+ try:
444
+ Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
445
+ live = Path(OUTPUT_DIR) / "live_metrics.json"
446
+ live.write_text(json.dumps({"latest": rec, "history": self.history[-400:]}, indent=2))
447
+ if rec["step"] % max(LOG_STEPS * 4, 20) == 0 or rec["step"] <= 3:
448
+ from huggingface_hub import HfApi
449
+
450
+ HfApi(token=HF_TOKEN).upload_file(
451
+ path_or_fileobj=str(live),
452
+ path_in_repo="training/live_metrics.json",
453
+ repo_id=HF_REPO,
454
+ repo_type="model",
455
+ token=HF_TOKEN,
456
+ )
457
+ except Exception as e:
458
+ log(f"[metrics] hub upload skipped: {e}")
459
+
460
+ def on_save(self, args, state, control, **kwargs):
461
+ if not is_rank0():
462
+ return
463
+ pointer = {
464
+ "global_step": int(state.global_step),
465
+ "checkpoint": f"checkpoint-{state.global_step}",
466
+ "output_dir": args.output_dir,
467
+ "updated_unix": int(time.time()),
468
+ }
469
+ p = Path(OUTPUT_DIR) / "RESUME_POINTER.json"
470
+ p.write_text(json.dumps(pointer, indent=2))
471
+ try:
472
+ from huggingface_hub import HfApi
473
+
474
+ HfApi(token=HF_TOKEN).upload_file(
475
+ path_or_fileobj=str(p),
476
+ path_in_repo="training/RESUME_POINTER.json",
477
+ repo_id=HF_REPO,
478
+ repo_type="model",
479
+ token=HF_TOKEN,
480
+ )
481
+ except Exception as e:
482
+ log(f"[save] pointer upload skipped: {e}")
483
+
484
+ return [TimeLimitCallback(), MetricsCallback()]
485
+
486
+
487
+ # ---------------------------------------------------------------------------
488
+ # Resume
489
+ # ---------------------------------------------------------------------------
490
+ def find_resume() -> Optional[str]:
491
+ out = Path(OUTPUT_DIR)
492
+ local = sorted(out.glob("checkpoint-*"), key=lambda p: int(p.name.split("-")[-1]) if p.name.split("-")[-1].isdigit() else -1)
493
+ if local:
494
+ log(f"[resume] local {local[-1]}")
495
+ return str(local[-1])
496
+ try:
497
+ from huggingface_hub import HfApi, snapshot_download
498
+
499
+ api = HfApi(token=HF_TOKEN)
500
+ files = api.list_repo_files(HF_REPO, repo_type="model")
501
+ ckpts = []
502
+ for f in files:
503
+ if "checkpoint-" in f and f.endswith("trainer_state.json"):
504
+ try:
505
+ step = int(f.split("checkpoint-")[1].split("/")[0])
506
+ ckpts.append((step, f))
507
+ except Exception:
508
+ pass
509
+ if not ckpts:
510
+ # pointer file
511
+ if "training/RESUME_POINTER.json" in files:
512
+ log("[resume] pointer exists but no checkpoint files listed yet")
513
+ return None
514
+ step, _ = max(ckpts)
515
+ dest = out / f"checkpoint-{step}"
516
+ log(f"[resume] downloading checkpoint-{step} from hub")
517
+ snapshot_download(
518
+ HF_REPO,
519
+ repo_type="model",
520
+ allow_patterns=[f"**/*checkpoint-{step}/**", f"checkpoint-{step}/**"],
521
+ local_dir=str(out),
522
+ token=HF_TOKEN,
523
+ )
524
+ if dest.exists():
525
+ return str(dest)
526
+ # hub may nest
527
+ found = list(out.rglob(f"checkpoint-{step}/trainer_state.json"))
528
+ if found:
529
+ return str(found[0].parent)
530
+ except Exception as e:
531
+ log(f"[resume] hub lookup failed: {e}")
532
+ return None
533
+
534
+
535
+ # ---------------------------------------------------------------------------
536
+ # Data
537
+ # ---------------------------------------------------------------------------
538
+ def load_and_mix(tokenizer):
539
+ from datasets import Dataset, concatenate_datasets, load_dataset
540
+
541
+ pieces = []
542
+ val_ds = None
543
+ report = []
544
+
545
+ def take_split(dsobj, split_name="train"):
546
+ if hasattr(dsobj, "keys"):
547
+ keys = list(dsobj.keys())
548
+ if split_name in dsobj:
549
+ return dsobj[split_name]
550
+ if "train" in dsobj:
551
+ return dsobj["train"]
552
+ return dsobj[keys[0]]
553
+ return dsobj
554
+
555
+ for spec in DATASETS:
556
+ ds_id = spec["id"]
557
+ cfg = spec.get("config")
558
+ try:
559
+ kwargs = {"path": ds_id}
560
+ if cfg:
561
+ kwargs["name"] = cfg
562
+ log(f"[data] loading {ds_id}" + (f" ({cfg})" if cfg else ""))
563
+ raw = load_dataset(**kwargs)
564
+ split = take_split(raw, spec.get("split", "train"))
565
+
566
+ # r0b0tlab: prefer native validation
567
+ if ds_id.startswith("r0b0tlab/") and val_ds is None:
568
+ if hasattr(raw, "keys") and "validation" in raw:
569
+ val_ds = raw["validation"]
570
+ elif "split" in split.column_names:
571
+ val_ds = split.filter(lambda x: str(x.get("split", "")).lower() in ("validation", "val"))
572
+ split = split.filter(lambda x: str(x.get("split", "train")).lower() in ("train", ""))
573
+
574
+ cap = spec.get("cap")
575
+ if cap and len(split) > cap:
576
+ split = split.shuffle(seed=SEED).select(range(cap))
577
+
578
+ n_before = len(split)
579
+ # upsample via repetition for weight > 1
580
+ w = spec.get("weight", 1.0)
581
+ copies = max(int(round(w)), 1)
582
+ if copies > 1:
583
+ split = concatenate_datasets([split] * copies)
584
+
585
+ pieces.append(split)
586
+ report.append({"id": ds_id, "rows": n_before, "used": len(split), "domain": spec.get("domain")})
587
+ log(f"[data] {n_before} rows -> {len(split)} used ({spec.get('domain')})")
588
+ except Exception as e:
589
+ msg = f"[data] FAILED {ds_id}: {e}"
590
+ log(msg)
591
+ traceback.print_exc()
592
+ if spec.get("required"):
593
+ raise
594
+ report.append({"id": ds_id, "error": str(e)})
595
+
596
+ if not pieces:
597
+ raise RuntimeError("No datasets loaded")
598
+
599
+ train = concatenate_datasets(pieces).shuffle(seed=SEED)
600
+ if MAX_SAMPLES:
601
+ n = min(int(MAX_SAMPLES), len(train))
602
+ train = train.select(range(n))
603
+ if val_ds is not None:
604
+ val_ds = val_ds.select(range(min(64, len(val_ds))))
605
+
606
+ def to_text(ex):
607
+ msgs = extract_messages(ex)
608
+ if not msgs:
609
+ return {"text": ""}
610
+ tools = ex.get("tools")
611
+ if isinstance(tools, str):
612
+ try:
613
+ tools = json.loads(tools)
614
+ except Exception:
615
+ tools = None
616
+ if tools == []:
617
+ tools = None
618
+ text = apply_template(tokenizer, msgs, tools=tools)
619
+ return {"text": text or ""}
620
+
621
+ log("[data] applying chat template…")
622
+ num_proc = min(4, os.cpu_count() or 2)
623
+ cols = train.column_names
624
+ train = train.map(to_text, remove_columns=cols, num_proc=num_proc, desc="format-train")
625
+ train = train.filter(lambda x: isinstance(x.get("text"), str) and len(x["text"]) > 48)
626
+
627
+ if val_ds is not None:
628
+ vcols = val_ds.column_names
629
+ val_ds = val_ds.map(to_text, remove_columns=vcols, num_proc=num_proc, desc="format-val")
630
+ val_ds = val_ds.filter(lambda x: isinstance(x.get("text"), str) and len(x["text"]) > 48)
631
+ if len(val_ds) > VAL_SIZE:
632
+ val_ds = val_ds.shuffle(seed=SEED).select(range(VAL_SIZE))
633
+ else:
634
+ # last-resort holdout
635
+ n = min(VAL_SIZE, max(1, len(train) // 40))
636
+ val_ds = train.select(range(n))
637
+ train = train.select(range(n, len(train)))
638
+
639
+ log(f"[data] train={len(train)} val={len(val_ds)}")
640
+ if is_rank0():
641
+ Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
642
+ (Path(OUTPUT_DIR) / "dataset_report.json").write_text(json.dumps({"report": report, "train": len(train), "val": len(val_ds)}, indent=2))
643
+ return train, val_ds, report
644
+
645
+
646
+ # ---------------------------------------------------------------------------
647
+ # Model
648
+ # ---------------------------------------------------------------------------
649
+ def load_model(max_seq: int):
650
+ import torch
651
+
652
+ scheme, scheme_label = resolve_qat_scheme()
653
+ loader = None
654
+ loader_name = None
655
+ for name in ("FastVisionModel", "FastModel", "FastLanguageModel"):
656
+ try:
657
+ import unsloth
658
+
659
+ loader = getattr(unsloth, name)
660
+ loader_name = name
661
+ break
662
+ except Exception:
663
+ continue
664
+ if loader is None:
665
+ from unsloth import FastLanguageModel as loader
666
+
667
+ loader_name = "FastLanguageModel"
668
+ log(f"[model] loader={loader_name} name={MODEL_NAME} seq={max_seq} qat={scheme_label}")
669
+
670
+ kwargs = dict(
671
+ model_name=MODEL_NAME,
672
+ max_seq_length=max_seq,
673
+ load_in_4bit=False,
674
+ load_in_8bit=False,
675
+ full_finetuning=True,
676
+ use_gradient_checkpointing="unsloth",
677
+ dtype=None,
678
+ token=HF_TOKEN,
679
+ )
680
+ model = tokenizer = None
681
+ # Prefer passing qat_scheme when it's an official string
682
+ if isinstance(scheme, str):
683
+ try:
684
+ model, tokenizer = loader.from_pretrained(**kwargs, qat_scheme=scheme)
685
+ except TypeError:
686
+ model, tokenizer = loader.from_pretrained(**kwargs)
687
+ model = apply_qat(model, scheme)
688
+ else:
689
+ try:
690
+ model, tokenizer = loader.from_pretrained(**kwargs, qat_scheme=scheme)
691
+ except TypeError:
692
+ model, tokenizer = loader.from_pretrained(**kwargs)
693
+ model = apply_qat(model, scheme)
694
+
695
+ freeze_vision(model)
696
+ t, a = count_trainable(model)
697
+ log(f"[model] trainable {t/1e6:.2f}M / {a/1e6:.2f}M")
698
+
699
+ # Verify fake-quant modules landed
700
+ n_fq = 0
701
+ for m in model.modules():
702
+ if "FakeQuant" in m.__class__.__name__ or "QAT" in m.__class__.__name__:
703
+ n_fq += 1
704
+ log(f"[QAT] fake-quant / QAT modules detected: {n_fq}")
705
+ if n_fq == 0:
706
+ log("[QAT] WARNING: no fake-quant modules found — applying prepare again")
707
+ model = apply_qat(model, scheme if not isinstance(scheme, str) else scheme)
708
+
709
+ return model, tokenizer, loader, scheme_label
710
+
711
+
712
+ def build_sft_config(max_seq, batch, ga, resume_dir=None):
713
+ from trl import SFTConfig
714
+
715
+ params = set(inspect.signature(SFTConfig.__init__).parameters)
716
+ use_bf16 = False
717
+ use_fp16 = True
718
+ try:
719
+ import torch
720
+
721
+ # T4 has no bf16 tensor cores
722
+ major, _ = torch.cuda.get_device_capability(0) if torch.cuda.is_available() else (0, 0)
723
+ use_bf16 = major >= 8
724
+ use_fp16 = not use_bf16
725
+ except Exception:
726
+ pass
727
+
728
+ cfg = dict(
729
+ output_dir=OUTPUT_DIR,
730
+ per_device_train_batch_size=batch,
731
+ per_device_eval_batch_size=1,
732
+ gradient_accumulation_steps=ga,
733
+ num_train_epochs=EPOCHS,
734
+ learning_rate=LR,
735
+ warmup_ratio=WARMUP_RATIO,
736
+ weight_decay=WEIGHT_DECAY,
737
+ max_grad_norm=MAX_GRAD_NORM,
738
+ logging_steps=LOG_STEPS,
739
+ save_steps=SAVE_STEPS,
740
+ eval_steps=EVAL_STEPS,
741
+ save_total_limit=2,
742
+ lr_scheduler_type="cosine",
743
+ optim="adamw_8bit",
744
+ seed=SEED,
745
+ report_to="none",
746
+ fp16=use_fp16,
747
+ bf16=use_bf16,
748
+ dataloader_num_workers=2,
749
+ dataloader_pin_memory=True,
750
+ remove_unused_columns=False,
751
+ hub_model_id=HF_REPO,
752
+ hub_strategy="every_save",
753
+ push_to_hub=bool(HF_TOKEN),
754
+ hub_private_repo=True,
755
+ hub_token=HF_TOKEN,
756
+ save_safetensors=True,
757
+ logging_first_step=True,
758
+ load_best_model_at_end=False,
759
+ greater_is_better=False,
760
+ metric_for_best_model="eval_loss",
761
+ )
762
+ # eval strategy names differ
763
+ if "eval_strategy" in params:
764
+ cfg["eval_strategy"] = "steps"
765
+ cfg["save_strategy"] = "steps"
766
+ elif "evaluation_strategy" in params:
767
+ cfg["evaluation_strategy"] = "steps"
768
+ cfg["save_strategy"] = "steps"
769
+
770
+ if "max_length" in params:
771
+ cfg["max_length"] = max_seq
772
+ elif "max_seq_length" in params:
773
+ cfg["max_seq_length"] = max_seq
774
+
775
+ if "dataset_text_field" in params:
776
+ cfg["dataset_text_field"] = "text"
777
+ if "packing" in params and PACKING:
778
+ cfg["packing"] = True
779
+ if "padding_free" in params and PACKING:
780
+ cfg["padding_free"] = True
781
+ if "assistant_only_loss" in params:
782
+ cfg["assistant_only_loss"] = True
783
+ if "completion_only_loss" in params:
784
+ cfg["completion_only_loss"] = True
785
+ if "include_num_input_tokens_seen" in params:
786
+ cfg["include_num_input_tokens_seen"] = True
787
+ if "dataset_kwargs" in params:
788
+ cfg["dataset_kwargs"] = {"skip_prepare_dataset": False}
789
+ if "ddp_find_unused_parameters" in params:
790
+ cfg["ddp_find_unused_parameters"] = False
791
+ if "gradient_checkpointing" in params:
792
+ cfg["gradient_checkpointing"] = True
793
+ if "optim_args" in params:
794
+ pass
795
+ # drop unknown later via filter
796
+ cfg = {k: v for k, v in cfg.items() if k in params or k in ("output_dir",)}
797
+ # SFTConfig inherits TrainingArguments — keep keys it accepts
798
+ try:
799
+ return SFTConfig(**{k: v for k, v in cfg.items() if k in params})
800
+ except TypeError as e:
801
+ log(f"[cfg] SFTConfig retry after {e}")
802
+ # drop extras
803
+ ok = {}
804
+ for k, v in cfg.items():
805
+ try:
806
+ SFTConfig(**{**ok, k: v})
807
+ ok[k] = v
808
+ except TypeError:
809
+ log(f"[cfg] drop {k}")
810
+ return SFTConfig(**ok)
811
+
812
+
813
+ class CortexTrainer:
814
+ """Factory wrapping TRL SFTTrainer with MTP-aware compute_loss."""
815
+
816
+ @staticmethod
817
+ def build(model, tokenizer, train_ds, val_ds, args):
818
+ from trl import SFTTrainer
819
+
820
+ class _T(SFTTrainer):
821
+ def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
822
+ outputs = model(**inputs)
823
+ loss = outputs.loss if hasattr(outputs, "loss") else outputs[0]
824
+ extra = {}
825
+ for key in ("mtp_loss", "aux_loss"):
826
+ val = getattr(outputs, key, None)
827
+ if val is not None:
828
+ try:
829
+ extra[key] = float(val.detach().float().mean().item())
830
+ except Exception:
831
+ pass
832
+ if extra:
833
+ self._last_mtp = extra
834
+ try:
835
+ self.log(extra)
836
+ except Exception:
837
+ pass
838
+ return (loss, outputs) if return_outputs else loss
839
+
840
+ kw = dict(model=model, args=args, train_dataset=train_ds, eval_dataset=val_ds)
841
+ sig = inspect.signature(SFTTrainer.__init__)
842
+ if "processing_class" in sig.parameters:
843
+ kw["processing_class"] = tokenizer
844
+ elif "tokenizer" in sig.parameters:
845
+ kw["tokenizer"] = tokenizer
846
+ if "callbacks" in sig.parameters:
847
+ kw["callbacks"] = make_callbacks(tokenizer)
848
+ try:
849
+ return _T(**kw)
850
+ except TypeError:
851
+ kw.pop("eval_dataset", None)
852
+ return _T(**kw)
853
+
854
+
855
+ # ---------------------------------------------------------------------------
856
+ # Main
857
+ # ---------------------------------------------------------------------------
858
+ def main():
859
+ random.seed(SEED)
860
+ Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
861
+
862
+ if HF_TOKEN:
863
+ from huggingface_hub import login, HfApi
864
+
865
+ login(token=HF_TOKEN, add_to_git_credential=False)
866
+ if is_rank0():
867
+ try:
868
+ HfApi(token=HF_TOKEN).create_repo(HF_REPO, repo_type="model", private=True, exist_ok=True)
869
+ except Exception as e:
870
+ log(f"[hub] create_repo: {e}")
871
+
872
+ import torch
873
+
874
+ log(f"[hw] cuda={torch.cuda.is_available()} n={torch.cuda.device_count()}")
875
+ if torch.cuda.is_available():
876
+ for i in range(torch.cuda.device_count()):
877
+ p = torch.cuda.get_device_properties(i)
878
+ log(f"[hw] gpu{i} {p.name} {p.total_mem/1024**3:.1f}GB" if hasattr(p, "total_mem") else f"[hw] gpu{i} {p.name} {p.total_memory/1024**3:.1f}GB")
879
+
880
+ resume = find_resume()
881
+ seq, batch, ga = MAX_SEQ, BATCH, GRAD_ACCUM
882
+ backoff = [
883
+ (MAX_SEQ, BATCH, GRAD_ACCUM),
884
+ (MAX_SEQ, max(1, BATCH // 2), GRAD_ACCUM * 2),
885
+ (4096, 2, 4),
886
+ (4096, 1, 8),
887
+ (2048, 2, 4),
888
+ (2048, 1, 8),
889
+ ]
890
+ # de-dup
891
+ seen = set()
892
+ plans = []
893
+ for t in backoff:
894
+ if t not in seen:
895
+ seen.add(t)
896
+ plans.append(t)
897
+
898
+ last_err = None
899
+ for seq, batch, ga in plans:
900
+ model = tokenizer = trainer = None
901
+ try:
902
+ log(f"[run] seq={seq} batch={batch} ga={ga} packing={PACKING}")
903
+ model, tokenizer, loader, scheme_label = load_model(seq)
904
+ train_ds, val_ds, report = load_and_mix(tokenizer)
905
+ args = build_sft_config(seq, batch, ga)
906
+ trainer = CortexTrainer.build(model, tokenizer, train_ds, val_ds, args)
907
+ if is_rank0():
908
+ try:
909
+ if hasattr(trainer, "model"):
910
+ pass
911
+ (Path(OUTPUT_DIR) / "run_config.json").write_text(
912
+ json.dumps(
913
+ {
914
+ "model": MODEL_NAME,
915
+ "repo": HF_REPO,
916
+ "seq": seq,
917
+ "batch": batch,
918
+ "ga": ga,
919
+ "lr": LR,
920
+ "epochs": EPOCHS,
921
+ "qat": scheme_label,
922
+ "packing": PACKING,
923
+ "max_hours": MAX_TRAIN_SECONDS / 3600,
924
+ "world_size": int(os.environ.get("WORLD_SIZE", "1")),
925
+ "resume": resume,
926
+ "data": report,
927
+ },
928
+ indent=2,
929
+ )
930
+ )
931
+ except Exception:
932
+ pass
933
+ log("[train] starting")
934
+ trainer.train(resume_from_checkpoint=resume)
935
+ last_err = None
936
+ break
937
+ except torch.cuda.OutOfMemoryError as e:
938
+ last_err = e
939
+ log(f"[OOM] seq={seq} batch={batch}: {e}")
940
+ try:
941
+ del trainer, model, tokenizer
942
+ except Exception:
943
+ pass
944
+ gc.collect()
945
+ torch.cuda.empty_cache()
946
+ resume = None # fresh after OOM
947
+ continue
948
+ except Exception as e:
949
+ last_err = e
950
+ log(f"[run] failed: {e}")
951
+ traceback.print_exc()
952
+ # OOM-like
953
+ if "out of memory" in str(e).lower():
954
+ try:
955
+ del trainer, model, tokenizer
956
+ except Exception:
957
+ pass
958
+ gc.collect()
959
+ torch.cuda.empty_cache()
960
+ resume = None
961
+ continue
962
+ raise
963
+
964
+ if last_err is not None and trainer is None:
965
+ raise last_err
966
+
967
+ # Final save (still in QAT-prepare / fake-quant state so the next session can resume)
968
+ if is_rank0() and trainer is not None:
969
+ log("[save] final checkpoint")
970
+ trainer.save_model(OUTPUT_DIR)
971
+ try:
972
+ tokenizer.save_pretrained(OUTPUT_DIR)
973
+ except Exception:
974
+ pass
975
+ try:
976
+ trainer.push_to_hub(commit_message=f"Cortex A 0.5 SFT step {trainer.state.global_step}")
977
+ except Exception as e:
978
+ log(f"[save] push_to_hub: {e}")
979
+ try:
980
+ from huggingface_hub import HfApi
981
+
982
+ HfApi(token=HF_TOKEN).upload_folder(
983
+ folder_path=OUTPUT_DIR,
984
+ repo_id=HF_REPO,
985
+ repo_type="model",
986
+ token=HF_TOKEN,
987
+ ignore_patterns=["*.tmp", "checkpoint-*/*.pt"],
988
+ )
989
+ except Exception as e2:
990
+ log(f"[save] upload_folder: {e2}")
991
+
992
+ # If we actually finished epochs (not time-killed), convert QAT fake-quant → real 4-bit
993
+ elapsed = time.time() - START_TS
994
+ finished = elapsed < (MAX_TRAIN_SECONDS - 300) and trainer.state.global_step > 0
995
+ if finished:
996
+ log("[QAT] converting fake-quant → real quantized weights")
997
+ try:
998
+ from torchao.quantization import quantize_
999
+ from torchao.quantization.qat import QATConfig
1000
+
1001
+ quantize_(trainer.model, QATConfig(step="convert"))
1002
+ qdir = str(Path(OUTPUT_DIR) / "qat_converted")
1003
+ Path(qdir).mkdir(exist_ok=True)
1004
+ try:
1005
+ trainer.model.save_pretrained_torchao(qdir, tokenizer)
1006
+ except TypeError:
1007
+ trainer.model.save_pretrained_torchao(qdir)
1008
+ except Exception:
1009
+ trainer.model.save_pretrained(qdir)
1010
+ tokenizer.save_pretrained(qdir)
1011
+ from huggingface_hub import HfApi
1012
+
1013
+ HfApi(token=HF_TOKEN).upload_folder(
1014
+ folder_path=qdir,
1015
+ path_in_repo="qat_converted",
1016
+ repo_id=HF_REPO,
1017
+ repo_type="model",
1018
+ token=HF_TOKEN,
1019
+ )
1020
+ except Exception as e:
1021
+ log(f"[QAT] convert skipped: {e}")
1022
+
1023
+ log(f"[done] step={trainer.state.global_step} elapsed={elapsed/3600:.2f}h")
1024
+
1025
+
1026
+ if __name__ == "__main__":
1027
+ main()