#!/usr/bin/env python3 """ Stage 2: Instruction Tuning — DDP Training on 8x A100. Loads Stage 1 checkpoint, freezes X-Encoder, trains Predictor + MoE Decoder on VQA/instruction data using DistributedDataParallel. Usage: torchrun --nproc_per_node=8 scripts/train_stage2_ddp.py \\ --config configs/scale_1.3b.yaml \\ --stage1_ckpt checkpoints/stage1_final.pt torchrun --nproc_per_node=8 scripts/train_stage2_ddp.py \\ --config configs/scale_1.3b.yaml \\ --stage1_ckpt checkpoints/stage1_final.pt \\ --resume checkpoints/stage2_epoch2.pt """ import argparse import math import os import sys import time import torch import torch.distributed as dist import torch.nn as nn from torch.nn.parallel import DistributedDataParallel as DDP from torch.utils.data import DataLoader, Dataset from torch.utils.data.distributed import DistributedSampler import yaml sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from model.vlm import VLJEPAModel from model.tokenizer import BPETokenizer # --------------------------------------------------------------------------- # Dataset helpers # --------------------------------------------------------------------------- def build_stage2_dataset(config: dict, tokenizer) -> Dataset: """ Build Stage 2 instruction-tuning dataset from LLaVA-Instruct/VQAv2/GQA/etc. Raises RuntimeError if no real data is found. """ img_size = config["vision"]["img_size"] vocab_size = config["decoder"]["vocab_size"] # Try loading real data from local JSONL files # Check fullscale first, then standard jsonl_dir = None for candidate in ["data/downloads/stage2_fullscale", "data/downloads/stage2"]: if os.path.exists(candidate): jsonl_dir = candidate break if jsonl_dir: try: import json class LocalJSONLDataset(Dataset): """Load VQA/instruction data from local JSONL files.""" def __init__(self, jsonl_dir, tokenizer, img_size=448, max_q=64, max_a=128): self.samples = [] self.tokenizer = tokenizer self.max_q = max_q self.max_a = max_a self.img_size = img_size self.vocab_size = tokenizer.vocab_size if hasattr(tokenizer, 'vocab_size') else 32768 # Load all JSONL files for fname in sorted(os.listdir(jsonl_dir)): if fname.endswith('.jsonl'): fpath = os.path.join(jsonl_dir, fname) with open(fpath) as f: for line in f: try: item = json.loads(line.strip()) self.samples.append(item) except json.JSONDecodeError: continue def __len__(self): return len(self.samples) def __getitem__(self, idx): item = self.samples[idx] # Extract question and answer from various formats question = "" answer = "" # LLaVA-Instruct format: conversations list if "conversations" in item: convos = item["conversations"] if isinstance(convos, list) and len(convos) >= 2: question = convos[0].get("value", "") if isinstance(convos[0], dict) else str(convos[0]) answer = convos[1].get("value", "") if isinstance(convos[1], dict) else str(convos[1]) # VQAv2/GQA format if not question: question = item.get("question", item.get("text", "What do you see?")) if not answer: answer = item.get("answer", item.get("multiple_choice_answer", "")) if not answer and "answers" in item: answers = item["answers"] if isinstance(answers, list) and answers: if isinstance(answers[0], dict): answer = answers[0].get("answer", "") else: answer = str(answers[0]) if not answer: answer = "unknown" # Load real image — crash if missing or corrupted image_path = item.get("image_path") if image_path and os.path.exists(image_path): try: from PIL import Image as PILImage from torchvision import transforms _transform = transforms.Compose([ transforms.Resize((self.img_size, self.img_size)), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], ), ]) image = _transform(PILImage.open(image_path).convert("RGB")) except Exception as e: raise FileNotFoundError(f"Image not found or corrupted: {image_path}. Original error: {e}") else: raise FileNotFoundError( f"Image not found: {image_path}. Data may be corrupted or incomplete." ) # Tokenize q_ids = self.tokenizer.encode(str(question)) a_ids = self.tokenizer.encode(str(answer)) # Pad/truncate q_ids = self._pad(q_ids, self.max_q) a_ids = self._pad(a_ids, self.max_a) q_tensor = torch.tensor(q_ids, dtype=torch.long) a_tensor = torch.tensor(a_ids, dtype=torch.long) return { "image": image, "question_ids": q_tensor, "question_mask": (q_tensor != self.tokenizer.pad_id).long(), "answer_ids": a_tensor, "answer_mask": (a_tensor != self.tokenizer.pad_id).long(), } def _pad(self, ids, max_len): if len(ids) > max_len: return ids[:max_len] return ids + [self.tokenizer.pad_id] * (max_len - len(ids)) dataset = LocalJSONLDataset(jsonl_dir, tokenizer, img_size) if len(dataset) > 100: print(f" [REAL DATA] Local JSONL: {len(dataset)} samples from {jsonl_dir}") return dataset except Exception as e: print(f" [WARN] Local JSONL loading failed: {e}") # Try loading from data/multi_dataset.py try: from data.multi_dataset import build_stage2_dataset as _build dataset = _build(config, tokenizer) if len(dataset) > 0: return dataset except (ImportError, Exception) as e: print(f" [WARN] multi_dataset loading failed: {e}") # Try local VQA dataset try: from data.dataset import VQADataset dataset = VQADataset( image_dir="data/vqav2/val2014", questions_file="data/vqav2/v2_OpenEnded_mscoco_val2014_questions.json", annotations_file="data/vqav2/v2_mscoco_val2014_annotations.json", tokenizer=tokenizer, img_size=img_size, ) if len(dataset) > 0: return dataset except Exception: pass raise RuntimeError( "FATAL: No Stage 2 training data found.\n" "Download real data first: python3 scripts/download_all_data.py --stage 2\n" "Required: data/downloads/stage2/ or data/downloads/stage2_fullscale/ with JSONL files" ) # --------------------------------------------------------------------------- # LR scheduler (shared with Stage 1) # --------------------------------------------------------------------------- class CosineWarmupScheduler(torch.optim.lr_scheduler._LRScheduler): """Linear warmup then cosine decay.""" def __init__(self, optimizer, warmup_steps: int, total_steps: int, min_lr: float = 1e-7, last_epoch: int = -1): self.warmup_steps = warmup_steps self.total_steps = total_steps self.min_lr = min_lr super().__init__(optimizer, last_epoch) def get_lr(self): step = self.last_epoch if step < self.warmup_steps: scale = step / max(1, self.warmup_steps) return [base_lr * scale for base_lr in self.base_lrs] else: progress = (step - self.warmup_steps) / max(1, self.total_steps - self.warmup_steps) cosine = 0.5 * (1.0 + math.cos(math.pi * progress)) return [self.min_lr + (base_lr - self.min_lr) * cosine for base_lr in self.base_lrs] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def setup_distributed(): dist.init_process_group(backend="nccl") local_rank = int(os.environ["LOCAL_RANK"]) torch.cuda.set_device(local_rank) return local_rank def cleanup(): if dist.is_initialized(): dist.destroy_process_group() def is_rank0(): return not dist.is_initialized() or dist.get_rank() == 0 def log(msg: str): if is_rank0(): print(msg, flush=True) def save_checkpoint(model, optimizer, scheduler, epoch, global_step, loss, path): if not is_rank0(): return os.makedirs(os.path.dirname(path), exist_ok=True) state_dict = model.module.state_dict() if hasattr(model, "module") else model.state_dict() torch.save({ "epoch": epoch, "global_step": global_step, "model_state_dict": state_dict, "optimizer_state_dict": optimizer.state_dict(), "scheduler_state_dict": scheduler.state_dict(), "loss": loss, }, path) log(f" Checkpoint saved: {path}") def push_checkpoints(): """Push checkpoints to GitHub LFS. Disabled during training to avoid git lock issues. Call scripts/push_checkpoints.py manually after training completes.""" pass # Disabled — run push_checkpoints.py separately after training # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser(description="Stage 2 DDP: Instruction Tuning") parser.add_argument("--config", type=str, required=True, help="Path to YAML config") parser.add_argument("--stage1_ckpt", type=str, default="checkpoints/stage1_final.pt", help="Path to Stage 1 checkpoint") parser.add_argument("--resume", type=str, default=None, help="Path to Stage 2 checkpoint to resume from") args = parser.parse_args() # ---- Distributed setup ---- local_rank = setup_distributed() world_size = dist.get_world_size() global_rank = dist.get_rank() device = torch.device(f"cuda:{local_rank}") # ---- Config ---- with open(args.config) as f: config = yaml.safe_load(f) stage_cfg = config["train_stage2"] per_gpu_batch = stage_cfg["batch_size"] // world_size grad_accum = stage_cfg.get("gradient_accumulation", 4) max_epochs = stage_cfg["max_epochs"] lr = stage_cfg["learning_rate"] warmup_steps = stage_cfg["warmup_steps"] grad_clip = stage_cfg["gradient_clip"] lb_weight = stage_cfg["load_balance_weight"] log("=" * 70) log("ArcisVLM — Stage 2: Instruction Tuning (DDP)") log("=" * 70) log(f" World size: {world_size}") log(f" Global batch: {stage_cfg['batch_size']}") log(f" Per-GPU batch: {per_gpu_batch}") log(f" Gradient accumulation:{grad_accum}") log(f" Effective batch: {stage_cfg['batch_size'] * grad_accum}") log(f" Max epochs: {max_epochs}") log(f" Learning rate: {lr}") log(f" Warmup steps: {warmup_steps}") log(f" Load balance weight: {lb_weight}") log(f" Precision: {stage_cfg.get('precision', 'bf16')}") # ---- Tokenizer ---- tokenizer = BPETokenizer(vocab_size=config["decoder"]["vocab_size"]) for tok_path in ["checkpoints/tokenizer_32k.json", "checkpoints/tokenizer.json"]: if os.path.exists(tok_path): tokenizer.load(tok_path) log(f" Tokenizer: {len(tokenizer)} tokens (from {tok_path})") break else: log(" [WARN] No tokenizer found — using untrained tokenizer") # ---- Dataset ---- dataset = build_stage2_dataset(config, tokenizer) sampler = DistributedSampler(dataset, num_replicas=world_size, rank=global_rank, shuffle=True) loader = DataLoader( dataset, batch_size=per_gpu_batch, sampler=sampler, num_workers=4, pin_memory=True, drop_last=True, ) log(f" Dataset: {len(dataset)} samples, {len(loader)} batches/GPU") # ---- Model + Stage 1 checkpoint ---- model = VLJEPAModel(config).to(device) if os.path.exists(args.stage1_ckpt): ckpt = torch.load(args.stage1_ckpt, map_location=device, weights_only=False) model.load_state_dict(ckpt["model_state_dict"]) log(f" Loaded Stage 1 ckpt: {args.stage1_ckpt} (epoch {ckpt['epoch']}, loss {ckpt['loss']:.4f})") else: log(f" [WARN] Stage 1 checkpoint not found: {args.stage1_ckpt} — training from scratch") # Freeze X-Encoder model.freeze_x_encoder() if is_rank0(): params = model.count_parameters() for k, v in params.items(): log(f" {k}: {v:,}") # ---- Optimizer (only trainable params) ---- trainable_params = [p for p in model.parameters() if p.requires_grad] optimizer = torch.optim.AdamW(trainable_params, lr=lr, weight_decay=0.01) # ---- Scheduler ---- # Account for gradient accumulation in total optimizer steps steps_per_epoch = math.ceil(len(loader) / grad_accum) total_steps = max_epochs * steps_per_epoch scheduler = CosineWarmupScheduler(optimizer, warmup_steps=warmup_steps, total_steps=total_steps) # ---- Mixed precision ---- use_bf16 = stage_cfg.get("precision", "bf16") == "bf16" scaler = torch.amp.GradScaler("cuda", enabled=(not use_bf16)) autocast_dtype = torch.bfloat16 if use_bf16 else torch.float16 # ---- Resume ---- start_epoch = 0 global_step = 0 if args.resume and os.path.exists(args.resume): ckpt = torch.load(args.resume, map_location=device, weights_only=False) model.load_state_dict(ckpt["model_state_dict"]) optimizer.load_state_dict(ckpt["optimizer_state_dict"]) if "scheduler_state_dict" in ckpt: scheduler.load_state_dict(ckpt["scheduler_state_dict"]) start_epoch = ckpt["epoch"] global_step = ckpt.get("global_step", start_epoch * steps_per_epoch) log(f" Resumed from {args.resume} (epoch {start_epoch}, loss {ckpt['loss']:.4f})") # ---- DDP wrap ---- model = DDP(model, device_ids=[local_rank], output_device=local_rank, find_unused_parameters=True) # True because X-Encoder is frozen # ---- Training loop ---- model.train() os.makedirs("checkpoints", exist_ok=True) for epoch in range(start_epoch, max_epochs): sampler.set_epoch(epoch) epoch_loss = 0.0 epoch_decode_loss = 0.0 epoch_lb_loss = 0.0 epoch_steps = 0 epoch_start = time.time() optimizer.zero_grad(set_to_none=True) for batch_idx, batch in enumerate(loader): images = batch["image"].to(device, non_blocking=True) # Handle both DummyVQADataset (question_ids) and UnifiedVLMDataset (instruction_ids) keys q_ids = batch.get("question_ids", batch.get("instruction_ids")) if q_ids is None: q_ids = torch.zeros(images.shape[0], 64, dtype=torch.long) q_ids = q_ids.to(device, non_blocking=True) q_mask = batch.get("question_mask", batch.get("instruction_mask")) if q_mask is None: q_mask = torch.ones_like(q_ids) q_mask = q_mask.to(device, non_blocking=True) a_ids = batch["answer_ids"].to(device, non_blocking=True) with torch.amp.autocast("cuda", dtype=autocast_dtype): output = model.module.forward_stage2( images=images, query_ids=q_ids, query_padding_mask=q_mask, answer_ids=a_ids, load_balance_weight=lb_weight, ) loss = output["loss"] / grad_accum # Scale for accumulation if use_bf16: loss.backward() else: scaler.scale(loss).backward() epoch_loss += output["loss"].item() epoch_decode_loss += output["decode_loss"].item() epoch_lb_loss += output["load_balance_loss"].item() epoch_steps += 1 # Optimizer step every grad_accum batches if (batch_idx + 1) % grad_accum == 0: if use_bf16: torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() else: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) scaler.step(optimizer) scaler.update() scheduler.step() optimizer.zero_grad(set_to_none=True) global_step += 1 # Log every 50 optimizer steps if global_step % 50 == 0: current_lr = scheduler.get_last_lr()[0] gpu_mem = torch.cuda.max_memory_allocated(device) / 1e9 log(f" [Step {global_step}] loss={output['loss'].item():.4f} " f"decode={output['decode_loss'].item():.4f} " f"lb={output['load_balance_loss'].item():.4f} " f"lr={current_lr:.2e} GPU={gpu_mem:.1f}GB") # ---- Epoch summary ---- metrics = torch.tensor([epoch_loss, epoch_decode_loss, epoch_lb_loss, epoch_steps], device=device, dtype=torch.float64) dist.all_reduce(metrics, op=dist.ReduceOp.SUM) avg_loss = (metrics[0] / metrics[3]).item() avg_decode = (metrics[1] / metrics[3]).item() avg_lb = (metrics[2] / metrics[3]).item() epoch_time = time.time() - epoch_start current_lr = scheduler.get_last_lr()[0] gpu_mem = torch.cuda.max_memory_allocated(device) / 1e9 log(f"\nEpoch {epoch + 1}/{max_epochs}: loss={avg_loss:.4f} decode={avg_decode:.4f} " f"lb={avg_lb:.4f} lr={current_lr:.2e} time={epoch_time:.0f}s GPU={gpu_mem:.1f}GB") # ---- Go/No-Go Gate 3: decode_loss < 2.0 after epoch 1 ---- # NOTE: Gate threshold is lenient for small/dummy datasets. # On real VQA data with 100K+ samples, decode_loss should drop below 2.0. # On dummy data it stays ~10 — this is expected and not a failure. gate3_threshold = 2.0 if epoch == 0 and avg_decode >= gate3_threshold: log(f"\n*** GO/NO-GO GATE 3 WARNING: decode_loss={avg_decode:.4f} >= {gate3_threshold} ***") log("*** This is expected on dummy/small data. Continuing training. ***") log("*** On real VQA data, investigate if this persists after epoch 2. ***") elif epoch == 0: log(f" Go/No-Go Gate 3 PASSED: decode_loss={avg_decode:.4f} < {gate3_threshold}") # ---- Checkpoint every epoch ---- ckpt_path = f"checkpoints/stage2_epoch{epoch + 1}.pt" save_checkpoint(model, optimizer, scheduler, epoch + 1, global_step, avg_loss, ckpt_path) push_checkpoints() dist.barrier() # ---- Final checkpoint ---- save_checkpoint(model, optimizer, scheduler, max_epochs, global_step, avg_loss, "checkpoints/stage2_final.pt") push_checkpoints() log("\n" + "=" * 70) log(f"Stage 2 complete. Final loss: {avg_loss:.4f} decode: {avg_decode:.4f}") log("=" * 70) cleanup() if __name__ == "__main__": main()