| import torch |
| import torch.nn as nn |
| from torch.nn import functional as F |
| from model.transformer import GPT |
| from model.config import ModelConfig |
| from model.tokenizer import AdvancedTokenizer |
| from train.dataset import TextDataset |
| from torch.utils.data import DataLoader |
| import os |
| import time |
| import math |
| import copy |
| import requests |
| from tqdm import tqdm |
|
|
| |
| try: |
| torch.serialization.add_safe_globals([ModelConfig]) |
| except: |
| pass |
|
|
|
|
| |
| |
| |
| class EMA: |
| """Maintains an exponential moving average of model parameters for better generalization.""" |
| def __init__(self, model, decay=0.999): |
| self.decay = decay |
| self.shadow = {name: param.clone().detach() for name, param in model.named_parameters()} |
|
|
| def update(self, model): |
| with torch.no_grad(): |
| for name, param in model.named_parameters(): |
| if name in self.shadow: |
| self.shadow[name].mul_(self.decay).add_(param, alpha=1 - self.decay) |
|
|
| def apply(self, model): |
| """Apply EMA weights to model (for evaluation/saving).""" |
| backup = {} |
| for name, param in model.named_parameters(): |
| if name in self.shadow: |
| backup[name] = param.clone() |
| param.data.copy_(self.shadow[name]) |
| return backup |
|
|
| def restore(self, model, backup): |
| """Restore original weights after EMA evaluation.""" |
| for name, param in model.named_parameters(): |
| if name in backup: |
| param.data.copy_(backup[name]) |
|
|
|
|
| |
| |
| |
| class EarlyStopping: |
| def __init__(self, patience=5, min_delta=1e-4): |
| self.patience = patience |
| self.min_delta = min_delta |
| self.best_loss = float('inf') |
| self.counter = 0 |
|
|
| def check(self, val_loss) -> bool: |
| """Returns True if training should stop.""" |
| if val_loss < self.best_loss - self.min_delta: |
| self.best_loss = val_loss |
| self.counter = 0 |
| return False |
| self.counter += 1 |
| return self.counter >= self.patience |
|
|
|
|
| |
| |
| |
| def get_lr(step, total_steps, max_lr, min_lr_ratio=0.1, warmup_steps=100): |
| """Cosine annealing with linear warmup.""" |
| if step < warmup_steps: |
| return max_lr * (step + 1) / max(1, warmup_steps) |
| decay_ratio = (step - warmup_steps) / max(1, total_steps - warmup_steps) |
| coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio)) |
| return max_lr * min_lr_ratio + max_lr * (1 - min_lr_ratio) * coeff |
|
|
|
|
| |
| |
| |
| def train(dataset_path=None, job=None, text_content=None, category="text", |
| continue_learning=True, noise_level=0.0, pin_memory=True, |
| expert_offloading=True, max_epochs=100): |
| """ |
| Optimized training for RTX 4060 (8GB VRAM). |
| |
| Key optimizations: |
| • Mixed precision (bf16/fp16) |
| • Gradient accumulation (effective batch 128) |
| • Gradient checkpointing (2x VRAM savings) |
| • torch.compile with CUDA graphs |
| • EMA weights averaging |
| • Cosine LR with warmup |
| • Label smoothing |
| • Early stopping |
| • Expert offloading to CPU |
| """ |
| from model.category_manager import get_category_manager |
|
|
| |
| text = None |
| if text_content: |
| text = text_content |
| elif dataset_path and os.path.exists(dataset_path): |
| pass |
|
|
| if not text: |
| data_path = 'input.txt' |
| if not os.path.exists(data_path): |
| print("input.txt not found. Using dummy data for test.") |
| try: |
| url = 'https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt' |
| r = requests.get(url) |
| with open(data_path, 'w') as f: |
| f.write(r.text) |
| print("Downloaded TinyShakespeare.") |
| except: |
| text = "Hello world. This is a test of the Sail AI agent system. " * 100 |
| with open(data_path, 'w') as f: |
| f.write(text) |
|
|
| if not text: |
| with open(data_path, 'r', encoding='utf-8') as f: |
| text = f.read() |
|
|
| |
| existing_checkpoint = None |
| if continue_learning and os.path.exists("sail.pt"): |
| print("Loading existing model for continuous learning...") |
| try: |
| existing_checkpoint = torch.load("sail.pt", map_location='cpu', weights_only=True) |
| print(f"Loaded existing model. Continuing training on category: {category}") |
| except Exception as e: |
| print(f"Could not load existing model: {e}. Starting fresh.") |
| existing_checkpoint = None |
|
|
| |
| print("Initializing Advanced Tokenizer...") |
| if job: job.message = "Training Tokenizer..." |
|
|
| tokenizer = AdvancedTokenizer(vocab_size=5000) |
|
|
| if existing_checkpoint and 'vocab' in existing_checkpoint: |
| print("Merging vocabularies...") |
| tokenizer.word_to_id = existing_checkpoint['vocab'].copy() |
| |
| |
| for token in tokenizer.specials: |
| if token not in tokenizer.word_to_id: |
| tokenizer.word_to_id[token] = len(tokenizer.word_to_id) |
| |
| tokenizer.id_to_word = {v: k for k, v in tokenizer.word_to_id.items()} |
| tokenizer.is_trained = True |
| tokenizer.train(text) |
| else: |
| tokenizer.train(text) |
|
|
| |
| config = ModelConfig() |
| config.vocab_size = len(tokenizer.word_to_id) |
| print(f"Vocab Size: {config.vocab_size}") |
|
|
| device = 'cuda' if torch.cuda.is_available() else 'cpu' |
| print(f"Using device: {device}") |
| config.device = device |
| config.pin_memory = pin_memory |
| config.expert_offloading = expert_offloading |
|
|
| if device == 'cuda': |
| gpu_name = torch.cuda.get_device_name(0) |
| gpu_mem = torch.cuda.get_device_properties(0).total_memory / 1024**3 |
| print(f"GPU: {gpu_name} ({gpu_mem:.1f} GB)") |
| torch.backends.cudnn.benchmark = True |
| torch.backends.cuda.matmul.allow_tf32 = True |
| torch.backends.cudnn.allow_tf32 = True |
|
|
| if job: job.message = f"Device: {device}. Vocab: {config.vocab_size}. Category: {category}" |
|
|
| |
| model = GPT(config) |
|
|
| if existing_checkpoint and 'model_state_dict' in existing_checkpoint: |
| try: |
| print("Loading weights into model (CPU)...") |
| state_dict = existing_checkpoint['model_state_dict'] |
| |
| |
| if 'token_emb.weight' in state_dict and state_dict['token_emb.weight'].shape[0] != config.vocab_size: |
| old_v = state_dict['token_emb.weight'].shape[0] |
| new_v = config.vocab_size |
| print(f"Resizing embeddings from {old_v} to {new_v} due to vocabulary expansion...") |
| |
| |
| new_emb = model.token_emb.weight.data.clone() |
| min_v = min(old_v, new_v) |
| new_emb[:min_v] = state_dict['token_emb.weight'][:min_v] |
| state_dict['token_emb.weight'] = new_emb |
| |
| if 'lm_head.weight' in state_dict: |
| new_lm = model.lm_head.weight.data.clone() |
| new_lm[:min_v] = state_dict['lm_head.weight'][:min_v] |
| state_dict['lm_head.weight'] = new_lm |
|
|
| model.load_state_dict(state_dict, strict=False) |
| print("Loaded existing model weights.") |
| except Exception as e: |
| print(f"Could not load weights: {e}. Training from scratch.") |
|
|
| del existing_checkpoint |
| import gc |
| gc.collect() |
|
|
| |
| print(f"Moving model to {device}...") |
| model.to(device) |
| if device == 'cuda': |
| torch.cuda.empty_cache() |
|
|
| n_params = sum(p.numel() for p in model.parameters()) |
| print(f"Model parameters: {n_params/1e6:.2f}M") |
|
|
| |
| if config.expert_offloading and device == 'cuda': |
| print("Expert offloading enabled — idle experts will use CPU RAM") |
|
|
| |
| optimizer = torch.optim.AdamW( |
| model.parameters(), |
| lr=config.learning_rate, |
| weight_decay=config.weight_decay, |
| fused=(device == 'cuda') |
| ) |
|
|
| |
| use_amp = config.use_amp and device == 'cuda' |
| scaler = torch.amp.GradScaler('cuda', enabled=use_amp) |
| if use_amp: |
| print("Mixed Precision Training: ENABLED (bf16/fp16)") |
|
|
| |
| ema = None |
| if config.use_ema: |
| ema = EMA(model, decay=config.ema_decay) |
| print(f"EMA: ENABLED (decay={config.ema_decay})") |
|
|
| |
| early_stopping = EarlyStopping(patience=config.patience, min_delta=config.min_delta) |
|
|
| |
| print("Encoding dataset...") |
| if job: job.message = "Encoding Dataset..." |
|
|
| if dataset_path and dataset_path.endswith('.json'): |
| print("Detected Instruction Dataset (JSON).") |
| from train.instruction_loader import InstructionDataset |
| train_ds = InstructionDataset(dataset_path, tokenizer, config.block_size) |
| else: |
| train_ds = TextDataset(text, tokenizer, config, pin_memory=config.pin_memory) |
| if len(train_ds) == 0: |
| print("Dataset too small for block_size. Repeating text...") |
| text = text * (config.block_size // len(text) + 2) |
| train_ds = TextDataset(text, tokenizer, config) |
|
|
| num_workers = min(os.cpu_count() or 2, 4) |
| train_dl = DataLoader( |
| train_ds, |
| batch_size=config.batch_size, |
| shuffle=True, |
| pin_memory=(device == 'cuda'), |
| num_workers=num_workers, |
| prefetch_factor=2 if (device == 'cuda') else None, |
| persistent_workers=True if (device == 'cuda' and num_workers > 0) else False, |
| drop_last=True, |
| ) |
|
|
| |
| if config.use_compile and device == 'cuda': |
| print("Compiling model with torch.compile (mode='reduce-overhead')...") |
| try: |
| model = torch.compile(model, mode="reduce-overhead") |
| print("Model compiled with CUDA Graphs!") |
| except Exception as e: |
| print(f"Warning: torch.compile failed ({e}). Proceeding without.") |
|
|
| |
| accumulation_steps = config.gradient_accumulation_steps |
| total_steps = (len(train_dl) // accumulation_steps + 1) * max_epochs |
|
|
| print(f"\n{'='*60}") |
| print(f" TRAINING CONFIG") |
| print(f" Epochs : {max_epochs}") |
| print(f" Batch Size : {config.batch_size} (eff. {config.batch_size * accumulation_steps})") |
| print(f" Grad Accum : {accumulation_steps}") |
| print(f" Learning Rate : {config.learning_rate}") |
| print(f" Label Smoothing : {config.label_smoothing}") |
| print(f" Noise Level : {noise_level}") |
| print(f" Total Steps : ~{total_steps}") |
| print(f" Category : {category}") |
| print(f"{'='*60}\n") |
|
|
| model.train() |
| step = 0 |
| best_loss = float('inf') |
| optimizer.zero_grad(set_to_none=True) |
|
|
| for epoch in range(max_epochs): |
| epoch_loss = 0.0 |
| epoch_steps = 0 |
|
|
| pbar = tqdm(train_dl, desc=f"Epoch {epoch+1}/{max_epochs}") |
| for i, (x, y) in enumerate(pbar): |
| x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True) |
|
|
| |
| with torch.amp.autocast('cuda', enabled=use_amp): |
| logits, loss = model(x, y, noise_level=noise_level) |
| loss = loss / accumulation_steps |
|
|
| |
| scaler.scale(loss).backward() |
|
|
| if (i + 1) % accumulation_steps == 0 or (i + 1) == len(train_dl): |
| |
| scaler.unscale_(optimizer) |
| torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=config.max_grad_norm) |
|
|
| |
| lr = get_lr(step, total_steps, config.learning_rate, |
| warmup_steps=config.warmup_steps) |
| for param_group in optimizer.param_groups: |
| param_group['lr'] = lr |
|
|
| scaler.step(optimizer) |
| scaler.update() |
| optimizer.zero_grad(set_to_none=True) |
|
|
| |
| if ema is not None: |
| ema.update(model) |
|
|
| actual_loss = loss.item() * accumulation_steps |
| epoch_loss += actual_loss |
| epoch_steps += 1 |
|
|
| pbar.set_description( |
| f"Loss: {actual_loss:.4f} LR: {lr:.2e}" |
| ) |
|
|
| if job: |
| job.message = f"Epoch {epoch+1} - Loss: {actual_loss:.4f} - LR: {lr:.2e}" |
|
|
| step += 1 |
|
|
| |
| avg_loss = epoch_loss / max(epoch_steps, 1) |
| print(f" Epoch {epoch+1} avg loss: {avg_loss:.4f}") |
|
|
| |
| if avg_loss < best_loss: |
| best_loss = avg_loss |
| _save_checkpoint(model, config, tokenizer, category, job, ema, tag="best") |
|
|
| |
| if early_stopping.check(avg_loss): |
| print(f" Early stopping triggered at epoch {epoch+1} (patience={config.patience})") |
| break |
|
|
| |
| if device == 'cuda' and (epoch + 1) % 5 == 0: |
| alloc = torch.cuda.memory_allocated() / 1024**3 |
| total = torch.cuda.get_device_properties(0).total_memory / 1024**3 |
| print(f" VRAM: {alloc:.1f}GB / {total:.1f}GB") |
|
|
| print("Training Complete.") |
| _save_checkpoint(model, config, tokenizer, category, job, ema, tag="final") |
|
|
| |
| cat_manager = get_category_manager() |
| details = dataset_path or "text_content" |
| cat_manager.add_training(category, details, config.vocab_size) |
| print(f"Category '{category}' training recorded.") |
|
|
|
|
| def _save_checkpoint(model, config, tokenizer, category, job, ema, tag=""): |
| """Save checkpoint, optionally with EMA weights.""" |
| if job: job.message = "Saving Model..." |
|
|
| |
| backup = None |
| if ema is not None: |
| backup = ema.apply(model) |
|
|
| checkpoint = { |
| 'model_state_dict': model.state_dict(), |
| 'config': config, |
| 'vocab': tokenizer.word_to_id, |
| 'vocab_size': config.vocab_size, |
| 'category': category, |
| 'timestamp': time.time(), |
| 'tag': tag, |
| } |
|
|
| |
| torch.save(checkpoint, "sail.pt") |
| print(f"Saved 'sail.pt' (Category: {category}, Tag: {tag})") |
|
|
| |
| if job: |
| backup_path = f"sail_{job.id}.pt" |
| torch.save(checkpoint, backup_path) |
| print(f"Backup saved to '{backup_path}'") |
|
|
| |
| if ema is not None and backup is not None: |
| ema.restore(model, backup) |
|
|
|
|
| if __name__ == '__main__': |
| train() |
|
|