"""Train or fine-tune the SEEDS conditional diffusion model.""" from __future__ import annotations import argparse import math import os import numpy as np import torch import torch.distributed as dist from torch import nn from torch.nn.parallel import DistributedDataParallel as DDP from torch.utils.data import DistributedSampler from common import SEEDS, build_model, choose_device, load_config, resolve_path, set_seed from data_loader import SEEDSDataset, build_dataloader def _is_distributed() -> bool: return int(os.environ.get("WORLD_SIZE", "1")) > 1 def _setup_distributed(requested_device: str) -> tuple[torch.device, int, int, bool]: distributed = _is_distributed() if not distributed: return choose_device(requested_device), 0, 1, False if not dist.is_initialized(): backend = "nccl" if torch.cuda.is_available() else "gloo" dist.init_process_group(backend=backend, init_method="env://") rank = dist.get_rank() world_size = dist.get_world_size() local_rank = int(os.environ.get("LOCAL_RANK", rank)) if torch.cuda.is_available(): torch.cuda.set_device(local_rank) device = torch.device("cuda", local_rank) else: device = torch.device("cpu") return device, rank, world_size, True def _denoising_loss( model: nn.Module, clean: torch.Tensor, seeds: torch.Tensor, climate: torch.Tensor, ) -> torch.Tensor: core_model = model.module if isinstance(model, DDP) else model diffusion_time = torch.rand(clean.shape[0], device=clean.device, dtype=clean.dtype) noise = torch.randn_like(clean) sigma = core_model.sigma(diffusion_time).view(-1, 1, 1, 1, 1) noisy = clean + sigma * noise model_input = noisy / torch.sqrt(1.0 + sigma.square()) prediction = model(model_input, seeds, climate, diffusion_time) return ((prediction - noise) ** 2).flatten(1).mean() def _run_validation(model: nn.Module, loader, device: torch.device, distributed: bool) -> float: model.eval() losses = [] cuda_devices = [device.index] if device.type == "cuda" and device.index is not None else [] with torch.random.fork_rng(devices=cuda_devices): torch.manual_seed(12345) with torch.no_grad(): for batch in loader: loss = _denoising_loss( model, batch["targets"].to(device), batch["seeds"].to(device), batch["climate"].to(device), ) losses.append(float(loss)) if not losses: raise RuntimeError("validation loader produced no batches") value = torch.tensor(float(np.mean(losses)), device=device) if distributed: dist.all_reduce(value, op=dist.ReduceOp.SUM) value /= dist.get_world_size() return float(value.item()) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", default="conf/config.yaml") parser.add_argument("--max-steps", type=int, default=None) parser.add_argument("--max-batches", type=int, default=None) parser.add_argument("--device", default=None) parser.add_argument("--finetune", action="store_true") args = parser.parse_args() config = load_config(args.config) device, rank, world_size, distributed = _setup_distributed(args.device or config["training"]["device"]) set_seed(config["project"]["seed"] + rank) data, paths, training = config["data"], config["paths"], config["training"] root = args.config train_path, val_path = resolve_path(paths["train_data"], root), resolve_path(paths["val_data"], root) train_dataset = SEEDSDataset(train_path, len(data["variables"]), data["faces"], data["height"], data["width"], data["seed_count"]) val_dataset = SEEDSDataset(val_path, len(data["variables"]), data["faces"], data["height"], data["width"], data["seed_count"]) train_sampler = DistributedSampler(train_dataset, num_replicas=world_size, rank=rank, shuffle=True) if distributed else None val_sampler = DistributedSampler(val_dataset, num_replicas=world_size, rank=rank, shuffle=False) if distributed else None train_loader = build_dataloader(train_path, len(data["variables"]), data["faces"], data["height"], data["width"], data["seed_count"], training["batch_size"], True, training["num_workers"], args.max_batches, train_sampler, train_dataset) val_loader = build_dataloader(val_path, len(data["variables"]), data["faces"], data["height"], data["width"], data["seed_count"], training["batch_size"], False, training["num_workers"], args.max_batches, val_sampler, val_dataset) model = build_model(config).to(device) checkpoint_path = resolve_path(paths["checkpoint"], root) if args.finetune or training.get("resume"): source = resolve_path(training.get("resume") or paths["checkpoint"], root) if not source.exists(): raise FileNotFoundError(f"checkpoint does not exist: {source}") state = torch.load(source, map_location=device, weights_only=False) model.load_state_dict(state["model"] if "model" in state else state) if distributed: model = DDP(model, device_ids=[device.index] if device.type == "cuda" else None) optimizer = torch.optim.AdamW(model.parameters(), lr=training["learning_rate"], weight_decay=training["weight_decay"]) train_losses, val_losses = [], [] completed_steps = 0 accumulation_steps = training.get("gradient_accumulation_steps", 1) if accumulation_steps < 1: raise ValueError("gradient_accumulation_steps must be positive") loss_ema = None for epoch in range(training["epochs"]): if train_sampler is not None: train_sampler.set_epoch(epoch) model.train() epoch_losses = [] optimizer.zero_grad(set_to_none=True) accumulated_batches = 0 for batch in train_loader: loss = _denoising_loss( model, batch["targets"].to(device), batch["seeds"].to(device), batch["climate"].to(device), ) if not torch.isfinite(loss): raise FloatingPointError(f"non-finite training loss at epoch {epoch + 1}: {loss.item()}") (loss / accumulation_steps).backward() epoch_losses.append(float(loss.detach())) completed_steps += 1 accumulated_batches += 1 if accumulated_batches == accumulation_steps: torch.nn.utils.clip_grad_norm_(model.parameters(), training["grad_clip_norm"]) optimizer.step() optimizer.zero_grad(set_to_none=True) accumulated_batches = 0 if args.max_steps is not None and completed_steps >= args.max_steps: break if accumulated_batches: torch.nn.utils.clip_grad_norm_(model.parameters(), training["grad_clip_norm"]) optimizer.step() optimizer.zero_grad(set_to_none=True) if not epoch_losses: raise RuntimeError("training loader produced no batches") epoch_loss_tensor = torch.tensor([sum(epoch_losses), len(epoch_losses)], device=device, dtype=torch.float64) if distributed: dist.all_reduce(epoch_loss_tensor, op=dist.ReduceOp.SUM) epoch_loss = float((epoch_loss_tensor[0] / epoch_loss_tensor[1]).item()) train_losses.append(epoch_loss) loss_ema = epoch_loss if loss_ema is None else 0.9 * loss_ema + 0.1 * epoch_loss validation = _run_validation(model, val_loader, device, distributed) val_losses.append(validation) if rank == 0: print( f"epoch={epoch + 1}/{training['epochs']} " f"train_loss={epoch_loss:.6f} train_ema={loss_ema:.6f} val_loss={validation:.6f}" ) if args.max_steps is not None and completed_steps >= args.max_steps: break checkpoint_path.parent.mkdir(parents=True, exist_ok=True) if rank == 0: state_dict = model.module.state_dict() if distributed else model.state_dict() torch.save({"model": state_dict, "config": config, "epoch": len(train_losses), "step": completed_steps}, checkpoint_path) np.save(checkpoint_path.parent / "train_loss.npy", np.asarray(train_losses, dtype=np.float32)) np.save(checkpoint_path.parent / "val_loss.npy", np.asarray(val_losses, dtype=np.float32)) if not math.isfinite(train_losses[-1]): raise FloatingPointError("final training loss is not finite") if rank == 0: print(f"saved checkpoint: {checkpoint_path}") if distributed: dist.destroy_process_group() if __name__ == "__main__": main()