Repository navigation
[worker, trainer, cfg, tests, doc] feat: multi-teacher routing and standalone teacher pool for diffusion OPD - #427
Conversation
There was a problem hiding this comment.
Pull request overview
Adds verl-style multi-teacher routing and an optional standalone teacher_pool for diffusion OPD distillation, plus a bundled fix to avoid EADDRINUSE by slicing rendezvous port ranges per Ray worker group.
Changes:
- Extend diffusion distillation config to support multiple named teachers (routed by
distillation.teacher_key) and optional standalone teacher resource pools (n_gpus_per_node/nnodes+ per-teacherworld_size). - Introduce
DiffusionTeacherManagerto dispatch per-key teacher inference concurrently and scatterteacher_prev_sample_meanback in original row order. - Fix Ray worker-group rendezvous port conflicts by defaulting
trainer.ray_master_port_rangetonulland slicing configured ranges per group.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| verl_omni/workers/engine_workers.py | Build and route multiple frozen teachers; add standalone teacher role support; async teacher inference dispatch. |
| verl_omni/workers/config/diffusion/distillation.py | Add multi-teacher + standalone pool config surface and validation. |
| verl_omni/trainer/main_diffusion.py | Register teacher_pool and standalone teacher workers when configured. |
| verl_omni/trainer/diffusion/v1/trainer_base.py | Slice rendezvous port ranges per worker group (v1 trainer). |
| verl_omni/trainer/diffusion/teacher_manager.py | New manager to route batches to per-key teacher worker groups and reorder outputs. |
| verl_omni/trainer/diffusion/ray_diffusion_trainer.py | Standalone teacher pool splitting, worker-group creation, manager integration, and port slicing. |
| verl_omni/trainer/diffusion/diffusion_trainer_utils.py | Add worker_group_port_ranges() helper for per-group port slicing. |
| verl_omni/trainer/config/diffusion/distillation/diffusion_distillation.yaml | Update YAML defaults/docs for multi-teacher and teacher pool fields. |
| verl_omni/trainer/config/diffusion_trainer.yaml | Default trainer.ray_master_port_range to null and document port slicing behavior. |
| verl_omni/trainer/config/_generated_diffusion_veomni_trainer.yaml | Regenerate config to reflect new distillation fields and port-range default. |
| verl_omni/trainer/config/_generated_diffusion_trainer.yaml | Regenerate config to reflect new distillation fields and port-range default. |
| tests/workers/test_distillation_teacher_config_on_cpu.py | Add unit test ensuring infer_teacher_batch routes by teacher_key. |
| tests/trainer/diffusion/test_distillation_trainer_wiring_on_cpu.py | Add CPU tests for per-group port slicing and teacher pool registration. |
| tests/trainer/diffusion/test_distillation_config_on_cpu.py | Expand config tests for multi-teacher resolution and standalone pool constraints. |
| tests/trainer/diffusion/test_diffusion_teacher_manager_on_cpu.py | New CPU tests for teacher routing, padding, concurrency ordering, and scatter-back. |
| tests/trainer/diffusion/test_controller_nsys_profiler_on_cpu.py | Ensure controller profiler tests set use_teacher_policy off explicitly. |
| tests/special_e2e/run_diffusion_teacher_smoke.sh | Extend diffusion teacher smoke to cover two-teacher colocated + standalone pool cases. |
| tests/special_e2e/create_dummy_diffusion_data.py | Allow generating dummy data with multiple data_source values for routing. |
| tests/gpu_smoke/run_gpu_smoke_diffusion_e2e.sh | Add two new GPU smoke entries for MOPD and standalone-teacher-pool runs. |
| docs/examples/config.md | Update config documentation for multi-teacher/teacher-pool distillation fields. |
| docs/algo/diffusion_opd.md | Document multi-teacher routing and standalone teacher pool usage and constraints. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if teacher_model.model_path is None: | ||
| raise ValueError("model_path must be specified for the distillation teacher model.") | ||
| self.teacher_models["teacher_model"] = teacher_model | ||
| assert "teacher_model" in self.teacher_models |
| def __init__( | ||
| self, config: DictConfig, role: str, distillation_config: Optional[DistillationConfig] = None, **kwargs | ||
| self, | ||
| config: DictConfig, | ||
| role: str, | ||
| distillation_config: Optional[DistillationConfig] = None, | ||
| teacher_key: Optional[str] = None, | ||
| **kwargs, | ||
| ): | ||
| Worker.__init__(self) | ||
| self.config = config | ||
| self.distillation_config = distillation_config |
|
we need a reward curve for this |
Updated. Detailed setup under the curve picture. |
|
result looks promising, I have no exp. on multi-teacher OPD, perhaps you can share the idea in the verl-omni meeting @SamitHuang @wtomin @knlnguyen1802 @AndyZhou952 pls take a look thanks |
|
Added a paragraph that states the design of teacher manager in diffusion_opd.md. Also FYI, the original idea of Diffusion MOPD: https://arxiv.org/pdf/2605.08063 (FlowOPD), https://arxiv.org/abs/2605.15055 (DiffusionOPD, The 2 paper are of similar date). This PR's implementation is more like the FlowOPD one, and borrowed the teacher pool idea and data proto from verl (mixed batch, and routing and data padding happen before dispatching to worker, and data is collected from workers before sending to loss, per my understanding that's usually where a manager class comes in). I'd say this is a verl style implementation of the Diffusion MOPD idea :) |
| # Stable port range used by RayWorkerGroup for torch distributed rendezvous. | ||
| ray_master_port_range: [20000, 30000] | ||
| # Port range for torch distributed rendezvous, sliced per worker group. Leave null for ephemeral | ||
| # ports; set it only to isolate concurrent training jobs on one machine. | ||
| ray_master_port_range: null |
There was a problem hiding this comment.
can we separated the port range fix to different pr?
There was a problem hiding this comment.
Yes, reorganized the commits and rebased. There is a conflict with the PR that adds _to_diffusion_worker_tensordict, since we moved the teacher rpc call to teacher manager. A minor refactor is applied (moved it to diffusion_trainer_utils.py, otherwise there are import issues), pls take a look.
| if OmegaConf.select(self.config.trainer, "ray_master_port_range") is not None: | ||
| wg_kwargs["master_port_range"] = OmegaConf.to_container(self.config.trainer.ray_master_port_range) | ||
| for resource_pool, class_dict in self.resource_pool_to_cls.items(): | ||
| if not class_dict: | ||
| continue | ||
| pools = [(pool, class_dict) for pool, class_dict in self.resource_pool_to_cls.items() if class_dict] | ||
| master_port_range = OmegaConf.select(self.config.trainer, "ray_master_port_range") | ||
| port_ranges = worker_group_port_ranges(master_port_range, len(pools)) | ||
| for (resource_pool, class_dict), port_range in zip(pools, port_ranges, strict=True): | ||
| if port_range is not None: | ||
| wg_kwargs["master_port_range"] = port_range |
| def worker_group_port_ranges(master_port_range: Optional[Sequence[int]], num_groups: int) -> list[Optional[list[int]]]: | ||
| """Slice a rendezvous port range into one disjoint sub-range per worker group. | ||
|
|
||
| Ports are only bound at ``init_model``, after every group has been spawned, so groups | ||
| sharing a range would all pick its first free port. | ||
| """ | ||
| if master_port_range is None: | ||
| return [None] * num_groups | ||
| lo, hi = (int(port) for port in master_port_range) | ||
| stride = (hi - lo) // num_groups | ||
| if stride < 1: | ||
| raise ValueError( | ||
| f"trainer.ray_master_port_range={master_port_range} has fewer ports than worker groups ({num_groups})." | ||
| ) | ||
| return [[lo + i * stride, hi if i == num_groups - 1 else lo + (i + 1) * stride] for i in range(num_groups)] |
| def worker_group_port_ranges(master_port_range: Optional[Sequence[int]], num_groups: int) -> list[Optional[list[int]]]: | ||
| """Slice a rendezvous port range into one disjoint sub-range per worker group. | ||
|
|
||
| Ports are only bound at ``init_model``, after every group has been spawned, so groups | ||
| sharing a range would all pick its first free port. | ||
| """ | ||
| if master_port_range is None: | ||
| return [None] * num_groups | ||
| lo, hi = (int(port) for port in master_port_range) | ||
| stride = (hi - lo) // num_groups | ||
| if stride < 1: | ||
| raise ValueError( | ||
| f"trainer.ray_master_port_range={master_port_range} has fewer ports than worker groups ({num_groups})." | ||
| ) | ||
| return [[lo + i * stride, hi if i == num_groups - 1 else lo + (i + 1) * stride] for i in range(num_groups)] |
| @@ -734,9 +755,12 @@ def _init_colocated_workers(self): | |||
| wg_kwargs["worker_nsight_options"] = OmegaConf.to_container(worker_nsight_options) | |||
| wg_kwargs["device_name"] = self.device_name | |||
|
|
|||
| for resource_pool, class_dict in self.resource_pool_to_cls.items(): | |||
| if not class_dict: | |||
| continue | |||
| pools = [(pool, class_dict) for pool, class_dict in self.resource_pool_to_cls.items() if class_dict] | |||
| master_port_range = OmegaConf.select(self.config.trainer, "ray_master_port_range") | |||
| port_ranges = worker_group_port_ranges(master_port_range, len(pools)) | |||
| for (resource_pool, class_dict), port_range in zip(pools, port_ranges, strict=True): | |||
| if port_range is not None: | |||
| wg_kwargs["master_port_range"] = port_range | |||
|
and pls resolve conflict |
6d7822d to
2becae6
Compare
…llation Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
…erManager Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
…on distillation Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
…iffusion distillation Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
…nfer micro batch size Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
Co-authored-by: Claude Code Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
2becae6 to
7c5065a
Compare
…andalone teacher pool for diffusion OPD (verl-project#427) * [cfg] feat: multi-teacher entries and routing key for diffusion distillation Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn> * [worker] feat: build one frozen teacher per routing key Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn> * [trainer] feat: route rollout batches to teachers with DiffusionTeacherManager Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn> * [trainer] feat: standalone teacher pool split per teacher for diffusion distillation Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn> * [tests, doc] feat: multi-teacher and standalone smokes and docs for diffusion distillation Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn> * [trainer, worker, tests] fix: pad routed teacher sub-batches to the infer micro batch size Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn> * [recipe] feat: mixed-task MOPD distillation example for SD3.5-Medium Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn> * [doc] feat: teacher runtime design section for diffusion OPD Co-authored-by: Claude Code Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn> --------- Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
What does this PR do?
Brings verl's multi-teacher on-policy distillation design (
DistillationConfig,AsyncTeacherLLMServerManager,teacher_pool) to the diffusion OPD runtime merged in #300 and #325.distillation.teacher_modelsaccepts several named entries, each with a routingkeyand, for a standalone pool, aworld_size. A single-teacher config is unchanged and resolves to the keydefault.distillation.teacher_key(defaultdata_source) names the batch column used for routing.distillation.n_gpus_per_nodeandnnodesdefine an optionalteacher_pool;nnodes: 0(the default) keeps the teachers colocated with the actor, exactly as before.ActorRolloutRefWorkerbuilds one frozenTrainingWorkerper routing key (self.teachers), acceptsrole="teacher"for standalone groups, andinfer_teacher_batchpicks the teacher from ateacher_keycarried in the TensorDict metadata. The call isblocking=False.DiffusionTeacherManageris the diffusion counterpart of verl'sAsyncTeacherLLMServerManager. It resolves routing keys with verl's exact error messages, sends each key's rows to its teacher group (padded to the group's DP size × its infer micro batch size so every rank's shard splits into micro-batches, all groups in flight before the first.get()), and scattersteacher_prev_sample_meanback in the original row order. The loss side is untouched.nnodes > 0,main_diffusionregistersteacher_pool/Role.TeacherModel, and the trainer splits the pool into one sub-pool per teacher (split_resource_pool, byworld_size), each with its own worker group, so the teachers score concurrently. Teacher scoring remains a serial stage of the step, so a standalone pool buys memory isolation and distance from the rollout engine's sleep/wake cycle, not speed. Note: the defaulttrainer.ray_master_port_rangehands every spawned worker group the same rendezvous port, so multi-worker-group runs currently needtrainer.ray_master_port_range=null(the standalone smoke sets it); the port-range fix, split out of this PR per review, makes the default work by slicing the range per group.Not in scope, left for follow-ups: per-teacher
lora_adapter_path/guidance_scale, a pure-distillation fast path, and the v1 trainer.Checklist Before Starting
teacheranddistillationreturn only [trainer, worker, cfg] feat: OPD end-to-end draft — teacher-in-ref + distill verification (#293) #304 and [trainer, rollout] Support pickscore for DiffusionOPD #355.task_idof its multi-task dataset to frozen LoRA teachers on the actor backbone. This PR is the config, registry, routing, and placement layer in verl's shape for separate-weight teachers. The two compose: [trainer, rollout] Support pickscore for DiffusionOPD #355'steacher_namecolumn can serve directly asteacher_key.[worker, trainer, cfg, tests, doc] feat: ...API and Usage Example
Default-off; a single-teacher config needs no change. Two colocated teachers routed by
data_source:The same teachers on their own GPUs (the actor keeps
trainer.n_gpus_per_node):Full reference, including the
teacher_model-is-popped pitfall inherited from verl:docs/algo/diffusion_opd.md.Test
CPU (
python -m pytest … -q, verl 0.10.0.dev @ c4b389a, vllm-omni 0.27.0rc1):GPU (4× RTX PRO 6000, vllm-omni 0.27.0rc1, tiny random SD3 checkpoints unless noted;
tests/special_e2e/run_diffusion_teacher_smoke.sh). Withrollout.seed=0the rollout engine is close to, but not bit-exact across runs: four same-code runs of the same smoke span 2e-5 relative indistill_kl_loss, and theirall_latentsdiffer by up to 1.7e-2. That floor is the yardstick for every scalar below; the placement question is settled on tensors instead.SMOKE=distill/coexistence(regression)distill_kl_loss0.12816689 vs. pre-PR code 0.12816426 (rel 2e-5, at the floor)SMOKE=mopd(two colocated teachers,data_sourcealternating)distill_kl_loss0.12816554 vs. single-teacher 0.12816689: every row is scored and scattered back in orderSMOKE=standalone(actor 1 +teacher_pool2, one sub-pool per teacher)timing_s/teacher0.58 s vs. 1.10 s with a sequential.get()key/ missingkey/ missing columnValueErrorwith verl's messagesworld_size1+1, and vs. standalone 2+1 (FSDP DP=2 teacher; also the 1-row-per-key subset, which pads 1→2)teacher_prev_sample_meanistorch.equalin all four comparisons (max|Δ| = 0)world_size=2distill_kl_loss3.20e-3 → 1.50e-3 vs. 3.21e-3 → 1.49e-3 (rel gap 0.2% at step 1, ≤ 2.9% over 10 steps, within the floor);timing_s/teacher2.11 s vs. 2.12 s,timing_s/step13.1 s vs. 13.5 sinfer_micro_batch_size=8(sub-batch padding path)distill_kl_loss7.8e-4 → 4.8e-4) plus the 300-step reward-curve run below on the same pathAll smokes, the unknown-key negative, and the real-checkpoint run were repeated after rebasing onto current main (verl pin
c4b389a, vllm-omni 0.27.0rc1): step-1distill_kl_loss0.12816553 (single-teacher) and 0.11859834 (standalone) are bit-identical to the pre-rebase values, mopd 0.11859706 sits at the rollout floor; the negative still fails at startup with verl's message; the real SD3.5-M colocated 3-step run gives 3.18e-3 → 2.05e-3 → 2.06e-3 vs. 3.21e-3 → 2.06e-3 → 2.05e-3 pre-rebase (within the floor),timing_s/teacher2.01 s vs. 2.07 s.tests/gpu_smoke/run_gpu_smoke_diffusion_e2e.shgains themopd(test 7) andstandalone(test 8) cases.Reward Curves
Setup: the DiffusionOPD paper's Stage-2 scenario — SD3.5-M LoRA student on mixed two-task data (flow-GRPO OCR prompts + PickScore-SFW prompts,
data_sourcecolumn as the routing key), distilling from the paper's two task teachers (quanhaol/DiffusionOPDOCR / Aes LoRAs merged into full SD3.5-M checkpoints). Loss isdistill_klonly; rewards (GenRM-OCR for ocr rows, PickScore for pickscore rows — the same scorer the reference implementation uses for the Aes task) are monitored, never in the loss. 300 steps, val every 5 steps on a fixed 64-prompt set (32/task), colocated placement (standalone equivalence is settled bit-exactly above).Four runs, one sentence each:
teacher_models+ defaultteacher_key=data_source): OCR reward climbs 0.20 → the 0.719 teacher band by step ~25 and oscillates around it; PickScore climbs 0.755 → 0.819 (holding 0.818–0.822 over the last 60 steps, peak 0.826 at step 280 — the Aes band) — each task converges toward its own teacher.defaultpath): OCR also reaches its band, but PickScore drops from 0.755 and stays pinned at 0.732–0.739 for all 300 steps — exactly the OCR-Teacher's own PickScore level; without routing, the off-task reward is dragged to the single teacher's level and longer training does not recover it.On the
distill_klwobble of the routed run (left panel): each logged point is the mean over one 8-prompt step whose task mix is a fresh random draw, and the two teachers' KL magnitudes differ, so the ag
gregate swings with the mix. One LoRA also cannot sit tightly on two teachers at once, so the two runs
' loss levels are not comparable. The per-task reward curves are the primary evidence.
Command (the new example script in this PR; the control run uses the same mixed data with a single
teacher_modelentry, i.e. the unchanged auto-keydefaultpath):Design & Code Changes
verl_omni/workers/config/diffusion/distillation.py:DiffusionDistillationTeacherModelConfig(key,model_path,world_size),_resolve_teacher_modelsline for line from verl, pool-size check whennnodes > 0.verl_omni/workers/engine_workers.py:self.teachers: dict[str, TrainingWorker],role="teacher",infer_teacher_batchroutes onteacher_key.verl_omni/trainer/diffusion/teacher_manager.py(new):DiffusionTeacherManager; routed sub-batches pad toworld_size * infer_micro_batch_size_per_gpu._to_diffusion_worker_tensordict(the [diffusion, trainer, tests] feat: strip responses from worker batches #430 responses-stripping projection) moves todiffusion_trainer_utils.pyso the manager applies it to teacher RPCs too.examples/diffusionopd_trainer/sd35/run_sd35_medium_mopd_distill.sh+mixed_task_reward.py(new): the mixed OCR + PickScore recipe behind the reward curves.verl_omni/trainer/diffusion/ray_diffusion_trainer.py,main_diffusion.py:teacher_poolregistration, per-teacher sub-pools and worker groups, the manager in the fit loop, profiler start/stop for standalone groups.docs/examples/config.mdsynced.Checklist Before Submitting
docs/algo/diffusion_opd.md,docs/examples/config.md.ci-e2e-diffusiontests 7 and 8.Note on AI assistance
AI assistance (Claude) was used for this change. I reviewed every changed line, ran the CPU suites and the GPU runs above myself on the hardware listed, and can defend the design end to end.