Skip to content

Fix double optimizer step in DefaultTrainingLoop with static capture - #1912

Open
wilyan09007 wants to merge 1 commit into
NVIDIA:mainfrom
wilyan09007:fix/issue-1911
Open

Fix double optimizer step in DefaultTrainingLoop with static capture#1912
wilyan09007 wants to merge 1 commit into
NVIDIA:mainfrom
wilyan09007:fix/issue-1911

Conversation

@wilyan09007

Copy link
Copy Markdown

PhysicsNeMo Pull Request

Description

StaticCaptureTraining runs the backward pass and steps the optimizer through its grad scaler, but the loop skipped only loss.backward() and called optimizer.step() unconditionally. The gradients are still populated there, so with enable_static_capture=True every minibatch got a second update: the effective learning rate doubled, stateful optimizers advanced twice, and the update bypassed the grad scaler's inf/nan check, while the minibatch count and scheduler still recorded one.

This moves optimizer.step() into the eager branch, leaving the capture wrapper the sole owner of the update.

test_single_optimizer_step_per_minibatch runs one deterministic minibatch each way on CPU and asserts identical parameter deltas and a single optimizer.step call; it fails on main. Every other test in that file sets enable_static_capture=False, so this path had no coverage.

Unrelated and left alone: the loop's model.zero_grad(set_to_none=True) before each wrapped step is what capture.py warns can invalidate a recorded CUDA graph.

Closes #1911

Checklist

Dependencies

None.

Review Process

All PRs are reviewed by the PhysicsNeMo team before merging.

Depending on which files are changed, GitHub may automatically assign a maintainer for review.

We are also testing AI-based code review tools (e.g., Greptile), which may add automated comments with a confidence score.
This score reflects the AI's assessment of merge readiness and is not a qualitative judgment of your work, nor is
it an indication that the PR will be accepted / rejected.

AI-generated feedback should be reviewed critically for usefulness.
You are not required to respond to every AI comment, but they are intended to help both authors and reviewers.
Please react to Greptile comments with 👍 or 👎 to provide feedback on their accuracy.

StaticCaptureTraining runs the backward pass and steps the optimizer
through its grad scaler, but DefaultTrainingLoop skipped only
loss.backward() and called optimizer.step() unconditionally. The
gradients are still populated at that point, so every minibatch got a
second update: the effective learning rate doubled, stateful optimizers
advanced twice, and the update bypassed the grad scaler's inf/nan check
while the minibatch count and scheduler still recorded one step.

Move optimizer.step() into the eager branch so the capture wrapper is
the sole owner of the update.

Signed-off-by: William <wilyan090@gmail.com>
@copy-pr-bot

copy-pr-bot Bot commented Aug 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.

@github-actions

Copy link
Copy Markdown
Contributor

CODEOWNERS review map

Current for commit 9afbf87a2ddc. An approval covers every file listed for that owner; one owner is sufficient for shared files.

@dallasfoster — 2 file(s)
  • physicsnemo/active_learning/loop.py
  • test/active_learning/test_loop.py
@laserkelvin — 2 file(s)
  • physicsnemo/active_learning/loop.py
  • test/active_learning/test_loop.py

No CODEOWNER

  • CHANGELOG.md

Comment /codeowners-info to refresh.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR prevents DefaultTrainingLoop from applying a second optimizer update when static capture already owns backward propagation and optimizer stepping.

  • Moves the eager optimizer step into the non-static-capture branch.
  • Adds a deterministic CPU regression test comparing eager and wrapped parameter updates.
  • Documents the corrected behavior in the changelog.

Important Files Changed

Filename Overview
physicsnemo/active_learning/loop.py Correctly makes the eager loop and StaticCaptureTraining mutually exclusive owners of backward propagation and optimizer stepping.
test/active_learning/test_loop.py Adds useful fallback-path coverage, but disables AMP and uses one CPU minibatch, leaving the grad-scaler and graph record/replay integration untested.
CHANGELOG.md Accurately explains the double optimizer update and the resulting effective-learning-rate and gradient-scaling consequences.

Reviews (1): Last reviewed commit: "Fix double optimizer step in DefaultTrai..." | Re-trigger Greptile

DefaultTrainingLoop(
enable_static_capture=enable_static_capture,
use_progress_bars=False,
use_amp=False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Static-capture test skips AMP paths

Passing use_amp=False with a single CPU minibatch exercises only the fallback wrapper path, not the grad-scaler ownership or CUDA graph record/replay behavior central to this fix. The test can therefore pass without detecting missing or duplicate optimizer updates in those production capture modes.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On CPU there is no graph or enabled scaler for this test to skip. _StaticCapture.__init__ takes the CPU branch, which sets cuda_graphs_enabled = False and builds the scaler with _init_amp_scaler(False) no matter what use_amp is; use_gradscaler is only read on the CUDA branch. So turning use_amp back on would not reach graph record/replay or an enabled scaler, it would only add CPU bfloat16 autocast, and that is what I turned off so the two paths can be compared with an exact torch.equal on the parameter deltas.

Step ownership is covered. A disabled GradScaler.step(optim) still calls through to optimizer.step, so call_count == 1 on the capture path is counting the wrapper's own update, which is the behavior the fix depends on.

Enabled-scaler inf/nan skipping and graph replay are GPU-only and belong to capture.py, where test/utils/test_capture.py already parametrizes over cuda:0. What changed here is a branch in the training loop that does not vary by device or capture mode.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. The bug is a missing conditional in the training loop — optimizer.step() was called unconditionally instead of only in the eager branch — and that branch condition doesn't vary by device or capture mode. Testing it on CPU is sufficient and cleaner: it isolates exactly the invariant being fixed (step count == 1) without CUDA graph or scaler complexity that lives in a different layer.

The disabled-scaler call-through point is the key one I missed: GradScaler.step with enabled=False delegates directly to optimizer.step, so the mock's call_count accurately reflects both the wrapper's own update and any spurious second call from the loop. That's precisely what the test needs to observe.

My original concern was misplaced — the AMP/graph behavior is orthogonal to this fix and is already covered in test/utils/test_capture.py. The test is correct as written.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛[BUG]: DefaultTrainingLoop performs two optimizer steps per minibatch with static capture

1 participant