Download daisychain/cluster.py from DaisyChainAI/DaisyChain-Train: direct link, hf CLI and curl.
- Browser
- Download file 8.13 kB
-
https://huggingface.co/DaisyChainAI/DaisyChain-Train/resolve/d51e6088b103edb81044d3431bbbc00a6d70021f/daisychain/cluster.py
- Command line
-
hf download hf://DaisyChainAI/DaisyChain-Train@d51e6088b103edb81044d3431bbbc00a6d70021f/daisychain/cluster.py
-
curl -L -o cluster.py https://huggingface.co/DaisyChainAI/DaisyChain-Train/resolve/d51e6088b103edb81044d3431bbbc00a6d70021f/daisychain/cluster.py
8.13 kB
| """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]} | |
| 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)) | |
| 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() | |