CoolFace
Modelpublic

pathcosmos/EVAFRILL-Mo-3B

sourceHugging Facemitupdated 6mo agoView on Hugging Face
1likes30downloads
sft.py855 linesDownload Raw Back to scripts
1"""2train/sft.py — Supervised Fine-Tuning (SFT) entry point.3 4Loads a pretrained checkpoint and fine-tunes it on instruction/conversation5data using SFTDataset, which masks prompt tokens with ignore_index=-1 so only6the assistant response tokens contribute to the loss.7 8Launch single-GPU:9    python train/sft.py \\10        --base_checkpoint checkpoints/korean_1b_fp8_run1/checkpoint-0034000 \\11        --sft_data data/sft/train.jsonl \\12        --device cuda:013 14Launch multi-GPU (DDP via torchrun, 7 GPU):15    torchrun --nproc_per_node=7 train/sft.py \\16        --base_checkpoint checkpoints/3b_final/checkpoint-0319772 \\17        --sft_data data/sft_combined/train_filtered.jsonl18 19KEY DIFFERENCES from pretrain.py:20  - Loads weights from a pretrained checkpoint via LLM.from_pretrained()21  - Uses SFTDataset (JSONL instruction data) instead of PackedDataset22  - Lower default learning rate (2e-5 vs 2e-4)23  - Fewer default steps (3000 vs 100000)24  - Copies tokenizer.json to checkpoint_dir for easy deployment25"""26 27from __future__ import annotations28 29import argparse30import os31import random32import signal33import shutil34import sys35from pathlib import Path36 37import numpy as np38import torch39import torch.nn.functional as F40from torch.utils.data import DataLoader, DistributedSampler, RandomSampler41 42# B200 Tensor Core 최대 활용: TF32 matmul + cuDNN43torch.backends.cuda.matmul.allow_tf32 = True44torch.backends.cudnn.allow_tf32 = True45torch.set_float32_matmul_precision("high")  # TF32 precision for fp32 matmul46 47# Allow imports from the project root regardless of working directory.48_PROJECT_ROOT = Path(__file__).resolve().parent.parent49if str(_PROJECT_ROOT) not in sys.path:50    sys.path.insert(0, str(_PROJECT_ROOT))51 52from model import LLM53from train.trainer import TrainConfig, Trainer54from train.utils import (55    cleanup_ddp,56    get_cosine_schedule_with_warmup,57    is_main_process,58    load_checkpoint,59    setup_ddp,60)61 62# ---------------------------------------------------------------------------63# Optional TransformerEngine import (FP8 support)64# ---------------------------------------------------------------------------65try:66    import transformer_engine.pytorch as te  # type: ignore[import]67    HAS_TE = True68except ImportError:69    te = None  # type: ignore[assignment]70    HAS_TE = False71 72 73# ---------------------------------------------------------------------------74# Argument parsing75# ---------------------------------------------------------------------------76 77 78def parse_args() -> argparse.Namespace:79    parser = argparse.ArgumentParser(80        description="Supervised Fine-Tuning (SFT) of a pretrained decoder-only LLM.",81        formatter_class=argparse.ArgumentDefaultsHelpFormatter,82    )83 84    # --- Required paths -----------------------------------------------------85    parser.add_argument(86        "--base_checkpoint",87        type=Path,88        required=True,89        help=(90            "Path to the pretrained checkpoint directory. "91            "Must contain model.pt and config.yaml (produced by save_checkpoint)."92        ),93    )94    parser.add_argument(95        "--sft_data",96        type=Path,97        required=True,98        help="Path to the JSONL SFT training data file.",99    )100 101    # --- Optional paths -----------------------------------------------------102    parser.add_argument(103        "--val_data",104        type=Path,105        default=None,106        help="Optional path to JSONL SFT validation data file.",107    )108    parser.add_argument(109        "--checkpoint_dir",110        type=Path,111        default=Path("checkpoints/korean_1b_sft"),112        help="Root directory for saving SFT checkpoints.",113    )114    parser.add_argument(115        "--resume",116        type=Path,117        default=None,118        help="Path to an SFT checkpoint directory to resume fine-tuning from.",119    )120    parser.add_argument(121        "--tokenizer",122        type=Path,123        default=None,124        help=(125            "Override path to tokenizer.json. "126            "Defaults to <base_checkpoint>/tokenizer.json, "127            "then falls back to tokenizer/korean_sp/tokenizer.json."128        ),129    )130    parser.add_argument(131        "--log_file",132        type=Path,133        default=None,134        help=(135            "Path to a text file for structured training logs (rank-0 only). "136            "If omitted, logs go only to stdout."137        ),138    )139 140    # --- Training hyper-parameters ------------------------------------------141    parser.add_argument(142        "--max_steps",143        type=int,144        default=3000,145        help="Total number of optimiser steps.",146    )147    parser.add_argument(148        "--batch_size",149        type=int,150        default=4,151        help="Per-GPU micro-batch size.",152    )153    parser.add_argument(154        "--lr",155        type=float,156        default=2e-5,157        help=(158            "Peak learning rate. "159            "SFT uses a much lower lr than pretraining (2e-5 vs 2e-4) "160            "to preserve pretrained representations."161        ),162    )163    parser.add_argument(164        "--weight_decay",165        type=float,166        default=0.01,167        help="AdamW weight decay. Lower than pretrain (0.01 vs 0.1).",168    )169    parser.add_argument(170        "--warmup_steps",171        type=int,172        default=100,173        help="Number of linear LR warmup steps.",174    )175    parser.add_argument(176        "--grad_accum",177        type=int,178        default=2,179        help="Gradient accumulation steps.",180    )181    parser.add_argument(182        "--seed",183        type=int,184        default=42,185        help="Base random seed (rank offset is added automatically in DDP).",186    )187    parser.add_argument(188        "--use_fp8",189        action="store_true",190        default=False,191        help=(192            "Enable TransformerEngine FP8 training "193            "(requires B200/H100, uses MXFP8BlockScaling)."194        ),195    )196 197    # --- Single-GPU device override (ignored when using torchrun) -----------198    parser.add_argument(199        "--device",200        type=str,201        default=None,202        help=(203            "Explicit device string (e.g. 'cuda:0'). "204            "Ignored when running under torchrun (DDP auto-assigns devices)."205        ),206    )207 208    parser.add_argument(209        "--config", type=Path, default=None,210        help="YAML config file. Values under 'train:' section are used as CLI defaults.",211    )212    parser.add_argument("--save_interval", type=int, default=500, help="Checkpoint save interval (steps).")213    parser.add_argument("--eval_interval", type=int, default=250, help="Validation eval interval (steps).")214    parser.add_argument("--neftune_alpha", type=float, default=5.0, help="NEFTune noise magnitude (0 to disable).")215    parser.add_argument("--no_fp8", action="store_true", default=False, help="Force disable FP8 even if pretrained config has use_fp8=True.")216    parser.add_argument("--num_workers", type=int, default=4, help="Number of DataLoader worker processes.")217    parser.add_argument("--max_val_batches", type=int, default=0, help="Max validation batches (0=unlimited).")218 219    # First pass: just get --config220    args, remaining = parser.parse_known_args()221 222    # Load YAML config and apply values as defaults223    if args.config is not None:224        if not args.config.exists():225            raise FileNotFoundError(f"Config file not found: {args.config}")226        import yaml227        with open(args.config, "r") as f:228            yaml_cfg = yaml.safe_load(f)229        train_section = yaml_cfg.get("train", {})230        yaml_to_arg = {231            "max_steps": "max_steps",232            "batch_size": "batch_size",233            "lr": "lr",234            "weight_decay": "weight_decay",235            "warmup_steps": "warmup_steps",236            "grad_accum_steps": "grad_accum",237            "save_interval": "save_interval",238            "eval_interval": "eval_interval",239            "neftune_alpha": "neftune_alpha",240            "max_val_batches": "max_val_batches",241        }242        new_defaults = {}243        for yaml_key, arg_name in yaml_to_arg.items():244            if yaml_key in train_section:245                new_defaults[arg_name] = train_section[yaml_key]246        if new_defaults:247            parser.set_defaults(**new_defaults)248 249    return parser.parse_args()250 251 252# ---------------------------------------------------------------------------253# Seed helper254# ---------------------------------------------------------------------------255 256 257def set_seed(seed: int) -> None:258    """Set deterministic seeds for Python, NumPy, and PyTorch."""259    random.seed(seed)260    np.random.seed(seed)261    torch.manual_seed(seed)262    torch.cuda.manual_seed_all(seed)263 264 265# ---------------------------------------------------------------------------266# Optimizer parameter groups267# (Copied from pretrain.py to avoid circular import; identical logic)268# ---------------------------------------------------------------------------269 270 271def build_optimizer_param_groups(272    model: torch.nn.Module,273    weight_decay: float,274) -> list[dict]:275    """276    Split parameters into two groups:277      - decay group   : weight tensors with ndim >= 2 (Linear, etc.)278      - no-decay group: bias, LayerNorm/RMSNorm weights, and embedding weights279 280    This follows standard practice (e.g. GPT-style training).281    """282    decay_params: list[torch.nn.Parameter] = []283    no_decay_params: list[torch.nn.Parameter] = []284 285    # Module types whose parameters should never be decayed.286    no_decay_module_types = (287        torch.nn.Embedding,288        torch.nn.LayerNorm,289    )290    # Also skip any parameter whose name ends with '.bias'.291    no_decay_name_suffixes = ("bias",)292 293    # Collect module-level exclusions.294    no_decay_module_params: set[int] = set()295    for module in model.modules():296        if isinstance(module, no_decay_module_types):297            for param in module.parameters(recurse=False):298                no_decay_module_params.add(id(param))299 300    seen: set[int] = set()301    for name, param in model.named_parameters():302        if not param.requires_grad:303            continue304        if id(param) in seen:305            continue306        seen.add(id(param))307 308        if (309            id(param) in no_decay_module_params310            or any(name.endswith(sfx) for sfx in no_decay_name_suffixes)311            or param.ndim < 2312        ):313            no_decay_params.append(param)314        else:315            decay_params.append(param)316 317    return [318        {"params": decay_params, "weight_decay": weight_decay},319        {"params": no_decay_params, "weight_decay": 0.0},320    ]321 322 323# ---------------------------------------------------------------------------324# Tokenizer resolution helper325# ---------------------------------------------------------------------------326 327 328def _resolve_tokenizer_path(args: argparse.Namespace) -> Path:329    """330    Determine the tokenizer path in priority order:331      1. Explicit --tokenizer argument332      2. tokenizer.json inside the base_checkpoint directory333      3. Project default: tokenizer/korean_sp/tokenizer.json334    """335    if args.tokenizer is not None:336        p = Path(args.tokenizer)337        if not p.exists():338            raise FileNotFoundError(f"Tokenizer not found at --tokenizer path: {p}")339        return p340 341    ckpt_tok = args.base_checkpoint / "tokenizer.json"342    if ckpt_tok.exists():343        return ckpt_tok344 345    default_tok = _PROJECT_ROOT / "tokenizer" / "korean_sp" / "tokenizer.json"346    if default_tok.exists():347        return default_tok348 349    raise FileNotFoundError(350        "Could not locate tokenizer.json. Tried:\n"351        f"  1. {ckpt_tok}\n"352        f"  2. {default_tok}\n"353        "Use --tokenizer to specify an explicit path."354    )355 356 357# ---------------------------------------------------------------------------358# Dynamic padding collate function359# ---------------------------------------------------------------------------360 361 362def dynamic_collate_fn(batch: list) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:363    """364    Collate function that pads each batch to its own maximum sequence length365    instead of a fixed global max_seq_len.  This reduces wasted FLOPs on366    short sequences and speeds up SFT which tends to have highly variable367    response lengths.368 369    Pads to the batch-local max, aligned to 64 tokens (for Flash Attention370    efficiency), with a floor of 512 tokens so micro-batches are not too short.371 372    Args:373        batch: List of ``(input_ids, labels)`` tuples from SFTDataset.374 375    Returns:376        Tuple of ``(input_ids, labels, attention_mask)`` tensors shaped377        ``[B, max_len]``.378        ``input_ids``      is right-padded with 0 (pad token).379        ``labels``         is right-padded with -1 (cross-entropy ignore_index).380        ``attention_mask`` is 1 for real tokens, 0 for padding.381    """382    # 64-token alignment + minimum 512 floor383    raw_max = max(item[0].size(0) for item in batch)384    max_len = max(512, ((raw_max + 63) // 64) * 64)385 386    input_ids_list, labels_list, mask_list = [], [], []387    for ids, labs in batch:388        pad_len = max_len - ids.size(0)389        input_ids_list.append(F.pad(ids, (0, pad_len), value=0))390        labels_list.append(F.pad(labs, (0, pad_len), value=-1))391        mask_list.append(392            F.pad(torch.ones(ids.size(0), dtype=torch.long), (0, pad_len), value=0)393        )394 395    return (396        torch.stack(input_ids_list),397        torch.stack(labels_list),398        torch.stack(mask_list),399    )400 401 402# ---------------------------------------------------------------------------403# NEFTune helper404# ---------------------------------------------------------------------------405 406 407def add_neftune_hook(model: torch.nn.Module, noise_alpha: float = 10.0):408    """409    Register a forward hook on the model's input embedding layer that adds410    uniform noise scaled by noise_alpha during training (NEFTune).411 412    Reference: "NEFTune: Noisy Embeddings Improve Instruction Finetuning"413    (Jain et al., 2023). https://arxiv.org/abs/2310.05914414 415    Args:416        model:       Raw (non-DDP) model instance.417        noise_alpha: Noise magnitude parameter (paper default: 10).418 419    Returns:420        The hook handle (call ``handle.remove()`` to deactivate), or None if421        the embedding layer could not be located.422    """423    # Unwrap DDP if needed424    raw = model.module if hasattr(model, "module") else model425 426    # 1) Try the standard HuggingFace accessor first.427    embedding: torch.nn.Embedding | None = None428    if hasattr(raw, "get_input_embeddings"):429        try:430            emb = raw.get_input_embeddings()431            if isinstance(emb, torch.nn.Embedding):432                embedding = emb433        except Exception:434            pass435 436    # 2) Fallback: walk common attribute paths found in open-source LLMs.437    if embedding is None:438        for attr_path in [439            "embedding",440            "embed_tokens",441            "token_embedding",442            "wte",443            "word_embeddings",444            "tok_embeddings",445            "transformer.wte",446            "model.embed_tokens",447            "model.embedding",448        ]:449            obj = raw450            for part in attr_path.split("."):451                obj = getattr(obj, part, None)452                if obj is None:453                    break454            if obj is not None and isinstance(obj, torch.nn.Embedding):455                embedding = obj456                break457 458    if embedding is None:459        print("[WARN] NEFTune: embedding layer을 찾지 못함, NEFTune 비활성화")460        return None461 462    print(463        f"[INFO] NEFTune: {type(embedding).__name__} hook 등록 "464        f"(shape={tuple(embedding.weight.shape)}, alpha={noise_alpha})"465    )466 467    def _hook(468        module: torch.nn.Module,469        inp: tuple,470        out: torch.Tensor,471    ) -> torch.Tensor:472        if module.training:473            # out shape: [B, seq_len, d_model]474            mag = noise_alpha / ((out.size(1) * out.size(2)) ** 0.5)475            out = out + torch.empty_like(out).uniform_(-mag, mag)476        return out477 478    return embedding.register_forward_hook(_hook)479 480 481# ---------------------------------------------------------------------------482# Main483# ---------------------------------------------------------------------------484 485 486def main() -> None:487    args = parse_args()488 489    # ---- Distributed setup -------------------------------------------------490    is_ddp = "RANK" in os.environ491    rank = 0492    local_rank = 0493    world_size = 1494 495    if is_ddp:496        rank, local_rank, world_size, device = setup_ddp()497    else:498        # Single-GPU: honour --device flag, else pick cuda:0 or cpu.499        if args.device is not None:500            device = torch.device(args.device)501        elif torch.cuda.is_available():502            device = torch.device("cuda:0")503        else:504            device = torch.device("cpu")505 506    # Per-rank seed so data shuffling differs across replicas.507    set_seed(args.seed + rank)508 509    # ---- NUMA affinity for optimal GPU↔CPU memory locality ---------------510    # B200 topology: GPU 0-3 → NUMA node 0 (cores 0-35)511    #                GPU 4-6 → NUMA node 1 (cores 36-71)  [7 GPU 환경]512    try:513        if local_rank < 4:514            os.sched_setaffinity(0, set(range(0, 36)))   # NUMA node 0515        else:516            os.sched_setaffinity(0, set(range(36, 72)))   # NUMA node 1517        if is_main_process():518            print(f"NUMA affinity: rank {rank} (GPU {local_rank}) → "519                  f"{'NUMA0 cores 0-35' if local_rank < 4 else 'NUMA1 cores 36-71'}")520    except (AttributeError, OSError) as e:521        if is_main_process():522            print(f"[WARN] NUMA affinity failed: {e}")523 524    # ---- Validate base checkpoint ------------------------------------------525    if not args.base_checkpoint.exists():526        raise FileNotFoundError(527            f"Base checkpoint directory not found: {args.base_checkpoint}"528        )529    for required_file in ("model.pt", "config.yaml"):530        if not (args.base_checkpoint / required_file).exists():531            raise FileNotFoundError(532                f"Expected {required_file} inside base checkpoint: {args.base_checkpoint}"533            )534 535    # ---- Load pretrained model ---------------------------------------------536    # LLM.from_pretrained() reads config.yaml + model.pt and returns the model on CPU.537    # We move it to the target device immediately after loading.538    #539    # NOTE: fp8_model_init() is intentionally NOT used here (same as pretrain.py).540    # MXFP8Tensor weights are incompatible with DDP's _broadcast_coalesced.541    # Weights stay in float32; TransformerEngine quantizes on-the-fly inside fp8_autocast.542    model = LLM.from_pretrained(args.base_checkpoint)543 544    # FP8 override: --no_fp8 forces BF16 even if pretrained config had use_fp8=True.545    # --use_fp8 enables FP8 if pretrained config had it disabled.546    if args.no_fp8:547        model.config.use_fp8 = False548    elif args.use_fp8:549        model.config.use_fp8 = True550 551    # Move model to target device in bfloat16 (more memory-efficient than fp32552    # for fine-tuning, and required when BF16 autocast + TE are active).553    model = model.to(device=device, dtype=torch.bfloat16)554 555    # ---- Gradient checkpointing ----------------------------------------556    # Trades activation memory for recomputation during backward pass.557    # Especially useful for large models / long sequences in SFT.558    if hasattr(model, 'gradient_checkpointing_enable'):559        model.gradient_checkpointing_enable()560        if rank == 0:561            print("[INFO] Gradient checkpointing enabled")562 563    # FP8 alignment check: (batch_size × seq_len) must be divisible by 8.564    if model.config.use_fp8:565        seq_len = model.config.max_seq_len566        if (args.batch_size * seq_len) % 8 != 0:567            raise ValueError(568                f"FP8: batch_size × max_seq_len = {args.batch_size} × {seq_len} "569                f"= {args.batch_size * seq_len} must be divisible by 8."570            )571 572    if is_main_process():573        total_params = sum(p.numel() for p in model.parameters())574        print(f"Pretrained model loaded: {total_params:,} parameters")575        print(f"LMConfig: {model.config}")576 577    # ---- Wrap in DDP -------------------------------------------------------578    if is_ddp:579        from torch.nn.parallel import DistributedDataParallel as DDP580 581        model = DDP(582            model,583            device_ids=[local_rank],584            output_device=local_rank,585            gradient_as_bucket_view=True,586            bucket_cap_mb=800,587            find_unused_parameters=False,588        )589 590    # ---- Tokenizer ---------------------------------------------------------591    tokenizer_path = _resolve_tokenizer_path(args)592    if is_main_process():593        print(f"Loading tokenizer from: {tokenizer_path}")594 595    # Use the fast tokenizers library (same as the rest of the project).596    from tokenizers import Tokenizer  # type: ignore[import]597    tokenizer = Tokenizer.from_file(str(tokenizer_path))598 599    # ---- Dataset & DataLoader ----------------------------------------------600    # Import SFTDataset (created separately alongside this file).601    # SFTDataset returns (input_ids, targets) where prompt token positions in602    # targets are filled with -1.  The Trainer._compute_loss already uses603    # ignore_index=-1, so only response tokens contribute to the gradient.604    from data.sft_dataset import SFTDataset  # type: ignore[import]605 606    train_dataset = SFTDataset(607        data_path=args.sft_data,608        tokenizer=tokenizer,609        max_seq_len=model.config.max_seq_len610        if not isinstance(model, torch.nn.parallel.DistributedDataParallel)611        else model.module.config.max_seq_len,612    )613 614    if is_ddp:615        train_sampler: DistributedSampler | RandomSampler = DistributedSampler(616            train_dataset,617            num_replicas=world_size,618            rank=rank,619            shuffle=True,620            seed=args.seed,621        )622        shuffle = False623    else:624        train_sampler = RandomSampler(train_dataset)625        shuffle = False  # Sampler is provided; DataLoader must not also shuffle.626 627    train_loader = DataLoader(628        train_dataset,629        batch_size=args.batch_size,630        sampler=train_sampler,631        # SFT datasets are typically small enough that 2–4 workers suffice.632        # We use 4 to balance I/O with CPU parsing overhead from JSONL.633        num_workers=args.num_workers,634        pin_memory=True,635        drop_last=True,636        prefetch_factor=2,637        persistent_workers=True,638        collate_fn=dynamic_collate_fn,639    )640 641    # Optional validation loader.642    # NOTE: The current Trainer implementation does not yet accept a val_loader643    # argument; the eval_interval config field is reserved for future use.644    # We construct the loader here so that once Trainer gains eval support,645    # wiring it in requires only passing val_loader=val_loader below.646    val_loader: DataLoader | None = None647    if args.val_data is not None:648        if not args.val_data.exists():649            raise FileNotFoundError(f"Validation data not found: {args.val_data}")650        val_dataset = SFTDataset(651            data_path=args.val_data,652            tokenizer=tokenizer,653            max_seq_len=train_dataset.max_seq_len,654        )655        val_loader = DataLoader(656            val_dataset,657            batch_size=args.batch_size,658            shuffle=False,659            num_workers=2,660            pin_memory=True,661            drop_last=False,662            collate_fn=dynamic_collate_fn,663        )664        if is_main_process():665            print(f"Validation dataset: {len(val_dataset):,} samples")666 667    # ---- Optimizer ---------------------------------------------------------668    # Use the same two-group split (weight_decay / no weight_decay) as pretrain.669    # Unwrap DDP to get the raw model's parameters.670    raw_model = getattr(model, "module", model)671    param_groups = build_optimizer_param_groups(raw_model, args.weight_decay)672    optimizer = torch.optim.AdamW(673        param_groups,674        lr=args.lr,675        betas=(0.9, 0.95),676        eps=1e-8,677        fused=torch.cuda.is_available(),  # Use fused kernel when on CUDA.678    )679 680    # ---- TrainConfig -------------------------------------------------------681    # Set use_fp8 from the (possibly overridden) model config so Trainer builds682    # the correct FP8 recipe and wraps forward passes in fp8_autocast.683    use_fp8 = raw_model.config.use_fp8684 685    train_config = TrainConfig(686        max_steps=args.max_steps,687        checkpoint_dir=str(args.checkpoint_dir),688        grad_accum_steps=args.grad_accum,689        use_fp8=use_fp8,690        log_file=str(args.log_file) if args.log_file is not None else None,691        save_interval=args.save_interval,692        log_interval=10,693        eval_interval=args.eval_interval,694        max_val_batches=args.max_val_batches,695    )696 697    # ---- LR Scheduler ------------------------------------------------------698    scheduler = get_cosine_schedule_with_warmup(699        optimizer=optimizer,700        warmup_steps=args.warmup_steps,701        total_steps=train_config.max_steps,702    )703 704    # ---- Resume from SFT checkpoint ----------------------------------------705    # When --resume is given we restore the SFT optimizer/scheduler state as706    # well so learning rate, momentum buffers, etc. are correctly restored.707    # NOTE: This resumes SFT training, NOT the pretrain checkpoint.708    #       The pretrain weights were already loaded above via from_pretrained().709    start_step = 0710    if args.resume is not None:711        if not args.resume.exists():712            raise FileNotFoundError(f"Resume checkpoint not found: {args.resume}")713        start_step, resume_loss = load_checkpoint(714            path=args.resume,715            model=model,716            optimizer=optimizer,717            scheduler=scheduler,718        )719        if is_main_process():720            print(f"Resumed SFT from {args.resume} at step {start_step} (loss={resume_loss:.4f})")721 722    if args.resume is not None and isinstance(train_sampler, DistributedSampler):723        steps_per_epoch = len(train_loader)724        approx_epoch = start_step // steps_per_epoch if steps_per_epoch > 0 else 0725        train_sampler.set_epoch(approx_epoch)726        if is_main_process():727            print(f"[INFO] Resume: sampler epoch set to {approx_epoch}")728 729    # ---- Checkpoint directory ----------------------------------------------730    args.checkpoint_dir.mkdir(parents=True, exist_ok=True)731 732    # ---- Copy tokenizer to checkpoint dir for easy deployment later --------733    # This mirrors the tokenizer into the SFT checkpoint root so that the734    # final checkpoint directory is self-contained for convert_to_hf.py, etc.735    if is_main_process():736        dest_tok = args.checkpoint_dir / "tokenizer.json"737        if not dest_tok.exists():738            shutil.copy2(str(tokenizer_path), str(dest_tok))739            print(f"Tokenizer copied to {dest_tok}")740 741    # ---- Trainer -----------------------------------------------------------742    trainer = Trainer(743        model=model,744        train_loader=train_loader,745        optimizer=optimizer,746        scheduler=scheduler,747        config=train_config,748        device=device,749        rank=rank,750        sampler=train_sampler if is_ddp else None,751        val_loader=val_loader,752    )753 754    # ---- Signal handlers for graceful shutdown ----------------------------755    import signal as _signal_mod756 757    _trainer_ref = trainer758 759    def _graceful_shutdown_handler(signum, frame):760        sig_name = _signal_mod.Signals(signum).name761        if is_main_process():762            import datetime as _dt763            ts = _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")764            msg = (765                f"[{ts}] [SIGNAL] Received {sig_name} (signum={signum}). "766                f"Initiating graceful shutdown..."767            )768            print(f"\n{msg}")769            if args.log_file is not None:770                try:771                    with open(args.log_file, "a", encoding="utf-8") as f:772                        f.write(msg + "\n")773                except Exception:774                    pass775        _trainer_ref.request_shutdown(sig_name)776 777    for _sig in (_signal_mod.SIGHUP, _signal_mod.SIGTERM):778        _signal_mod.signal(_sig, _graceful_shutdown_handler)779 780    # ---- SFT banner --------------------------------------------------------781    if is_main_process():782        import datetime783 784        inner_config = raw_model.config785        eff_batch_seqs = args.batch_size * args.grad_accum * world_size786        eff_tokens_per_step = eff_batch_seqs * inner_config.max_seq_len787        train_samples = len(train_dataset)788        precision_label = "FP8 (MXFP8BlockScaling)" if use_fp8 else "BF16"789        nccl_debug = os.environ.get("NCCL_DEBUG", "not set")790        omp_threads = os.environ.get("OMP_NUM_THREADS", "not set")791 792        print(793            f"\n{'='*70}\n"794            f"  LLM Supervised Fine-Tuning — "795            f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"796            f"{'='*70}\n"797            f"  base ckpt : {args.base_checkpoint}\n"798            f"  sft data  : {args.sft_data} ({train_samples:,} samples)\n"799            f"  model     : {inner_config.num_params:,} params  |  "800            f"d_model={inner_config.d_model}  n_layers={inner_config.n_layers}\n"801            f"  precision : {precision_label}\n"802            f"  GPUs      : {world_size}  |  batch/GPU={args.batch_size}  "803            f"grad_accum={args.grad_accum}\n"804            f"  eff_batch : {eff_batch_seqs} seqs  "805            f"= {eff_tokens_per_step:,} tok/step\n"806            f"  max_steps : {train_config.max_steps:,}\n"807            f"  lr        : {args.lr:.2e}  "808            f"warmup={args.warmup_steps}  weight_decay={args.weight_decay}\n"809            f"  ckpt_dir  : {args.checkpoint_dir}\n"810            f"  env       : OMP_NUM_THREADS={omp_threads}  NCCL_DEBUG={nccl_debug}\n"811            f"{'='*70}\n"812        )813 814    # ---- NEFTune -----------------------------------------------------------815    # Add uniform noise to embeddings during training to improve instruction816    # following (Jain et al., 2023).  Hook is registered on the raw (non-DDP)817    # model so it survives DDP's internal module wrapping.818    neftune_alpha = getattr(args, 'neftune_alpha', 5.0)819    neftune_handle = add_neftune_hook(raw_model, noise_alpha=neftune_alpha)820    if rank == 0:821        if neftune_handle is not None:822            print(f"[INFO] NEFTune enabled (noise_alpha={neftune_alpha})")823        else:824            print("[WARN] NEFTune disabled - embedding layer not found")825 826    # ---- Train -------------------------------------------------------------827    try:828        trainer.train(start_step=start_step)829    except KeyboardInterrupt:830        if is_main_process():831            print("\n[INFO] SFT interrupted by user (KeyboardInterrupt).")832    except Exception as e:833        import traceback834        if is_main_process():835            tb = traceback.format_exc()836            print(f"\n[ERROR] SFT failed at rank {rank}:\n{tb}")837            if args.log_file is not None:838                with open(args.log_file, "a", encoding="utf-8") as f:839                    import datetime840                    f.write(841                        f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] "842                        f"[FATAL] {tb}\n"843                    )844        raise845    finally:846        # Remove NEFTune hook so the model is clean for inference/saving.847        if neftune_handle is not None:848            neftune_handle.remove()849        if is_ddp:850            cleanup_ddp()851 852 853if __name__ == "__main__":854    main()855