UniRL 文档
指南

Models

Model bundle, stage, condition, and per-model package contracts.

Where it fits: cross-cutting — the model code that both the rollout step (pipeline.generate) and the train step (stage.replay) run, on one shared set of weights. Full map: ../README.md.

UniRL model package: the stage runs two forwards over one bundle — diffuse/autoregress for the rollout and replay for training — both through the same bundle.transformer that the FSDP backend mutates in place, so the policy-gradient ratio is honest

The one idea that makes UniRL's RL correct: the stage runs the rollout forward (diffuse / autoregress) and the train forward (replay) over the same bundle.transformer — the one the FSDP backend mutates in place — so new_logp and old_logp land on identical, just-updated weights.

What it is

unirl/models/ holds one self-contained subpackage per model (sd3/, qwen3/, hunyuan_image3/, pe/, …). Each implements the shared bundle / pipeline / stage / conditions contract so the same model code, on the same weights, serves both the rollout engine (generate) and the train stack (replay).

Not to be confused with the repository-root models/ directory, which only holds local checkpoint and reward-model symlinks.

Why it exists

Rollout and training must score the same weights or the GRPO ratio is meaningless — and "same weights" is subtle, because the FSDP backend mutates bundle.transformer in place (LoRA injection, FSDP sharding). The package is built so both sides reach that one mutated object by attribute — the backend wraps bundle.<trainable_attr>, the engine evals pipeline.<stage_attr>.trainable_module() — instead of holding a private copy. That is the real reason a bundle is bare modules with no lifecycle logic: a self-loaded second copy (from_config) would never see the adapters or the optimizer step, so replay would silently score stale weights and the ratio would drift off 1.0 with no error. (The uniform contract across SD3 / Qwen3 / HunyuanImage3 is the secondary payoff — one backend drives them all by attribute name.)

How it works

A model package bridges three concerns through one shared bundle:

  • Bundle (bundle.py) — a pure container of weights + tokenizer/scheduler, loaded by from_config. No lifecycle logic. The biggest models (HunyuanImage3, Bagel) add a from_meta_config + materialize() path so each rank loads only its shard of the large transformer; SD3 / Qwen3 load eagerly.
  • Pipeline (pipeline.py) — the endomorphic generate entrypoint (generate(sample) -> sample): it reads ancestor primitives through sample.conditioning(), reads sampling params from a pre-forked generation Part, builds typed Conditions, runs the stage, and fills that Part.
  • Stages (diffusion.py / ar.py) — the trainable units. DiffusionStage exposes diffuse (rollout), replay (train), and predict_noise_at_step (DiffusionNFT); ARStage exposes autoregress and replay. Each exposes trainable_module(), returning the nn.Module the FSDP backend wraps and engines eval-scope.
  • Conditions (conditions.py) — typed Batch subclasses with from_dict/to_dict, so the pipeline works in typed form internally but stores the generic Dict[str, Condition] shape on the generated Part.

The bundle is shared, not duplicated: the trainer builds it once and injects it into both sides, so replay reads exactly the weights training updates — hence pipelines offer a bundle-injected constructor alongside the self-loading from_config. σ is engine-pinned: a diffusion pipeline reads the generation Part's DiffusionSamplingParams.sigmas verbatim and raises if it's None — it doesn't compute σ at generate time. Diffusion pipelines also implement latent_shape() so the trainer can author the byte-identical x_T recipe.

Extending it: a new model is a new unirl/models/<model>/ with config.py / bundle.py / diffusion.py|ar.py / conditions.py / pipeline.py mirroring an existing one — there is no registry, models are selected purely by _target_. Read .claude/skills/development/add-model-bundle/SKILL.md first — it is the authoritative bundle / pipeline / stage / conditions contract.

Gotchas

  • trainable_module() is on the stage, not the bundle. The backend reaches the model via getattr(bundle, trainable_attr); engines via getattr(pipeline, stage_attr).trainable_module(). Keep both pointing at the same module.
  • Bundles must stay pure containers — no LoRA, FSDP, autocast, or weight-sync logic; those are train-side lifecycle concerns.
  • Share one bundle. For colocate/trainside runs the pipeline must be built from the injected bundle (not from_config), or replay reads a stale second copy of the weights.
  • σ is engine-pinned — a diffusion pipeline reads the generation Part's sigmas and raises if it's None; never build the σ tensor inside generate.
  • CFG empty-negative differs per model (SD3 "", Qwen-Image " ") — use the model's canonical upstream value or the rollout/replay ratio drifts off 1.0.
  • HunyuanVideo-1.5 prompt-template whitespace is tokenizer state. Keep PROMPT_TEMPLATE_SYSTEM_MESSAGE byte-identical to upstream because mllm_crop_start=108 is tied to that exact prefix length; collapsing its indentation drops user-prompt tokens.
  • Work that needs real storage goes through types/post_materialize.py. A bundle or a structural injector (LoRA / NFT / mirror) may run while the module is still on the meta device, where writing a tensor is a no-op. Register such work with defer_after_materialize; the train backend drains it with apply_deferred_ops after the weight load. The pair describes model-construction state, not training policy, which is why it lives under models/types/ and not under train/.
  • Name-keyed matching around materialization compares canonical names. Deferred ops (and materialize()) run post-wrap, and checkpoint_wrapper (activation checkpointing) interposes a _checkpoint_wrapped_module. segment in named_parameters() names while state_dict() keys stay canonical (the wrapper's state-dict hooks strip it). Any match between module params and checkpoint keys goes through canonical_param_name, exported beside the deferred-op pair in types/post_materialize.py.

Conversation composition

unirl/models/types/conversations.py holds the transpose helpers with more than one model consumer: build_text_messages (qwen3, qwen3_5), build_vision_messages (qwen_vl, qwen3_5), and the two composition rules both use — system_prefix and group_consecutive_roles. The sglang wire render imports those two rather than keeping private copies, so the rules have one definition.

  • One system rule: the config system_instruction is a default; an explicit system turn in the data wins. A path that wants "data is the sole source of truth" leaves system_instruction unset in the recipe — not a code branch.
  • A render with one model consumer stays in that model's package and imports the shared rules (Qwen3-Omni's URI-media render lives in unirl/models/qwen3_omni/media.py, next to the media contract it enforces). It moves into the shared layer when a second consumer actually exists.
  • Model chat stages own encoding — chat template, processor, Bagel splits — never the composition rules.

Support matrix

Model and algorithm support are independent dimensions (see ../algorithms/README.md); this table is the model dimension. A row is runnable only when the package and a recipe under examples/ exist for the listed entrypoint and rollout engine — a package existing by itself is not evidence of end-to-end support. The Model column lists the pretrained_model_ckpt_path defaults of the listed recipes, not marketing aliases; where a recipe has no Hub default, it names the env var to set and the checkpoint it expects, and the row is tagged checkpoint-path-required. Engines: trainside = in-process rollout on the training weights; sglang / sglang_diffusion / vllm / vllm_omni / fastvideo = external engines with a UniRL adapter (see ../rollout/engine/README.md).

Status legend

StatusMeaning
✅ RunnablePackage + at least one recipe for every listed entrypoint / engine.
🧪 SFT-onlytrain_sft only; the pipeline has no generate, so no RL rollout path.
🧩 Bundle-onlyPackage ships a bundle but no pipeline; usable only under another package's pipeline, as the listed recipe does.
🔗 ComposedNo weights of its own; composes other rows' packages.

Restriction tags

  • engine-specific — only the listed engines have a recipe; other engines have no adapter or no recipe and are unverified.
  • scorer-dependent — the recipe's reward does not run from the base install alone. It needs a remote scorer served by unirl-reward-service through RemoteRewardBackend (e.g. editreward, wise, geneval, the hpsv2 / pickscore / hpsv3 / … bundle), a judge process in its own venv through ManagedScorerProcessBackend (editscore), an external judge endpoint (llm_judge → $JUDGE_URL), or a local scorer with its own install or checkpoint: hpsv3 / hpsv3pp (hpsv3 package), ocr ([eval] extra), videoalign (VIDEOALIGN_CKPT), imagebind (git install, CC-BY-NC-SA). Local scorers that only pull Hugging Face weights through transformers (pickscore, clip, clap, videopickscore, videoclipdelta) do not count. Registry: ../reward/local/registry.py.
  • multi-node — the recipe's launch topology is NxM with N ≥ 2 nodes.
  • LoRA-only — every shipped recipe injects LoRA; full fine-tuning has no recipe.
  • checkpoint-path-required — the recipe has no Hugging Face default; set PRETRAINED_MODEL (or the named env var).

Image diffusion

PackageModel (recipe default)ModalityEntrypointsEngines with a recipeRecipesRestrictionsStatus
sd3/stabilityai/stable-diffusion-3.5-mediumText → Imagetrain_diffusion, train_sft, train_pe (image side)trainside, sglang_diffusion, vllm_omnisd3_trainside, sd3_nft_sglang, sd3_vllmomni, sft/sd3_sft_lorasd3_dancegrpo_hpsv3*, sd3_nft_reward_service, sd3_opd_multi_teacher: scorer-dependent✅
qwen_image/Qwen/Qwen-ImageText → Imagetrain_diffusiontrainside, sglang_diffusion, vllm_omniqwen_image_trainside, qwen_image_sglang, qwen_image_grpo_vllmomni—✅
qwen_image_edit_plus/Qwen/Qwen-Image-Edit-2511Text + Image → Imagetrain_diffusiontrainside, sglang_diffusionqwen_image_edit_plus_nft, qwen_image_edit_plus_flowgrpo_sglangengine-specific (no vllm_omni adapter); *_managed_editscore: scorer-dependent✅
flux2_klein/black-forest-labs/FLUX.2-klein-base-4B / -9BText → Image; Text + Image → Imagetrain_diffusiontrainside, sglang_diffusionflux2_klein_trainside, flux2_klein_sglang, flux2_klein_4b_editrewardengine-specific; edit (*_editreward*) recipes: scorer-dependent✅
z_image/Tongyi-MAI/Z-ImageText → Imagetrain_diffusiontrainside, sglang_diffusionz_image_trainside, z_image_sglang_full_tensorengine-specific✅
boogu_image/Boogu/Boogu-Image-0.1-BaseText → Imagetrain_diffusiontrainsideboogu_image_trainsideengine-specific (trainside only)✅

Video diffusion

PackageModel (recipe default)ModalityEntrypointsEngines with a recipeRecipesRestrictionsStatus
wan21/Wan-AI/Wan2.1-T2V-1.3B-Diffusers, Wan-AI/Wan2.1-I2V-14B-720P-Diffusers (I2V)Text → Video; Image → Videotrain_diffusion, train_sfttrainside, sglang_diffusion, fastvideowan21_t2v, wan21_i2v, wan21_t2v_sglang, wan21_t2v_dancegrpo_fastvideo, sft/wan21_t2v_ucf101_fullwan21_t2v_videoalign_dancegrpo: scorer-dependent; fastvideo needs $FASTVIDEO_PATH✅
wan22/Wan-AI/Wan2.2-T2V-A14B-Diffusers, Wan-AI/Wan2.2-I2V-A14B-Diffusers (I2V)Text → Video; Image → Videotrain_diffusiontrainside, sglang_diffusion, fastvideowan22_t2v_14b, wan22_i2v, wan22_t2v_14b_sglang, wan22_t2v_14b_dancegrpo_fastvideoextends wan21/ (dual-expert)✅
wan22_v2v/PRETRAINED_MODEL → Wan-AI/Wan2.2-T2V-A14B-Diffusers (V2V pipeline over wan22/)Video → Videotrain_diffusiontrainsidewan22_v2v_14bengine-specific (trainside only); checkpoint-path-required (default is a cluster path); videoclipdelta scorer✅
hunyuan_video10/hunyuanvideo-community/HunyuanVideoText → Videotrain_diffusiontrainside, sglang_diffusionhunyuan_video10_t2v_trainside, hunyuan_video10_t2v_sglangengine-specific✅
hunyuan_video15/hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2vText → Videotrain_diffusiontrainside, vllm_omnihunyuan_video15_t2v_dancegrpo_trainside, hunyuan_video15_t2v_vllmomni_colocateengine-specific (no sglang_diffusion adapter)✅
ltx2/Lightricks/LTX-2Text → Videotrain_diffusiontrainside, sglang_diffusionltx2_t2v_trainside, ltx2_t2v_sglang_loramergeengine-specific; I2V is in the config but has no recipe (not claimed)✅
ltx2/dg845/LTX-2.3-DiffusersText → Audio + Videotrain_diffusiontrainsideltx2_3_t2av_trainside, ltx2_3_t2av_audioreward_trainsideengine-specific (trainside only); *_audioreward*: t2av_composite (videopickscore + clap), imagebind opt-in (scorer-dependent)✅
minimax_h3/PRETRAINED_MODEL (no default) → MiniMax-H3 (33B dense) Hub id or local snapshotText → Video + Audiotrain_diffusiontrainsideminimax_h3_t2va_trainside, minimax_h3_t2va_nftengine-specific (trainside only — no vllm_omni adapter on main); checkpoint-path-required; canvas axes multiples of 32 within 1:4–4:1 and a 768×1344 area cap, 124 frames minimum (17n + 5), 1 node × 8 GPUs minimum; reward is t2av_composite (imagebind mode all + clap; ImageBind is CC-BY-NC-SA, scorer-dependent)✅

Unified and multimodal generators

PackageModel (recipe default)ModalityEntrypointsEngines with a recipeRecipesRestrictionsStatus
hunyuan_image3/tencent/HunyuanImage-3.0-InstructText → Image; Text + Image → Imagetrain_unified_modeltrainside, vllm_omnihi3_trainside_t2i, hi3_vllmomni, hi3_it2iengine-specific; hi3_it2i: scorer-dependent (editreward)✅
bagel/ByteDance-Seed/BAGEL-7B-MoTText → Image; Text + Image → Image; Text + Image → Texttrain_diffusion, train_ar, train_unified_model, train_async_diffusion, train_sfttrainside, vllm_omnibagel_trainside_lora, bagel_vllmomni, bagel_vllmomni_async, ar/bagel_grpo_arxivqa_mc_2x8_lora, unified_model/bagel_trainside_unigrpo, sft/bagel_sft_loraengine-specific; bagel_vllmomni*, unified_model/bagel_trainside_unigrpo: checkpoint-path-required (default is a pod-local /root/... path); AR VQA recipe: multi-node (2×8); edit (bagel_editreward, bagel_it2i_*): scorer-dependent✅
sensenova_u1/sensenova/SenseNova-U1.5-8B-MoT-Preview, sensenova/SenseNova-U1.5-8B-MoT-SFT (OCR *_4x8)Text → Image (pixel flow)train_diffusiontrainsidesensenova_u1_5_trainside, sensenova_u1_5_ocr_trainside_lora_4x8engine-specific (trainside only); *_4x8 recipes: multi-node, scorer-dependent (ocr); recipe dir is sensenova_u1_5/, package is sensenova_u1/✅
janus_pro/deepseek-ai/Janus-Pro-7BText → Image (AR image tokens); Text + Image → Texttrain_artrainsidejanus_pro_grpo_t2i_lora, janus_pro_grpo_geo3k_mc_loraengine-specific (trainside only); LoRA-only✅

Autoregressive LLM / VLM / omni

PackageModel (recipe default)ModalityEntrypointsEngines with a recipeRecipesRestrictionsStatus
qwen3/Qwen/Qwen3-4B-Base (Qwen/Qwen3-4B-Instruct in deep research, Qwen/Qwen3-0.6B in PE); vllm recipe: QWEN3_MOE_PATH → Qwen3-30B-A3BText → Texttrain_ar, train_async_ar, train_agentic, train_pe (rewriter side), train_sftsglang, vllm, agentic (over sglang), trainside (PE recipes only)qwen3_grpo_4b_base_dapo_sglang, qwen3_moe_grpo_30b_a3b_gsm8k_fsdp_vllm_tp4, qwen3_grpo_4b_base_dapo_sglang_async, deep_research_search_judge, sft/qwen3_sftvllm recipe: 30B-A3B only, checkpoint-path-required (native IPC weight sync accepts model_type == qwen3_moe); agentic recipe: scorer-dependent (llm_judge posts to an OpenAI-compatible $JUDGE_URL)✅
qwen3_moe/QWEN3_MOE_PATH (no default) → Qwen3-30B-A3B, stacked for VeOmni EPText → Texttrain_ar (through the qwen3/ pipeline)sglangqwen3_moe_grpo_30b_a3b_veomni_ep_sglangbundle-only: bundle.py only, no pipeline / stage / conditions — the recipe builds Qwen3Pipeline.from_bundle on it for VeOmni expert parallelism; checkpoint-path-required🧩
qwen3_5/tiny-random/qwen3.5 (smoke); real runs: QWEN3_5_PATH → Qwen3.5-9B, QWEN3_5_MOE_PATH (no default) → Qwen3.5-35B-A3BText → Text; Text + Image → Texttrain_arsglangqwen3_5_grpo_9b_base_dapo_sglang, qwen3_5_grpo_9b_geo3k_mc_sglang, qwen3_5_moe_grpo_35b_a3b_base_dapo_sglangengine-specific (sglang only); checkpoint-path-required (9B recipes default to tiny-random/qwen3.5, a 4-layer smoke-test model; MoE recipes have no default); no SFT recipe; hybrid-attention fast path needs flash-linear-attention from the [sglang] extra✅
qwen_vl/Qwen/Qwen2.5-VL-7B-InstructText + Image → Texttrain_ar, train_sfttrainside, sglangqwen_vl_grpo_geo3k_mc_4x8, qwen_vl_grpo_geo3k_mc_sglang_4x8, sft/qwen_vl_sftpackage targets Qwen2.5-VL (not Qwen3-VL); RL recipes: multi-node (4×8)✅
qwen3_omni/QWEN3_OMNI_PATH (placeholder default) → Qwen3-Omni-30B-A3B-Instruct (Thinker)Text / Image / Audio / Video → Texttrain_ar, train_sftvllm_omniqwen3_omni_video_r1_gspo_lora_vllm_omni_1x4, qwen3_omni_audio_dcase_gspo_lora_vllm_omni_1x4, sft/qwen3_omni_audio_sft_loraengine-specific (vllm_omni only, TP must divide the audio tower's 20 heads); LoRA-only; checkpoint-path-required✅
cosmos3/nvidia/Cosmos3-NanoVideo (+ action) predictiontrain_sft— (Cosmos3Pipeline.generate is unimplemented)sft/cosmos3_droid100_videopred, sft/cosmos3_droid100_action_bcSFT-only; dataset-dependent (datasets/droid100/); see cosmos3/README.md🧪

Composed pipelines and workflows

PackageComposesModalityEntrypointsEngines with a recipeRecipesRestrictionsStatus
pe/qwen3/ (rewriter, Qwen/Qwen3-0.6B) + sd3/ (image)Text → Text → Imagetrain_petrainside, composed (sglang + sglang_diffusion)pe_trainside_pickscore, pe_sglang_full_pickscorepe_sglang_full_wise: scorer-dependent🔗
— (examples/deep_research/)qwen3/ + agentic engine over sglangMulti-turn tool usetrain_agenticagentic (sglang)deep_research_search_judgea workflow, not a model; scorer-dependent (llm_judge)🔗

Not supported (no package): the sglang_diffusion adapter registry also exposes FluxAdapter (plain FLUX) and MochiAdapter, but there is no unirl/models/ package and no recipe for either, so they are not model rows. unirl/models/types/ is the shared contract layer, not a model.

Adding a row. A new model is a new package following .claude/skills/development/add-model-bundle/SKILL.md plus at least one recipe per examples/README.md#adding-or-editing-a-recipe; add the row here and the short form in the root README.md only once both exist, and list only the engines a recipe actually exercises.

Source: unirl/models/README.md — edit the README next to the code, then run npm run sync:readmes from docs/.

目录