File size: 11,140 Bytes
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
"""ZeRO-2 sharding of the backbone's fp32 master weights, AdamW moments and gradients
across data-parallel ranks (SPEC.md §12.9).

The trainer keeps bf16 compute weights on every rank (the functional backbone calls its
layers' weights directly, so they must be whole on the device) and, without sharding, a
full fp32 master copy + two AdamW moments + an fp32 gradient: 16 bytes per trainable
backbone parameter, ~22 GB for Qwen3.5-2B, more than an L4 holds. :class:`ZeroMaster`
splits those 16 bytes over ``world`` ranks:

* The trainable parameters are laid end to end in a flat vector of ``N`` elements cut
  into buckets (``bucket`` elements, the last one padded to a multiple of ``world``).
  Rank ``r`` owns the ``r``-th of ``world`` equal pieces of every bucket; its shard is
  those pieces concatenated (``shard_len`` ~ ``N / world``).
* :attr:`master` is ONE fp32 parameter (the shard) that AdamW updates; the optimizer's
  moments are shard-sized. Element-wise AdamW over a flat tensor is the same arithmetic
  as over the separate tensors (same decay everywhere, as the unsharded trainer).
* A step's bf16 gradients stay on the parameters' ``.grad`` until the step's last part
  (:meth:`collect`): a one-part step keeps them where autograd put them (no copy); a
  several-part step accumulates them in a host fp32 vector (no second device copy).
  :meth:`reduce_scatter` then sums every rank's gradient into the owners' fp32 shard
  (bucket by bucket, fp32), so :attr:`master` ``.grad`` holds the shard of the sum.
* After ``opt.step()``, :meth:`gather_params` rounds each rank's updated shard to bf16
  and all-gathers it into every rank's compute weights: bitwise the unsharded trainer's
  ``p.copy_(master)``.

``sim_world`` (smoke tests on one GPU): shard as rank 0 of a ``sim_world``-rank job with
no collectives, so memory and step time are the real per-rank ones; the other ranks'
pieces are simply not updated (their weights stay as loaded). Never for real training.
"""

from __future__ import annotations

import bisect
import math
from typing import Iterator

import torch
from torch import Tensor, nn

BUCKET = 1 << 25  # 32M elements: 128 MB fp32 per collective


def _dist():
    import torch.distributed as dist

    return dist


class ZeroMaster:
    def __init__(self, params: list[nn.Parameter], rank: int, world: int,
                 bucket: int = BUCKET, sim_world: int = 0) -> None:
        if not params:
            raise ValueError("ZeroMaster needs trainable parameters")
        self.params = params
        self.sim = sim_world > 1
        self.world = sim_world if self.sim else world
        self.rank = 0 if self.sim else rank
        self.comm = world > 1 and not self.sim
        self.numel = [p.numel() for p in params]
        self.offsets = [0]
        for n in self.numel:
            self.offsets.append(self.offsets[-1] + n)
        self.N = self.offsets[-1]
        w = self.world
        bucket = max(w, bucket - bucket % w)
        # (start, end, padded length) of every bucket in the flat vector
        self.buckets: list[tuple[int, int, int]] = []
        for s in range(0, self.N, bucket):
            e = min(self.N, s + bucket)
            self.buckets.append((s, e, math.ceil((e - s) / w) * w))
        self.shard_len = sum(L // w for _, _, L in self.buckets)
        dev = params[0].device
        self.device = dev
        shard = torch.empty(self.shard_len, dtype=torch.float32, device=dev)
        o = 0
        for b, (s, e, L) in enumerate(self.buckets):
            piece = L // w
            shard[o:o + piece] = self._bucket_values(b)[self.rank * piece:(self.rank + 1) * piece]
            o += piece
        self.master = nn.Parameter(shard)
        self.grad_buf = torch.zeros_like(shard)  # resident: the shard's fp32 gradient
        self.held: list[Tensor | None] | None = None  # one-part step: the bf16 grads
        self.host: Tensor | None = None  # several-part step: fp32 sum on the host
        self._host_buf: Tensor | None = None  # kept once allocated (pinned)
        self.spills = 0

    # ---------------------------------------------------------------- flat layout

    def _segments(self, s: int, e: int) -> Iterator[tuple[int, int, int, int]]:
        """(param index, offset in param, offset in [s, e), length) covering [s, e)."""
        i = bisect.bisect_right(self.offsets, s) - 1
        while i < len(self.numel) and self.offsets[i] < e:
            a = max(s, self.offsets[i])
            b = min(e, self.offsets[i + 1])
            yield i, a - self.offsets[i], a - s, b - a
            i += 1

    def _bucket_values(self, b: int) -> Tensor:
        s, e, L = self.buckets[b]
        out = torch.zeros(L, dtype=torch.float32, device=self.device)
        for i, po, bo, n in self._segments(s, e):
            out[bo:bo + n] = self.params[i].detach().reshape(-1)[po:po + n].float()
        return out

    def _pieces(self) -> Iterator[tuple[int, int, int, int, int]]:
        """(bucket start, end, padded length, piece length, shard offset)."""
        o = 0
        for s, e, L in self.buckets:
            piece = L // self.world
            yield s, e, L, piece, o
            o += piece

    # ---------------------------------------------------------------- gradients

    @property
    def has_grad(self) -> bool:
        return self.held is not None or self.host is not None

    def collect(self, more: bool) -> None:
        """Take the parameters' current ``.grad`` (one part's bf16 gradient) into the
        step's sum and clear them. ``more``: further parts of this step will run, so the
        sum moves to the host (the next part's backward then has the device memory)."""
        grads = [p.grad for p in self.params]
        for p in self.params:
            p.grad = None
        if all(g is None for g in grads):
            return
        if self.held is None and self.host is None and not more:
            self.held = grads
            return
        if self.host is None:
            if self._host_buf is None:
                self._host_buf = torch.zeros(self.N, dtype=torch.float32,
                                             pin_memory=self.device.type == "cuda")
            else:
                self._host_buf.zero_()
            self.host = self._host_buf
            self.spills += 1
            if self.held is not None:
                self._to_host(self.held)
                self.held = None
        self._to_host(grads)

    def _to_host(self, grads: list[Tensor | None]) -> None:
        assert self.host is not None
        for i, g in enumerate(grads):
            if g is not None:
                o = self.offsets[i]
                self.host[o:o + self.numel[i]] += g.detach().reshape(-1).float().cpu()

    def discard(self) -> None:
        self.held = None
        self.host = None
        for p in self.params:
            p.grad = None
        self.master.grad = None

    def reduce_scatter(self) -> None:
        """Sum the step's gradient over ranks into :attr:`master` ``.grad`` (this rank's
        shard, fp32). A rank without gradient contributes zeros."""
        held, host = self.held, self.host
        self.held = self.host = None
        for s, e, L, piece, o in self._pieces():
            buf = torch.zeros(L, dtype=torch.float32, device=self.device)
            if host is not None:
                buf[:e - s] = host[s:e].to(self.device, non_blocking=True)
            elif held is not None:
                for i, po, bo, n in self._segments(s, e):
                    g = held[i]
                    if g is not None:
                        buf[bo:bo + n] = g.reshape(-1)[po:po + n].float()
            out = self.grad_buf[o:o + piece]
            if self.comm:
                _reduce_scatter(out, buf)
            else:
                out.copy_(buf[self.rank * piece:(self.rank + 1) * piece])
            del buf
        del held, host
        self.master.grad = self.grad_buf

    def grad_sq_norm(self) -> Tensor:
        """Squared L2 norm of the whole (summed) gradient, identical on every rank."""
        sq = self.grad_buf.pow(2).sum()
        if self.comm:
            _dist().all_reduce(sq)
        return sq

    # ---------------------------------------------------------------- weights

    @torch.no_grad()
    def gather_params(self) -> None:
        """Every rank's updated shard -> bf16 -> the compute weights of every rank."""
        dtype = self.params[0].dtype
        for s, e, L, piece, o in self._pieces():
            mine = self.master[o:o + piece].to(dtype)
            if self.comm:
                full = torch.empty(L, dtype=dtype, device=self.device)
                _all_gather(full, mine)
                lo, hi = 0, e - s
            else:  # one process: only this rank's piece is known
                full = torch.zeros(L, dtype=dtype, device=self.device)
                full[self.rank * piece:(self.rank + 1) * piece] = mine
                lo = self.rank * piece
                hi = min(e - s, lo + piece)
            for i, po, bo, n in self._segments(s, e):
                a, b = max(bo, lo), min(bo + n, hi)
                if a < b:
                    self.params[i].view(-1)[po + a - bo:po + b - bo] = full[a:b]

    @torch.no_grad()
    def full_master(self) -> Tensor:
        """The whole fp32 master vector (all ranks; tests)."""
        parts = []
        for s, e, L, piece, o in self._pieces():
            mine = self.master[o:o + piece].detach()
            if self.comm:
                full = torch.empty(L, dtype=mine.dtype, device=self.device)
                _all_gather(full, mine.contiguous())
            else:
                full = mine
            parts.append(full[:e - s])
        return torch.cat(parts)


def _backend() -> str:
    return str(_dist().get_backend())


def _reduce_scatter(out: Tensor, inp: Tensor) -> None:
    dist = _dist()
    if _backend() == "nccl":
        dist.reduce_scatter_tensor(out, inp)
    else:  # gloo (CPU tests): all-reduce, keep this rank's piece
        dist.all_reduce(inp)
        r, n = dist.get_rank(), out.numel()
        out.copy_(inp[r * n:(r + 1) * n])


def _all_gather(out: Tensor, inp: Tensor) -> None:
    dist = _dist()
    if _backend() == "nccl":
        dist.all_gather_into_tensor(out, inp)
    else:
        dist.all_gather(list(out.chunk(dist.get_world_size())), inp)


def sharded_static_bytes(trainable_backbone: int, heads: int, weights: int, world: int
                         ) -> float:
    """:func:`deg.memplan.static_bytes` with ZeRO-2: bf16 weights + bf16 grads whole,
    fp32 master + AdamW moments + fp32 gradient of the backbone per shard, heads whole."""
    return (weights + 2.0 * trainable_backbone + 16.0 * trainable_backbone / world
            + 16.0 * heads)


def broadcast_module(module: nn.Module, src: int = 0) -> None:
    """Every parameter and buffer of ``module`` from rank ``src`` (fresh heads are drawn
    from per-rank seeds; replicas must start identical)."""
    dist = _dist()
    with torch.no_grad():
        for t in [*module.parameters(), *module.buffers()]:
            dist.broadcast(t.data, src)