Train Stack
v2 single-stage train stack: FSDPBackend, TrainStack, structural injection, EMA shadow, and the train-step contract.
Where it fits: the optimizer half of the train step — rollout → reward → advantage → train → sync. In: a track with advantages (plus the algorithm's gradients). Out: updated weights (synced back to rollout in dedicated modes). Full map:
../README.md.
What it is
unirl/train is the optimizer half of the UniRL training loop. It owns the
trainable model's parameters, optimizer, scheduler, EMA shadow, and structural
injection (LoRA / DiffusionNFT / mirror), and it sequences loss → backward → optimizer step
for one rollout track at a time. The loss math itself belongs to the algorithms
module; this module never computes a loss.
Why it exists
The split exists because two correctness invariants live on the train side, not in
the loss math, and both silently corrupt training if they slip. Per-block
fully_shard alone would leave the root params (embed / final norm / lm_head) as
plain replicated tensors FSDP never reduces — their replicas would drift apart
across ranks and the grad-norm would be wrong — so fsdp_wrap claims them into a
root fully_shard group by default (root_wrap) and fails fast when a multi-rank
run leaves a trainable param outside every group. The uniform wrap also keeps the
optimizer an all-DTensor param bag (a mixed Tensor + DTensor bag forces
foreach=False or AdamW's fused kernel raises). Centralizing wrap + step here lets
every algorithm inherit these invariants for free; the loss module legitimately
knows nothing about DTensor sharding or wrap topology.
How it works
FSDPBackend(backend/fsdp/backend.py) holds the FSDP2-wrapped module (bundle.<trainable_attr>, defaulttransformer), the optimizer, scheduler, and EMA (when configured). Structural injection (inject_lora/inject_nft/inject_mirror) happens once at construction, beforefsdp_wrap.optimizer_stepis the single chokepoint: clip → step → schedule → EMA, and it skips the whole step on a non-finite grad norm (stepping would scale every parameter by the bad norm and poison the next rollout).TrainStack(stack/base.py) takes one backend + oneStageAlgorithmand runstrain_track: move the segment onto device →prepare_segment_anchors(freeze π_old once) →num_updates_per_batchoptimizer steps over disjoint mini-batches, each a micro-batch loop ofcompute_loss_and_backward. The mini/micro slicing comes from one source — the injectedmicro_planner(stack/planner/;CountPlannerby default,TokenBudgetPlannerfor token packing) — shared withprepare_segment_anchors(stack/anchor.py, also used byUnifiedModelTrainStack), so when an algorithm replays its anchor, it's recomputed at the exact geometry training uses, which is what pins the on-policy PPO ratio to 1 under bf16's batch-shape sensitivity.AgenticTraineruses the same interface after concatenating every successful trajectory's generated assistant turns.UnifiedModelTrainStack(unified_model_stack.py) drives two algorithms (ar+image) backward-accumulating into one shared optimizer step on one shared backbone (HunyuanImage3).
Extending it: a new structural injection mode is an inject_<mode> alongside
inject_lora (lora.py) and inject_nft / inject_mirror (ema.py), called
before fsdp_wrap, plus a config in configs.py; a new optimizer or LR schedule
is a branch in optim.py plus fields on OptimizerConfig / LrSchedulerConfig
in backend/base.py; a multi-update-capable algorithm sets
supports_multi_update = True, declares anchor_fields, and exposes
recomputes_anchor when its anchor must follow the planned micro geometry (see
../algorithms/README.md).
Gotchas
num_updates_per_batch > 1needssupports_multi_updateand must evenly divide the per-worker batch — otherwise the ctor or_build_mini_batch_slicesraises (a ragged mini-batch would silently drop samples and desync grad-accum across DP ranks).- Multi-update membership is contiguous by default. Set
shuffle_updates: trueon the stack to shuffle the full per-worker batch before it is partitioned into optimizer updates;shuffle_seed(default 0) is combined with the rollout id so the per-rollout ordering is reproducible across checkpoint resume. It is a no-op atnum_updates_per_batch: 1, requires the defaultCountPlanner, and gathers each micro-batch lazily, so it adds no full-track copy. optimizer_stepsilently skips (does not crash) on a non-finite grad norm and zeroes grads — a flat loss curve with a logged warning means grads went non-finite.- Checkpointing preserves a never-stepped AdamW — DCP materializes empty optimizer state with a dummy step; UniRL resets it so the first real update remains step 1.
master_dtypedefaults toNone, so the optimizer master followsparam_dtype— a bf16-loaded base then keeps a bf16 LoRA master and the ~1e-6 AdamW steps round away (the policy drifts into a degenerate reward-hack). An fp32-loaded model gets an fp32 master for free; a bf16 load needsmaster_dtype: fp32set explicitly. The ctor never warns.- The EP fused-expert layout registry is keyed by
config.model_type, never by parameter-name suffix (backend/veomni/ep/experts.py). HunyuanImage3's fused params end in.experts.down_projtoo, but itsgate_and_uphalves are stored swapped relative to Qwen3-MoE'sgate_up, so a suffix match would silently hand one model's packing semantics to another — today that only fails closed because HI3's other suffix happens not to match. AnExpertExportLayoutpairs a model module'sis_fused_expert_param(name)anditer_hf_expert_tensors(name, stacked)operations; register it in_EXPERT_EXPORT_LAYOUTS_BY_MODEL_TYPEto enable full-weight sync for that family. Consumers outsidetrain/take the transform frombackend.expert_weight_export_transform()—distributed/weight_sync/must not import the layout (the core-dependency guard rejects it). - Advantages are not computed here —
trainraises ifpart.advantages is None; the trainer must callcompute_advantageson the full shard first. - Selective offload supports direct GPU-streaming weight sync. The trainer
keeps FSDP parameter shards on device while vLLM receives weights, clears
consumed gradients and offloads optimizer state before publication, then
offloads the model after publication commits. Calling
offload()without arguments retains the legacy full-state behavior. fsdp_wrapwraps nothing when no block class is discovered — the warning says "root-only wrap" but_enumerate_block_instancesreturns(), so the shard/cast loops are no-ops and the model trains unsharded and un-cast. Passblock_class_namesexplicitly in the recipe.fsdp_wrapshards the leftover params (embed / final norm / lm_head) into a rootfully_shardgroup by default (root_wrap, on) — the root group never reshards after forward. Setroot_wrap: falsefor models whose stages call submodules of the wrapped object directly (bagel) or that wrap frozen mixed-dtype siblings (hunyuan_image3); a multi-rank run with a trainable param left outside every group then fails fast.defer_grad_syncneeds the last micro-batch of an optimizer step to run a backward — the deferred reduce-scatter only fires inside it. If that micro skips backward (an all-empty micro) while earlier ones ran,TrainStack.trainraises instead of silently stepping on never-synced grads (which would also leak the stale accumulation into the next step's reduce-scatter).fsdp_mode: hybridmakes the HSDP shard group explicit —hsdp_shard_sizeis the number of contiguous ranks that shard parameters; the remainingworld_size / hsdp_shard_sizedimension holds replicated model copies and synchronizes their gradients. Set it todevices_per_nodefor the usual intra-node FSDP + inter-node replication layout (for example, world 16 with shard 8 gives a(2, 8)mesh). Larger groups such as shard 32 are supported when the world is a larger divisible multiple. Hybrid fails fast when the shard size does not divide the world or leaves only one replica group; usefullfor that one-group case.fsdp_mode: no_shardtrades memory for the all-gather — a(world, 1)mesh leaves the full model on every rank, so no parameter bytes cross ranks and only gradients are all-reduced (DDP). It pays off where the re-gathered bytes dwarf the gradient (LoRA on a frozen base re-gathers the whole backbone every micro-batch) and the model still fits unsharded — per-rank memory becomes the whole model + grads + optimizer state.reshard_after_forwardis not a no-op here:falsekeeps every block's unsharded compute copy resident, which is a second full copy of the model wheneverparam_dtypeupcasts (fp32 compute over a bf16 checkpoint), so leave ittrueunless that copy is cheap.defer_grad_sync: truethen gives one all-reduce per optimizer step. VeOmni only supportsfull.copy_engine_all_gather: truetakes the FSDP all-gather off the SMs — FSDPBackend creates the default NCCL group with the zero-CTA policy and everyfully_shardgroup allocates its all-gather buffer from NCCL symmetric memory, so the gather runs on the copy engines (cudaMemcpyBatchAsync) instead of anncclDevKernel_AllGatherkernel. Needs PyTorch >= 2.13, NCCL >= 2.28, and a shard group that stays on one node over NVLink (fullon a single node, orhybridwithhsdp_shard_size: devices_per_node);no_shardand VeOmni reject it. FSDPBackend must be what brings uptorch.distributed(it binds WORLD to the rank's CUDA device so DeviceMesh splits the shard group from it and the policy is inherited), andNCCL_CTA_POLICYmust stay unset or2. WORLD keeps the usualcpu:gloo,cuda:ncclpair, so inhybridmode torch logs oneProcessGroupGloo::split ... Falling back to default optionswarning per process while splitting the gloo half; it is expected.lora_cfg.frozen_adapters(OPD teachers) have real weights only afterapply_deferred_ops— the adapter is injected pre-wrap so meta-init bundles work, but its weights load after materialization; reading a teacher earlier sees a null or uninitialized adapter. Teachers must be plain LoRA deltas: unconverted base-rewriting inits (pissa, olora, ...),modules_to_save,layer_replication,trainable_token_indices, DoRA, andbias != "none"are rejected at startup.- Adapter checkpoints exclude frozen teachers, and resume requires the same teachers —
teachers reload from their paths and the checkpoint pins each by a content sha256, so a
different teacher set or different weights raises on
load. A checkpoint trained without teachers resumes into any teacher set.
Profiling → Perfetto
Opt-in torch.profiler (unirl/utils/profiling.py): one env var writes a gzipped Perfetto
trace to outputs/profiler/ on rank 0 (no-op otherwise). Training compute only (not rollout).
Both capture one snapshot of a single train step (after a short warmup, then off — one trace, not one per step; training keeps running). They differ in which slice they record:
one-update— one optimizer update (forward + backward + optimizer) with its cross-GPU comm. Skips the anchor forward; small; for compute/comm overlap.train— the whole step: anchor forward (recompute old-policy log-probs; for diffusion it replays the denoising trajectory, so it's large) + all updates. For step-time breakdown.
The unified-model stack (HI3, AR+image) only supports
train— it fuses each step into a single update, so there is no per-update boundary forone-updateto wrap (a warning is logged and no trace is produced).
UNIRL_PROFILE=one-update python -m unirl.train_diffusion --config-name=<recipe> ...
UNIRL_PROFILE=train python -m unirl.train_diffusion --config-name=<recipe> ...Download the .gz to your local machine, then https://ui.perfetto.dev/ → Open trace file.
Configurable env vars (defaults are fine)
| var | what it does | default |
|---|---|---|
UNIRL_PROFILE | one-update / train (unset = off) | off |
UNIRL_PROFILE_DIR | where the trace is written (auto-created) | outputs/profiler |
UNIRL_PROFILE_RANKS | which ranks profile: 0, all, or a list like 0,8 | 0 |
UNIRL_PROFILE_CUDA | record GPU kernels; 0 = CPU-only trace | 1 |
UNIRL_PROFILE_WARMUP | skip the first few iterations before recording (avoids the one-time first-iter compile / alloc) — one-update: skip N updates; train: schedule warmup | 2 / 1 |
UNIRL_PROFILE_MEMORY | also record CUDA memory alloc/free (memory-over-time; bigger trace) | off |
Source:
unirl/train/readme.md— edit the README next to the code, then runnpm run sync:readmesfromdocs/.