arcisvlm / scripts /train_stage2_ddp.py
Hardik Sanghvi
feat: integrate Gemma 4 E2B backbone for production-quality VLM inference
7a564e3
Raw
History Blame Contribute Delete
21 kB
#!/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()