Skip to content

Add adapter-only checkpoint save/load (--save-trainable-params-only) - #7280

Open
roma5087 wants to merge 3 commits into
NVIDIA:mainfrom
roma5087:peft-adapter-only-checkpointing
Open

roma5087 wants to merge 3 commits into
NVIDIA:mainfrom
roma5087:peft-adapter-only-checkpointing

Conversation

@roma5087

Copy link
Copy Markdown

Fixes #7270

What this does

Adds --save-trainable-params-only, which filters a torch_dist checkpoint's
model section(s) down to parameters with requires_grad=True at save time, and
filters the load request the same way at load time. For PEFT/LoRA-style
fine-tuning (frozen base model, a small number of trainable adapter parameters),
this keeps the saved checkpoint proportional to the adapter instead of
duplicating the frozen base weights on every save, and lets that checkpoint be
loaded back onto a matching base model (--dist-ckpt-strictness log_unexpected
required at load time; see the new config field's docstring).

Verification

Beyond the unit tests below, this was verified end-to-end on a real GPTModel
(2x A100) across --tensor-model-parallel-size in {1,2},
--pipeline-model-parallel-size in {1,2}, and --swiglu/gated_linear_unit
in {True,False}: adapter-only checkpoints are smaller than full checkpoints,
frozen base weights survive a non-strict load untouched, adapter weights and
optimizer momentum state (exp_avg/exp_avg_sq) are correctly restored on
resume, and iteration count is correctly restored.

It was also verified against Megatron-Bridge's real, unmodified LoRA/PEFT
implementation (imported directly, not reimplemented): Bridge's LoRA correctly
applies to a Megatron-Core GPTModel, and a checkpoint saved by this PR's
save_checkpoint(save_trainable_params_only=True) loads correctly via Bridge's
real apply_peft_adapter_filter_to_state_dict substituted into
load_checkpoint() — for Bridge's actual production configuration
(DistributedOptimizer, real training before checkpointing; Bridge's own
test_finetune_lora.py GPU CI test already exercises this combination and
passes).

Tests added

  • tests/unit_tests/test_checkpointing.py::TestFilterStateDictToTrainableParams
    — pure-function unit tests for the save-side filter (frozen/trainable leaves,
    ShardedTensorFactory, _extra_state, multi-PP-stage sections).
  • tests/unit_tests/dist_checkpointing/test_peft_checkpointing.py — real
    GPTModel save → load → resume round trip, parametrized over TP/PP/SwiGLU (6
    cases), including optimizer momentum-state restoration.

Known limitations (not fixed in this PR)

  • --use-distributed-optimizer does not support any frozen parameters at
    all today (DistributedOptimizer._build_optimizer_group_ranges raises a
    KeyError if a frozen param's grad-buffer bucket wasn't built the same way
    the optimizer's param groups were) — a pre-existing Megatron-Core limitation,
    unrelated to this change, that a real PEFT run must work around either by
    freezing parameters before the model is DDP-wrapped (so the grad buffer and
    optimizer param groups agree from construction) or by using the standard
    wrap_with_ddp=True training entrypoint path.
  • A checkpoint saved before the optimizer has ever taken a single step, for
    a model with a ShardedTensorFactory-wrapped trainable parameter (e.g. a
    LoRA adapter on a SwiGLU-gated linear_fc1), can fail to load
    with
    apply_factory_merges: Different dict keys encountered. save_checkpoint
    never calls init_state_fn (so a never-stepped torch.optim.Adam's
    state is empty and nothing is written for the optimizer section), while
    load_checkpoint always does (so a fresh optimizer's load request expects
    zero-seeded state for every trainable param) — for an ordinary ShardedTensor
    this asymmetry is tolerated by --dist-ckpt-strictness log_unexpected, but
    apply_factory_merges's key-set equality check has no such escape hatch.
    This is a pre-existing dist_checkpointing/optimizer gap independent of PEFT
    or this flag specifically (it would affect any GLU-gated model's iteration-0
    checkpoint under the plain optimizer path), not something this PR introduces,
    and does not affect a normal training run (which always takes at least one
    step before its first checkpoint save).

Add --save-trainable-params-only, which filters the model section(s) of
a torch_dist checkpoint down to parameters with requires_grad=True before
save. This lets a PEFT/LoRA-style fine-tuning run (frozen base model, a
small number of trainable adapter parameters) write a checkpoint sized to
the adapter instead of duplicating the frozen base weights on every save.

The optimizer section is left untouched, since it already only holds
state for whichever parameters were passed to the optimizer.

Loading such a checkpoint onto a fully-initialized base model requires
--dist-ckpt-strictness log_unexpected (or another *_unexpected value):
the on-disk checkpoint is missing the frozen keys the model will
request, and this repurposes Megatron-Core's existing StrictHandling
support for that rather than adding new load-side code.

The new flag is restricted to --ckpt-format torch_dist, since only that
format's ShardedTensor/ShardedTensorFactory leaves preserve each
parameter's requires_grad through checkpointing; the other formats call
state_dict(keep_vars=False), which detaches every tensor and would make
every parameter look frozen. This is enforced both at argument-parse
time and again at the point the filter actually runs, since
args.ckpt_format can be reassigned after validate_args() (e.g. via
--ckpt-convert-format).

Fixes NVIDIA#7270

Signed-off-by: Matthew Romano <matthewromano5087@gmail.com>
Three real bugs found by actually running save/load/resume round trips on
GPU (2x A100, TP=1/2, PP=1/2, with and without SwiGLU), not caught by the
unit tests alone:

- The torch_dist-format guard was only checked in validate_args(), which
  runs once at CLI-parse time. args.ckpt_format can be reassigned after
  that (e.g. --ckpt-convert-format), so a converted-format save with
  --save-trainable-params-only would silently detach every tensor and
  drop the entire model section -- including the adapter weights being
  saved, not just the frozen base model. Re-check the effective format at
  the point the filter actually runs and fail loudly instead.

- TE `_extra_state` entries were kept unconditionally regardless of
  their sibling weight's frozen/trainable status, since a ShardedObject
  carries no requires_grad signal of its own. A frozen module's
  extra_state surviving while its weight/bias got dropped left a
  structurally inconsistent checkpoint (extra_state present, weight
  missing at the same path) that failed to load. Drop `_extra_state`
  unconditionally instead, matching Megatron-Bridge's proven PEFT
  checkpoint filter.

- Loading an adapter-only checkpoint back onto a matching base model
  crashed in apply_factory_merges whenever a frozen parameter was
  ShardedTensorFactory-wrapped (e.g. SwiGLU's linear_fc1): the load path
  only filtered the on-disk checkpoint, and relied on
  --dist-ckpt-strictness log_unexpected to reconcile the *load request*
  (built from the fresh, unfiltered model) against it. That reconciliation
  doesn't correctly propagate through un-built ShardedTensorFactory
  requests. Fix by filtering the load request's model section the same
  way the save filtered what was written -- the same technique
  Megatron-Bridge's own (production, SwiGLU-tested) PEFT checkpoint
  loading uses. --dist-ckpt-strictness log_unexpected is still required
  in addition, since the optimizer section is deliberately left
  unfiltered and a separate load-time shape-validation list needs
  non-strict handling for a frozen parameter's optimizer-section entry.

Verified with a real GPTModel (get_model/get_megatron_optimizer, plain
Adam since DistributedOptimizer doesn't support any frozen parameters at
all -- a pre-existing, unrelated limitation) across TP in {1,2}, PP in
{1,2}, and gated_linear_unit in {True,False}: adapter-only checkpoints
are correctly smaller than full checkpoints, frozen base weights survive
a non-strict load untouched, and the adapter parameters and iteration
count are correctly restored.

Also adds a unit test for the _extra_state fix.

Signed-off-by: Matthew Romano <matthewromano5087@gmail.com>
…ointing

tests/unit_tests/test_checkpointing.py::TestFilterStateDictToTrainableParams
only exercised the save-side filter function in isolation against synthetic
ShardedTensor objects; it never actually saved or loaded a real checkpoint, so
it couldn't have caught any of the three bugs fixed in the previous commit.

Add a real TP/PP/SwiGLU-parametrized test that builds an actual GPTModel with
a frozen base and a trainable adapter on every layer, saves it adapter-only,
loads it back onto a matching (corrupted) base model, and confirms: the
adapter-only checkpoint is smaller than the full one, the frozen base weights
are untouched by the load, the adapter weights are restored, and (via
optimizer.state_dict() comparison) the adapter's optimizer momentum state is
correctly restored -- i.e. resuming PEFT training actually works, not just
loading weights. Run at TP in {1,2}, PP in {1,2}, and gated_linear_unit in
{True,False} (6 cases), matching the real save/load/resume verification run
manually on 2x A100 for this PR.

Signed-off-by: Matthew Romano <matthewromano5087@gmail.com>
@copy-pr-bot

copy-pr-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

This branch has not been deployed

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

Labels

community-request waiting-on-maintainers Waiting on maintainers to respond

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add adapter-only checkpointing to Megatron-LM

2 participants