refactor(diffusion): make role residency one choice per role, not per phase - #428
leviking98z-rgb merged 4 commits into
Conversation
e632859 to
46825ca
Compare
7f3bbb2 to
ccc3980
Compare
… phase A colocated loop moved the whole train state across PCIe twice per rollout for no reason. Generation ended by reloading the trainer, and the reward phase -- the very next statement -- began by offloading it again, because the two were separate opt-ins (`enable_fsdp_offload`, `offload_train_during_reward`) that each owned one phase and knew nothing about the other. On a 33B DiT that is tens of GB of host-to-device and device-to-host traffic per rollout, cancelling out. Residency is now a property of a role rather than of a phase: train_resident, rollout_resident and reward_resident each say whether that role keeps its weights on the GPU while idle. ResidencyPlanner translates a phase's needs into transitions and issues only the ones that change something, so the reward phase inherits an already-parked trainer and emits nothing. The trainer is made resident at the two points that read its weights: the optimizer step, and a checkpoint. The save hook sits behind maybe_save_checkpoint's own due-or-not predicate, so a window that evaluates without saving leaves the trainer parked instead of paying a round trip for a save that is not going to happen. Every transition goes through the planner, including evaluate()'s rollout wake/sleep, so its state table is the only source of truth for what is resident. Parking is unconditional in enter(): an untracked active role (a trainside rollout, or a trainer on a separate slab) still displaces whatever shares the slab it runs on, so a non-resident reward is parked for the optimizer step even when the trainer itself is not tracked. Roles that do not share the slab are not tracked at all, so nothing moves memory the active role could not have used: a reward with its own reward_fraction slab, and the trainer behind a `layout: separate` or trainside rollout, whose generation reads the weights that would be parked. Trainside behaviour is therefore unchanged. Parking the trainer before the rollout wakes means the LoRA push can no longer be one call: the read wants the trainer resident and the load wants the rollout awake. LocalLoraWeightSync gains the extract/push split RemoteLoraWeightSync already had, and sync() keeps its exact previous behaviour as extract+push. The adapter only changes when the optimizer steps, so the cache survives the push and covers every later push until the next step -- an accumulate window and an eval's later chunks reuse it rather than onloading the trainer to read weights that cannot have changed. That read goes through enter(), never set(), so it cannot land the trainer on the slab beside a reward the rollout phase has not parked yet. train_resident=false is rejected at startup where it cannot be honoured: with nothing to park (separate layout, or a trainside rollout), with a full-weight sync whose single sync() reads the trainer and loads the rollout in one call, and with EMA/DiffusionNFT plus an external colocated rollout. The sync check probes the built object for extract/push rather than matching a class name. RewardService.offload/onload carried no dispatch decorator, so the role's Handle could not proxy them and reward residency had never been driven from a phase boundary at all. Broadcast rather than scatter: each reward worker holds its own copy of the scorer, so each has to move its own weights. Defaults reproduce the previous behaviour exactly (train_resident: true, rollout_resident: false, reward_resident: true), and the three retired keys are rejected with the key that replaces them rather than being ignored. AsyncDiffusionTrainer rejects rollout_resident=false as well. Its engine owns a dedicated slab and is never idle: _boundary_evaluate leaves it awake on purpose (sleep_after=False) and the loop resubmits prompts straight after the checkpoint, so the save hook's enter() would sleep an engine that is about to generate. The synchronous loop re-enters ROLLOUT before every generate and is unaffected. Both LoRA syncs hold the extracted adapter past the push, so the one flag the trainer keeps for "the sync still has what I read" is true for either of them.
The gate was a standalone copy of bagel_vllmomni.yaml, and a copy drifts: it still set cfg_text_scale, which BagelDiffusionParams renamed to guidance_scale, so on current main the gate could not build its own sampling params. Every recipe under examples/ is already on the new name. As an overlay it carries only the settings that make the planner observable, and cannot drift again. The resolved config is unchanged apart from eval_interval / save_interval, which are spelled out at the trainer's own defaults so the eval and checkpoint arms this gate exists for can be selected with a plain override rather than Hydra's append syntax.
ccc3980 to
3569918
Compare
|
Rebased onto
The all-parked sequence per rollout, verbatim from the log: Two things changed in the branch besides the rebase:
One thing the long arm surfaced that the recipe should say out loud: Still current against today's
|
Fixes #429.
Summary
A colocated loop moved the whole train state across PCIe twice per rollout for no reason.
Generation ended by reloading the trainer, and the reward phase — the very next statement —
began by offloading it again, because the two were separate opt-ins (
enable_fsdp_offload,offload_train_during_reward) that each owned one phase and knew nothing about the other. On a33B DiT that is tens of GB of host-to-device and device-to-host traffic per rollout, cancelling
out.
Residency becomes a property of a role rather than of a phase.
train_resident,rollout_residentandreward_residenteach say whether that role keeps its weights on the GPUwhile it is idle; only weights ever move, never a role's process.
ResidencyPlannertranslatesa phase's needs into transitions and issues only the ones that change something, so the reward
phase inherits an already-parked trainer and emits nothing. The trainer returns once,
immediately before the optimizer step, which also means it stays parked across a whole
accumulate_rolloutswindow instead of round-tripping per rollout.Roles that do not share the slab are not tracked at all, so nothing moves memory the active role
could not have used: a reward with its own
reward_fractionslab, and the trainer behind alayout: separateor trainside rollout, whose generation reads the very weights that would beparked. Trainside behaviour is therefore unchanged.
Two supporting changes fall out of parking the trainer before the rollout wakes:
the load wants the rollout awake.
LocalLoraWeightSyncgains theextract()/push()splitthat
RemoteLoraWeightSyncalready had, andsync()keeps its exact previous behaviour asextract+push. The adapter only changes when the optimizer steps, so the cache survivesthe push and covers every later push until the next step: an accumulate window and an eval's
later chunks reuse it instead of onloading the trainer to read weights that cannot have
changed. That read goes through
enter(), neverset(), so it cannot land the trainer on theslab beside a reward the rollout phase has not parked yet.
train_resident: falseis rejected at startup wherever it cannot be honoured: with nothing topark (
layout: separate, or a trainside rollout), with a full-weight sync (TensorWeightSync,NCCLWeightSync,IPCWeightSync,CheckpointWeightSync) whose singlesync()reads thetrainer and loads the rollout in one call, and with EMA/DiffusionNFT plus an external colocated
rollout. The unparkable case is checked first, since reporting the sync would send the reader
after the wrong knob. The sync check probes the built object for
extract/pushrather thanmatching a
_target_suffix, so a new implementation is classified by what it can do.AsyncDiffusionTrainerrejects bothreward_resident: false(async scoring runs at reap timeoutside
_reward_phase()) androllout_resident: false. The second matters because the savehook lives on the base class:
_boundary_evaluatedeliberately leaves the engine awake(
sleep_after=False) and the loop resubmits prompts straight after the checkpoint, so parkingat that boundary would sleep an engine about to be asked to generate. The synchronous loop
re-enters
ROLLOUTbefore every generate and is unaffected.RewardService.offload()/onload()carried no dispatch decorator, so the role'sHandlecould not proxy them (
AttributeError: 'Handle' object has no attribute 'onload'). Everyscorer implements the pair and the managed-process backend has its own
per_callchoreography, but the training loop had no way to reach either — which is why reward residency
had never been driven from a phase boundary. Broadcast rather than scatter: each reward worker
holds its own copy of the scorer, so each has to move its own weights.
Two invariants are worth stating because getting them wrong is silent:
resident before each.
train()runstrain_step → evaluate → maybe_save_checkpoint, andevaluateparks the trainer to give the rollout the slab; without the second onload the savewould gather CPU shards, which a
full_state_dictall-gather on the NCCL group cannot do. Thesave-side onload is a
_prepare_for_save()hook called aftermaybe_save_checkpoint's owndue-or-not predicate, so the predicate stays in one place and a window that evaluates without
saving does not pay a round trip for a save that will not happen.
enter()is unconditional, including when the active role is untracked. Atrainside rollout, or a trainer on a separate slab, still runs and still displaces whatever
shares the slab it runs on — so a non-resident reward is parked for the optimizer step even
when the trainer itself is not tracked.
Every transition goes through the planner,
evaluate()'s rollout wake/sleep included, so itsstate table is the only source of truth for what is resident. A no-sync multi-chunk evaluation
preserves the rollout while reward scoring runs between chunks; otherwise an unpinned SGLang
engine would discard the adapter that the caller deliberately did not repush.
Synchronous defaults reproduce the previous behaviour exactly (
train_resident: true,rollout_resident: false,reward_resident: true). Async diffusion intentionally overrides onlyrollout_residenttotrue; its constructor and entry point now reference the same policy object,so direct construction cannot silently fall back to the synchronous value. The three retired keys
are rejected at startup with the key that replaces them rather than being ignored. All three invert, so the
message says so instead of naming one direction.
The AR, PE and unified-model trainers have their own
enable_fsdp_offloadparameter; thoserecipes are untouched, since the rejection lives in
DiffusionTrainer.__init__.Test Plan
Three checks: the planner's transition sequences per policy, the retired-key rejection, and two
hardware runs of the same recipe under different policies.
Hardware. BAGEL-7B-MoT t2i + PickScore on 1x8 H20,
layout: colocate, external vLLM-Omnirollout, two rollouts at
weight_sync_interval: 1so the run has to cross a LoRA push betweenrollouts. Run twice against the same recipe:
train_resident/rollout_resident/reward_resident: false), so theplanner has to sequence all of them — a recipe exercising one role would not test it. The
reward is a managed vLLM child with its own CUDA context, which makes reward residency a real
transition rather than bookkeeping.
train_resident: true,reward_resident: true) — the policy every recipe that thisPR does not touch runs under. This is the regression arm.
eval_interval: 2andsave_interval: 2, so the run has tocheckpoint right after an eval has parked the trainer. That ordering is the one that made the
save gather CPU shards before the fix, and the gate's own
eval_interval: 0had hidden it.eval_interval: 1,eval_num_prompts: 24,eval_chunk_prompts: 8andsave_interval: 2— three eval chunks per pass, an eval after every rollout, and one windowthat evaluates without being due to save. The later chunks resync, which is what reaches the
adapter read on a parked trainer; a single-chunk eval never gets there.
Recipe added in this PR:
examples/diffusion/bagel/bagel_t2i_residency_gate.yaml.Planner.
ResidencyPlannerdriven directly through the loop's phase sequence with recordingcallables in place of the engines, asserting the exact transition list for each policy. This is
what makes the claim engine-independent: the planner reaches an engine solely through the
(onload, offload)pair handed to it, so a policy's sequence is identical whether the rollout isSGLang, vLLM-Omni, FastVideo or trainside. Per
CLAUDE.md, this repository does not commit atests/tree or one-off harnesses under another name; the planner harness was run externally andits exact observed sequences are quoted below.
The follow-up review harness also checks: async direct construction defaults rollout residency to
trueand rejects explicitfalse; a two-chunk no-sync eval emits norollout offload; adaptercache validity is owned and invalidated by the LoRA weight-sync object; and inherited distributed
cache methods bind on both local and remote LoRA sync handles.
ruff checkandruff format --checkpass on every changed file.Test Result
Defaults arm — nothing moves but the rollout.
RES_GATE_RC=0, and over two rollouts theloop emitted exactly:
No train and no reward transition at all, which is what the old code did with
enable_fsdp_offload: false+offload_train_during_reward: false: an unconditionalwake_up(), a conditionalsleep(), and a_reward_phase()that was a no-op. Every recipe nottouched by this PR is on this path.
All-parked arm.
RES_GATE_RC=0. Both rollouts emitted the same six transitions in the sameorder, and nothing else:
The rollout engine returned ~28 GiB per worker on each park
(
Sleep Level 1: physically freed 28.60 GiB). Reward onload/offload appear exactly once perrollout — the transition that was previously unreachable through the
Handle. The traineroffloads once before generation and returns once before the optimizer step; the redundant
reward-phase pair the old per-phase flags produced is gone.
Reward moved 0.7880 → 0.6821 across the two rollouts; at two points that is noise, and this gate
checks orchestration rather than a learning curve.
Eval + checkpoint arm.
RES_GATE_RC=0, and the transition sequence ends the way the fixrequires — the trainer comes back after the final eval parked it:
The checkpoint is on disk (1.9 GB
checkpoint.pt+trainer_state.json) with no all-gathererror after it. Note the first eval runs before any training, so it parks a trainer that then
returns for the first optimizer step — one round trip that the old code also paid.
Multi-chunk eval arm.
RES_GATE_RC=0, 51 transitions overEVAL step 0,rollout 1/2,EVAL step 1,rollout 2/2,EVAL step 2, then the save. Three properties are what this armis for:
train onloads are immediately preceded byreward offloadtrain onloads total, for 2 optimizer steps + 1 save, across 3 evals / 2 rollouts / 3 chunks per evalcheckpoint.pt+trainer_state.json, no all-gather errorBefore the fixes this arm would have shown a
train onloadwith the reward still resident (thepeak violation), one
train onloadper rollout inside the window, and an extra onload for thewindow that evaluates without saving.
Long arm with accumulation.
RES_GATE_RC=0over 10 rollouts ataccumulate_rollouts: 2(so five windows, one optimizer step each),
eval_interval: 4,save_interval: 4. Threecheckpoints written (
checkpoint-4,-8,-10, 5.5 GB total), no errors. The transitioncounts are the point:
rollout onload/rollout offloadreward onload/reward offloadtrain onload/train offloadSixteen rollout phases against seven trainer onloads — five optimizer steps plus two
checkpoints (the third coincides with a step). One round trip per window, not per rollout,
which is the property the adapter cache buys and which a two-rollout run cannot show.
Planner and follow-up CPU checks. All pass, including the ones that encode the regression risk.
The follow-up output ended with
planner_check_okandfollowup_residency_regressions_ok:rollout onload,rollout offloadrollout_resident: truerollout onloadonly — woken once, never slepttrain_resident: falsetrain offload,rollout onload,rollout offload,train onloadtrain_resident: false, untracked train (trainside / separate)reward_resident: false, reward on its own slabThe all-parked row is computed independently of the hardware run and matches it transition for
transition. With
accumulate_rollouts: 2and a parked trainer, the planner issues onetrain onloadfor the window rather than one per rollout. All three retired keys raise atstartup for either value with the replacing key named, and a config without them is accepted
unchanged.
Five of the checks exist because an earlier revision of this PR got them wrong, and each fails
against the revision it was written for:
enter(TRAIN)returned early when the trainer was untracked, leaving a non-resident reward on the GPU for the whole optimizer step — on alayout: separateslab that is exactly when it should be gonemaybe_save_checkpointset(TRAIN, True), which moves only the trainer, so the peak held the trainer next to a reward the previous rollout had left resident — the 75GB-trainer-plus-27GB-judge shape thatqwen_image_edit_plus_nft_managed_editscore.yamldocuments as an H20 OOMeval_intervaldenser thansave_intervalpaid a round trip on every evalAsyncDiffusionTrainerhas to rejectrollout_resident: false: the hook is on the base class, and the async loop has no wake between the checkpoint and the next submissionNot run on hardware: SGLang. It is the most-used external engine for diffusion in
examples/(21 recipe references vs 15 for vLLM-Omni) and it is affected by this change, but no SGLang
build was available on the test host. Its coverage rests on the planner checks above plus the
interface being the one the planner uses —
SGLangDiffusionRolloutEngine.sleep/wake_uparethe same
BaseRolloutEnginecontract vLLM-Omni implements, and no SGLang recipe sets any of theretired keys, so all of them run the defaults arm verified above. Worth a maintainer sanity run
if you have a host with SGLang.
Not run:
reward_resident: falseandrollout_resident: falseunderAsyncDiffusionTrainer—both rejected at startup by design, the first because async scoring happens at reap time
outside
_reward_phase(), the second because the engine owns a dedicated slab and the loopsubmits across the checkpoint boundary.
Reviewer Notes
Follow-up review removed the duplicate trainer-side adapter-cache flag, the unused
ResidencyPlanner.on_gpu()method, repeated literal residency defaults and redundant internal boolcoercions. Review fixes were AI-assisted and then checked against the full diff and focused harnesses.