diff --git a/jobs/process/BaseSDTrainProcess.py b/jobs/process/BaseSDTrainProcess.py index a9233d6..dc813b0 100644 --- a/jobs/process/BaseSDTrainProcess.py +++ b/jobs/process/BaseSDTrainProcess.py @@ -12,6 +12,8 @@ from typing import Union, List, Optional import numpy as np import yaml +from PIL import Image, ImageOps +from torchvision import transforms from diffusers import T2IAdapter, ControlNetModel from diffusers.training_utils import compute_density_for_timestep_sampling from safetensors.torch import save_file, load_file @@ -40,6 +42,7 @@ from toolkit.network_mixins import Network from toolkit.optimizer import get_optimizer from toolkit.paths import CONFIG_ROOT from toolkit.progress_bar import ToolkitProgressBar +from toolkit.prompt_utils import concat_prompt_embeds from toolkit.reference_adapter import ReferenceAdapter from toolkit.sampler import get_sampler from toolkit.saving import save_t2i_from_diffusers, load_t2i_model, save_ip_adapter_from_diffusers, \ @@ -47,7 +50,7 @@ from toolkit.saving import save_t2i_from_diffusers, load_t2i_model, save_ip_adap from toolkit.scheduler import get_lr_scheduler from toolkit.sd_device_states_presets import get_train_sd_device_state_preset -from toolkit.stable_diffusion_model import StableDiffusion +from toolkit.stable_diffusion_model import StableDiffusion, BlankNetwork from jobs.process import BaseTrainProcess from toolkit.metadata import get_meta_for_safetensors, load_metadata_from_safetensors, add_base_model_info_to_meta, \ @@ -261,6 +264,8 @@ class BaseSDTrainProcess(BaseTrainProcess): self.steps_this_boundary = 0 self.num_consecutive_oom = 0 self.additional_logs = {} + # cached latents, prompt embeds, and fixed noise for validation + self._validation_cache = None def post_process_generate_image_config_list(self, generate_image_config_list: List[GenerateImageConfig]): # override in subclass @@ -1570,6 +1575,146 @@ class BaseSDTrainProcess(BaseTrainProcess): self.load_training_state_from_metadata(latest_save_path) # set trainable params self.sd.adapter = self.adapter + + def setup_validation(self): + # caches everything needed for validation (latents, prompt embeds, fixed noise) + # must be called while the vae and text encoder are still loaded, they may be + # dumped later to save memory + val_config = self.train_config.validation_config + if val_config is None: + return + if not self.accelerator.is_main_process: + return + validation_items = [] + for item in val_config.validation_items: + if not item.image_path: + print_acc("Skipping validation item with no image") + continue + if not os.path.exists(item.image_path): + print_acc(f"Skipping validation item, image not found: {item.image_path}") + continue + validation_items.append(item) + if len(validation_items) == 0: + print_acc("Validation config has no valid validation_items, skipping validation") + return + print_acc(f"Caching validation latents and embeddings for {len(validation_items)} images") + device = self.device_torch + dtype = get_torch_dtype(self.train_config.dtype) + resolution = val_config.resolution + + image_list = [] + prompt_list = [] + for item in validation_items: + img = Image.open(item.image_path) + img = ImageOps.exif_transpose(img).convert('RGB') + # deterministic resize, shortest side to resolution then center crop + img = transforms.Resize(resolution)(img) + img = transforms.CenterCrop(resolution)(img) + tensor = transforms.ToTensor()(img) * 2.0 - 1.0 + image_list.append(tensor) + prompt_list.append(item.prompt) + + fork_devices = [device] if device.type == 'cuda' else [] + with torch.no_grad(), torch.random.fork_rng(devices=fork_devices): + # encode the prompts one at a time so they can be reassembled per sigma later + te = self.sd.text_encoder + te_list = te if isinstance(te, list) else ([te] if te is not None else []) + orig_te_devices = [next(t.parameters()).device for t in te_list] + self.sd.text_encoder_to(device) + embeds_list = [ + self.sd.encode_prompt([prompt]).to('cpu', dtype=torch.float32).detach() + for prompt in prompt_list + ] + for t, te_device in zip(te_list, orig_te_devices): + t.to(te_device) + + # seed so the vae latent dist sampling is always identical + torch.manual_seed(42) + orig_vae_device = self.sd.vae.device + latents = self.sd.encode_images(image_list, device=device, dtype=dtype) + self.sd.vae.to(orig_vae_device) + + # fixed noise per image, seeds start at 42 and increment for each image + noise_list = [] + for i in range(latents.shape[0]): + generator = torch.Generator(device='cpu').manual_seed(42 + i) + noise_list.append( + torch.randn(latents[i:i + 1].shape, generator=generator, dtype=torch.float32) + ) + noise = torch.cat(noise_list, dim=0) + + self._validation_cache = { + 'latents': latents.to('cpu', dtype=torch.float32), + 'noise': noise, + 'embeds': embeds_list, + } + flush() + + def validate(self): + val_config = self.train_config.validation_config + if val_config is None or self._validation_cache is None: + return + if not self.accelerator.is_main_process: + return + device = self.device_torch + dtype = get_torch_dtype(self.train_config.dtype) + sigmas = val_config.validation_sigmas + cache = self._validation_cache + + was_unet_training = self.sd.unet.training + self.sd.unet.eval() + # the network is only active inside its context, without this the base model is validated + network = self.network if self.network is not None else BlankNetwork() + start_multiplier = network.multiplier + network.multiplier = 1.0 + with torch.no_grad(), network: + latents = cache['latents'].to(device, dtype=dtype) + noise = cache['noise'].to(device, dtype=dtype) + num_images = latents.shape[0] + + # single batch of every (image, sigma) pair, ordered sigma major + batch_latents = torch.cat([latents] * len(sigmas), dim=0) + batch_noise = torch.cat([noise] * len(sigmas), dim=0) + batch_embeds = concat_prompt_embeds( + [e.clone().to(device, dtype=dtype) for e in cache['embeds']] * len(sigmas) + ) + + if self.sd.is_flow_matching: + timestep_values = [sigma * 1000.0 for sigma in sigmas] + else: + num_train_timesteps = self.sd.noise_scheduler.config.num_train_timesteps + timestep_values = [ + min(int(round(sigma * num_train_timesteps)), num_train_timesteps - 1) + for sigma in sigmas + ] + timesteps = torch.tensor( + [t for t in timestep_values for _ in range(num_images)], + device=device, + ) + + noisy_latents = self.sd.add_noise(batch_latents, batch_noise, timesteps).detach() + + noise_pred = self.sd.predict_noise( + latents=noisy_latents.to(device, dtype=dtype), + conditional_embeddings=batch_embeds, + timestep=timesteps, + guidance_scale=1.0, + guidance_embedding_scale=self.train_config.cfg_scale, + bypass_guidance_embedding=self.train_config.bypass_guidance_embedding, + ) + + if self.sd.is_flow_matching: + target = batch_noise - batch_latents + elif self.sd.prediction_type == 'v_prediction': + target = self.sd.noise_scheduler.get_velocity(batch_latents, batch_noise, timesteps) + else: + target = batch_noise + + val_loss = torch.nn.functional.mse_loss(noise_pred.float(), target.float()) + self.additional_logs['val/loss'] = val_loss.item() + network.multiplier = start_multiplier + if was_unet_training: + self.sd.unet.train() def run(self): # torch.autograd.set_detect_anomaly(True) @@ -2065,6 +2210,10 @@ class BaseSDTrainProcess(BaseTrainProcess): ) self.lr_scheduler = lr_scheduler + # cache validation latents and embeddings now, the vae and text encoder + # may be dumped before the train loop starts + self.setup_validation() + ### HOOk ### self.before_dataset_load() # load datasets if passed in the root process @@ -2480,6 +2629,18 @@ class BaseSDTrainProcess(BaseTrainProcess): print("\n==== Profile Results ====") print(self.torch_profiler.key_averages().table(sort_by="cpu_time_total", row_limit=1000)) self.timer.stop('train_loop') + + # run validation before any possible sampling/logging for this step + if not did_oom and self.train_config.validation_config is not None: + val_config = self.train_config.validation_config + is_validate_step = ( + self.step_num == self.start_step + or (val_config.validate_every_n_steps and self.step_num % val_config.validate_every_n_steps == 0) + ) + if is_validate_step: + with self.timer('validate'): + self.validate() + if not did_first_flush: flush() did_first_flush = True diff --git a/toolkit/config_modules.py b/toolkit/config_modules.py index ef59002..3b0a0d6 100644 --- a/toolkit/config_modules.py +++ b/toolkit/config_modules.py @@ -337,6 +337,23 @@ class AdapterConfig: self.i2v_do_start_frame: bool = kwargs.get('i2v_do_start_frame', False) +class ValidationItem: + def __init__(self, **kwargs): + self.image_path: str = kwargs.get('image_path', '') + self.prompt: str = kwargs.get('prompt', '') + + +class ValidationConfig: + def __init__(self, **kwargs): + self.validation_items: List[ValidationItem] = [ + item if isinstance(item, ValidationItem) else ValidationItem(**item) + for item in kwargs.get('validation_items', []) + ] + self.resolution: int = kwargs.get('resolution', 512) + self.validate_every_n_steps: int = kwargs.get('validate_every_n_steps', 10) + self.validation_sigmas: List[float] = kwargs.get('validation_sigmas', [1.0, 0.75, 0.5, 0.25]) + + class EmbeddingConfig: def __init__(self, **kwargs): self.trigger = kwargs.get('trigger', 'custom_embedding') @@ -592,6 +609,10 @@ class TrainConfig: self.max_loss_debug: bool = kwargs.get("max_loss_debug", False) # will clip the loss to this amount to prevent wild outliers self.max_loss: Optional[float] = kwargs.get("max_loss", None) + self.validation_config: Optional[ValidationConfig] = None + validation = kwargs.get('validation_config', None) + if validation is not None: + self.validation_config: ValidationConfig = ValidationConfig(**validation) ModelArch = Literal['sd1', 'sd2', 'sd3', 'sdxl', 'pixart', 'pixart_sigma', 'auraflow', 'flux', 'flex1', 'flex2', 'lumina2', 'vega', 'ssd', 'wan21', 'anima'] @@ -1417,5 +1438,3 @@ def validate_configs( if train_config.batch_size > 1 and any(dataset_config.auto_frame_count for dataset_config in dataset_configs): raise ValueError("Cannot use batch size greater than 1 with auto_frame_count. Please set batch_size to 1 or auto_frame_count to False.") - - diff --git a/ui/src/app/jobs/new/SimpleJob.tsx b/ui/src/app/jobs/new/SimpleJob.tsx index e5d51b7..5f9a22a 100644 --- a/ui/src/app/jobs/new/SimpleJob.tsx +++ b/ui/src/app/jobs/new/SimpleJob.tsx @@ -199,6 +199,8 @@ export default function SimpleJob({ const showGPUSelect = !isMac(); + const validationConfig = jobConfig.config.process[0].train.validation_config; + let numDatasetCols = 4; let numSampleTopCols = 4; let datasetStyleClass = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6'; @@ -834,6 +836,136 @@ export default function SimpleJob({ +
+ Validation runs a stable loss check on a fixed set of images. Each image is encoded once at startup + and predicted at the selected sigmas with fixed seeds, so the result is always deterministic and + comparable across the run. The average loss is logged as val/loss every time validation runs. The + images need to match the concept of your dataset, but{' '} + do not include the validation images in the dataset. + They must be images containing the concept you want to train, but not an image trained on. +
+