chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Chat / inference CLI for any stage checkpoint (base, SFT, DPO, PPO, GRPO).
|
||||
|
||||
Model dimensions are read from the checkpoint, so you only pass the path. Use the chat
|
||||
template for instruction-tuned models, or --raw for base-model continuation.
|
||||
|
||||
One-shot:
|
||||
PYTHONPATH=. python scripts/chat.py --ckpt /ephemeral/ckpts/sft.pt --prompt "What is 13 + 29?"
|
||||
PYTHONPATH=. python scripts/chat.py --ckpt /ephemeral/ckpts/grpo.pt --prompt "..." --greedy
|
||||
PYTHONPATH=. python scripts/chat.py --ckpt /ephemeral/ckpts/base_pretrained.pt --raw --prompt "Once upon a time"
|
||||
Interactive REPL (no --prompt):
|
||||
PYTHONPATH=. python scripts/chat.py --ckpt /ephemeral/ckpts/sft.pt
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
import torch
|
||||
|
||||
from src.post_training.inference import generate_reply, load_model_from_ckpt
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--ckpt", required=True)
|
||||
p.add_argument("--prompt", default=None, help="one-shot prompt; omit for interactive REPL")
|
||||
p.add_argument("--system", default=None, help="optional system message (chat mode)")
|
||||
p.add_argument("--raw", action="store_true", help="base-model continuation (no chat template)")
|
||||
p.add_argument("--max_new_tokens", type=int, default=256)
|
||||
p.add_argument("--temperature", type=float, default=0.8)
|
||||
p.add_argument("--top_p", type=float, default=0.95)
|
||||
p.add_argument("--top_k", type=int, default=None)
|
||||
p.add_argument("--greedy", action="store_true", help="deterministic argmax decoding")
|
||||
p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
||||
args = p.parse_args()
|
||||
|
||||
model = load_model_from_ckpt(args.ckpt, args.device)
|
||||
n = sum(p.numel() for p in model.parameters())
|
||||
print(f"loaded {args.ckpt} ({n/1e6:.0f}M params) on {args.device} | "
|
||||
f"mode={'raw' if args.raw else 'chat'} {'greedy' if args.greedy else f'T={args.temperature} top_p={args.top_p}'}")
|
||||
|
||||
def reply(text):
|
||||
return generate_reply(model, text, device=args.device, system=args.system, raw=args.raw,
|
||||
max_new_tokens=args.max_new_tokens, temperature=args.temperature,
|
||||
top_p=args.top_p if args.top_p < 1 else None, top_k=args.top_k, greedy=args.greedy)
|
||||
|
||||
if args.prompt is not None:
|
||||
print(reply(args.prompt))
|
||||
return
|
||||
|
||||
print("Interactive chat (Ctrl-D / 'exit' to quit).")
|
||||
while True:
|
||||
try:
|
||||
text = input("\nyou> ").strip()
|
||||
except EOFError:
|
||||
break
|
||||
if text in ("exit", "quit"):
|
||||
break
|
||||
if text:
|
||||
print("bot>", reply(text))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
import argparse
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
from typing import List
|
||||
|
||||
# Base URL for the dataset files
|
||||
BASE_URL = "https://huggingface.co/datasets/monology/pile-uncopyrighted/resolve/main"
|
||||
VAL_URL = f"{BASE_URL}/val.jsonl.zst" # URL for the validation dataset
|
||||
TRAIN_URLS = [f"{BASE_URL}/train/{i:02d}.jsonl.zst" for i in range(65)] # URLs for 65 training files (adjust the range if needed)
|
||||
|
||||
def download_file(url: str, file_name: str) -> None:
|
||||
"""
|
||||
Downloads a file from the given URL and saves it with the specified file name.
|
||||
Displays a progress bar using tqdm.
|
||||
|
||||
Args:
|
||||
url (str): The URL of the file to download.
|
||||
file_name (str): The local path where the file will be saved.
|
||||
"""
|
||||
print(f"Downloading: {file_name}...")
|
||||
response = requests.get(url, stream=True) # Stream the file content
|
||||
total_size = int(response.headers.get('content-length', 0)) # Get total file size if available
|
||||
block_size = 1024 # Size of each block for the progress bar
|
||||
with open(file_name, 'wb') as f: # Open file for writing in binary mode
|
||||
for chunk in tqdm(response.iter_content(block_size), total=total_size // block_size, desc="Downloading", leave=True):
|
||||
f.write(chunk) # Write each chunk to the file
|
||||
|
||||
def download_dataset(val_url: str, train_urls: List[str], val_dir: str, train_dir: str, max_train_files: int) -> None:
|
||||
"""
|
||||
Manages downloading of the dataset, including both validation and training files.
|
||||
|
||||
Args:
|
||||
val_url (str): URL for the validation dataset.
|
||||
train_urls (list): List of URLs for the training dataset files.
|
||||
val_dir (str): Directory where the validation file will be stored.
|
||||
train_dir (str): Directory where the training files will be stored.
|
||||
max_train_files (int): Maximum number of training files to download.
|
||||
"""
|
||||
# Define the path for the validation file
|
||||
val_file_path = os.path.join(val_dir, "val.jsonl.zst")
|
||||
if not os.path.exists(val_file_path): # Check if the validation file already exists
|
||||
print(f"Validation file not found. Downloading from {val_url}...")
|
||||
download_file(val_url, val_file_path) # Download the validation file
|
||||
else:
|
||||
print("Validation data already present. Skipping download.")
|
||||
|
||||
# Loop through the training file URLs and download if not already present
|
||||
for idx, url in enumerate(train_urls[:max_train_files]): # Limit to max_train_files
|
||||
file_name = f"{idx:02d}.jsonl.zst" # Format file name (e.g., 00.jsonl.zst)
|
||||
file_path = os.path.join(train_dir, file_name) # Construct the full file path
|
||||
if not os.path.exists(file_path): # Check if the file already exists
|
||||
print(f"Training file {file_name} not found. Downloading...")
|
||||
download_file(url, file_path) # Download the training file
|
||||
else:
|
||||
print(f"Training file {file_name} already present. Skipping download.")
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Main function to parse arguments and orchestrate the dataset download process.
|
||||
"""
|
||||
# Parse command-line arguments using argparse
|
||||
parser = argparse.ArgumentParser(description="Download PILE dataset.") # Description of the script
|
||||
parser.add_argument('--train_max', type=int, default=1, help="Max number of training files to download.") # Max training files
|
||||
parser.add_argument('--train_dir', default="data/train", help="Directory for storing training data.") # Training directory
|
||||
parser.add_argument('--val_dir', default="data/val", help="Directory for storing validation data.") # Validation directory
|
||||
|
||||
args = parser.parse_args() # Parse the arguments provided by the user
|
||||
|
||||
# Ensure directories for training and validation data exist
|
||||
os.makedirs(args.train_dir, exist_ok=True) # Create training directory if it doesn't exist
|
||||
os.makedirs(args.val_dir, exist_ok=True) # Create validation directory if it doesn't exist
|
||||
|
||||
# Start downloading the dataset
|
||||
download_dataset(VAL_URL, TRAIN_URLS, args.val_dir, args.train_dir, args.train_max)
|
||||
|
||||
print("Dataset downloaded successfully.") # Indicate successful download
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Entry point of the script
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
import os
|
||||
import json
|
||||
import zstandard as zstd
|
||||
import tiktoken
|
||||
import h5py
|
||||
from tqdm import tqdm
|
||||
import argparse
|
||||
from typing import Optional
|
||||
|
||||
def process_files(input_dir: str, output_file: str, tokenizer_name: str, max_data: Optional[int] = None) -> None:
|
||||
"""
|
||||
Process a specified number of lines from each .jsonl.zst file in the input directory
|
||||
and save encoded tokens to an HDF5 file.
|
||||
|
||||
Args:
|
||||
input_dir (str): Directory containing input .jsonl.zst files.
|
||||
output_file (str): Path to the output HDF5 file.
|
||||
tokenizer_name (str): Name of the tiktoken tokenizer to use (e.g., 'r50k_base').
|
||||
max_data (int, optional): Maximum number of lines to process from each file.
|
||||
If None, process all lines.
|
||||
"""
|
||||
# Print processing strategy based on max_data
|
||||
if max_data is not None:
|
||||
print(f"You have chosen max_data = {max_data}. Processing only the top {max_data} JSON objects from each file.")
|
||||
else:
|
||||
print("Processing all available JSON objects from each file.")
|
||||
|
||||
# Load the tokenizer using the provided tokenizer name
|
||||
enc = tiktoken.get_encoding(tokenizer_name)
|
||||
|
||||
# Create an HDF5 file for output
|
||||
with h5py.File(output_file, 'w') as out_f:
|
||||
# Initialize the dataset for storing tokenized data
|
||||
dataset = out_f.create_dataset('tokens', (0,), maxshape=(None,), dtype='i')
|
||||
start_index = 0 # Track the starting index for the next batch of tokens
|
||||
|
||||
# Process each .jsonl.zst file in the input directory
|
||||
for filename in sorted(os.listdir(input_dir)):
|
||||
if filename.endswith(".jsonl.zst"): # Only process .jsonl.zst files
|
||||
in_file = os.path.join(input_dir, filename)
|
||||
print(f"Processing: {in_file}")
|
||||
|
||||
processed_lines = 0 # Counter for processed lines in the current file
|
||||
|
||||
# Open the compressed .jsonl.zst file for reading
|
||||
with zstd.open(in_file, 'rt', encoding='utf-8') as in_f:
|
||||
# Iterate over each line in the file
|
||||
for line in tqdm(in_f, desc=f"Processing {filename}", total=max_data if max_data is not None else None):
|
||||
try:
|
||||
# Parse the line as JSON
|
||||
data = json.loads(line)
|
||||
text = data.get('text') # Extract the 'text' field from the JSON object
|
||||
|
||||
if text:
|
||||
# Tokenize the text and append an end-of-text token
|
||||
encoded = enc.encode(text + "<|endoftext|>", allowed_special={'<|endoftext|>'})
|
||||
encoded_len = len(encoded)
|
||||
|
||||
# Resize the dataset to accommodate new tokens
|
||||
end_index = start_index + encoded_len
|
||||
dataset.resize(dataset.shape[0] + encoded_len, axis=0)
|
||||
|
||||
# Store the encoded tokens in the dataset
|
||||
dataset[start_index:end_index] = encoded
|
||||
start_index = end_index # Update the start index
|
||||
else:
|
||||
# Warn if 'text' key is missing in the JSON object
|
||||
print(f"Warning: 'text' key missing in line from {filename}")
|
||||
except json.JSONDecodeError:
|
||||
# Handle JSON decoding errors
|
||||
print(f"Warning: Could not decode JSON from line in {filename}")
|
||||
except Exception as e:
|
||||
# Handle any other errors
|
||||
print(f"An error occurred while processing line in {filename}: {e}")
|
||||
|
||||
processed_lines += 1
|
||||
# Stop processing if max_data limit is reached
|
||||
if max_data is not None and processed_lines >= max_data:
|
||||
break
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main function to parse arguments, validate directories, and process files.
|
||||
"""
|
||||
# Parse command-line arguments
|
||||
parser = argparse.ArgumentParser(description="Preprocess PILE dataset files and save tokens to HDF5.")
|
||||
parser.add_argument("--train_dir", type=str, default="data/train", help="Directory containing training .jsonl.zst files.")
|
||||
parser.add_argument("--val_dir", type=str, default="data/val", help="Directory containing validation .jsonl.zst files.")
|
||||
parser.add_argument("--out_train_file", type=str, default="data/train/pile_train.h5", help="Path to the output training HDF5 file.")
|
||||
parser.add_argument("--out_val_file", type=str, default="data/val/pile_dev.h5", help="Path to the output validation HDF5 file.")
|
||||
parser.add_argument("--tokenizer_name", type=str, default="r50k_base", help="Name of the tiktoken tokenizer to use.")
|
||||
parser.add_argument("--max_data", type=int, default=1000, help="Maximum number of json objects to process from each file in both train and val datasets (default: 1000).")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate the existence of the training and validation directories
|
||||
if not os.path.isdir(args.train_dir):
|
||||
print(f"Error: Training directory not found: {args.train_dir}")
|
||||
return
|
||||
if not os.path.isdir(args.val_dir):
|
||||
print(f"Error: Validation directory not found: {args.val_dir}")
|
||||
return
|
||||
|
||||
# Process training data
|
||||
print("Starting training data preprocessing...")
|
||||
process_files(args.train_dir, args.out_train_file, args.tokenizer_name, args.max_data)
|
||||
print("Training data preprocessing complete.")
|
||||
|
||||
# Process validation data
|
||||
print("Starting validation data preprocessing...")
|
||||
process_files(args.val_dir, args.out_val_file, args.tokenizer_name, args.max_data)
|
||||
print("Validation data preprocessing complete.")
|
||||
|
||||
# Entry point of the script
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Evaluate any stage checkpoint on GSM8K (greedy) and optionally dump sample generations.
|
||||
Use it to build the headline "GSM8K accuracy across stages" table:
|
||||
|
||||
for s in base_pretrained sft dpo ppo grpo; do
|
||||
PYTHONPATH=. python scripts/eval_post_training.py --ckpt /ephemeral/ckpts/$s.pt \
|
||||
--label $s --limit 200 --append /ephemeral/logs/stage_table.jsonl
|
||||
done
|
||||
PYTHONPATH=. python scripts/eval_post_training.py --table /ephemeral/logs/stage_table.jsonl
|
||||
|
||||
Model dimensions are read from the checkpoint's stored ``cfg`` so you don't have to repeat
|
||||
them. Reward checkpoints (which have a reward head, not an LM head only) still load because
|
||||
we keep just the backbone keys for generation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
import torch
|
||||
|
||||
from src.models.transformer import Transformer
|
||||
from src.post_training.evaluation import gsm8k_accuracy, load_gsm8k_eval
|
||||
|
||||
|
||||
def model_from_ckpt(ckpt_path: str, device: str, overrides: dict | None = None) -> Transformer:
|
||||
ck = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
||||
cfg = ck.get("cfg", {}) or {}
|
||||
cfg = {**cfg, **(overrides or {})}
|
||||
model = Transformer(
|
||||
n_head=cfg.get("n_head", 16), n_embed=cfg.get("n_embed", 1024),
|
||||
context_length=cfg.get("context_length", 1024), vocab_size=cfg.get("vocab_size", 50304),
|
||||
N_BLOCKS=cfg.get("n_blocks", 24),
|
||||
)
|
||||
state = ck["model_state_dict"] if "model_state_dict" in ck else ck
|
||||
state = {k.removeprefix("module.").removeprefix("transformer."): v for k, v in state.items()}
|
||||
backbone_keys = set(model.state_dict().keys())
|
||||
filtered = {k: v for k, v in state.items() if k in backbone_keys}
|
||||
model.load_state_dict(filtered, strict=False)
|
||||
return model.to(device).eval()
|
||||
|
||||
|
||||
def print_table(path: str):
|
||||
rows = [json.loads(l) for l in open(path) if l.strip()]
|
||||
print(f"\n{'stage':<18}{'GSM8K acc':>10}{'n':>8}")
|
||||
print("-" * 36)
|
||||
for r in rows:
|
||||
print(f"{r['label']:<18}{r['accuracy']*100:>9.1f}%{r['n']:>8}")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--ckpt")
|
||||
p.add_argument("--label", default="model")
|
||||
p.add_argument("--limit", type=int, default=200)
|
||||
p.add_argument("--split", default="test")
|
||||
p.add_argument("--max_new_tokens", type=int, default=300)
|
||||
p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
||||
p.add_argument("--samples", type=int, default=3)
|
||||
p.add_argument("--append", default=None, help="append the result row to this JSONL")
|
||||
p.add_argument("--table", default=None, help="just print a stage table from this JSONL and exit")
|
||||
args = p.parse_args()
|
||||
|
||||
if args.table:
|
||||
print_table(args.table)
|
||||
return
|
||||
|
||||
model = model_from_ckpt(args.ckpt, args.device)
|
||||
qa = load_gsm8k_eval(args.split, limit=args.limit)
|
||||
res = gsm8k_accuracy(model, qa, device=args.device, max_new_tokens=args.max_new_tokens,
|
||||
greedy=True, return_samples=args.samples)
|
||||
print(f"[{args.label}] GSM8K {args.split} accuracy: {res['accuracy']*100:.1f}% ({res['correct']}/{res['n']})")
|
||||
for s in res["samples"]:
|
||||
print(f"\n Q: {s['q'][:120]}\n gold={s['gold']} correct={s['correct']}\n A: {s['response'][:300]}")
|
||||
|
||||
if args.append:
|
||||
os.makedirs(os.path.dirname(args.append) or ".", exist_ok=True)
|
||||
with open(args.append, "a") as f:
|
||||
f.write(json.dumps({"label": args.label, "accuracy": res["accuracy"],
|
||||
"correct": res["correct"], "n": res["n"]}) + "\n")
|
||||
print(f"\nappended -> {args.append}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,68 @@
|
||||
import torch
|
||||
import tiktoken
|
||||
import argparse
|
||||
from config.config import default_config as config
|
||||
from src.models.transformer import Transformer # Assuming your Transformer class is in this module
|
||||
|
||||
|
||||
def load_checkpoint(model_path: str, device: str):
|
||||
try:
|
||||
return torch.load(model_path, map_location=torch.device(device), weights_only=False)
|
||||
except TypeError:
|
||||
return torch.load(model_path, map_location=torch.device(device))
|
||||
|
||||
def generate_text(model_path: str, input_text: str, max_new_tokens: int = 100, device: str = 'cuda') -> str:
|
||||
"""
|
||||
Generates text using a pre-trained Transformer model.
|
||||
|
||||
Args:
|
||||
model_path (str): Path to the saved model checkpoint.
|
||||
input_text (str): The initial text to start generation from.
|
||||
max_new_tokens (int): The maximum number of new tokens to generate.
|
||||
device (str): 'cuda' or 'cpu', the device to run the model on.
|
||||
|
||||
Returns:
|
||||
str: The generated text.
|
||||
"""
|
||||
# Load the model checkpoint
|
||||
checkpoint = load_checkpoint(model_path, device)
|
||||
|
||||
# Initialize the model using the configuration from config.py
|
||||
model = Transformer(
|
||||
n_head=config['n_head'],
|
||||
n_embed=config['n_embed'],
|
||||
context_length=config['context_length'],
|
||||
vocab_size=config['vocab_size'],
|
||||
N_BLOCKS=config['n_blocks']
|
||||
)
|
||||
model.load_state_dict(checkpoint['model_state_dict'])
|
||||
model.eval().to(device)
|
||||
|
||||
# Load the tokenizer
|
||||
enc = tiktoken.get_encoding("r50k_base")
|
||||
|
||||
start_ids = enc.encode_ordinary(input_text)
|
||||
context = torch.tensor(start_ids, dtype=torch.long, device=device).unsqueeze(0)
|
||||
|
||||
# Generation process
|
||||
with torch.no_grad():
|
||||
generated_tokens = model.generate(context, max_new_tokens=max_new_tokens)[0].tolist()
|
||||
|
||||
# Decode the generated tokens
|
||||
output_text = enc.decode(generated_tokens)
|
||||
|
||||
return output_text
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate text using a pre-trained Transformer model.")
|
||||
parser.add_argument('--model_path', type=str, help='Path to the saved model checkpoint.')
|
||||
parser.add_argument('--input_text', type=str, help='The initial text to start generation from.')
|
||||
parser.add_argument('--max_new_tokens', type=int, default=100, help='Maximum number of new tokens to generate.')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
generated = generate_text(args.model_path, args.input_text, args.max_new_tokens, config['device'])
|
||||
print(f"Generated text:\n{generated}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Build preference data for the reward model and DPO from real public datasets:
|
||||
- Anthropic/hh-rlhf (helpful/harmless human preferences)
|
||||
- HuggingFaceH4/ultrafeedback_binarized (LLM-judged preference pairs)
|
||||
|
||||
Emits JSONL of ``{"prompt", "chosen", "rejected"}`` (train + held-out test). The held-out
|
||||
test split is what reward-model preference accuracy is measured on.
|
||||
|
||||
Example:
|
||||
PYTHONPATH=. HF_HOME=/ephemeral/hf_cache python scripts/prepare_preference_data.py \
|
||||
--source both --max_per_source 40000 --out_dir /ephemeral/data
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
os.environ.setdefault("HF_HOME", "/ephemeral/hf_cache")
|
||||
|
||||
_ASSISTANT_MARKER = "\n\nAssistant:"
|
||||
|
||||
|
||||
def _split_hh(text: str) -> tuple[str, str] | None:
|
||||
"""Split an HH-RLHF conversation string into (prompt_context, final_response)."""
|
||||
idx = text.rfind(_ASSISTANT_MARKER)
|
||||
if idx == -1:
|
||||
return None
|
||||
prompt = text[:idx].strip()
|
||||
response = text[idx + len(_ASSISTANT_MARKER):].strip()
|
||||
if not prompt or not response:
|
||||
return None
|
||||
return prompt, response
|
||||
|
||||
|
||||
def from_hh(max_n: int, split: str) -> list[dict]:
|
||||
from datasets import load_dataset
|
||||
|
||||
ds = load_dataset("Anthropic/hh-rlhf", split=split)
|
||||
if max_n:
|
||||
ds = ds.select(range(min(max_n, len(ds))))
|
||||
out = []
|
||||
for ex in ds:
|
||||
c = _split_hh(ex["chosen"]); r = _split_hh(ex["rejected"])
|
||||
if not c or not r:
|
||||
continue
|
||||
prompt, chosen = c
|
||||
_, rejected = r
|
||||
if chosen == rejected:
|
||||
continue
|
||||
out.append({"prompt": prompt, "chosen": chosen, "rejected": rejected})
|
||||
return out
|
||||
|
||||
|
||||
def from_ultrafeedback(max_n: int, split: str) -> list[dict]:
|
||||
from datasets import load_dataset
|
||||
|
||||
hf_split = "train_prefs" if split == "train" else "test_prefs"
|
||||
ds = load_dataset("HuggingFaceH4/ultrafeedback_binarized", split=hf_split)
|
||||
if max_n:
|
||||
ds = ds.select(range(min(max_n, len(ds))))
|
||||
out = []
|
||||
for ex in ds:
|
||||
prompt = ex["prompt"].strip()
|
||||
chosen = ex["chosen"][-1]["content"].strip()
|
||||
rejected = ex["rejected"][-1]["content"].strip()
|
||||
if not prompt or not chosen or not rejected or chosen == rejected:
|
||||
continue
|
||||
out.append({"prompt": prompt, "chosen": chosen, "rejected": rejected})
|
||||
return out
|
||||
|
||||
|
||||
def collect(source: str, max_n: int, split: str) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
if source in ("hh", "both"):
|
||||
print(f"Loading Anthropic/hh-rlhf [{split}] ...")
|
||||
rows += from_hh(max_n, split)
|
||||
if source in ("ultrafeedback", "both"):
|
||||
print(f"Loading ultrafeedback_binarized [{split}] ...")
|
||||
try:
|
||||
rows += from_ultrafeedback(max_n, split)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" (skipping ultrafeedback: {e})")
|
||||
return rows
|
||||
|
||||
|
||||
def write_jsonl(rows: list[dict], path: str) -> None:
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r) + "\n")
|
||||
print(f" wrote {len(rows)} pairs -> {path}")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--source", choices=["hh", "ultrafeedback", "both"], default="both")
|
||||
p.add_argument("--max_per_source", type=int, default=40000)
|
||||
p.add_argument("--out_dir", default="/ephemeral/data")
|
||||
args = p.parse_args()
|
||||
|
||||
train = collect(args.source, args.max_per_source, "train")
|
||||
test = collect(args.source, max(2000, args.max_per_source // 20), "test")
|
||||
import random
|
||||
random.Random(0).shuffle(train)
|
||||
write_jsonl(train, os.path.join(args.out_dir, "preferences.jsonl"))
|
||||
write_jsonl(test, os.path.join(args.out_dir, "preferences_test.jsonl"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Download + tokenize Pile-uncopyrighted shards into a single flat-token HDF5 for
|
||||
pretraining. Faster than the original ``data_preprocess.py`` (which resizes the HDF5 per
|
||||
document): here we stream-decompress, batch-tokenize with tiktoken, and write tokens to
|
||||
the HDF5 in large chunks.
|
||||
|
||||
Writes to /ephemeral by default (the 1.5TB disk).
|
||||
|
||||
Examples:
|
||||
# dev split from the Pile validation file
|
||||
PYTHONPATH=. python scripts/prepare_pretrain_data.py --split val \
|
||||
--out /ephemeral/data/pile_dev.h5
|
||||
# one training shard
|
||||
PYTHONPATH=. python scripts/prepare_pretrain_data.py --split train --num_shards 1 \
|
||||
--out /ephemeral/data/pile_train.h5
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
import requests
|
||||
import tiktoken
|
||||
import zstandard as zstd
|
||||
from tqdm import tqdm
|
||||
|
||||
BASE_URL = "https://huggingface.co/datasets/monology/pile-uncopyrighted/resolve/main"
|
||||
EOT_ID = 50256
|
||||
WRITE_CHUNK = 8_000_000 # flush tokens to HDF5 in ~8M-token chunks
|
||||
ENC_BATCH = 1024 # documents per tiktoken batch-encode
|
||||
|
||||
|
||||
def shard_urls(split: str, num_shards: int) -> list[str]:
|
||||
if split == "val":
|
||||
return [f"{BASE_URL}/val.jsonl.zst"]
|
||||
return [f"{BASE_URL}/train/{i:02d}.jsonl.zst" for i in range(num_shards)]
|
||||
|
||||
|
||||
def download(url: str, dest: str) -> str:
|
||||
if os.path.exists(dest):
|
||||
print(f" cached: {dest}")
|
||||
return dest
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
print(f" downloading {url}")
|
||||
with requests.get(url, stream=True) as r:
|
||||
r.raise_for_status()
|
||||
total = int(r.headers.get("content-length", 0))
|
||||
with open(dest + ".part", "wb") as f:
|
||||
for chunk in tqdm(r.iter_content(1 << 20), total=total >> 20, unit="MB", desc="dl"):
|
||||
f.write(chunk)
|
||||
os.replace(dest + ".part", dest)
|
||||
return dest
|
||||
|
||||
|
||||
def iter_texts(zst_path: str):
|
||||
dctx = zstd.ZstdDecompressor()
|
||||
with open(zst_path, "rb") as fh:
|
||||
reader = dctx.stream_reader(fh)
|
||||
for line in io.TextIOWrapper(reader, encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
txt = json.loads(line).get("text")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if txt:
|
||||
yield txt
|
||||
|
||||
|
||||
def tokenize_to_h5(zst_paths: list[str], out_path: str, max_tokens: int | None) -> int:
|
||||
enc = tiktoken.get_encoding("r50k_base")
|
||||
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
||||
total = 0
|
||||
buf: list[int] = []
|
||||
with h5py.File(out_path, "w") as f:
|
||||
dset = f.create_dataset("tokens", (0,), maxshape=(None,), dtype="i4", chunks=(WRITE_CHUNK,))
|
||||
|
||||
def flush():
|
||||
nonlocal total, buf
|
||||
if not buf:
|
||||
return
|
||||
arr = np.asarray(buf, dtype=np.int32)
|
||||
dset.resize(total + arr.size, axis=0)
|
||||
dset[total: total + arr.size] = arr
|
||||
total += arr.size
|
||||
buf = []
|
||||
|
||||
for zp in zst_paths:
|
||||
print(f" tokenizing {zp}")
|
||||
docs: list[str] = []
|
||||
pbar = tqdm(iter_texts(zp), unit="doc", desc="tok")
|
||||
for txt in pbar:
|
||||
docs.append(txt)
|
||||
if len(docs) >= ENC_BATCH:
|
||||
for ids in enc.encode_ordinary_batch(docs):
|
||||
buf.extend(ids)
|
||||
buf.append(EOT_ID)
|
||||
docs = []
|
||||
if len(buf) >= WRITE_CHUNK:
|
||||
flush()
|
||||
pbar.set_postfix(tokens=f"{total/1e6:.1f}M")
|
||||
if max_tokens and total >= max_tokens:
|
||||
break
|
||||
if docs:
|
||||
for ids in enc.encode_ordinary_batch(docs):
|
||||
buf.extend(ids)
|
||||
buf.append(EOT_ID)
|
||||
flush()
|
||||
if max_tokens and total >= max_tokens:
|
||||
break
|
||||
print(f" wrote {total:,} tokens -> {out_path}")
|
||||
return total
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--split", choices=["train", "val"], required=True)
|
||||
p.add_argument("--num_shards", type=int, default=1, help="train shards to use")
|
||||
p.add_argument("--raw_dir", default="/ephemeral/data/pile_raw")
|
||||
p.add_argument("--out", required=True)
|
||||
p.add_argument("--max_tokens", type=int, default=None, help="stop after this many tokens")
|
||||
args = p.parse_args()
|
||||
|
||||
urls = shard_urls(args.split, args.num_shards)
|
||||
local = []
|
||||
for u in urls:
|
||||
name = "val.jsonl.zst" if args.split == "val" else os.path.basename(u)
|
||||
local.append(download(u, os.path.join(args.raw_dir, name)))
|
||||
tokenize_to_h5(local, args.out, args.max_tokens)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Build RL prompt sets ({"prompt", "gold"}) for PPO/GRPO:
|
||||
- GSM8K train -> rl_prompts_train.jsonl (the RL training prompts)
|
||||
- GSM8K test -> rl_prompts_test.jsonl (held-out benchmark for eval)
|
||||
- a programmatic arithmetic set -> arithmetic_prompts.jsonl (RL curriculum warm-up,
|
||||
where even a weak model gets some non-zero reward so RL has signal to start from)
|
||||
|
||||
Example:
|
||||
PYTHONPATH=. HF_HOME=/ephemeral/hf_cache python scripts/prepare_rl_prompts.py --out_dir /ephemeral/data
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
|
||||
os.environ.setdefault("HF_HOME", "/ephemeral/hf_cache")
|
||||
|
||||
from src.post_training.rewards import gsm8k_gold_answer
|
||||
|
||||
|
||||
def write_jsonl(rows, path):
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r) + "\n")
|
||||
print(f" wrote {len(rows)} prompts -> {path}")
|
||||
|
||||
|
||||
def gsm8k_prompts(split: str, limit: int | None):
|
||||
from datasets import load_dataset
|
||||
|
||||
ds = load_dataset("openai/gsm8k", "main", split=split)
|
||||
if limit:
|
||||
ds = ds.select(range(min(limit, len(ds))))
|
||||
rows = []
|
||||
for ex in ds:
|
||||
gold = gsm8k_gold_answer(ex["answer"])
|
||||
if gold is not None:
|
||||
rows.append({"prompt": ex["question"].strip(), "gold": gold})
|
||||
return rows
|
||||
|
||||
|
||||
def arithmetic_prompts(n: int, max_val: int, seed: int):
|
||||
rng = random.Random(seed)
|
||||
ops = [("+", lambda a, b: a + b), ("-", lambda a, b: a - b), ("*", lambda a, b: a * b)]
|
||||
rows = []
|
||||
for _ in range(n):
|
||||
a, b = rng.randint(0, max_val), rng.randint(0, max_val)
|
||||
sym, fn = rng.choice(ops)
|
||||
rows.append({"prompt": f"What is {a} {sym} {b}?", "gold": float(fn(a, b))})
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--out_dir", default="/ephemeral/data")
|
||||
p.add_argument("--train_limit", type=int, default=None)
|
||||
p.add_argument("--test_limit", type=int, default=500)
|
||||
p.add_argument("--arith_n", type=int, default=5000)
|
||||
p.add_argument("--arith_max", type=int, default=20)
|
||||
args = p.parse_args()
|
||||
|
||||
print("Loading GSM8K train ...")
|
||||
write_jsonl(gsm8k_prompts("train", args.train_limit), os.path.join(args.out_dir, "rl_prompts_train.jsonl"))
|
||||
print("Loading GSM8K test ...")
|
||||
write_jsonl(gsm8k_prompts("test", args.test_limit), os.path.join(args.out_dir, "rl_prompts_test.jsonl"))
|
||||
print("Generating arithmetic curriculum ...")
|
||||
write_jsonl(arithmetic_prompts(args.arith_n, args.arith_max, seed=0),
|
||||
os.path.join(args.out_dir, "arithmetic_prompts.jsonl"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Build the SFT dataset from real public instruction data + GSM8K, render it through the
|
||||
chat template (masking prompt tokens), pack to fixed-length rows, and write a packed
|
||||
HDF5 (``tokens`` + ``loss_mask``) plus a held-out dev file.
|
||||
|
||||
Datasets (downloaded via HuggingFace ``datasets`` to /ephemeral/hf_cache):
|
||||
- tatsu-lab/alpaca (general instruction following)
|
||||
- databricks/databricks-dolly-15k (general instruction following)
|
||||
- openai/gsm8k (main, train) (math, reformatted into <think>/<answer> so the model
|
||||
learns the reasoning format the RL verifier rewards)
|
||||
|
||||
Example:
|
||||
PYTHONPATH=. HF_HOME=/ephemeral/hf_cache python scripts/prepare_sft_data.py \
|
||||
--context_length 1024 --out_dir /ephemeral/data
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
|
||||
from src.post_training.chat_template import encode_chat, ANSWER_OPEN, ANSWER_CLOSE, THINK_OPEN, THINK_CLOSE
|
||||
from src.post_training.sft import pack_examples
|
||||
|
||||
os.environ.setdefault("HF_HOME", "/ephemeral/hf_cache")
|
||||
_CALC_RE = re.compile(r"<<[^>]*>>") # GSM8K calculator annotations
|
||||
_HASH_RE = re.compile(r"####\s*(.+)\s*$")
|
||||
|
||||
|
||||
def gsm8k_to_messages(question: str, answer: str) -> list[dict]:
|
||||
"""Reformat a GSM8K (question, answer) into chat messages whose assistant turn uses
|
||||
the <think>...</think><answer>N</answer> structure."""
|
||||
answer = _CALC_RE.sub("", answer).strip()
|
||||
m = _HASH_RE.search(answer)
|
||||
final = m.group(1).strip() if m else answer
|
||||
reasoning = _HASH_RE.sub("", answer).strip()
|
||||
completion = f"{THINK_OPEN}{reasoning}{THINK_CLOSE}{ANSWER_OPEN}{final}{ANSWER_CLOSE}"
|
||||
return [{"role": "user", "content": question.strip()},
|
||||
{"role": "assistant", "content": completion}]
|
||||
|
||||
|
||||
def alpaca_to_messages(ex: dict) -> list[dict]:
|
||||
instr = ex["instruction"].strip()
|
||||
inp = (ex.get("input") or "").strip()
|
||||
user = f"{instr}\n\n{inp}" if inp else instr
|
||||
return [{"role": "user", "content": user}, {"role": "assistant", "content": ex["output"].strip()}]
|
||||
|
||||
|
||||
def dolly_to_messages(ex: dict) -> list[dict]:
|
||||
instr = ex["instruction"].strip()
|
||||
ctx = (ex.get("context") or "").strip()
|
||||
user = f"{instr}\n\n{ctx}" if ctx else instr
|
||||
return [{"role": "user", "content": user}, {"role": "assistant", "content": ex["response"].strip()}]
|
||||
|
||||
|
||||
def collect_examples(context_length: int, limit_per_set: int | None) -> list[tuple[list[int], list[int]]]:
|
||||
from datasets import load_dataset
|
||||
|
||||
examples: list[tuple[list[int], list[int]]] = []
|
||||
n_kept = n_skipped = 0
|
||||
|
||||
def add(messages):
|
||||
nonlocal n_kept, n_skipped
|
||||
ids, mask = encode_chat(messages)
|
||||
if len(ids) <= context_length and sum(mask) > 0:
|
||||
examples.append((ids, mask))
|
||||
n_kept += 1
|
||||
else:
|
||||
n_skipped += 1
|
||||
|
||||
print("Loading tatsu-lab/alpaca ...")
|
||||
alpaca = load_dataset("tatsu-lab/alpaca", split="train")
|
||||
for ex in (alpaca.select(range(min(limit_per_set, len(alpaca)))) if limit_per_set else alpaca):
|
||||
add(alpaca_to_messages(ex))
|
||||
|
||||
print("Loading databricks/databricks-dolly-15k ...")
|
||||
try:
|
||||
dolly = load_dataset("databricks/databricks-dolly-15k", split="train")
|
||||
for ex in (dolly.select(range(min(limit_per_set, len(dolly)))) if limit_per_set else dolly):
|
||||
add(dolly_to_messages(ex))
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" (skipping dolly: {e})")
|
||||
|
||||
print("Loading openai/gsm8k (main/train) ...")
|
||||
gsm = load_dataset("openai/gsm8k", "main", split="train")
|
||||
for ex in (gsm.select(range(min(limit_per_set, len(gsm)))) if limit_per_set else gsm):
|
||||
add(gsm8k_to_messages(ex["question"], ex["answer"]))
|
||||
|
||||
print(f"Collected {n_kept} examples (skipped {n_skipped} that exceeded context_length).")
|
||||
return examples
|
||||
|
||||
|
||||
def write_packed(examples, context_length: int, out_path: str) -> int:
|
||||
tokens, masks = pack_examples(examples, context_length)
|
||||
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
||||
with h5py.File(out_path, "w") as f:
|
||||
f.create_dataset("tokens", data=tokens)
|
||||
f.create_dataset("loss_mask", data=masks)
|
||||
print(f" wrote {tokens.shape[0]} packed rows x {context_length} -> {out_path}")
|
||||
return tokens.shape[0]
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--context_length", type=int, default=1024)
|
||||
p.add_argument("--out_dir", default="/ephemeral/data")
|
||||
p.add_argument("--dev_frac", type=float, default=0.02)
|
||||
p.add_argument("--limit_per_set", type=int, default=None, help="cap examples per dataset (debug)")
|
||||
p.add_argument("--seed", type=int, default=42)
|
||||
args = p.parse_args()
|
||||
|
||||
examples = collect_examples(args.context_length, args.limit_per_set)
|
||||
rng = np.random.default_rng(args.seed)
|
||||
rng.shuffle(examples)
|
||||
n_dev = max(1, int(len(examples) * args.dev_frac))
|
||||
dev, train = examples[:n_dev], examples[n_dev:]
|
||||
|
||||
write_packed(train, args.context_length, os.path.join(args.out_dir, "sft_packed.h5"))
|
||||
write_packed(dev, args.context_length, os.path.join(args.out_dir, "sft_dev_packed.h5"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Pretrain the mid-size (~400M) base model from scratch on the Pile HDF5 corpus.
|
||||
|
||||
This is the shared starting checkpoint for every post-training stage. It upgrades the
|
||||
original ``train_transformer.py`` recipe with the things needed to actually train a
|
||||
mid-size model on 2x H100: DistributedDataParallel, bf16 autocast, gradient accumulation,
|
||||
a cosine LR schedule with warmup, weight-decay param groups, and periodic checkpointing.
|
||||
The original ``train_transformer.py`` is left untouched.
|
||||
|
||||
Single GPU:
|
||||
PYTHONPATH=. python scripts/pretrain_base.py
|
||||
Both GPUs:
|
||||
PYTHONPATH=. torchrun --standalone --nproc_per_node=2 scripts/pretrain_base.py
|
||||
|
||||
Override any config field from the CLI, e.g. ``--batch_size 16 --train_steps 50000``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from config.post_training_config import PretrainConfig
|
||||
from data_loader.data_loader import get_batch_iterator
|
||||
from src.post_training.cli import parse_config_with_json
|
||||
from src.post_training.distributed import ddp_setup, ddp_wrap, cleanup, reduce_scalar
|
||||
from src.post_training.logging_utils import MetricsLogger
|
||||
from src.post_training.optim import configure_optimizer, cosine_lr
|
||||
from src.post_training.utils import (
|
||||
amp_autocast, build_model_from_config, save_stage_ckpt, set_seed, unwrap,
|
||||
)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def estimate_loss(model, cfg, ctx, iters: int) -> dict[str, float]:
|
||||
model.eval()
|
||||
out = {}
|
||||
for split, path in [("train", cfg.train_path), ("dev", cfg.dev_path)]:
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
it = get_batch_iterator(path, cfg.batch_size, cfg.context_length, device=ctx.device)
|
||||
losses = torch.zeros(iters)
|
||||
for k in range(iters):
|
||||
xb, yb = next(it)
|
||||
with amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
_, loss = model(xb, yb)
|
||||
losses[k] = loss.item()
|
||||
out[split] = losses.mean().item()
|
||||
model.train()
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
cfg, extras = parse_config_with_json(
|
||||
PretrainConfig, "configs/pretrain.json",
|
||||
extra={"--resume": dict(type=str, default=None, help="checkpoint to resume from")})
|
||||
resume = extras.resume
|
||||
ctx = ddp_setup(cfg.device)
|
||||
# Different data shuffle per rank (the loader shuffles via numpy global RNG).
|
||||
set_seed(cfg.seed + ctx.rank)
|
||||
|
||||
model = build_model_from_config(cfg).to(ctx.device)
|
||||
start_step = 0
|
||||
if resume and os.path.exists(resume):
|
||||
ck = torch.load(resume, map_location="cpu", weights_only=False)
|
||||
unwrap(model).load_state_dict(ck["model_state_dict"])
|
||||
start_step = ck.get("step", 0)
|
||||
if ctx.is_main:
|
||||
print(f"Resumed from {resume} at step {start_step}")
|
||||
|
||||
if cfg.compile:
|
||||
model = torch.compile(model)
|
||||
model = ddp_wrap(model, ctx)
|
||||
|
||||
optimizer = configure_optimizer(unwrap(model), cfg.lr, cfg.weight_decay)
|
||||
if resume and os.path.exists(resume):
|
||||
ck = torch.load(resume, map_location="cpu", weights_only=False)
|
||||
if ck.get("optimizer_state_dict"):
|
||||
optimizer.load_state_dict(ck["optimizer_state_dict"])
|
||||
|
||||
logger = None
|
||||
if ctx.is_main:
|
||||
n_params = sum(p.numel() for p in unwrap(model).parameters())
|
||||
print(f"Model parameters: {n_params:,} (~{n_params/1e6:.0f}M) | world_size={ctx.world_size}")
|
||||
print(f"Effective batch = {cfg.batch_size}*{cfg.grad_accum}*{ctx.world_size} "
|
||||
f"= {cfg.batch_size*cfg.grad_accum*ctx.world_size} seqs/step")
|
||||
logger = MetricsLogger("pretrain", cfg.log_dir, use_wandb=cfg.use_wandb,
|
||||
wandb_project=cfg.wandb_project, config=vars(cfg).copy() if hasattr(cfg, "__dict__") else None)
|
||||
|
||||
batch_iter = get_batch_iterator(cfg.train_path, cfg.batch_size, cfg.context_length, device=ctx.device)
|
||||
tokens_per_step = cfg.batch_size * cfg.context_length * cfg.grad_accum * ctx.world_size
|
||||
|
||||
model.train()
|
||||
t0 = time.perf_counter()
|
||||
for step in range(start_step, cfg.train_steps):
|
||||
lr = cosine_lr(step, warmup_steps=cfg.warmup_steps, max_steps=cfg.train_steps,
|
||||
lr=cfg.lr, min_lr=cfg.min_lr)
|
||||
for g in optimizer.param_groups:
|
||||
g["lr"] = lr
|
||||
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
accum_loss = 0.0
|
||||
for micro in range(cfg.grad_accum):
|
||||
xb, yb = next(batch_iter)
|
||||
# Only sync grads on the last micro-step (DDP optimization).
|
||||
sync = (micro == cfg.grad_accum - 1) or not ctx.enabled
|
||||
cm = model.no_sync() if (ctx.enabled and not sync) else _nullcm()
|
||||
with cm, amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
_, loss = model(xb, yb)
|
||||
loss = loss / cfg.grad_accum
|
||||
loss.backward()
|
||||
accum_loss += loss.item()
|
||||
|
||||
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
|
||||
optimizer.step()
|
||||
|
||||
if ctx.is_main and step % 20 == 0:
|
||||
dt = time.perf_counter() - t0
|
||||
tok_s = tokens_per_step * 20 / dt if step > start_step else 0.0
|
||||
t0 = time.perf_counter()
|
||||
print(f"step {step} | loss {accum_loss:.4f} | lr {lr:.2e} | {tok_s:,.0f} tok/s")
|
||||
if logger:
|
||||
logger.log(step, {"train_loss": accum_loss, "lr": lr, "tok_per_s": tok_s})
|
||||
|
||||
if step > start_step and step % cfg.eval_steps == 0:
|
||||
ev = estimate_loss(model, cfg, ctx, cfg.eval_iters)
|
||||
ev = {k: reduce_scalar(v, ctx) for k, v in ev.items()}
|
||||
if ctx.is_main:
|
||||
print(f" [eval] step {step} | " + " | ".join(f"{k} {v:.4f}" for k, v in ev.items()))
|
||||
if logger:
|
||||
logger.log(step, {f"eval_{k}": v for k, v in ev.items()})
|
||||
|
||||
if ctx.is_main and step > start_step and step % cfg.save_every == 0:
|
||||
save_stage_ckpt(cfg.out_ckpt, model, optimizer, stage="pretrain",
|
||||
cfg=cfg, step=step, metrics={"train_loss": accum_loss})
|
||||
print(f" saved checkpoint -> {cfg.out_ckpt} (step {step})")
|
||||
|
||||
if ctx.is_main:
|
||||
save_stage_ckpt(cfg.out_ckpt, model, optimizer, stage="pretrain", cfg=cfg,
|
||||
step=cfg.train_steps, metrics={"train_loss": accum_loss})
|
||||
print(f"Done. Final checkpoint -> {cfg.out_ckpt}")
|
||||
if logger:
|
||||
logger.close()
|
||||
cleanup(ctx)
|
||||
|
||||
|
||||
import contextlib
|
||||
|
||||
|
||||
def _nullcm():
|
||||
return contextlib.nullcontext()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render the hand-drawn, colour-coded diagram sources (docs/diagrams/src/*.mmd) to SVGs
|
||||
# (docs/diagrams/*.svg) that are embedded as images in the docs. We pre-render because
|
||||
# GitHub's live Mermaid does not reliably support `look: handDrawn`; embedding the SVG makes
|
||||
# the hand-drawn look show everywhere.
|
||||
#
|
||||
# One-time setup:
|
||||
# sudo apt-get install -y nodejs # Node >= 18
|
||||
# sudo npm install -g @mermaid-js/mermaid-cli # provides `mmdc`
|
||||
# # a Chrome/Chromium for headless rendering (e.g. google-chrome-stable)
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# bash scripts/render_diagrams.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SRC=docs/diagrams/src
|
||||
OUT=docs/diagrams
|
||||
CHROME="${CHROME:-/usr/bin/google-chrome-stable}"
|
||||
PP=$(mktemp)
|
||||
echo "{\"executablePath\":\"$CHROME\",\"args\":[\"--no-sandbox\",\"--disable-gpu\",\"--disable-dev-shm-usage\"]}" > "$PP"
|
||||
|
||||
for m in "$SRC"/*.mmd; do
|
||||
base=$(basename "$m" .mmd)
|
||||
mmdc -p "$PP" -i "$m" -o "$OUT/$base.png" -b white -s 2 # PNG @2x: renders in every viewer (GitHub, VS Code preview)
|
||||
echo "rendered $OUT/$base.png"
|
||||
done
|
||||
rm -f "$PP"
|
||||
echo "Done. Edit a .mmd in $SRC, re-run this script, and the embedded image updates."
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Turnkey post-training pipeline: SFT -> Reward Model -> {DPO, PPO} -> GRPO -> eval table.
|
||||
# Assumes the base model is already pretrained (scripts/pretrain_base.py ->
|
||||
# /ephemeral/ckpts/base_pretrained.pt) and the datasets are prepared (scripts/prepare_*).
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# bash scripts/run_posttraining.sh # use both GPUs (torchrun)
|
||||
# NPROC=1 bash scripts/run_posttraining.sh # single GPU
|
||||
#
|
||||
# Each stage writes a checkpoint to /ephemeral/ckpts and metrics JSONL to /ephemeral/logs.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
export PYTHONPATH=. HF_HOME=/ephemeral/hf_cache PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
|
||||
PY=/ephemeral/venv/bin/python
|
||||
NPROC=${NPROC:-2}
|
||||
|
||||
run() { # run a training script single- or multi-GPU
|
||||
if [ "$NPROC" -gt 1 ]; then
|
||||
/ephemeral/venv/bin/torchrun --standalone --nproc_per_node="$NPROC" "$@"
|
||||
else
|
||||
$PY "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "############ 1/5 SFT ############"
|
||||
run scripts/train_sft.py
|
||||
|
||||
echo "############ 2/5 Reward Model ############"
|
||||
run scripts/train_reward.py
|
||||
|
||||
echo "############ 3/5 DPO ############"
|
||||
run scripts/train_dpo.py --loss_type dpo
|
||||
|
||||
echo "############ 4/5 PPO (GSM8K, verifier reward) ############"
|
||||
run scripts/train_ppo.py --reward_source verifier
|
||||
|
||||
echo "############ 5/5 GRPO (arithmetic curriculum -> GSM8K) ############"
|
||||
run scripts/train_grpo.py
|
||||
|
||||
echo "############ Eval: GSM8K accuracy across stages ############"
|
||||
TABLE=/ephemeral/logs/stage_table.jsonl
|
||||
rm -f "$TABLE"
|
||||
for s in base_pretrained sft dpo ppo grpo; do
|
||||
[ -f "/ephemeral/ckpts/$s.pt" ] && \
|
||||
$PY scripts/eval_post_training.py --ckpt "/ephemeral/ckpts/$s.pt" --label "$s" --limit 200 --append "$TABLE"
|
||||
done
|
||||
$PY scripts/eval_post_training.py --table "$TABLE"
|
||||
echo "Done. Metrics in /ephemeral/logs, checkpoints in /ephemeral/ckpts."
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Direct Preference Optimization (and ORPO / KTO variants) on preference pairs.
|
||||
|
||||
The policy is initialized from the SFT checkpoint; a frozen deep copy of it serves as the
|
||||
DPO/KTO reference (ORPO is reference-free). Reports implicit-reward accuracy on held-out
|
||||
preferences and GSM8K dev accuracy.
|
||||
|
||||
PYTHONPATH=. python scripts/train_dpo.py --loss_type dpo --beta 0.1
|
||||
PYTHONPATH=. torchrun --standalone --nproc_per_node=2 scripts/train_dpo.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import torch
|
||||
|
||||
from config.post_training_config import DPOConfig
|
||||
from data_loader.preference_dataset import get_preference_iterator
|
||||
from src.post_training.cli import parse_config_with_json
|
||||
from src.post_training.distributed import ddp_setup, ddp_wrap, cleanup, reduce_scalar
|
||||
from src.post_training.dpo import dpo_loss, orpo_loss, kto_loss, implicit_accuracy
|
||||
from src.post_training.logging_utils import MetricsLogger
|
||||
from src.post_training.optim import configure_optimizer, cosine_lr
|
||||
from src.post_training.rollout import sequence_logprobs
|
||||
from src.post_training.utils import (
|
||||
amp_autocast, load_backbone_from_ckpt, make_frozen_copy, save_stage_ckpt, set_seed, unwrap,
|
||||
)
|
||||
|
||||
TEST_PATH = "/ephemeral/data/preferences_test.jsonl"
|
||||
|
||||
|
||||
def _logps(model, ids, mask, requires_grad):
|
||||
return sequence_logprobs(model, ids, mask, requires_grad=requires_grad)
|
||||
|
||||
|
||||
def _compute_losses(policy, ref, batch, cfg, ctx):
|
||||
B = batch["chosen_ids"].size(0)
|
||||
ids = torch.cat([batch["chosen_ids"], batch["rejected_ids"]], dim=0)
|
||||
mask = torch.cat([batch["chosen_mask"], batch["rejected_mask"]], dim=0)
|
||||
|
||||
with amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
psum, pn = _logps(policy, ids, mask, requires_grad=True)
|
||||
pc, pr, ncn, nrn = psum[:B], psum[B:], pn[:B], pn[B:]
|
||||
|
||||
if cfg.loss_type == "orpo":
|
||||
return orpo_loss(pc, pr, ncn, nrn, orpo_lambda=cfg.orpo_lambda)
|
||||
|
||||
with torch.no_grad(), amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
rsum, _ = _logps(ref, ids, mask, requires_grad=False)
|
||||
rc, rr = rsum[:B], rsum[B:]
|
||||
if cfg.loss_type == "kto":
|
||||
return kto_loss(pc, pr, rc, rr, beta=cfg.beta)
|
||||
return dpo_loss(pc, pr, rc, rr, beta=cfg.beta)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def eval_implicit_acc(policy, ref, cfg, ctx, max_batches: int = 100) -> tuple[float, float]:
|
||||
policy.eval()
|
||||
it = get_preference_iterator(TEST_PATH, cfg.batch_size, cfg.max_len, device=ctx.device,
|
||||
rank=ctx.rank, world_size=ctx.world_size, shuffle=False, infinite=False)
|
||||
acc, marg, n = 0.0, 0.0, 0
|
||||
for batch in it:
|
||||
loss, cr, rr = _compute_losses(policy, ref, batch, cfg, ctx)
|
||||
acc += implicit_accuracy(cr, rr).item()
|
||||
marg += (cr - rr).mean().item()
|
||||
n += 1
|
||||
if n >= max_batches:
|
||||
break
|
||||
policy.train()
|
||||
return acc / max(1, n), marg / max(1, n)
|
||||
|
||||
|
||||
def main():
|
||||
cfg, _ = parse_config_with_json(DPOConfig, "configs/dpo.json")
|
||||
ctx = ddp_setup(cfg.device)
|
||||
set_seed(cfg.seed + ctx.rank)
|
||||
|
||||
policy = load_backbone_from_ckpt(cfg, cfg.sft_ckpt, ctx.device)
|
||||
ref = make_frozen_copy(policy, device=ctx.device) if cfg.loss_type != "orpo" else None
|
||||
policy = ddp_wrap(policy, ctx)
|
||||
optimizer = configure_optimizer(unwrap(policy), cfg.lr, cfg.weight_decay)
|
||||
|
||||
with open(cfg.pref_path) as f:
|
||||
n_rows = sum(1 for line in f if line.strip())
|
||||
total_steps = max(1, (n_rows // (cfg.batch_size * ctx.world_size)) * cfg.epochs)
|
||||
|
||||
logger = None
|
||||
if ctx.is_main:
|
||||
print(f"DPO[{cfg.loss_type}] from {cfg.sft_ckpt} | {n_rows} pairs | total_steps={total_steps} | beta={cfg.beta}")
|
||||
logger = MetricsLogger(f"dpo_{cfg.loss_type}", cfg.log_dir, use_wandb=cfg.use_wandb, wandb_project=cfg.wandb_project)
|
||||
|
||||
train_it = get_preference_iterator(cfg.pref_path, cfg.batch_size, cfg.max_len, device=ctx.device,
|
||||
rank=ctx.rank, world_size=ctx.world_size, shuffle=True, infinite=True)
|
||||
|
||||
policy.train()
|
||||
t0 = time.perf_counter()
|
||||
for step in range(total_steps):
|
||||
lr = cosine_lr(step, warmup_steps=cfg.warmup_steps, max_steps=total_steps, lr=cfg.lr, min_lr=cfg.lr * 0.1)
|
||||
for g in optimizer.param_groups:
|
||||
g["lr"] = lr
|
||||
|
||||
batch = next(train_it)
|
||||
loss, cr, rr = _compute_losses(policy, ref, batch, cfg, ctx)
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(policy.parameters(), cfg.grad_clip)
|
||||
optimizer.step()
|
||||
|
||||
if ctx.is_main and step % 20 == 0:
|
||||
acc = implicit_accuracy(cr, rr).item()
|
||||
dt = time.perf_counter() - t0; t0 = time.perf_counter()
|
||||
print(f"step {step}/{total_steps} | loss {loss.item():.4f} | acc {acc:.3f} | "
|
||||
f"r_chosen {cr.mean().item():.3f} r_rejected {rr.mean().item():.3f} | {dt:.1f}s/20")
|
||||
if logger:
|
||||
logger.log(step, {"train_loss": loss.item(), "train_acc": acc,
|
||||
"r_chosen": cr.mean().item(), "r_rejected": rr.mean().item(), "lr": lr})
|
||||
|
||||
if step > 0 and step % cfg.eval_steps == 0:
|
||||
acc, marg = eval_implicit_acc(policy, ref, cfg, ctx)
|
||||
acc, marg = reduce_scalar(acc, ctx), reduce_scalar(marg, ctx)
|
||||
if ctx.is_main:
|
||||
print(f" [eval] step {step} | test_acc {acc:.3f} | margin {marg:.3f}")
|
||||
if logger:
|
||||
logger.log(step, {"test_acc": acc, "test_margin": marg})
|
||||
|
||||
if ctx.is_main and step > 0 and step % cfg.save_every == 0:
|
||||
save_stage_ckpt(cfg.out_ckpt, policy, optimizer, stage=f"dpo_{cfg.loss_type}", cfg=cfg, step=step,
|
||||
metrics={"train_loss": loss.item()})
|
||||
|
||||
if ctx.is_main:
|
||||
# Unwrap for the final eval: other ranks are already at cleanup(), so a collective on
|
||||
# the DDP-wrapped policy here would hang (NCCL timeout). The periodic eval runs on all ranks.
|
||||
acc, marg = eval_implicit_acc(unwrap(policy), ref, cfg, ctx)
|
||||
save_stage_ckpt(cfg.out_ckpt, policy, optimizer, stage=f"dpo_{cfg.loss_type}", cfg=cfg, step=total_steps,
|
||||
metrics={"test_acc": acc, "test_margin": marg})
|
||||
print(f"Done DPO[{cfg.loss_type}]. test_acc {acc:.3f} margin {marg:.3f} -> {cfg.out_ckpt}")
|
||||
if logger:
|
||||
logger.close()
|
||||
cleanup(ctx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
GRPO / RLVR on GSM8K (DeepSeek-R1 style), from scratch -- no critic, group-relative
|
||||
advantages, verifiable reward.
|
||||
|
||||
Per iteration: for each prompt sample a group of G completions, score each with the GSM8K
|
||||
verifier, compute group-relative advantages, and update with a token-level clipped
|
||||
surrogate + KL-to-reference penalty. An arithmetic warm-up curriculum runs first so the
|
||||
policy gets non-zero reward variance before facing full GSM8K.
|
||||
|
||||
PYTHONPATH=. python scripts/train_grpo.py
|
||||
PYTHONPATH=. torchrun --standalone --nproc_per_node=2 scripts/train_grpo.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import torch
|
||||
|
||||
from config.post_training_config import GRPOConfig
|
||||
from data_loader.prompt_dataset import get_prompt_iterator
|
||||
from src.post_training.cli import parse_config_with_json
|
||||
from src.post_training.chat_template import decode, encode_prompt
|
||||
from src.post_training.distributed import ddp_setup, ddp_wrap, cleanup, reduce_scalar
|
||||
from src.post_training.evaluation import gsm8k_accuracy, load_gsm8k_eval
|
||||
from src.post_training.grpo import group_advantages, grpo_loss
|
||||
from src.post_training.logging_utils import MetricsLogger
|
||||
from src.post_training.optim import configure_optimizer
|
||||
from src.post_training.rewards import reward_gsm8k
|
||||
from src.post_training.rollout import compute_logprobs, rollout_prompts
|
||||
from src.post_training.utils import (
|
||||
amp_autocast, load_backbone_from_ckpt, make_frozen_copy, save_stage_ckpt, set_seed, unwrap,
|
||||
)
|
||||
|
||||
|
||||
def seq_lengths_from_mask(response_mask, prompt_lens):
|
||||
N, T = response_mask.shape
|
||||
pos = torch.arange(T, device=response_mask.device)
|
||||
last = torch.where(response_mask, pos[None, :], torch.full_like(response_mask, -1, dtype=torch.long)).max(dim=1).values
|
||||
return torch.where(last >= 0, last + 1, prompt_lens)
|
||||
|
||||
|
||||
def main():
|
||||
cfg, _ = parse_config_with_json(GRPOConfig, "configs/grpo.json")
|
||||
ctx = ddp_setup(cfg.device)
|
||||
set_seed(cfg.seed + ctx.rank)
|
||||
|
||||
policy = load_backbone_from_ckpt(cfg, cfg.sft_ckpt, ctx.device)
|
||||
ref = make_frozen_copy(policy, device=ctx.device)
|
||||
policy_ddp = ddp_wrap(policy, ctx)
|
||||
optimizer = configure_optimizer(unwrap(policy_ddp), cfg.lr, weight_decay=0.0)
|
||||
|
||||
eval_set = None
|
||||
logger = MetricsLogger("grpo", cfg.log_dir, use_wandb=cfg.use_wandb, wandb_project=cfg.wandb_project) if ctx.is_main else None
|
||||
if ctx.is_main:
|
||||
print(f"GRPO from {cfg.sft_ckpt} | group_size={cfg.group_size} | world={ctx.world_size}")
|
||||
|
||||
warm_it = get_prompt_iterator(cfg.curriculum_path, cfg.prompts_per_iter, rank=ctx.rank,
|
||||
world_size=ctx.world_size, seed=cfg.seed)
|
||||
main_it = get_prompt_iterator(cfg.prompt_path, cfg.prompts_per_iter, rank=ctx.rank,
|
||||
world_size=ctx.world_size, seed=cfg.seed)
|
||||
|
||||
G = cfg.group_size
|
||||
for it in range(cfg.iterations):
|
||||
rows = next(warm_it if it < cfg.curriculum_iters else main_it)
|
||||
# Replicate each prompt G times, group-contiguously.
|
||||
base_prompts = [encode_prompt([{"role": "user", "content": r["prompt"]}]) for r in rows]
|
||||
prompts = [p for p in base_prompts for _ in range(G)]
|
||||
golds = [r.get("gold") for r in rows for _ in range(G)]
|
||||
|
||||
policy.eval()
|
||||
with amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
seqs, rmask, plens = rollout_prompts(policy, prompts, cfg.rollout_len, device=ctx.device,
|
||||
temperature=cfg.temperature, top_p=cfg.top_p if cfg.top_p < 1 else None)
|
||||
resp = rmask[:, 1:]
|
||||
|
||||
seq_lens = seq_lengths_from_mask(rmask, plens)
|
||||
responses = [decode(seqs[i, plens[i]:seq_lens[i]].tolist()) for i in range(len(prompts))]
|
||||
rewards = torch.tensor([reward_gsm8k(responses[i], golds[i]) for i in range(len(prompts))],
|
||||
device=ctx.device, dtype=torch.float32)
|
||||
adv = group_advantages(rewards, G)
|
||||
|
||||
with torch.no_grad(), amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
old_logp, _ = compute_logprobs(policy, seqs, rmask, temperature=cfg.temperature, requires_grad=False)
|
||||
ref_logp, _ = compute_logprobs(ref, seqs, rmask, temperature=cfg.temperature, requires_grad=False)
|
||||
old_logp, ref_logp = old_logp.float(), ref_logp.float()
|
||||
|
||||
policy.train()
|
||||
N = seqs.size(0)
|
||||
agg = {"loss": 0.0, "kl": 0.0, "clipfrac": 0.0, "n": 0}
|
||||
for _ in range(cfg.grpo_epochs):
|
||||
perm = torch.randperm(N, device=ctx.device)
|
||||
for s in range(0, N, max(1, G)): # minibatch ~ one group's worth
|
||||
mb = perm[s:s + max(1, G)]
|
||||
with amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
new_logp, _ = compute_logprobs(policy_ddp, seqs[mb], rmask[mb], temperature=cfg.temperature, requires_grad=True)
|
||||
loss, st = grpo_loss(new_logp.float(), old_logp[mb], ref_logp[mb], adv[mb], resp[mb],
|
||||
clip=cfg.clip, kl_coef=cfg.kl_coef)
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(policy_ddp.parameters(), cfg.grad_clip)
|
||||
optimizer.step()
|
||||
agg["loss"] += loss.item(); agg["kl"] += st["kl"]; agg["clipfrac"] += st["clipfrac"]; agg["n"] += 1
|
||||
|
||||
mean_reward = reduce_scalar(rewards.mean().item(), ctx)
|
||||
# Fraction of groups with non-zero reward spread (informative groups).
|
||||
grp_std = rewards.view(-1, G).std(dim=1)
|
||||
informative = reduce_scalar((grp_std > 1e-6).float().mean().item(), ctx)
|
||||
resp_len = reduce_scalar(resp.float().sum(1).mean().item(), ctx)
|
||||
n = max(1, agg["n"])
|
||||
if ctx.is_main and it % 5 == 0:
|
||||
phase = "warmup" if it < cfg.curriculum_iters else "gsm8k"
|
||||
print(f"iter {it}[{phase}] | reward {mean_reward:.3f} | informative {informative:.2f} | "
|
||||
f"loss {agg['loss']/n:.4f} | KL {agg['kl']/n:.4f} | clipfrac {agg['clipfrac']/n:.3f} | resp_len {resp_len:.0f}")
|
||||
if logger:
|
||||
logger.log(it, {"reward": mean_reward, "informative_groups": informative,
|
||||
"loss": agg["loss"]/n, "kl": agg["kl"]/n, "resp_len": resp_len})
|
||||
|
||||
if ctx.is_main and it > 0 and it % cfg.eval_every == 0:
|
||||
if eval_set is None:
|
||||
eval_set = load_gsm8k_eval("test", limit=200)
|
||||
res = gsm8k_accuracy(unwrap(policy_ddp), eval_set, device=ctx.device, max_new_tokens=cfg.rollout_len)
|
||||
print(f" [eval] iter {it} | GSM8K test acc {res['accuracy']:.3f} ({res['correct']}/{res['n']})")
|
||||
if logger:
|
||||
logger.log(it, {"gsm8k_acc": res["accuracy"]})
|
||||
if ctx.is_main and it > 0 and it % cfg.save_every == 0:
|
||||
save_stage_ckpt(cfg.out_ckpt, unwrap(policy_ddp), optimizer, stage="grpo", cfg=cfg, step=it,
|
||||
metrics={"reward": mean_reward})
|
||||
|
||||
if ctx.is_main:
|
||||
save_stage_ckpt(cfg.out_ckpt, unwrap(policy_ddp), optimizer, stage="grpo", cfg=cfg, step=cfg.iterations, metrics={})
|
||||
print(f"Done GRPO -> {cfg.out_ckpt}")
|
||||
if logger:
|
||||
logger.close()
|
||||
cleanup(ctx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
PPO RLHF on GSM8K (the classic InstructGPT recipe), from scratch.
|
||||
|
||||
Per iteration: roll out completions with the current policy, score them (verifiable GSM8K
|
||||
reward, or a trained reward model), add a per-token KL-to-reference penalty, compute GAE
|
||||
advantages with the shared value head, then run several clipped-surrogate update epochs.
|
||||
Reports mean reward, KL, value loss, clip fraction, and held-out GSM8K accuracy.
|
||||
|
||||
PYTHONPATH=. python scripts/train_ppo.py --reward_source verifier
|
||||
PYTHONPATH=. torchrun --standalone --nproc_per_node=2 scripts/train_ppo.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from config.post_training_config import PPOConfig
|
||||
from data_loader.prompt_dataset import get_prompt_iterator
|
||||
from src.post_training.cli import parse_config_with_json
|
||||
from src.post_training.chat_template import EOT_ID, decode, encode_prompt
|
||||
from src.post_training.distributed import ddp_setup, ddp_wrap, cleanup, reduce_scalar
|
||||
from src.post_training.evaluation import gsm8k_accuracy, load_gsm8k_eval
|
||||
from src.post_training.logging_utils import MetricsLogger
|
||||
from src.post_training.optim import configure_optimizer
|
||||
from src.post_training.ppo import compute_gae, whiten, ppo_policy_loss, ppo_value_loss, approx_kl
|
||||
from src.post_training.reward_model import load_reward_model
|
||||
from src.post_training.rewards import reward_gsm8k
|
||||
from src.post_training.rollout import compute_logprobs, rollout_prompts
|
||||
from src.post_training.utils import (
|
||||
amp_autocast, load_backbone_from_ckpt, make_frozen_copy, masked_mean, save_stage_ckpt, set_seed, unwrap,
|
||||
)
|
||||
from src.post_training.value_head import TransformerWithValueHead
|
||||
|
||||
|
||||
def actor_logp_values(actor, seqs, temperature):
|
||||
"""One forward through the actor-critic -> (logp, values) in the action frame (B,T-1)."""
|
||||
logits, values = actor(seqs)
|
||||
logits = logits[:, :-1, :]
|
||||
logp_all = F.log_softmax(logits.float() / max(temperature, 1e-6), dim=-1)
|
||||
logp = logp_all.gather(-1, seqs[:, 1:, None]).squeeze(-1)
|
||||
return logp, values[:, :-1]
|
||||
|
||||
|
||||
def seq_lengths_from_mask(response_mask, prompt_lens):
|
||||
"""Real token count per row = last response position + 1 (or prompt_len if no response)."""
|
||||
N, T = response_mask.shape
|
||||
pos = torch.arange(T, device=response_mask.device)
|
||||
last = torch.where(response_mask, pos[None, :], torch.full_like(response_mask, -1, dtype=torch.long)).max(dim=1).values
|
||||
return torch.where(last >= 0, last + 1, prompt_lens)
|
||||
|
||||
|
||||
def main():
|
||||
cfg, _ = parse_config_with_json(PPOConfig, "configs/ppo.json")
|
||||
ctx = ddp_setup(cfg.device)
|
||||
set_seed(cfg.seed + ctx.rank)
|
||||
|
||||
backbone = load_backbone_from_ckpt(cfg, cfg.sft_ckpt, ctx.device)
|
||||
ref = make_frozen_copy(backbone, device=ctx.device)
|
||||
actor = TransformerWithValueHead(backbone).to(ctx.device)
|
||||
actor_ddp = ddp_wrap(actor, ctx)
|
||||
optimizer = configure_optimizer(unwrap(actor_ddp), cfg.lr, weight_decay=0.0)
|
||||
|
||||
rm = load_reward_model(cfg, cfg.reward_ckpt, ctx.device) if cfg.reward_source == "rm" else None
|
||||
|
||||
eval_set = None # loaded lazily on first eval to keep startup/smoke runs offline
|
||||
logger = MetricsLogger("ppo", cfg.log_dir, use_wandb=cfg.use_wandb, wandb_project=cfg.wandb_project) if ctx.is_main else None
|
||||
if ctx.is_main:
|
||||
print(f"PPO from {cfg.sft_ckpt} | reward={cfg.reward_source} | world={ctx.world_size}")
|
||||
|
||||
prompt_it = get_prompt_iterator(cfg.prompt_path, cfg.prompts_per_iter, rank=ctx.rank,
|
||||
world_size=ctx.world_size, seed=cfg.seed)
|
||||
|
||||
for it in range(cfg.iterations):
|
||||
rows = next(prompt_it)
|
||||
prompts = [encode_prompt([{"role": "user", "content": r["prompt"]}]) for r in rows]
|
||||
golds = [r.get("gold") for r in rows]
|
||||
|
||||
# --- rollout ---
|
||||
actor.eval()
|
||||
with amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
seqs, rmask, plens = rollout_prompts(actor, prompts, cfg.rollout_len, device=ctx.device,
|
||||
temperature=cfg.temperature, top_p=cfg.top_p if cfg.top_p < 1 else None)
|
||||
resp = rmask[:, 1:] # action-frame response mask (N, T-1)
|
||||
|
||||
# --- score ---
|
||||
seq_lens = seq_lengths_from_mask(rmask, plens)
|
||||
responses = [decode(seqs[i, plens[i]:seq_lens[i]].tolist()) for i in range(len(rows))]
|
||||
if cfg.reward_source == "rm":
|
||||
with torch.no_grad(), amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
task_r = rm(seqs, seq_lengths=seq_lens).float().tolist()
|
||||
else:
|
||||
task_r = [reward_gsm8k(responses[i], golds[i]) for i in range(len(rows))]
|
||||
|
||||
# --- per-token rewards: KL penalty everywhere + task reward at last response token ---
|
||||
with torch.no_grad(), amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
old_logp, old_values = actor_logp_values(actor, seqs, cfg.temperature)
|
||||
ref_logp, _ = compute_logprobs(ref, seqs, rmask, temperature=cfg.temperature, requires_grad=False)
|
||||
old_logp, old_values, ref_logp = old_logp.float(), old_values.float(), ref_logp.float()
|
||||
|
||||
rewards = -cfg.kl_coef * (old_logp - ref_logp) * resp.float()
|
||||
last_idx = seq_lens - 2 # action index of the last response token
|
||||
last_idx = last_idx.clamp(min=0)
|
||||
task_t = torch.tensor(task_r, device=ctx.device, dtype=torch.float32)
|
||||
rewards[torch.arange(len(rows), device=ctx.device), last_idx] += task_t
|
||||
|
||||
values_next = torch.cat([old_values[:, 1:], torch.zeros_like(old_values[:, :1])], dim=1)
|
||||
adv, returns = compute_gae(rewards, old_values, values_next, resp, gamma=cfg.gamma, lam=cfg.gae_lambda)
|
||||
adv = whiten(adv, resp)
|
||||
|
||||
# --- clipped PPO update epochs over minibatches of rollout rows ---
|
||||
actor.train()
|
||||
N = seqs.size(0)
|
||||
stats = {"policy_loss": 0.0, "value_loss": 0.0, "clipfrac": 0.0, "kl": 0.0, "n": 0}
|
||||
for _ in range(cfg.ppo_epochs):
|
||||
perm = torch.randperm(N, device=ctx.device)
|
||||
for s in range(0, N, cfg.minibatch_size):
|
||||
mb = perm[s:s + cfg.minibatch_size]
|
||||
with amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
new_logp, new_values = actor_logp_values(actor_ddp, seqs[mb], cfg.temperature)
|
||||
m = resp[mb]
|
||||
p_loss, clipf = ppo_policy_loss(new_logp.float(), old_logp[mb], adv[mb], m, clip=cfg.clip)
|
||||
v_loss = ppo_value_loss(new_values.float(), old_values[mb], returns[mb], m, vf_clip=cfg.vf_clip)
|
||||
loss = p_loss + cfg.vf_coef * v_loss
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(actor_ddp.parameters(), cfg.grad_clip)
|
||||
optimizer.step()
|
||||
stats["policy_loss"] += p_loss.item(); stats["value_loss"] += v_loss.item()
|
||||
stats["clipfrac"] += clipf.item()
|
||||
stats["kl"] += approx_kl(new_logp.float(), old_logp[mb], m).item(); stats["n"] += 1
|
||||
|
||||
mean_reward = reduce_scalar(float(sum(task_r) / max(1, len(task_r))), ctx)
|
||||
kl_ref = reduce_scalar(masked_mean(old_logp - ref_logp, resp).item(), ctx)
|
||||
resp_len = reduce_scalar(resp.float().sum(1).mean().item(), ctx)
|
||||
n = max(1, stats["n"])
|
||||
if ctx.is_main and it % 5 == 0:
|
||||
print(f"iter {it} | reward {mean_reward:.3f} | KL_ref {kl_ref:.3f} | "
|
||||
f"ploss {stats['policy_loss']/n:.4f} | vloss {stats['value_loss']/n:.4f} | "
|
||||
f"clipfrac {stats['clipfrac']/n:.3f} | resp_len {resp_len:.0f}")
|
||||
if logger:
|
||||
logger.log(it, {"reward": mean_reward, "kl_ref": kl_ref, "policy_loss": stats["policy_loss"]/n,
|
||||
"value_loss": stats["value_loss"]/n, "clipfrac": stats["clipfrac"]/n, "resp_len": resp_len})
|
||||
|
||||
if ctx.is_main and it > 0 and it % cfg.eval_every == 0:
|
||||
if eval_set is None:
|
||||
eval_set = load_gsm8k_eval("test", limit=200)
|
||||
res = gsm8k_accuracy(unwrap(actor_ddp), eval_set, device=ctx.device, max_new_tokens=cfg.rollout_len)
|
||||
print(f" [eval] iter {it} | GSM8K test acc {res['accuracy']:.3f} ({res['correct']}/{res['n']})")
|
||||
if logger:
|
||||
logger.log(it, {"gsm8k_acc": res["accuracy"]})
|
||||
if ctx.is_main and it > 0 and it % cfg.save_every == 0:
|
||||
save_stage_ckpt(cfg.out_ckpt, unwrap(actor_ddp).transformer, optimizer, stage="ppo", cfg=cfg, step=it,
|
||||
metrics={"reward": mean_reward})
|
||||
|
||||
if ctx.is_main:
|
||||
save_stage_ckpt(cfg.out_ckpt, unwrap(actor_ddp).transformer, optimizer, stage="ppo", cfg=cfg, step=cfg.iterations, metrics={})
|
||||
print(f"Done PPO -> {cfg.out_ckpt}")
|
||||
if logger:
|
||||
logger.close()
|
||||
cleanup(ctx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Train the reward model on preference pairs with the Bradley-Terry loss.
|
||||
|
||||
Initializes the reward backbone from the SFT checkpoint, adds a scalar reward head, and
|
||||
trains so chosen responses score above rejected ones. Reports held-out preference accuracy.
|
||||
|
||||
Single GPU:
|
||||
PYTHONPATH=. python scripts/train_reward.py
|
||||
Both GPUs:
|
||||
PYTHONPATH=. torchrun --standalone --nproc_per_node=2 scripts/train_reward.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import torch
|
||||
|
||||
from config.post_training_config import RewardConfig
|
||||
from data_loader.preference_dataset import get_preference_iterator
|
||||
from src.post_training.cli import parse_config_with_json
|
||||
from src.post_training.distributed import ddp_setup, ddp_wrap, cleanup, reduce_scalar
|
||||
from src.post_training.logging_utils import MetricsLogger
|
||||
from src.post_training.optim import configure_optimizer, cosine_lr
|
||||
from src.post_training.reward_model import RewardModel
|
||||
from src.post_training.reward_train import bradley_terry_loss, preference_accuracy, reward_margin
|
||||
from src.post_training.utils import amp_autocast, load_backbone_from_ckpt, save_stage_ckpt, set_seed, unwrap
|
||||
|
||||
TEST_PATH = "/ephemeral/data/preferences_test.jsonl"
|
||||
|
||||
|
||||
def _pair_rewards(rm, batch, cfg, ctx):
|
||||
"""Forward chosen+rejected in one pass; return (chosen_rewards, rejected_rewards)."""
|
||||
B = batch["chosen_ids"].size(0)
|
||||
ids = torch.cat([batch["chosen_ids"], batch["rejected_ids"]], dim=0)
|
||||
lens = torch.cat([batch["chosen_len"], batch["rejected_len"]], dim=0)
|
||||
with amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
rewards = rm(ids, seq_lengths=lens).float()
|
||||
return rewards[:B], rewards[B:]
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def eval_accuracy(rm, cfg, ctx, max_batches: int = 100) -> tuple[float, float]:
|
||||
rm.eval()
|
||||
it = get_preference_iterator(TEST_PATH, cfg.batch_size, cfg.max_len, device=ctx.device,
|
||||
rank=ctx.rank, world_size=ctx.world_size, shuffle=False, infinite=False)
|
||||
acc, marg, n = 0.0, 0.0, 0
|
||||
for batch in it:
|
||||
cr, rr = _pair_rewards(rm, batch, cfg, ctx)
|
||||
acc += preference_accuracy(cr, rr).item()
|
||||
marg += reward_margin(cr, rr).item()
|
||||
n += 1
|
||||
if n >= max_batches:
|
||||
break
|
||||
rm.train()
|
||||
return acc / max(1, n), marg / max(1, n)
|
||||
|
||||
|
||||
def main():
|
||||
cfg, _ = parse_config_with_json(RewardConfig, "configs/reward.json")
|
||||
ctx = ddp_setup(cfg.device)
|
||||
set_seed(cfg.seed + ctx.rank)
|
||||
|
||||
backbone = load_backbone_from_ckpt(cfg, cfg.sft_ckpt, ctx.device)
|
||||
rm = RewardModel(backbone).to(ctx.device)
|
||||
# find_unused_parameters=True: the reward model uses the backbone's forward_hidden + a
|
||||
# reward head and never its lm_head, so lm_head params get no gradient. Without this flag
|
||||
# DDP errors on the first backward.
|
||||
rm = ddp_wrap(rm, ctx, find_unused_parameters=True)
|
||||
optimizer = configure_optimizer(unwrap(rm), cfg.lr, cfg.weight_decay)
|
||||
|
||||
import json
|
||||
with open(cfg.pref_path) as f:
|
||||
n_rows = sum(1 for line in f if line.strip())
|
||||
total_steps = max(1, (n_rows // (cfg.batch_size * ctx.world_size)) * cfg.epochs)
|
||||
|
||||
logger = None
|
||||
if ctx.is_main:
|
||||
print(f"Reward model from {cfg.sft_ckpt} | {n_rows} pairs | total_steps={total_steps}")
|
||||
logger = MetricsLogger("reward", cfg.log_dir, use_wandb=cfg.use_wandb, wandb_project=cfg.wandb_project)
|
||||
|
||||
train_it = get_preference_iterator(cfg.pref_path, cfg.batch_size, cfg.max_len, device=ctx.device,
|
||||
rank=ctx.rank, world_size=ctx.world_size, shuffle=True, infinite=True)
|
||||
|
||||
rm.train()
|
||||
t0 = time.perf_counter()
|
||||
for step in range(total_steps):
|
||||
lr = cosine_lr(step, warmup_steps=cfg.warmup_steps, max_steps=total_steps, lr=cfg.lr, min_lr=cfg.lr * 0.1)
|
||||
for g in optimizer.param_groups:
|
||||
g["lr"] = lr
|
||||
|
||||
batch = next(train_it)
|
||||
cr, rr = _pair_rewards(rm, batch, cfg, ctx)
|
||||
loss = bradley_terry_loss(cr, rr)
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(rm.parameters(), cfg.grad_clip)
|
||||
optimizer.step()
|
||||
|
||||
if ctx.is_main and step % 20 == 0:
|
||||
acc = preference_accuracy(cr, rr).item()
|
||||
dt = time.perf_counter() - t0; t0 = time.perf_counter()
|
||||
print(f"step {step}/{total_steps} | loss {loss.item():.4f} | train_acc {acc:.3f} | lr {lr:.2e} | {dt:.1f}s/20")
|
||||
if logger:
|
||||
logger.log(step, {"train_loss": loss.item(), "train_acc": acc, "lr": lr})
|
||||
|
||||
if step > 0 and step % cfg.eval_steps == 0:
|
||||
acc, marg = eval_accuracy(rm, cfg, ctx)
|
||||
acc, marg = reduce_scalar(acc, ctx), reduce_scalar(marg, ctx)
|
||||
if ctx.is_main:
|
||||
print(f" [eval] step {step} | test_acc {acc:.3f} | margin {marg:.3f}")
|
||||
if logger:
|
||||
logger.log(step, {"test_acc": acc, "test_margin": marg})
|
||||
|
||||
if ctx.is_main and step > 0 and step % cfg.save_every == 0:
|
||||
save_stage_ckpt(cfg.out_ckpt, rm, optimizer, stage="reward", cfg=cfg, step=step,
|
||||
metrics={"train_loss": loss.item()})
|
||||
|
||||
if ctx.is_main:
|
||||
# Unwrap for the final eval: other ranks are already at cleanup(), so a collective on
|
||||
# the DDP-wrapped model here would hang (NCCL timeout). The periodic eval runs on all ranks.
|
||||
acc, marg = eval_accuracy(unwrap(rm), cfg, ctx)
|
||||
save_stage_ckpt(cfg.out_ckpt, rm, optimizer, stage="reward", cfg=cfg, step=total_steps,
|
||||
metrics={"test_acc": acc, "test_margin": marg})
|
||||
print(f"Done RM. test_acc {acc:.3f} margin {marg:.3f} -> {cfg.out_ckpt}")
|
||||
if logger:
|
||||
logger.close()
|
||||
cleanup(ctx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Supervised Fine-Tuning of the pretrained base on packed instruction data.
|
||||
|
||||
Loads the base checkpoint, trains with the prompt-masked SFT loss, periodically reports
|
||||
masked dev loss, and saves an SFT checkpoint. DDP + bf16, single code path for 1 or N GPUs.
|
||||
|
||||
Single GPU:
|
||||
PYTHONPATH=. python scripts/train_sft.py
|
||||
Both GPUs:
|
||||
PYTHONPATH=. torchrun --standalone --nproc_per_node=2 scripts/train_sft.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import math
|
||||
import time
|
||||
|
||||
import torch
|
||||
|
||||
from config.post_training_config import SFTConfig
|
||||
from data_loader.sft_dataset import get_sft_batch_iterator
|
||||
from src.post_training.cli import parse_config_with_json
|
||||
from src.post_training.distributed import ddp_setup, ddp_wrap, cleanup, reduce_scalar
|
||||
from src.post_training.logging_utils import MetricsLogger
|
||||
from src.post_training.optim import configure_optimizer, cosine_lr
|
||||
from src.post_training.sft import sft_loss
|
||||
from src.post_training.utils import amp_autocast, load_backbone_from_ckpt, save_stage_ckpt, set_seed, unwrap
|
||||
|
||||
DEV_PATH = "/ephemeral/data/sft_dev_packed.h5"
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def eval_dev(model, cfg, ctx, dev_path: str, max_batches: int = 50) -> float:
|
||||
model.eval()
|
||||
it = get_sft_batch_iterator(dev_path, cfg.batch_size, device=ctx.device,
|
||||
rank=ctx.rank, world_size=ctx.world_size, shuffle=False, infinite=False)
|
||||
total, n = 0.0, 0
|
||||
for tokens, mask, _ in it:
|
||||
with amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
logits, _ = model(tokens)
|
||||
loss = sft_loss(logits, tokens, mask)
|
||||
total += loss.item(); n += 1
|
||||
if n >= max_batches:
|
||||
break
|
||||
model.train()
|
||||
return total / max(1, n)
|
||||
|
||||
|
||||
def main():
|
||||
cfg, _ = parse_config_with_json(SFTConfig, "configs/sft.json")
|
||||
ctx = ddp_setup(cfg.device)
|
||||
set_seed(cfg.seed + ctx.rank)
|
||||
|
||||
model = load_backbone_from_ckpt(cfg, cfg.pretrained_ckpt, ctx.device)
|
||||
if cfg.compile:
|
||||
model = torch.compile(model)
|
||||
model = ddp_wrap(model, ctx)
|
||||
optimizer = configure_optimizer(unwrap(model), cfg.lr, cfg.weight_decay)
|
||||
|
||||
logger = None
|
||||
if ctx.is_main:
|
||||
print(f"SFT from {cfg.pretrained_ckpt} | world_size={ctx.world_size}")
|
||||
logger = MetricsLogger("sft", cfg.log_dir, use_wandb=cfg.use_wandb, wandb_project=cfg.wandb_project)
|
||||
|
||||
train_it = get_sft_batch_iterator(cfg.data_path, cfg.batch_size, device=ctx.device,
|
||||
rank=ctx.rank, world_size=ctx.world_size, shuffle=True, infinite=True)
|
||||
|
||||
# Estimate total steps for the cosine schedule from dataset size.
|
||||
import h5py
|
||||
with h5py.File(cfg.data_path, "r") as f:
|
||||
n_rows = f["tokens"].shape[0]
|
||||
steps_per_epoch = max(1, n_rows // (cfg.batch_size * ctx.world_size))
|
||||
total_steps = cfg.max_steps if cfg.max_steps > 0 else steps_per_epoch * cfg.epochs
|
||||
if ctx.is_main:
|
||||
print(f"{n_rows} packed rows | ~{steps_per_epoch} steps/epoch | total_steps={total_steps}")
|
||||
|
||||
model.train()
|
||||
t0 = time.perf_counter()
|
||||
for step in range(total_steps):
|
||||
lr = cosine_lr(step, warmup_steps=cfg.warmup_steps, max_steps=total_steps, lr=cfg.lr, min_lr=cfg.min_lr)
|
||||
for g in optimizer.param_groups:
|
||||
g["lr"] = lr
|
||||
|
||||
tokens, mask, epoch = next(train_it)
|
||||
if epoch >= cfg.epochs and cfg.max_steps <= 0:
|
||||
break
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
with amp_autocast(cfg.amp_dtype, ctx.device):
|
||||
logits, _ = model(tokens)
|
||||
loss = sft_loss(logits, tokens, mask)
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
|
||||
optimizer.step()
|
||||
|
||||
if ctx.is_main and step % 20 == 0:
|
||||
dt = time.perf_counter() - t0; t0 = time.perf_counter()
|
||||
print(f"step {step}/{total_steps} | loss {loss.item():.4f} | ppl {math.exp(min(20, loss.item())):.2f} | lr {lr:.2e} | {dt:.1f}s/20")
|
||||
if logger:
|
||||
logger.log(step, {"train_loss": loss.item(), "lr": lr})
|
||||
|
||||
if step > 0 and step % cfg.eval_steps == 0:
|
||||
dev = reduce_scalar(eval_dev(model, cfg, ctx, DEV_PATH), ctx)
|
||||
if ctx.is_main:
|
||||
print(f" [eval] step {step} | dev_loss {dev:.4f} | dev_ppl {math.exp(min(20, dev)):.2f}")
|
||||
if logger:
|
||||
logger.log(step, {"dev_loss": dev})
|
||||
|
||||
if ctx.is_main and step > 0 and step % cfg.save_every == 0:
|
||||
save_stage_ckpt(cfg.out_ckpt, model, optimizer, stage="sft", cfg=cfg, step=step,
|
||||
metrics={"train_loss": loss.item()})
|
||||
|
||||
if ctx.is_main:
|
||||
# Use the unwrapped model for the final eval: the other ranks have already reached
|
||||
# cleanup(), so calling the DDP-wrapped model here would launch a collective with no
|
||||
# peer and hang (NCCL timeout). The periodic eval above runs on all ranks, so it is fine.
|
||||
dev = eval_dev(unwrap(model), cfg, ctx, DEV_PATH)
|
||||
save_stage_ckpt(cfg.out_ckpt, model, optimizer, stage="sft", cfg=cfg, step=total_steps,
|
||||
metrics={"dev_loss": dev})
|
||||
print(f"Done SFT. dev_loss {dev:.4f} -> {cfg.out_ckpt}")
|
||||
if logger:
|
||||
logger.close()
|
||||
cleanup(ctx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,640 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from tqdm import tqdm
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from config.config import default_config as config
|
||||
from src.models.transformer import Transformer
|
||||
|
||||
|
||||
# --- Runtime Diagnostics Helpers ---
|
||||
|
||||
def bytes_to_gib(num_bytes: int) -> float:
|
||||
"""Convert a byte count to gibibytes for human-readable memory reports."""
|
||||
return num_bytes / (1024 ** 3)
|
||||
|
||||
|
||||
def get_device_report(device: str) -> str:
|
||||
"""
|
||||
Build a short report describing the runtime environment: PyTorch/CUDA
|
||||
versions and, when running on a GPU, its name, capability, and total VRAM.
|
||||
This makes it easy to collect comparable training reports across machines.
|
||||
"""
|
||||
lines = [
|
||||
f"PyTorch version: {torch.__version__}",
|
||||
f"Configured device: {device}",
|
||||
f"CUDA available: {torch.cuda.is_available()}",
|
||||
f"CUDA version: {torch.version.cuda}",
|
||||
]
|
||||
|
||||
if device.startswith('cuda') and torch.cuda.is_available():
|
||||
device_index = torch.cuda.current_device()
|
||||
props = torch.cuda.get_device_properties(device_index)
|
||||
total_vram_gib = bytes_to_gib(props.total_memory)
|
||||
lines.extend([
|
||||
f"GPU name: {torch.cuda.get_device_name(device_index)}",
|
||||
f"GPU capability: {props.major}.{props.minor}",
|
||||
f"Total VRAM: {total_vram_gib:.2f} GiB",
|
||||
])
|
||||
else:
|
||||
lines.append("GPU name: N/A (running without CUDA)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_peak_memory_report(device: str) -> str:
|
||||
"""Report peak GPU memory (allocated/reserved) since the last reset, or N/A on CPU."""
|
||||
if device.startswith('cuda') and torch.cuda.is_available():
|
||||
peak_allocated = bytes_to_gib(torch.cuda.max_memory_allocated())
|
||||
peak_reserved = bytes_to_gib(torch.cuda.max_memory_reserved())
|
||||
return (
|
||||
f"Peak VRAM allocated: {peak_allocated:.2f} GiB | "
|
||||
f"Peak VRAM reserved: {peak_reserved:.2f} GiB"
|
||||
)
|
||||
return "Peak VRAM allocated: N/A | Peak VRAM reserved: N/A"
|
||||
|
||||
|
||||
def estimate_memory_budget(num_params: int, device: str, use_amp: bool) -> str:
|
||||
"""
|
||||
Print a rough training VRAM budget so users can predict OOM before launching.
|
||||
|
||||
AdamW keeps fp32 weights + grads + two moment buffers (~16 bytes/param). This is an
|
||||
estimate of optimizer/parameter state only (activations depend on batch/context and
|
||||
are reduced a lot by gradient checkpointing). CUDA-only; returns N/A otherwise.
|
||||
"""
|
||||
if not (device.startswith("cuda") and torch.cuda.is_available()):
|
||||
return "VRAM budget: N/A (no CUDA device)"
|
||||
# weights(4) + grad(4) + Adam m(4) + Adam v(4); AMP adds bf16/fp16 copies but keeps
|
||||
# the fp32 master state, so ~16 B/param is a reasonable floor either way.
|
||||
state_gib = bytes_to_gib(num_params * 16)
|
||||
props = torch.cuda.get_device_properties(torch.cuda.current_device())
|
||||
total_gib = bytes_to_gib(props.total_memory)
|
||||
note = " (+ activations; reduce with --grad-checkpointing / --grad-accum)"
|
||||
return (
|
||||
f"VRAM budget: ~{state_gib:.2f} GiB params+optimizer state vs {total_gib:.2f} GiB "
|
||||
f"total on {torch.cuda.get_device_name()}{note}"
|
||||
)
|
||||
|
||||
|
||||
# --- Checkpoint Helpers ---
|
||||
|
||||
CHECKPOINT_RE = re.compile(r"checkpoint_step_(\d+)\.pt$")
|
||||
|
||||
|
||||
def load_checkpoint_file(path: str, device: str) -> Dict[str, Any]:
|
||||
"""Load a checkpoint while supporting both newer and older PyTorch versions."""
|
||||
try:
|
||||
return torch.load(path, map_location=torch.device(device), weights_only=False)
|
||||
except TypeError:
|
||||
return torch.load(path, map_location=torch.device(device))
|
||||
|
||||
|
||||
def default_checkpoint_dir(out_path: str) -> str:
|
||||
"""Return a checkpoint directory tied to the configured final model path."""
|
||||
model_path = Path(out_path)
|
||||
return str(model_path.with_suffix("")) + "_checkpoints"
|
||||
|
||||
|
||||
def checkpoint_path(checkpoint_dir: str, step: int) -> str:
|
||||
"""Build a stable checkpoint path for the last completed training step."""
|
||||
return os.path.join(checkpoint_dir, f"checkpoint_step_{step:08d}.pt")
|
||||
|
||||
|
||||
def checkpoint_step(path: str) -> int:
|
||||
"""Extract the step number from a checkpoint filename."""
|
||||
match = CHECKPOINT_RE.search(os.path.basename(path))
|
||||
if not match:
|
||||
return -1
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def list_checkpoints(checkpoint_dir: str) -> List[str]:
|
||||
"""Return periodic checkpoints sorted by training step."""
|
||||
if not os.path.isdir(checkpoint_dir):
|
||||
return []
|
||||
paths = [
|
||||
os.path.join(checkpoint_dir, name)
|
||||
for name in os.listdir(checkpoint_dir)
|
||||
if CHECKPOINT_RE.search(name)
|
||||
]
|
||||
return sorted(paths, key=checkpoint_step)
|
||||
|
||||
|
||||
def resolve_resume_path(resume: Optional[str], checkpoint_dir: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve a resume argument.
|
||||
|
||||
``--resume`` with no value uses the latest periodic checkpoint in checkpoint_dir.
|
||||
``--resume path/to/file.pt`` loads that exact checkpoint.
|
||||
"""
|
||||
if resume is None:
|
||||
return None
|
||||
if resume == "latest":
|
||||
checkpoints = list_checkpoints(checkpoint_dir)
|
||||
if not checkpoints:
|
||||
raise FileNotFoundError(f"No checkpoints found in {checkpoint_dir}")
|
||||
return checkpoints[-1]
|
||||
return resume
|
||||
|
||||
|
||||
def current_lr(optimizer: torch.optim.Optimizer) -> float:
|
||||
"""Read the learning rate from the first optimizer parameter group."""
|
||||
return float(optimizer.param_groups[0]["lr"])
|
||||
|
||||
|
||||
def lr_for_step(train_config: Dict[str, Any], step: int) -> float:
|
||||
"""Return the learning rate that should be active at a given step."""
|
||||
if step > train_config['t_lr_decay_step']:
|
||||
return float(train_config['t_lr_decayed'])
|
||||
return float(train_config['t_lr'])
|
||||
|
||||
|
||||
def set_optimizer_lr(optimizer: torch.optim.Optimizer, lr: float) -> None:
|
||||
"""Set all optimizer parameter groups to the same learning rate."""
|
||||
for group in optimizer.param_groups:
|
||||
group["lr"] = lr
|
||||
|
||||
|
||||
def save_training_checkpoint(
|
||||
path: str,
|
||||
model: Transformer,
|
||||
optimizer: torch.optim.Optimizer,
|
||||
train_config: Dict[str, Any],
|
||||
losses: List[float],
|
||||
*,
|
||||
step: int,
|
||||
train_loss: Optional[float] = None,
|
||||
dev_loss: Optional[float] = None,
|
||||
is_final: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Save model, optimizer, loss history, and LR schedule metadata.
|
||||
|
||||
``step`` is the last completed zero-based training step, so resume starts at
|
||||
``step + 1``.
|
||||
"""
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
payload = {
|
||||
'model_state_dict': model.state_dict(),
|
||||
'optimizer_state_dict': optimizer.state_dict(),
|
||||
'losses': losses,
|
||||
'train_loss': train_loss,
|
||||
'dev_loss': dev_loss,
|
||||
'step': step,
|
||||
'last_completed_step': step,
|
||||
'steps': step + 1,
|
||||
'is_final': is_final,
|
||||
'config': dict(train_config),
|
||||
'device': train_config['device'],
|
||||
'pytorch_version': torch.__version__,
|
||||
'cuda_version': torch.version.cuda,
|
||||
'lr_state': {
|
||||
'current_lr': current_lr(optimizer),
|
||||
'initial_lr': train_config['t_lr'],
|
||||
'decayed_lr': train_config['t_lr_decayed'],
|
||||
'decay_step': train_config['t_lr_decay_step'],
|
||||
},
|
||||
}
|
||||
target_dir = os.path.dirname(path) or "."
|
||||
with tempfile.NamedTemporaryFile(
|
||||
dir=target_dir,
|
||||
prefix=f".{os.path.basename(path)}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as tmp_file:
|
||||
tmp_path = tmp_file.name
|
||||
try:
|
||||
torch.save(payload, tmp_path)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
raise
|
||||
|
||||
|
||||
def restore_training_checkpoint(
|
||||
path: str,
|
||||
model: Transformer,
|
||||
optimizer: torch.optim.Optimizer,
|
||||
train_config: Dict[str, Any],
|
||||
device: str,
|
||||
) -> Tuple[int, List[float]]:
|
||||
"""
|
||||
Restore model/optimizer state and return ``(next_step, losses)``.
|
||||
|
||||
Older checkpoints did not have ``last_completed_step``. For those, ``steps``
|
||||
is treated as the number of completed optimizer steps.
|
||||
"""
|
||||
checkpoint = load_checkpoint_file(path, device)
|
||||
model.load_state_dict(checkpoint['model_state_dict'])
|
||||
|
||||
optimizer_state = checkpoint.get('optimizer_state_dict')
|
||||
if optimizer_state:
|
||||
optimizer.load_state_dict(optimizer_state)
|
||||
|
||||
if 'last_completed_step' in checkpoint:
|
||||
last_completed_step = int(checkpoint['last_completed_step'])
|
||||
next_step = last_completed_step + 1
|
||||
else:
|
||||
next_step = int(checkpoint.get('steps', 0))
|
||||
last_completed_step = next_step - 1
|
||||
|
||||
if not optimizer_state:
|
||||
set_optimizer_lr(optimizer, lr_for_step(train_config, next_step))
|
||||
|
||||
losses = [float(loss) for loss in checkpoint.get('losses', [])]
|
||||
print(
|
||||
f"Resumed from {path}. "
|
||||
f"Last completed step: {last_completed_step}. Next step: {next_step}."
|
||||
)
|
||||
return next_step, losses
|
||||
|
||||
|
||||
def prune_old_checkpoints(checkpoint_dir: str, keep_last: int) -> None:
|
||||
"""Keep only the most recent N periodic checkpoints when requested."""
|
||||
if keep_last <= 0:
|
||||
return
|
||||
checkpoints = list_checkpoints(checkpoint_dir)
|
||||
for old_path in checkpoints[:-keep_last]:
|
||||
os.remove(old_path)
|
||||
|
||||
|
||||
def unique_output_path(out_path: str) -> str:
|
||||
"""Avoid overwriting an existing final model checkpoint."""
|
||||
modified_model_out_path = out_path
|
||||
save_tries = 0
|
||||
while os.path.exists(modified_model_out_path):
|
||||
save_tries += 1
|
||||
model_out_name = os.path.splitext(out_path)[0]
|
||||
modified_model_out_path = model_out_name + f"_{save_tries}" + ".pt"
|
||||
return modified_model_out_path
|
||||
|
||||
|
||||
def as_float(value: Any) -> Optional[float]:
|
||||
"""Convert scalar tensors/numbers to plain floats for checkpoint metadata."""
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "item"):
|
||||
return float(value.item())
|
||||
return float(value)
|
||||
|
||||
|
||||
# --- Training / Evaluation ---
|
||||
|
||||
@torch.no_grad()
|
||||
def estimate_loss(model: Transformer, train_config: Dict[str, Any], steps: int) -> Dict[str, float]:
|
||||
"""
|
||||
Evaluate the model on training and development datasets and calculate average loss.
|
||||
|
||||
Args:
|
||||
model (Transformer): The model being trained.
|
||||
train_config (dict): Training configuration values.
|
||||
steps (int): Number of steps to evaluate.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing average losses for 'train' and 'dev' splits.
|
||||
"""
|
||||
out = {}
|
||||
model.eval() # Set the model to evaluation mode.
|
||||
from data_loader.data_loader import get_batch_iterator
|
||||
|
||||
for split in ['train', 'dev']:
|
||||
# Select the appropriate data path for the current split.
|
||||
data_path = train_config['train_path'] if split == 'train' else train_config['dev_path']
|
||||
|
||||
# Create a batch iterator for evaluation.
|
||||
batch_iterator_eval = get_batch_iterator(
|
||||
data_path,
|
||||
train_config['t_batch_size'],
|
||||
train_config['t_context_length'],
|
||||
device=train_config['device'],
|
||||
)
|
||||
|
||||
# Track loss values for each evaluation step.
|
||||
losses_eval = []
|
||||
for _ in range(steps):
|
||||
try:
|
||||
# Fetch a batch and calculate the loss.
|
||||
xb, yb = next(batch_iterator_eval)
|
||||
_, loss = model(xb, yb)
|
||||
losses_eval.append(float(loss.item()))
|
||||
except StopIteration:
|
||||
# Handle the case where the data iterator ends early.
|
||||
print(f"Warning: Iterator for {split} ended early.")
|
||||
break
|
||||
|
||||
# Compute the mean loss for the current split.
|
||||
out[split] = float(np.mean(losses_eval)) if losses_eval else float("nan")
|
||||
|
||||
model.train() # Restore the model to training mode.
|
||||
return out
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Train the Transformer model from scratch.")
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
nargs="?",
|
||||
const="latest",
|
||||
default=None,
|
||||
help=(
|
||||
"Resume from a checkpoint path. Pass --resume with no value, or "
|
||||
"--resume latest, to use the newest checkpoint in the checkpoint directory."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint-every",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Save a periodic checkpoint every N completed steps. 0 disables periodic checkpoints.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint-dir",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Directory for periodic checkpoints. Defaults to a directory next to t_out_path.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-last",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Keep only the most recent N periodic checkpoints. 0 keeps all.",
|
||||
)
|
||||
# --- Memory-optimisation flags (opt-in; all default to the config values, which are OFF) ---
|
||||
parser.add_argument(
|
||||
"--amp",
|
||||
dest="amp",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Enable bf16/fp16 mixed-precision autocast (CUDA only; ignored on CPU).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--amp-dtype",
|
||||
type=str,
|
||||
choices=["bf16", "fp16"],
|
||||
default=None,
|
||||
help="Autocast dtype when --amp is set: bf16 (default, no GradScaler) or fp16.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--grad-checkpointing",
|
||||
dest="grad_checkpointing",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Recompute transformer-block activations in backward to save VRAM.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--grad-accum",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Accumulate gradients over N micro-batches per optimizer step (effective batch xN).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report-memory",
|
||||
dest="report_memory",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Print a rough VRAM budget (params + optimizer state) before training (CUDA only).",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
train_config = dict(config)
|
||||
checkpoint_every = (
|
||||
args.checkpoint_every
|
||||
if args.checkpoint_every is not None
|
||||
else train_config.get('t_checkpoint_steps', 0)
|
||||
)
|
||||
keep_last = (
|
||||
args.keep_last
|
||||
if args.keep_last is not None
|
||||
else train_config.get('t_keep_last_checkpoints', 0)
|
||||
)
|
||||
checkpoint_dir = (
|
||||
args.checkpoint_dir
|
||||
or train_config.get('t_checkpoint_dir')
|
||||
or default_checkpoint_dir(train_config['t_out_path'])
|
||||
)
|
||||
|
||||
# --- Resolve memory-optimisation options (CLI overrides config; all default OFF) ---
|
||||
use_amp = args.amp if args.amp is not None else bool(train_config.get('use_amp', False))
|
||||
amp_dtype_name = args.amp_dtype or train_config.get('amp_dtype', 'bf16')
|
||||
use_grad_ckpt = (
|
||||
args.grad_checkpointing if args.grad_checkpointing is not None
|
||||
else bool(train_config.get('use_gradient_checkpointing', False))
|
||||
)
|
||||
grad_accum = max(1, args.grad_accum if args.grad_accum is not None
|
||||
else int(train_config.get('grad_accum_steps', 1)))
|
||||
report_memory = (
|
||||
args.report_memory if args.report_memory is not None
|
||||
else bool(train_config.get('report_memory_budget', False))
|
||||
)
|
||||
|
||||
device_is_cuda = train_config['device'].startswith('cuda') and torch.cuda.is_available()
|
||||
if use_amp and not device_is_cuda:
|
||||
print("[mem-opt] --amp requested but no CUDA device available; disabling AMP.")
|
||||
use_amp = False
|
||||
amp_dtype = torch.bfloat16 if amp_dtype_name == 'bf16' else torch.float16
|
||||
|
||||
def autocast_ctx():
|
||||
if use_amp:
|
||||
return torch.autocast(device_type='cuda', dtype=amp_dtype)
|
||||
return contextlib.nullcontext()
|
||||
|
||||
# GradScaler is only needed for fp16; bf16 has enough range. A disabled scaler is a no-op,
|
||||
# so the scale/unscale_/step/update calls below work unchanged for bf16 and CPU.
|
||||
use_scaler = use_amp and amp_dtype == torch.float16
|
||||
scaler = torch.amp.GradScaler('cuda', enabled=use_scaler)
|
||||
|
||||
# --- Initialize the Model and Print Parameters ---
|
||||
|
||||
# Print runtime/device diagnostics and reset GPU peak-memory stats before training.
|
||||
print(get_device_report(train_config['device']))
|
||||
if train_config['device'].startswith('cuda') and torch.cuda.is_available():
|
||||
torch.cuda.reset_peak_memory_stats()
|
||||
|
||||
model = Transformer(
|
||||
n_head=train_config['n_head'],
|
||||
n_embed=train_config['n_embed'],
|
||||
context_length=train_config['context_length'],
|
||||
vocab_size=train_config['vocab_size'],
|
||||
N_BLOCKS=train_config['n_blocks'],
|
||||
).to(train_config['device'])
|
||||
|
||||
# Print the total number of parameters.
|
||||
total_params = sum(p.numel() for p in model.parameters())
|
||||
print(f"Total number of parameters in the model: {total_params:,}")
|
||||
|
||||
# Apply opt-in memory optimisations.
|
||||
model.gradient_checkpointing = use_grad_ckpt
|
||||
if report_memory:
|
||||
print(estimate_memory_budget(total_params, train_config['device'], use_amp))
|
||||
if use_amp or use_grad_ckpt or grad_accum > 1:
|
||||
print(
|
||||
f"[mem-opt] amp={use_amp}"
|
||||
f"{'(' + amp_dtype_name + ')' if use_amp else ''} "
|
||||
f"grad_checkpointing={use_grad_ckpt} grad_accum={grad_accum}"
|
||||
)
|
||||
|
||||
# --- Optimizer Setup and Loss Tracking ---
|
||||
|
||||
# Set up the AdamW optimizer with the specified learning rate.
|
||||
optimizer = torch.optim.AdamW(model.parameters(), lr=train_config['t_lr'])
|
||||
|
||||
# List to track loss values during training.
|
||||
losses: List[float] = []
|
||||
start_step = 0
|
||||
last_completed_step = -1
|
||||
resume_path = resolve_resume_path(args.resume, checkpoint_dir)
|
||||
if resume_path is not None:
|
||||
start_step, losses = restore_training_checkpoint(
|
||||
resume_path,
|
||||
model,
|
||||
optimizer,
|
||||
train_config,
|
||||
train_config['device'],
|
||||
)
|
||||
last_completed_step = start_step - 1
|
||||
|
||||
# Define a window size for averaging recent losses in the training loop.
|
||||
avg_window = 64
|
||||
|
||||
# --- Training Loop ---
|
||||
|
||||
from data_loader.data_loader import get_batch_iterator
|
||||
|
||||
# Create a batch iterator for the training data.
|
||||
batch_iterator = get_batch_iterator(
|
||||
train_config['train_path'],
|
||||
train_config['t_batch_size'],
|
||||
train_config['t_context_length'],
|
||||
device=train_config['device'],
|
||||
)
|
||||
|
||||
# Number of tokens processed per optimizer step (batch * context * grad_accum), for throughput.
|
||||
tokens_per_step = train_config['t_batch_size'] * train_config['t_context_length'] * grad_accum
|
||||
last_eval_time = time.perf_counter()
|
||||
latest_train_loss = None
|
||||
latest_dev_loss = None
|
||||
|
||||
# Create a progress bar to monitor training progress.
|
||||
pbar = tqdm(range(start_step, train_config['t_train_steps']))
|
||||
for step in pbar:
|
||||
try:
|
||||
# Start the step timer.
|
||||
step_start_time = time.perf_counter()
|
||||
|
||||
# Accumulate gradients over `grad_accum` micro-batches (==1 => original behaviour).
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
step_loss = 0.0
|
||||
for _ in range(grad_accum):
|
||||
xb, yb = next(batch_iterator)
|
||||
with autocast_ctx():
|
||||
_, loss = model(xb, yb)
|
||||
# Scale so the accumulated gradient equals the full-batch mean gradient.
|
||||
loss = loss / grad_accum
|
||||
scaler.scale(loss).backward()
|
||||
step_loss += float(loss.item())
|
||||
|
||||
# Record the (accumulated) loss for tracking.
|
||||
losses.append(step_loss)
|
||||
pbar.set_description(f"Train loss: {np.mean(losses[-avg_window:]):.4f}")
|
||||
|
||||
# Clip gradients to prevent exploding gradients (unscale first for fp16 AMP).
|
||||
scaler.unscale_(optimizer)
|
||||
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
||||
|
||||
scaler.step(optimizer)
|
||||
scaler.update()
|
||||
last_completed_step = step
|
||||
|
||||
# Measure step time and instantaneous throughput for diagnostics.
|
||||
step_time = time.perf_counter() - step_start_time
|
||||
tokens_per_second = tokens_per_step / step_time if step_time > 0 else float('inf')
|
||||
|
||||
# Periodically evaluate the model on training and development data.
|
||||
if step % train_config['t_eval_steps'] == 0:
|
||||
evaluation_losses = estimate_loss(model, train_config, train_config['t_eval_iters'])
|
||||
latest_train_loss = evaluation_losses['train']
|
||||
latest_dev_loss = evaluation_losses['dev']
|
||||
# Report timing/throughput for the most recent step and wall-time since last eval.
|
||||
now = time.perf_counter()
|
||||
elapsed_since_eval = now - last_eval_time
|
||||
last_eval_time = now
|
||||
print(
|
||||
f"Step: {step}, Train loss: {latest_train_loss:.4f}, Dev loss: {latest_dev_loss:.4f}, "
|
||||
f"Step time: {step_time:.3f}s, Throughput: {tokens_per_second:.2f} tokens/s, "
|
||||
f"Elapsed since last eval: {elapsed_since_eval:.2f}s"
|
||||
)
|
||||
print(get_peak_memory_report(train_config['device']))
|
||||
|
||||
# Decay the learning rate at the specified step.
|
||||
if step == train_config['t_lr_decay_step']:
|
||||
print('Decaying learning rate')
|
||||
set_optimizer_lr(optimizer, train_config['t_lr_decayed'])
|
||||
|
||||
if checkpoint_every and checkpoint_every > 0 and (step + 1) % checkpoint_every == 0:
|
||||
path = checkpoint_path(checkpoint_dir, step)
|
||||
save_training_checkpoint(
|
||||
path,
|
||||
model,
|
||||
optimizer,
|
||||
train_config,
|
||||
losses,
|
||||
step=step,
|
||||
train_loss=as_float(latest_train_loss),
|
||||
dev_loss=as_float(latest_dev_loss),
|
||||
)
|
||||
prune_old_checkpoints(checkpoint_dir, int(keep_last or 0))
|
||||
print(f"Saved checkpoint to {path}")
|
||||
except StopIteration:
|
||||
# Handle the case where the training data iterator ends early.
|
||||
print("Training data iterator finished early.")
|
||||
break
|
||||
|
||||
# --- Save Model and Final Evaluation ---
|
||||
|
||||
# Perform a final evaluation of the model on training and development datasets.
|
||||
evaluation_losses = estimate_loss(model, train_config, 200)
|
||||
train_loss = evaluation_losses['train']
|
||||
dev_loss = evaluation_losses['dev']
|
||||
|
||||
final_step = max(last_completed_step, start_step - 1)
|
||||
modified_model_out_path = unique_output_path(train_config['t_out_path'])
|
||||
|
||||
# Save the model's state dictionary, optimizer state, and training metadata
|
||||
# (including the runtime device / PyTorch / CUDA versions for reproducibility).
|
||||
save_training_checkpoint(
|
||||
modified_model_out_path,
|
||||
model,
|
||||
optimizer,
|
||||
train_config,
|
||||
losses,
|
||||
step=final_step,
|
||||
train_loss=train_loss,
|
||||
dev_loss=dev_loss,
|
||||
is_final=True,
|
||||
)
|
||||
print(f"Saved model to {modified_model_out_path}")
|
||||
print(get_peak_memory_report(train_config['device']))
|
||||
print(f"Finished training. Train loss: {train_loss:.4f}, Dev loss: {dev_loss:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user