Skip to content

Migrate optimizer state across devices [GH-1615]#1616

Closed
shi-eric wants to merge 1 commit into
NVIDIA:mainfrom
shi-eric:ershi/fix-optimizer-device
Closed

Migrate optimizer state across devices [GH-1615]#1616
shi-eric wants to merge 1 commit into
NVIDIA:mainfrom
shi-eric:ershi/fix-optimizer-device

Conversation

@shi-eric

@shi-eric shi-eric commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Description

Closes #1615.

Adam and SGD reused shape- and dtype-compatible optimizer state buffers
when replacement parameters moved to another device. This could leave the
parameters and their state on different devices. Migrate compatible state
buffers to the replacement parameter's device while preserving their values.
Shape or dtype changes continue to allocate zeroed state.

Changes

  • Migrate compatible Adam first- and second-moment buffers across devices.
  • Migrate compatible SGD momentum buffers across devices.
  • Cover CPU-to-CUDA migration with mixed moved and unmoved parameters.
  • Document the corrected optimizer state reuse behavior in the changelog.

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.
  • CHANGELOG.md is updated for any user-facing changes under the Unreleased section.

Validation summary

  • Verified test_adam_set_params_migrates_state preserves both moment
    buffers and reuses the state of an unmoved parameter.
  • Verified test_sgd_set_params_migrates_state preserves the momentum
    buffer and reuses the state of an unmoved parameter.
  • Ran the focused Adam and SGD suites on CPU and two CUDA devices: 37 tests
    passed.
  • Ran pre-commit checks on all changed files.

Bug fix

Without this change, the optimizer state remains on the CPU after replacing
the parameter with one on CUDA:

import warp as wp

param = wp.zeros(4, dtype=wp.float32, device="cpu")
optimizer = wp.optim.Adam([param])
optimizer.m[0].fill_(1.0)

param = wp.zeros(4, dtype=wp.float32, device="cuda:0")
optimizer.set_params([param])

assert optimizer.m[0].device == param.device
optimizer.step([wp.ones_like(param)])

Summary by CodeRabbit

  • Bug Fixes
    • Fixed optimizer state handling when replacing parameters on a different device. Adam moment buffers and SGD momentum buffers now migrate to the replacement parameter’s device when sizes/dtypes match, preserving accumulated values and avoiding incorrect cross-device reuse.
  • Tests
    • Added new unit tests for Adam and SGD verifying device migration, value preservation across set_params() calls, and that unchanged parameters’ state buffers keep the same object identity.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Enterprise

Run ID: b8888a81-8f0f-4ea9-95e0-853fde8e798a

📥 Commits

Reviewing files that changed from the base of the PR and between a3342ed and 964aa34.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • warp/_src/optim/adam.py
  • warp/_src/optim/sgd.py
  • warp/tests/test_adam.py
  • warp/tests/test_sgd.py
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • warp/_src/optim/sgd.py
  • warp/_src/optim/adam.py
  • warp/tests/test_adam.py
  • warp/tests/test_sgd.py

📝 Walkthrough

Walkthrough

Adam and SGD set_params now migrate compatible optimizer state buffers to a replacement parameter’s device. New CUDA-targeted tests verify preserved values and unchanged state for unmoved parameters, and the changelog note was updated.

Changes

Optimizer state device migration

Layer / File(s) Summary
Adam moment buffer migration
warp/_src/optim/adam.py, warp/tests/test_adam.py
Adam.set_params now migrates m and v to the parameter device on device mismatch, and the new test verifies migration, preserved values, and repeated back-and-forth device moves.
SGD momentum buffer migration
warp/_src/optim/sgd.py, warp/tests/test_sgd.py
SGD.set_params now migrates b to the parameter device on device mismatch, and the new test verifies migration, preserved values, and unchanged identity for the unmoved parameter.
Changelog update
CHANGELOG.md
The unreleased fixed note now covers Adam and SGD cross-device optimizer state migration.

Estimated code review effort: 2 (Simple) | ~12 minutes

Related Issues: #1615

Suggested labels: bug, optimizer

Suggested reviewers: none

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: migrating optimizer state across devices.
Linked Issues check ✅ Passed The code and tests address Adam/SGD state migration across device changes while preserving values and leaving unmoved state intact.
Out of Scope Changes check ✅ Passed The changelog updates and added optimizer tests are directly related to the device-migration fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a bug where Adam.set_params() and SGD.set_params() would reuse shape- and dtype-compatible optimizer state buffers even when the replacement parameter moved to a different device, leaving moments/momentum on the wrong device and causing cross-device kernel launches.

  • Adds a device-migration branch (elif self.m[i].device != param.device) to both Adam and SGD that calls warp.array.to() (which internally calls warp.clone()) to copy the buffer to the new device while preserving accumulated state.
  • Tests cover CPU→CUDA and CUDA→CPU migration for both optimizers, verify value preservation after migration, confirm unmoved parameters are not reallocated, and run a full step() post-migration to validate end-to-end correctness.

Confidence Score: 5/5

Safe to merge — the fix is a minimal two-line addition per optimizer that only activates when shape and dtype already match, with no risk to the existing allocation or zero-initialization paths.

The change is surgical and well-contained: an elif branch guarded by a device inequality check, delegating to warp.array.to() which is already used throughout the codebase and internally calls warp.clone() for value-preserving cross-device copies. Existing behavior for shape/dtype mismatches is untouched. The new tests exercise both migration directions (CPU↔CUDA) for both optimizers, verify value preservation, confirm unmoved-parameter identity is retained, and validate a full optimizer step post-migration.

No files require special attention.

Important Files Changed

Filename Overview
warp/_src/optim/adam.py Adds device-migration elif branches for first and second moment buffers; logic is correct — device check only fires after shape/dtype match, and delegates to warp.clone via .to().
warp/_src/optim/sgd.py Adds identical device-migration pattern for momentum buffer; correctly placed as elif so it only fires when shape and dtype already match.
warp/tests/test_adam.py New test_adam_set_params_migrates_state covers CPU→CUDA and CUDA→CPU migration, value preservation, unmoved-param identity, and a full step() post-migration; registered against get_cuda_test_devices().
warp/tests/test_sgd.py New test_sgd_set_params_migrates_state mirrors the Adam test structure; adds the missing import numpy as np that the new assertions require.
CHANGELOG.md Changelog entry correctly describes the bug and fix under the Unreleased section with the appropriate issue reference.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["set_params(params) called"] --> B["For each param[i]"]
    B --> C{"m[i] is None OR\nshape mismatch OR\ndtype mismatch?"}
    C -- Yes --> D["Allocate fresh zeros on param.device\n(state reset)"]
    C -- No --> E{"m[i].device\n!= param.device?"}
    E -- Yes --> F["m[i] = m[i].to(param.device)\nwarp.clone() copies values\nacross devices"]
    E -- No --> G["No-op: reuse existing buffer\n(same device, shape, dtype)"]
    D --> H["Same check for v[i] / b[i]"]
    F --> H
    G --> H
    H --> I["step() runs kernel\non param.device\nwith co-located state"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["set_params(params) called"] --> B["For each param[i]"]
    B --> C{"m[i] is None OR\nshape mismatch OR\ndtype mismatch?"}
    C -- Yes --> D["Allocate fresh zeros on param.device\n(state reset)"]
    C -- No --> E{"m[i].device\n!= param.device?"}
    E -- Yes --> F["m[i] = m[i].to(param.device)\nwarp.clone() copies values\nacross devices"]
    E -- No --> G["No-op: reuse existing buffer\n(same device, shape, dtype)"]
    D --> H["Same check for v[i] / b[i]"]
    F --> H
    G --> H
    H --> I["step() runs kernel\non param.device\nwith co-located state"]
Loading

Reviews (4): Last reviewed commit: "Migrate optimizer state across devices (..." | Re-trigger Greptile

Comment thread warp/tests/test_adam.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
warp/tests/test_adam.py (1)

162-208: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing CUDA→CPU and CUDA→CUDA migration coverage.

test_adam_set_params_migrates_state only exercises CPU→CUDA migration (plus an unmoved CPU parameter). The linked issue explicitly asks for coverage of CPU-to-CUDA, CUDA-to-CPU, and CUDA-to-CUDA cases, but only CPU-to-CUDA (and the mixed case) is tested here. This gap means a regression in the CUDA→CPU path, or the potential unsynchronized peer-to-peer copy issue on CUDA→CUDA (flagged in adam.py), would go undetected.

Consider adding symmetric variants (starting the parameter on device and moving it to "cpu", and — when more than one CUDA device is available — moving between two CUDA devices) to close this gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@warp/tests/test_adam.py` around lines 162 - 208, The Adam migration test only
covers CPU-to-CUDA, so expand test_adam_set_params_migrates_state to also verify
the symmetric CUDA-to-CPU path and, when multiple CUDA devices are available,
CUDA-to-CUDA migration. Reuse the existing Adam setup and state assertions
around warp.optim.Adam.set_params, but add separate cases that start a parameter
on device, move it to "cpu", and move it between two CUDA devices to ensure m/v
buffers follow correctly in all directions.
warp/_src/optim/adam.py (1)

136-145: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Migration logic duplicated across m/v (here) and b in sgd.py.

The reuse/reallocate/migrate pattern is repeated almost verbatim three times (Adam's m, Adam's v, and SGD's b in warp/_src/optim/sgd.py). Extracting a small shared helper would reduce duplication and ensure any future fix (e.g. the sync issue above) only needs to be applied once.

♻️ Suggested helper extraction
def _migrate_or_reset_state(buffer, shape, dtype, device):
    if buffer is None or buffer.shape != shape or buffer.dtype != dtype:
        return wp.zeros(shape=shape, dtype=dtype, device=device)
    if buffer.device != device:
        migrated = wp.empty(shape=shape, dtype=dtype, device=device)
        wp.copy(migrated, buffer)
        return migrated
    return buffer

Usage: self.m[i] = _migrate_or_reset_state(self.m[i], param.shape, dtype, param.device), similarly for v and SGD's b.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@warp/_src/optim/adam.py` around lines 136 - 145, The state reset/migration
logic for Adam’s `m` and `v` is duplicated and matches the `b` handling in
`sgd.py`, so extract the repeated reuse/reallocate/migrate pattern into a small
shared helper. Add a helper like `_migrate_or_reset_state` in the optimizers
module and use it from `Adam`’s update path for both `self.m[i]` and
`self.v[i]`, and from SGD for `self.b[i]`, so the shape/dtype/device checks and
migration behavior live in one place.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@warp/_src/optim/adam.py`:
- Around line 136-145: Synchronize the source CUDA device before migrating Adam
moments in set_params() so cross-GPU copies do not race with in-flight updates
from step(). In the migration branches that use wp.copy for self.m[i] and
self.v[i], add a source-device sync or event barrier before copying into
migrated_m and migrated_v, then continue assigning the migrated buffers as
before.

---

Nitpick comments:
In `@warp/_src/optim/adam.py`:
- Around line 136-145: The state reset/migration logic for Adam’s `m` and `v` is
duplicated and matches the `b` handling in `sgd.py`, so extract the repeated
reuse/reallocate/migrate pattern into a small shared helper. Add a helper like
`_migrate_or_reset_state` in the optimizers module and use it from `Adam`’s
update path for both `self.m[i]` and `self.v[i]`, and from SGD for `self.b[i]`,
so the shape/dtype/device checks and migration behavior live in one place.

In `@warp/tests/test_adam.py`:
- Around line 162-208: The Adam migration test only covers CPU-to-CUDA, so
expand test_adam_set_params_migrates_state to also verify the symmetric
CUDA-to-CPU path and, when multiple CUDA devices are available, CUDA-to-CUDA
migration. Reuse the existing Adam setup and state assertions around
warp.optim.Adam.set_params, but add separate cases that start a parameter on
device, move it to "cpu", and move it between two CUDA devices to ensure m/v
buffers follow correctly in all directions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Enterprise

Run ID: 55ddbbeb-927e-41a9-90ab-e16cf00568f4

📥 Commits

Reviewing files that changed from the base of the PR and between f9fd4eb and 1b575b5.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • warp/_src/optim/adam.py
  • warp/_src/optim/sgd.py
  • warp/tests/test_adam.py
  • warp/tests/test_sgd.py

Comment thread warp/_src/optim/adam.py Outdated
Comment on lines +136 to +145
elif self.m[i].device != param.device:
migrated_m = wp.empty(shape=param.shape, dtype=dtype, device=param.device)
wp.copy(migrated_m, self.m[i])
self.m[i] = migrated_m
if self.v[i] is None or self.v[i].shape != param.shape or self.v[i].dtype != dtype:
self.v[i] = wp.zeros(shape=param.shape, dtype=dtype, device=param.device)
elif self.v[i].device != param.device:
migrated_v = wp.empty(shape=param.shape, dtype=dtype, device=param.device)
wp.copy(migrated_v, self.v[i])
self.v[i] = migrated_v

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Relevant code in warp/_src/optim/adam.py ==\n'
ast-grep outline warp/_src/optim/adam.py --view expanded || true
sed -n '1,240p' warp/_src/optim/adam.py | cat -n

printf '\n== Search for wp.copy / synchronize_device semantics ==\n'
rg -n "synchronize_device|wp\.copy\(|copy\(" warp/_src docs README.md -g '!**/*.pyc' || true

printf '\n== Locate copy implementation and any device-ordering docs ==\n'
fd -a "copy*.py" warp/_src || true
fd -a "*.md" . | sed -n '1,120p'

Repository: NVIDIA/warp

Length of output: 24857


🌐 Web query:

Warp documentation wp.copy CUDA CUDA peer access source device synchronize_device multi-device copy semantics

💡 Result:

In NVIDIA Warp, the wp.copy function handles data movement between arrays, including cross-device transfers [1][2]. Its behavior regarding multi-device copy semantics, synchronization, and peer access is summarized below. Multi-Device Copy Semantics and Synchronization Copying between CUDA arrays on different devices is asynchronous with respect to the host [3][4]. Because CUDA devices operate asynchronously relative to each other, peer-to-peer transfers require careful synchronization to ensure correctness [3]. When a transfer occurs between GPUs, the destination device typically performs the copy, necessitating that any prior work on the source device is complete before the transfer begins [3][4]. To manage this, users should synchronize devices explicitly: 1. Use wp.synchronize_device(source_device) before the copy to ensure the source array is ready [3][4]. 2. Use wp.synchronize_device(dest_device) after the copy to ensure the transfer has finished [3][4]. By default, if the stream argument is omitted, Warp selects a stream for the copy based on these rules: 1. If the destination array is on a CUDA device, it uses the current stream on that destination device [1]. 2. Otherwise, if the source array is on a CUDA device, it uses the current stream on that source device [1]. Users can override this by explicitly providing a stream, allowing the copy to be performed on a stream from any device [3]. CUDA Peer Access CUDA peer access enables direct memory access (DMA) between GPUs, which significantly accelerates cross-device transfers compared to using CPU staging buffers [5][6]. - Support: You can check if peer access is supported between two devices using wp.is_peer_access_supported(device1, device2) [5][6]. - Configuration: Peer access is directional and can be enabled using wp.set_peer_access_enabled(target_device, peer_device, True) or managed temporarily with the wp.ScopedPeerAccess(target_device, peer_device, True) context manager [5][7][6]. - Scope: Peer access settings apply to all allocations made with the default CUDA allocator [7]. - Note on Memory Pools: Peer access does not accelerate transfers between arrays allocated via stream-ordered memory pools (introduced in Warp 0.14.0); for those, memory-pool access must be enabled instead [5][8]. As with all asynchronous CUDA operations, wp.synchronize_device is the primary tool for ensuring memory consistency between the host and specific GPU devices [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== warp/_src/optim/adam.py excerpt ==\n'
sed -n '120,170p' warp/_src/optim/adam.py | cat -n

printf '\n== repo references to synchronize_device ==\n'
rg -n "synchronize_device" warp/_src | sed -n '1,120p' || true

printf '\n== repo references to wp.copy ==\n'
rg -n "wp\.copy\(" warp/_src | sed -n '1,120p' || true

Repository: NVIDIA/warp

Length of output: 7182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== docs/deep_dive/concurrency.rst excerpt ==\n'
sed -n '90,170p' docs/deep_dive/concurrency.rst | cat -n

printf '\n== docs/user_guide/devices.rst excerpt ==\n'
sed -n '60,210p' docs/user_guide/devices.rst | cat -n

printf '\n== warp/_src/context.py copy implementation excerpt ==\n'
sed -n '12526,12870p' warp/_src/context.py | cat -n

Repository: NVIDIA/warp

Length of output: 31026


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== docs/deep_dive/concurrency.rst around cross-device copy ==\n'
sed -n '120,155p' docs/deep_dive/concurrency.rst | cat -n

printf '\n== docs/deep_dive/concurrency.rst around stream/device sync discussion ==\n'
sed -n '240,270p' docs/deep_dive/concurrency.rst | cat -n

printf '\n== warp/_src/context.py around wp.copy device selection and CUDA copy paths ==\n'
sed -n '12526,12740p' warp/_src/context.py | cat -n

Repository: NVIDIA/warp

Length of output: 16610


Synchronize the source CUDA device before migrating Adam moments across devices. wp.copy(migrated_m, self.m[i]) / wp.copy(migrated_v, self.v[i]) enqueue the peer copy on the destination stream, but cross-GPU copies need outstanding source-device work to finish first. set_params() can run immediately after step(), so this can race with the previous update and copy stale m/v state; add a source-device sync or event barrier before the copy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@warp/_src/optim/adam.py` around lines 136 - 145, Synchronize the source CUDA
device before migrating Adam moments in set_params() so cross-GPU copies do not
race with in-flight updates from step(). In the migration branches that use
wp.copy for self.m[i] and self.v[i], add a source-device sync or event barrier
before copying into migrated_m and migrated_v, then continue assigning the
migrated buffers as before.

@shi-eric
shi-eric force-pushed the ershi/fix-optimizer-device branch 2 times, most recently from 9d08214 to a3342ed Compare July 7, 2026 17:05
Adam and SGD previously reused compatible optimizer state buffers even
when replacement parameters moved to another device. This left kernels
with state and parameters on different devices.

Migrate compatible state buffers while preserving their accumulated
values. Keep incompatible shape or dtype changes using freshly zeroed
buffers, and cover mixed moved and unmoved parameters.

Signed-off-by: Eric Shi <ershi@nvidia.com>
@shi-eric
shi-eric force-pushed the ershi/fix-optimizer-device branch from a3342ed to 964aa34 Compare July 7, 2026 17:11
@shi-eric shi-eric closed this Jul 8, 2026
@shi-eric
shi-eric deleted the ershi/fix-optimizer-device branch July 8, 2026 16:26
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.

Adam and SGD reuse optimizer state buffers from the wrong device

1 participant