Download src/train_modal.py from Reizxn/makeitwork1: direct link, hf CLI and curl.
- Browser
- Download file 31.9 kB
-
https://huggingface.co/Reizxn/makeitwork1/resolve/main/src/train_modal.py
- Command line
-
hf download hf://Reizxn/makeitwork1/src/train_modal.py
-
curl -L -o train_modal.py https://huggingface.co/Reizxn/makeitwork1/resolve/main/src/train_modal.py
31.9 kB
| """ | |
| Modal training script for Retriever500M. | |
| Runs pretraining and/or SFT on Modal cloud GPUs instead of the local laptop. | |
| Supports resuming from the existing bf16 checkpoint. | |
| Usage: | |
| # Step 1: Seed the volume with the initial checkpoint | |
| modal run src/train_modal.py --command seed | |
| # Step 2: Run pretraining (continues from latest checkpoint) | |
| modal run src/train_modal.py --command pretrain --steps 1000 | |
| # Step 3: Run SFT (starts from pretrained checkpoint) | |
| modal run src/train_modal.py --command sft --steps 500 | |
| # Step 4: Download checkpoints back to local | |
| modal run src/train_modal.py --command download | |
| # Or do everything in one shot: | |
| modal run src/train_modal.py --command pipeline --pretrain-steps 1000 --sft-steps 500 | |
| """ | |
| import os | |
| import sys | |
| import json | |
| import time | |
| import argparse | |
| from dataclasses import asdict | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| from tqdm import tqdm | |
| import modal | |
| # βββ Modal Setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| APP_NAME = "retriever500m" | |
| VOLUME_NAME = "retriever500m-data" | |
| PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| SRC_DIR = os.path.join(PROJECT_DIR, "src") | |
| DATA_DIR = os.path.join(PROJECT_DIR, "data") | |
| TOKENIZER_DIR = os.path.join(PROJECT_DIR, "tokenizer") | |
| CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints") | |
| volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True) | |
| # Image: PyTorch with CUDA, plus bitsandbytes for 8-bit optimizer. | |
| # Data, tokenizer, src, and initial checkpoint are baked into the image. | |
| # The volume is used only for saving new checkpoints and logs. | |
| image = ( | |
| modal.Image.from_registry( | |
| "pytorch/pytorch:2.4.1-cuda12.1-cudnn9-runtime", | |
| add_python="3.11", | |
| ) | |
| .pip_install("tokenizers>=0.15,<0.21", "tqdm", "numpy") | |
| .pip_install("bitsandbytes>=0.43,<0.45") | |
| .add_local_dir(SRC_DIR, "/root/src") | |
| .add_local_dir(TOKENIZER_DIR, "/root/tokenizer") | |
| .add_local_dir(DATA_DIR, "/root/data", ignore=["raw_large/", "raw/", "dedup/"]) | |
| .add_local_dir(CHECKPOINT_DIR, "/root/seed_checkpoints") | |
| ) | |
| app = modal.App(APP_NAME, image=image) | |
| # Remote paths inside the container. | |
| # Data/tokenizer/src are baked into the image. | |
| # The volume is mounted at /root/vol for checkpoints and logs. | |
| VOL_MOUNT = "/root/vol" | |
| REMOTE_DATA = "/root/data" # baked into image | |
| REMOTE_CKPT = "/root/vol/checkpoints" # on volume (for new checkpoints) | |
| REMOTE_LOGS = "/root/vol/logs" # on volume | |
| REMOTE_TOKENIZER = "/root/tokenizer" # baked into image | |
| REMOTE_SRC = "/root/src" # baked into image | |
| REMOTE_SEED_CKPT = "/root/seed_checkpoints" # baked into image (initial checkpoint) | |
| # βββ Seed command ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def seed(): | |
| """Copy the initial checkpoint from the image to the volume.""" | |
| import shutil | |
| os.makedirs(REMOTE_CKPT, exist_ok=True) | |
| os.makedirs(REMOTE_LOGS, exist_ok=True) | |
| # Copy seed checkpoints from image to volume | |
| if os.path.exists(REMOTE_SEED_CKPT): | |
| for fname in os.listdir(REMOTE_SEED_CKPT): | |
| src = os.path.join(REMOTE_SEED_CKPT, fname) | |
| dst = os.path.join(REMOTE_CKPT, fname) | |
| if os.path.isfile(src) and not os.path.exists(dst): | |
| shutil.copy2(src, dst) | |
| print(f" Seeded {fname} ({os.path.getsize(src) / 1e6:.1f} MB)") | |
| elif os.path.exists(dst): | |
| print(f" SKIP {fname} (already on volume)") | |
| volume.commit() | |
| print("Seed complete.") | |
| # βββ Download command ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def download(): | |
| """Download checkpoints and logs from the Modal volume to local.""" | |
| import shutil | |
| os.makedirs(CHECKPOINT_DIR, exist_ok=True) | |
| os.makedirs(os.path.join(PROJECT_DIR, "logs"), exist_ok=True) | |
| # Download checkpoints | |
| for fname in os.listdir(REMOTE_CKPT): | |
| src = os.path.join(REMOTE_CKPT, fname) | |
| if os.path.isfile(src): | |
| dst = os.path.join(CHECKPOINT_DIR, fname) | |
| shutil.copy2(src, dst) | |
| print(f" Downloaded {fname} ({os.path.getsize(src) / 1e6:.1f} MB)") | |
| # Download logs | |
| for fname in os.listdir(REMOTE_LOGS): | |
| src = os.path.join(REMOTE_LOGS, fname) | |
| if os.path.isfile(src): | |
| dst = os.path.join(PROJECT_DIR, "logs", fname) | |
| shutil.copy2(src, dst) | |
| print(f" Downloaded log {fname}") | |
| print("Download complete.") | |
| # βββ Training logic (shared) βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_lr(step, warmup, max_steps, max_lr, min_lr): | |
| """Cosine LR schedule with linear warmup.""" | |
| if step < warmup: | |
| return max_lr * (step + 1) / warmup | |
| if step > max_steps: | |
| return min_lr | |
| decay_ratio = (step - warmup) / (max_steps - warmup) | |
| coeff = 0.5 * (1.0 + np.cos(np.pi * decay_ratio)) | |
| return min_lr + coeff * (max_lr - min_lr) | |
| def setup_optimizer(model, lr, use_8bit=True): | |
| decay_params, no_decay_params = [], [] | |
| for name, param in model.named_parameters(): | |
| if not param.requires_grad: | |
| continue | |
| if "embedding" in name or "norm" in name: | |
| no_decay_params.append(param) | |
| else: | |
| decay_params.append(param) | |
| param_groups = [ | |
| {"params": decay_params, "weight_decay": 0.1}, | |
| {"params": no_decay_params, "weight_decay": 0.0}, | |
| ] | |
| if use_8bit: | |
| try: | |
| import bitsandbytes as bnb | |
| optimizer = bnb.optim.AdamW8bit(param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8) | |
| print("Using 8-bit AdamW (bitsandbytes)") | |
| return optimizer | |
| except Exception as e: | |
| print(f"8-bit optimizer unavailable ({e}), falling back to AdamW") | |
| optimizer = torch.optim.AdamW(param_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8) | |
| print("Using standard AdamW") | |
| return optimizer | |
| # βββ Pretraining βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_and_tokenize(corpus_path, tokenizer): | |
| """Load corpus, tokenize, return flat numpy array of token IDs.""" | |
| print(f"Loading corpus from {corpus_path}...") | |
| with open(corpus_path, "r", encoding="utf-8") as f: | |
| text = f.read() | |
| print(f"Corpus size: {len(text) / 1e6:.1f} MB") | |
| chunk_size = 1_000_000 | |
| all_tokens = [] | |
| print("Tokenizing corpus...") | |
| for i in tqdm(range(0, len(text), chunk_size)): | |
| chunk = text[i : i + chunk_size] | |
| encoded = tokenizer.encode(chunk) | |
| all_tokens.extend(encoded.ids) | |
| tokens = np.array(all_tokens, dtype=np.int32) | |
| print(f"Total tokens: {len(tokens):,}") | |
| return tokens | |
| def get_batch(tokens, batch_size, seq_len, device): | |
| max_start = len(tokens) - seq_len - 1 | |
| indices = np.random.randint(0, max_start, size=batch_size) | |
| input_ids = np.stack([tokens[i : i + seq_len] for i in indices]) | |
| targets = np.stack([tokens[i + 1 : i + seq_len + 1] for i in indices]) | |
| input_ids = torch.from_numpy(input_ids).long().to(device) | |
| targets = torch.from_numpy(targets).long().to(device) | |
| return input_ids, targets | |
| def run_pretrain(steps, batch_size, grad_accum, seq_len, lr, warmup, | |
| save_every, log_every, corpus, resume, use_8bit_adam): | |
| sys.path.insert(0, REMOTE_SRC) | |
| from model import ModelConfig, Retriever500M | |
| from tokenizers import Tokenizer | |
| device = torch.device("cuda") | |
| print(f"Device: {device}") | |
| print(f"GPU: {torch.cuda.get_device_name(0)}") | |
| print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.2f} GB") | |
| os.makedirs(REMOTE_CKPT, exist_ok=True) | |
| os.makedirs(REMOTE_LOGS, exist_ok=True) | |
| # Tokenizer | |
| tokenizer_path = os.path.join(REMOTE_TOKENIZER, "tokenizer.json") | |
| tokenizer = Tokenizer.from_file(tokenizer_path) | |
| vocab_size = tokenizer.get_vocab_size() | |
| print(f"Vocab size: {vocab_size}") | |
| # Data | |
| if corpus == "curated": | |
| corpus_path = os.path.join(REMOTE_DATA, "corpus_curated.txt") | |
| elif corpus == "default": | |
| corpus_path = os.path.join(REMOTE_DATA, "corpus.txt") | |
| else: | |
| corpus_path = corpus | |
| tokens = load_and_tokenize(corpus_path, tokenizer) | |
| # Model | |
| config = ModelConfig( | |
| vocab_size=vocab_size, | |
| d_model=1_280, | |
| n_layers=23, | |
| n_heads=20, | |
| d_ff=3_456, | |
| max_seq_len=seq_len, | |
| dropout=0.0, | |
| tie_embeddings=True, | |
| ) | |
| model = Retriever500M(config).to(device) | |
| total_params = model.count_parameters() | |
| print(f"Model parameters: {total_params:,} ({total_params / 1e6:.1f}M)") | |
| optimizer = setup_optimizer(model, lr, use_8bit=use_8bit_adam) | |
| # Resume | |
| start_step = 0 | |
| best_loss = float("inf") | |
| accum_loss = 0.0 | |
| prev_log_steps = [] | |
| if resume: | |
| resume_path = os.path.join(REMOTE_CKPT, "latest.pt") | |
| if not os.path.exists(resume_path): | |
| resume_path = os.path.join(REMOTE_CKPT, "latest_bf16.pt") | |
| if not os.path.exists(resume_path): | |
| # Fall back to seed checkpoint in image | |
| resume_path = os.path.join(REMOTE_SEED_CKPT, "latest_bf16.pt") | |
| if os.path.exists(resume_path): | |
| print(f"Resuming from {resume_path}") | |
| ckpt = torch.load(resume_path, map_location=device, weights_only=False) | |
| model.load_state_dict(ckpt["model_state_dict"]) | |
| start_step = int(ckpt.get("step", 0)) | |
| best_loss = float(ckpt.get("loss", float("inf"))) | |
| accum_loss = best_loss | |
| print(f" Resuming at step {start_step} (best_loss={best_loss:.4f})") | |
| prev_log_path = os.path.join(REMOTE_LOGS, "training_log.json") | |
| if os.path.exists(prev_log_path): | |
| try: | |
| with open(prev_log_path, "r") as f: | |
| prev_log = json.load(f) | |
| prev_log_steps = prev_log.get("steps", []) | |
| print(f" Loaded {len(prev_log_steps)} previous log entries") | |
| except Exception: | |
| pass | |
| else: | |
| print("WARNING: No checkpoint found, starting from scratch!") | |
| # Training loop | |
| effective_batch = batch_size * grad_accum | |
| max_steps_total = start_step + steps | |
| print(f"\nTraining configuration:") | |
| print(f" Micro batch size: {batch_size}") | |
| print(f" Gradient accum: {grad_accum}") | |
| print(f" Effective batch: {effective_batch}") | |
| print(f" Sequence length: {seq_len}") | |
| print(f" Learning rate: {lr}") | |
| print(f" Steps this run: {steps}") | |
| print(f" Start step: {start_step}") | |
| print(f" Target step: {max_steps_total}") | |
| print() | |
| log = { | |
| "config": asdict(config), | |
| "train_args": {"steps": steps, "batch_size": batch_size, "grad_accum": grad_accum, | |
| "seq_len": seq_len, "lr": lr, "warmup": warmup, | |
| "save_every": save_every, "log_every": log_every, | |
| "corpus": corpus, "resume": resume, "use_8bit_adam": use_8bit_adam}, | |
| "total_params": total_params, | |
| "steps": list(prev_log_steps), | |
| } | |
| model.train() | |
| start_time = time.time() | |
| pbar = tqdm(range(start_step, max_steps_total), desc="Training", | |
| initial=start_step, total=max_steps_total) | |
| for step in pbar: | |
| lr_now = get_lr(step, warmup, max_steps_total, lr, lr * 0.1) | |
| for pg in optimizer.param_groups: | |
| pg["lr"] = lr_now | |
| optimizer.zero_grad(set_to_none=True) | |
| total_loss = 0.0 | |
| for _ in range(grad_accum): | |
| input_ids, targets = get_batch(tokens, batch_size, seq_len, device) | |
| with torch.autocast(device_type="cuda", dtype=torch.bfloat16): | |
| out = model(input_ids, targets=targets, use_checkpoint=False) | |
| loss = out["loss"] / grad_accum | |
| loss.backward() | |
| total_loss += loss.item() | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) | |
| optimizer.step() | |
| avg_loss = total_loss | |
| accum_loss = accum_loss * 0.95 + avg_loss * 0.05 | |
| if step % log_every == 0 or step == max_steps_total - 1: | |
| elapsed = time.time() - start_time | |
| steps_this_run = step - start_step + 1 | |
| steps_per_sec = steps_this_run / elapsed | |
| vram_used = torch.cuda.max_memory_allocated() / 1e9 | |
| log_entry = { | |
| "step": step, "loss": avg_loss, "ema_loss": accum_loss, | |
| "lr": lr_now, "elapsed_s": elapsed, | |
| "steps_per_sec": steps_per_sec, "vram_gb": vram_used, | |
| } | |
| log["steps"].append(log_entry) | |
| pbar.set_postfix({ | |
| "loss": f"{avg_loss:.4f}", "ema": f"{accum_loss:.4f}", | |
| "lr": f"{lr_now:.2e}", "vram": f"{vram_used:.1f}G", | |
| }) | |
| if (step + 1) % save_every == 0 or step == max_steps_total - 1: | |
| ckpt_path = os.path.join(REMOTE_CKPT, f"model_step_{step + 1}.pt") | |
| torch.save({ | |
| "model_state_dict": model.state_dict(), | |
| "optimizer_state_dict": optimizer.state_dict(), | |
| "config": asdict(config), | |
| "step": step + 1, "loss": accum_loss, | |
| }, ckpt_path) | |
| print(f"\n Saved checkpoint: {ckpt_path}") | |
| latest_path = os.path.join(REMOTE_CKPT, "latest.pt") | |
| torch.save({ | |
| "model_state_dict": model.state_dict(), | |
| "config": asdict(config), | |
| "step": step + 1, "loss": accum_loss, | |
| }, latest_path) | |
| if accum_loss < best_loss: | |
| best_loss = accum_loss | |
| best_path = os.path.join(REMOTE_CKPT, "best.pt") | |
| torch.save({ | |
| "model_state_dict": model.state_dict(), | |
| "config": asdict(config), | |
| "step": step + 1, "loss": accum_loss, | |
| }, best_path) | |
| # Save log | |
| log_path = os.path.join(REMOTE_LOGS, "training_log.json") | |
| with open(log_path, "w") as f: | |
| json.dump(log, f, indent=2) | |
| # Commit volume so checkpoints persist | |
| volume.commit() | |
| if step % 50 == 0: | |
| torch.cuda.reset_peak_memory_stats() | |
| # Final log save | |
| log_path = os.path.join(REMOTE_LOGS, "training_log.json") | |
| with open(log_path, "w") as f: | |
| json.dump(log, f, indent=2) | |
| volume.commit() | |
| total_time = time.time() - start_time | |
| print(f"\nPretraining complete!") | |
| print(f" Total time: {total_time:.1f}s ({total_time/60:.1f} min)") | |
| print(f" Final EMA loss: {accum_loss:.4f}") | |
| print(f" Best loss: {best_loss:.4f}") | |
| return accum_loss | |
| # βββ SFT βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Special token IDs | |
| SYSTEM_ID = 32000 | |
| USER_ID = 32001 | |
| ASSISTANT_ID = 32002 | |
| SEARCH_ID = 32003 | |
| RESULT_ID = 32004 | |
| EVIDENCE_ID = 32005 | |
| REASONING_ID = 32006 | |
| FINISH_ID = 32007 | |
| END_ID = 32008 | |
| def load_special_tokens(): | |
| global SYSTEM_ID, USER_ID, ASSISTANT_ID, SEARCH_ID, RESULT_ID | |
| global EVIDENCE_ID, REASONING_ID, FINISH_ID, END_ID | |
| path = os.path.join(REMOTE_TOKENIZER, "special_tokens.json") | |
| if os.path.exists(path): | |
| with open(path, "r") as f: | |
| data = json.load(f) | |
| ids = data["token_ids"] | |
| SYSTEM_ID = ids.get("<tool_call>", 32000) | |
| USER_ID = ids.get("<tool_call>", 32001) | |
| ASSISTANT_ID = ids.get("<tool_call>", 32002) | |
| SEARCH_ID = ids.get("<|search|>", 32003) | |
| RESULT_ID = ids.get("<|result|>", 32004) | |
| EVIDENCE_ID = ids.get("<|evidence|>", 32005) | |
| REASONING_ID = ids.get("<|reasoning|>", 32006) | |
| FINISH_ID = ids.get("<|finish|>", 32007) | |
| END_ID = ids.get("<|end|>", 32008) | |
| def format_trace_to_tokens(trace, tokenizer, max_seq_len=768): | |
| messages = trace["trace"] | |
| all_tokens = [] | |
| loss_mask = [] | |
| for msg in messages: | |
| role = msg["role"] | |
| content = msg["content"] | |
| if role == "system": | |
| tokens = [SYSTEM_ID] + tokenizer.encode(content).ids + [END_ID] | |
| all_tokens.extend(tokens) | |
| loss_mask.extend([0] * len(tokens)) | |
| elif role == "user": | |
| tokens = [USER_ID] + tokenizer.encode(content).ids + [END_ID] | |
| all_tokens.extend(tokens) | |
| loss_mask.extend([0] * len(tokens)) | |
| elif role == "assistant": | |
| tokens = [ASSISTANT_ID] + tokenizer.encode(content).ids + [END_ID] | |
| all_tokens.extend(tokens) | |
| loss_mask.extend([1] * len(tokens)) | |
| elif role == "result": | |
| if content: | |
| tokens = [RESULT_ID] + tokenizer.encode(content).ids + [END_ID] | |
| else: | |
| tokens = [RESULT_ID, END_ID] | |
| all_tokens.extend(tokens) | |
| loss_mask.extend([0] * len(tokens)) | |
| if len(all_tokens) > max_seq_len: | |
| all_tokens = all_tokens[:max_seq_len] | |
| loss_mask = loss_mask[:max_seq_len] | |
| return np.array(all_tokens, dtype=np.int32), np.array(loss_mask, dtype=np.int32) | |
| def load_sft_dataset(traces_path, tokenizer, max_seq_len=768): | |
| print(f"Loading SFT traces from {traces_path}...") | |
| dataset = [] | |
| with open(traces_path, "r", encoding="utf-8") as f: | |
| for line in f: | |
| trace = json.loads(line) | |
| ids, mask = format_trace_to_tokens(trace, tokenizer, max_seq_len) | |
| if len(ids) > 10: | |
| dataset.append((ids, mask)) | |
| print(f" Loaded {len(dataset):,} traces") | |
| return dataset | |
| def get_sft_batch(dataset, batch_size, seq_len, device): | |
| indices = np.random.randint(0, len(dataset), size=batch_size) | |
| input_ids_list = [] | |
| loss_mask_list = [] | |
| for idx in indices: | |
| ids, mask = dataset[idx] | |
| if len(ids) < seq_len: | |
| pad_len = seq_len - len(ids) | |
| ids = np.concatenate([ids, np.zeros(pad_len, dtype=np.int32)]) | |
| mask = np.concatenate([mask, np.zeros(pad_len, dtype=np.int32)]) | |
| else: | |
| ids = ids[:seq_len] | |
| mask = mask[:seq_len] | |
| input_ids_list.append(ids) | |
| loss_mask_list.append(mask) | |
| input_ids = torch.from_numpy(np.stack(input_ids_list)).long().to(device) | |
| loss_mask = torch.from_numpy(np.stack(loss_mask_list)).long().to(device) | |
| targets = torch.cat([input_ids[:, 1:], torch.zeros_like(input_ids[:, :1])], dim=1) | |
| return input_ids, targets, loss_mask | |
| def run_sft(steps, batch_size, grad_accum, seq_len, lr, warmup, | |
| save_every, log_every, use_8bit_adam): | |
| from torch import nn | |
| sys.path.insert(0, REMOTE_SRC) | |
| from model import ModelConfig, Retriever500M | |
| from tokenizers import Tokenizer | |
| load_special_tokens() | |
| device = torch.device("cuda") | |
| print(f"Device: {device}") | |
| print(f"GPU: {torch.cuda.get_device_name(0)}") | |
| print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.2f} GB") | |
| os.makedirs(REMOTE_CKPT, exist_ok=True) | |
| os.makedirs(REMOTE_LOGS, exist_ok=True) | |
| # Tokenizer (agent tokenizer with special tokens) | |
| tokenizer_path = os.path.join(REMOTE_TOKENIZER, "tokenizer_agent.json") | |
| tokenizer = Tokenizer.from_file(tokenizer_path) | |
| vocab_size = tokenizer.get_vocab_size() | |
| print(f"Vocab size: {vocab_size}") | |
| # Data | |
| traces_path = os.path.join(REMOTE_DATA, "sft_traces.jsonl") | |
| gold_path = os.path.join(REMOTE_DATA, "gold_traces.jsonl") | |
| dataset = load_sft_dataset(traces_path, tokenizer, seq_len) | |
| gold_dataset = load_sft_dataset(gold_path, tokenizer, seq_len) | |
| dataset.extend(gold_dataset) | |
| print(f" Total (with gold): {len(dataset):,}") | |
| # Model | |
| config = ModelConfig( | |
| vocab_size=vocab_size, | |
| d_model=1_280, n_layers=23, n_heads=20, d_ff=3_456, | |
| max_seq_len=seq_len, dropout=0.0, tie_embeddings=True, | |
| ) | |
| model = Retriever500M(config).to(device) | |
| # Load pretrained checkpoint | |
| ckpt_path = os.path.join(REMOTE_CKPT, "latest.pt") | |
| if not os.path.exists(ckpt_path): | |
| ckpt_path = os.path.join(REMOTE_CKPT, "latest_bf16.pt") | |
| if not os.path.exists(ckpt_path): | |
| ckpt_path = os.path.join(REMOTE_SEED_CKPT, "latest_bf16.pt") | |
| if os.path.exists(ckpt_path): | |
| print(f"Loading pretrained weights from {ckpt_path}...") | |
| ckpt = torch.load(ckpt_path, map_location=device, weights_only=False) | |
| old_config = ModelConfig(**ckpt["config"]) | |
| state_dict = ckpt["model_state_dict"] | |
| old_vocab = old_config.vocab_size | |
| if old_vocab != vocab_size: | |
| print(f" Vocab size mismatch: {old_vocab} -> {vocab_size}") | |
| old_weight = state_dict["token_embedding.weight"] | |
| d_model = old_weight.shape[1] | |
| new_weight = torch.zeros(vocab_size, d_model) | |
| new_weight[:old_vocab] = old_weight | |
| nn.init.normal_(new_weight[old_vocab:], mean=0.0, std=0.02) | |
| state_dict["token_embedding.weight"] = new_weight | |
| model.load_state_dict(state_dict) | |
| print(f" Loaded (step {ckpt.get('step', '?')}, loss {ckpt.get('loss', '?')})") | |
| else: | |
| print("WARNING: No checkpoint found, starting from scratch!") | |
| total_params = model.count_parameters() | |
| print(f"Model parameters: {total_params:,} ({total_params / 1e6:.1f}M)") | |
| optimizer = setup_optimizer(model, lr, use_8bit=use_8bit_adam) | |
| effective_batch = batch_size * grad_accum | |
| print(f"\nSFT configuration:") | |
| print(f" Batch size: {batch_size}") | |
| print(f" Grad accum: {grad_accum}") | |
| print(f" Effective batch: {effective_batch}") | |
| print(f" Sequence length: {seq_len}") | |
| print(f" Learning rate: {lr}") | |
| print(f" Steps: {steps}") | |
| print(f" Warmup: {warmup}") | |
| print() | |
| log = { | |
| "config": asdict(config), | |
| "train_args": {"steps": steps, "batch_size": batch_size, "grad_accum": grad_accum, | |
| "seq_len": seq_len, "lr": lr, "warmup": warmup, | |
| "save_every": save_every, "log_every": log_every, | |
| "use_8bit_adam": use_8bit_adam}, | |
| "total_params": total_params, | |
| "steps": [], | |
| } | |
| model.train() | |
| start_time = time.time() | |
| accum_loss = 0.0 | |
| best_loss = float("inf") | |
| pbar = tqdm(range(steps), desc="SFT") | |
| for step in pbar: | |
| lr_now = get_lr(step, warmup, steps, lr, lr * 0.1) | |
| for pg in optimizer.param_groups: | |
| pg["lr"] = lr_now | |
| optimizer.zero_grad(set_to_none=True) | |
| total_loss = 0.0 | |
| for _ in range(grad_accum): | |
| input_ids, targets, loss_mask = get_sft_batch( | |
| dataset, batch_size, seq_len, device | |
| ) | |
| with torch.autocast(device_type="cuda", dtype=torch.bfloat16): | |
| out = model(input_ids, targets=targets, use_checkpoint=True) | |
| logits = out["logits"] | |
| if loss_mask.sum() > 0: | |
| shifted_mask = loss_mask[:, 1:].contiguous() | |
| masked_logits = logits[:, :-1, :].contiguous() | |
| masked_targets = targets[:, :-1].contiguous() | |
| flat_logits = masked_logits.view(-1, masked_logits.size(-1)) | |
| flat_targets = masked_targets.view(-1) | |
| flat_mask = shifted_mask.view(-1).float() | |
| per_token_loss = F.cross_entropy( | |
| flat_logits, flat_targets, | |
| ignore_index=-100, reduction="none" | |
| ) | |
| masked_loss = (per_token_loss * flat_mask).sum() / flat_mask.sum().clamp(min=1) | |
| loss = masked_loss / grad_accum | |
| else: | |
| loss = out["loss"] / grad_accum | |
| loss.backward() | |
| total_loss += loss.item() | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) | |
| optimizer.step() | |
| avg_loss = total_loss | |
| accum_loss = accum_loss * 0.95 + avg_loss * 0.05 | |
| if step % log_every == 0 or step == steps - 1: | |
| elapsed = time.time() - start_time | |
| steps_per_sec = (step + 1) / elapsed | |
| vram_used = torch.cuda.max_memory_allocated() / 1e9 | |
| log_entry = { | |
| "step": step, "loss": avg_loss, "ema_loss": accum_loss, | |
| "lr": lr_now, "elapsed_s": elapsed, | |
| "steps_per_sec": steps_per_sec, "vram_gb": vram_used, | |
| } | |
| log["steps"].append(log_entry) | |
| pbar.set_postfix({ | |
| "loss": f"{avg_loss:.4f}", "ema": f"{accum_loss:.4f}", | |
| "lr": f"{lr_now:.2e}", "vram": f"{vram_used:.1f}G", | |
| }) | |
| if (step + 1) % save_every == 0 or step == steps - 1: | |
| ckpt_path = os.path.join(REMOTE_CKPT, f"sft_step_{step + 1}.pt") | |
| torch.save({ | |
| "model_state_dict": model.state_dict(), | |
| "optimizer_state_dict": optimizer.state_dict(), | |
| "config": asdict(config), | |
| "step": step + 1, "loss": accum_loss, | |
| }, ckpt_path) | |
| print(f"\n Saved checkpoint: {ckpt_path}") | |
| latest_path = os.path.join(REMOTE_CKPT, "sft_latest.pt") | |
| torch.save({ | |
| "model_state_dict": model.state_dict(), | |
| "config": asdict(config), | |
| "step": step + 1, "loss": accum_loss, | |
| }, latest_path) | |
| if accum_loss < best_loss: | |
| best_loss = accum_loss | |
| best_path = os.path.join(REMOTE_CKPT, "sft_best.pt") | |
| torch.save({ | |
| "model_state_dict": model.state_dict(), | |
| "config": asdict(config), | |
| "step": step + 1, "loss": accum_loss, | |
| }, best_path) | |
| log_path = os.path.join(REMOTE_LOGS, "sft_log.json") | |
| with open(log_path, "w") as f: | |
| json.dump(log, f, indent=2) | |
| volume.commit() | |
| if step % 50 == 0: | |
| torch.cuda.reset_peak_memory_stats() | |
| log_path = os.path.join(REMOTE_LOGS, "sft_log.json") | |
| with open(log_path, "w") as f: | |
| json.dump(log, f, indent=2) | |
| volume.commit() | |
| total_time = time.time() - start_time | |
| print(f"\nSFT complete!") | |
| print(f" Total time: {total_time:.1f}s ({total_time/60:.1f} min)") | |
| print(f" Final EMA loss: {accum_loss:.4f}") | |
| print(f" Best loss: {best_loss:.4f}") | |
| return accum_loss | |
| # βββ Modal entry points ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| GPU_CHOICES = {"a10g": "A10G", "a100": "A100", "h100": "H100"} | |
| def pretrain( | |
| steps=1000, | |
| batch_size=8, | |
| grad_accum=4, | |
| seq_len=512, | |
| lr=3e-4, | |
| warmup=100, | |
| save_every=200, | |
| log_every=10, | |
| corpus="curated", | |
| resume=True, | |
| use_8bit_adam=True, | |
| gpu="a10g", | |
| ): | |
| """Run pretraining on Modal GPU.""" | |
| return run_pretrain(steps, batch_size, grad_accum, seq_len, lr, warmup, | |
| save_every, log_every, corpus, resume, use_8bit_adam) | |
| def sft( | |
| steps=500, | |
| batch_size=4, | |
| grad_accum=8, | |
| seq_len=768, | |
| lr=5e-5, | |
| warmup=20, | |
| save_every=100, | |
| log_every=10, | |
| use_8bit_adam=True, | |
| gpu="a10g", | |
| ): | |
| """Run SFT on Modal GPU.""" | |
| return run_sft(steps, batch_size, grad_accum, seq_len, lr, warmup, | |
| save_every, log_every, use_8bit_adam) | |
| def pipeline(pretrain_steps=1000, sft_steps=500, gpu="a10g"): | |
| """Run pretraining then SFT in one go.""" | |
| print("=" * 60) | |
| print("PHASE 1: PRETRAINING") | |
| print("=" * 60) | |
| run_pretrain( | |
| steps=pretrain_steps, batch_size=8, grad_accum=4, seq_len=512, | |
| lr=3e-4, warmup=100, save_every=200, log_every=10, | |
| corpus="curated", resume=True, use_8bit_adam=True, | |
| ) | |
| print("\n" + "=" * 60) | |
| print("PHASE 2: SFT") | |
| print("=" * 60) | |
| run_sft( | |
| steps=sft_steps, batch_size=8, grad_accum=4, seq_len=768, | |
| lr=5e-5, warmup=20, save_every=100, log_every=10, | |
| use_8bit_adam=True, | |
| ) | |
| # βββ Local entry point for `modal run` βββββββββββββββββββββββββββββββββββββββ | |
| def main( | |
| command: str = "pipeline", | |
| steps: int = 1000, | |
| batch_size: int = 8, | |
| grad_accum: int = 4, | |
| seq_len: int = 512, | |
| lr: float = 3e-4, | |
| warmup: int = 100, | |
| save_every: int = 200, | |
| log_every: int = 10, | |
| corpus: str = "curated", | |
| resume: bool = True, | |
| use_8bit_adam: bool = True, | |
| gpu: str = "a10g", | |
| pretrain_steps: int = 1000, | |
| sft_steps: int = 500, | |
| ): | |
| if command == "seed": | |
| seed.remote() | |
| elif command == "pretrain": | |
| result = pretrain.remote( | |
| steps=steps, batch_size=batch_size, grad_accum=grad_accum, | |
| seq_len=seq_len, lr=lr, warmup=warmup, | |
| save_every=save_every, log_every=log_every, | |
| corpus=corpus, resume=resume, use_8bit_adam=use_8bit_adam, | |
| gpu=gpu, | |
| ) | |
| print(f"Pretraining final loss: {result}") | |
| elif command == "sft": | |
| result = sft.remote( | |
| steps=steps, batch_size=batch_size, grad_accum=grad_accum, | |
| seq_len=seq_len, lr=lr, warmup=warmup, | |
| save_every=save_every, log_every=log_every, | |
| use_8bit_adam=use_8bit_adam, gpu=gpu, | |
| ) | |
| print(f"SFT final loss: {result}") | |
| elif command == "download": | |
| download.remote() | |
| elif command == "pipeline": | |
| pipeline.remote(pretrain_steps=pretrain_steps, sft_steps=sft_steps, gpu=gpu) | |
| else: | |
| print(f"Unknown command: {command}") | |
| print("Available: upload, pretrain, sft, download, pipeline") | |