scripts/, and starting a run is one command:
How a launch script starts a training job
A launch script is a recipe, not the training process. It assembles the fulltrain_diffusion.py command line, starts a local Ray cluster, and submits the command as a Ray
job — submitted rather than run directly, so the driver lives inside the cluster and sees every
node’s GPUs.
The structure of a launch script
Every launcher follows the same layout:ScriptArgs — script options as flags and MILES_SCRIPT_* env vars
@U.dataclass_cli exposes each ScriptArgs field twice: as a --kebab-case CLI option
(--num-rollout) and as an environment variable with the MILES_SCRIPT_ prefix
(MILES_SCRIPT_NUM_ROLLOUT). A command-line value beats the env var, which beats the field
default. The env form is how wrappers and cluster tooling inject machine-specific values
without editing the script.
Options shared by every launcher, from the ExecuteTrainConfig base class and repo convention:
wandb flags are emitted only when
WANDB_API_KEY is set — logging turns on by exporting the
key, with no script change.
execute() — the flag groups
The command line is assembled as one string block per concern, then concatenated — the parser sees a flat list, and the groups follow concern, not flag prefix (a--diffusion-* flag can
live in rollout_args, a --micro-batch-size-* flag in perf_args).
Ways to override a recipe
From lightest to heaviest:-
Append flags with
--extra-args. The value lands at the end of the command line, and for an argparse flag given twice the later occurrence wins — so it overrides anything the recipe sets: -
Set a script option, as a flag or a
MILES_SCRIPT_*env var. - Edit the script. The launcher is the canonical home of a recipe’s hyperparameters; change the flag blocks directly for anything you want to keep.
script="scripts/run_diffusion_grpo_sd3_ocr_sglang.py", args=["--num-rollout", "2", ...]), so
launch scripts must stay runnable with no arguments and configurable through ScriptArgs — CI
drives them the same way you do.
What execute_train runs on your machine
- Kills stale
sglang/ray/milesprocesses. - Starts a fresh cluster with
export CUDA_VISIBLE_DEVICES=... && ray start --head— the device list must be in the raylet’s own environment; set per job or per actor it never reaches the scheduler, which then places work on excluded GPUs. - Builds the Ray runtime env: NCCL socket vars,
MASTER_ADDR,PYTHONPATH, plus anything from--extra-env-vars. - Submits the job:
ray job submit -- python3 train_diffusion.py <flags>.
The batch-size arithmetic
Diffusion adds a dimension LLM RL does not have: one sample expands into several train pairs, one per trained denoising step.global_batch_size counts samples and must divide by dp_size
(= train world size ÷ --sequence-parallel-size).
Most recipes use the 2D tile — it states “S samples × T timesteps per forward” directly, which
the flat knob cannot express. Wan2.2 is the exception:
--micro-batch-size 2 is its way of
keeping each forward on one side of the high/low-noise expert boundary.
Worked example (the Qwen-Image recipe):
Adapting a recipe
Start from the closest existing script and change one group at a time:Multi-node training
The worked example isscripts/run_diffusion_grpo_wan22_pickscore_17gpu_multinode.py:
wan2.2-A14B full finetune on 2 nodes × 8 GPUs (train + rollout colocated) plus 1 reward GPU on a
separate node. Multi-node runs submit into a cluster you build yourself; the launcher only submits
(MILES_SCRIPT_EXTERNAL_RAY=1).
Bring up the cluster
Each node runs ONE of the blocks below, chosen by its role. The twoexport lines must be in
the environment of the ray start daemon itself, which is why every block repeats them.
On the head node:
ulimit -n: a non-interactive ssh shell defaults to 1024 open files. The raylet inherits it and, once the FSDP actors and engines connect, dies withepoll: Too many open files— but the driver log shows a misleadingActorUnavailableError: ... RpcError: Socket closed. Verify withgrep "Max open files" /proc/$(pgrep -f raylet | head -1)/limitsafterray start.CUDA_VISIBLE_DEVICESfollows the same rule the launcher automates single-node: it must be in the raylet’s environment, and with an external cluster the launcher refuses a--cuda-visible-devicesof its own.
ray status on the head should show every node and the full GPU count.
Submit
--pickscore-num-workers 4 --pickscore-num-gpus-per-worker 0.25, no
--colocate-reward) are default-scheduled and land on the only free GPU: the reward node.
Verify the run is healthy
- With
--rollout-patch-group wanand--sglang-attention-backend torch_sdpa,train/model_output_mean_abs_diffis expected to be exactly0.0from the first optimizer step. The 4-GPU proxy E2E standard records0.0, and the documented 17-GPU runs sustained it over 200 rollouts. Any nonzero value indicates a train/rollout parity regression; check the patch group, backend, dtype, and versions on every rank. - Both training nodes near 100% GPU util during rollout; a 100%-vs-idle split between nodes means the reward workers were packed onto one node.
Multi-node pitfalls
Next
- CLI Reference — every flag, grouped.
- Core Concepts — what one rollout iteration does, object by object.
- Dtype Control — what the three dtype flags actually do.

