File size: 8,130 Bytes
309b968
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""DaisyChain cluster core — data-parallel CPU/GPU training across spare machines.

Design: distribute the *parallel* axis (the batch) across nodes; keep each node's
work local. Every node holds a full model replica and trains on its shard; a
capacity-weighted gradient all-reduce combines them into the exact full-batch
gradient, so replicas stay bit-identical.

  - Each node uses ~90% of its cores (and its GPU if it has one).
  - Capacity is MEASURED (matmuls/sec) so a strong node auto-takes a bigger
    batch. Gradients are reduced on CPU copies, so CPU and GPU nodes mix.

Pools compute, not memory: the model must fit on one node. Honest limits are in
docs/LIMITS.md.
"""
from __future__ import annotations

import json
import os
import socket
import time

import torch
import torch.distributed as dist


# ---------------------------------------------------------------- resources ---
def configure_cpu(fraction: float = 0.9) -> int:
    cores = os.cpu_count() or 1
    n = max(1, int(round(cores * fraction)))
    torch.set_num_threads(n)
    return n


def pick_device() -> "torch.device":
    if os.environ.get("DAISY_FORCE_CPU") == "1":
        return torch.device("cpu")
    return torch.device("cuda" if torch.cuda.is_available() else "cpu")


def _gpu_info():
    if not torch.cuda.is_available():
        return None
    p = torch.cuda.get_device_properties(0)
    return {"name": p.name, "vram_gb": round(p.total_memory / 1e9, 1),
            "capability": f"{p.major}.{p.minor}"}


def _available_ram_gb():
    try:
        import psutil
        return round(psutil.virtual_memory().available / 1e9, 1)
    except Exception:
        return None


def capacity_score(device=None, secs: float = 0.3) -> float:
    """Measured throughput: fixed matmuls/sec on the local device. Self-calibrating
    (a GPU scores far higher), so capacity weighting hands it a bigger batch."""
    dev = device or pick_device()
    try:
        a = torch.randn(512, 512, device=dev)
        b = torch.randn(512, 512, device=dev)
        _ = a @ b
        if dev.type == "cuda":
            torch.cuda.synchronize()
        t0, it = time.time(), 0
        while time.time() - t0 < secs:
            a = a @ b
            it += 1
        if dev.type == "cuda":
            torch.cuda.synchronize()
        return it / (time.time() - t0)
    except Exception:
        return float(os.cpu_count() or 1)


def survey_node(cpu_fraction: float = 0.9, measure: bool = True) -> dict:
    cores = int(os.environ.get("DAISY_CORES", os.cpu_count() or 1))
    dev = pick_device()
    gpu = _gpu_info() if dev.type == "cuda" else None
    if "DAISY_CAPACITY" in os.environ:
        cap = float(os.environ["DAISY_CAPACITY"])
    elif measure:
        cap = capacity_score(dev)
    else:
        cap = float(cores)
    return {"host": socket.gethostname(), "cores": cores,
            "threads": max(1, int(round(cores * cpu_fraction))),
            "ram_gb": _available_ram_gb(), "device": dev.type,
            "gpu": gpu, "capacity": cap}


# ------------------------------------------------------------------ cluster ---
def init_cluster(backend: str = "gloo"):
    os.environ.setdefault("USE_LIBUV", "0")
    if not dist.is_initialized():
        dist.init_process_group(backend=backend)
    return dist.get_rank(), dist.get_world_size()


def cluster_plan(cpu_fraction: float = 0.9, base_batch: int = 32) -> dict:
    rank, world = dist.get_rank(), dist.get_world_size()
    me = survey_node(cpu_fraction)
    gathered = [None] * world
    dist.all_gather_object(gathered, me)

    caps = [float(g.get("capacity") or g["cores"]) for g in gathered]
    total_cap = sum(caps) or world
    global_batch = base_batch * world
    batches = [max(1, round(global_batch * c / total_cap)) for c in caps]
    total_batch = sum(batches)
    weights = [b / total_batch for b in batches]
    rams = [g["ram_gb"] for g in gathered if g["ram_gb"] is not None]
    return {"rank": rank, "world": world, "nodes": gathered,
            "weights": weights, "local_batches": batches,
            "my_weight": weights[rank], "my_local_batch": batches[rank],
            "total_cores": sum(g["cores"] for g in gathered),
            "total_ram_gb": (sum(rams) if rams else None),
            "global_batch": sum(batches),
            "devices": [g.get("device", "cpu") for g in gathered],
            "capacities": [round(c, 1) for c in caps],
            "gpus": [g.get("gpu") for g in gathered]}


@torch.no_grad()
def broadcast_params(model, src: int = 0):
    for p in model.parameters():
        cpu = p.data.detach().to("cpu")
        dist.broadcast(cpu, src=src)
        p.data.copy_(cpu.to(p.data.device))


@torch.no_grad()
def capacity_weighted_allreduce_grads(model, weight: float):
    """Σ_i w_i g_i with w_i = n_i/Σn_j == the true full-batch mean gradient.
    Reduced on CPU copies so mixed CPU/GPU nodes interoperate over gloo."""
    for p in model.parameters():
        if p.grad is None:
            p.grad = torch.zeros_like(p.data)
        g = p.grad.detach().to("cpu").mul_(weight)
        dist.all_reduce(g, op=dist.ReduceOp.SUM)
        p.grad.copy_(g.to(p.grad.device))


class DaisyCluster:
    """One node's handle on the cluster. Same code runs on every machine."""

    def __init__(self, cpu_fraction: float = 0.9, base_batch: int = 32):
        self.threads = configure_cpu(cpu_fraction)
        self.device = pick_device()
        self.rank, self.world = init_cluster()
        self.plan = cluster_plan(cpu_fraction, base_batch)

    def is_master(self):
        return self.rank == 0

    def _write_status(self, path, **kw):
        payload = {"rank": self.rank, "world": self.world,
                   "plan": {"total_cores": self.plan["total_cores"],
                            "total_ram_gb": self.plan["total_ram_gb"],
                            "weights": self.plan["weights"],
                            "devices": self.plan["devices"],
                            "local_batches": self.plan["local_batches"]}, **kw}
        try:
            with open(path, "w") as f:
                json.dump(payload, f)
        except Exception:
            pass

    def fit(self, model, task, steps=500, lr=1e-2, optimizer="sgd",
            status_path=None, step_delay=0.0):
        """Train `model` on `task` (build_model already called). task.sample(n)
        draws this node's shard; task.loss(model, X, y) returns a scalar."""
        model.to(self.device)
        broadcast_params(model)
        if optimizer == "adam":
            opt = torch.optim.Adam(model.parameters(), lr=lr)
        else:
            opt = torch.optim.SGD(model.parameters(), lr=lr)
        w, nb = self.plan["my_weight"], self.plan["my_local_batch"]
        for s in range(steps):
            X, y = task.sample(nb)
            X, y = X.to(self.device), y.to(self.device)
            opt.zero_grad(set_to_none=False)
            loss = task.loss(model, X, y)
            loss.backward()
            capacity_weighted_allreduce_grads(model, w)
            opt.step()
            if step_delay:
                time.sleep(step_delay)
            if s % max(1, steps // 20) == 0 or s == steps - 1:
                lt = loss.detach().to("cpu").clone()
                dist.all_reduce(lt, op=dist.ReduceOp.SUM)
                if self.is_master():
                    cl = lt.item() / self.world
                    print(f"  step {s:5d}  cluster-avg loss {cl:.6f}", flush=True)
                    if status_path:
                        self._write_status(status_path, step=s, total_steps=steps,
                                            cluster_avg_loss=cl, done=(s == steps - 1))
        return model

    def replica_diff(self, model):
        vec = torch.cat([p.data.reshape(-1).to("cpu") for p in model.parameters()])
        bucket = [torch.zeros_like(vec) for _ in range(self.world)]
        dist.all_gather(bucket, vec)
        return max((bucket[i] - bucket[0]).abs().max().item() for i in range(self.world))

    def shutdown(self):
        if dist.is_initialized():
            dist.barrier()
            dist.destroy_process_group()