feat(diffusion): add SFT loss hub and pre-encoded data manager - #90
feat(diffusion): add SFT loss hub and pre-encoded data manager#90zhihengy wants to merge 17 commits into
Conversation
…log hooks with lazy encoder pool
| # lives in each row's metadata dict under "video" or "image" (images train as single | ||
| # frames and require --sft-num-frames 1). Encoded pairs are cached next to the jsonl | ||
| # under .sft_cache/, one content-addressed file per sample. | ||
| parser.add_argument("--sft-height", type=int, default=None, help="SFT encode height (center crop)") |
There was a problem hiding this comment.
May reuse --diffusion-height and --diffusion-width here
There was a problem hiding this comment.
Same with num-frames; All of these specification parameters should be understood as describing the dimensions of the generated media, whether the media is obtained by preprocessing the data source or generated directly by the rollout engine.
There was a problem hiding this comment.
done (ddc5648) — dropped --sft-height/--sft-width/--sft-num-frames; SFT now reads --diffusion-height/--diffusion-width/--diffusion-output-num-frames. --sft-frame-stride stays (preprocessing-only, no rollout analogue).
|
|
||
|
|
||
| def set_default_diffusion_args(args) -> None: | ||
| if args.loss_type == "sft_loss": |
There was a problem hiding this comment.
We should follow Miles LLM- give args through our sample instead of making a default custom function. These default args can be redundant in the codebase
There was a problem hiding this comment.
done (9dc9b8e) — removed the auto-wiring block; the sample script (scripts/run-diffusion-sft-wan22.sh) now passes the five plugin paths explicitly, and validation rejects sft_loss when a path is missing / still the RL default (with the exact flag to set), so misconfigs fail at parse time instead of deep in the RL data path. Validation-not-defaulting keeps the command line describing what actually runs.
| from miles.utils.misc import load_function | ||
|
|
||
| sft_cfg_cls = load_function(args.train_pipeline_config_path) | ||
| if sft_cfg_cls.encode_sft_sample is TrainPipelineConfig.encode_sft_sample: |
There was a problem hiding this comment.
The encoders are a relatively independent part of the miles, and TrainPipelineConfig should only organize everything that happens in the training engine. We shouldn't make training engine and other parts over-coupled
There was a problem hiding this comment.
| f"--loss-type sft_loss is not supported for {sft_cfg_cls.__name__}: " | ||
| "it does not implement load_sft_encoder/encode_sft_sample" | ||
| ) | ||
| if args.diffusion_flow_shift is None: |
There was a problem hiding this comment.
Let's rename this to fsdp_flow_shift since it's regenerated at training side
There was a problem hiding this comment.
done (ddc5648) — added --fsdp-flow-shift for the training-side sigma grid and SFT now requires it; kept --diffusion-flow-shift untouched as the rollout-engine launch parameter for RL.
|
|
||
| # Formal "no rollout engines" mode: skips engine/router startup, weight sync, and | ||
| # the rollout placement view. Implied by debug_train_only and by SFT. | ||
| args.train_only = args.debug_train_only or args.loss_type == "sft_loss" |
There was a problem hiding this comment.
train_only rename here makes more sense, but we should do as few silent inferences on args as we can since Miles wants to expose every option explicitly to users. Here, let's make debug_train_only an alias of train_only and only do verification for args.train_only==True when loss_type="sft_loss"
There was a problem hiding this comment.
done (bcef656) — --train-only is now a real flag with --debug-train-only as an argparse alias (same dest, old scripts unchanged), the codebase reads only args.train_only, and sft_loss validates train_only==True instead of inferring it; the sample script passes --train-only explicitly.
| """Postprocess the model after FSDP wrap + weight materialization (default: no-op).""" | ||
| return None | ||
|
|
||
| def load_sft_encoder(self, args, device: torch.device): |
There was a problem hiding this comment.
SFT encoders loading: according to Miles' philosophy, encoders should be directly passed as an HF checkpoint name argument option
There was a problem hiding this comment.
| """Load this family's frozen encode components (tokenizer/text encoder/VAE) for SFT caching.""" | ||
| raise NotImplementedError(f"{type(self).__name__} does not implement SFT encoding") | ||
|
|
||
| def encode_sft_sample(self, encoder, pixels: torch.Tensor, prompt: str, generator: torch.Generator) -> dict: |
There was a problem hiding this comment.
Also, this process function should be moved to rollout and not coupled with the training side; maybe let's create an encoder_hub for all encoder logic
There was a problem hiding this comment.
| return noise_pred_neg + scale * (noise_pred_pos - noise_pred_neg) | ||
|
|
||
| @classmethod | ||
| def validate_args(cls, args) -> None: |
There was a problem hiding this comment.
We should put this validation on the encoder side as well, disentangled from the trainer TPC. (sft_num_frames - 1) % 4 == 0 is a Wan encoder/VAE-specific constraint, not a global SFT rule. As we already resolved diffusion_model_family in arguments.py (this arg is designed to make model-specific logic decentralized for easier maintenance), we should directly do model-specific operations according to the args.diffusion_model_family
There was a problem hiding this comment.
We want to keep the train pipeline config only as the driver for the training backend
There was a problem hiding this comment.
done — the 4k+1 constraint moved to encoder_hub/wan2_2.validate_args, dispatched via args.diffusion_model_family at argument validation; Wan's TPC validate_args override is deleted and TPC is back to training-backend-only (#96 + 6486fe6). One accepted limitation: a custom --train-pipeline-config-path resolves family=None, which has no encoder_hub entry, so SFT rejects it until an override is actually needed.
| timesteps_for_model = timesteps | ||
|
|
||
| cond_list = [{key: value.to(device) for key, value in pair["cond_kwargs"].items()} for pair in batch] | ||
| pos_cond = cast_cond_to_dtype( |
There was a problem hiding this comment.
Rockdu TODO: integrate SFT&NFT into input dtype precision control
| from miles.utils.metric_buffer import MetricBuffer | ||
|
|
||
|
|
||
| def sample_grid_indices(ctx: DiffusionLossContext, bsz: int) -> tuple[str, nn.Module, torch.Tensor]: |
There was a problem hiding this comment.
TODO: centralize forward noising logic (in NFT/SFT) into diffusers/local-maintained schedulers and add new args for train-side scheduler designation
…--fsdp-flow-shift Review feedback (PR #90): the media geometry args describe the generated media regardless of whether it comes from preprocessing or the rollout engine, so SFT reuses --diffusion-height/--diffusion-width/ --diffusion-output-num-frames instead of its own --sft-* trio. The SFT training sigma grid is regenerated on the training side, so its shift is now --fsdp-flow-shift (fsdp_* namespace), leaving --diffusion-flow-shift as the rollout-engine launch parameter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…defaults Review feedback (PR #90): follow Miles LLM convention — the sample script passes the five SFT plugin paths explicitly and the framework validates the combination instead of silently rewriting args. Misconfigured runs now fail at argument validation with the exact flag to set, rather than deep in the RL data path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mes its alias Review feedback (PR #90): no silent inference of train_only from loss_type. --train-only is now a real user-facing flag (argparse alias keeps --debug-train-only working) and sft_loss validates it is set instead of setting it. The rollout placement view keeps its seats unconditionally: engine startup is gated by args.train_only, and rollout-side actor pools (the SFT encoder pool) seat there. The debug_rollout_only/train_only exclusion assert now runs before the debug_rollout_only reconfiguration so the combo fails with the intended message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…training-only Review feedback (PR #90): TrainPipelineConfig drives the training backend only. Encoder loading/encoding now lives in miles/rollout/encoder_hub (stacked base PR #96), dispatched by args.diffusion_model_family; the Wan-specific 4k+1 frame constraint validates there too. Encoders load from the explicit --sft-encoder-checkpoint, which also replaces hf_checkpoint in the per-sample cache key since it is what determines cache content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review resolution note — encoder comments are resolved in a stacked PR. The encoder-related review comments (TPC coupling, encoder as explicit HF checkpoint arg, encoder_hub on the rollout side, Wan 4k+1 validation dispatched by model family) are addressed by splitting the encoder logic into a new base PR:
Merge order: #96 first, then this PR. Per-thread replies are under the corresponding inline comments (some may show as outdated after the refactor). |
| return { | ||
| "actor": (pg, actor_pg_reordered_bundle_indices, actor_pg_reordered_gpu_ids), | ||
| "rollout": (pg, rollout_pg_reordered_bundle_indices, rollout_pg_reordered_gpu_ids), | ||
| "actor": (pg, all_reordered_bundle_indices, all_reordered_gpu_ids), |
There was a problem hiding this comment.
From my understanding, this assumes all settings are collocated training, which is not good for later framework evolution.
| IMAGE_EXTENSIONS = {".bmp", ".jpeg", ".jpg", ".png", ".webp"} | ||
|
|
||
|
|
||
| def read_media_clip(path: str, *, height: int, width: int, num_frames: int, frame_stride: int) -> torch.Tensor: |
There was a problem hiding this comment.
Seems like this can be replaced by any general data preprocessing. Do we want to make a custom function here? Just a discussion, we can keep this here for now
| num_grid = len(ctx.scheduler.timesteps) | ||
| if len(ctx.models) == 1: | ||
| component_name, model = next(iter(ctx.models.items())) | ||
| return component_name, model, torch.randint(num_grid, (bsz,)) |
There was a problem hiding this comment.
This may break determinism for load/save
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What
Diffusion SFT on (video, prompt) datasets, delivered entirely through existing plugin seams: a rollout function + convert/log hooks on the RolloutManager side, and the PR #63 prepare/loss hooks on the trainer side. No sglang engines run:
--loss-type sft_lossimplies a formalizedtrain_onlymode. RolloutManager, the driver loop, and placement orchestration are structurally untouched, so an upstream merge sees additive plugin files plus a few flag renames.How
Data plane —
miles/rollout/sft_rollout.py, all plugged via existing--*-pathslots:generate_rollout(via--rollout-function-path): pulls (prompt,metadata.videoormetadata.image) rows from the standardRolloutDataSource(--prompt-data/--input-key; epoch iteration, per-epoch shuffle, and checkpoint resume come from the framework), checks a per-sample content-addressed cache (filename = hash(checkpoint, encode geometry, video path+size+mtime, prompt)), and lazily encodes misses through a persistent encoder actor pool. First epoch pays the encode once; epoch 2+ is all hits; a fully-warm run never loads the encoders at all.convert_samples_to_train_data(via the full-override--custom-convert-samples-to-train-data-path): emits train pairs + the flow-shifted sigma grid; the reward/advantage code path never executes.log_rollout_data(via--custom-rollout-log-function-path): logsrollout/sft_cache_miss,rollout/sft_encode_seconds,rollout/sft_epochon therollout/stepaxis and short-circuits the RL reward stats.Image datasets: Wan trains images as single-frame videos. Rows with
metadata.image(or any image-extension path) read via PIL as[C,1,H,W]and require--sft-num-frames 1; the VAE, cache addressing, and loss path are shape-agnostic. Validated e2e (16-image dataset, 100 steps, 23s encode).Trainer side —
loss_hub/sft.py(unchanged across data-plane iterations):prepare_sft_batchsamples grid sigmas phase-pure per micro-batch (dual-expert Wan2.2 viacomponent_for_timestep, mass-weighted so the marginal stays uniform), corrupts cached latents withx_t = (1-σ)x₀ + σε, and collates cond through the familyTrainPipelineConfig;sft_loss_formulais velocity MSE vsε - x₀.Family seam:
miles/rollout/encoder_hub(base PR #96) — frozen-encoder loading/encoding per model family, dispatched byargs.diffusion_model_family, loading from the explicit--sft-encoder-checkpoint. TrainPipelineConfig stays training-backend-only; families without an encoder_hub entry are rejected at argument validation.Small upstream-friendly cleanups:
--train-onlyformalizes the "no rollout engines" mode as an explicit flag (--debug-train-onlykept as an argparse alias): it gates engine startup, router launch (also fixing the stray router the debug mode used to spawn), weight sync, and eval;sft_lossvalidates it is set rather than inferring it.rm_hub:set/get_reward_placement_grouprenamed toset/get_manager_placement_group(old names kept as aliases) — it publishes the manager's placement for colocated actor pools, and the encoder pool is now its second consumer.Validation
parse_argsround-trip: the sample script passes the five plugin paths explicitly andsft_lossvalidates them (missing path / RL-default rollout function rejected with the exact flag to set); invalid combos (eval, KL, recompute, ref-mode, EMA, n_samples>1) rejected at validation.GPU validation: head-to-head vs VideoX-Fun + before/after
Trained Wan2.2-TI2V-5B LoRA on PAI/X-Fun-Videos-Demo (16 open-source videos, 480x832@24fps, detailed captions), settings matched to the
scripts/wan2.2/README_TRAIN_LORA.mdquick-start of VideoX-Fun as the reference implementation. Both runs in wandb projectSFT: miles-d vs videox-fun.eps - x0Caveat: 16 videos x 100 epochs is the best case for a warm cache (first build took ~60s here and is excluded); the wall-clock gap does not generalize to huge-dataset few-epoch runs, and the before/after results demonstrate fit to the training set, not held-out quality (held-out fixed-(t, eps) eval is planned as the next step).
Smoothed train loss: miles 0.290 -> 0.232, VideoX-Fun 0.267 -> 0.251 (VideoX keeps a hardcoded 10% text dropout and per-epoch temporal jitter, which raises its floor; miles trains fixed offline clips). Wall clock 7 min vs 24 min — the offline-encode payoff (no per-step UMT5 forward).
Fixed-(t, eps) loss on the training set (same frozen noise + timesteps for both models — isolates model improvement from sampling variance):
Before/after generations (training prompts, same seed 42, 480x480x33f, 40 UniPC steps, cfg 5.0; top = base, bottom = after 400-step LoRA SFT — outputs shift toward the training clips' composition and lighting):
Side-by-side animations: p0 | p3 (base left, SFT right; assets on branch
sft-pr90-assets).