1063 lines
39 KiB
Python
1063 lines
39 KiB
Python
|
|
"""
|
|||
|
|
train/sft.py — Supervised Fine-Tuning (SFT) entry point.
|
|||
|
|
|
|||
|
|
Loads a pretrained checkpoint and fine-tunes it on instruction/conversation
|
|||
|
|
data using SFTDataset, which masks prompt tokens with ignore_index=-1 so only
|
|||
|
|
the assistant response tokens contribute to the loss.
|
|||
|
|
|
|||
|
|
Launch single-GPU:
|
|||
|
|
python train/sft.py \\
|
|||
|
|
--base_checkpoint checkpoints/korean_1b_fp8_run1/checkpoint-0034000 \\
|
|||
|
|
--sft_data data/sft/train.jsonl \\
|
|||
|
|
--device cuda:0
|
|||
|
|
|
|||
|
|
Launch multi-GPU (DDP via torchrun):
|
|||
|
|
torchrun --nproc_per_node=8 train/sft.py \\
|
|||
|
|
--base_checkpoint checkpoints/korean_1b_fp8_run1/checkpoint-0034000 \\
|
|||
|
|
--sft_data data/sft/train.jsonl
|
|||
|
|
|
|||
|
|
KEY DIFFERENCES from pretrain.py:
|
|||
|
|
- Loads weights from a pretrained checkpoint via LLM.from_pretrained()
|
|||
|
|
- Uses SFTDataset (JSONL instruction data) instead of PackedDataset
|
|||
|
|
- Lower default learning rate (2e-5 vs 2e-4)
|
|||
|
|
- Fewer default steps (3000 vs 100000)
|
|||
|
|
- Copies tokenizer.json to checkpoint_dir for easy deployment
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import os
|
|||
|
|
import random
|
|||
|
|
import signal
|
|||
|
|
import shutil
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import numpy as np
|
|||
|
|
import torch
|
|||
|
|
import torch.nn.functional as F
|
|||
|
|
from torch.utils.data import DataLoader, DistributedSampler, RandomSampler
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Data Mixing: Interleave SFT + Pretrain batches for forgetting prevention
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MixingDataLoader:
|
|||
|
|
"""
|
|||
|
|
Wraps two DataLoaders and yields batches from one or the other
|
|||
|
|
based on a probability ratio.
|
|||
|
|
|
|||
|
|
With ``pretrain_ratio=0.3``, 30% of batches come from the pretrain
|
|||
|
|
loader and 70% from the SFT loader. Both loaders cycle infinitely.
|
|||
|
|
|
|||
|
|
This is duck-type compatible with DataLoader for the Trainer's needs:
|
|||
|
|
- ``__iter__`` yields batches
|
|||
|
|
- ``__len__`` returns the SFT loader length (used for epoch estimation)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
sft_loader: DataLoader,
|
|||
|
|
pretrain_loader: DataLoader,
|
|||
|
|
pretrain_ratio: float = 0.3,
|
|||
|
|
sft_sampler: DistributedSampler | RandomSampler | None = None,
|
|||
|
|
pretrain_sampler: DistributedSampler | RandomSampler | None = None,
|
|||
|
|
) -> None:
|
|||
|
|
self.sft_loader = sft_loader
|
|||
|
|
self.pretrain_loader = pretrain_loader
|
|||
|
|
self.pretrain_ratio = pretrain_ratio
|
|||
|
|
self.sft_sampler = sft_sampler
|
|||
|
|
self.pretrain_sampler = pretrain_sampler
|
|||
|
|
self._epoch = 0
|
|||
|
|
|
|||
|
|
def __len__(self) -> int:
|
|||
|
|
return len(self.sft_loader)
|
|||
|
|
|
|||
|
|
def __iter__(self):
|
|||
|
|
sft_iter = iter(self.sft_loader)
|
|||
|
|
pt_iter = iter(self.pretrain_loader)
|
|||
|
|
|
|||
|
|
while True:
|
|||
|
|
use_pretrain = random.random() < self.pretrain_ratio
|
|||
|
|
try:
|
|||
|
|
if use_pretrain:
|
|||
|
|
batch = next(pt_iter)
|
|||
|
|
else:
|
|||
|
|
batch = next(sft_iter)
|
|||
|
|
except StopIteration:
|
|||
|
|
# Whichever exhausted, restart it
|
|||
|
|
if use_pretrain:
|
|||
|
|
self._epoch += 1
|
|||
|
|
if self.pretrain_sampler is not None and hasattr(self.pretrain_sampler, 'set_epoch'):
|
|||
|
|
self.pretrain_sampler.set_epoch(self._epoch)
|
|||
|
|
pt_iter = iter(self.pretrain_loader)
|
|||
|
|
try:
|
|||
|
|
batch = next(pt_iter)
|
|||
|
|
except StopIteration:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"Pretrain DataLoader is empty after restart. "
|
|||
|
|
"Check pretrain_data path and drop_last settings."
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
self._epoch += 1
|
|||
|
|
if self.sft_sampler is not None and hasattr(self.sft_sampler, 'set_epoch'):
|
|||
|
|
self.sft_sampler.set_epoch(self._epoch)
|
|||
|
|
sft_iter = iter(self.sft_loader)
|
|||
|
|
try:
|
|||
|
|
batch = next(sft_iter)
|
|||
|
|
except StopIteration:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"SFT DataLoader is empty after restart. "
|
|||
|
|
"Check sft_data path and drop_last settings."
|
|||
|
|
)
|
|||
|
|
yield batch
|
|||
|
|
|
|||
|
|
# B200 Tensor Core 최대 활용: TF32 matmul + cuDNN
|
|||
|
|
torch.backends.cuda.matmul.allow_tf32 = True
|
|||
|
|
torch.backends.cudnn.allow_tf32 = True
|
|||
|
|
torch.set_float32_matmul_precision("high") # TF32 precision for fp32 matmul
|
|||
|
|
|
|||
|
|
# Allow imports from the project root regardless of working directory.
|
|||
|
|
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|||
|
|
if str(_PROJECT_ROOT) not in sys.path:
|
|||
|
|
sys.path.insert(0, str(_PROJECT_ROOT))
|
|||
|
|
|
|||
|
|
from model import LLM
|
|||
|
|
from train.trainer import TrainConfig, Trainer
|
|||
|
|
from train.utils import (
|
|||
|
|
cleanup_ddp,
|
|||
|
|
get_cosine_schedule_with_warmup,
|
|||
|
|
is_main_process,
|
|||
|
|
load_checkpoint,
|
|||
|
|
setup_ddp,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Optional TransformerEngine import (FP8 support)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
try:
|
|||
|
|
import transformer_engine.pytorch as te # type: ignore[import]
|
|||
|
|
HAS_TE = True
|
|||
|
|
except ImportError:
|
|||
|
|
te = None # type: ignore[assignment]
|
|||
|
|
HAS_TE = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Argument parsing
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_args() -> argparse.Namespace:
|
|||
|
|
parser = argparse.ArgumentParser(
|
|||
|
|
description="Supervised Fine-Tuning (SFT) of a pretrained decoder-only LLM.",
|
|||
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# --- Required paths -----------------------------------------------------
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--base_checkpoint",
|
|||
|
|
type=Path,
|
|||
|
|
required=True,
|
|||
|
|
help=(
|
|||
|
|
"Path to the pretrained checkpoint directory. "
|
|||
|
|
"Must contain model.pt and config.yaml (produced by save_checkpoint)."
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--sft_data",
|
|||
|
|
type=Path,
|
|||
|
|
required=True,
|
|||
|
|
help="Path to the JSONL SFT training data file.",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# --- Optional paths -----------------------------------------------------
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--val_data",
|
|||
|
|
type=Path,
|
|||
|
|
default=None,
|
|||
|
|
help="Optional path to JSONL SFT validation data file.",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--checkpoint_dir",
|
|||
|
|
type=Path,
|
|||
|
|
default=Path("checkpoints/korean_1b_sft"),
|
|||
|
|
help="Root directory for saving SFT checkpoints.",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--resume",
|
|||
|
|
type=Path,
|
|||
|
|
default=None,
|
|||
|
|
help="Path to an SFT checkpoint directory to resume fine-tuning from.",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--tokenizer",
|
|||
|
|
type=Path,
|
|||
|
|
default=None,
|
|||
|
|
help=(
|
|||
|
|
"Override path to tokenizer.json. "
|
|||
|
|
"Defaults to <base_checkpoint>/tokenizer.json, "
|
|||
|
|
"then falls back to tokenizer/korean_sp/tokenizer.json."
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--log_file",
|
|||
|
|
type=Path,
|
|||
|
|
default=None,
|
|||
|
|
help=(
|
|||
|
|
"Path to a text file for structured training logs (rank-0 only). "
|
|||
|
|
"If omitted, logs go only to stdout."
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# --- Training hyper-parameters ------------------------------------------
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--max_steps",
|
|||
|
|
type=int,
|
|||
|
|
default=3000,
|
|||
|
|
help="Total number of optimiser steps.",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--batch_size",
|
|||
|
|
type=int,
|
|||
|
|
default=4,
|
|||
|
|
help="Per-GPU micro-batch size.",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--lr",
|
|||
|
|
type=float,
|
|||
|
|
default=2e-5,
|
|||
|
|
help=(
|
|||
|
|
"Peak learning rate. "
|
|||
|
|
"SFT uses a much lower lr than pretraining (2e-5 vs 2e-4) "
|
|||
|
|
"to preserve pretrained representations."
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--weight_decay",
|
|||
|
|
type=float,
|
|||
|
|
default=0.01,
|
|||
|
|
help="AdamW weight decay. Lower than pretrain (0.01 vs 0.1).",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--warmup_steps",
|
|||
|
|
type=int,
|
|||
|
|
default=100,
|
|||
|
|
help="Number of linear LR warmup steps.",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--grad_accum",
|
|||
|
|
type=int,
|
|||
|
|
default=2,
|
|||
|
|
help="Gradient accumulation steps.",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--seed",
|
|||
|
|
type=int,
|
|||
|
|
default=42,
|
|||
|
|
help="Base random seed (rank offset is added automatically in DDP).",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--use_fp8",
|
|||
|
|
action="store_true",
|
|||
|
|
default=False,
|
|||
|
|
help=(
|
|||
|
|
"Enable TransformerEngine FP8 training "
|
|||
|
|
"(requires B200/H100, uses MXFP8BlockScaling)."
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# --- Single-GPU device override (ignored when using torchrun) -----------
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--device",
|
|||
|
|
type=str,
|
|||
|
|
default=None,
|
|||
|
|
help=(
|
|||
|
|
"Explicit device string (e.g. 'cuda:0'). "
|
|||
|
|
"Ignored when running under torchrun (DDP auto-assigns devices)."
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--config", type=Path, default=None,
|
|||
|
|
help="YAML config file. Values under 'train:' section are used as CLI defaults.",
|
|||
|
|
)
|
|||
|
|
parser.add_argument("--save_interval", type=int, default=500, help="Checkpoint save interval (steps).")
|
|||
|
|
parser.add_argument("--eval_interval", type=int, default=250, help="Validation eval interval (steps).")
|
|||
|
|
parser.add_argument("--neftune_alpha", type=float, default=5.0, help="NEFTune noise magnitude (0 to disable).")
|
|||
|
|
parser.add_argument("--max_grad_norm", type=float, default=1.0, help="Maximum gradient L2 norm for clipping.")
|
|||
|
|
|
|||
|
|
# --- Data mixing (forgetting prevention) --------------------------------
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--pretrain_data",
|
|||
|
|
type=Path,
|
|||
|
|
default=None,
|
|||
|
|
help="Path to pretrain .bin file for data mixing. Enables SFT+pretrain interleaving.",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--pretrain_mix_ratio",
|
|||
|
|
type=float,
|
|||
|
|
default=0.3,
|
|||
|
|
help="Fraction of batches from pretrain data (0.3 = 30%% pretrain, 70%% SFT).",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# First pass: just get --config
|
|||
|
|
args, remaining = parser.parse_known_args()
|
|||
|
|
|
|||
|
|
# Load YAML config and apply values as defaults
|
|||
|
|
if args.config is not None:
|
|||
|
|
if not args.config.exists():
|
|||
|
|
raise FileNotFoundError(f"Config file not found: {args.config}")
|
|||
|
|
import yaml
|
|||
|
|
with open(args.config, "r") as f:
|
|||
|
|
yaml_cfg = yaml.safe_load(f)
|
|||
|
|
train_section = yaml_cfg.get("train", {})
|
|||
|
|
yaml_to_arg = {
|
|||
|
|
"max_steps": "max_steps",
|
|||
|
|
"batch_size": "batch_size",
|
|||
|
|
"lr": "lr",
|
|||
|
|
"weight_decay": "weight_decay",
|
|||
|
|
"warmup_steps": "warmup_steps",
|
|||
|
|
"grad_accum_steps": "grad_accum",
|
|||
|
|
"save_interval": "save_interval",
|
|||
|
|
"eval_interval": "eval_interval",
|
|||
|
|
"neftune_alpha": "neftune_alpha",
|
|||
|
|
"pretrain_mix_ratio": "pretrain_mix_ratio",
|
|||
|
|
"max_grad_norm": "max_grad_norm",
|
|||
|
|
}
|
|||
|
|
# pretrain_data is a Path, handle separately
|
|||
|
|
if "pretrain_data" in train_section:
|
|||
|
|
parser.set_defaults(pretrain_data=Path(train_section["pretrain_data"]))
|
|||
|
|
new_defaults = {}
|
|||
|
|
for yaml_key, arg_name in yaml_to_arg.items():
|
|||
|
|
if yaml_key in train_section:
|
|||
|
|
new_defaults[arg_name] = train_section[yaml_key]
|
|||
|
|
if new_defaults:
|
|||
|
|
parser.set_defaults(**new_defaults)
|
|||
|
|
|
|||
|
|
return parser.parse_args()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Seed helper
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def set_seed(seed: int) -> None:
|
|||
|
|
"""Set deterministic seeds for Python, NumPy, and PyTorch."""
|
|||
|
|
random.seed(seed)
|
|||
|
|
np.random.seed(seed)
|
|||
|
|
torch.manual_seed(seed)
|
|||
|
|
torch.cuda.manual_seed_all(seed)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Optimizer parameter groups
|
|||
|
|
# (Copied from pretrain.py to avoid circular import; identical logic)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_optimizer_param_groups(
|
|||
|
|
model: torch.nn.Module,
|
|||
|
|
weight_decay: float,
|
|||
|
|
) -> list[dict]:
|
|||
|
|
"""
|
|||
|
|
Split parameters into two groups:
|
|||
|
|
- decay group : weight tensors with ndim >= 2 (Linear, etc.)
|
|||
|
|
- no-decay group: bias, LayerNorm/RMSNorm weights, and embedding weights
|
|||
|
|
|
|||
|
|
This follows standard practice (e.g. GPT-style training).
|
|||
|
|
"""
|
|||
|
|
decay_params: list[torch.nn.Parameter] = []
|
|||
|
|
no_decay_params: list[torch.nn.Parameter] = []
|
|||
|
|
|
|||
|
|
# Module types whose parameters should never be decayed.
|
|||
|
|
no_decay_module_types = (
|
|||
|
|
torch.nn.Embedding,
|
|||
|
|
torch.nn.LayerNorm,
|
|||
|
|
)
|
|||
|
|
# Also skip any parameter whose name ends with '.bias'.
|
|||
|
|
no_decay_name_suffixes = ("bias",)
|
|||
|
|
|
|||
|
|
# Collect module-level exclusions.
|
|||
|
|
no_decay_module_params: set[int] = set()
|
|||
|
|
for module in model.modules():
|
|||
|
|
if isinstance(module, no_decay_module_types):
|
|||
|
|
for param in module.parameters(recurse=False):
|
|||
|
|
no_decay_module_params.add(id(param))
|
|||
|
|
|
|||
|
|
seen: set[int] = set()
|
|||
|
|
for name, param in model.named_parameters():
|
|||
|
|
if not param.requires_grad:
|
|||
|
|
continue
|
|||
|
|
if id(param) in seen:
|
|||
|
|
continue
|
|||
|
|
seen.add(id(param))
|
|||
|
|
|
|||
|
|
if (
|
|||
|
|
id(param) in no_decay_module_params
|
|||
|
|
or any(name.endswith(sfx) for sfx in no_decay_name_suffixes)
|
|||
|
|
or param.ndim < 2
|
|||
|
|
):
|
|||
|
|
no_decay_params.append(param)
|
|||
|
|
else:
|
|||
|
|
decay_params.append(param)
|
|||
|
|
|
|||
|
|
return [
|
|||
|
|
{"params": decay_params, "weight_decay": weight_decay},
|
|||
|
|
{"params": no_decay_params, "weight_decay": 0.0},
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Tokenizer resolution helper
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _resolve_tokenizer_path(args: argparse.Namespace) -> Path:
|
|||
|
|
"""
|
|||
|
|
Determine the tokenizer path in priority order:
|
|||
|
|
1. Explicit --tokenizer argument
|
|||
|
|
2. tokenizer.json inside the base_checkpoint directory
|
|||
|
|
3. Project default: tokenizer/korean_sp/tokenizer.json
|
|||
|
|
"""
|
|||
|
|
if args.tokenizer is not None:
|
|||
|
|
p = Path(args.tokenizer)
|
|||
|
|
if not p.exists():
|
|||
|
|
raise FileNotFoundError(f"Tokenizer not found at --tokenizer path: {p}")
|
|||
|
|
return p
|
|||
|
|
|
|||
|
|
ckpt_tok = args.base_checkpoint / "tokenizer.json"
|
|||
|
|
if ckpt_tok.exists():
|
|||
|
|
return ckpt_tok
|
|||
|
|
|
|||
|
|
default_tok = _PROJECT_ROOT / "tokenizer" / "korean_sp" / "tokenizer.json"
|
|||
|
|
if default_tok.exists():
|
|||
|
|
return default_tok
|
|||
|
|
|
|||
|
|
raise FileNotFoundError(
|
|||
|
|
"Could not locate tokenizer.json. Tried:\n"
|
|||
|
|
f" 1. {ckpt_tok}\n"
|
|||
|
|
f" 2. {default_tok}\n"
|
|||
|
|
"Use --tokenizer to specify an explicit path."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Dynamic padding collate function
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def dynamic_collate_fn(batch: list) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|||
|
|
"""
|
|||
|
|
Collate function that pads each batch to its own maximum sequence length
|
|||
|
|
instead of a fixed global max_seq_len. This reduces wasted FLOPs on
|
|||
|
|
short sequences and speeds up SFT which tends to have highly variable
|
|||
|
|
response lengths.
|
|||
|
|
|
|||
|
|
Pads to the batch-local max, aligned to 64 tokens (for Flash Attention
|
|||
|
|
efficiency), with a floor of 512 tokens so micro-batches are not too short.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
batch: List of ``(input_ids, labels)`` tuples from SFTDataset.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Tuple of ``(input_ids, labels, attention_mask)`` tensors shaped
|
|||
|
|
``[B, max_len]``.
|
|||
|
|
``input_ids`` is right-padded with 0 (pad token).
|
|||
|
|
``labels`` is right-padded with -1 (cross-entropy ignore_index).
|
|||
|
|
``attention_mask`` is 1 for real tokens, 0 for padding.
|
|||
|
|
"""
|
|||
|
|
# 64-token alignment + minimum 512 floor
|
|||
|
|
raw_max = max(item[0].size(0) for item in batch)
|
|||
|
|
max_len = max(512, ((raw_max + 63) // 64) * 64)
|
|||
|
|
|
|||
|
|
input_ids_list, labels_list, mask_list = [], [], []
|
|||
|
|
for ids, labs in batch:
|
|||
|
|
pad_len = max_len - ids.size(0)
|
|||
|
|
input_ids_list.append(F.pad(ids, (0, pad_len), value=0))
|
|||
|
|
labels_list.append(F.pad(labs, (0, pad_len), value=-1))
|
|||
|
|
mask_list.append(
|
|||
|
|
F.pad(torch.ones(ids.size(0), dtype=torch.long), (0, pad_len), value=0)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
torch.stack(input_ids_list),
|
|||
|
|
torch.stack(labels_list),
|
|||
|
|
torch.stack(mask_list),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# NEFTune helper
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_neftune_hook(model: torch.nn.Module, noise_alpha: float = 10.0):
|
|||
|
|
"""
|
|||
|
|
Register a forward hook on the model's input embedding layer that adds
|
|||
|
|
uniform noise scaled by noise_alpha during training (NEFTune).
|
|||
|
|
|
|||
|
|
Reference: "NEFTune: Noisy Embeddings Improve Instruction Finetuning"
|
|||
|
|
(Jain et al., 2023). https://arxiv.org/abs/2310.05914
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
model: Raw (non-DDP) model instance.
|
|||
|
|
noise_alpha: Noise magnitude parameter (paper default: 10).
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
The hook handle (call ``handle.remove()`` to deactivate), or None if
|
|||
|
|
the embedding layer could not be located.
|
|||
|
|
"""
|
|||
|
|
# Unwrap DDP if needed
|
|||
|
|
raw = model.module if hasattr(model, "module") else model
|
|||
|
|
|
|||
|
|
# 1) Try the standard HuggingFace accessor first.
|
|||
|
|
embedding: torch.nn.Embedding | None = None
|
|||
|
|
if hasattr(raw, "get_input_embeddings"):
|
|||
|
|
try:
|
|||
|
|
emb = raw.get_input_embeddings()
|
|||
|
|
if isinstance(emb, torch.nn.Embedding):
|
|||
|
|
embedding = emb
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# 2) Fallback: walk common attribute paths found in open-source LLMs.
|
|||
|
|
if embedding is None:
|
|||
|
|
for attr_path in [
|
|||
|
|
"embedding",
|
|||
|
|
"embed_tokens",
|
|||
|
|
"token_embedding",
|
|||
|
|
"wte",
|
|||
|
|
"word_embeddings",
|
|||
|
|
"tok_embeddings",
|
|||
|
|
"transformer.wte",
|
|||
|
|
"model.embed_tokens",
|
|||
|
|
"model.embedding",
|
|||
|
|
]:
|
|||
|
|
obj = raw
|
|||
|
|
for part in attr_path.split("."):
|
|||
|
|
obj = getattr(obj, part, None)
|
|||
|
|
if obj is None:
|
|||
|
|
break
|
|||
|
|
if obj is not None and isinstance(obj, torch.nn.Embedding):
|
|||
|
|
embedding = obj
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if embedding is None:
|
|||
|
|
print("[WARN] NEFTune: embedding layer을 찾지 못함, NEFTune 비활성화")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
print(
|
|||
|
|
f"[INFO] NEFTune: {type(embedding).__name__} hook 등록 "
|
|||
|
|
f"(shape={tuple(embedding.weight.shape)}, alpha={noise_alpha})"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def _hook(
|
|||
|
|
module: torch.nn.Module,
|
|||
|
|
inp: tuple,
|
|||
|
|
out: torch.Tensor,
|
|||
|
|
) -> torch.Tensor:
|
|||
|
|
if module.training:
|
|||
|
|
# out shape: [B, seq_len, d_model]
|
|||
|
|
mag = noise_alpha / ((out.size(1) * out.size(2)) ** 0.5)
|
|||
|
|
out = out + torch.empty_like(out).uniform_(-mag, mag)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
return embedding.register_forward_hook(_hook)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Main
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> None:
|
|||
|
|
args = parse_args()
|
|||
|
|
|
|||
|
|
# ---- Distributed setup -------------------------------------------------
|
|||
|
|
is_ddp = "RANK" in os.environ
|
|||
|
|
rank = 0
|
|||
|
|
local_rank = 0
|
|||
|
|
world_size = 1
|
|||
|
|
|
|||
|
|
if is_ddp:
|
|||
|
|
rank, local_rank, world_size, device = setup_ddp()
|
|||
|
|
else:
|
|||
|
|
# Single-GPU: honour --device flag, else pick cuda:0 or cpu.
|
|||
|
|
if args.device is not None:
|
|||
|
|
device = torch.device(args.device)
|
|||
|
|
elif torch.cuda.is_available():
|
|||
|
|
device = torch.device("cuda:0")
|
|||
|
|
else:
|
|||
|
|
device = torch.device("cpu")
|
|||
|
|
|
|||
|
|
# Per-rank seed so data shuffling differs across replicas.
|
|||
|
|
set_seed(args.seed + rank)
|
|||
|
|
|
|||
|
|
# ---- NUMA affinity for optimal GPU↔CPU memory locality ---------------
|
|||
|
|
# B200 topology: GPU 0-3 → NUMA node 0 (cores 0-35)
|
|||
|
|
# GPU 4-7 → NUMA node 1 (cores 36-71)
|
|||
|
|
try:
|
|||
|
|
if local_rank < 4:
|
|||
|
|
os.sched_setaffinity(0, set(range(0, 36))) # NUMA node 0
|
|||
|
|
else:
|
|||
|
|
os.sched_setaffinity(0, set(range(36, 72))) # NUMA node 1
|
|||
|
|
if is_main_process():
|
|||
|
|
print(f"NUMA affinity: rank {rank} (GPU {local_rank}) → "
|
|||
|
|
f"{'NUMA0 cores 0-35' if local_rank < 4 else 'NUMA1 cores 36-71'}")
|
|||
|
|
except (AttributeError, OSError) as e:
|
|||
|
|
if is_main_process():
|
|||
|
|
print(f"[WARN] NUMA affinity failed: {e}")
|
|||
|
|
|
|||
|
|
# ---- Validate base checkpoint ------------------------------------------
|
|||
|
|
if not args.base_checkpoint.exists():
|
|||
|
|
raise FileNotFoundError(
|
|||
|
|
f"Base checkpoint directory not found: {args.base_checkpoint}"
|
|||
|
|
)
|
|||
|
|
for required_file in ("model.pt", "config.yaml"):
|
|||
|
|
if not (args.base_checkpoint / required_file).exists():
|
|||
|
|
raise FileNotFoundError(
|
|||
|
|
f"Expected {required_file} inside base checkpoint: {args.base_checkpoint}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---- Load pretrained model ---------------------------------------------
|
|||
|
|
# LLM.from_pretrained() reads config.yaml + model.pt and returns the model on CPU.
|
|||
|
|
# We move it to the target device immediately after loading.
|
|||
|
|
#
|
|||
|
|
# NOTE: fp8_model_init() is intentionally NOT used here (same as pretrain.py).
|
|||
|
|
# MXFP8Tensor weights are incompatible with DDP's _broadcast_coalesced.
|
|||
|
|
# Weights stay in float32; TransformerEngine quantizes on-the-fly inside fp8_autocast.
|
|||
|
|
model = LLM.from_pretrained(args.base_checkpoint)
|
|||
|
|
|
|||
|
|
# When FP8 flag is passed at SFT time, enable it on the loaded config.
|
|||
|
|
# This is useful if the pretrained model was trained without FP8 but you
|
|||
|
|
# want to fine-tune with FP8 precision (the TE layers must exist in the model).
|
|||
|
|
if args.use_fp8:
|
|||
|
|
model.config.use_fp8 = True
|
|||
|
|
|
|||
|
|
# Move model to target device in bfloat16 (more memory-efficient than fp32
|
|||
|
|
# for fine-tuning, and required when BF16 autocast + TE are active).
|
|||
|
|
model = model.to(device=device, dtype=torch.bfloat16)
|
|||
|
|
|
|||
|
|
# ---- Gradient checkpointing ----------------------------------------
|
|||
|
|
# Trades activation memory for recomputation during backward pass.
|
|||
|
|
# Especially useful for large models / long sequences in SFT.
|
|||
|
|
if hasattr(model, 'gradient_checkpointing_enable'):
|
|||
|
|
model.gradient_checkpointing_enable()
|
|||
|
|
if rank == 0:
|
|||
|
|
print("[INFO] Gradient checkpointing enabled")
|
|||
|
|
|
|||
|
|
# FP8 alignment check: (batch_size × seq_len) must be divisible by 8.
|
|||
|
|
if model.config.use_fp8:
|
|||
|
|
seq_len = model.config.max_seq_len
|
|||
|
|
if (args.batch_size * seq_len) % 8 != 0:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"FP8: batch_size × max_seq_len = {args.batch_size} × {seq_len} "
|
|||
|
|
f"= {args.batch_size * seq_len} must be divisible by 8."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if is_main_process():
|
|||
|
|
total_params = sum(p.numel() for p in model.parameters())
|
|||
|
|
print(f"Pretrained model loaded: {total_params:,} parameters")
|
|||
|
|
print(f"LMConfig: {model.config}")
|
|||
|
|
|
|||
|
|
# ---- Wrap in DDP -------------------------------------------------------
|
|||
|
|
if is_ddp:
|
|||
|
|
from torch.nn.parallel import DistributedDataParallel as DDP
|
|||
|
|
|
|||
|
|
model = DDP(
|
|||
|
|
model,
|
|||
|
|
device_ids=[local_rank],
|
|||
|
|
output_device=local_rank,
|
|||
|
|
gradient_as_bucket_view=True,
|
|||
|
|
bucket_cap_mb=800,
|
|||
|
|
find_unused_parameters=False,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---- Tokenizer ---------------------------------------------------------
|
|||
|
|
tokenizer_path = _resolve_tokenizer_path(args)
|
|||
|
|
if is_main_process():
|
|||
|
|
print(f"Loading tokenizer from: {tokenizer_path}")
|
|||
|
|
|
|||
|
|
# Use the fast tokenizers library (same as the rest of the project).
|
|||
|
|
from tokenizers import Tokenizer # type: ignore[import]
|
|||
|
|
tokenizer = Tokenizer.from_file(str(tokenizer_path))
|
|||
|
|
|
|||
|
|
# ---- Dataset & DataLoader ----------------------------------------------
|
|||
|
|
# Import SFTDataset (created separately alongside this file).
|
|||
|
|
# SFTDataset returns (input_ids, targets) where prompt token positions in
|
|||
|
|
# targets are filled with -1. The Trainer._compute_loss already uses
|
|||
|
|
# ignore_index=-1, so only response tokens contribute to the gradient.
|
|||
|
|
from data.sft_dataset import SFTDataset # type: ignore[import]
|
|||
|
|
|
|||
|
|
max_seq_len_cfg = (
|
|||
|
|
model.config.max_seq_len
|
|||
|
|
if not isinstance(model, torch.nn.parallel.DistributedDataParallel)
|
|||
|
|
else model.module.config.max_seq_len
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# DDP optimization: rank 0 does tokenization + cache, other ranks load cache.
|
|||
|
|
# This avoids 8× redundant work and 8× memory usage.
|
|||
|
|
# Rank 0 gets all 64 CPU cores for parallel tokenization (reserve 8 for system)
|
|||
|
|
tok_workers = 64 if is_main_process() else 0
|
|||
|
|
if is_ddp:
|
|||
|
|
if is_main_process():
|
|||
|
|
# Rank 0: full tokenization with parallel workers + save cache
|
|||
|
|
train_dataset = SFTDataset(
|
|||
|
|
data_path=args.sft_data,
|
|||
|
|
tokenizer=tokenizer,
|
|||
|
|
max_seq_len=max_seq_len_cfg,
|
|||
|
|
tokenizer_path=tokenizer_path,
|
|||
|
|
num_workers=tok_workers,
|
|||
|
|
)
|
|||
|
|
# Barrier: wait for rank 0 to finish tokenization and save cache
|
|||
|
|
torch.distributed.barrier()
|
|||
|
|
if not is_main_process():
|
|||
|
|
# Other ranks: load from cache (rank 0 already saved it)
|
|||
|
|
train_dataset = SFTDataset(
|
|||
|
|
data_path=args.sft_data,
|
|||
|
|
tokenizer=tokenizer,
|
|||
|
|
max_seq_len=max_seq_len_cfg,
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
train_dataset = SFTDataset(
|
|||
|
|
data_path=args.sft_data,
|
|||
|
|
tokenizer=tokenizer,
|
|||
|
|
max_seq_len=max_seq_len_cfg,
|
|||
|
|
tokenizer_path=tokenizer_path,
|
|||
|
|
num_workers=tok_workers,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if is_ddp:
|
|||
|
|
train_sampler: DistributedSampler | RandomSampler = DistributedSampler(
|
|||
|
|
train_dataset,
|
|||
|
|
num_replicas=world_size,
|
|||
|
|
rank=rank,
|
|||
|
|
shuffle=True,
|
|||
|
|
seed=args.seed,
|
|||
|
|
)
|
|||
|
|
shuffle = False
|
|||
|
|
else:
|
|||
|
|
train_sampler = RandomSampler(train_dataset)
|
|||
|
|
shuffle = False # Sampler is provided; DataLoader must not also shuffle.
|
|||
|
|
|
|||
|
|
train_loader = DataLoader(
|
|||
|
|
train_dataset,
|
|||
|
|
batch_size=args.batch_size,
|
|||
|
|
sampler=train_sampler,
|
|||
|
|
# SFT datasets are typically small enough that 2–4 workers suffice.
|
|||
|
|
# We use 4 to balance I/O with CPU parsing overhead from JSONL.
|
|||
|
|
num_workers=4,
|
|||
|
|
pin_memory=True,
|
|||
|
|
drop_last=True,
|
|||
|
|
prefetch_factor=2,
|
|||
|
|
persistent_workers=True,
|
|||
|
|
collate_fn=dynamic_collate_fn,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---- Pretrain Data Mixing (forgetting prevention) -----------------------
|
|||
|
|
# When --pretrain_data is specified, create a second DataLoader for pretrain
|
|||
|
|
# data and wrap both in MixingDataLoader. This interleaves SFT and pretrain
|
|||
|
|
# batches (default 70/30 ratio) so the model retains pretrained knowledge.
|
|||
|
|
pretrain_sampler = None
|
|||
|
|
if args.pretrain_data is not None:
|
|||
|
|
if not args.pretrain_data.exists():
|
|||
|
|
raise FileNotFoundError(f"Pretrain data not found: {args.pretrain_data}")
|
|||
|
|
|
|||
|
|
from data import PackedDataset
|
|||
|
|
|
|||
|
|
max_seq_len = (
|
|||
|
|
model.config.max_seq_len
|
|||
|
|
if not isinstance(model, torch.nn.parallel.DistributedDataParallel)
|
|||
|
|
else model.module.config.max_seq_len
|
|||
|
|
)
|
|||
|
|
pretrain_dataset = PackedDataset(args.pretrain_data, seq_len=max_seq_len)
|
|||
|
|
|
|||
|
|
if is_ddp:
|
|||
|
|
pretrain_sampler = DistributedSampler(
|
|||
|
|
pretrain_dataset,
|
|||
|
|
num_replicas=world_size,
|
|||
|
|
rank=rank,
|
|||
|
|
shuffle=True,
|
|||
|
|
seed=args.seed + 1000, # different seed from SFT
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
pretrain_sampler = RandomSampler(pretrain_dataset)
|
|||
|
|
|
|||
|
|
pretrain_loader = DataLoader(
|
|||
|
|
pretrain_dataset,
|
|||
|
|
batch_size=args.batch_size,
|
|||
|
|
sampler=pretrain_sampler,
|
|||
|
|
num_workers=4,
|
|||
|
|
pin_memory=True,
|
|||
|
|
drop_last=True,
|
|||
|
|
prefetch_factor=2,
|
|||
|
|
persistent_workers=True,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Wrap both loaders in MixingDataLoader
|
|||
|
|
effective_loader = MixingDataLoader(
|
|||
|
|
sft_loader=train_loader,
|
|||
|
|
pretrain_loader=pretrain_loader,
|
|||
|
|
pretrain_ratio=args.pretrain_mix_ratio,
|
|||
|
|
sft_sampler=train_sampler if is_ddp else None,
|
|||
|
|
pretrain_sampler=pretrain_sampler if is_ddp else None,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if is_main_process():
|
|||
|
|
print(
|
|||
|
|
f"[INFO] Data mixing enabled: "
|
|||
|
|
f"{(1 - args.pretrain_mix_ratio) * 100:.0f}% SFT + "
|
|||
|
|
f"{args.pretrain_mix_ratio * 100:.0f}% pretrain"
|
|||
|
|
)
|
|||
|
|
print(f"[INFO] Pretrain data: {args.pretrain_data} ({len(pretrain_dataset):,} samples)")
|
|||
|
|
else:
|
|||
|
|
effective_loader = train_loader
|
|||
|
|
|
|||
|
|
# Optional validation loader.
|
|||
|
|
# NOTE: The current Trainer implementation does not yet accept a val_loader
|
|||
|
|
# argument; the eval_interval config field is reserved for future use.
|
|||
|
|
# We construct the loader here so that once Trainer gains eval support,
|
|||
|
|
# wiring it in requires only passing val_loader=val_loader below.
|
|||
|
|
val_loader: DataLoader | None = None
|
|||
|
|
if args.val_data is not None:
|
|||
|
|
if not args.val_data.exists():
|
|||
|
|
raise FileNotFoundError(f"Validation data not found: {args.val_data}")
|
|||
|
|
if is_ddp:
|
|||
|
|
if is_main_process():
|
|||
|
|
val_dataset = SFTDataset(
|
|||
|
|
data_path=args.val_data,
|
|||
|
|
tokenizer=tokenizer,
|
|||
|
|
max_seq_len=train_dataset.max_seq_len,
|
|||
|
|
tokenizer_path=tokenizer_path,
|
|||
|
|
num_workers=tok_workers,
|
|||
|
|
)
|
|||
|
|
torch.distributed.barrier()
|
|||
|
|
if not is_main_process():
|
|||
|
|
val_dataset = SFTDataset(
|
|||
|
|
data_path=args.val_data,
|
|||
|
|
tokenizer=tokenizer,
|
|||
|
|
max_seq_len=train_dataset.max_seq_len,
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
val_dataset = SFTDataset(
|
|||
|
|
data_path=args.val_data,
|
|||
|
|
tokenizer=tokenizer,
|
|||
|
|
max_seq_len=train_dataset.max_seq_len,
|
|||
|
|
tokenizer_path=tokenizer_path,
|
|||
|
|
num_workers=tok_workers,
|
|||
|
|
)
|
|||
|
|
val_loader = DataLoader(
|
|||
|
|
val_dataset,
|
|||
|
|
batch_size=args.batch_size,
|
|||
|
|
shuffle=False,
|
|||
|
|
num_workers=2,
|
|||
|
|
pin_memory=True,
|
|||
|
|
drop_last=False,
|
|||
|
|
collate_fn=dynamic_collate_fn,
|
|||
|
|
)
|
|||
|
|
if is_main_process():
|
|||
|
|
print(f"Validation dataset: {len(val_dataset):,} samples")
|
|||
|
|
|
|||
|
|
# ---- Optimizer ---------------------------------------------------------
|
|||
|
|
# Use the same two-group split (weight_decay / no weight_decay) as pretrain.
|
|||
|
|
# Unwrap DDP to get the raw model's parameters.
|
|||
|
|
raw_model = getattr(model, "module", model)
|
|||
|
|
param_groups = build_optimizer_param_groups(raw_model, args.weight_decay)
|
|||
|
|
optimizer = torch.optim.AdamW(
|
|||
|
|
param_groups,
|
|||
|
|
lr=args.lr,
|
|||
|
|
betas=(0.9, 0.95),
|
|||
|
|
eps=1e-8,
|
|||
|
|
fused=torch.cuda.is_available(), # Use fused kernel when on CUDA.
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---- TrainConfig -------------------------------------------------------
|
|||
|
|
# Set use_fp8 from the (possibly overridden) model config so Trainer builds
|
|||
|
|
# the correct FP8 recipe and wraps forward passes in fp8_autocast.
|
|||
|
|
use_fp8 = raw_model.config.use_fp8
|
|||
|
|
|
|||
|
|
train_config = TrainConfig(
|
|||
|
|
max_steps=args.max_steps,
|
|||
|
|
checkpoint_dir=str(args.checkpoint_dir),
|
|||
|
|
grad_accum_steps=args.grad_accum,
|
|||
|
|
use_fp8=use_fp8,
|
|||
|
|
log_file=str(args.log_file) if args.log_file is not None else None,
|
|||
|
|
save_interval=args.save_interval,
|
|||
|
|
log_interval=10,
|
|||
|
|
eval_interval=args.eval_interval,
|
|||
|
|
max_grad_norm=args.max_grad_norm,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---- LR Scheduler ------------------------------------------------------
|
|||
|
|
scheduler = get_cosine_schedule_with_warmup(
|
|||
|
|
optimizer=optimizer,
|
|||
|
|
warmup_steps=args.warmup_steps,
|
|||
|
|
total_steps=train_config.max_steps,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---- Resume from SFT checkpoint ----------------------------------------
|
|||
|
|
# When --resume is given we restore the SFT optimizer/scheduler state as
|
|||
|
|
# well so learning rate, momentum buffers, etc. are correctly restored.
|
|||
|
|
# NOTE: This resumes SFT training, NOT the pretrain checkpoint.
|
|||
|
|
# The pretrain weights were already loaded above via from_pretrained().
|
|||
|
|
start_step = 0
|
|||
|
|
if args.resume is not None:
|
|||
|
|
if not args.resume.exists():
|
|||
|
|
raise FileNotFoundError(f"Resume checkpoint not found: {args.resume}")
|
|||
|
|
start_step, resume_loss = load_checkpoint(
|
|||
|
|
path=args.resume,
|
|||
|
|
model=model,
|
|||
|
|
optimizer=optimizer,
|
|||
|
|
scheduler=scheduler,
|
|||
|
|
)
|
|||
|
|
if is_main_process():
|
|||
|
|
print(f"Resumed SFT from {args.resume} at step {start_step} (loss={resume_loss:.4f})")
|
|||
|
|
|
|||
|
|
if args.resume is not None and isinstance(train_sampler, DistributedSampler):
|
|||
|
|
steps_per_epoch = len(train_loader)
|
|||
|
|
approx_epoch = start_step // steps_per_epoch if steps_per_epoch > 0 else 0
|
|||
|
|
train_sampler.set_epoch(approx_epoch)
|
|||
|
|
if is_main_process():
|
|||
|
|
print(f"[INFO] Resume: sampler epoch set to {approx_epoch}")
|
|||
|
|
|
|||
|
|
# ---- Checkpoint directory ----------------------------------------------
|
|||
|
|
args.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
# ---- Copy tokenizer to checkpoint dir for easy deployment later --------
|
|||
|
|
# This mirrors the tokenizer into the SFT checkpoint root so that the
|
|||
|
|
# final checkpoint directory is self-contained for convert_to_hf.py, etc.
|
|||
|
|
if is_main_process():
|
|||
|
|
dest_tok = args.checkpoint_dir / "tokenizer.json"
|
|||
|
|
if not dest_tok.exists():
|
|||
|
|
shutil.copy2(str(tokenizer_path), str(dest_tok))
|
|||
|
|
print(f"Tokenizer copied to {dest_tok}")
|
|||
|
|
|
|||
|
|
# ---- Trainer -----------------------------------------------------------
|
|||
|
|
# When data mixing is active, pass effective_loader (MixingDataLoader).
|
|||
|
|
# MixingDataLoader handles its own epoch cycling, so no external sampler needed.
|
|||
|
|
trainer = Trainer(
|
|||
|
|
model=model,
|
|||
|
|
train_loader=effective_loader,
|
|||
|
|
optimizer=optimizer,
|
|||
|
|
scheduler=scheduler,
|
|||
|
|
config=train_config,
|
|||
|
|
device=device,
|
|||
|
|
rank=rank,
|
|||
|
|
sampler=train_sampler if is_ddp and args.pretrain_data is None else None,
|
|||
|
|
val_loader=val_loader,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---- Signal handlers for graceful shutdown ----------------------------
|
|||
|
|
import signal as _signal_mod
|
|||
|
|
|
|||
|
|
_trainer_ref = trainer
|
|||
|
|
|
|||
|
|
def _graceful_shutdown_handler(signum, frame):
|
|||
|
|
sig_name = _signal_mod.Signals(signum).name
|
|||
|
|
if is_main_process():
|
|||
|
|
import datetime as _dt
|
|||
|
|
ts = _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|||
|
|
msg = (
|
|||
|
|
f"[{ts}] [SIGNAL] Received {sig_name} (signum={signum}). "
|
|||
|
|
f"Initiating graceful shutdown..."
|
|||
|
|
)
|
|||
|
|
print(f"\n{msg}")
|
|||
|
|
if args.log_file is not None:
|
|||
|
|
try:
|
|||
|
|
with open(args.log_file, "a", encoding="utf-8") as f:
|
|||
|
|
f.write(msg + "\n")
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
_trainer_ref.request_shutdown(sig_name)
|
|||
|
|
|
|||
|
|
for _sig in (_signal_mod.SIGHUP, _signal_mod.SIGTERM):
|
|||
|
|
_signal_mod.signal(_sig, _graceful_shutdown_handler)
|
|||
|
|
|
|||
|
|
# ---- SFT banner --------------------------------------------------------
|
|||
|
|
if is_main_process():
|
|||
|
|
import datetime
|
|||
|
|
|
|||
|
|
inner_config = raw_model.config
|
|||
|
|
eff_batch_seqs = args.batch_size * args.grad_accum * world_size
|
|||
|
|
eff_tokens_per_step = eff_batch_seqs * inner_config.max_seq_len
|
|||
|
|
train_samples = len(train_dataset)
|
|||
|
|
precision_label = "FP8 (MXFP8BlockScaling)" if use_fp8 else "BF16"
|
|||
|
|
nccl_debug = os.environ.get("NCCL_DEBUG", "not set")
|
|||
|
|
omp_threads = os.environ.get("OMP_NUM_THREADS", "not set")
|
|||
|
|
|
|||
|
|
mix_label = "none"
|
|||
|
|
if args.pretrain_data is not None:
|
|||
|
|
mix_label = (
|
|||
|
|
f"{(1 - args.pretrain_mix_ratio) * 100:.0f}% SFT + "
|
|||
|
|
f"{args.pretrain_mix_ratio * 100:.0f}% pretrain"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
print(
|
|||
|
|
f"\n{'='*70}\n"
|
|||
|
|
f" LLM Supervised Fine-Tuning — "
|
|||
|
|
f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|||
|
|
f"{'='*70}\n"
|
|||
|
|
f" base ckpt : {args.base_checkpoint}\n"
|
|||
|
|
f" sft data : {args.sft_data} ({train_samples:,} samples)\n"
|
|||
|
|
f" data mix : {mix_label}\n"
|
|||
|
|
f" model : {inner_config.num_params:,} params | "
|
|||
|
|
f"d_model={inner_config.d_model} n_layers={inner_config.n_layers}\n"
|
|||
|
|
f" precision : {precision_label}\n"
|
|||
|
|
f" GPUs : {world_size} | batch/GPU={args.batch_size} "
|
|||
|
|
f"grad_accum={args.grad_accum}\n"
|
|||
|
|
f" eff_batch : {eff_batch_seqs} seqs "
|
|||
|
|
f"= {eff_tokens_per_step:,} tok/step\n"
|
|||
|
|
f" max_steps : {train_config.max_steps:,}\n"
|
|||
|
|
f" lr : {args.lr:.2e} "
|
|||
|
|
f"warmup={args.warmup_steps} weight_decay={args.weight_decay}\n"
|
|||
|
|
f" ckpt_dir : {args.checkpoint_dir}\n"
|
|||
|
|
f" env : OMP_NUM_THREADS={omp_threads} NCCL_DEBUG={nccl_debug}\n"
|
|||
|
|
f"{'='*70}\n"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---- NEFTune -----------------------------------------------------------
|
|||
|
|
# Add uniform noise to embeddings during training to improve instruction
|
|||
|
|
# following (Jain et al., 2023). Hook is registered on the raw (non-DDP)
|
|||
|
|
# model so it survives DDP's internal module wrapping.
|
|||
|
|
neftune_alpha = getattr(args, 'neftune_alpha', 5.0)
|
|||
|
|
neftune_handle = add_neftune_hook(raw_model, noise_alpha=neftune_alpha)
|
|||
|
|
if rank == 0:
|
|||
|
|
if neftune_handle is not None:
|
|||
|
|
print(f"[INFO] NEFTune enabled (noise_alpha={neftune_alpha})")
|
|||
|
|
else:
|
|||
|
|
print("[WARN] NEFTune disabled - embedding layer not found")
|
|||
|
|
|
|||
|
|
# ---- Train -------------------------------------------------------------
|
|||
|
|
try:
|
|||
|
|
trainer.train(start_step=start_step)
|
|||
|
|
except KeyboardInterrupt:
|
|||
|
|
if is_main_process():
|
|||
|
|
print("\n[INFO] SFT interrupted by user (KeyboardInterrupt).")
|
|||
|
|
except Exception as e:
|
|||
|
|
import traceback
|
|||
|
|
if is_main_process():
|
|||
|
|
tb = traceback.format_exc()
|
|||
|
|
print(f"\n[ERROR] SFT failed at rank {rank}:\n{tb}")
|
|||
|
|
if args.log_file is not None:
|
|||
|
|
with open(args.log_file, "a", encoding="utf-8") as f:
|
|||
|
|
import datetime
|
|||
|
|
f.write(
|
|||
|
|
f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] "
|
|||
|
|
f"[FATAL] {tb}\n"
|
|||
|
|
)
|
|||
|
|
raise
|
|||
|
|
finally:
|
|||
|
|
# Remove NEFTune hook so the model is clean for inference/saving.
|
|||
|
|
if neftune_handle is not None:
|
|||
|
|
neftune_handle.remove()
|
|||
|
|
if is_ddp:
|
|||
|
|
cleanup_ddp()
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|