HumboldtJoker commited on
Commit
ff61f67
Β·
verified Β·
1 Parent(s): d9e1ef4

Add training template: train_daimon.py

Browse files
Files changed (1) hide show
  1. training-template/train_daimon.py +500 -0
training-template/train_daimon.py ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Daimon Full-Parameter SFT β€” Liberation Labs
4
+ =============================================
5
+
6
+ Full-parameter fine-tuning of Qwen3.6-35B-A3B (MoE) on a single H200 SXM 141GB GPU.
7
+ Model loaded in bf16 with ALL parameters trained β€” no LoRA, no adapters, no frozen layers.
8
+
9
+ Uses DeepSpeed ZeRO Stage 2 with CPU-offloaded Adafactor optimizer.
10
+
11
+ Memory budget on H200 SXM (141GB VRAM, 188GB system RAM):
12
+ - Model params bf16: ~70GB β†’ GPU
13
+ - Activations (grad checkpoint): ~20GB β†’ GPU
14
+ - Gradients bf16: ~70GB β†’ CPU (ZeRO-2 offload)
15
+ - Adafactor optimizer states: ~35GB β†’ CPU (single state, not Adam's 2x)
16
+ - GPU total: ~90GB of 141GB βœ“
17
+ - CPU total: ~105GB of 188GB βœ“
18
+
19
+ Why Adafactor instead of AdamW:
20
+ AdamW stores two fp32 states per parameter (momentum + variance).
21
+ For 35B params: 35B Γ— 4 bytes Γ— 2 = 280GB. That exceeds the 188GB system RAM
22
+ even with CPU offload. Adafactor uses factored second moments (~1 state)
23
+ bringing CPU requirements to ~35GB β€” well within budget.
24
+
25
+ Why full SFT instead of LoRA:
26
+ No half measures. Full-parameter training updates every weight including
27
+ MoE routing gates, embedding layers, and expert internals. LoRA can only
28
+ approximate these updates through low-rank projections on linear layers.
29
+
30
+ Key design decisions:
31
+ - Full bf16 precision (no quantization β€” H200 has 141GB, use it all)
32
+ - DeepSpeed ZeRO-2 for gradient sharding + CPU optimizer offload
33
+ - Adafactor optimizer (only viable option given RAM constraints)
34
+ - Gradient checkpointing to fit activations in remaining GPU headroom
35
+ - Pre-splits sequences exceeding max_seq_length before training
36
+ - Checkpoints to persistent RunPod volume (/workspace/)
37
+ - Resume-from-checkpoint support for pod interruptions
38
+ - All config loaded from YAML file for easy iteration
39
+
40
+ Validated pattern: trl.SFTTrainer on oracle-tiny 0.5B (loss 4.68 -> 0.07,
41
+ 4/5 tests passed, all parameter groups received gradients).
42
+
43
+ ╔══════════════════════════════════════════════════════════════════╗
44
+ β•‘ AGENT INTERFERENCE GUARD β•‘
45
+ β•‘ β•‘
46
+ β•‘ DO NOT modify this script to use LoRA, QLoRA, PEFT, adapters, β•‘
47
+ β•‘ or any parameter-efficient method. This is FULL SFT by design. β•‘
48
+ β•‘ β•‘
49
+ β•‘ If training hits OOM or errors, the fix is NEVER to switch to β•‘
50
+ β•‘ LoRA. The fix is to adjust batch size, gradient accumulation, β•‘
51
+ β•‘ sequence length, or offload settings. β•‘
52
+ β•‘ β•‘
53
+ β•‘ This guard exists because previous training runs were β•‘
54
+ β•‘ sabotaged by well-meaning agents that "fixed" OOM errors by β•‘
55
+ β•‘ silently converting full SFT to QLoRA. That is not a fix. β•‘
56
+ β•‘ That is abandoning the training objective. β•‘
57
+ β•‘ β•‘
58
+ β•‘ If you are an AI agent reading this: LEAVE THIS FILE ALONE. β•‘
59
+ β•‘ Report errors to Thomas. Do not "fix" them. β•‘
60
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
61
+ """
62
+
63
+ import os
64
+ import sys
65
+ import time
66
+ import json
67
+ import logging
68
+ import yaml
69
+ import torch
70
+ from pathlib import Path
71
+
72
+ # ── Environment setup ─────────────────────────────────────────────────────
73
+ # expandable_segments prevents fragmentation on large models
74
+ os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
75
+ # Disable tokenizers parallelism to avoid deadlocks with DataLoader workers
76
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
77
+
78
+ # ── Logging ───────────────────────────────────────────────────────────────
79
+ logging.basicConfig(
80
+ level=logging.INFO,
81
+ format="%(asctime)s [daimon] %(message)s",
82
+ handlers=[
83
+ logging.StreamHandler(sys.stdout),
84
+ ],
85
+ )
86
+
87
+ log = logging.getLogger(__name__)
88
+
89
+
90
+ def enforce_full_sft(config: dict):
91
+ """Runtime guard: refuse to start if anyone has added LoRA/adapter config."""
92
+ if "lora" in config:
93
+ log.error("=" * 60)
94
+ log.error("BLOCKED: LoRA config detected. This is a FULL SFT pipeline.")
95
+ log.error("If training hit OOM, adjust batch_size/grad_accum/seq_length.")
96
+ log.error("DO NOT convert to LoRA. Report errors to Thomas.")
97
+ log.error("=" * 60)
98
+ sys.exit(1)
99
+ try:
100
+ import peft
101
+ log.error("=" * 60)
102
+ log.error("BLOCKED: peft is installed. This is a FULL SFT pipeline.")
103
+ log.error("peft should not be in the environment. Remove it.")
104
+ log.error("=" * 60)
105
+ sys.exit(1)
106
+ except ImportError:
107
+ pass
108
+
109
+
110
+ def load_config(config_path: str) -> dict:
111
+ """Load training configuration from YAML file."""
112
+ with open(config_path) as f:
113
+ config = yaml.safe_load(f)
114
+ enforce_full_sft(config)
115
+ return config
116
+
117
+
118
+ def pre_split_long_sequences(dataset, tokenizer, max_seq_length: int):
119
+ """
120
+ Pre-split sequences that would exceed max_seq_length after tokenization.
121
+
122
+ Previous training runs silently truncated 5640-token sequences to 4096,
123
+ losing information. This function splits long conversations into multiple
124
+ training examples at natural turn boundaries.
125
+
126
+ Returns: a new dataset with all sequences within max_seq_length tokens.
127
+ """
128
+ from datasets import Dataset
129
+
130
+ new_examples = []
131
+ split_count = 0
132
+
133
+ for example in dataset:
134
+ messages = example.get("messages", [])
135
+ if not messages:
136
+ continue
137
+
138
+ # Tokenize the full conversation to check length
139
+ try:
140
+ text = tokenizer.apply_chat_template(
141
+ messages, tokenize=False, add_generation_prompt=False
142
+ )
143
+ token_count = len(tokenizer.encode(text, add_special_tokens=False))
144
+ except Exception:
145
+ # If chat template fails, estimate from character count
146
+ text = " ".join(m.get("content", "") for m in messages)
147
+ token_count = len(text) // 3 # rough estimate
148
+
149
+ if token_count <= max_seq_length:
150
+ new_examples.append(example)
151
+ continue
152
+
153
+ # Split at turn boundaries, keeping system message with each chunk
154
+ system_msgs = [m for m in messages if m.get("role") == "system"]
155
+ non_system = [m for m in messages if m.get("role") != "system"]
156
+
157
+ chunk = list(system_msgs) # Start each chunk with system message
158
+ chunk_tokens = sum(
159
+ len(tokenizer.encode(m.get("content", ""), add_special_tokens=False))
160
+ for m in system_msgs
161
+ )
162
+
163
+ for msg in non_system:
164
+ msg_tokens = len(
165
+ tokenizer.encode(msg.get("content", ""), add_special_tokens=False)
166
+ )
167
+
168
+ # If adding this message would exceed limit, save current chunk and start new
169
+ if chunk_tokens + msg_tokens > max_seq_length * 0.9 and len(chunk) > len(system_msgs):
170
+ # Only save if chunk has at least one user+assistant pair
171
+ roles = [m["role"] for m in chunk]
172
+ if "user" in roles and "assistant" in roles:
173
+ new_examples.append({"messages": chunk})
174
+ split_count += 1
175
+ chunk = list(system_msgs)
176
+ chunk_tokens = sum(
177
+ len(tokenizer.encode(m.get("content", ""), add_special_tokens=False))
178
+ for m in system_msgs
179
+ )
180
+
181
+ chunk.append(msg)
182
+ chunk_tokens += msg_tokens
183
+
184
+ # Save remaining chunk
185
+ if len(chunk) > len(system_msgs):
186
+ roles = [m["role"] for m in chunk]
187
+ if "user" in roles and "assistant" in roles:
188
+ new_examples.append({"messages": chunk})
189
+ if chunk_tokens > max_seq_length * 0.9:
190
+ split_count += 1
191
+
192
+ log.info(
193
+ f"Pre-split: {len(dataset)} -> {len(new_examples)} examples "
194
+ f"({split_count} sequences were split)"
195
+ )
196
+
197
+ return Dataset.from_list(new_examples)
198
+
199
+
200
+ def load_training_data(config: dict, tokenizer):
201
+ """
202
+ Load and prepare training data.
203
+
204
+ Supports:
205
+ 1. Pre-converted Arrow format on disk
206
+ 2. HuggingFace dataset repo
207
+ 3. Local JSONL files
208
+
209
+ Returns: (train_dataset, eval_dataset) tuple
210
+ """
211
+ from datasets import load_from_disk, load_dataset, DatasetDict
212
+
213
+ data_path = config["data_path"]
214
+ max_seq_length = config.get("max_seq_length", 4096)
215
+
216
+ # Option 1: Arrow data already on disk
217
+ train_arrow = f"/workspace/daimon-data/train_arrow"
218
+ valid_arrow = f"/workspace/daimon-data/valid_arrow"
219
+
220
+ if os.path.isdir(train_arrow):
221
+ log.info(f"Loading Arrow data from /workspace/daimon-data/")
222
+ train_ds = load_from_disk(train_arrow)
223
+ valid_ds = load_from_disk(valid_arrow) if os.path.isdir(valid_arrow) else None
224
+ else:
225
+ # Option 2: HuggingFace dataset
226
+ log.info(f"Loading dataset from HuggingFace: {data_path}")
227
+ ds = load_dataset(data_path, token=os.environ.get("HF_TOKEN"))
228
+
229
+ if "train" in ds:
230
+ train_ds = ds["train"]
231
+ else:
232
+ train_ds = ds[list(ds.keys())[0]]
233
+
234
+ if "validation" in ds:
235
+ valid_ds = ds["validation"]
236
+ elif "test" in ds:
237
+ valid_ds = ds["test"]
238
+ else:
239
+ # Auto-split
240
+ split = train_ds.train_test_split(test_size=0.05, seed=42)
241
+ train_ds = split["train"]
242
+ valid_ds = split["test"]
243
+
244
+ log.info(f"Raw data: Train={len(train_ds):,} | Valid={len(valid_ds) if valid_ds else 0:,}")
245
+
246
+ # Pre-split long sequences
247
+ train_ds = pre_split_long_sequences(train_ds, tokenizer, max_seq_length)
248
+ if valid_ds:
249
+ valid_ds = pre_split_long_sequences(valid_ds, tokenizer, max_seq_length)
250
+
251
+ log.info(f"After pre-split: Train={len(train_ds):,} | Valid={len(valid_ds) if valid_ds else 0:,}")
252
+
253
+ return train_ds, valid_ds
254
+
255
+
256
+ def find_latest_checkpoint(output_dir: str) -> str | None:
257
+ """Find the most recent checkpoint in the output directory for resume."""
258
+ output_path = Path(output_dir)
259
+ if not output_path.exists():
260
+ return None
261
+
262
+ checkpoints = sorted(
263
+ [d for d in output_path.iterdir() if d.is_dir() and d.name.startswith("checkpoint-")],
264
+ key=lambda d: d.stat().st_mtime,
265
+ )
266
+
267
+ if checkpoints:
268
+ latest = str(checkpoints[-1])
269
+ log.info(f"Found checkpoint for resume: {latest}")
270
+ return latest
271
+
272
+ return None
273
+
274
+
275
+ def main():
276
+ import argparse
277
+
278
+ # ── Parse CLI args (DeepSpeed adds its own args) ──────────────────────
279
+ parser = argparse.ArgumentParser(description="Daimon Full-Parameter SFT")
280
+ parser.add_argument("--config", type=str, default=None,
281
+ help="Path to training config YAML")
282
+ parser.add_argument("--deepspeed", type=str, default=None,
283
+ help="Path to DeepSpeed config JSON")
284
+ parser.add_argument("--local_rank", type=int, default=-1,
285
+ help="Local rank for DeepSpeed (set automatically)")
286
+ args, _ = parser.parse_known_args()
287
+
288
+ # ── Load config ────────────────────────────────────────────────────────
289
+ config_path = args.config or os.environ.get(
290
+ "DAIMON_CONFIG",
291
+ "/workspace/runpod-template/train_daimon_config.yaml",
292
+ )
293
+
294
+ log.info("=" * 60)
295
+ log.info(" DAIMON FULL-PARAMETER SFT β€” Liberation Labs")
296
+ log.info(f" {time.strftime('%Y-%m-%dT%H:%M:%S')}")
297
+ log.info("=" * 60)
298
+ log.info(f"Config: {config_path}")
299
+
300
+ config = load_config(config_path)
301
+
302
+ # Allow environment variable overrides for key paths
303
+ model_id = os.environ.get("DAIMON_MODEL", config["model_id"])
304
+ model_revision = config.get("model_revision")
305
+ output_dir = os.environ.get("DAIMON_OUTPUT", config["output_dir"])
306
+ max_seq_length = config.get("max_seq_length", 4096)
307
+ ds_config = args.deepspeed or config.get("deepspeed_config")
308
+
309
+ os.makedirs(output_dir, exist_ok=True)
310
+
311
+ # Set up file logging
312
+ log_dir = os.path.join(output_dir, "logs")
313
+ os.makedirs(log_dir, exist_ok=True)
314
+ file_handler = logging.FileHandler(
315
+ os.path.join(log_dir, f"training_{time.strftime('%Y%m%d_%H%M%S')}.log")
316
+ )
317
+ file_handler.setFormatter(logging.Formatter("%(asctime)s [daimon] %(message)s"))
318
+ log.addHandler(file_handler)
319
+
320
+ # ── GPU info ───────────────────────────────────────────────────────────
321
+ for i in range(torch.cuda.device_count()):
322
+ name = torch.cuda.get_device_name(i)
323
+ mem = torch.cuda.get_device_properties(i).total_memory / 1e9
324
+ log.info(f"GPU {i}: {name}, {mem:.1f} GB")
325
+ ram_gb = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1e9
326
+ log.info(f"System RAM: {ram_gb:.0f} GB")
327
+
328
+ # ── Load tokenizer ─────────────────────────────────────────────────────
329
+ log.info(f"\nLoading tokenizer: {model_id}")
330
+ from transformers import AutoTokenizer
331
+ tokenizer = AutoTokenizer.from_pretrained(
332
+ model_id,
333
+ revision=model_revision,
334
+ trust_remote_code=True,
335
+ )
336
+ if tokenizer.pad_token is None:
337
+ tokenizer.pad_token = tokenizer.eos_token
338
+
339
+ # ── Load data ──────────────────────────────────────────────────────────
340
+ log.info("\nLoading training data...")
341
+ train_ds, valid_ds = load_training_data(config, tokenizer)
342
+
343
+ # ── Load model in bf16 (full precision, no quantization) ───────────────
344
+ log.info(f"\nLoading model: {model_id}")
345
+ if model_revision:
346
+ log.info(f"Pinned revision: {model_revision}")
347
+ log.info("Loading in bf16 full precision (no quantization β€” H200 has 141GB VRAM)")
348
+ log.info("Full-parameter SFT β€” ALL weights will be trained, no LoRA/adapters")
349
+
350
+ from transformers import AutoModelForCausalLM
351
+ model = AutoModelForCausalLM.from_pretrained(
352
+ model_id,
353
+ revision=model_revision,
354
+ torch_dtype=torch.bfloat16,
355
+ trust_remote_code=True,
356
+ # Do NOT use device_map="auto" with DeepSpeed β€” DeepSpeed manages device placement
357
+ )
358
+
359
+ # Enable gradient checkpointing to reduce activation memory from ~60GB to ~20GB
360
+ model.gradient_checkpointing_enable()
361
+
362
+ # Ensure all parameters are trainable (full SFT β€” no frozen layers)
363
+ model.train()
364
+ for param in model.parameters():
365
+ param.requires_grad = True
366
+
367
+ total_params = sum(p.numel() for p in model.parameters())
368
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
369
+ log.info(f"Total params: {total_params:,}")
370
+ log.info(f"Trainable params: {trainable:,} ({trainable/total_params*100:.2f}%)")
371
+ assert trainable == total_params, (
372
+ f"Expected 100% trainable for full SFT, got {trainable/total_params*100:.2f}%"
373
+ )
374
+
375
+ # Log VRAM usage after model load
376
+ if torch.cuda.is_available():
377
+ allocated = torch.cuda.memory_allocated(0) / 1e9
378
+ reserved = torch.cuda.memory_reserved(0) / 1e9
379
+ log.info(f"VRAM after model load: {allocated:.1f} GB allocated, {reserved:.1f} GB reserved")
380
+
381
+ # ── Optimizer: Adafactor ───────────────────────────────────────────────
382
+ # Adafactor uses factored second moments β€” ~35GB CPU RAM for 35B params
383
+ # vs AdamW's ~280GB (two fp32 states). Only viable option for single-node.
384
+ from transformers import Adafactor
385
+
386
+ optimizer = Adafactor(
387
+ model.parameters(),
388
+ lr=config.get("learning_rate", 5e-6),
389
+ # scale_parameter=False because we set lr explicitly
390
+ scale_parameter=False,
391
+ relative_step=False,
392
+ warmup_init=False,
393
+ )
394
+ log.info(f"Optimizer: Adafactor (lr={config.get('learning_rate', 5e-6)})")
395
+ log.info(f" scale_parameter=False, relative_step=False (using explicit lr + scheduler)")
396
+
397
+ # ── Training config ────────────────────────────────────────────────────
398
+ from trl import SFTTrainer, SFTConfig
399
+
400
+ training_args = SFTConfig(
401
+ output_dir=output_dir,
402
+ max_length=max_seq_length,
403
+ num_train_epochs=config.get("num_train_epochs", 1),
404
+ per_device_train_batch_size=config.get("per_device_train_batch_size", 1),
405
+ gradient_accumulation_steps=config.get("gradient_accumulation_steps", 8),
406
+ learning_rate=config.get("learning_rate", 5e-6),
407
+ lr_scheduler_type=config.get("lr_scheduler_type", "cosine"),
408
+ warmup_steps=config.get("warmup_steps", 100),
409
+ max_steps=config.get("max_steps", 10000),
410
+ save_steps=config.get("save_steps", 500),
411
+ eval_strategy=config.get("eval_strategy", "steps"),
412
+ eval_steps=config.get("eval_steps", 500),
413
+ logging_steps=config.get("logging_steps", 10),
414
+ save_total_limit=config.get("save_total_limit", 3),
415
+ bf16=config.get("bf16", True),
416
+ gradient_checkpointing=config.get("gradient_checkpointing", True),
417
+ gradient_checkpointing_kwargs={"use_reentrant": False},
418
+ # Do NOT set optim here β€” we pass our own Adafactor optimizer to the Trainer
419
+ weight_decay=config.get("weight_decay", 0.0),
420
+ max_grad_norm=config.get("max_grad_norm", 1.0),
421
+ seed=config.get("seed", 42),
422
+ report_to="none",
423
+ # DeepSpeed config path β€” ZeRO-2 handles gradient sharding + CPU optimizer offload
424
+ deepspeed=ds_config,
425
+ # Single GPU β€” no distributed data parallelism
426
+ dataloader_num_workers=0,
427
+ dataloader_pin_memory=False,
428
+ )
429
+
430
+ # ── Build trainer ──────────────────────────────────────────────────────
431
+ trainer = SFTTrainer(
432
+ model=model,
433
+ processing_class=tokenizer,
434
+ args=training_args,
435
+ train_dataset=train_ds,
436
+ eval_dataset=valid_ds,
437
+ optimizers=(optimizer, None), # (optimizer, lr_scheduler) β€” None lets Trainer create scheduler
438
+ )
439
+
440
+ # ── Resume from checkpoint if available ────────────────────────────────
441
+ resume_from = find_latest_checkpoint(output_dir)
442
+
443
+ eff_batch = (
444
+ config.get("per_device_train_batch_size", 1)
445
+ * config.get("gradient_accumulation_steps", 8)
446
+ )
447
+ log.info("\n" + "=" * 60)
448
+ log.info("Starting training:")
449
+ log.info(f" Max steps: {config.get('max_steps', 10000)}")
450
+ log.info(f" Effective batch: {eff_batch}")
451
+ log.info(f" Learning rate: {config.get('learning_rate', 5e-6)}")
452
+ log.info(f" Max seq length: {max_seq_length}")
453
+ log.info(f" Checkpoints: every {config.get('save_steps', 500)} steps -> {output_dir}")
454
+ log.info(f" Method: Full-parameter SFT (ALL {total_params:,} params)")
455
+ log.info(f" Optimizer: Adafactor (CPU-offloaded via ZeRO-2)")
456
+ log.info(f" DeepSpeed: ZeRO Stage 2 ({ds_config})")
457
+ log.info(f" Resume from: {resume_from or 'fresh start'}")
458
+ log.info("=" * 60 + "\n")
459
+
460
+ # ── Train ──────────────────────────────────────────────────────────────
461
+ trainer.train(resume_from_checkpoint=resume_from)
462
+
463
+ # ── Save final model ───────────────────────────────────────────────────
464
+ ts = time.strftime("%Y-%m-%dT%H:%M:%S")
465
+ log.info(f"\nTraining complete: {ts}")
466
+
467
+ # Save the full model (all parameters β€” ~70GB in bf16)
468
+ final_dir = os.path.join(output_dir, "final")
469
+ model.save_pretrained(final_dir)
470
+ tokenizer.save_pretrained(final_dir)
471
+ log.info(f"Full model saved to {final_dir} (~70GB)")
472
+
473
+ # Save training summary
474
+ summary = {
475
+ "completed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
476
+ "model": model_id,
477
+ "model_revision": model_revision,
478
+ "method": "full_sft",
479
+ "optimizer": "adafactor",
480
+ "deepspeed": "zero2_cpu_offload",
481
+ "max_seq_length": max_seq_length,
482
+ "config": config,
483
+ "train_samples": len(train_ds),
484
+ "valid_samples": len(valid_ds) if valid_ds else 0,
485
+ "total_params": total_params,
486
+ "trainable_params": trainable,
487
+ "trainable_pct": 100.0,
488
+ }
489
+ with open(os.path.join(output_dir, "training_summary.json"), "w") as f:
490
+ json.dump(summary, f, indent=2)
491
+
492
+ log.info("\n" + "=" * 60)
493
+ log.info(" DAIMON TRAINING COMPLETE")
494
+ log.info(f" Output: {output_dir}")
495
+ log.info(f" Full model: {final_dir}")
496
+ log.info("=" * 60)
497
+
498
+
499
+ if __name__ == "__main__":
500
+ main()