Skip to content

fix: stop DeepSpeedConfig writing max_grad_norm back into the caller's config dict - #8289

Merged
tohtana merged 3 commits into
deepspeedai:masterfrom
ebarkhordar:fix/max-grad-norm-config-mutation
Aug 23, 2026
Merged

tohtana merged 3 commits into
deepspeedai:masterfrom
ebarkhordar:fix/max-grad-norm-config-mutation

Conversation

@ebarkhordar

Copy link
Copy Markdown
Contributor

What happens

deepspeed.initialize() writes into the dict the caller passed as config. When
optimizer.params.max_grad_norm is set to a positive value, DeepSpeedConfig._do_warning_check
assigns 0.0 into self.optimizer_params, and that is the caller's own
config["optimizer"]["params"] object rather than a copy.

Measured in a clean python:3.11-slim container at HEAD 11b518a00, torch 2.13.0+cpu,
deepspeed installed with pip install -e . from the checkout
(deepspeed.__file__ = /src/deepspeed/__init__.py, deepspeed.__version__ = 0.19.6+unknown):

import os, json, copy
os.environ.update(MASTER_ADDR="127.0.0.1", MASTER_PORT="29517",
                  RANK="0", LOCAL_RANK="0", WORLD_SIZE="1")
import torch, deepspeed

cfg = {"train_micro_batch_size_per_gpu": 1,
       "optimizer": {"type": "AdamW", "params": {"lr": 1e-3, "max_grad_norm": 1.0}}}
model = torch.nn.Linear(4, 4)
client_opt = torch.optim.AdamW(model.parameters(), lr=1e-3)

print("BEFORE:", json.dumps(cfg["optimizer"]["params"]))
engine, *_ = deepspeed.initialize(model=model, optimizer=client_opt, config=cfg)
print("AFTER :", json.dumps(cfg["optimizer"]["params"]))
print("gradient_clipping in force:", engine.gradient_clipping())

Observed:

BEFORE: {"lr": 0.001, "max_grad_norm": 1.0}
[WARNING] [config.py:1068:_do_warning_check] DeepSpeedConfig: In FP32 mode, DeepSpeed does not permit MAX_GRAD_NORM (1.0) > 0, setting to zero
AFTER : {"lr": 0.001, "max_grad_norm": 0.0}
gradient_clipping in force: 1.0

Expected: initialize leaves the caller's dict as it found it.

Passing a client optimizer is what makes this visible, because _configure_basic_optimizer is
then never called and the usual ValueError never fires, so initialization succeeds with the
caller's config quietly rewritten. The same run without a client optimizer still raises the
ValueError, and still leaves 0.0 behind in the caller's dict, because the zeroing happens
during config construction and the engine's check tests for the key's presence rather than its
value.

The warning is also no longer accurate. The value it claims to zero is not read by anything:
get_optimizer_gradient_clipping (config.py:458) is its only reader and has no callers
anywhere in deepspeed/ or tests/ (checked with an AST scan for Call nodes, not a text
search). The clipping actually applied comes from gradient_clipping, which is why the run
above reports 1.0. The engine side of this behaviour was removed in abe2204d (#232, 2020)
and replaced by the hard ValueError; the config side predates that change and was not
revisited with it.

Why the fix looks like this

_configure_basic_optimizer already declares the invariant this line breaks, at
engine.py:2088, added three months ago in 3c337b542 (#8010):

# Copy so the pop() calls below (torch_adam, adam_w_mode, fp32_optimizer_states) do not
# mutate the shared config dict returned by optimizer_params().
optimizer_parameters = dict(self.optimizer_params() or {})

Enumerating every writer of that dict across deepspeed/ by AST (subscript assignment plus
update/pop/setdefault/clear/popitem calls):

writers
before 4: config.py:1071 on the caller's dict, plus engine.py:2096, 2097, 2115 on the copy
after 3: engine.py:2096, 2097, 2115, all on the copy

The FP16 and FP32 branches were a pair with the zeroing, so removing it leaves them without a
distinction to draw. Nothing passes max_grad_norm to an FP16 wrapper today: the only
consumers of a max_grad_norm param group are the Lamb and OneBit optimizers, which take it
as a constructor argument. The two branches therefore collapse into one warning carrying the
same remedy that _configure_basic_optimizer already raises.

If you would rather keep both original messages and drop only the assignment, or split the
message change into its own PR, say so and I will rework it.

Tests

tests/unit/runtime/test_ds_config_dict.py::test_max_grad_norm_leaves_caller_config_untouched
pins the caller's dict directly. In the same container, on master it fails with
assert 0.0 == 1.0; with this change it passes.

The rest of that file is unaffected: 27 passed, 5 skipped. TestArgs needs --shm-size above
the Docker default and fails with OSError: [Errno 28] No space left on device without it, on
master and on this branch alike.

yapf --style .style.yapf --diff and flake8 --config .flake8 are both clean on the two
changed files.

One limit worth stating: the unit test covers config construction, which is where the write
happens. The full deepspeed.initialize path is covered by the container run above rather
than by a unit test, since it needs a built comm extension.

…s config dict

_do_warning_check assigned 0.0 into self.optimizer_params, which is the caller's own
config["optimizer"]["params"] rather than a copy, so deepspeed.initialize() rewrote a
dict it does not own. With a client optimizer passed in, _configure_basic_optimizer is
never reached, so initialization succeeded and the change went unreported.

The zeroed value has no reader: get_optimizer_gradient_clipping is its only consumer and
has no callers. The engine side of this behaviour was removed in abe2204 (deepspeedai#232) and
replaced by a ValueError; the config side predates that and was not revisited. The FP16
and FP32 branches existed only to gate the assignment, so they collapse into a single
warning carrying the same remedy _configure_basic_optimizer already raises.

engine.py:2088 states the same invariant for its own pop() calls.

Signed-off-by: Ehsan Barkhordar <realbarkhordar@gmail.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 675a8726b7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

assert ds_config.eigenvalue_verbose is False


def test_max_grad_norm_leaves_caller_config_untouched():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the required sign-off trailer

This is a single-parent, non-merge commit, but its commit message has no Signed-off-by trailer. Add the author identity from git config user.name and git config user.email using --signoff so the commit satisfies the repository's mandatory commit policy.

AGENTS.md reference: AGENTS.md:L8-L8

Useful? React with 👍 / 👎.

@tohtana tohtana left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good catch, thank you @ebarkhordar!

@tohtana
tohtana enabled auto-merge August 23, 2026 06:50
@tohtana
tohtana added this pull request to the merge queue Aug 23, 2026
Merged via the queue into deepspeedai:master with commit f567175 Aug 23, 2026
3 checks passed
@ebarkhordar
ebarkhordar deleted the fix/max-grad-norm-config-mutation branch August 23, 2026 08:15
alanhuangyoo added a commit to alanhuangyoo/DeepSpeed that referenced this pull request Aug 29, 2026
DeepSpeedConfig stores the dict it is handed by reference, and deepspeedai#8289
established that parsing must not write back into it -- the caller owns
that dict and may reuse it afterwards.

The elasticity branch still does, two lines above the comment that says
otherwise: it assigns train_batch_size, train_micro_batch_size_per_gpu
and gradient_accumulation_steps into self._param_dict before the copy is
taken. A caller that passes a config with elasticity enabled gets three
keys back that it never set, and print_user_config() then reports them
as though the user had.

Collect the overrides and apply them to the copy instead. All three are
top-level keys, so the existing shallow copy is enough to keep them off
the caller's dict, and the parsed values are unchanged.

Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
banxingmjj pushed a commit to openanolis/DeepSpeed that referenced this pull request Sep 2, 2026
…eepspeedai#8329)

`DeepSpeedConfig` keeps the dict it is handed by reference:

```python
if isinstance(config, dict):
    self._param_dict = config
```

deepspeedai#8289 established that parsing must not write back into it — the caller
owns that dict and may reuse it after initialization.

The elasticity branch still does, two lines above the comment that says
otherwise:

```python
        self._param_dict[TRAIN_BATCH_SIZE] = final_batch_size
        self._param_dict[TRAIN_MICRO_BATCH_SIZE_PER_GPU] = micro_batch_size
        self._param_dict[GRADIENT_ACCUMULATION_STEPS] = gradient_accu_steps

    # Pass a copy so that user json is unmodified, e.g. for logging
    self._initialize_params(copy.copy(self._param_dict))
```

A caller that enables elasticity gets three keys back that it never set.
`print_user_config()` dumps `self._param_dict`, so it then reports them
as though the user had written them.

**It also makes the dict unparseable a second time.** The elasticity
path rejects those keys in the input unless
`ignore_non_elastic_batch_info` is set:

```
One or more batch related parameters were found in your ds_config (...).
These parameters *will not be used* since elastic training is enabled ...
```

The first parse succeeds and injects them; the second parse of the same
dict trips that guard, and its message asks the user to remove three
keys they never wrote.

### The fix

Collect the overrides and apply them to the copy. All three are
top-level keys, so the existing shallow copy keeps them off the caller's
dict.

### Test

`DeepSpeedConfig(config_dict)` twice on an elasticity config, with
`ignore_non_elastic_batch_info` left out so the guard is live:

before
```
parse 1 OK, caller dict gained: ['gradient_accumulation_steps', 'train_batch_size', 'train_micro_batch_size_per_gpu']
parse 2 FAILED: ElasticityConfigError One or more batch related parameters were found in your ds_config ...
```

after
```
parse 1 OK, caller dict gained: nothing
parse 2 OK
```

Parsed values are unchanged either way (`train_batch_size=4 micro=2
gas=2`), so this only removes the write-back.

`test_elasticity_leaves_caller_config_untouched` sits next to deepspeedai#8289's
`test_max_grad_norm_leaves_caller_config_untouched` and covers both
symptoms. On master it fails at

```
AssertionError: assert {'elasticity', 'train_batch_size', 'train_micro_batch_size_per_gpu',
                        'gradient_accumulation_steps'} == {'elasticity'}
```

```
tests/unit/runtime/test_ds_config_dict.py   27 passed, 5 skipped
tests/unit/elasticity/test_elastic.py       23 passed, 3 skipped
yapf --diff / flake8                        clean
```

---------

Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
Signed-off-by: Masahiro Tanaka <tanaka.masahiro@gmail.com>
Co-authored-by: Masahiro Tanaka <tanaka.masahiro@gmail.com>
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.

3 participants