File size: 13,718 Bytes
05182f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
"""FSDP readers/writers and one scalar EMA; no implicit fabric overrides."""

import os
import tempfile
from datetime import timedelta
from functools import partial

import torch
import torch.distributed as dist
from torch.distributed.checkpoint.state_dict import StateDictOptions, get_model_state_dict
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp import MixedPrecision, ShardingStrategy
from torch.distributed.fsdp.wrap import size_based_auto_wrap_policy

from src.utils.checkpoint_state import (
    normalize_generator_fsdp_key,
    normalize_generator_fsdp_state_dict,
)


def launch_distributed_job():
    """Direct Python uses a local process group; torchrun uses its own rendezvous."""
    local_rank = int(os.environ.get("LOCAL_RANK", "0"))
    torch.cuda.set_device(local_rank)
    if dist.is_initialized():
        return
    kwargs = {
        "backend": "nccl",
        "device_id": torch.device("cuda", local_rank),
        "timeout": timedelta(minutes=30),
    }
    if "RANK" in os.environ:
        dist.init_process_group(**kwargs)
    else:
        descriptor, filename = tempfile.mkstemp(prefix="looking-ahead-pg-")
        os.close(descriptor)
        os.unlink(filename)
        dist.init_process_group(init_method=f"file://{filename}", rank=0, world_size=1, **kwargs)


def barrier():
    if dist.is_initialized():
        dist.barrier()


def is_main_process():
    return not dist.is_initialized() or dist.get_rank() == 0


def fsdp_state_dict(model):
    """All ranks collect; only global rank zero retains ordinary weight tensors."""
    checkpoint = get_model_state_dict(
        model, options=StateDictOptions(full_state_dict=True, cpu_offload=True)
    )
    return checkpoint if is_main_process() else {}


def fsdp_wrap(module, *, sharding_strategy, mixed_precision, sync_module_states=False):
    precision = (
        MixedPrecision(
            param_dtype=torch.bfloat16,
            reduce_dtype=torch.float32,
            buffer_dtype=torch.float32,
            cast_forward_inputs=False,
        )
        if mixed_precision
        else None
    )
    strategy = {
        "full": ShardingStrategy.FULL_SHARD,
        "hybrid_full": ShardingStrategy.HYBRID_SHARD,
        "hybrid_zero2": ShardingStrategy._HYBRID_SHARD_ZERO2,
        "no_shard": ShardingStrategy.NO_SHARD,
    }[sharding_strategy]
    return FSDP(
        module,
        auto_wrap_policy=partial(size_based_auto_wrap_policy, min_num_params=int(5e7)),
        sharding_strategy=strategy,
        mixed_precision=precision,
        device_id=torch.cuda.current_device(),
        limit_all_gathers=True,
        use_orig_params=True,
        sync_module_states=sync_module_states,
    )


def _fsdp_sharding_strategy(module: torch.nn.Module):
    strategy = getattr(module, "sharding_strategy", None)
    if strategy is not None:
        return strategy
    fsdp_state = getattr(module, "_fsdp_state", None)
    if fsdp_state is not None:
        return getattr(fsdp_state, "sharding_strategy", None)
    return None


def _effective_no_shard(module: torch.nn.Module) -> bool:
    """Return true when all FSDP wrappers use effective ``NO_SHARD``.

    A plain module is treated like ``NO_SHARD`` for EMA serialization helpers;
    this keeps the direct save/load path testable without requiring CUDA/FSDP.
    """
    fsdp_modules = [submodule for submodule in module.modules() if isinstance(submodule, FSDP)]
    if not fsdp_modules:
        return True
    return all(
        _fsdp_sharding_strategy(submodule) == ShardingStrategy.NO_SHARD
        for submodule in fsdp_modules
    )


class EMA_FSDP:
    """
    Exponential Moving Average for an FSDP-wrapped model.

    Design notes
    ------------
    * **Sharded, on-GPU shadow.** Each rank stores an EMA shadow of *only its
      own local shard* of every ``flat_param`` (FSDP1) / ``DTensor`` (FSDP2),
      keeping the buffer in fp32 on the same device as the live parameter.
      This means ``update`` is a pure local elementwise op -- no
      ``summon_full_params``, no all-gather, no host-device traffic, no
      cross-rank synchronization -- so it is essentially free compared to
      the optimizer step.
    * **Collective only on save.** ``full_state_dict`` is the only path that
      materializes full EMA tensors for checkpointing. Sharded FSDP uses
      ``summon_full_params``; effective ``NO_SHARD`` returns the already-full
      local EMA shadows directly. It should only run at checkpoint time, never
      per-step.
    * Non-floating-point buffers (e.g. ``num_batches_tracked``) are never
      averaged.
    """

    def __init__(
        self,
        fsdp_module: torch.nn.Module,
        decay: float = 0.999,
    ):
        self.decay = decay
        self.num_updates = 0

        # name -> fp32 tensor on the *same device & with the same local
        # shape* as the corresponding live (sharded) parameter. Each rank
        # only holds its own shard.
        self.shadow: dict = {}
        self._is_main = is_main_process()

        self._init_shadow(fsdp_module)

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------
    def _iter_named_tensors(self, module):
        yield from module.named_parameters()

    # ------------------------------------------------------------------
    # Initialization
    # ------------------------------------------------------------------
    @torch.no_grad()
    def _init_shadow(self, fsdp_module):
        # No collective: each rank just clones its own shard in fp32. For
        # FSDP1 the local shard is a 1-D ``flat_param`` slice; for FSDP2 it
        # is a ``DTensor`` whose ``_local_tensor`` we read transparently
        # (``detach().clone()`` on a DTensor preserves the DTensor wrapping,
        # which is exactly what we want for arithmetic later).
        for n, t in self._iter_named_tensors(fsdp_module):
            self.shadow[n] = t.detach().to(dtype=torch.float32).clone()

    # ------------------------------------------------------------------
    # Update -- per-step hot path
    # ------------------------------------------------------------------
    @torch.no_grad()
    def update(self, fsdp_module):
        """Per-rank local EMA update. **No** collective communication.

        Cost is one fp32 mul_+add_ over the local shard, which is dominated
        by memory bandwidth and negligible next to the optimizer step.
        """
        self.num_updates += 1
        d = self.decay
        one_minus_d = 1.0 - d

        for n, t in self._iter_named_tensors(fsdp_module):
            shadow = self.shadow.get(n)
            if shadow is None:
                # New / unseen parameter -- initialize on the fly.
                self.shadow[n] = t.detach().to(dtype=torch.float32).clone()
                continue
            # Cast live param to fp32 *on device* (cheap if already fp32,
            # otherwise a fused kernel). No host transfer, no collective.
            src = t.detach()
            if src.dtype != torch.float32:
                src = src.to(dtype=torch.float32)
            shadow.mul_(d).add_(src, alpha=one_minus_d)

    # ------------------------------------------------------------------
    # (De)serialization helpers
    # ------------------------------------------------------------------
    def state_dict(self):
        """Return the *local-shard* shadow on every rank.

        Note: this is **not** a full state dict. Use :meth:`full_state_dict`
        if you want the rank-0-only fully-gathered version (e.g. for
        checkpointing). Each rank's tensors live on its own device.
        """
        return self.shadow

    @torch.no_grad()
    def load_full_state_dict(
        self, full_sd, fsdp_module, *, allow_missing_aux=False, expected_shapes=None
    ):
        """Restore ordinary EMA from each rank's validated full source state.

        All-rank writeback avoids PyTorch's unsupported rank0-only writeback.
        The temporary swap restores the original independent live shards.
        """
        full_sd = normalize_generator_fsdp_state_dict(full_sd)
        expected = {
            normalize_generator_fsdp_key(name): tensor
            for name, tensor in self._iter_named_tensors(fsdp_module)
        }
        missing = set(expected) - set(full_sd)
        unexpected = set(full_sd) - set(expected)
        if unexpected or (missing and not allow_missing_aux):
            raise RuntimeError("EMA checkpoint has missing or unexpected parameter keys.")
        if allow_missing_aux:
            from src.utils.predictive_training_checkpoint import is_predictive_aux_key

            if any(not is_predictive_aux_key(k) for k in missing):
                raise RuntimeError("EMA checkpoint is missing backbone parameters")
        if expected_shapes is not None:
            for name, value in full_sd.items():
                if tuple(value.shape) != expected_shapes[name]:
                    raise RuntimeError(f"EMA checkpoint shape mismatch: {name}")
        if _effective_no_shard(fsdp_module):
            for name, tensor in expected.items():
                if name in full_sd and full_sd[name].shape != tensor.shape:
                    raise RuntimeError(f"EMA checkpoint shape mismatch: {name}")
            new_shadow = {}
            for n, t in self._iter_named_tensors(fsdp_module):
                canonical_name = normalize_generator_fsdp_key(n)
                src = full_sd.get(canonical_name, t)
                new_shadow[n] = (
                    src.detach()
                    .to(
                        device=t.device,
                        dtype=torch.float32,
                    )
                    .clone()
                )
            self.shadow = new_shadow
            return

        # Backup live shards (each rank saves its own).
        backup = {n: t.data.clone() for n, t in self._iter_named_tensors(fsdp_module)}

        try:
            with FSDP.summon_full_params(
                fsdp_module, writeback=True, rank0_only=False, offload_to_cpu=False
            ):
                for name, tensor in self._iter_named_tensors(fsdp_module):
                    canonical_name = normalize_generator_fsdp_key(name)
                    if canonical_name in full_sd:
                        tensor.copy_(
                            full_sd[canonical_name].to(dtype=tensor.dtype, device=tensor.device)
                        )
            new_shadow = {
                name: tensor.detach().to(dtype=torch.float32).clone()
                for name, tensor in self._iter_named_tensors(fsdp_module)
            }
            self.shadow = new_shadow
        finally:
            for name, tensor in self._iter_named_tensors(fsdp_module):
                tensor.data.copy_(backup[name])

    @torch.no_grad()
    def full_state_dict(self, fsdp_module):
        """Return the *full* EMA state dict on rank 0 (empty dict elsewhere).

        This should only be used at checkpoint time. Effective ``NO_SHARD``
        returns the already-full EMA shadows directly. Sharded FSDP temporarily
        swaps the live FSDP parameters for the EMA shadows, summons them in
        full on rank 0, copies them out, then restores the live weights -- all
        without persisting any change.
        """
        if _effective_no_shard(fsdp_module):
            if not self._is_main:
                return {}
            return {
                normalize_generator_fsdp_key(n): shadow.detach()
                .to("cpu", dtype=torch.float32)
                .clone()
                for n, shadow in self.shadow.items()
            }

        # Clone the live local shards before copying EMA into their storage.
        # ``use_orig_params=True`` exposes views into FSDP's FlatParameter, so
        # retaining ``t.data`` here would only retain an alias. FSDP may reuse
        # and overwrite that storage while summoning full parameters, turning
        # an apparent restore into an accidental live <- EMA switch.
        backup = {}
        for n, t in self._iter_named_tensors(fsdp_module):
            shadow = self.shadow.get(n)
            if shadow is None:
                continue
            backup[n] = t.detach().clone()
            t.copy_(shadow.to(device=t.device, dtype=t.dtype), non_blocking=True)

        # CPU FSDP parameters are already host-resident. Asking PyTorch to
        # offload a rank0-only summon back to the same device can hang under
        # Gloo, while CUDA training still benefits from the original offload.
        offload_to_cpu = any(t.device.type != "cpu" for t in backup.values())

        try:
            with FSDP.summon_full_params(
                fsdp_module,
                writeback=False,
                rank0_only=True,
                offload_to_cpu=offload_to_cpu,
            ):
                if not self._is_main:
                    out = {}
                else:
                    out = {}
                    for n, t in self._iter_named_tensors(fsdp_module):
                        out[normalize_generator_fsdp_key(n)] = (
                            t.detach().to(device="cpu", dtype=torch.float32).clone()
                        )
        finally:
            # Restore the independent live-shard copies regardless of what
            # happened above. Copying into the post-context parameter views
            # preserves FSDP's storage/view invariants.
            for n, t in self._iter_named_tensors(fsdp_module):
                if n in backup:
                    t.copy_(backup[n], non_blocking=True)

        return out