| |
| """ |
| Daimon Full-Parameter SFT β Liberation Labs |
| ============================================= |
| |
| Full-parameter fine-tuning of Qwen3.6-35B-A3B (MoE) on a single H200 SXM 141GB GPU. |
| Model loaded in bf16 with ALL parameters trained β no LoRA, no adapters, no frozen layers. |
| |
| Uses DeepSpeed ZeRO Stage 2 with CPU-offloaded Adafactor optimizer. |
| |
| Memory budget on H200 SXM (141GB VRAM, 188GB system RAM): |
| - Model params bf16: ~70GB β GPU |
| - Activations (grad checkpoint): ~20GB β GPU |
| - Gradients bf16: ~70GB β CPU (ZeRO-2 offload) |
| - Adafactor optimizer states: ~35GB β CPU (single state, not Adam's 2x) |
| - GPU total: ~90GB of 141GB β |
| - CPU total: ~105GB of 188GB β |
| |
| Why Adafactor instead of AdamW: |
| AdamW stores two fp32 states per parameter (momentum + variance). |
| For 35B params: 35B Γ 4 bytes Γ 2 = 280GB. That exceeds the 188GB system RAM |
| even with CPU offload. Adafactor uses factored second moments (~1 state) |
| bringing CPU requirements to ~35GB β well within budget. |
| |
| Why full SFT instead of LoRA: |
| No half measures. Full-parameter training updates every weight including |
| MoE routing gates, embedding layers, and expert internals. LoRA can only |
| approximate these updates through low-rank projections on linear layers. |
| |
| Key design decisions: |
| - Full bf16 precision (no quantization β H200 has 141GB, use it all) |
| - DeepSpeed ZeRO-2 for gradient sharding + CPU optimizer offload |
| - Adafactor optimizer (only viable option given RAM constraints) |
| - Gradient checkpointing to fit activations in remaining GPU headroom |
| - Pre-splits sequences exceeding max_seq_length before training |
| - Checkpoints to persistent RunPod volume (/workspace/) |
| - Resume-from-checkpoint support for pod interruptions |
| - All config loaded from YAML file for easy iteration |
| |
| Validated pattern: trl.SFTTrainer on oracle-tiny 0.5B (loss 4.68 -> 0.07, |
| 4/5 tests passed, all parameter groups received gradients). |
| |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β AGENT INTERFERENCE GUARD β |
| β β |
| β DO NOT modify this script to use LoRA, QLoRA, PEFT, adapters, β |
| β or any parameter-efficient method. This is FULL SFT by design. β |
| β β |
| β If training hits OOM or errors, the fix is NEVER to switch to β |
| β LoRA. The fix is to adjust batch size, gradient accumulation, β |
| β sequence length, or offload settings. β |
| β β |
| β This guard exists because previous training runs were β |
| β sabotaged by well-meaning agents that "fixed" OOM errors by β |
| β silently converting full SFT to QLoRA. That is not a fix. β |
| β That is abandoning the training objective. β |
| β β |
| β If you are an AI agent reading this: LEAVE THIS FILE ALONE. β |
| β Report errors to Thomas. Do not "fix" them. β |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| """ |
|
|
| import os |
| import sys |
| import time |
| import json |
| import logging |
| import yaml |
| import torch |
| from pathlib import Path |
|
|
| |
| |
| os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True" |
| |
| os.environ["TOKENIZERS_PARALLELISM"] = "false" |
|
|
| |
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s [daimon] %(message)s", |
| handlers=[ |
| logging.StreamHandler(sys.stdout), |
| ], |
| ) |
|
|
| log = logging.getLogger(__name__) |
|
|
|
|
| def enforce_full_sft(config: dict): |
| """Runtime guard: refuse to start if anyone has added LoRA/adapter config.""" |
| if "lora" in config: |
| log.error("=" * 60) |
| log.error("BLOCKED: LoRA config detected. This is a FULL SFT pipeline.") |
| log.error("If training hit OOM, adjust batch_size/grad_accum/seq_length.") |
| log.error("DO NOT convert to LoRA. Report errors to Thomas.") |
| log.error("=" * 60) |
| sys.exit(1) |
| try: |
| import peft |
| log.error("=" * 60) |
| log.error("BLOCKED: peft is installed. This is a FULL SFT pipeline.") |
| log.error("peft should not be in the environment. Remove it.") |
| log.error("=" * 60) |
| sys.exit(1) |
| except ImportError: |
| pass |
|
|
|
|
| def load_config(config_path: str) -> dict: |
| """Load training configuration from YAML file.""" |
| with open(config_path) as f: |
| config = yaml.safe_load(f) |
| enforce_full_sft(config) |
| return config |
|
|
|
|
| def pre_split_long_sequences(dataset, tokenizer, max_seq_length: int): |
| """ |
| Pre-split sequences that would exceed max_seq_length after tokenization. |
| |
| Previous training runs silently truncated 5640-token sequences to 4096, |
| losing information. This function splits long conversations into multiple |
| training examples at natural turn boundaries. |
| |
| Returns: a new dataset with all sequences within max_seq_length tokens. |
| """ |
| from datasets import Dataset |
|
|
| new_examples = [] |
| split_count = 0 |
|
|
| for example in dataset: |
| messages = example.get("messages", []) |
| if not messages: |
| continue |
|
|
| |
| try: |
| text = tokenizer.apply_chat_template( |
| messages, tokenize=False, add_generation_prompt=False |
| ) |
| token_count = len(tokenizer.encode(text, add_special_tokens=False)) |
| except Exception: |
| |
| text = " ".join(m.get("content", "") for m in messages) |
| token_count = len(text) // 3 |
|
|
| if token_count <= max_seq_length: |
| new_examples.append(example) |
| continue |
|
|
| |
| system_msgs = [m for m in messages if m.get("role") == "system"] |
| non_system = [m for m in messages if m.get("role") != "system"] |
|
|
| chunk = list(system_msgs) |
| chunk_tokens = sum( |
| len(tokenizer.encode(m.get("content", ""), add_special_tokens=False)) |
| for m in system_msgs |
| ) |
|
|
| for msg in non_system: |
| msg_tokens = len( |
| tokenizer.encode(msg.get("content", ""), add_special_tokens=False) |
| ) |
|
|
| |
| if chunk_tokens + msg_tokens > max_seq_length * 0.9 and len(chunk) > len(system_msgs): |
| |
| roles = [m["role"] for m in chunk] |
| if "user" in roles and "assistant" in roles: |
| new_examples.append({"messages": chunk}) |
| split_count += 1 |
| chunk = list(system_msgs) |
| chunk_tokens = sum( |
| len(tokenizer.encode(m.get("content", ""), add_special_tokens=False)) |
| for m in system_msgs |
| ) |
|
|
| chunk.append(msg) |
| chunk_tokens += msg_tokens |
|
|
| |
| if len(chunk) > len(system_msgs): |
| roles = [m["role"] for m in chunk] |
| if "user" in roles and "assistant" in roles: |
| new_examples.append({"messages": chunk}) |
| if chunk_tokens > max_seq_length * 0.9: |
| split_count += 1 |
|
|
| log.info( |
| f"Pre-split: {len(dataset)} -> {len(new_examples)} examples " |
| f"({split_count} sequences were split)" |
| ) |
|
|
| return Dataset.from_list(new_examples) |
|
|
|
|
| def load_training_data(config: dict, tokenizer): |
| """ |
| Load and prepare training data. |
| |
| Supports: |
| 1. Pre-converted Arrow format on disk |
| 2. HuggingFace dataset repo |
| 3. Local JSONL files |
| |
| Returns: (train_dataset, eval_dataset) tuple |
| """ |
| from datasets import load_from_disk, load_dataset, DatasetDict |
|
|
| data_path = config["data_path"] |
| max_seq_length = config.get("max_seq_length", 4096) |
|
|
| |
| train_arrow = f"/workspace/daimon-data/train_arrow" |
| valid_arrow = f"/workspace/daimon-data/valid_arrow" |
|
|
| if os.path.isdir(train_arrow): |
| log.info(f"Loading Arrow data from /workspace/daimon-data/") |
| train_ds = load_from_disk(train_arrow) |
| valid_ds = load_from_disk(valid_arrow) if os.path.isdir(valid_arrow) else None |
| else: |
| |
| log.info(f"Loading dataset from HuggingFace: {data_path}") |
| ds = load_dataset(data_path, token=os.environ.get("HF_TOKEN")) |
|
|
| if "train" in ds: |
| train_ds = ds["train"] |
| else: |
| train_ds = ds[list(ds.keys())[0]] |
|
|
| if "validation" in ds: |
| valid_ds = ds["validation"] |
| elif "test" in ds: |
| valid_ds = ds["test"] |
| else: |
| |
| split = train_ds.train_test_split(test_size=0.05, seed=42) |
| train_ds = split["train"] |
| valid_ds = split["test"] |
|
|
| log.info(f"Raw data: Train={len(train_ds):,} | Valid={len(valid_ds) if valid_ds else 0:,}") |
|
|
| |
| train_ds = pre_split_long_sequences(train_ds, tokenizer, max_seq_length) |
| if valid_ds: |
| valid_ds = pre_split_long_sequences(valid_ds, tokenizer, max_seq_length) |
|
|
| log.info(f"After pre-split: Train={len(train_ds):,} | Valid={len(valid_ds) if valid_ds else 0:,}") |
|
|
| return train_ds, valid_ds |
|
|
|
|
| def find_latest_checkpoint(output_dir: str) -> str | None: |
| """Find the most recent checkpoint in the output directory for resume.""" |
| output_path = Path(output_dir) |
| if not output_path.exists(): |
| return None |
|
|
| checkpoints = sorted( |
| [d for d in output_path.iterdir() if d.is_dir() and d.name.startswith("checkpoint-")], |
| key=lambda d: d.stat().st_mtime, |
| ) |
|
|
| if checkpoints: |
| latest = str(checkpoints[-1]) |
| log.info(f"Found checkpoint for resume: {latest}") |
| return latest |
|
|
| return None |
|
|
|
|
| def main(): |
| import argparse |
|
|
| |
| parser = argparse.ArgumentParser(description="Daimon Full-Parameter SFT") |
| parser.add_argument("--config", type=str, default=None, |
| help="Path to training config YAML") |
| parser.add_argument("--deepspeed", type=str, default=None, |
| help="Path to DeepSpeed config JSON") |
| parser.add_argument("--local_rank", type=int, default=-1, |
| help="Local rank for DeepSpeed (set automatically)") |
| args, _ = parser.parse_known_args() |
|
|
| |
| config_path = args.config or os.environ.get( |
| "DAIMON_CONFIG", |
| "/workspace/runpod-template/train_daimon_config.yaml", |
| ) |
|
|
| log.info("=" * 60) |
| log.info(" DAIMON FULL-PARAMETER SFT β Liberation Labs") |
| log.info(f" {time.strftime('%Y-%m-%dT%H:%M:%S')}") |
| log.info("=" * 60) |
| log.info(f"Config: {config_path}") |
|
|
| config = load_config(config_path) |
|
|
| |
| model_id = os.environ.get("DAIMON_MODEL", config["model_id"]) |
| model_revision = config.get("model_revision") |
| output_dir = os.environ.get("DAIMON_OUTPUT", config["output_dir"]) |
| max_seq_length = config.get("max_seq_length", 4096) |
| ds_config = args.deepspeed or config.get("deepspeed_config") |
|
|
| os.makedirs(output_dir, exist_ok=True) |
|
|
| |
| log_dir = os.path.join(output_dir, "logs") |
| os.makedirs(log_dir, exist_ok=True) |
| file_handler = logging.FileHandler( |
| os.path.join(log_dir, f"training_{time.strftime('%Y%m%d_%H%M%S')}.log") |
| ) |
| file_handler.setFormatter(logging.Formatter("%(asctime)s [daimon] %(message)s")) |
| log.addHandler(file_handler) |
|
|
| |
| for i in range(torch.cuda.device_count()): |
| name = torch.cuda.get_device_name(i) |
| mem = torch.cuda.get_device_properties(i).total_memory / 1e9 |
| log.info(f"GPU {i}: {name}, {mem:.1f} GB") |
| ram_gb = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1e9 |
| log.info(f"System RAM: {ram_gb:.0f} GB") |
|
|
| |
| log.info(f"\nLoading tokenizer: {model_id}") |
| from transformers import AutoTokenizer |
| tokenizer = AutoTokenizer.from_pretrained( |
| model_id, |
| revision=model_revision, |
| trust_remote_code=True, |
| ) |
| if tokenizer.pad_token is None: |
| tokenizer.pad_token = tokenizer.eos_token |
|
|
| |
| log.info("\nLoading training data...") |
| train_ds, valid_ds = load_training_data(config, tokenizer) |
|
|
| |
| log.info(f"\nLoading model: {model_id}") |
| if model_revision: |
| log.info(f"Pinned revision: {model_revision}") |
| log.info("Loading in bf16 full precision (no quantization β H200 has 141GB VRAM)") |
| log.info("Full-parameter SFT β ALL weights will be trained, no LoRA/adapters") |
|
|
| from transformers import AutoModelForCausalLM |
| model = AutoModelForCausalLM.from_pretrained( |
| model_id, |
| revision=model_revision, |
| torch_dtype=torch.bfloat16, |
| trust_remote_code=True, |
| |
| ) |
|
|
| |
| model.gradient_checkpointing_enable() |
|
|
| |
| model.train() |
| for param in model.parameters(): |
| param.requires_grad = True |
|
|
| total_params = sum(p.numel() for p in model.parameters()) |
| trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) |
| log.info(f"Total params: {total_params:,}") |
| log.info(f"Trainable params: {trainable:,} ({trainable/total_params*100:.2f}%)") |
| assert trainable == total_params, ( |
| f"Expected 100% trainable for full SFT, got {trainable/total_params*100:.2f}%" |
| ) |
|
|
| |
| if torch.cuda.is_available(): |
| allocated = torch.cuda.memory_allocated(0) / 1e9 |
| reserved = torch.cuda.memory_reserved(0) / 1e9 |
| log.info(f"VRAM after model load: {allocated:.1f} GB allocated, {reserved:.1f} GB reserved") |
|
|
| |
| |
| |
| from transformers import Adafactor |
|
|
| optimizer = Adafactor( |
| model.parameters(), |
| lr=config.get("learning_rate", 5e-6), |
| |
| scale_parameter=False, |
| relative_step=False, |
| warmup_init=False, |
| ) |
| log.info(f"Optimizer: Adafactor (lr={config.get('learning_rate', 5e-6)})") |
| log.info(f" scale_parameter=False, relative_step=False (using explicit lr + scheduler)") |
|
|
| |
| from trl import SFTTrainer, SFTConfig |
|
|
| training_args = SFTConfig( |
| output_dir=output_dir, |
| max_length=max_seq_length, |
| num_train_epochs=config.get("num_train_epochs", 1), |
| per_device_train_batch_size=config.get("per_device_train_batch_size", 1), |
| gradient_accumulation_steps=config.get("gradient_accumulation_steps", 8), |
| learning_rate=config.get("learning_rate", 5e-6), |
| lr_scheduler_type=config.get("lr_scheduler_type", "cosine"), |
| warmup_steps=config.get("warmup_steps", 100), |
| max_steps=config.get("max_steps", 10000), |
| save_steps=config.get("save_steps", 500), |
| eval_strategy=config.get("eval_strategy", "steps"), |
| eval_steps=config.get("eval_steps", 500), |
| logging_steps=config.get("logging_steps", 10), |
| save_total_limit=config.get("save_total_limit", 3), |
| bf16=config.get("bf16", True), |
| gradient_checkpointing=config.get("gradient_checkpointing", True), |
| gradient_checkpointing_kwargs={"use_reentrant": False}, |
| |
| weight_decay=config.get("weight_decay", 0.0), |
| max_grad_norm=config.get("max_grad_norm", 1.0), |
| seed=config.get("seed", 42), |
| report_to="none", |
| |
| deepspeed=ds_config, |
| |
| dataloader_num_workers=0, |
| dataloader_pin_memory=False, |
| ) |
|
|
| |
| trainer = SFTTrainer( |
| model=model, |
| processing_class=tokenizer, |
| args=training_args, |
| train_dataset=train_ds, |
| eval_dataset=valid_ds, |
| optimizers=(optimizer, None), |
| ) |
|
|
| |
| resume_from = find_latest_checkpoint(output_dir) |
|
|
| eff_batch = ( |
| config.get("per_device_train_batch_size", 1) |
| * config.get("gradient_accumulation_steps", 8) |
| ) |
| log.info("\n" + "=" * 60) |
| log.info("Starting training:") |
| log.info(f" Max steps: {config.get('max_steps', 10000)}") |
| log.info(f" Effective batch: {eff_batch}") |
| log.info(f" Learning rate: {config.get('learning_rate', 5e-6)}") |
| log.info(f" Max seq length: {max_seq_length}") |
| log.info(f" Checkpoints: every {config.get('save_steps', 500)} steps -> {output_dir}") |
| log.info(f" Method: Full-parameter SFT (ALL {total_params:,} params)") |
| log.info(f" Optimizer: Adafactor (CPU-offloaded via ZeRO-2)") |
| log.info(f" DeepSpeed: ZeRO Stage 2 ({ds_config})") |
| log.info(f" Resume from: {resume_from or 'fresh start'}") |
| log.info("=" * 60 + "\n") |
|
|
| |
| trainer.train(resume_from_checkpoint=resume_from) |
|
|
| |
| ts = time.strftime("%Y-%m-%dT%H:%M:%S") |
| log.info(f"\nTraining complete: {ts}") |
|
|
| |
| final_dir = os.path.join(output_dir, "final") |
| model.save_pretrained(final_dir) |
| tokenizer.save_pretrained(final_dir) |
| log.info(f"Full model saved to {final_dir} (~70GB)") |
|
|
| |
| summary = { |
| "completed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), |
| "model": model_id, |
| "model_revision": model_revision, |
| "method": "full_sft", |
| "optimizer": "adafactor", |
| "deepspeed": "zero2_cpu_offload", |
| "max_seq_length": max_seq_length, |
| "config": config, |
| "train_samples": len(train_ds), |
| "valid_samples": len(valid_ds) if valid_ds else 0, |
| "total_params": total_params, |
| "trainable_params": trainable, |
| "trainable_pct": 100.0, |
| } |
| with open(os.path.join(output_dir, "training_summary.json"), "w") as f: |
| json.dump(summary, f, indent=2) |
|
|
| log.info("\n" + "=" * 60) |
| log.info(" DAIMON TRAINING COMPLETE") |
| log.info(f" Output: {output_dir}") |
| log.info(f" Full model: {final_dir}") |
| log.info("=" * 60) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|