Feature/zero multi loss separate backward#8135
Conversation
Signed-off-by: nathon-lee <leejianwoo@gmail.com>
…te-backward-test Fix ZeRO-3 so two separate backward passes on the same forward graph work correctly when `retain_graph=True` is used on the first backward.
There was a problem hiding this comment.
💡 Codex Review
DeepSpeed/deepspeed/runtime/engine.py
Lines 4348 to 4350 in f873f69
After removing the all-rank save_dir validation, a misconfigured save_checkpoint(save_dir=None) or empty string now fails only on rank 0 inside makedirs, while the other ranks proceed to dist.barrier() and can hang indefinitely. The previous check failed synchronously on every rank before this rank-gated filesystem call, so distributed checkpoint misconfiguration no longer fails cleanly.
ℹ️ 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".
| for i in range(len(ev_values)): | ||
| self.summary_events.append(( | ||
| f"Train/Eigenvalues/ModelBlockParam_{i}", | ||
| self.ev_values[i][0], |
There was a problem hiding this comment.
Use block eigenvalues when logging events
When monitoring and eigenvalue logging are enabled at a gradient-accumulation boundary, this block now reads self.ev_values[i][0], but self.ev_values is never initialized anywhere on DeepSpeedEngine (the local value is ev_values = self.block_eigenvalue.values()). That makes engine.step() raise AttributeError instead of writing the eigenvalue summaries in that configuration.
Useful? React with 👍 / 👎.
…changes Signed-off-by: nathon-lee <leejianwoo@gmail.com>
…te-backward-test fix(engine,zero3): restore pre-8135 behavior for non-deepspeedai#8045 changes
|
Hi @nathon-lee, |
|
Thanks, @tohtana — great point. I agree this tradeoff should be explicit. I’ll scope it tightly: delayed ZeRO-3 release applies only to the explicit retain_graph=True backward on the same forward graph, and the state is reset in finally, so default backward behavior remains unchanged. I’ll also add targeted coverage to confirm (1) no state leak beyond that window and (2) normal ZeRO-3 flow is unaffected outside this path. Thanks again, @tohtana. If you see a cleaner approach here, I’d really appreciate your suggestion. |
|
@nathon-lee thanks for this improved PR. Can you and @tohtana help clarify this discussion concerning parameter gather/release, as I think gather and release is orthogonal to your changes. Is this correct? |
|
Hi @sfc-gh-truwase, |
|
@tohtana thanks, I just noticed the relevant change. @nathon-lee is there a reason for skipping param release? If it is latency-related, I suggest reverting and addressing in a follow up, if needed. If it is correctness-related, then we can discuss more. Thanks! |
Signed-off-by: nathon-lee <leejianwoo@gmail.com> fix(zero3): defer retained param release and flush after follow-up backward Signed-off-by: nathon-lee <leejianwoo@gmail.com> fix: add retain_graph_state_clears_after_followup_backward and retained_backward_does_not_contaminate_next_step Signed-off-by: nathon-lee <leejianwoo@gmail.com> fix: fix some format errs by tool Signed-off-by: nathon-lee <leejianwoo@gmail.com>
…status assertions Signed-off-by: nathon-lee <leejianwoo@gmail.com>
…kward - Reset output backward preprocess state after each autograd graph completes. - Ensure preprocess_once runs once per backward graph task, including repeated backward on retained graphs. - Keep backward lifecycle consistent for manual scale(...).backward() flows. - Relax ZeRO-3 retained-graph status assertions from strict NOT_AVAILABLE to no INFLIGHT. Signed-off-by: nathon-lee <leejianwoo@gmail.com>
…st flow - Reset output-backward preprocess state at graph completion so retained-graph backward can re-enter preprocess/prologue correctly. - Keep preprocess_once execution scoped to each autograd graph task. - Relax ZeRO-3 retained-graph status checks to require no INFLIGHT params instead of forcing all NOT_AVAILABLE. - In cross-step contamination test, use engine.backward path for the retained pair to validate step isolation without manual-path lifecycle interference. Signed-off-by: nathon-lee <leejianwoo@gmail.com>
|
Thanks @sfc-gh-truwase and @tohtana for the helpful review comments. I pushed an update aligned with the focused lifecycle/test changes from nathon-lee#28. You are right that gather/release primitives themselves are orthogonal. Specifically: We do not keep parameters gathered indefinitely after the first backward. |
…te-backward-test fix(zero3): Add focused lifecycle coverage for the ZeRO retained-backward fix.
|
@nathon-lee thanks for the update. I am still confused about why we need to defer param release so that the second backward works. Since the gather hooks are attached to (sub)modules, my expectation is that the 2nd (or Nth) backward (or forward) should work correctly. |
|
@nathon-lee, @tohtana, my understanding of |
|
Sorry for the delayed reply, I’ve been a bit busy recently. |
Yes, I agree with that interpretation in general PyTorch semantics. |
|
I’ll walk through this issue in detail below. I added several unit tests and ran them on both branches (with and without this change). The branch without this change consistently reproduces the original issue. I’ll also explain what is going wrong and why this fix addresses it. Problem statement1) Master Branch Problem On master, the “same forward, two separate backward calls” pattern is broken in ZeRO-3:
This indicates two distinct issues:
2) How I fixed it I fixed this with two coordinated changes:
Why both are required:
3) Validation summary Your A/B results are consistent with the intended fix:
This validates:
4) Sequence Diagrams Before fix: engine.backward path sequenceDiagram
autonumber
participant U as User
participant E as Engine
participant OH as OutputHookManager
participant Z as ZeROOptimizer
participant PO as ParameterOffload
participant PC as ParamCoordinator
U->>E: forward() produces output, loss1, loss2
E->>OH: register output backward hooks
U->>E: zero_grad()
U->>E: backward(loss1, retain_graph=True)
E->>OH: tensor backward hook fires
OH->>E: preprocess_once_fn() runs once
E->>Z: enter backward context
Z->>PO: pre-backward hook
PO->>PC: fetch_sub_module(forward=False)
Z->>PO: post-backward hook
PO->>PC: release_sub_module(forward=False) (immediate)
U->>E: zero_grad()
U->>E: backward(loss2, retain_graph=False)
E->>OH: second backward on same forward graph
Note over OH: Hook exists, but preprocess state may already be consumed
Note over PC: Release/fetch lifecycle may be out of sync for second backward
Before fix: manual scale(...).backward path sequenceDiagram
autonumber
participant U as User
participant E as Engine
participant OH as OutputHookManager
participant Z as ZeROOptimizer
participant PO as ParameterOffload
participant PC as ParamCoordinator
U->>E: forward() produces output, loss1, loss2
E->>OH: register output backward hooks
U->>E: zero_grad()
U->>E: scale(loss1)
U->>OH: scaled_loss.backward(retain_graph=True)
OH->>E: preprocess_once_fn() runs
E->>Z: enter backward context via hook path
Z->>PO: pre-backward hook
PO->>PC: fetch_sub_module(forward=False)
Z->>PO: post-backward hook
PO->>PC: release_sub_module(forward=False) (immediate)
U->>E: zero_grad()
U->>E: scale(loss2)
U->>OH: scaled_loss.backward(retain_graph=False)
Note over E,OH: Manual backward depends heavily on hook lifecycle
Note over PO,PC: Without retain-aware release deferral, second backward can fail
After PR #8135: only the two new lines (graph-task preprocess + deferred release) sequenceDiagram
autonumber
participant U as User
participant OH as OutputHookManager
participant Z as ZeROOptimizer
participant PO as ParameterOffload
participant PC as ParamCoordinator
rect rgb(230,245,255)
Note over OH: New line A: preprocess once per graph task
U->>OH: backward #1 (retain_graph=True)
OH->>OH: read current_graph_task_id = G1
OH->>OH: run preprocess_once_fn for G1
OH->>OH: queue callback to reset state at graph completion
U->>OH: backward #2 on same forward graph
OH->>OH: read current_graph_task_id = G2
OH->>OH: G2 != G1, run preprocess_once_fn again
end
rect rgb(235,255,235)
Note over Z,PO: New line B: defer release during retained backward
U->>Z: set retain_graph_on_current_backward = True
PO->>Z: post-backward asks retain_graph_checker()
Z->>Z: defer_backward_release(release_fn), no immediate release
U->>Z: next backward with retain_graph=False
Z->>Z: flush_deferred_backward_releases()
Z->>PC: execute deferred release_sub_module calls
Z->>Z: clear retain_graph state
end
Unit testsDetails` @pytest.mark.parametrize("zero_stage", [1, 2, 3]) class TestZeroUserBackwardSeparateLoss(DistributedTest): """Test using separate loss functions""" world_size = 2@pytest.mark.parametrize("zero_stage", [3]) ` Test resultsmaster branch Details` root@15c31a442c5f:/workspace/DeepSpeed# git branch * master root@15c31a442c5f:/workspace/DeepSpeed# root@15c31a442c5f:/workspace/DeepSpeed# pytest -q tests/unit/v1/zero/test_zero_user_backward.py -k "two_losses_separate_backward_gas1 or preprocess_runs_once_per_backward_graph_task or retain_graph_defers_release_until_followup_backward" ========================================================== test session starts =========================================================== platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /usr/bin/python3.12 cachedir: .pytest_cache rootdir: /workspace/DeepSpeed/tests configfile: pytest.ini plugins: anyio-4.12.0 collected 62 items / 55 deselected / 7 selectedtests/unit/v1/zero/test_zero_user_backward.py::TestZeroUserBackwardSeparateLoss::test_two_losses_separate_backward_gas1[1] PASSED [ 14%] ================================================================ FAILURES ================================================================ The above exception was the direct cause of the following exception: item = <Function test_two_losses_separate_backward_gas1[3]>
tests/conftest.py:69: tests/unit/common.py:498: in call self = <multiprocessing.pool.MapResult object at 0x7bf1d7286b10>, timeout = 600
E RuntimeError: The size of tensor a (0) must match the size of tensor b (4) at non-singleton dimension 1 /usr/lib/python3.12/multiprocessing/pool.py:774: RuntimeError The above exception was the direct cause of the following exception: item = <Function test_preprocess_runs_once_per_backward_graph_task[1]>
tests/conftest.py:69: tests/unit/common.py:498: in call self = <multiprocessing.pool.MapResult object at 0x7bf1d71200b0>, timeout = 600
E AssertionError: expected 2 backward prologue calls for two backward graph tasks, got 1 /usr/lib/python3.12/multiprocessing/pool.py:774: AssertionError The above exception was the direct cause of the following exception: item = <Function test_preprocess_runs_once_per_backward_graph_task[2]>
tests/conftest.py:69: tests/unit/common.py:498: in call self = <multiprocessing.pool.MapResult object at 0x7bf1d71218b0>, timeout = 600
E AssertionError: expected 2 backward prologue calls for two backward graph tasks, got 1 /usr/lib/python3.12/multiprocessing/pool.py:774: AssertionError The above exception was the direct cause of the following exception: item = <Function test_preprocess_runs_once_per_backward_graph_task[3]>
tests/conftest.py:69: tests/unit/common.py:498: in call self = <multiprocessing.pool.MapResult object at 0x7bf1d7123440>, timeout = 600
E RuntimeError: The size of tensor a (0) must match the size of tensor b (4) at non-singleton dimension 1 /usr/lib/python3.12/multiprocessing/pool.py:774: RuntimeError The above exception was the direct cause of the following exception: item = <Function test_zero3_retain_graph_defers_release_until_followup_backward[3]>
tests/conftest.py:69: tests/unit/common.py:498: in call self = <multiprocessing.pool.MapResult object at 0x7bf1d7175190>, timeout = 600
E AssertionError: During retain_graph window, releasable ZeRO-3 params should remain AVAILABLE /usr/lib/python3.12/multiprocessing/pool.py:774: AssertionError unit/v1/zero/test_zero_user_backward.py::TestZeroUserBackwardSeparateLoss::test_two_losses_separate_backward_gas1[1] unit/v1/zero/test_zero_user_backward.py::TestZeroUserBackwardSeparateLoss::test_two_losses_separate_backward_gas1[1] unit/v1/zero/test_zero_user_backward.py::TestZeroUserBackwardSeparateLoss::test_two_losses_separate_backward_gas1[1] -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html (14 durations < 1s hidden.) Modified with PR8135 Details` root@15c31a442c5f:/workspace/DeepSpeed_woo# git branch * feature/zero-multi-loss-separate-backward master root@15c31a442c5f:/workspace/DeepSpeed_woo# root@15c31a442c5f:/workspace/DeepSpeed_woo# root@15c31a442c5f:/workspace/DeepSpeed_woo# pytest -q tests/unit/v1/zero/test_zero_user_backward.py -k "two_losses_separate_backward_gas1 or preprocess_runs_once_per_backward_graph_task or retain_graph_defers_release_until_followup_backward" ========================================================== test session starts =========================================================== platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /usr/bin/python3.12 cachedir: .pytest_cache rootdir: /workspace/DeepSpeed_woo/tests configfile: pytest.ini plugins: anyio-4.12.0 collected 66 items / 58 deselected / 8 selectedtests/unit/v1/zero/test_zero_user_backward.py::TestZeroUserBackwardSeparateLoss::test_two_losses_separate_backward_gas1[1] PASSED [ 12%] ============================================================ warnings summary ============================================================ unit/v1/zero/test_zero_user_backward.py::TestZeroUserBackwardSeparateLoss::test_two_losses_separate_backward_gas1[1] unit/v1/zero/test_zero_user_backward.py::TestZeroUserBackwardSeparateLoss::test_two_losses_separate_backward_gas1[1] unit/v1/zero/test_zero_user_backward.py::TestZeroUserBackwardSeparateLoss::test_two_losses_separate_backward_gas1[1] -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html (16 durations < 1s hidden.) |
Hi @tohtana, thank you for raising this concern. Great point, and I agree with your lifecycle concern. While gather/release primitives are orthogonal as operations, parameter lifetime is not. In particular, pre_sub_module_backward_function() gathers via fetch_sub_module(), so skipping the matching release_sub_module() must be tightly bounded. What this PR does is a bounded lifetime extension only within the retained-graph window: after the first backward with retain_graph=True, release is deferred so the follow-up backward on the same forward graph remains valid; |
Summary
This PR fixes retained-graph backward behavior for ZeRO when running two separate backward passes on the same forward graph.
Specifically, it addresses the sequence:
zero_grad()backward(loss1, retain_graph=True)zero_grad()backward(loss2)where ZeRO-3 previously failed on the second backward.
Issue: #7352
Background
The retained-graph path exposed two related lifecycle problems:
This is a correctness issue (not a latency optimization).
What changed
Graph-task-aware backward preprocess lifecycle
Deferred ZeRO-3 param release during retained backward
retain_graph=True, ZeRO-3 release is deferred within the retained window.Retained-backward lifecycle test hardening
Why this approach
Both fixes are required:
Validation
A/B branch comparison
I ran the same targeted retained-graph subset on:
Result:
Targeted tests covered
test_two_losses_separate_backward_gas1test_two_losses_separate_manual_backward_gas1test_preprocess_runs_once_per_backward_graph_tasktest_zero3_retain_graph_defers_release_until_followup_backward(Details and logs are included in the PR discussion.)
Risk / Follow-up
Notes
retain_graph=Truein core PyTorch semantics primarily keeps autograd graph/saved intermediates alive.cc @sfc-gh-truwase @tohtana