| """Training / inference helpers.""" |
|
|
| import inspect |
| import json |
| import os |
| import re |
| import shutil |
| import subprocess |
| from pathlib import Path |
| from typing import Any, Iterable, List, Literal, Optional, Union |
|
|
| import lightning as L |
| import torch |
| import torch.nn as nn |
| from lightning.fabric.loggers import CSVLogger, TensorBoardLogger |
| from lightning.fabric.strategies import FSDPStrategy |
| from lightning.fabric.utilities.load import _lazy_load as lazy_load |
| from lightning.pytorch.cli import instantiate_class |
| from lightning.pytorch.loggers import WandbLogger |
| from typing_extensions import Self |
|
|
|
|
| |
|
|
| def init_out_dir(out_dir: Path) -> Path: |
| if not isinstance(out_dir, Path): |
| out_dir = Path(out_dir) |
| if not out_dir.is_absolute() and "LIGHTNING_ARTIFACTS_DIR" in os.environ: |
| return Path(os.getenv("LIGHTNING_ARTIFACTS_DIR")) / out_dir |
| return out_dir |
|
|
|
|
| def find_resume_path(resume: Union[bool, Literal["auto"], Path], out_dir: Path) -> Optional[Path]: |
| if not resume or isinstance(resume, Path): |
| return resume or None |
| |
| candidates = list(out_dir.rglob("lit_model.pth")) |
| if not candidates: |
| if resume == "auto": |
| return None |
| raise FileNotFoundError( |
| f"resume=True but no `lit_model.pth` under {out_dir}; set resume='auto' to skip or False to start fresh." |
| ) |
| return max(candidates, key=lambda p: p.stat().st_mtime) |
|
|
|
|
| |
|
|
| def find_multiple(n: int, k: int) -> int: |
| if n % k == 0: |
| return n |
| return n + k - (n % k) |
|
|
|
|
| def num_parameters(module: nn.Module, requires_grad: Optional[bool] = None) -> int: |
| total = 0 |
| for p in module.parameters(): |
| if requires_grad is None or p.requires_grad == requires_grad: |
| total += p.numel() |
| return total |
|
|
|
|
| |
|
|
| def chunked_cross_entropy( |
| logits: Union[torch.Tensor, List[torch.Tensor]], |
| targets: torch.Tensor, |
| chunk_size: int = 128, |
| ignore_index: int = -100, |
| ) -> torch.Tensor: |
| """Memory-friendly cross-entropy: optionally chunk along the seq axis to bound the activation peak.""" |
| if isinstance(logits, list): |
| if chunk_size == 0: |
| logits = torch.cat(logits, dim=1).reshape(-1, logits[0].size(-1)) |
| return torch.nn.functional.cross_entropy(logits, targets.reshape(-1), ignore_index=ignore_index) |
| logit_chunks = [c.reshape(-1, c.size(-1)) for c in logits] |
| target_chunks = [c.reshape(-1) for c in targets.split(logits[0].size(1), dim=1)] |
| loss_chunks = [ |
| torch.nn.functional.cross_entropy(lc, tc, ignore_index=ignore_index, reduction="none") |
| for lc, tc in zip(logit_chunks, target_chunks) |
| ] |
| non_masked = (targets != ignore_index).sum() |
| return torch.cat(loss_chunks).sum() / non_masked.maximum(torch.ones_like(non_masked)) |
|
|
| logits = logits.reshape(-1, logits.size(-1)) |
| targets = targets.reshape(-1) |
| if chunk_size == 0: |
| return torch.nn.functional.cross_entropy(logits, targets, ignore_index=ignore_index) |
| logit_chunks = logits.split(chunk_size) |
| target_chunks = targets.split(chunk_size) |
| loss_chunks = [ |
| torch.nn.functional.cross_entropy(lc, tc, ignore_index=ignore_index, reduction="none") |
| for lc, tc in zip(logit_chunks, target_chunks) |
| ] |
| non_masked = (targets != ignore_index).sum() |
| return torch.cat(loss_chunks).sum() / non_masked.maximum(torch.ones_like(non_masked)) |
|
|
|
|
| |
|
|
| def get_default_supported_precision(training: bool) -> str: |
| """Return bf16 if the GPU supports it, otherwise 16-bit. Picks `-mixed` for training, `-true` for inference.""" |
| if torch.cuda.is_available() and torch.cuda.is_bf16_supported(): |
| return "bf16-mixed" if training else "bf16-true" |
| return "16-mixed" if training else "16-true" |
|
|
|
|
| def load_checkpoint(fabric: L.Fabric, model: nn.Module, checkpoint_path: Path, strict: bool = True) -> None: |
| if isinstance(fabric.strategy, FSDPStrategy): |
| fabric.load_raw(checkpoint_path, model, strict=strict) |
| else: |
| state_dict = lazy_load(checkpoint_path) |
| state_dict = state_dict.get("model", state_dict) |
| model.load_state_dict(state_dict, strict=strict) |
|
|
|
|
| |
|
|
| class CycleIterator: |
| """Indefinitely cycles through an iterable, exposing the current epoch count.""" |
|
|
| def __init__(self, iterable: Iterable) -> None: |
| self.iterable = iterable |
| self.epoch = 0 |
| self._iterator = None |
|
|
| def __next__(self) -> Any: |
| if self._iterator is None: |
| self._iterator = iter(self.iterable) |
| try: |
| return next(self._iterator) |
| except StopIteration: |
| self._iterator = iter(self.iterable) |
| self.epoch += 1 |
| return next(self._iterator) |
|
|
| def __iter__(self) -> Self: |
| return self |
|
|
|
|
| |
|
|
| def copy_config_files(source_dir: Path, out_dir: Path) -> None: |
| """Copy model_config.yaml and tokenizer files (when present) into the output directory.""" |
| for name in ("config.json", "generation_config.json", "model_config.yaml", |
| "tokenizer.json", "tokenizer.model", "tokenizer_config.json"): |
| src = source_dir / name |
| if src.exists(): |
| shutil.copy(src, out_dir) |
|
|
|
|
| def parse_devices(devices: Union[str, int]) -> int: |
| if devices in (-1, "auto"): |
| return torch.cuda.device_count() or 1 |
| if isinstance(devices, int) and devices > 0: |
| return devices |
| raise ValueError(f"Devices must be 'auto' or a positive integer, got: {devices!r}") |
|
|
|
|
| def choose_logger( |
| logger_name: Literal["csv", "tensorboard", "wandb"], |
| out_dir: Path, |
| name: str, |
| log_interval: int = 1, |
| resume: Optional[bool] = None, |
| **kwargs: Any, |
| ): |
| if logger_name == "csv": |
| return CSVLogger(root_dir=(out_dir / "logs"), name="csv", flush_logs_every_n_steps=log_interval, **kwargs) |
| if logger_name == "tensorboard": |
| return TensorBoardLogger(root_dir=(out_dir / "logs"), name="tensorboard", **kwargs) |
| if logger_name == "wandb": |
| return WandbLogger(project=name, resume=resume, **kwargs) |
| raise ValueError(f"`--logger_name={logger_name}` is not a valid option. Choose from 'csv', 'tensorboard', 'wandb'.") |
|
|
|
|
| def instantiate_torch_optimizer(optimizer, model_parameters, **kwargs): |
| """Resolve `optimizer` (str name like 'AdamW' or a dict with class_path/init_args) to an actual optimizer.""" |
| if isinstance(optimizer, str): |
| class_module, class_name = optimizer.rsplit(".", 1) if "." in optimizer else ("torch.optim", optimizer) |
| module = __import__(class_module, fromlist=[class_name]) |
| optimizer_cls = getattr(module, class_name) |
| valid = set(inspect.signature(optimizer_cls).parameters) |
| kwargs = {k: v for k, v in kwargs.items() if k in valid} |
| return optimizer_cls(model_parameters, **kwargs) |
| if isinstance(optimizer, dict): |
| optimizer = dict(optimizer) |
| class_module, class_name = optimizer["class_path"].rsplit(".", 1) |
| module = __import__(class_module, fromlist=[class_name]) |
| valid = set(inspect.signature(getattr(module, class_name)).parameters) |
| optimizer["init_args"].update({k: v for k, v in kwargs.items() if k in valid}) |
| return instantiate_class(model_parameters, optimizer) |
| raise ValueError(f'Unrecognized "optimizer" value: {optimizer}') |
|
|
|
|
| |
|
|
| def check_nvlink_connectivity(fabric=None): |
| """Best-effort check that the multi-GPU interconnect is fast. Prints a warning if not.""" |
| custom_print = fabric.print if fabric is not None else print |
| if os.getenv("RANK", "0") != "0": |
| return |
| try: |
| if not torch.cuda.is_available(): |
| custom_print("No GPUs available") |
| return |
| gpu_name = torch.cuda.get_device_properties(0).name.lower() |
| if "nvidia" in gpu_name: |
| _check_nvidia_connectivity(custom_print) |
| elif "advanced micro devices" in gpu_name or "amd" in gpu_name: |
| _check_amd_connectivity(custom_print) |
| else: |
| custom_print(f"Unrecognized GPU vendor: {gpu_name}") |
| except Exception as e: |
| custom_print(f"An error occurred while checking GPU connectivity: {e}") |
|
|
|
|
| def _check_nvidia_connectivity(custom_print): |
| result = subprocess.run(["nvidia-smi", "topo", "-m"], stdout=subprocess.PIPE, text=True) |
| if result.returncode != 0: |
| custom_print("Failed to run nvidia-smi"); return |
| lines = result.stdout.strip().split("\n") |
| start = next((i for i, line in enumerate(lines) if "GPU0" in line), None) |
| if start is None: |
| custom_print("Failed to parse nvidia-smi output"); return |
| headers = lines[start].split() |
| gpu_count = len([h for h in headers if re.match(r"^GPU\d+$", h)]) |
| all_nvlink = all( |
| all("NV" in conn for conn in row.split()[1:1 + gpu_count] if conn != "X") |
| for row in lines[start + 1: start + 1 + gpu_count] |
| ) |
| if all_nvlink: |
| custom_print("All GPUs are fully connected via NVLink.") |
| else: |
| custom_print("Warning: not all GPUs are fully connected via NVLink — multi-GPU throughput may be suboptimal.") |
|
|
|
|
| def _check_amd_connectivity(custom_print): |
| result = subprocess.run(["rocm-smi", "--showtopotype"], stdout=subprocess.PIPE, text=True) |
| if result.returncode != 0: |
| custom_print("Failed to run rocm-smi"); return |
| lines = result.stdout.strip().split("\n") |
| gpu_idx = next((i for i, line in enumerate(lines) if re.match(r"^\s*GPU0", line)), None) |
| if gpu_idx is None or gpu_idx == 0: |
| custom_print("Failed to parse rocm-smi output (no GPU headers found)"); return |
| headers = lines[gpu_idx - 1].strip().split() |
| gpu_count = len([h for h in headers if re.match(r"^GPU\d+$", h)]) |
| gpu_lines = [l.strip() for l in lines[gpu_idx: gpu_idx + gpu_count] if re.match(r"^\s*GPU\d+", l)] |
| if len(gpu_lines) != gpu_count: |
| custom_print("Mismatch in GPU count when parsing rocm-smi output"); return |
| all_xgmi = all( |
| all(conn in ("XGMI", "0") for conn in row.split()[1:1 + gpu_count]) |
| for row in gpu_lines |
| ) |
| if all_xgmi: |
| custom_print("All GPUs are fully connected via XGMI.") |
| else: |
| custom_print("Warning: not all GPUs are fully connected via XGMI — multi-GPU throughput may be suboptimal.") |
|
|
|
|
| |
|
|
| def fix_and_load_json(s: str): |
| """Tolerant JSON parser: strips trailing commas and inserts missing ones before retrying.""" |
| s = re.sub(r',(\s*[}\]])', r'\1', s) |
| s = re.sub(r'(?<=[}\]0-9truefalsenull"])\s*(\n\s*)"', r',\1"', s) |
| try: |
| return json.loads(s) |
| except json.JSONDecodeError as e: |
| raise ValueError(f"Failed to parse JSON after fixing: {e}") |
|
|
|
|
| def create_finetuning_performance_report(training_time: float, device_type: str) -> str: |
| lines = [ |
| "", |
| "| ------------------------------------------------------", |
| f"| Training Time : {training_time:.2f} s", |
| ] |
| if device_type == "cuda": |
| memory_used = torch.cuda.max_memory_allocated() / 1e9 |
| lines.append(f"| Peak Memory : {memory_used:.2f} GB") |
| lines.append("| ------------------------------------------------------") |
| return "\n".join(lines) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|