Skip to content

Commit b209861

Browse files
feat: implement wake-sleep API for trainer
1 parent 431d078 commit b209861

16 files changed

Lines changed: 923 additions & 11 deletions

File tree

d9d/core/offload/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Offloading of GPU-resident training state to host memory and back."""
2+
3+
from .api import DEFAULT_SLEEP_TAGS, Offloadable, OffloadContext, OnloadContext, SleepTag
4+
from .tensor import OffloadedTensor, offload_tensor, onload_tensor
5+
6+
__all__ = [
7+
"DEFAULT_SLEEP_TAGS",
8+
"OffloadContext",
9+
"Offloadable",
10+
"OffloadedTensor",
11+
"OnloadContext",
12+
"SleepTag",
13+
"offload_tensor",
14+
"onload_tensor",
15+
]

d9d/core/offload/api.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import dataclasses
2+
from enum import StrEnum
3+
from typing import Protocol, runtime_checkable
4+
5+
from d9d.core.dist_context import DistributedContext
6+
7+
8+
class SleepTag(StrEnum):
9+
"""
10+
Subsystem selector for Trainer.sleep and Trainer.wake.
11+
12+
Attributes:
13+
TENSOR_STATES: All GPU tensor state - model parameters and buffers, optimizer state,
14+
gradient buckets and the residual loss accumulator. Always offloaded as a unit.
15+
COMMS: NCCL process groups. Opt-in; its implementation is deferred to a second phase,
16+
so requesting it currently raises NotImplementedError.
17+
"""
18+
19+
TENSOR_STATES = "tensor_states"
20+
COMMS = "comms"
21+
22+
23+
DEFAULT_SLEEP_TAGS = frozenset({SleepTag.TENSOR_STATES})
24+
"""The default tag set for Trainer.sleep and Trainer.wake: tensor state only, no comms."""
25+
26+
27+
@dataclasses.dataclass(kw_only=True, frozen=True)
28+
class OffloadContext:
29+
"""
30+
Context passed to Offloadable.offload.
31+
32+
Attributes:
33+
dist_context: The distributed context the subsystem was built under.
34+
pin_memory: Whether to allocate the host buffer in pinned memory.
35+
"""
36+
37+
dist_context: DistributedContext
38+
pin_memory: bool
39+
40+
41+
@dataclasses.dataclass(kw_only=True, frozen=True)
42+
class OnloadContext:
43+
"""
44+
Context passed to Offloadable.onload.
45+
46+
Attributes:
47+
dist_context: The distributed context the subsystem was built under.
48+
"""
49+
50+
dist_context: DistributedContext
51+
52+
53+
@runtime_checkable
54+
class Offloadable(Protocol):
55+
"""
56+
Protocol for subsystems that own GPU-resident state and can release it to host memory.
57+
58+
An "offload" followed by an "onload" must be observationally a no-op: parameter identities,
59+
optimizer state keys, DTensor wrapper instances, placements and dtypes are all preserved
60+
across the round trip. Only the underlying device storages are reallocated.
61+
"""
62+
63+
def offload(self, ctx: OffloadContext) -> None:
64+
"""
65+
Releases the GPU memory owned by this subsystem, moving its state to host memory.
66+
67+
Args:
68+
ctx: Context for this operation.
69+
"""
70+
71+
def onload(self, ctx: OnloadContext) -> None:
72+
"""
73+
Restores GPU residency of the state previously released by "offload".
74+
75+
Args:
76+
ctx: Context for this operation.
77+
"""
78+
79+
def is_offloaded(self) -> bool:
80+
"""
81+
Reports whether this subsystem currently has its state on host memory.
82+
83+
Returns:
84+
True if the subsystem is offloaded, False otherwise.
85+
"""

d9d/core/offload/tensor.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import dataclasses
2+
3+
import torch
4+
from torch.distributed.tensor import DTensor
5+
6+
7+
@dataclasses.dataclass(slots=True, frozen=True)
8+
class OffloadedTensor:
9+
"""
10+
Handle to a tensor whose local storage has been swapped to host memory.
11+
12+
Produced by "offload_tensor" and consumed by "onload_tensor". Subsystems hold these as
13+
opaque handles between an offload and the matching onload.
14+
15+
Attributes:
16+
host: The host-memory mirror that currently backs the offloaded tensor's local storage.
17+
"""
18+
19+
host: torch.Tensor
20+
21+
22+
def _local_storage_holder(tensor: torch.Tensor) -> torch.Tensor:
23+
"""Returns the storage-bearing tensor: the DTensor's local shard, or the tensor itself."""
24+
return tensor._local_tensor if isinstance(tensor, DTensor) else tensor # noqa: SLF001
25+
26+
27+
def offload_tensor(tensor: torch.Tensor, *, pin_memory: bool) -> OffloadedTensor:
28+
"""
29+
Swaps "tensor"'s local storage for a host-memory mirror, in place.
30+
31+
For a DTensor, the wrapper instance is preserved across the swap - only the local shard's
32+
underlying storage is rebound, via "_local_tensor.data". "device_mesh", "placements",
33+
global "shape" and global "stride" live on the wrapper and never leave it, so any
34+
external reference to the DTensor keeps pointing at the same object. For a plain tensor,
35+
"tensor.data" itself is rebound.
36+
37+
Args:
38+
tensor: The device tensor to offload. May be a plain tensor or a DTensor.
39+
pin_memory: Whether to allocate the host buffer in pinned memory.
40+
41+
Returns:
42+
A handle holding the host buffer; pass back to "onload_tensor" with the same tensor.
43+
"""
44+
45+
local = _local_storage_holder(tensor)
46+
host = torch.empty_like(local, device="cpu", pin_memory=pin_memory)
47+
host.copy_(local, non_blocking=True)
48+
local.data = host
49+
return OffloadedTensor(host=host)
50+
51+
52+
def onload_tensor(tensor: torch.Tensor, offloaded: OffloadedTensor, *, device: torch.device) -> None:
53+
"""
54+
Restores "tensor"'s local storage to "device", in place, from a host buffer.
55+
56+
The tensor object (and, for a DTensor, its wrapper) is the same instance as before the
57+
offload; only the underlying device storage is freshly allocated.
58+
59+
Args:
60+
tensor: The same tensor previously passed to "offload_tensor".
61+
offloaded: The handle returned by "offload_tensor".
62+
device: The accelerator device to restore the local storage onto.
63+
"""
64+
65+
local = _local_storage_holder(tensor)
66+
fresh = torch.empty_like(local, device=device)
67+
fresh.copy_(offloaded.host, non_blocking=True)
68+
local.data = fresh

d9d/loop/component/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .stepper import Stepper
1414
from .task_operator import ForwardResult, InferenceTaskOperator, TrainTaskOperator
1515
from .timeout_manager import TimeoutManager
16+
from .train_sleeper import TrainSleeper
1617

1718
__all__ = [
1819
"BatchMaths",
@@ -34,5 +35,6 @@
3435
"Stepper",
3536
"TimeoutManager",
3637
"TrackedModules",
38+
"TrainSleeper",
3739
"TrainTaskOperator",
3840
]

d9d/loop/component/gradient_manager.py

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from torch.distributed.tensor import DTensor
55

66
from d9d.core.dist_context import DistributedContext
7+
from d9d.core.offload import Offloadable, OffloadContext, OnloadContext
78
from d9d.internals.grad_sync import GradientSynchronizer
89
from d9d.loop.config import GradientManagerConfig
910
from d9d.metric.impl.aggregation import WeightedMeanMetric
@@ -12,7 +13,7 @@
1213
from .model_stage_factory import TrackedModules
1314

1415

15-
class GradientManager:
16+
class GradientManager(Offloadable):
1617
"""
1718
Manages the lifecycle of gradients during the training loop.
1819
@@ -53,6 +54,10 @@ def __init__(
5354
)
5455
self._grads_to_scale: list[torch.Tensor] | None = None
5556

57+
self._installed = False
58+
self._offloaded = False
59+
self._in_flight_count = 0
60+
5661
def _setup_grad_dtype(self):
5762
if self._config.grad_dtype is None:
5863
return
@@ -85,6 +90,15 @@ def _scale_grads(self):
8590
if len(self._grads_to_scale) > 0:
8691
torch._foreach_mul_(self._grads_to_scale, scale_factor)
8792

93+
def _bind(self):
94+
self._setup_grad_dtype()
95+
self._grad_sync.bind()
96+
self._bind_grads_to_scale()
97+
98+
def _unbind(self):
99+
self._unbind_grads_to_scale()
100+
self._grad_sync.unbind()
101+
88102
@contextmanager
89103
def install(self):
90104
"""
@@ -95,12 +109,11 @@ def install(self):
95109
as the boundary for the accumulation phase.
96110
"""
97111

98-
self._setup_grad_dtype()
99-
self._grad_sync.bind()
100-
self._bind_grads_to_scale()
112+
self._bind()
113+
self._installed = True
101114
yield
102-
self._unbind_grads_to_scale()
103-
self._grad_sync.unbind()
115+
self._installed = False
116+
self._unbind()
104117

105118
def add_loss_with_weight(self, loss: torch.Tensor, loss_weight: torch.Tensor):
106119
"""
@@ -112,6 +125,7 @@ def add_loss_with_weight(self, loss: torch.Tensor, loss_weight: torch.Tensor):
112125
"""
113126

114127
self._loss.update(loss, loss_weight)
128+
self._in_flight_count += 1
115129

116130
def sync_and_scale(self):
117131
"""
@@ -151,3 +165,62 @@ def zero_grad(self):
151165

152166
self._grad_sync.zero_grad()
153167
self._loss.reset()
168+
self._in_flight_count = 0
169+
170+
@property
171+
def has_in_flight_gradients(self) -> bool:
172+
"""
173+
Checks whether a gradient accumulation is currently in flight.
174+
175+
The counter is raised by "add_loss_with_weight" and reset by "zero_grad". While it is
176+
non-zero, partial accumulation state lives in the synchronizer buckets, so offloading
177+
the gradient state would lose it.
178+
"""
179+
180+
return self._in_flight_count > 0
181+
182+
def offload(self, ctx: OffloadContext) -> None:
183+
"""
184+
Releases the GPU memory of the gradient state to host memory.
185+
186+
The synchronizer bucket buffers are released and the residual loss accumulator is reset.
187+
188+
Args:
189+
ctx: Context for this operation.
190+
191+
Raises:
192+
RuntimeError: If the gradient state is already offloaded, or if the manager has
193+
not been installed.
194+
"""
195+
196+
if self._offloaded:
197+
raise RuntimeError("GradientManager is already offloaded.")
198+
if not self._installed:
199+
raise RuntimeError("GradientManager must be installed before it can be offloaded.")
200+
201+
self._unbind()
202+
self._loss.reset()
203+
self._offloaded = True
204+
205+
def onload(self, ctx: OnloadContext) -> None:
206+
"""
207+
Restores GPU residency of the gradient state released by "offload".
208+
209+
Args:
210+
ctx: Context for this operation.
211+
212+
Raises:
213+
RuntimeError: If the gradient state is not offloaded.
214+
"""
215+
216+
if not self._offloaded:
217+
raise RuntimeError("GradientManager is not offloaded.")
218+
219+
if self._installed:
220+
self._bind()
221+
222+
self._offloaded = False
223+
224+
def is_offloaded(self) -> bool:
225+
"""Reports whether the gradient state is currently on host memory."""
226+
return self._offloaded

0 commit comments

Comments
 (0)