[Feature] Add Learner primitive (LocalLearner, FSDP2Learner) - #3926
[Feature] Add Learner primitive (LocalLearner, FSDP2Learner)#3926theap06 wants to merge 5 commits into
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/rl/3926
Note: Links to docs will display an error until the docs builds have been completed. This comment was automatically generated by Dr. CI and updates every 15 minutes. |
c75e16b to
09e5cf6
Compare
Introduces torchrl.trainers.Learner: a backend-agnostic entry point for taking one optimization step on a tensordict batch with a given LossModule. LocalLearner is the single-process reference implementation. Mirrors the role Collector plays for data collection and LLMWrapperBase plays for generation/scoring, so training placement (local / sharded / remote) becomes a swappable backend behind a fixed contract instead of a hand-rolled loop per recipe. get_weights() returns a TensorDictBase, matching what WeightSyncScheme.send() already accepts, so this composes with the existing weight-sync path unchanged.
Refactors Learner.update() to live in the base class as concrete, generic step logic (zero_grad -> forward -> sum loss_* keys -> backward -> clip -> step), operating only on self.model/self.optimizer/self.clip_grad_norm/ self.grad_accum_steps. This is what lets FSDP2Learner reuse the exact same training step as LocalLearner: sharded training only changes how the model is constructed (wrapped with fully_shard by the caller) and how get_weights() gathers the result. FSDP2Learner.get_weights() gathers every DTensor leaf via full_tensor() into a plain tensor, so its output is consumable by WeightSyncScheme exactly like LocalLearner's, with no changes on the receiving side. Verified on a single-rank (world_size=1) gloo process group, which exercises the real fully_shard()/DTensor code path without a cluster: forward/backward/clip_grad_norm_/optimizer.step() dispatch correctly through DTensor, TensorDict.from_module()/.apply() handle DTensor leaves transparently, and FSDP2Learner produces bit-exact losses and gathered weights vs LocalLearner given the same seed/data/lr.
…d-sync
Fixes three real gaps in FSDP2Learner identified after the initial PR:
1. get_weights() previously gathered every DTensor leaf to EVERY rank via
full_tensor(), replicating the whole model in every rank's memory for
no benefit -- does not scale to large sharded models. Now uses
torch.distributed.checkpoint.state_dict.get_model_state_dict with
StateDictOptions(full_state_dict=True, cpu_offload=True), which
gathers to rank 0 only (other ranks get an empty tensordict) by
documented DCP semantics. Verified: correct nested key shape via
unflatten_keys('.'), matches the prior full_tensor()-based output.
2. No sharded-checkpoint path existed. Learner (base) gains real
state_dict()/load_state_dict() covering model + optimizer state
(a bare Optimizer is not an nn.Module, so plain nn.Module.state_dict()
silently drops it -- a real, previously-latent bug for LocalLearner
too). Both overrides clone their tensors: nn.Module.state_dict() and
Optimizer.state_dict() return views onto live tensors, not copies, so
without cloning, further training after checkpointing would silently
corrupt the saved checkpoint (caught by round-trip tests). FSDP2Learner
overrides these two methods again with get_state_dict/set_state_dict
(DCP-aware, handles DTensor optimizer state, rank0-only via
cpu_offload). Verified end to end: model weights AND Adam/SGD-momentum
optimizer state round-trip correctly through save/perturb/load.
3. grad_accum_steps>1 was untested on FSDP2Learner, and update() did no
FSDP2-specific optimization during accumulation. Learner.update() now
toggles model.set_requires_gradient_sync(...) when the model exposes
it (FSDP2-wrapped models do; LocalLearner's plain model doesn't, so
this is a no-op there), deferring the cross-rank gradient reduction
until the last micro-batch of an accumulation window instead of
reducing on every micro-batch. Verified against a non-sharded
reference: the accumulated, synced gradient after 2 microbatches
matches a plain model accumulating the same 2 microbatches exactly.
Still unverified (unchanged from the original PR): all of the above is
tested at world_size=1 (single-rank gloo), which exercises the real
fully_shard()/DTensor/DCP code paths but not actual cross-rank
communication or memory behavior.
…eckpoint contract Addresses review feedback on the Learner primitive. - The optimizer, not `model`, defines what is trained. Grad-norm clipping now covers `optimizer.param_groups` rather than `self.model.parameters()`, and `update()` validates before its first optimizer step that no parameter received a gradient the optimizer does not cover. The silent failure this prevents: `LocalLearner(actor, Adam(actor.parameters()))` with a loss module that owns or expands its critics leaves those critics differentiated on every step and never updated. Documented on the class, in `LocalLearner`'s `model` argument, and in the docs page. - Checkpointing moves from `state_dict`/`load_state_dict` to `checkpoint`/`load_checkpoint`. Overriding the `nn.Module` methods broke their contract: `destination`/`prefix`/`keep_vars` were ignored and the return value discarded, so nesting a `Learner` inside any parent module silently dropped all of its state. `state_dict` is now plain `nn.Module` behavior again. - `load_checkpoint` resets the accumulation counter to 0 with a warning instead of resuming mid-window: gradients are not checkpointed, so resuming at a non-zero step would step the optimizer after fewer micro-batches than `grad_accum_steps` and under-scale that update. - `update()` raises a clear error on a non-scalar summed loss (what a loss built with `reduction="none"` returns) rather than letting `.backward()` fail with a torch-internal message. - `LearnerCapabilities` is frozen, so the shared class-level default on `Learner.capabilities` cannot be mutated into every other instance. - `torch.distributed.checkpoint.state_dict` is imported lazily through a cached accessor rather than at module top, keeping `import torchrl` free of `torch.distributed.checkpoint`. - Corrects the `FSDP2Learner.get_weights` docs: it uses `get_model_state_dict` with `StateDictOptions`, not per-leaf `full_tensor()`, and the default `cpu_offload=True` returns weights on rank 0 only -- which an unconditional `scheme.send(learner.get_weights())` on every rank would silently no-op. - Records why the `"loss"` key prefix cannot be tightened to `"loss_"`: `"loss"` with no underscore is a real out_key of DQNLoss, GAILLoss and OnlineDTLoss. Tests cover each of the above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the suite locally shows the FSDP2 checkpoint round-trip fails: after load_checkpoint the weights are not restored. get_state_dict(full_state_dict=True, cpu_offload=True) does not guarantee fresh tensors -- cpu_offload only copies when the shards are off-CPU, so on a CPU (or single-rank) mesh the "gathered" state aliases the live shards. Training on after checkpointing then mutates the checkpoint, which is exactly what Learner.checkpoint guards against with _clone_tensors. The previous docstring claimed the opposite; it now says why the clone is required. test/test_trainer.py: 100 passed, 19 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
09e5cf6 to
3614a44
Compare
|
Reviewed the rebased head 3614a44, with particular attention to the latest optimizer-coverage/checkpoint contract changes and the final FSDP2 checkpoint-cloning fix. The base and FSDP2 checkpoint paths now clone tensor state before returning it, accumulation restores safely reset partial windows, and the optimizer-coverage check runs before the first step. I ran the Learner/FSDP2-focused test selection locally: 27 passed, including the FSDP2 checkpoint round trip, accumulation/reference comparison, and plain-tensor weight gather. I did not find a blocking issue in the current code. The meaningful residual risk is unchanged from the PR description: these checks use world_size=1 gloo/CPU, so actual multi-rank collective behavior and rank-0-only checkpoint/weight gathering still need real distributed coverage. |
Summary
Introduces
torchrl.trainers.Learner: a backend-agnostic entry point for takingone optimization step on a tensordict batch with a given
LossModule.LocalLearneris the single-process reference implementation;FSDP2Learnershards the same model with
fully_shardand reuses the training step unchanged.It plays the same role for training that
Collectorplays for data collectionand
LLMWrapperBaseplays for generation/scoring: a fixed, TensorDict-nativecontract with interchangeable backends, so algorithm code does not need to know
whether the update runs on one device, under sharded training, or on a remote
training process.
Design
Learner.update()is concrete, in the base class, and touches onlyself.model/self.optimizer/self.clip_grad_norm/self.grad_accum_steps: zero_grad -> forward -> sum the loss module's"loss"-prefixed output keys -> backward -> optional grad-norm clip ->optimizer step. This is what lets
FSDP2Learnerreuse the exact same step asLocalLearner-- sharded training only changes model construction and weightgathering, not the step itself.
get_weights()is the one place sharding is not transparent: it must returnplain tensors (for
WeightSyncScheme.send, which already accepts aTensorDictBase), even when the learner's parameters are sharded.FSDP2Learnerdoes not decide sharding granularity or device mesh -- itaccepts a model the caller has already wrapped with
fully_shard, exactly asbare FSDP2 usage works. Keeping that decision in caller code avoids
FSDP2Learnerbecoming a second place those choices are made.Two contracts worth reading before using this
The optimizer defines what is trained, not
model.modelis theweight-sync source and the gradient-sync handle; the parameters that get clipped
and stepped are the ones in
optimizer.param_groups. Many TorchRL losses holdtheir trainable parameters on the loss module as
TensorDictParams, and thelosses that expand their networks (
SACLoss,REDQLoss, ...) hold copies ofthe modules you passed in:
The second line is silent -- the critics are differentiated on every step and
never updated -- so
update()now checks before its first optimizer step andraises if any parameter received a gradient that no param group covers. Grad-norm
clipping likewise covers
optimizer.param_groups, notmodel.parameters().A
Learnerowns exactly one optimizer, so algorithms that deliberately useseveral (per-network learning rates, a separate entropy-temperature optimizer)
are not expressible as a single
Learnertoday; use one per optimizer.Checkpointing is
checkpoint()/load_checkpoint(), notstate_dict(). Abare
Optimizeris not annn.Module, sonn.Module.state_dict()structurallycannot carry its state and a resume would reset Adam's moments. Overriding
state_dictto return it anyway would break thenn.Modulecontract -- a parentmodule calls
child.state_dict(destination=...)and discards the return value,so nesting a
Learnerinside any other module would silently drop all of itsstate. Separate names keep both contracts intact.
Gradients are not checkpointed, so a checkpoint taken mid-accumulation-window
resets the accumulation counter to 0 with a warning rather than resuming at a
non-zero step with empty gradients (which would step after fewer micro-batches
than
grad_accum_stepsand under-scale that update).Notes
update()raises on a non-scalar summed loss, which is what a loss built withreduction="none"produces -- previously this surfaced as a torch-internal"grad can be implicitly created only for scalar outputs" error.
"loss"prefix cannot be tightened to"loss_":"loss"with nounderscore is a real out_key (
DQNLoss,GAILLoss,OnlineDTLoss).FSDP2Learner.get_weights()'s defaultcpu_offload=True, the fullweights are returned on rank 0 only and every other rank gets an empty
tensordict. That is deliberate (gathering the full model onto every rank does
not scale), but it means an unconditional
scheme.send(learner.get_weights())sends nothing from non-zero ranks. Passcpu_offload=Falseto gather everywhere.calls and step calls (
grad_normonly appears on the latter).*Configcompanion yet. These classes have noTrainerwiring in this PR,so a Hydra config would have nothing to instantiate against; the configs land
with the trainer integration (follow-up 2 below).
checkpoint()rename carries nodeprecation shim.
Planned follow-ups (not in this PR)
FSDP2Learnerverification on real multi-GPU hardware -- the onething I could not test here. The single-rank gloo/CPU tests exercise the
fully_shard/DTensor code paths but not actual cross-rank communication.reward-model recipe, once [Feature] Add RewardModelLoss objective for RLHF reward-model training #3922 lands) onto
LocalLearner, as the first realconsumer, with the
*Configcompanions.RemoteLearnerdesign writeup scoping one external backend (TorchTitan isthe most PyTorch-native of the candidates and the most plausible first
integration target) before any implementation.
Tested
test/test_trainer.py: 100 passed, 19 skipped (26 of those are theLearner/FSDP2Learnertests). Single-rank gloo/CPU only; multi-GPU isfollow-up 1.
c75e16bfixes a checkpoint bug that only surfaced once the suite actually ran:get_state_dict(full_state_dict=True, cpu_offload=True)does not guaranteefresh tensors —
cpu_offloadonly copies when the shards are off-CPU, so on aCPU or single-rank mesh the "gathered" state aliases the live shards and training
on after a checkpoint silently mutates it.
FSDP2Learner.checkpointnow clones,like the base implementation.