Add AdamW optimizer to extension/training - #18848
BryanBradfo wants to merge 9 commits into
Conversation
Ports AdamW alongside the existing SGD implementation, following the pattern in extension/training/optimizer/sgd.{h,cpp}. Weight decay is decoupled (applied to the parameter directly, not folded into the gradient) per Loshchilov & Hutter 2019, this is the property that distinguishes AdamW from Adam-with-L2.
Fixes pytorch#18766
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/18848
Note: Links to docs will display an error until the docs builds have been completed. ❌ 1 New FailureAs of commit e0d1fac with merge base 9f209bd ( NEW FAILURE - The following job has failed:
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
|
@pytorchbot label "release notes: training" |
There was a problem hiding this comment.
Pull request overview
Adds an AdamW optimizer implementation to ExecuTorch’s on-device training extension, aligning behavior with torch.optim.AdamW (decoupled weight decay) and integrating it into the existing C++ optimizer build/test setup.
Changes:
- Introduces
AdamWoptimizer implementation (adamw.{h,cpp}) and exposes it as a training optimizer target. - Adds new gtests for AdamW and wires them into the optimizer test targets.
- Updates training extension build source lists and documentation to reflect AdamW availability.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| shim_et/xplat/executorch/build/build_variables.bzl | Adds AdamW source to extension training sources list. |
| extension/training/optimizer/targets.bzl | Defines a new adamw C++ library target. |
| extension/training/optimizer/adamw.h | Declares AdamW API, options, param group, and state types. |
| extension/training/optimizer/adamw.cpp | Implements AdamW step logic and state allocation/freeing. |
| extension/training/optimizer/test/targets.bzl | Adds adamw_test target. |
| extension/training/optimizer/test/adamw_test.cpp | New unit tests for AdamW behavior and defaults. |
| extension/training/README.md | Updates optimizer list to include AdamW. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| TensorImpl* m_impl = new TensorImpl( | ||
| g.scalar_type(), | ||
| g.sizes().size(), | ||
| const_cast<TensorImpl::SizesType*>(g.sizes().data()), | ||
| m_buf_ptr, | ||
| const_cast<TensorImpl::DimOrderType*>(g.dim_order().data())); | ||
| TensorImpl* v_impl = new TensorImpl( | ||
| g.scalar_type(), | ||
| g.sizes().size(), | ||
| const_cast<TensorImpl::SizesType*>(g.sizes().data()), | ||
| v_buf_ptr, | ||
| const_cast<TensorImpl::DimOrderType*>(g.dim_order().data())); |
There was a problem hiding this comment.
In portable (non-USE_ATEN_LIB) mode, the state tensors' TensorImpl are constructed with sizes/dim_order pointers taken from the gradient tensor (g.sizes().data()/g.dim_order().data()). TensorImpl does not own these arrays, and the optimizer state outlives the gradient tensor, so this can become a use-after-free if the gradient's metadata storage doesn't live for the lifetime of the optimizer. Allocate and store your own sizes/dim_order (and strides if needed) with the state, or use a construction path that ensures the metadata buffers are owned/stable for the optimizer lifetime.
There was a problem hiding this comment.
Good catch in principle. This matches the existing pattern in SGD (sgd.cpp:119-125) which constructs TensorImpl with the same d_p.sizes().data() / d_p.dim_order().data() pointers from the gradient. In practice, parameter shapes are stable across training steps in ExecuTorch's captured graph model. Happy to address both optimizers together in a follow-up if the maintainer prefers.
| // Bias-corrected update. | ||
| const double bias_correction1 = 1.0 - std::pow(beta1, step); | ||
| const double bias_correction2 = 1.0 - std::pow(beta2, step); | ||
| adamw_update_hack( |
There was a problem hiding this comment.
AdamWOptions parameters are used in bias-correction divisors (1 - beta^step) and sqrt(bias_correction2). With beta1==1 or beta2==1 (or beta2<0), step() will divide by zero / take sqrt of a negative, producing NaNs/inf. Consider validating lr/beta1/beta2/eps ranges (e.g., lr>=0, 0<=beta<1, eps>0) either in AdamWOptions construction or at the start of step() and return Error::InvalidArgument on invalid values.
There was a problem hiding this comment.
SGD also has no parameter validation (sgd.h), and PyTorch's C++ AdamW documents 0 <= beta < 1 as a precondition rather than a runtime check. Adding validation only to AdamW would be inconsistent. Happy to add range checks across both optimizers in a follow-up if the maintainer wants.
| } | ||
| auto g = named_gradient->second; | ||
| auto p = param_iter->second; | ||
|
|
There was a problem hiding this comment.
AdamW::step immediately treats both parameters and gradients as float tensors and iterates assuming matching shapes. If a caller passes a non-float tensor or a gradient whose shape/numel differs from the parameter, this can lead to undefined behavior or out-of-bounds reads/writes (e.g., in addcmul_sq_out_hack/adamw_update_hack). Add explicit scalar_type/shape checks (at least dtype==Float and p.numel()==g.numel()) and return Error::InvalidArgument when unsupported.
| if ( | |
| p.scalar_type() != executorch::aten::ScalarType::Float || | |
| g.scalar_type() != executorch::aten::ScalarType::Float) { | |
| return Error::InvalidArgument; | |
| } | |
| if (p.numel() != g.numel()) { | |
| return Error::InvalidArgument; | |
| } |
| AdamWParamState* state_ptr = nullptr; | ||
| if (param_state_it == state_.end()) { | ||
| void* m_buf_ptr = malloc(g.nbytes()); | ||
| void* v_buf_ptr = malloc(g.nbytes()); |
There was a problem hiding this comment.
The per-parameter state allocation uses malloc() but never checks for allocation failure. On memory-constrained targets this can turn into a null dereference in memset()/from_blob/TensorImpl construction. Check m_buf_ptr/v_buf_ptr for nullptr, free any partially-allocated buffers, and return Error::MemoryAllocationFailed.
| void* v_buf_ptr = malloc(g.nbytes()); | |
| void* v_buf_ptr = malloc(g.nbytes()); | |
| if (m_buf_ptr == nullptr || v_buf_ptr == nullptr) { | |
| free(m_buf_ptr); | |
| free(v_buf_ptr); | |
| return Error::MemoryAllocationFailed; | |
| } |
|
You can ignore copilot probably its noise ratio is pretty bad. Ive been trying to figure out how to turn it off for the repo. |
|
|
||
| namespace { | ||
| // out[i] = a[i] + alpha * b[i] | ||
| void add_out_hack( |
There was a problem hiding this comment.
ahh my legacy... @manuelcandales do you remember how to call executorch ops outside of the interpreter. I cant recall
Replace manual TensorImpl construction with make_tensor_ptr from extension/tensor, removing the #ifdef USE_ATEN_LIB block and simplifying the destructor. Store defaults_ by value since it is always initialized.
|
|
@BryanBradfo Can you complete the EasyCLA CI step please ? Once completed, we can merge this PR. |
|
@BryanBradfo Pinging again. Could you pls take care of easy CLA ? |
There was a problem hiding this comment.
🟡 Changes recommended
The new AdamW implementation has concrete crash/UB risks (unchecked malloc failures and unconditional float/contiguous assumptions) that should be guarded before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
extension/training/optimizer/adamw.cpp:164
- malloc() can return nullptr; the subsequent memset() would then dereference a null pointer and crash. Check allocation failures and return Error::MemoryAllocationFailed (freeing any partially allocated buffer) before continuing.
void* m_buf_ptr = malloc(g.nbytes());
void* v_buf_ptr = malloc(g.nbytes());
std::memset(m_buf_ptr, 0, g.nbytes());
std::memset(v_buf_ptr, 0, g.nbytes());
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
| auto g = named_gradient->second; | ||
| auto p = param_iter->second; | ||
|
|
|
Few compilation issues.. /__w/executorch/executorch/pytorch/executorch/extension/training/optimizer/adamw.cpp:168:22: error: no matching function for call to 'make_tensor_ptr' |
There was a problem hiding this comment.
🟡 Changes recommended
A critical compilation issue and two runtime correctness and memory-safety issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
extension/training/optimizer/adamw.cpp:36
- These helpers unconditionally reinterpret every tensor as
float, but the publicAdamWAPI acceptsexecutorch::aten::Tensorandstep()never checks its scalar type. Passing a valid double/half/bfloat16 parameter or gradient therefore uses 4-byte indexing against data with a different element size and corrupts the update instead of performing AdamW. Dispatch over supported floating types or reject non-float inputs before updating.
auto a_ptr = a.const_data_ptr<float>();
auto b_ptr = b.const_data_ptr<float>();
auto out_ptr = out.mutable_data_ptr<float>();
extension/training/optimizer/adamw.cpp:164
- The state allocations are unchecked. If either
mallocreturns null, the followingmemsetdereferences it, and if only the second allocation fails the first buffer is leaked; this is especially problematic for on-device training wherestep()already has an error-return path. Check both pointers (while allowing zero-byte tensors), free any partial allocation, and returnError::MemoryAllocationFailedbefore clearing the buffers.
void* m_buf_ptr = malloc(g.nbytes());
void* v_buf_ptr = malloc(g.nbytes());
std::memset(m_buf_ptr, 0, g.nbytes());
std::memset(v_buf_ptr, 0, g.nbytes());
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
| sizes, | ||
| m_buf_ptr, | ||
| g.scalar_type(), | ||
| executorch::aten::TensorShapeDynamism::STATIC, |
make_tensor_ptr gained a Device parameter between when this branch was written and now, so the two state-buffer calls were passing TensorShapeDynamism::STATIC where a Device is expected and the deleter where the dynamism is expected. Both moment buffers come from malloc and are therefore CPU, so pass an explicit CPU device. Also checks the malloc results before memset. The two allocations were used unconditionally, so a failure on a memory-constrained target dereferenced null rather than returning an error. Original work by Bryan (@BryanBradfo) in pytorch#18848; this commit only repairs the build against current main. Co-authored-by: Bryan <BryanBradfo@users.noreply.github.com>
|
Pushed two commits to this branch ( The compile error. Verification. I reproduced the exact CI error locally, confirmed the fix clears it, and The decoupled-weight-decay ordering matches, and all four gtest expectations agree with PyTorch. Please still run Still blocking: EasyCLA. Commits The fix commit was authored with Claude Code. |
There was a problem hiding this comment.
🟡 Changes recommended
Three moderate correctness and robustness issues in the AdamW implementation remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
extension/training/optimizer/adamw.cpp:155
- This mutates
pbefore the only allocation failure thatstep()reports. If eithermallocbelow returns null, the call returnsMemoryAllocationFailedafter applying weight decay; retrying the step then decays this parameter again even though its moment update never ran. Allocate/initialize the per-parameter state before changingp(or preflight all state allocations before the update pass).
if (weight_decay != 0.0) {
add_out_hack(p, p, -lr * weight_decay, p);
}
extension/training/optimizer/adamw.cpp:147
- The update helpers always access tensors as
float, but this path constructs state usingg.scalar_type()and never rejects other dtypes. A Double parameter/gradient is therefore partially written/read as 32-bit values, while Half can be read past its buffer. Validate both tensors as Float before applying weight decay, or implement dtype-specific updates.
auto g = named_gradient->second;
auto p = param_iter->second;
extension/training/optimizer/adamw.cpp:167
malloc(0)is permitted to returnnullptr, and ExecuTorch supports valid zero-element tensors. With an empty parameter/gradient this branch can incorrectly returnMemoryAllocationFailed; if zero bytes are allowed, the followingmemsetcalls must also be skipped rather than passing a null pointer. Treat allocation as failed only wheng.nbytes() != 0and guard zero-byte initialization.
void* m_buf_ptr = malloc(g.nbytes());
void* v_buf_ptr = malloc(g.nbytes());
if (m_buf_ptr == nullptr || v_buf_ptr == nullptr) {
free(m_buf_ptr);
free(v_buf_ptr);
return Error::MemoryAllocationFailed;
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
make_tensor_ptr gained a Device parameter between when this branch was written and now, so the two state-buffer calls were passing TensorShapeDynamism::STATIC where a Device is expected and the deleter where the dynamism is expected. Both moment buffers come from malloc and are therefore CPU, so pass an explicit CPU device. Also checks the malloc results before memset. The two allocations were used unconditionally, so a failure on a memory-constrained target dereferenced null rather than returning an error. Original work by Bryan (@BryanBradfo) in pytorch#18848; this commit only repairs the build against current main. Co-authored-by: Bryan <BryanBradfo@users.noreply.github.com>
04ec9a5 to
525f89c
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Address the AdamW input-safety, allocation-ordering, empty-tensor, and test-coverage issues.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
extension/training/optimizer/adamw.cpp:166
g.nbytes()is zero for valid zero-element tensors, which ExecuTorch supports. On an allocator wheremalloc(0)returnsnullptr, this branch reportsMemoryAllocationFailedeven though no state storage is needed; the zero-lengthmemsetshould also be skipped. Gate allocation failure and initialization on a nonzero byte count so empty parameters remain a no-op.
if (m_buf_ptr == nullptr || v_buf_ptr == nullptr) {
free(m_buf_ptr);
free(v_buf_ptr);
return Error::MemoryAllocationFailed;
extension/training/optimizer/adamw.cpp:154
- Weight decay mutates
pbefore the lazy state allocation below. If either allocation fails,step()returnsMemoryAllocationFailedafter changing this parameter (and possibly earlier ones), so retrying can apply decay twice and leave the step partially applied. Initialize the state before mutating parameters, then apply decay immediately before the moment update.
if (weight_decay != 0.0) {
add_out_hack(p, p, -lr * weight_decay, p);
}
extension/training/optimizer/test/adamw_test.cpp:88
- These behavioral tests use only constant ±1 gradients and a 0.1 tolerance. That makes the normalized update nearly sign-only, so substantial errors in the moment recurrence, bias correction, or epsilon placement can still pass; the claimed six-decimal PyTorch cross-check is not encoded here. Add a varying-gradient multi-step reference case with tight expected values.
EXPECT_NEAR(p1[0], 2.0, 0.1);
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
| auto a_ptr = a.const_data_ptr<float>(); | ||
| auto b_ptr = b.const_data_ptr<float>(); | ||
| auto out_ptr = out.mutable_data_ptr<float>(); | ||
| for (size_t i = 0; i < a.numel(); ++i) { | ||
| out_ptr[i] = a_ptr[i] + b_ptr[i] * alpha; |
|
Opened #22857 carrying this work forward, with credit to @BryanBradfo in both the commit message and the PR description. To be explicit about why it is a squash under a different author rather than a re-post of your commits: EasyCLA keys off commit authorship, so any PR containing @BryanBradfo if you sign the CLA, this PR is the better one to land and I will close #22857. The build fix you need is already pushed here, and CI is green apart from the CLA gate: cc @JacobSzwejbka — your call which one you would rather take. |
Ports the existing SGD optimizer at extension/training/optimizer/sgd.{h,cpp}
to AdamW. The algorithmic difference is decoupled weight decay: the
parameter is decayed directly rather than mixing the decay into the
gradient (Loshchilov & Hutter, 2019). Matches torch.optim.AdamW at its
default settings.
C++ only. Python bindings are deliberately left out: the pybindings file
has a standing TODO to build a generic optimizer interface first, so
copying PySGD to PyAdamW now would only add duplication. amsgrad and
maximize are also left out.
The implementation originates from Bryan (github.com/BryanBradfo) in
pull request pytorch#18848, which could not be merged because the contributor
did not complete the Linux Foundation CLA. Credit for the design and
implementation belongs to them; see the pull request description for the
full provenance note.
Fixes pytorch#18766
The three optimizer tests all fed a constant gradient, where Adam's normalized update is close to sign-only, so a broken moment recurrence or bias correction would still have passed them. Adds an eight-step varying gradient case with and without decoupled weight decay, expectations taken from torch.optim.AdamW on the same sequence. Adam with coupled L2 lands on 0.79996 for the decay case against AdamW's 0.77575, so the tolerance also pins the AdamW-vs-Adam distinction under a non-trivial gradient.
There was a problem hiding this comment.
🔵 Needs a closer look
Address the three moderate correctness and failure-handling issues in adamw.cpp.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
extension/training/optimizer/adamw.cpp:169
malloc(0)is allowed to returnnullptr, so an empty parameter/gradient can incorrectly returnMemoryAllocationFailedeven though no state storage is needed. ExecuTorch's allocation paths treat null data as valid whennbytes() == 0; handle zero-byte buffers separately and only treat null as failure for nonzero allocations.
extension/training/optimizer/adamw.cpp:153
- Weight decay mutates
pbefore lazy state allocation. If either allocation fails at lines 163-166,step()returnsMemoryAllocationFailedafter changing the parameter, so retrying applies decay again without a moment update and a multi-parameter step can be partially applied. Allocate/validate the state before mutating parameters, or make this failure path transactional.
if (weight_decay != 0.0) {
add_out_hack(p, p, -lr * weight_decay, p);
extension/training/optimizer/adamw.cpp:146
- These helpers unconditionally read and write
floatelements, but this path accepts arbitrary parameter/gradient dtypes; it also allocates state fromg.nbytes()while the final update iteratesp.numel(). A non-Float tensor or a gradient with a different numel can therefore produce incorrect results or out-of-bounds reads. Reject unsupported dtypes and mismatched sizes before applying decay.
auto g = named_gradient->second;
auto p = param_iter->second;
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
The allocation-failure path added in the previous commit returned MemoryAllocationFailed after the in-place weight decay had already changed p, while its moments and step counter stayed put, so a retried step would decay the parameter a second time. Moving the state lookup above the decay closes that window; decay still precedes the moment update, so the arithmetic is unchanged.
Ports the existing SGD optimizer at extension/training/optimizer/sgd.{h,cpp}
to AdamW. The algorithmic difference is decoupled weight decay: the
parameter is decayed directly rather than mixing the decay into the
gradient (Loshchilov & Hutter, 2019). Matches torch.optim.AdamW at its
default settings.
C++ only. Python bindings are deliberately left out: the pybindings file
has a standing TODO to build a generic optimizer interface first, so
copying PySGD to PyAdamW now would only add duplication. amsgrad and
maximize are also left out.
The implementation originates from Bryan (github.com/BryanBradfo) in
pull request pytorch#18848, which could not be merged because the contributor
did not complete the Linux Foundation CLA. Credit for the design and
implementation belongs to them; see the pull request description for the
full provenance note.
Fixes pytorch#18766
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate issues remain in adamw.cpp involving dtype validation and zero-element tensor allocation.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
extension/training/optimizer/adamw.cpp:164
malloc(0)is permitted to returnnullptr, and ExecuTorch supports zero-numel tensors. For an empty parameter/gradient this branch can therefore returnMemoryAllocationFailedbefore the otherwise no-op step, depending on the allocator/platform. Gate the allocation failure check andmemsetong.nbytes() != 0(or skip state allocation for empty tensors).
extension/training/optimizer/adamw.cpp:146
- These helpers unconditionally reinterpret tensors as
float, butstep()does not validate the parameter or gradient dtype and allocates the state usingg.scalar_type(). A Half/BFloat16 input makes the state buffer smaller than the subsequentmutable_data_ptr<float>()writes, while Double or integer inputs are read with the wrong representation, causing memory corruption or incorrect updates. Reject non-Float tensors here (and document the supported dtype), or add dtype-specific kernels.
auto g = named_gradient->second;
auto p = param_iter->second;
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
Upgraded verification: the real C++ now runs, not just a transcriptionEarlier I could only say I had transcribed The two varying-gradient values are the ones taken from Per-parameter state isolation, which the cases above do not cover because they use one optimizer That last line also covers the What this still does not coverThe gtest harness itself ( |
… extension (based on pytorch#18848 and pytorch#22857) - Carries over the AdamW optimizer changes from pytorch#18848 - Incorporates the allocation-safety and test-coverage fixes from pytorch#22857 - Add Float and Half AdamW support with dtype, shape, layout, and CPU validation
Adds AdamW to the training optimizer extension. It's a port of the existing SGD implementation at
extension/training/optimizer/sgd.{h,cpp}, with the main algorithmic difference being decoupled weight decay (the parameter gets decayed directly instead of mixing the decay into the gradient). Matchestorch.optim.AdamWwith default settings.Fixes #18766
Scope
C++ only for this PR. Python bindings are left out on purpose: the pybindings file has a TODO to build a generic optimizer interface first, so copying
PySGDtoPyAdamWnow would just add duplication. Happy to follow up with that.amsgradandmaximizeare also left out, both rarely used and easy to add later if needed.Test plan
Six new gtests pass, and the SGD regression stays green:
Output was also cross-checked against
torch.optim.AdamWon four small cases (simple convergence, decoupled weight decay, multi-parameter). All four match to six decimal places.cc @JacobSzwejbka