PPO¶
Shared-backbone actor/value training uses the separate joint_ppo entry. See
public Joint PPO instructions. The text PPO entry below retains its
independent policy/reference/critic roles and shares only common PPO primitives and infrastructure.
The PPO entrypoint adds only the roles and algorithm logic that differ from GRPO:
reference: frozen token-probability scoring;critic: value prediction and value-function optimization;policy: PPO policy optimization through the sharedLLMTrainerobjective seam.
Everything else uses the existing posttrain components: DatasetEnv, RewardWorker,
ExperienceBuffer, WorkerGroup, ActorGroup, DeviceGroup, bind_device_groups,
the shared dispatch/data plane, multi-role checkpoint coordinator, and weight_sync.
Flow¶
Each iteration is a plain imperative role sequence:
data → rollout → reward → replay → reference.score → critic.predict → policy.prepare_batch
policy.prepare_batch → critic.install_targets
policy.train_batch + critic.train_batch
policy → rollout weights
The roles pass one Trajectory handle along the chain. Reward, reference, and critic
append PPO-owned fields to the trajectory; the controller does not inspect or move its
bulk data. Model-DP ingress is declared in the common dispatch registry and selected from
each consuming role's live ParallelDims. Because each trainable role therefore sees only its
own shard, the batch identity it publishes names the COMPLETE batch, not that shard: the
controller compares whole receipts across ranks and then hands one rank's batch_id back to
every rank as the key into its own shard cache.
The policy role computes GAE and immutable PPO targets, then the policy and critic each
run the configured epoch/minibatch schedule. Weight synchronization uses the same
weight_sync(rollout, policy) call as GRPO and OPD.
The policy and critic share one PPO update runtime over the existing LLMTrainer; optimizer
execution remains in the common engine. PPO specializes only vLLM request/output handling and
reuses the common rank-converged generation lifecycle.
Critic warmup¶
rl.critic_warmup_iterations (default 0, a non-negative integer counted in OUTER PPO iterations)
runs a leading critic-only phase. A freshly initialized value function is not yet calibrated to the
task's reward distribution, so its estimates make noisy GAE advantages; updating the actor on them
reinforces unhelpful actions and shows up as early reward fluctuation and larger policy-KL
excursions. During iterations 1..N the loop still collects rollouts, scores rewards and the frozen
reference, prepares targets and trains the critic; the actor's parameters, optimizer state and LR
scheduler are left exactly as they were. The first actor update is iteration N+1. 0 reproduces
the previous behaviour exactly, including the update sequence and the metric step.
Two clocks come apart while warmup is active, and only one of them is the actor's:
- the OUTER ITERATION is the loop's durable step — the checkpoint boundary name, the metric step, and the seed offset in the shared update plan that keeps the critic's minibatch shuffle moving;
policy_versioncounts PUBLISHED actor updates and is the rollout's staleness contract, so it stands still for the whole warmup phase.
weight_sync is therefore skipped during warmup: the actor is byte-unchanged, the rollout already
advertises exactly policy_version, and collection's staleness guard is an equality check that
already holds, so a re-push would add a full model transfer — and a transport failure surface — to
an iteration with no weight delta. Both roles still receive the SAME update plan; warmup never
varies the epoch/minibatch shape, which each role pinned into its checkpoint identity.
Metrics carry the phase: train/actor_warmup is 1.0 on a critic-only iteration, and that
iteration publishes no policy receipt, so the train/policy_* and optim/policy_optimizer_step
keys are absent rather than repeated (see ../reference/metrics.md).
Checkpointing a warmup iteration does not touch the actor either, which takes an explicit
mechanism rather than following from the actor being frozen. Under checkpoint_format="dcp" — the
production default — optimizer state is extracted with get_optimizer_state_dict, and that helper
MATERIALIZES a never-stepped optimizer by running a zero-gradient step() at zero learning rate.
No parameter moves and optimizer_step stays 0, so nothing visible records it; but AdamW's own
step counter advances, and it is what bias-corrects the first REAL actor update. Left alone, that
would make rl.save_every — an operational knob — change the weights a run produces. The trainer
therefore rolls the initialization back, writes an empty optimizer state, and marks the checkpoint
optimizer_pristine; resume reads the marker before touching the optimizer, because extracting
the load TARGET has exactly the same side effect.
Two details matter for rl.optimizer.name. The rollback covers group-owned state, not just
optimizer.state, because fusedadam keeps its counter on the param group — see
../reference/checkpoint.md. And a pristine resume still restores
the saved param_groups: the learning rate lives there, and the scheduler's own state does not
write it back into the optimizer.
Resume preserves warmup progress, including a run that ends inside it: the committed boundary is
named by the outer iteration, and the actor's own publication clock restores from its trainer
state. The boundary also records the configured rl.critic_warmup_iterations, and resume compares
that recorded value DIRECTLY; a run configured with a different warmup fails closed instead of
silently renumbering the actor's published versions. The value has to travel with the boundary
because it cannot be recovered from the actor's clock: an actor that never published reports
version 0, so max(0, iteration - N) reads the same under every N at least as long as the
boundary's iteration count, and a lengthened warmup would otherwise be accepted and slide the first
actor update later with nothing reported. That derived relation is still checked, as an independent
cross-check on the restored actor's own counter.
A boundary committed before the marker carried the warmup recorded none. Absence is treated as unknown rather than as a mismatch, so such a boundary keeps exactly the guard it shipped with — the derived cross-check alone, which cannot discriminate a lengthened warmup — and the resume logs that the configured value could not be verified against it.
Launch¶
Set up the container and Posttrain environment from sections 1 and 2 of the run guide. The public profile uses Qwen3-0.6B from Hugging Face: two colocated policy/rollout GPUs, one frozen-reference GPU, and one critic GPU. Validate the typed config without allocating workers, then run it:
"$POSTTRAIN_PY" -m cosmos_framework.posttrain.entrypoints.ppo \
--config cosmos_framework/posttrain/configs/ppo_qwen06b_public_4gpu.py \
--validate-manifest
"$POSTTRAIN_PY" -m cosmos_framework.posttrain.entrypoints.ppo \
--config cosmos_framework/posttrain/configs/ppo_qwen06b_public_4gpu.py -- \
job.name=ppo_public_4gpu
The profile runs 1000 steps; append rl.max_steps=5 after -- for a short check (see the
run guide Q&A).
The same role flow supports text, image, and video prompts; the selected
DataPacker owns model-specific tokenization and multimodal tensor preparation. Set
data_packer.factory to use the same custom packer-factory seam as GRPO/OPD; rl.data
also accepts a Hugging Face name, a local file of rows, or an importable dataset factory.
Configured rows use the generic project_prompt_items adapter over the existing
DatasetEnv/cursor path; PPO defines no separate prompt-dataset implementation. The prompt
column holds either a non-empty string (one user turn) or a conversation list — a non-empty
list of {"role", "content"} objects, each with a non-empty role and a non-empty string or
list content, the image or video inside the content parts — the same two shapes the GRPO
loader accepts, passed through to the DataPacker untouched. A local rl.data.path is read as
written by the data roles' own reader (JsonlRows, shared with the GRPO loader) in the shapes the
Hugging Face reader accepted -- one JSON object per line or any whitespace-separated stream of
objects (one object pretty-printed over several lines is one row; several, one row each; consecutive
objects need at least one whitespace character between them, so }{ is refused with its line and
column), one top-level JSON array (a file whose first non-blank line starts with [), any of them
gzip-compressed (gzip only, detected by the magic bytes; not bz2 / xz / zst) -- not through
Hugging Face datasets; a bad row is refused with its position, in file order (a byte outside UTF-8
further down never hides an earlier bad row):
Arrow would infer one struct type over every content block and null-fill the keys a block lacks
(which the Qwen chat template renders as
spurious image placeholders), null-fill a nested reference column the same way, and refuse a
conversation column whose turns mix string and list content. The projection refuses content parts
that carry the null-fill signature -- a null under a payload key (image: null on either a text
part or an image part with another handle) or a null type -- whatever reader produced them, when
the data role is built (row 0 is projected at construction); a null under a part's own optional key
(fps: null on a video part) is left to the DataPacker's contract.
Reward¶
rl.reward.terminal selects the episode-level verifier. It takes either of PPO's own matchers —
arithmetic_exact_match (default) or none — or the name of any shared rule reward, the same
registry GRPO selects from with rl.reward: arithmetic, gsm8k, countdown, exact_match,
nonempty, constant.
Prefer a shared rule unless you need PPO's exact-format matcher. arithmetic_exact_match requires
the completion to be nothing but an integer, so a correct answer written as prose scores zero;
the shared arithmetic rule reads the first integer anywhere in the completion.
Two consequences of naming a shared rule:
- Its declared
needscarry through to the run. A rule needingreference(every one above exceptnonemptyandconstant) makes the framework populateRewardContext.reference, and makes the manifest require a ground-truth source — so--validate-manifestrejects a run whose data cannot supply one instead of failing on the cluster. - The reward stays on PPO's scale (
correct_reward/incorrect_reward, default1.0/0.0) and is emitted under PPO's own term, so the objective and metrics are unchanged.
terminal_callable remains the escape hatch for an importable package.module:callable. It
overrides PPO's own matcher and is rejected in combination with a shared rule name, since the two
would otherwise compete silently. rl.reward.process (linear | none, default none) is the
separate per-token shaping term.
Current runtime scope¶
The current synchronous PPO entry requires grid.backend=ray and grid.grouping=per_role.
Rollout uses vLLM and produces one completion per prompt. Policy, frozen reference, and critic
may use supported HF or native model construction; training data may be text, image, or video.
Multi-role checkpoint publication currently requires the local checkpoint store.
Placement¶
PPO lowers its role declarations through the shared placement binder.
gpu_placement=colocatedplaces policy and rollout ActorGroups in one GPU DeviceGroup; reference and critic each use their own DeviceGroup.gpu_placement=disaggregatedgives policy, rollout, reference, and critic independent DeviceGroups; setrl.weight_sync_transport=nixlto use the shared NIXL weight transport.
Role shape and model parallelism remain separate: WorkerGroup placement owns
(replicas, ranks), while each model role owns its (dp_replicate, dp_shard)
factorization.
Checkpoints¶
Policy and critic save through their existing CheckpointManager instances, writing straight
into the boundary directory alongside the DatasetEnv cursor. The shared multi-role coordinator
then publishes one top-level _SUCCESS marker — the commit point, written only after every role
is verified resumable — followed by the latest_checkpoint.txt pointer. An uncommitted boundary
is invisible to resume, and a partial role save is never resumable. Multi-role publication currently supports the local checkpoint
store; config validation rejects the direct object-store backend before worker allocation.
The marker also records the optimizer counter each role committed, and restore checks every role against its own recorded value. Roles in one boundary are not required to share a counter — during critic warmup the actor's stands still while the critic's advances — so cross-role agreement is no longer the invariant; a boundary written before this field is still validated by the old agreement rule.
Alongside those counters the marker carries loop_state: one OPAQUE run-level slot the entrypoint
owns and the shared coordinator never interprets, read back verbatim through
committed_loop_state(). PPO records {"critic_warmup_iterations": N} there. The coordinator keeps
no schema for it on purpose — it is shared with the single-role loops, and a warmup field on it
would bind an algorithm-neutral boundary to one algorithm. Values must be JSON scalars so the
read-back comparison is exact; the marker version is unchanged, because the field is additive and
optional exactly as role_steps was.