Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 13 additions & 3 deletions examples/train/configs/genrl_wan2.1_t2v_1.3B_hpsv3_videoalign.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@
# - Full fine-tuning with beta > 0 requires models.reference and much
# more memory; keep beta at 0.0 for the 4xH100 probe run.
#
# Reproduction-critical setup for the reported 4xH100 probe run:
# - Use NUM_GPUS=4 so the distributed rollout/grouping shape matches.
# - Prepare the exact GenRL prompt JSONL files and VideoReward checkpoint:
# python examples/train/prepare_genrl_assets.py
# - Set VIDEOALIGN_CHECKPOINT_PATH to the prepared KwaiVGI/VideoReward snapshot.
# - Set FORCE_QWENVL_VIDEO_READER=opencv if torchvision video IO is missing.
#
# Usage:
# torchrun --nnodes=1 --nproc_per_node=4 \
# -m fastvideo.train.entrypoint.train \
# --config examples/train/configs/genrl_wan2.1_t2v_1.3B_hpsv3_videoalign.yaml
# NUM_GPUS=4 \
# VIDEOALIGN_CHECKPOINT_PATH=.cache/VideoReward \
# FORCE_QWENVL_VIDEO_READER=opencv \
# bash examples/train/run.sh \
# examples/train/configs/genrl_wan2.1_t2v_1.3B_hpsv3_videoalign.yaml \
# --training.checkpoint.output_dir /path/to/outputs/genrl_hpsv3_videoalign

models:
student:
Expand Down
157 changes: 157 additions & 0 deletions examples/train/prepare_genrl_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# SPDX-License-Identifier: Apache-2.0
"""Prepare GenRL prompt and reward assets for example training runs."""

from __future__ import annotations

import argparse
import json
import subprocess
from pathlib import Path

MIN_TRAIN_PROMPTS = 4
GENRL_REPO = "https://github.com/ModelTC/GenRL.git"
VIDEOREWARD_REPO = "KwaiVGI/VideoReward"
Comment thread
Abecid marked this conversation as resolved.


def _run(cmd: list[str], cwd: Path | None = None) -> None:
subprocess.run(cmd, cwd=cwd, check=True)


def _is_nonempty_dir(path: Path) -> bool:
return path.is_dir() and any(path.iterdir())


def prepare_genrl_prompts(genrl_dir: Path) -> Path:
if not genrl_dir.exists():
_run(["git", "clone", GENRL_REPO, str(genrl_dir)])
elif not (genrl_dir / ".git").exists() and not _is_nonempty_dir(
genrl_dir
):
genrl_dir.rmdir()
_run(["git", "clone", GENRL_REPO, str(genrl_dir)])

if (genrl_dir / ".git").exists():
_run(["git", "lfs", "install"], cwd=genrl_dir)
_run(
[
"git",
"lfs",
"pull",
"-I",
"datasets/filtered_prompts/*",
],
cwd=genrl_dir,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If git-lfs is not installed or configured on the system, running git lfs install or git lfs pull will fail with a generic subprocess.CalledProcessError and a cryptic traceback.

Wrapping these calls in a try-except block allows us to catch the error and provide a clear, actionable message to the user.

Suggested change
if (genrl_dir / ".git").exists():
_run(["git", "lfs", "install"], cwd=genrl_dir)
_run(
[
"git",
"lfs",
"pull",
"-I",
"datasets/filtered_prompts/*",
],
cwd=genrl_dir,
)
if (genrl_dir / ".git").exists():
try:
_run(["git", "lfs", "install"], cwd=genrl_dir)
_run(
[
"git",
"lfs",
"pull",
"-I",
"datasets/filtered_prompts/*",
],
cwd=genrl_dir,
)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
"Failed to pull Git LFS assets. Ensure that `git-lfs` is installed "
"on your system and available in your PATH."
) from exc


prompt_dir = genrl_dir / "datasets" / "filtered_prompts"
validate_prompt_file(
prompt_dir / "train.json",
min_prompts=MIN_TRAIN_PROMPTS,
)
validate_prompt_file(prompt_dir / "test.json", min_prompts=1)
return prompt_dir


def validate_prompt_file(path: Path, min_prompts: int) -> None:
if not path.exists():
raise FileNotFoundError(
f"Missing {path}. Expected GenRL filtered_prompts JSONL files."
)

prompt_count = 0
with path.open(encoding="utf-8") as f:
for line_no, raw_line in enumerate(f, start=1):
line = raw_line.strip()
if not line:
continue
if (
prompt_count == 0
and line_no == 1
and line.startswith("version https://git-lfs.github.com")
):
raise RuntimeError(
f"{path} is a Git LFS pointer, not real prompt JSON. "
"Install git-lfs and rerun this script."
)
item = json.loads(line)
if item.get("prompt"):
prompt_count += 1
Comment on lines +91 to +114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The prompt validation logic is prone to crashing or bypassing the Git LFS check if there are leading empty lines in the JSONL file. Additionally, if a line contains malformed JSON or a non-dictionary JSON value, json.loads or item.get will raise unhandled exceptions (JSONDecodeError or AttributeError) and crash the script with a cryptic traceback.

Using a saw_content flag (similar to the dataset loader) and wrapping the JSON parsing in a try-except block with explicit type checks makes the validation much more robust and user-friendly.

    prompt_count = 0
    saw_content = False
    with path.open(encoding="utf-8") as f:
        for line_no, raw_line in enumerate(f, start=1):
            line = raw_line.strip()
            if not line:
                continue
            if not saw_content and line.startswith("version https://git-lfs.github.com"):
                raise RuntimeError(
                    f"{path} is a Git LFS pointer, not real prompt JSON. "
                    "Install git-lfs and rerun this script."
                )
            saw_content = True
            try:
                item = json.loads(line)
            except json.JSONDecodeError as e:
                raise RuntimeError(
                    f"Malformed JSON on line {line_no} in {path}: {e}"
                ) from e
            if not isinstance(item, dict):
                raise RuntimeError(
                    f"Expected a JSON object (dict) on line {line_no} in {path}, got {type(item).__name__}."
                )
            if item.get("prompt"):
                prompt_count += 1


if prompt_count < min_prompts:
raise RuntimeError(
f"{path} has {prompt_count} usable prompts; expected at least "
f"{min_prompts}."
)


def prepare_video_reward(videoalign_dir: Path) -> Path:
if has_video_reward_checkpoint(videoalign_dir):
return videoalign_dir

try:
from huggingface_hub import snapshot_download
except ImportError as exc:
raise ImportError(
"huggingface_hub is required to download KwaiVGI/VideoReward. "
"Install FastVideo dependencies, then rerun this script."
) from exc

snapshot_download(
repo_id=VIDEOREWARD_REPO,
repo_type="model",
local_dir=str(videoalign_dir),
local_dir_use_symlinks=False,
)
if not has_video_reward_checkpoint(videoalign_dir):
raise RuntimeError(
f"Downloaded {VIDEOREWARD_REPO}, but no VideoReward checkpoint "
f"was found under {videoalign_dir}."
)
return videoalign_dir


def has_video_reward_checkpoint(root: Path) -> bool:
model_config = root / "model_config.json"
if not model_config.exists():
return False
for checkpoint in root.glob("checkpoint-*"):
if (checkpoint / "model.pth").exists():
return True
if (
(checkpoint / "adapter_model.safetensors").exists()
and (checkpoint / "non_lora_state_dict.pth").exists()
):
return True
return False
Comment on lines +149 to +166

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The has_video_reward_checkpoint function only checks for checkpoints nested inside a checkpoint-* subdirectory. If a user manually downloads or extracts the checkpoint directly into the root directory (so that model.pth or adapter_model.safetensors is in the root), it will not be detected.

Adding a fallback check for the root directory directly makes the checkpoint detection much more robust.

def has_video_reward_checkpoint(root: Path) -> bool:
    model_config = root / "model_config.json"
    if not model_config.exists():
        return False
    if (root / "model.pth").exists():
        return True
    if (
        (root / "adapter_model.safetensors").exists()
        and (root / "non_lora_state_dict.pth").exists()
    ):
        return True
    for checkpoint in root.glob("checkpoint-*"):
        if (checkpoint / "model.pth").exists():
            return True
        if (
            (checkpoint / "adapter_model.safetensors").exists()
            and (checkpoint / "non_lora_state_dict.pth").exists()
        ):
            return True
    return False



def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Prepare assets for GenRL HPSv3 + VideoAlign training."
)
parser.add_argument(
"--genrl-dir",
type=Path,
default=Path("GenRL"),
help="Directory containing or receiving the ModelTC/GenRL checkout.",
)
parser.add_argument(
"--videoalign-dir",
type=Path,
default=Path(".cache/VideoReward"),
help="Directory containing or receiving KwaiVGI/VideoReward.",
)
return parser.parse_args()


def main() -> None:
args = parse_args()
prompt_dir = prepare_genrl_prompts(args.genrl_dir)
videoalign_dir = prepare_video_reward(args.videoalign_dir)
print("GenRL assets ready.")
print(f"PROMPT_DATASET_PATH={prompt_dir}")
print(f"VIDEOALIGN_CHECKPOINT_PATH={videoalign_dir}")


if __name__ == "__main__":
main()
23 changes: 21 additions & 2 deletions fastvideo/train/methods/rl/utils/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,24 @@ def __init__(self, dataset: str, split: str = "train"):
self._load_all_prompts()

def _load_all_prompts(self) -> None:
if not os.path.exists(self.file_path):
raise FileNotFoundError("GenRL prompt file not found: "
f"{self.file_path}. Expected train.json/test.json "
"from GenRL/datasets/filtered_prompts.")

saw_content = False
with open(self.file_path, encoding="utf-8") as f:
for raw_line in f:
for line_no, raw_line in enumerate(f, start=1):
line = raw_line.strip()
if not line:
continue
if not saw_content and line.startswith("version https://git-lfs.github.com"):
raise RuntimeError(f"{self.file_path} is a Git LFS pointer, not the "
"real prompt JSON. Run `git lfs pull -I "
"'datasets/filtered_prompts/*'` in the GenRL "
"checkout or rerun "
"`python examples/train/prepare_genrl_assets.py`.")
saw_content = True
try:
item = json.loads(line)
prompt = item.get("prompt", "")
Expand All @@ -68,9 +81,15 @@ def _load_all_prompts(self) -> None:
self._metadatas.append(metadata)
except json.JSONDecodeError as e:
logger.warning(
"Skipping invalid JSON line: %s",
"Skipping invalid JSON line %d in %s: %s",
line_no,
self.file_path,
e,
)
if not self._prompts:
raise ValueError("No usable prompts found in "
f"{self.file_path}. Expected JSONL rows with a "
"`prompt` field.")

def __len__(self) -> int:
return len(self._prompts)
Expand Down