Skip to content

[Feature] Add Learner primitive (LocalLearner, FSDP2Learner) - #3926

Draft
theap06 wants to merge 5 commits into
pytorch:mainfrom
theap06:learner-primitive-clean
Draft

[Feature] Add Learner primitive (LocalLearner, FSDP2Learner)#3926
theap06 wants to merge 5 commits into
pytorch:mainfrom
theap06:learner-primitive-clean

Conversation

@theap06

@theap06 theap06 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

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; FSDP2Learner
shards the same model with fully_shard and reuses the training step unchanged.

It plays the same role for training that Collector plays for data collection
and LLMWrapperBase plays for generation/scoring: a fixed, TensorDict-native
contract 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 only
    self.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 FSDP2Learner reuse the exact same step as
    LocalLearner -- sharded training only changes model construction and weight
    gathering, not the step itself.
  • get_weights() is the one place sharding is not transparent: it must return
    plain tensors (for WeightSyncScheme.send, which already accepts a
    TensorDictBase), even when the learner's parameters are sharded.
  • FSDP2Learner does not decide sharding granularity or device mesh -- it
    accepts a model the caller has already wrapped with fully_shard, exactly as
    bare FSDP2 usage works. Keeping that decision in caller code avoids
    FSDP2Learner becoming a second place those choices are made.

Two contracts worth reading before using this

The optimizer defines what is trained, not model. model is the
weight-sync source and the gradient-sync handle; the parameters that get clipped
and stepped are the ones in optimizer.param_groups. Many TorchRL losses hold
their trainable parameters on the loss module as TensorDictParams, and the
losses that expand their networks (SACLoss, REDQLoss, ...) hold copies of
the modules you passed in:

loss_module = SACLoss(actor, qvalue)
learner = LocalLearner(actor, Adam(loss_module.parameters()))  # correct
learner = LocalLearner(actor, Adam(actor.parameters()))        # critics never train

The second line is silent -- the critics are differentiated on every step and
never updated -- so update() now checks before its first optimizer step and
raises if any parameter received a gradient that no param group covers. Grad-norm
clipping likewise covers optimizer.param_groups, not model.parameters().

A Learner owns exactly one optimizer, so algorithms that deliberately use
several (per-network learning rates, a separate entropy-temperature optimizer)
are not expressible as a single Learner today; use one per optimizer.

Checkpointing is checkpoint() / load_checkpoint(), not state_dict(). A
bare Optimizer is not an nn.Module, so nn.Module.state_dict() structurally
cannot carry its state and a resume would reset Adam's moments. Overriding
state_dict to return it anyway would break the nn.Module contract -- a parent
module calls child.state_dict(destination=...) and discards the return value,
so nesting a Learner inside any other module would silently drop all of its
state. 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_steps and under-scale that update).

Notes

  • update() raises on a non-scalar summed loss, which is what a loss built with
    reduction="none" produces -- previously this surfaced as a torch-internal
    "grad can be implicitly created only for scalar outputs" error.
  • The "loss" prefix cannot be tightened to "loss_": "loss" with no
    underscore is a real out_key (DQNLoss, GAILLoss, OnlineDTLoss).
  • With FSDP2Learner.get_weights()'s default cpu_offload=True, the full
    weights 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. Pass
    cpu_offload=False to gather everywhere.
  • With gradient accumulation the output key set differs between accumulation
    calls and step calls (grad_norm only appears on the latter).
  • No *Config companion yet. These classes have no Trainer wiring in this PR,
    so a Hydra config would have nothing to instantiate against; the configs land
    with the trainer integration (follow-up 2 below).
  • These are new, unreleased classes, so the checkpoint() rename carries no
    deprecation shim.

Planned follow-ups (not in this PR)

  1. Multi-rank FSDP2Learner verification on real multi-GPU hardware -- the one
    thing I could not test here. The single-rank gloo/CPU tests exercise the
    fully_shard/DTensor code paths but not actual cross-rank communication.
  2. Refactor an existing recipe's hand-rolled training loop (e.g. the
    reward-model recipe, once [Feature] Add RewardModelLoss objective for RLHF reward-model training #3922 lands) onto LocalLearner, as the first real
    consumer, with the *Config companions.
  3. A RemoteLearner design writeup scoping one external backend (TorchTitan is
    the 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 the
Learner/FSDP2Learner tests). Single-rank gloo/CPU only; multi-GPU is
follow-up 1.

c75e16b fixes a checkpoint bug that only surfaced once the suite actually ran:
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 and training
on after a checkpoint silently mutates it. FSDP2Learner.checkpoint now clones,
like the base implementation.

@pytorch-bot

pytorch-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown

🔗 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.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jul 2, 2026
@github-actions github-actions Bot added Feature New feature Documentation Improvements or additions to documentation Trainers labels Jul 2, 2026
@theap06
theap06 marked this pull request as draft August 2, 2026 07:24
@vmoens
vmoens force-pushed the learner-primitive-clean branch from c75e16b to 09e5cf6 Compare August 5, 2026 15:15
theap06 and others added 5 commits August 10, 2026 08:55
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>
@vmoens
vmoens force-pushed the learner-primitive-clean branch from 09e5cf6 to 3614a44 Compare August 10, 2026 07:55
@vmoens

vmoens commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Documentation Improvements or additions to documentation Feature New feature Trainers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants