Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9298db1
fix(fsdp): only read num_train_timesteps where a timestep is routed
niehen6174 Aug 11, 2026
c56af6e
fix(fsdp): accept modular-pipeline component specs in DiffusersModelB…
niehen6174 Aug 11, 2026
caeea0c
test(utils): give the LoRA arg fixture the sglang_tp_size it reads
niehen6174 Aug 11, 2026
47bd94a
feat(diffusion): add MiniMax H3 t2va Flow-GRPO support
niehen6174 Aug 11, 2026
f7749a9
merge: sync upstream/main and drop removed --diffusion-model flag
niehen6174 Aug 11, 2026
cba30e0
clear h3 sde
niehen6174 Aug 16, 2026
6a6c6ee
merge: sync upstream/main and drop dead sglang wandb metrics
niehen6174 Aug 16, 2026
6dcb7d6
docs: add H3 GRPO result plots for PR 154
niehen6174 Aug 17, 2026
d96c5c4
fix(rollout): tolerate missing train pipeline path and drop FlowGRPO …
niehen6174 Aug 18, 2026
0f442bb
Merge branch 'main' into feat/minimax-h3
Rockdu Aug 18, 2026
90ef293
fix(fsdp): tolerate missing train_pipeline_config_path in LoRA IPC
niehen6174 Aug 18, 2026
081b577
Merge branch 'main' into feat/minimax-h3
Rockdu Aug 20, 2026
79ed623
chore(docs): drop the duplicate H3 curve assets superseded by #179
Rockdu Aug 20, 2026
131126a
docs: H3 is natively supported; build the engine from sglang-miles-h3
Rockdu Aug 20, 2026
0b734b2
docs(h3): match the model-page structure and drop the setup section
Rockdu Aug 20, 2026
ddb3dc3
refactor(h3): drop the debug-alignment wrapper for --extra-args pass-…
Rockdu Aug 20, 2026
e2baa68
docs(h3): drop the redundant short-train example
Rockdu Aug 20, 2026
2474deb
refactor: drop the dead train-pipeline-config knobs and reject H3 cfg…
Rockdu Aug 20, 2026
48a3a30
refactor: assert the two real model_index component spec shapes
Rockdu Aug 20, 2026
2aff55e
chore: move the sglang-miles-h3 image change to its own PR
Rockdu Aug 20, 2026
3458f78
refactor: read train_pipeline_config_path directly and complete the t…
Rockdu Aug 20, 2026
1aabbf0
Merge branch 'main' into feat/minimax-h3
Rockdu Aug 20, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Each model links to its recipe page; every documented recipe is labeled with a [
| **[Wan2.2-T2V-A14B](https://miles.radixark.com/docs/diffusion/models/wan/wan2-2)** | T2V | Flow-GRPO + PickScore, LoRA SFT |
| **[LTX-2.3](https://miles.radixark.com/docs/diffusion/models/ltx/ltx2)** | T2V | Flow-GRPO + PickScore |
| **[Cosmos3 (Edge / Nano / Super)](https://miles.radixark.com/docs/diffusion/models/cosmos/cosmos3)** | T2I | Flow-GRPO + PickScore |
| **[MiniMax H3](https://miles.radixark.com/docs/diffusion/models/h3/h3)** | T2VA | Open ([PR #154](https://github.com/radixark/miles_diffusion/pull/154)); 2-GPU recipe, verified; large-scale coming soon |
| **[MiniMax H3](https://miles.radixark.com/docs/diffusion/models/h3/h3)** | T2VA | Flow-GRPO + PickScore (t2va, video branch); 2-GPU recipe, verified |


---
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ appear in the [Miles model list](https://miles.radixark.com/docs#supported-model
| [Wan2.2-T2V-A14B](models/wan/wan2-2.md) | T2V | Flow-GRPO + PickScore, LoRA SFT |
| [LTX-2.3](models/ltx/ltx2.md) | T2V | Flow-GRPO + PickScore |
| [Cosmos3-Nano](models/cosmos/cosmos3.md) | T2I | Flow-GRPO + PickScore |
| [MiniMax H3](models/h3/h3.md) | T2VA | **Not merged** — [PR #154](https://github.com/radixark/miles_diffusion/pull/154); 2-GPU PR-only recipe |
| [MiniMax H3](models/h3/h3.md) | T2VA | Flow-GRPO + PickScore (t2va, video branch); 2-GPU recipe |



Expand Down
99 changes: 40 additions & 59 deletions docs/models/h3/h3.md
Original file line number Diff line number Diff line change
@@ -1,102 +1,83 @@
---
title: MiniMax H3
description: Text-to-audio-video Flow-GRPO + PickScore on MiniMax H3 — 2-GPU recipe (not merged yet).
description: Text-to-audio-video Flow-GRPO + PickScore on MiniMax H3 — the verified 2-GPU recipe.
---

> **Not merged yet.** H3 support is only in
> [miles-diffusion#154](https://github.com/radixark/miles_diffusion/pull/154) and
> [sglang-diffusion#34365](https://github.com/sgl-project/sglang/pull/34365).
> Neither repo has it on `main`. After a normal install, follow [Setup](#3-setup) to
> move both checkouts onto those PRs.

## 1. Model introduction

[MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) is MiniMax's omni-modal generative system: one transformer
denoises a packed text + video + audio sequence and emits video with native stereo audio. MiniMax distilled CFG
into the checkpoint, so the official setting has **no classifier-free guidance** — the recipe is unguided
(`--diffusion-guidance-scale 1.0`). The unmerged PR trains the **t2va** (text-to-audio-video) workflow, video
(`--diffusion-guidance-scale 1.0`). The recipe trains the **t2va** (text-to-audio-video) workflow, video
branch only — the audio stream is rolled out but not trained.

## 2. Supported variants

| Model | HF ID | Notes |
|---|---|---|
| MiniMax-H3 | [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3) | t2va video-only Flow-GRPO on [PR #154](https://github.com/radixark/miles_diffusion/pull/154). `fl2va` / `ref2va` are not trained yet. |
| MiniMax-H3 | [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3) | t2va video-only Flow-GRPO. `fl2va` / `ref2va` are not trained yet. |

## 3. Setup
## 3. Family config

H3 is not on `main` of either repo. Finish [Installation](../../getting-started/installation.md) first
(Docker image or from source), then move **both** halves onto the open PRs — a `main` trainer
against a `main` engine will not run this recipe.
From `miles/backends/fsdp_utils/configs/h3.py`:

| Repo | PR | What it adds |
| Property | Value | Why |
|---|---|---|
| miles-diffusion | [radixark/miles_diffusion#154](https://github.com/radixark/miles_diffusion/pull/154) | H3 family config, LoRA IPC map, 2-GPU recipe |
| sglang-diffusion | [sgl-project/sglang#34365](https://github.com/sgl-project/sglang/pull/34365) | MiniMax H3 `t2va` RL rollout |

Paths below match the Docker image (`/root/miles_diffusion`, `/sgl-workspace/sglang`).

```bash
# miles-diffusion → PR 154
cd /root/miles_diffusion
git fetch https://github.com/radixark/miles_diffusion.git pull/154/head:pr-154
git checkout pr-154
pip install --force-reinstall -r requirements.txt # H3 needs the diffusers git pin
pip install -e . --no-deps

# sglang-diffusion → PR 34365
cd /sgl-workspace/sglang
git fetch https://github.com/sgl-project/sglang.git pull/34365/head:pr-34365
git checkout pr-34365
pip install -e "python[all]"
```

Drop these checkouts once both PRs merge.
| CFG training | Off (asserted) | CFG is distilled into the checkpoint; the forward is unguided |
| `sde_timestep_divisor` | 1000.0 | The DiT conditions on `1 - sigma`, so the recorded timestep is divided back down |
| Weight sync | `--use-lora --lora-ipc-weight-sync` (asserted) | sgl-d's H3 DiT renames modules and fuses Q/K/V; other sync modes push names the engine drops with a warning, silently training nothing |
| LoRA targets | `attn.to_{q,k,v}`, `attn.to_out.0`, `ff.net.0.proj`, `ff.net.2` | Grouped for the fused engine layers by `h3_weight_key_mapper.collect_h3_lora_layer_groups` |
| Optimizer state | `audio` allowed missing | The audio branch is rolled out but never trained |
| Sample micro-batch | 1 (asserted) | One packed sequence per forward |
| Forced sampling params | `task=t2va`, `short_edge=768`, `conditions=[]` | sgl-d accepts only these for H3, so none of them is exposed as an argument |

## 4. Launch

The 2-GPU recipe lives on [PR #154](https://github.com/radixark/miles_diffusion/pull/154), not `main`:
`scripts/run_diffusion_grpo_h3_t2va_2gpu.py` — 2 colocate GPUs, reward colocated, 16:9 / 4 s
(sglang pins `short_edge=768`, so the canvas is 1344×768 / 107 frames). Requires `--use-lora --lora-ipc-weight-sync`
(the recipe already sets both).
Canonical recipe: `scripts/run_diffusion_grpo_h3_t2va_2gpu.py` — train, rollout, and PickScore
colocated on 2 GPUs; 16:9 / 4 s (with `short_edge=768` the canvas is 1344×768 / 107 frames).

**Status:** [📈 V — Verified](../../user-guide/recipe-verification.md#v), based on the complete run reported on PR #154.
The command does not exist on `main`.
**Status:** [📈 V — Verified](../../user-guide/recipe-verification.md#v)

```bash
# alignment check (optimizer frozen)
# alignment diagnostic: freeze the weights, so log_prob_mean_abs_diff measures
# train-vs-rollout deviation rather than parameter drift
python3 scripts/run_diffusion_grpo_h3_t2va_2gpu.py \
--cuda-visible-devices 0,1 \
--num-rollout 2 --eval-interval 0 --debug-alignment
--num-rollout 2 --eval-interval 0 --extra-args "--debug-skip-optimizer-step"

# short train
# long run at the resource-limited scale
python3 scripts/run_diffusion_grpo_h3_t2va_2gpu.py \
--cuda-visible-devices 0,1 --num-rollout 5 --eval-interval 0

# 2-GPU long run (resource-limited scale)
python3 scripts/run_diffusion_grpo_h3_t2va_2gpu.py \
--rollout-batch-size 4 --n-samples-per-prompt 16 \
--cuda-visible-devices 0,1
--rollout-batch-size 4 --n-samples-per-prompt 16
```

The commands above are the 2-GPU verification path on
[PR #154](https://github.com/radixark/miles_diffusion/pull/154). No larger canonical H3 topology is committed to
`main`.
## 5. Recipe notes

H3 solves `x0 = x + sigma * v`, the opposite sign from the flow-matching convention the shared
train and rollout steps assume, so `compute_noise_pred` returns the negated velocity. Both halves
must agree: a flipped sign still produces plausible latents and trains on garbage.

## 5. Reference results
The rollout engine needs sglang's `sglang-miles-h3` branch, which
[Installation](../../getting-started/installation.md) already builds from
(`SGLANG_DIFFUSION_BRANCH`). An engine built from sglang `main` rejects the run with
`MiniMax H3 does not support rollout`.

2-GPU long run from [PR #154](https://github.com/radixark/miles_diffusion/pull/154)
(`rollout_batch_size=4`, `n_samples_per_prompt=16`):
## 6. Reference results

2-GPU long run (`rollout_batch_size=4`, `n_samples_per_prompt=16`):

- `train/log_prob_mean_abs_diff` stays in **1.4e-5 .. 7.1e-5** (under `1e-4`).
- Eval PickScore (`eval/pickscore_test`, every 10 rollouts): **0.806 → 0.814**, peak **0.816** at step 59.
- Train `rollout/reward/raw_mean` sits around **~0.79** (64 samples / step; noisier than eval).

This is the batch-4 curve reported on [PR #154](https://github.com/radixark/miles_diffusion/pull/154); it is not evidence
for a larger topology.
This is the batch-4 curve; it is not evidence for a larger topology, and no larger canonical H3
topology is committed yet.

![MiniMax H3 log_prob_mean_abs_diff](../../assets/images/h3/log_prob_diff.png)

![MiniMax H3 eval PickScore](../../assets/images/h3/reward_eval.png)

![MiniMax H3 train rollout PickScore](../../assets/images/h3/reward_rollout.png)

## 7. Pairs well with

- [LoRA Training and Weight Sync](../../advanced/lora.md) — the IPC merge this recipe requires.
- [Rewards](../../user-guide/rewards.md) — PickScore worker pool configuration.
3 changes: 1 addition & 2 deletions docs/user-guide/recipe-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ count as verification.
Flow-GRPO + PickScore.
- `run_diffusion_grpo_cosmos3_pickscore_t2i_4gpu.py` — Cosmos3-Nano
Flow-GRPO + PickScore.
- `run_diffusion_grpo_h3_t2va_2gpu.py` — MiniMax H3 Flow-GRPO + PickScore
([PR #154](https://github.com/radixark/miles_diffusion/pull/154) only; not on `main`).
- `run_diffusion_grpo_h3_t2va_2gpu.py` — MiniMax H3 Flow-GRPO + PickScore.
- **○ NV**
- `run_diffusion_grpo_wan22_pickscore_5gpu.py` — Wan2.2 5-GPU LoRA
Flow-GRPO + PickScore.
Expand Down
185 changes: 185 additions & 0 deletions miles/backends/fsdp_utils/configs/h3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""MiniMax H3 family config: t2va video-only Flow-GRPO."""

from __future__ import annotations

from argparse import Namespace

import torch

from miles.utils.types import CondKwargs

from .train_pipeline_config import TrainPipelineConfig, register_train_pipeline_config

AUDIO_IN_CHANNELS = 32


@register_train_pipeline_config("h3")
class H3TrainPipelineConfig(TrainPipelineConfig):
"""MiniMax H3 t2va video-only GRPO (audio branch frozen / deterministic in rollout)."""

hf_ckpt_name_patterns = ("minimax-h3", "minimax_h3", "/h3")
supports_cfg_training = False
sde_timestep_divisor = 1000.0
optimizer_state_allowed_missing = ["audio"]
lora_layer_group_collector_path = "miles.backends.fsdp_utils.h3_weight_key_mapper.collect_h3_lora_layer_groups"

lora_target_modules = [
"attn.to_q",
"attn.to_k",
"attn.to_v",
"attn.to_out.0",
"ff.net.0.proj",
"ff.net.2",
]

@classmethod
def validate_args(cls, args: Namespace) -> None:
# sglang's H3 DiT renames modules and fuses Q/K/V, so weights only reach the
# rollout through the LoRA IPC path's layer grouper; any other sync mode would
# push names the engine drops with a warning, silently training nothing.
if not (args.use_lora and args.lora_ipc_weight_sync):
raise ValueError("H3 training requires --use-lora with --lora-ipc-weight-sync")

@classmethod
def apply_rollout_sampling_params(
cls,
args: Namespace,
sampling_params: dict,
extra_sampling_params: dict,
) -> None:
extra_sampling_params.update(
{
# sgl-d accepts only task=t2va for rollout and short_edge=768 for any
# H3 request, so neither is exposed as an argument.
"task": "t2va",
"conditions": [],
"target": {
"short_edge": 768,
"aspect_ratio": str(args.diffusion_h3_aspect_ratio),
"duration_seconds": float(args.diffusion_h3_duration_seconds),
},
"audio_flow_shift": float(args.diffusion_audio_flow_shift),
}
)
if args.diffusion_flow_shift is not None:
extra_sampling_params["flow_shift"] = float(args.diffusion_flow_shift)
# MiniMaxH3SamplingParams marks CFG/canvas fields init=False; canvas comes from target.
extra_sampling_params.pop("guidance_scale_2", None)
for key in (
"guidance_scale",
"guidance_scale_2",
"true_cfg_scale",
"negative_prompt",
"width",
"height",
"num_frames",
"fps",
):
sampling_params.pop(key, None)

def prepare_cond_kwargs(self, cond: CondKwargs | None, device: torch.device) -> dict:
if cond is None:
return {}
kwargs: dict = {}
if cond.encoder_hidden_states:
enc = torch.cat(cond.encoder_hidden_states).to(device)
if enc.ndim == 2:
enc = enc.unsqueeze(0)
kwargs["encoder_hidden_states"] = enc
if cond.h3_packed_layout is not None:
kwargs["h3_packed_layout"] = {
k: (v.to(device) if isinstance(v, torch.Tensor) else v) for k, v in cond.h3_packed_layout.items()
}
if cond.h3_token_tags is not None:
kwargs["h3_token_tags"] = cond.h3_token_tags.to(device)
return kwargs

def collate_cond_for_sample_batch(
self,
per_sample_cond_kwargs: list[dict],
device: torch.device,
pad_to_len: int | None = None,
) -> dict:
if len(per_sample_cond_kwargs) != 1:
raise NotImplementedError("H3 GRPO currently requires micro-batch-size-sample=1")
return {k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in per_sample_cond_kwargs[0].items()}

def compute_noise_pred(
self,
*,
model: torch.nn.Module,
latents_input: torch.Tensor,
timesteps_input: torch.Tensor,
pos_cond: dict | None,
neg_cond: dict | None,
joint_cond: dict | None,
use_cfg: bool,
cfg_batching: bool,
guidance_scale: float,
true_cfg_scale: float | None,
) -> torch.Tensor:
del neg_cond, joint_cond, use_cfg, cfg_batching, guidance_scale, true_cfg_scale
cond = dict(pos_cond or {})
packed = cond.get("h3_packed_layout")
token_tags = cond.get("h3_token_tags")
encoder_hidden_states = cond.get("encoder_hidden_states")
if packed is None or token_tags is None or encoder_hidden_states is None:
raise ValueError("H3 train requires h3_packed_layout, h3_token_tags, encoder_hidden_states in pos_cond")

device = latents_input.device
dtype = latents_input.dtype

# latents_input: [B, num_video_target_rows, width]
bsz = latents_input.shape[0]
if bsz != 1:
raise NotImplementedError("H3 packed forward supports batch size 1 for now")

layout = {k: (v.to(device) if isinstance(v, torch.Tensor) else v) for k, v in packed.items()}
tags = (
token_tags.to(device) if isinstance(token_tags, torch.Tensor) else torch.tensor(token_tags, device=device)
)
sigma = (timesteps_input.float() / float(self.sde_timestep_divisor)).view(-1)
timestep = 1.0 - sigma
seq_len = int(layout["seq_len"])
width = latents_input.shape[-1]

img_pos = layout["img_pos"].view(-1).long().to(device)
audio_pos = layout["audio_pos"].view(-1).long().to(device)
update_mask = layout["update_mask"].view(-1).bool().to(device)
text_pos = layout["text_pos"].view(-1).long().to(device)

# The transformer takes one row block per modality, each ordered like its
# ``*_indices``, and scatters them into the packed buffer itself. Only the
# target rows are replayed; conditioning rows stay zero, as does the audio
# stream (H3 GRPO trains the video branch only).
video_hidden = torch.zeros(1, int(img_pos.shape[0]), width, device=device, dtype=dtype)
video_hidden[0, update_mask] = latents_input[0].to(dtype)
audio_hidden = torch.zeros(1, int(audio_pos.shape[0]), AUDIO_IN_CHANNELS, device=device, dtype=dtype)

out = model(
hidden_states=video_hidden,
audio_hidden_states=audio_hidden,
encoder_hidden_states=encoder_hidden_states.to(dtype),
timestep=timestep.to(dtype),
timestep_indices=layout.get("timestep_indices", torch.zeros(seq_len, device=device, dtype=torch.long)),
# Padding rows carry tag -1; the AdaLN table is indexed by tag, so they
# must be folded onto a real modality exactly as the rollout does.
token_tags=tags.long().clamp(min=0),
position_ids=layout["img_position_ids"].to(device=device, dtype=torch.float32),
video_indices=img_pos,
audio_indices=audio_pos,
text_indices=text_pos,
)
velocity = out[0] if isinstance(out, tuple) else out.sample
# Rows follow video_indices; keep the target subset and return the
# diffusers-compatible flow direction (negated H3 velocity).
return (-velocity[0, update_mask]).to(dtype)

def cfg_combine(
self,
noise_pred_pos: torch.Tensor,
noise_pred_neg: torch.Tensor,
guidance_scale: float,
true_cfg_scale: float | None = None,
) -> torch.Tensor:
raise NotImplementedError("H3 distilled CFG into the checkpoint; the forward is unguided")
19 changes: 19 additions & 0 deletions miles/backends/fsdp_utils/configs/train_pipeline_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def resolve_diffusion_model_family(model_ref: str) -> str:

def get_train_pipeline_config_cls(family: str) -> type[TrainPipelineConfig]:
"""The TrainPipelineConfig class registered for a resolved family key."""
_populate_registry()
cls = _REGISTRY.get(family.lower())
if cls is None:
raise ValueError(
Expand Down Expand Up @@ -96,7 +97,25 @@ class TrainPipelineConfig(abc.ABC):
def validate_args(cls, args) -> None:
"""Family-specific arg validation/defaults; runs once at arg validation."""

@classmethod # noqa: B027 — optional hook, deliberately non-abstract
def apply_rollout_sampling_params(
cls,
args,
sampling_params: dict,
extra_sampling_params: dict,
) -> None:
"""Adjust the ``POST /rollout/generate`` body for this family; default adjusts nothing.

Both dicts are mutated in place: ``sampling_params`` is the request body the
generic builder has already filled in, ``extra_sampling_params`` the
family-specific passthrough. Families whose sgl-d request schema rejects some
of the generic fields drop them here.
"""

sde_timestep_divisor = 1.0
# LoRA IPC layer grouper for families whose rollout module names or tensor layout
# differ from the trained diffusers ones; None keeps the generic PEFT grouping.
lora_layer_group_collector_path: str | None = None

def configure(self, args) -> None: # noqa: B027 optional no-op hook, not abstract
"""Bind the request constants a family needs at train time; default binds none."""
Expand Down
Loading
Loading