# ============================================================================ # ViuAI 500M — Supervised Fine-Tuning (SFT) Script # ============================================================================ # Universal script: works on H200(140GB), 5090(32GB), L4(24GB), T4x2(16GBx2) # Works on: Kaggle, Rental GPU, Any cloud provider # # Run commands: # Kaggle T4x2: torchrun --nproc_per_node=2 train_sft.py # Single GPU: torchrun --nproc_per_node=1 train_sft.py # Multi-GPU: torchrun --nproc_per_node= train_sft.py # ============================================================================ import os os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") # Redirect HF cache to working directory (rental GPUs often have tiny OS partitions) # This prevents "No space left on device" errors during checkpoint upload if os.path.exists("/kaggle/working"): os.environ.setdefault("HF_HOME", "/kaggle/working/hf_cache") else: os.environ.setdefault("HF_HOME", "./working/hf_cache") import json, math, time, random, sys, contextlib, gc, threading # Global list to track all background upload threads _upload_threads = [] import numpy as np import torch import torch.nn as nn import torch.nn.functional as F import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP from huggingface_hub import HfApi, hf_hub_download, create_repo from datetime import timedelta # ============================================================================ # 1. DDP Setup (works for single GPU too via torchrun --nproc_per_node=1) # ============================================================================ def setup_ddp(): if not torch.cuda.is_available(): raise RuntimeError("SFT training requires at least one CUDA GPU.") dist.init_process_group(backend="nccl", timeout=timedelta(minutes=30)) rank = dist.get_rank() local_rank = int(os.environ.get("LOCAL_RANK", 0)) world_size = dist.get_world_size() torch.cuda.set_device(local_rank) return rank, local_rank, world_size rank, local_rank, world_size = setup_ddp() DEVICE = f"cuda:{local_rank}" IS_MAIN = rank == 0 IS_LOCAL_MAIN = local_rank == 0 def log(msg): if IS_MAIN: print(msg, flush=True) # ============================================================================ # 2. Authentication (Kaggle + Rental GPU dono support) # ============================================================================ HF_TOKEN = os.environ.get("HF_TOKEN") if not HF_TOKEN: try: from kaggle_secrets import UserSecretsClient HF_TOKEN = UserSecretsClient().get_secret("HF_TOKEN") os.environ["HF_TOKEN"] = HF_TOKEN except Exception: pass if not HF_TOKEN: raise RuntimeError( "HF_TOKEN not found!\n" " Kaggle: Settings → Secrets → Add HF_TOKEN\n" " Rental GPU: export HF_TOKEN='hf_your_token'" ) api = HfApi(token=HF_TOKEN) # ============================================================================ # 3. Repository Configuration # ============================================================================ MAIN_REPO = os.environ.get("VIUAI_MODEL_REPO", "ViuAI/ViuAI-500M") SFT_DATA_REPO = os.environ.get("VIUAI_SFT_DATA_REPO", "ViuAI/viuai-500m-sft-tokenized") def env_flag(name, default=False): value = os.environ.get(name) if value is None: return default return value.strip().lower() in {"1", "true", "yes", "y", "on"} # Fresh SFT is the safe default. Set RESUME_SFT=1 only to continue an # interrupted SFT run from the configured SFT checkpoint directory. RESUME_SFT = env_flag("RESUME_SFT", default=True) RESUME_CHECKPOINT_FILE = "sft_ckpt_latest.pt" if RESUME_SFT else "__fresh_sft_start__.pt" SFT_CHECKPOINT_DIR = os.environ.get("SFT_CHECKPOINT_DIR", "sft_checkpoints/sft_v2").strip("/") if not SFT_CHECKPOINT_DIR or SFT_CHECKPOINT_DIR.startswith(".") or ".." in SFT_CHECKPOINT_DIR.split("/"): raise ValueError("SFT_CHECKPOINT_DIR must be a safe relative Hugging Face repository path.") if IS_MAIN: create_repo(MAIN_REPO, repo_type="model", token=HF_TOKEN, exist_ok=True, private=True) dist.barrier() # ============================================================================ # 4. Working Directory (auto-detect environment) # ============================================================================ if os.path.exists("/kaggle/working"): WORK = "/kaggle/working" elif os.path.exists("/root/working"): WORK = "/root/working" else: WORK = "./working" os.makedirs(WORK, exist_ok=True) os.makedirs(f"{WORK}/code", exist_ok=True) sys.path.insert(0, f"{WORK}/code") # ============================================================================ # 5. Download Model Code from HuggingFace # ============================================================================ def with_retry(fn, *args, retries=4, delay=5, **kwargs): for attempt in range(1, retries + 1): try: return fn(*args, **kwargs) except Exception as e: if "404" in str(e) or "EntryNotFoundError" in type(e).__name__: raise if attempt == retries: raise log(f" [retry {attempt}/{retries}] {type(e).__name__}: {e}") time.sleep(delay) delay *= 2 if IS_MAIN: log("šŸ“„ Downloading model code...") try: download_on_main(hf_hub_download, MAIN_REPO, "code/config.py", local_dir=WORK, token=HF_TOKEN) download_on_main(hf_hub_download, MAIN_REPO, "code/model.py", local_dir=WORK, token=HF_TOKEN) except NameError: # download_on_main is defined later, so we just use the original with_retry pattern for code download # Since we're downloading the code, we just do it manually with all_reduce ok = True err = "" if IS_MAIN: try: with_retry(hf_hub_download, MAIN_REPO, "code/config.py", local_dir=WORK, token=HF_TOKEN) with_retry(hf_hub_download, MAIN_REPO, "code/model.py", local_dir=WORK, token=HF_TOKEN) except Exception as e: ok = False err = str(e) print(f"Download failed: {err}") # Can't use DEVICE here since setup_ddp is done but DEVICE isn't assigned if this is before device assignment # Wait, DEVICE is defined right after setup_ddp. ok_tensor = torch.tensor([1 if ok else 0], device=DEVICE) dist.all_reduce(ok_tensor, op=dist.ReduceOp.MIN) if ok_tensor.item() == 0: raise RuntimeError(f"Model code download failed on main rank: {err}") dist.barrier() from config import ViuAIConfig from model import ViuAI # ============================================================================ # 6. GPU Auto-Detection & Hyperparameters # ============================================================================ mem_gb = torch.cuda.get_device_properties(local_rank).total_memory / 1e9 gpu_name = torch.cuda.get_device_name(local_rank) # Check if GPU supports bfloat16 (Ampere+ architecture, compute capability >= 8.0) compute_cap = torch.cuda.get_device_capability(local_rank) supports_bf16 = compute_cap[0] >= 8 # Ampere (8.0), Hopper (9.0) etc. if mem_gb >= 130: # H200 (141GB) / H100 (140GB) MICRO_BATCH = 64 GRAD_ACCUM = 1 CONTEXT_LEN = 2048 AMP_DTYPE = torch.bfloat16 USE_COMPILE = True EMPTY_CACHE_EVERY = 2000 elif mem_gb >= 70: # A100 (80GB) MICRO_BATCH = 32 GRAD_ACCUM = 2 CONTEXT_LEN = 2048 AMP_DTYPE = torch.bfloat16 USE_COMPILE = True EMPTY_CACHE_EVERY = 2000 elif mem_gb >= 28: # RTX 5090 (32GB), A10G (24GB but reported higher) MICRO_BATCH = 16 # increased from 2 -> 16, now feasible since # we're on a 32GB RTX 5090 instead of a 15.6GB # Kaggle T4. Matches the proven working 250M # config exactly. # NOTE: If this causes a CUDA out-of-memory # (OOM) error (500M has ~2x the parameters of # 250M, so may need more headroom), fall back # to MICRO_BATCH=8, GRAD_ACCUM=8 instead # (keeps the same effective batch size of 64). GRAD_ACCUM = 4 # reverted from 8 -> 4, matches 250M's proven # config. Effective batch size = 16 x 4 = 64 # (same as the working 250M run). CONTEXT_LEN = 1024 # unchanged AMP_DTYPE = torch.bfloat16 USE_COMPILE = True EMPTY_CACHE_EVERY = 200 elif mem_gb >= 20: # L4 (24GB), RTX 4090 (24GB) MICRO_BATCH = 4 GRAD_ACCUM = 8 CONTEXT_LEN = 2048 AMP_DTYPE = torch.bfloat16 if supports_bf16 else torch.float16 USE_COMPILE = supports_bf16 # Only compile on Ampere+ EMPTY_CACHE_EVERY = 100 else: # T4 (16GB), RTX 3060 (12GB) MICRO_BATCH = 2 # unchanged — limited by T4 GPU VRAM (15.6GB) GRAD_ACCUM = 16 # increased from 8 → 16, to raise effective # batch size from 32 to 64 (matches the # proven-stable 250M SFT run's effective batch, # reduces gradient noise) CONTEXT_LEN = 1024 # unchanged AMP_DTYPE = torch.float32 # Changed from float16 to float32 for stability USE_COMPILE = False # T4 Turing has poor torch.compile support EMPTY_CACHE_EVERY = 50 # SFT-specific hyperparameters (same for all GPUs) MAX_LR = 5e-05 # Increased from 1e-05 to 5e-05 to quickly learn new chat tokens # the PROVEN value from the working 250M run; # 3e-05 combined with our smaller batch size # caused training instability and garbage output MIN_LR = 1e-06 # unchanged WARMUP_STEPS = 50 # reverted from 150 → 50, matching the working # 250M run (no longer need extra-long warmup # since LR is back to a gentler, proven value) EPOCHS = 4 # reduced from 6 → 4 (250M used 3 successfully; # we keep slightly more since 500M has more # parameters to adapt, but avoid excessive # total training time) WEIGHT_DECAY = 0.01 GRAD_CLIP = 1.0 EVAL_EVERY = 1000 CKPT_EVERY = 4000 # Vocab size: 64009 because new SFT data uses 9 special tokens (<|user|>, <|assistant|>, <|endofturn|>, , , , , , ) SFT_VOCAB_SIZE = 64009 # Target effective batch logic removed to respect GPU-specific GRAD_ACCUM settings effective_batch = MICRO_BATCH * GRAD_ACCUM * world_size log(f"{'='*60}") log(f"šŸ–„ļø GPU: {gpu_name} | VRAM: {mem_gb:.1f}GB | Compute: {compute_cap[0]}.{compute_cap[1]}") log(f"āš™ļø MICRO_BATCH={MICRO_BATCH} | GRAD_ACCUM={GRAD_ACCUM} | CTX={CONTEXT_LEN}") log(f"āš™ļø AMP={AMP_DTYPE} | torch.compile={'ON' if USE_COMPILE else 'OFF'}") log(f"āš™ļø Effective Batch Size: {effective_batch} | GPUs: {world_size}") log(f"āš™ļø LR: {MAX_LR} → {MIN_LR} | Epochs: {EPOCHS}") log(f"{'='*60}") torch.backends.cudnn.benchmark = True torch.backends.cuda.matmul.allow_tf32 = True torch.backends.cudnn.allow_tf32 = True torch.set_float32_matmul_precision('high') # ============================================================================ # 7. SFT Dataset (Pre-tokenized .npy with -100 label masking) # ============================================================================ # Labels have -100 on user-turn tokens → loss is ONLY on assistant responses. # input_ids and labels are at SAME alignment (no shift in the data files). # The shift is handled in compute_sft_loss() below. class NpySFTDataset: def __init__(self, ids_path, labels_path, offsets_path, ctx_len): self.ids = np.load(ids_path, mmap_mode='r') self.labels = np.load(labels_path, mmap_mode='r') self.offsets = np.load(offsets_path) self.ctx_len = ctx_len if self.ids.ndim != 1 or self.labels.ndim != 1: raise ValueError("SFT ids and labels must be one-dimensional arrays.") if len(self.ids) != len(self.labels): raise ValueError( f"SFT ids/labels length mismatch: {len(self.ids)} != {len(self.labels)}" ) if self.offsets.ndim != 1 or len(self.offsets) == 0: raise ValueError("SFT offsets must be a non-empty one-dimensional array.") if int(self.offsets[0]) != 0 or np.any(np.diff(self.offsets) < 0): raise ValueError("SFT offsets must start at 0 and be monotonically increasing.") if int(self.offsets[-1]) > len(self.ids): raise ValueError("SFT offsets point beyond the ids array.") # Support both common formats: N start offsets, or N+1 offsets where # the final entry is the len(ids) sentinel. self.num_examples = len(self.offsets) - 1 if int(self.offsets[-1]) == len(self.ids) else len(self.offsets) if self.num_examples <= 0: raise ValueError("SFT dataset contains no examples.") def __len__(self): return self.num_examples def get(self, idx): start = int(self.offsets[idx]) end = int(self.offsets[idx + 1]) if idx + 1 < len(self.offsets) else len(self.ids) if end <= start: raise ValueError(f"SFT example {idx} is empty or has invalid offsets.") # Keep the end of an overlong conversation. Assistant targets are # normally at the end, while prefix truncation can remove every label. start = max(start, end - self.ctx_len) length = end - start input_ids = np.zeros(self.ctx_len, dtype=np.int64) labels = np.full(self.ctx_len, -100, dtype=np.int64) input_ids[:length] = self.ids[start:start + length].astype(np.int64) labels[:length] = self.labels[start:start + length].astype(np.int64) return input_ids, labels def get_sft_batch(dataset, batch_size, rng): batch_ids, batch_labels = [], [] empty_examples = 0 while len(batch_ids) < batch_size: idx = rng.randrange(len(dataset)) try: ids, labels = dataset.get(idx) except ValueError: empty_examples += 1 if empty_examples >= batch_size * 10: raise RuntimeError("Too many invalid/empty SFT examples encountered.") continue if not np.any(labels != -100): empty_examples += 1 if empty_examples >= batch_size * 10: raise RuntimeError( "Too many SFT examples have no unmasked assistant labels. " "Check the data preprocessing and conversation template." ) continue batch_ids.append(ids) batch_labels.append(labels) x = torch.from_numpy(np.stack(batch_ids)).pin_memory().to(DEVICE, non_blocking=True) y = torch.from_numpy(np.stack(batch_labels)).pin_memory().to(DEVICE, non_blocking=True) return x, y # ============================================================================ # 8. Checkpoint Key Remapping (handles torch.compile prefix + vocab resize) # ============================================================================ def remap_checkpoint_keys(ckpt_state_dict, model_state_dict): """Remap checkpoint keys to match current model. Handles: 1. torch.compile prefix: _orig_mod.X → X 2. Vocab size mismatch: resize tok_emb.weight and head.weight """ model_keys = set(model_state_dict.keys()) # Normalize common DistributedDataParallel and torch.compile prefixes. stripped = {} n_stripped = 0 for key, value in ckpt_state_dict.items(): normalized_key = key for prefix in ("module._orig_mod.", "_orig_mod.", "module."): if normalized_key.startswith(prefix): normalized_key = normalized_key[len(prefix):] break if normalized_key != key: stripped[normalized_key] = value n_stripped += 1 else: stripped[key] = value if n_stripped: log(f" šŸ”§ Stripped '_orig_mod.' from {n_stripped} keys (torch.compile artifact)") for emb_key in ["tok_emb.weight", "head.weight"]: if emb_key in stripped and emb_key in model_state_dict: ckpt_shape = stripped[emb_key].shape model_shape = model_state_dict[emb_key].shape if ckpt_shape != model_shape: log(f" šŸ“ Resizing '{emb_key}': {list(ckpt_shape)} → {list(model_shape)}") if len(ckpt_shape) != 2 or len(model_shape) != 2 or ckpt_shape[1] != model_shape[1]: raise ValueError( f"Cannot resize '{emb_key}' from {list(ckpt_shape)} to {list(model_shape)}" ) # Keep checkpoint surgery on CPU so loading does not create a # second large embedding tensor on the GPU. ckpt_weight = stripped[emb_key].detach().cpu() new_weight = model_state_dict[emb_key].detach().cpu().clone() min_vocab = min(ckpt_shape[0], model_shape[0]) new_weight[:min_vocab] = ckpt_weight[:min_vocab] # Smart initialization: copy the mean embedding to new special tokens if model_shape[0] > ckpt_shape[0]: log(f" 🧠 Smart init for new {model_shape[0] - ckpt_shape[0]} tokens (using mean embedding)") mean_emb = ckpt_weight[:min_vocab].mean(dim=0) for i in range(min_vocab, model_shape[0]): new_weight[i] = mean_emb stripped[emb_key] = new_weight return stripped # ============================================================================ # 9. Checkpoint Save / Load # ============================================================================ def save_checkpoint(model, optimizer, step, val_loss, filename, blocking=False, best_val_loss=None): """Save checkpoint locally + upload to HF in background. Args: blocking: If True, upload synchronously (used for final save). If False, upload in background thread (used during training). """ if not IS_MAIN: return ckpt_dir = f"{WORK}/{SFT_CHECKPOINT_DIR}" os.makedirs(ckpt_dir, exist_ok=True) path = f"{ckpt_dir}/{filename}" raw_model = model.module if hasattr(model, "module") else model if hasattr(raw_model, '_orig_mod'): raw_model = raw_model._orig_mod state_dict = raw_model.state_dict() clean_dict = {k.replace("_orig_mod.", ""): v for k, v in state_dict.items()} torch.save({"model": clean_dict, "optimizer": optimizer.state_dict(), "step": step, "val_loss": val_loss, "best_val_loss": best_val_loss}, path) log(f" šŸ’¾ Checkpoint saved locally @ step {step}, val_loss={val_loss:.4f}") if blocking: # Synchronous upload (for final save — must complete before exit) log(f" ā˜ļø Uploading {filename} to HF (blocking, please wait)...") try: with_retry(api.upload_file, path_or_fileobj=path, path_in_repo=f"{SFT_CHECKPOINT_DIR}/{filename}", repo_id=MAIN_REPO, repo_type="model", token=HF_TOKEN) log(f" āœ… Upload complete for {filename} @ step {step}") except Exception as e: log(f" āŒ Upload FAILED for {filename}: {e}") log(f" āš ļø File is still saved locally at: {path}") log(f" āš ļø Please upload manually before shutting down GPU!") else: # Background upload (for mid-training saves - doesn't block GPU) if filename == "sft_ckpt_best.pt": log(f" šŸ“Œ Skipping background upload for {filename} as requested.") return def _bg_upload(): try: with_retry(api.upload_file, path_or_fileobj=path, path_in_repo=f"{SFT_CHECKPOINT_DIR}/{filename}", repo_id=MAIN_REPO, repo_type="model", token=HF_TOKEN) log(f" ā˜ļø [Background upload complete for {filename} @ step {step}]") except Exception as e: log(f" āŒ [Background upload failed for {filename}: {e}]") t = threading.Thread(target=_bg_upload, daemon=False) # daemon=False: won't be killed on exit t.start() _upload_threads.append(t) log(f" ā˜ļø Uploading {filename} in background...") def save_best_checkpoint(model, step, val_loss): """Save best SFT model locally only (no HF upload). Like pretraining: just saves locally and logs val_loss. Final upload happens at the end or manually. This avoids wasting GPU time on repeated uploads. """ if not IS_MAIN: return ckpt_dir = f"{WORK}/{SFT_CHECKPOINT_DIR}" os.makedirs(ckpt_dir, exist_ok=True) path = f"{ckpt_dir}/sft_ckpt_best.pt" raw_model = model.module if hasattr(model, "module") else model if hasattr(raw_model, '_orig_mod'): raw_model = raw_model._orig_mod state_dict = raw_model.state_dict() clean_dict = {k.replace("_orig_mod.", ""): v for k, v in state_dict.items()} torch.save({"model": clean_dict, "step": step, "val_loss": val_loss}, path) log(f" ✨ New best! val_loss={val_loss:.4f} @ step {step} (saved locally)") def wait_for_uploads(): """Wait for ALL background uploads to finish before exiting.""" if not _upload_threads: return log(f"\nā³ Waiting for {len(_upload_threads)} background upload(s) to complete...") for i, t in enumerate(_upload_threads): t.join(timeout=600) # Wait up to 10 minutes per upload if t.is_alive(): log(f" āš ļø Upload thread {i+1} timed out after 10 minutes!") else: log(f" āœ… Upload {i+1}/{len(_upload_threads)} complete.") log(f"āœ… All background uploads finished.") def load_sft_checkpoint(model, optimizer): """Load checkpoint: tries SFT first, then falls back to base pretrained.""" if not RESUME_SFT: log("Fresh SFT requested: loading the base pretrained checkpoint.") # Try 1: Resume from existing SFT checkpoint try: if not RESUME_SFT: raise FileNotFoundError("Fresh SFT run requested.") log("šŸ” Looking for existing SFT checkpoint...") if IS_LOCAL_MAIN: try: with_retry(hf_hub_download, MAIN_REPO, f"{SFT_CHECKPOINT_DIR}/{RESUME_CHECKPOINT_FILE}", repo_type="model", local_dir=WORK, token=HF_TOKEN) except Exception: pass dist.barrier() path = f"{WORK}/{SFT_CHECKPOINT_DIR}/{RESUME_CHECKPOINT_FILE}" ckpt = torch.load(path, map_location="cpu", weights_only=False) # Get the actual model (unwrap DDP + compile) raw_model = model.module if hasattr(model, "module") else model if hasattr(raw_model, '_orig_mod'): raw_model = raw_model._orig_mod remapped = remap_checkpoint_keys(ckpt["model"], raw_model.state_dict()) missing, unexpected = raw_model.load_state_dict(remapped, strict=False) allowed_missing = {"head.weight"} bad_missing = [m for m in missing if m not in allowed_missing] if bad_missing: raise RuntimeError(f"Missing critical checkpoint keys: {bad_missing}") if unexpected: log(f"Warning: unexpected checkpoint keys ignored: {unexpected}") raw_model.head.weight = raw_model.tok_emb.weight log(" šŸ”— Re-tied head.weight with tok_emb.weight") if "optimizer" in ckpt: try: optimizer.load_state_dict(ckpt["optimizer"]) except Exception: log(" āš ļø Could not load optimizer state (architecture change?), using fresh optimizer") step = ckpt.get("step", 0) val_loss = ckpt.get("val_loss", -1) best_val_loss = ckpt.get("best_val_loss", None) log(f"āœ… Resumed SFT from step {step} (val_loss={val_loss:.4f})") del ckpt gc.collect() torch.cuda.empty_cache() return step, val_loss, best_val_loss except Exception as e: is_missing = isinstance(e, FileNotFoundError) or "No such file" in str(e) if RESUME_SFT and not is_missing: raise RuntimeError( "RESUME_SFT=1 was requested, but the SFT checkpoint could not be loaded due to an error. " "Fix the checkpoint or unset RESUME_SFT to start a fresh SFT run." ) from e log(f" (No SFT checkpoint found. Falling back to base pretrained model.)") log(f" (No SFT checkpoint found: {type(e).__name__})") # Try 2: Load BASE pretrained weights (30k step checkpoint) log("šŸ“„ Loading BASE 30k pretrained weights...") try: if IS_LOCAL_MAIN: with_retry(hf_hub_download, MAIN_REPO, "checkpoints/ckpt_latest.pt", repo_type="model", local_dir=WORK, token=HF_TOKEN) dist.barrier() path = f"{WORK}/checkpoints/ckpt_latest.pt" ckpt = torch.load(path, map_location="cpu", weights_only=False) raw_model = model.module if hasattr(model, "module") else model if hasattr(raw_model, '_orig_mod'): raw_model = raw_model._orig_mod remapped = remap_checkpoint_keys(ckpt["model"], raw_model.state_dict()) missing, unexpected = raw_model.load_state_dict(remapped, strict=False) allowed_missing = {"head.weight"} bad_missing = [m for m in missing if m not in allowed_missing] if bad_missing: raise RuntimeError(f"Missing critical checkpoint keys: {bad_missing}") if unexpected: log(f"Warning: unexpected checkpoint keys ignored: {unexpected}") raw_model.head.weight = raw_model.tok_emb.weight log(" šŸ”— Re-tied head.weight with tok_emb.weight") pretrain_step = ckpt.get("step", "?") pretrain_loss = ckpt.get("val_loss", -1) log(f"āœ… Loaded BASE pretrained weights (pretrain step={pretrain_step}, " f"val_loss={pretrain_loss:.4f})") log(" Starting SFT from step 0 (optimizer is fresh for SFT)") del ckpt gc.collect() torch.cuda.empty_cache() return 0, -1, None except Exception as e: log(f"āŒ FATAL: Cannot load base pretrained weights!") log(f" Error: {type(e).__name__}: {e}") raise RuntimeError( "SFT requires a pretrained base model but none could be loaded.\n" f"Checked: {MAIN_REPO}/checkpoints/ckpt_latest.pt\n" f" {MAIN_REPO}/{SFT_CHECKPOINT_DIR}/sft_ckpt_latest.pt" ) # ============================================================================ # 10. Learning Rate Schedule (Cosine with Warmup) # ============================================================================ def get_lr(step, warmup, total, max_lr, min_lr): if step < warmup: return max_lr * (step + 1) / warmup if step > total: return min_lr ratio = (step - warmup) / max(total - warmup, 1) return min_lr + 0.5 * (1 + math.cos(math.pi * ratio)) * (max_lr - min_lr) # ============================================================================ # 11. SFT Loss Function (CRITICAL: handles shift mismatch) # ============================================================================ # The 500M model.py does NOT shift logits/labels internally. # (It was removed because pretraining dataloader already shifts: y = tokens[i+1:]) # But SFT data has labels at SAME position as input_ids. # So we MUST shift here: # - Input to model: x[:, :-1] (all tokens except the last) # - Labels: y[:, 1:] (all labels shifted left by 1) # # This makes: logits[t] (predicts next token after 0..t) ↔ labels[t+1] āœ“ def compute_sft_loss(model, x, y, pad_id=0): """Forward pass with proper shift for SFT.""" x_in = x[:, :-1].contiguous() # [B, T-1] — drop last token y_shifted = y[:, 1:].contiguous() # [B, T-1] — shift labels left by 1 use_pad_id = None if pad_id is not None and (x_in == pad_id).any(): use_pad_id = pad_id logits, loss = model(x_in, targets=y_shifted, pad_id=use_pad_id) if not (y_shifted != -100).any(): return torch.nan_to_num(logits.float()).sum() * 0.0 return loss # ============================================================================ # 12. Model Initialization # ============================================================================ cfg = ViuAIConfig( vocab_size=SFT_VOCAB_SIZE, # 64005 (includes <|user|>, <|assistant|>, <|endofturn|>, , ) context_length=CONTEXT_LEN, use_checkpoint=True, # Gradient checkpointing (saves VRAM) z_loss_weight=0.0, # Disable z-loss for SFT attn_dropout=0.1, # Regularization for SFT resid_dropout=0.1, ) raw_model = ViuAI(cfg).to(DEVICE) if IS_MAIN: log(f"🧠 Model params: {raw_model.num_params()/1e6:.1f}M | vocab_size={SFT_VOCAB_SIZE}") # Optionally compile (significant speedup on Ampere+ GPUs) if USE_COMPILE: log("⚔ Compiling model with torch.compile (this takes 1-2 mins on first step)...") raw_model = torch.compile(raw_model) model = DDP(raw_model, device_ids=[local_rank]) decay_params = [] no_decay_params = [] for name, param in model.named_parameters(): if not param.requires_grad: continue if any(k in name for k in ["norm", "tok_emb"]): no_decay_params.append(param) else: decay_params.append(param) optimizer = torch.optim.AdamW( [ {"params": decay_params, "weight_decay": WEIGHT_DECAY}, {"params": no_decay_params, "weight_decay": 0.0}, ], lr=MAX_LR, betas=(0.9, 0.95) ) # Load checkpoint (SFT resume or base pretrained) start_step, prev_val_loss, best_val_loss_loaded = load_sft_checkpoint(model, optimizer) state_t = torch.tensor([ start_step, prev_val_loss if isinstance(prev_val_loss, (int, float)) else -1.0, best_val_loss_loaded if isinstance(best_val_loss_loaded, (int, float)) else -1.0 ], dtype=torch.float32, device=DEVICE) dist.broadcast(state_t, src=0) start_step = int(state_t[0].item()) prev_val_loss = state_t[1].item() if state_t[1].item() != -1.0 else None best_val_loss = state_t[2].item() if state_t[2].item() != -1.0 else None if best_val_loss is None and isinstance(prev_val_loss, (int, float)) and prev_val_loss >= 0: best_val_loss = prev_val_loss # ============================================================================ # 13. Download & Load SFT Data # ============================================================================ log("šŸ“„ Downloading SFT data (.npy files)...") # v2 dataset uses sft_v2_* prefix; v1 used sft_* prefix. # Set env var SFT_DATA_VERSION=v1 to use the old dataset. _DATA_VER = os.environ.get("SFT_DATA_VERSION", "v2") _PFX = f"sft_{_DATA_VER}_" if _DATA_VER != "v1_legacy" else "sft_" if _DATA_VER == "v1": # legacy v1 filenames had no version prefix _PFX = "sft_" SFT_DATA_FILES = [ f"{_PFX}train_ids.npy", f"{_PFX}train_labels.npy", f"{_PFX}train_offsets.npy", f"{_PFX}val_ids.npy", f"{_PFX}val_labels.npy", f"{_PFX}val_offsets.npy", ] log(f"šŸ“‚ SFT data version: {_DATA_VER} | prefix: '{_PFX}'") def download_on_main(fn, *args, fatal=True, **kwargs): ok = True err = "" if IS_LOCAL_MAIN: try: with_retry(fn, *args, **kwargs) except Exception as e: ok = False err = f"{type(e).__name__}: {e}" log(f"Download failed: {err}") ok_tensor = torch.tensor([1 if ok else 0], device=DEVICE) dist.all_reduce(ok_tensor, op=dist.ReduceOp.MIN) if ok_tensor.item() == 0: if fatal: raise RuntimeError(f"Download failed on main rank: {err}") return False dist.barrier() return True for fname in SFT_DATA_FILES: download_on_main(hf_hub_download, SFT_DATA_REPO, fname, repo_type="dataset", local_dir=WORK, token=HF_TOKEN) log("āœ… SFT data ready.") train_ds = NpySFTDataset( f"{WORK}/{_PFX}train_ids.npy", f"{WORK}/{_PFX}train_labels.npy", f"{WORK}/{_PFX}train_offsets.npy", CONTEXT_LEN ) val_ds = NpySFTDataset( f"{WORK}/{_PFX}val_ids.npy", f"{WORK}/{_PFX}val_labels.npy", f"{WORK}/{_PFX}val_offsets.npy", CONTEXT_LEN ) if IS_MAIN: log(f"šŸ“Š Train examples: {len(train_ds)}, Val examples: {len(val_ds)}") # ============================================================================ # 14. Sanity Checks # ============================================================================ if IS_MAIN: _s_ids, _s_labels = train_ds.get(0) _has_masked = (_s_labels == -100).any() _has_active = (_s_labels != -100).any() assert _has_active, ( "SFT FATAL: ALL tokens are masked (-100)! " "No assistant tokens to train on. Your data pipeline is broken." ) if not _has_masked: log("WARNING: sample 0 has no -100 labels; it will train on every token in that sample.") _max_id = int(_s_ids[_s_ids > 0].max()) if (_s_ids > 0).any() else 0 _masked_count = (_s_labels == -100).sum() _active_count = ((_s_labels != -100) & (_s_labels != 0)).sum() log(f"āœ… Data sanity check passed:") log(f" Masked (user) tokens: {_masked_count} | Active (assistant) tokens: {_active_count}") log(f" Max token ID in data: {_max_id} | Model vocab: {SFT_VOCAB_SIZE}") if _max_id >= SFT_VOCAB_SIZE: raise ValueError( f"SFT token ID ({_max_id}) is outside model vocab ({SFT_VOCAB_SIZE}). " "Use the tokenizer that created this dataset." ) log(f"āš ļø WARNING: Max token ID ({_max_id}) >= vocab ({SFT_VOCAB_SIZE})! Will crash!") del _s_ids, _s_labels # ============================================================================ # 15. Training Step Math # ============================================================================ PAD_ID = 0 rng = random.Random(1337 + rank) val_rng = random.Random(999) # Optional cap on training steps, read from environment variable. # Set MAX_TRAIN_STEPS in the shell/notebook before running to do a # limited test run (e.g. "2000"). Leave unset for full training # (runs all steps for EPOCHS as originally planned). _max_steps_env = os.environ.get("MAX_TRAIN_STEPS") MAX_TRAIN_STEPS = int(_max_steps_env) if _max_steps_env else None TOTAL_STEPS = max(1, (len(train_ds) // effective_batch) * EPOCHS) end_step = TOTAL_STEPS if MAX_TRAIN_STEPS is not None: end_step = min(end_step, start_step + MAX_TRAIN_STEPS) log(f"šŸ“ˆ Total SFT steps for {EPOCHS} epochs (full plan): {TOTAL_STEPS}") if MAX_TRAIN_STEPS is not None: log(f" āš ļø MAX_TRAIN_STEPS cap active: {MAX_TRAIN_STEPS}") log(f" Resumed from step: {start_step} | This run will go to: {end_step}") log(f" Training: step {start_step} → {end_step}") # ============================================================================ # 16. Evaluation Function # ============================================================================ @torch.no_grad() def evaluate(): # Call the wrapped module directly: only rank 0 evaluates, so DDP's # collective hooks must not be entered by a single process. eval_model = model.module if hasattr(model, "module") else model eval_model.eval() losses = [] for _ in range(20): x, y = get_sft_batch(val_ds, MICRO_BATCH, val_rng) with torch.autocast(device_type="cuda", dtype=AMP_DTYPE): loss = compute_sft_loss(eval_model, x, y, pad_id=PAD_ID) if torch.isfinite(loss): losses.append(loss.item()) del x, y, loss eval_model.train() torch.cuda.empty_cache() if not losses: return float("inf") return sum(losses) / len(losses) # ============================================================================ # 17. Training Loop # ============================================================================ log(f"\n{'='*60}") log(f"šŸš€ Starting SFT Training...") log(f"{'='*60}\n") model.train() val_loss = None # best_val_loss is already loaded above ema_tok_s = None global_t0 = time.time() t0 = time.time() for step in range(start_step, end_step): # Keep the original cosine schedule when resuming; restarting warm-up on # every resume causes an unintended learning-rate jump. lr = get_lr(step, WARMUP_STEPS, TOTAL_STEPS, MAX_LR, MIN_LR) for g in optimizer.param_groups: g["lr"] = lr optimizer.zero_grad(set_to_none=True) accum_loss = 0.0 for micro_i in range(GRAD_ACCUM): x, y = get_sft_batch(train_ds, MICRO_BATCH, rng) sync_ctx = model.no_sync() if micro_i < GRAD_ACCUM - 1 else contextlib.nullcontext() with sync_ctx: with torch.autocast(device_type="cuda", dtype=AMP_DTYPE): loss = compute_sft_loss(model, x, y, pad_id=PAD_ID) loss = loss / GRAD_ACCUM loss.backward() accum_loss += loss.item() del x, y, loss # NaN/Inf protection (synced across all ranks) is_nan = torch.tensor([0 if math.isfinite(accum_loss) else 1], device=DEVICE) dist.all_reduce(is_nan, op=dist.ReduceOp.MAX) if is_nan.item() > 0: if IS_MAIN: log(f"āš ļø NaN/Inf loss at step {step}! Skipping optimizer step.") optimizer.zero_grad(set_to_none=True) continue grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), GRAD_CLIP) if not torch.isfinite(grad_norm): if IS_MAIN: log(f"āš ļø Non-finite grad norm at step {step}. Skipping optimizer step.") optimizer.zero_grad(set_to_none=True) continue optimizer.step() # ---- Logging (every 10 steps) ---- if IS_MAIN and step % 10 == 0: dt = time.time() - t0 tok_s = (MICRO_BATCH * GRAD_ACCUM * CONTEXT_LEN * world_size * 10) / dt if dt > 0 else 0 ema_tok_s = tok_s if ema_tok_s is None else 0.7 * ema_tok_s + 0.3 * tok_s t0 = time.time() mem_alloc = torch.cuda.memory_allocated(DEVICE) / 1e9 mem_reserved = torch.cuda.memory_reserved(DEVICE) / 1e9 steps_done = step - start_step if steps_done > 0: elapsed = time.time() - global_t0 eta_s = elapsed / steps_done * (end_step - step) eta_str = f"{eta_s/60:.0f}min" if eta_s < 3600 else f"{eta_s/3600:.1f}hr" else: eta_str = "..." log(f"SFT step {step:5d}/{end_step} | loss {accum_loss:.4f} | lr {lr:.2e} | " f"{ema_tok_s:.0f} tok/s | ETA {eta_str} | " f"mem {mem_alloc:.1f}GB/{mem_reserved:.1f}GB") # ---- Memory cleanup (important for T4!) ---- if step > start_step and step % EMPTY_CACHE_EVERY == 0: torch.cuda.empty_cache() # ---- Evaluation ---- if step > start_step and step % EVAL_EVERY == 0: dist.barrier() val_loss = evaluate() if IS_MAIN else None if IS_MAIN and val_loss is not None: log(f" >> val_loss {val_loss:.4f}") if best_val_loss is None or val_loss < best_val_loss: best_val_loss = val_loss # User requested to skip best checkpoint to save time # save_best_checkpoint(model, step, val_loss) dist.barrier() # ---- Save latest checkpoint (background upload) ---- if step > start_step and step % CKPT_EVERY == 0: if IS_MAIN: wait_for_uploads() save_checkpoint(model, optimizer, step, val_loss if val_loss is not None else -1, "sft_ckpt_latest.pt", blocking=False, best_val_loss=best_val_loss) dist.barrier() # ============================================================================ # 18. Final Save & Cleanup # ============================================================================ dist.barrier() final_val = evaluate() if IS_MAIN else None # Final save: BLOCKING (synchronous) — must complete before program exits if IS_MAIN: wait_for_uploads() # Added to prevent race condition on final save final_val_for_save = final_val if final_val is not None and math.isfinite(final_val) else -1 save_checkpoint(model, optimizer, end_step, final_val_for_save, "sft_ckpt_latest.pt", blocking=True, best_val_loss=best_val_loss) if IS_MAIN and os.path.exists(f"{WORK}/{SFT_CHECKPOINT_DIR}/sft_ckpt_best.pt"): log(f" šŸ“Œ Skipping best checkpoint upload to HF as requested. File saved locally at: {WORK}/{SFT_CHECKPOINT_DIR}/sft_ckpt_best.pt") # Wait for any remaining background uploads from mid-training saves if IS_MAIN: wait_for_uploads() if IS_MAIN: log(f"\n{'='*60}") log(f"✨ 500M SFT Training Complete!") log(f" Final val_loss: {final_val if final_val is not None else -1:.4f}") log(f" Best val_loss: {best_val_loss if best_val_loss is not None else -1:.4f}") log(f" Checkpoints saved to: {MAIN_REPO}/{SFT_CHECKPOINT_DIR}/") log(f"{'='*60}") dist.barrier() dist.destroy_process_group()