Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/fsdp_model_roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ The existing loss receives the reference prediction, and custom prepare/loss hoo
access `ctx.reference_models` by component name.

A third source, the teacher (`--teacher-load`, with `--teacher-lora-adapter-path` for its
adapters), is reserved for a DMD2-style loss that miles does not ship yet. Until then it
has no built-in consumer: `--teacher-load` requires a custom prepare/loss/train-output hook,
which reads it from `ctx.teacher_models`, and teacher inference should run inside
`torch.no_grad()`.
adapters), is loaded by `--loss-type dmd2`, without adapters, into its critic group, which
scores the student's samples with it. Otherwise it has no built-in consumer: `--teacher-load`
requires a custom prepare/loss/train-output hook, which reads it from `ctx.teacher_models`, and
teacher inference should run inside `torch.no_grad()`.

Each source may load pretrained PEFT LoRA adapters, one plain PEFT directory (what
`save_pretrained` writes) per `--update-weight-target-module` entry, in the same order:
Expand Down
10 changes: 8 additions & 2 deletions docs/user-guide/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ See [Dtype Control](../advanced/dtype-control.md).

| Flag | Default | What |
|---|---|---|
| `--loss-type` | `policy_loss` | `policy_loss` (Flow-GRPO), `nft`, `sft_loss`, `custom_loss`. |
| `--loss-type` | `policy_loss` | `policy_loss` (Flow-GRPO), `nft`, `sft_loss`, `custom_loss`, `dmd2`. |
| `--advantage-estimator` | `grpo` | Only `grpo` today. |
| `--diffusion-clip-range` | `1e-4` | PPO ratio clip. |
| `--diffusion-adv-clip-max` | `5.0` | Advantage clamp. |
Expand Down Expand Up @@ -260,7 +260,7 @@ See [Dtype Control](../advanced/dtype-control.md).

| Flag | Type | Default | Notes |
|---|---|---|---|
| `--loss-type` | enum | `policy_loss` | `policy_loss` / `nft` / `sft_loss` / `custom_loss`. |
| `--loss-type` | enum | `policy_loss` | `policy_loss` / `nft` / `sft_loss` / `custom_loss` / `dmd2`. |
| `--advantage-estimator` | enum | `grpo` | |
| `--disable-grpo-std-normalization` | flag | off | Subtract the group mean only; skip dividing by the group std. Std normalization is also skipped when `n_samples_per_prompt == 1`, since a one-sample group has no std. With `--globalize-reward-mean` the batch mean is subtracted instead, and the batch-wide std from `--globalize-reward-std` is skipped too. |
| `--globalize-reward-mean` | flag | off | Leave **off** for flow_grpo parity. |
Expand All @@ -285,6 +285,12 @@ See [Dtype Control](../advanced/dtype-control.md).
| `--diffusion-nft-timestep-strategy-path` | str | `…nft.drop_random_steps` | `(args, num_steps, generator) -> list[int]` returning the denoising steps each sample trains on, in training order; `generator` is seeded per sample. Built in: `drop_random_steps` (random order), `drop_final_steps` (schedule order, noisiest first). The final clean output (σ = 0) is never a training step. |
| `--diffusion-nft-num-dropped-timesteps` | int | `0` | Steps the built-in strategies drop: random ones per sample for `drop_random_steps`, the final (lowest-noise) ones for `drop_final_steps`. |
| `--no-diffusion-nft-adaptive-weight` | flag | off | |
| `--dmd2-critic-steps-per-rollout` | int | `5` | Critic optimizer steps per rollout under `dmd2`, before the actor's single step. Each DP shard must split into this many steps. The last step's samples are scored by the critic before that step's update. |
| `--dmd2-teacher-guidance-scale` | float | – | Required by `dmd2`: the teacher's CFG scale when scoring the student's samples; `1` runs it unguided. |
| `--dmd2-score-sigma-range` | float float | `0.02 0.98` | `MIN MAX` of the unshifted sigma at which `dmd2` scores the student's samples: the last critic step draws grid points in this range and reads them through the visual `--fsdp-flow-shift`. The default is the reference DMD2 range. |
| `--dmd2-critic-loss-space` | str | `velocity` | `velocity` or `x0`: the space of the critic's flow-matching loss. `x0` regresses x0 = x_σ − σ·v, which weights the velocity MSE by σ². |
| `--critic-load` | str | `--teacher-load` | `dmd2` only: HF pipeline checkpoint the critic starts from. Its checkpoints go to the sibling `<--save>_critic` dir. |
| `--critic-lr` | float | `--lr` | `dmd2` only: the critic's learning rate. |

### Reward

Expand Down
13 changes: 10 additions & 3 deletions docs/user-guide/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ signatures they expect, and the defaults they replace — in the same spirit as
| **Model family** | `--train-pipeline-config-path` | Family `TrainPipelineConfig` |
| | `--model-backend-path` | Model load / FSDP backend |

`--loss-type` (`policy_loss` / `nft` / `sft_loss` / `custom_loss`) is the
`--loss-type` (`policy_loss` / `nft` / `sft_loss` / `custom_loss` / `dmd2`) is the
shortcut that auto-fills several of the train-side paths; see § Training.

***
Expand Down Expand Up @@ -234,11 +234,16 @@ def buffer_filter(
| `nft` | `data_conversion_hub.nft.expand_samples_to_train_pairs` | `loss_hub.nft.prepare_nft_batch` | `nft_loss_formula` |
| `sft_loss` | none; convert `sft_rollout.convert_samples_to_train_data` | `loss_hub.sft.prepare_sft_batch` | `sft_loss_formula` |
| `custom_loss` | your expand (or convert) | your prepare | your formula |
| `dmd2` | `data_conversion_hub.dmd2.expand_samples_to_train_pairs` | your prepare | your formula |

`sft_loss` auto-fills nothing: it requires the convert, rollout-log, prepare and loss
paths, plus `--train-only` and a rollout function that runs no engines (e.g.
`miles.rollout.sft_rollout.generate_rollout`; see `scripts/run_diffusion_sft_wan22.py`). `custom_loss` behaves like `policy_loss`:
any hook left unset falls back to the Flow-GRPO built-in.
any hook left unset falls back to the Flow-GRPO built-in. `dmd2` also trains a `critic` group
before the actor on each rollout: it starts from `--critic-load` (default `--teacher-load`),
learns the student's samples with the SFT loss at `--critic-lr` (default `--lr`). On its last step
it keeps its own x0 predictions from that step's forward, before the update, and after training hands
them with the teacher's x0 at the same noisy inputs to the actor as `ctx.external_data["critic"]`.

DiT forward always stays in the FSDP actor; the loss hook only computes the
objective.
Expand Down Expand Up @@ -308,7 +313,9 @@ def train_output(ctx, train_pairs) -> Any:

Runs without grad after this trainer group's train steps on a rollout. Every trainer group that
trains later on the same rollout reads the return value of its paired rank (same rank, same
train pairs) as `ctx.external_data[<role>]`.
train pairs) as `ctx.external_data[<role>]`. Prepare and loss hooks see the current optimizer step
as `ctx.optim_step_idx` and can leave inputs for this hook in `ctx.train_output_inputs`, which
starts empty on each train call.

### `--sde-step-backend-path`

Expand Down
9 changes: 7 additions & 2 deletions miles/backends/fsdp_utils/actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,15 @@ def update_weights(self, previous_ema: bool = False) -> None: # type: ignore[ov
self.weight_updater.update_weights()
clear_memory()

def _metric_role_tag(self) -> str:
# Upstream miles tags the train metrics of non-actor trainers, e.g. train/critic-loss.
return "" if self.role == "actor" else f"{self.role}-"

def _log_metrics(self, rollout_id: int, log_dict: dict[str, float], step: int) -> None:
"""Emit already-reduced metrics; every DP group computed the same values."""
if dist.get_rank() != 0:
return
log_dict["train/lr"] = float(self.optimizer.param_groups[0]["lr"])
log_dict[f"train/{self._metric_role_tag()}lr"] = float(self.optimizer.param_groups[0]["lr"])
log_dict["train/epoch"] = float(rollout_id)
log_dict["rollout/step"] = compute_rollout_step(self.args, rollout_id)
log_dict["train/step"] = float(step)
Expand Down Expand Up @@ -443,6 +447,7 @@ def _train_core(self, rollout_id: int, rollout_data, external_data: dict[str, An
microbatch_id = 0
for optim_step_idx, microbatch_ranges in enumerate(microbatch_schedule):
self.optimizer.zero_grad(set_to_none=True)
loss_ctx.optim_step_idx = optim_step_idx

old_log_prob_from_new = self.args.diffusion_recompute_old_log_prob and optim_step_idx == 0

Expand Down Expand Up @@ -489,7 +494,7 @@ def _train_core(self, rollout_id: int, rollout_data, external_data: dict[str, An
self.optimizer.zero_grad(set_to_none=True)
self.global_step += 1

reduced = {f"train/{key}": value for key, value in metrics.reduce().items()}
reduced = {f"train/{self._metric_role_tag()}{key}": value for key, value in metrics.reduce().items()}
self._log_metrics(rollout_id, reduced, step=self.global_step)

if self.custom_train_output_func is not None:
Expand Down
155 changes: 155 additions & 0 deletions miles/backends/fsdp_utils/loss_hub/dmd2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""DMD2 critic-group hooks: the critic (fake score) learns the student's samples and scores those of its last step."""

from __future__ import annotations

import dataclasses

import torch

from miles.backends.fsdp_utils.ema import reshard_model
from miles.backends.fsdp_utils.input_dtype_policy import apply_input_dtype_policy
from miles.backends.fsdp_utils.loss_hub.sft import prepare_sft_batch, sft_loss_formula
from miles.backends.fsdp_utils.loss_hub.types import DiffusionLossContext, PreparedBatch
from miles.utils.metric_buffer import MetricBuffer


def prepare_critic_batch(
ctx: DiffusionLossContext,
batch: list[dict],
*,
pad_to_len: int | None = None,
) -> PreparedBatch:
"""SFT flow matching on the student's x0 predictions, conditioned on their rollout prompts.

The last step's samples are scored at their noisy inputs, so its grid points come from the scoring range.
"""
config = ctx.train_pipeline_config
sft_batch = [
{
"latent": {"visual": pair["x0"]},
"cond_kwargs": config.prepare_cond_kwargs(pair["denoising_env"].pos_cond_kwargs, ctx.device),
}
for pair in batch
]
grid_index_range = None
if _is_scoring_step(ctx):
min_sigma, max_sigma = ctx.args.dmd2_score_sigma_range
num_train_timesteps = config.num_train_timesteps
# Grid index i carries the unshifted sigma 1 - i / num_train_timesteps.
grid_index_range = (round(num_train_timesteps * (1 - max_sigma)), round(num_train_timesteps * (1 - min_sigma)))
return prepare_sft_batch(ctx, sft_batch, pad_to_len=pad_to_len, grid_index_range=grid_index_range)


def critic_loss_formula(
ctx: DiffusionLossContext,
batch: list[dict],
prepared: PreparedBatch,
*,
new_pred: dict[str, torch.Tensor],
old_pred: dict[str, torch.Tensor] | None,
ref_pred: dict[str, torch.Tensor] | None,
metrics: MetricBuffer,
write_old_log_prob: bool = False,
old_log_prob_from_new: bool = False,
) -> torch.Tensor:
"""The SFT loss in --dmd2-critic-loss-space.

On the last step it also keeps the critic's x0 at each noisy input, before that step's update.
"""
latents = prepared.latents["visual"]
sigmas = prepared.extras["sigmas"]["visual"].view(len(batch), *([1] * (latents.ndim - 1)))
if _is_scoring_step(ctx):
x0_fake = latents - sigmas * new_pred["visual"].detach().float()
ctx.train_output_inputs.append(
(batch, prepared.component_name, latents, prepared.timesteps["visual"], sigmas, x0_fake)
)
if ctx.args.dmd2_critic_loss_space == "x0":
# x0 = x_sigma - sigma v, so the MSE on x0 weights the velocity MSE by sigma^2.
x0_target = latents - sigmas * prepared.extras["target"]["visual"]
prepared = dataclasses.replace(prepared, extras={**prepared.extras, "target": {"visual": x0_target}})
new_pred = {"visual": latents - sigmas * new_pred["visual"].float()}
return sft_loss_formula(
ctx,
batch,
prepared,
new_pred=new_pred,
old_pred=old_pred,
ref_pred=ref_pred,
metrics=metrics,
write_old_log_prob=write_old_log_prob,
old_log_prob_from_new=old_log_prob_from_new,
)


def score_last_step_samples(ctx: DiffusionLossContext, train_pairs: list[dict]) -> dict[int, dict]:
"""The teacher's x0 at each scored sample's noisy input, next to the critic's x0 kept there, by sample."""
predictions = {}
for batch, component_name, latents, timesteps, sigmas, x0_fake in ctx.train_output_inputs:
teacher = ctx.teacher_models[component_name]
real_pred = _predict_velocity(ctx, teacher, batch, latents, timesteps, ctx.args.dmd2_teacher_guidance_scale)
for pair, x0_real, fake in zip(batch, latents - sigmas * real_pred, x0_fake, strict=True):
predictions[pair["sample_index"]] = {"x0_real": x0_real.cpu(), "x0_fake": fake.cpu()}

# A no-grad forward leaves FSDP's root parameters gathered; sleep must move the shards.
for model in ctx.teacher_models.values():
reshard_model(model)
return predictions


def _is_scoring_step(ctx: DiffusionLossContext) -> bool:
return ctx.optim_step_idx == ctx.args.num_steps_per_rollout - 1


def _predict_velocity(
ctx: DiffusionLossContext,
model: torch.nn.Module,
batch: list[dict],
latents: torch.Tensor,
timesteps: torch.Tensor,
guidance_scale: float,
) -> torch.Tensor:
"""One velocity prediction, with CFG over the rollout's negative cond when ``guidance_scale > 1``."""
config = ctx.train_pipeline_config
use_cfg = guidance_scale > 1.0
envs = [pair["denoising_env"] for pair in batch]
if use_cfg and any(env.neg_cond_kwargs is None for env in envs):
raise ValueError(
"--dmd2-teacher-guidance-scale > 1 needs the rollout's negative cond, "
"which the engine returns only for requests that run CFG"
)
pos_list = [config.prepare_cond_kwargs(env.pos_cond_kwargs, ctx.device) for env in envs]
neg_list = [config.prepare_cond_kwargs(env.neg_cond_kwargs, ctx.device) for env in envs] if use_cfg else []
cfg_batching = use_cfg and config.cfg_batching
pos_cond = neg_cond = joint_cond = None
if cfg_batching:
joint_cond = config.collate_cond_for_sample_batch(pos_list + neg_list, ctx.device)
else:
pos_cond = config.collate_cond_for_sample_batch(pos_list, ctx.device)
if use_cfg:
neg_cond = config.collate_cond_for_sample_batch(neg_list, ctx.device)

latents_in, timesteps_in, (pos_in, neg_in, joint_in) = apply_input_dtype_policy(
config.input_dtype_policy,
latents={"visual": latents},
timesteps={"visual": config.process_timestep_as_input(timesteps)},
conds=(pos_cond, neg_cond, joint_cond),
default_dtype=ctx.forward_dtype,
)
with torch.autocast(
"cuda",
dtype=ctx.forward_dtype,
enabled=ctx.forward_dtype != torch.float32 and config.enable_autocast,
):
pred = config.compute_noise_pred(
model=model,
latents_input=latents_in,
timesteps_input=timesteps_in,
pos_cond=pos_in,
neg_cond=neg_in,
joint_cond=joint_in,
use_cfg=use_cfg,
cfg_batching=cfg_batching,
guidance_scale=guidance_scale,
true_cfg_scale=None,
)
return pred["visual"].float()
8 changes: 6 additions & 2 deletions miles/backends/fsdp_utils/loss_hub/sft.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ def prepare_sft_batch(
batch: list[dict],
*,
pad_to_len: int | None = None,
grid_index_range: tuple[int, int] | None = None,
) -> PreparedBatch:
"""Corrupt every latent stream at one shared grid index, each through its own shifted grid; CFG-free cached cond.

The visual grid picks the DiT component and the index is drawn uniformly from the points it serves. Sharing the
index pairs the streams' sigmas as inference steps them, one step index read through each stream's shift.
The visual grid picks the DiT component and the index is drawn uniformly from the points it serves, within
``grid_index_range`` (inclusive) when given. Sharing the index pairs the streams' sigmas as inference steps them,
one step index read through each stream's shift.
"""
device = ctx.device
config = ctx.train_pipeline_config
Expand All @@ -57,6 +59,8 @@ def prepare_sft_batch(
for stream_name in stream_names
}
component_name, model, visual_pool = select_component(ctx, noise_schedules["visual"], device)
if grid_index_range is not None:
visual_pool = visual_pool[(visual_pool >= grid_index_range[0]) & (visual_pool <= grid_index_range[1])]

# The visual stream keeps the single-stream seed, so single-stream runs draw as before; other streams add their
# name, so adding a stream never changes another stream's noise.
Expand Down
3 changes: 3 additions & 0 deletions miles/backends/fsdp_utils/loss_hub/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ class DiffusionLossContext:
device: torch.device
rollout_id: int = 0
microbatch_id: int = 0
optim_step_idx: int = 0
dp_rank: int = 0
reference_models: dict[str, torch.nn.Module] = field(default_factory=dict)
teacher_models: dict[str, torch.nn.Module] = field(default_factory=dict)
external_data: dict[str, Any] = field(default_factory=dict)
# Filled by loss hooks during one train call and read by the train-output hook.
train_output_inputs: list[Any] = field(default_factory=list)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optim_step_idx and train_output_inputs change during training; better to pass them as hook arguments / return values instead of mutating the ctx, which should stay rollout-level and read-only.



@dataclass(frozen=True)
Expand Down
43 changes: 43 additions & 0 deletions miles/ray/data_conversion_hub/dmd2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Convert DMD2 rollout samples, each stopped at its step i, into one train pair per sample."""

from argparse import Namespace
from typing import Any

from miles.utils.types import Sample


def expand_samples_to_train_pairs(
args: Namespace,
samples: list[Sample],
rewards: list[float],
raw_rewards: list[float],
) -> dict[str, Any]:
"""One pair per sample: the student's input ``x_i`` at its stop step ``i`` and its x0 prediction there."""
train_data: list[dict[str, Any]] = []
for sample in samples:
traj = sample.dit_trajectory
if traj is None or traj.latent_step_indices is None or sample.denoising_env is None:
raise ValueError(
f"sample {sample.index} missing dit_trajectory.latent_step_indices or denoising_env; "
"DMD2 rollouts need --diffusion-step-strategy-path miles.rollout.step_strategy_hub.stop_at_random_step"
)
step_index = int(traj.latent_step_indices[0])
# The engine sends step i straight to sigma 0, so the grid ends right after sigma_i.
if traj.latent_step_indices.tolist() != [step_index, step_index + 1] or len(traj.sigmas) != step_index + 2:
raise ValueError(
f"sample {sample.index} returned steps {traj.latent_step_indices.tolist()} on a "
f"{len(traj.sigmas)}-point sigma grid; DMD2 needs a rollout stopped at its first returned step"
)
train_data.append(
{
"latent": traj.latents[0],
"x0": traj.latents[1],
"timestep": float(traj.timesteps[step_index]),
"sigma": float(traj.sigmas[step_index]),
"step_index": step_index,
"denoising_env": sample.denoising_env,
"sample_index": sample.index,
"prompt": sample.prompt,
}
)
return {"train_data": train_data}
Loading
Loading