Quazim0t0 commited on
Commit
309b968
·
verified ·
1 Parent(s): 1dbdb3d

Old-hardware training through emulated GPU logic

Browse files
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pt
4
+ !daisychain/verified/weights/*.pt
5
+ *.egg-info/
6
+ .venv/
7
+ .DS_Store
8
+ status.json
9
+ daisychain_model.pt
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dean Byrne (Quazim0t0) / DaisyChainAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🌼 DaisyChain — Old Hardware Training Pipeline
2
+
3
+ > **In plain terms:** DaisyChain lets you use **old / spare machines** to train
4
+ > neural networks. The training runs through **emulated GPU logic** — verified
5
+ > INT8 units (GUDA-style) that stand in for a GPU's math — so machines *without*
6
+ > a modern GPU can still do the work. Chain several together and they train one
7
+ > shared model as a cluster.
8
+ > Before you rely on it, see what it **can't** do → [Limitations](docs/LIMITS.md).
9
+
10
+ **Use the hardware you already have to train.** Each machine runs the emulated
11
+ GPU logic (verified INT8 units — multiply / requantize / ReLU) to compute the
12
+ model, and DaisyChain pools the machines data-parallel: device selection,
13
+ capacity-weighted sharding, gradient sync, a P2P setup, and a live dashboard.
14
+ Two ways to run — **Docker** or **Python**.
15
+
16
+ > Built by **DaisyChainAI**. Point it at your model + data and it trains across
17
+ > whatever old machines you have, through the emulated GPU logic.
18
+
19
+ ---
20
+
21
+ ## ⚠️ Read this first
22
+ DaisyChain is for **small models on spare hardware**. It **pools compute, not
23
+ memory** (the model must fit on one node), scaling is **sublinear**, and it is
24
+ **not** a substitute for a real GPU on real models. Full envelope in
25
+ **[docs/LIMITS.md](docs/LIMITS.md)** — please read it before relying on it.
26
+
27
+ ---
28
+
29
+ ## Quick start
30
+
31
+ ### Docker (most reliable — one command)
32
+ ```bash
33
+ docker compose -f docker/docker-compose.yml up --build
34
+ # open http://localhost:8080
35
+ ```
36
+ Brings up a 3-node demo cluster + dashboard on one machine.
37
+
38
+ ### Python (real machines)
39
+ On every machine (`pip install daisychain` or `pip install -e .`):
40
+ ```bash
41
+ export MASTER_ADDR=100.101.102.10 # coordinator IP (Tailscale 100.x recommended)
42
+ export MASTER_PORT=29560
43
+ export WORLD_SIZE=3
44
+ export RANK=0 # 1, 2, ... on the others
45
+ export GLOO_SOCKET_IFNAME=tailscale0 # your mesh / LAN NIC
46
+ daisychain-train
47
+ ```
48
+
49
+ ### Windows helper
50
+ ```bat
51
+ scripts\setup.bat
52
+ ```
53
+ An interactive menu: Docker, Python node, or just install deps.
54
+
55
+ Full walkthrough: **[docs/QUICKSTART.md](docs/QUICKSTART.md)**.
56
+
57
+ ---
58
+
59
+ ## How it works
60
+
61
+ Each machine runs the **same** command; they form a cluster and train one shared
62
+ model. Two things happen:
63
+
64
+ 1. **The compute runs through the emulated GPU logic.** By default the model is
65
+ built from `VerifiedLinear` layers, so every forward multiply / requantize /
66
+ ReLU is done by the **bundled verified INT8 units** (`daisychain/verified/`)
67
+ — the emulated GPU math. Rank 0 prints **cluster-wide unit-invocation counts**
68
+ so you can see the emulated logic doing the work.
69
+ 2. **The machines are pooled data-parallel.** Each node trains on its own shard;
70
+ gradients are capacity-weighted and combined into the exact full-batch
71
+ gradient, so replicas stay **bit-identical**. Faster machines automatically
72
+ take a bigger share.
73
+
74
+ ```
75
+ old machine A ─┐
76
+ old machine B ─┼─► each runs the emulated GPU logic on its shard ─► one model
77
+ old machine C ─┘ (gradients combined across the cluster)
78
+ ```
79
+
80
+ ## Bring your own model
81
+ DaisyChain trains any **Task** (`build_model` / `sample` / `loss`). Copy
82
+ `examples/my_task_template.py`, set `DAISY_TASK=your_module:YourTask`. Use
83
+ `VerifiedLinear` (see `daisychain/verified_task.py`) to run your model's compute
84
+ through the emulated units. See **[docs/CUSTOM_TASK.md](docs/CUSTOM_TASK.md)**.
85
+
86
+ ## Plain-float alternative
87
+ If you'd rather skip the emulated units and just train with normal float math on
88
+ each machine, set `DAISY_TASK=daisychain.example_task:ExampleTask`. Same cluster,
89
+ same pooling — the model math just runs as ordinary float instead of through the
90
+ verified units.
91
+
92
+ ## The dashboard
93
+ `daisychain-dashboard` (or the Docker service) serves a Tailwind page at
94
+ `:8080` — readiness banner, P2P connectivity scan, pooled cores/RAM + capacity
95
+ plan (per-node device, weight, batch), and live training loss.
96
+
97
+ ## Networking
98
+ Use **Tailscale** for a P2P mesh so machines on different networks get stable
99
+ IPs on one interface — **[docs/TAILSCALE.md](docs/TAILSCALE.md)**.
100
+
101
+ ---
102
+
103
+ ## Layout
104
+ ```
105
+ daisychain/cluster.py capacity-weighted CPU/GPU data-parallel trainer
106
+ daisychain/task.py the Task interface + loader
107
+ daisychain/train.py entry point (daisychain-train)
108
+ daisychain/example_task.py default runnable task (plain float)
109
+ daisychain/verified/ bundled trained N/N units + VerifiedLinear (train through them)
110
+ daisychain/verified_task.py example task whose forward runs on the verified units
111
+ daisychain/dashboard/ agent + P2P scanner + Tailwind server
112
+ docker/ Dockerfile, dashboard image, compose (demo cluster)
113
+ scripts/setup.bat / setup.sh interactive setup helpers
114
+ config/ nodes + cluster env examples
115
+ examples/my_task_template.py starting point for your own model
116
+ docs/ QUICKSTART, LIMITS, CUSTOM_TASK, TAILSCALE
117
+ ```
118
+
119
+ ## Install
120
+ ```bash
121
+ pip install torch numpy psutil
122
+ pip install -e . # exposes: daisychain-train, daisychain-agent, daisychain-dashboard
123
+ ```
124
+
125
+ Requires Python ≥ 3.9, PyTorch ≥ 2.0. Multi-node is reliable on **Linux/macOS**;
126
+ on **Windows use Docker/WSL** (see LIMITS).
127
+
128
+ ---
129
+
130
+ **License:** MIT · **Author:** Dean Byrne (Quazim0t0) · **Org:** DaisyChainAI
131
+
132
+ ## Citation
133
+
134
+ ```bibtex
135
+ @misc{byrne2026daisychain,
136
+ title = {DaisyChain: An Old Hardware Training Pipeline},
137
+ author = {Byrne, Dean (Quazim0t0)},
138
+ year = {2026},
139
+ howpublished = {\url{https://huggingface.co/DaisyChainAI/old-hw-train}},
140
+ note = {Chain spare/old machines into a data-parallel training cluster}
141
+ }
142
+ ```
143
+
144
+ **Dean Byrne (Quazim0t0)** · 2026
config/cluster.example.env ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to cluster.env and set on EACH machine (change only RANK per machine).
2
+ # Then: source it and run `daisychain-train` (or use the scripts/ helpers).
3
+
4
+ MASTER_ADDR=100.101.102.10 # the coordinator's IP (Tailscale 100.x recommended)
5
+ MASTER_PORT=29560
6
+ WORLD_SIZE=3
7
+ RANK=0 # 0 on the coordinator, 1 / 2 / ... on the others
8
+ GLOO_SOCKET_IFNAME=tailscale0 # the NIC to use (tailscale0, or eth0 / your LAN NIC)
9
+ USE_LIBUV=0
10
+
11
+ # Task + training
12
+ DAISY_TASK=daisychain.example_task:ExampleTask # swap for "your_module:YourTask"
13
+ DAISY_STEPS=300
14
+ DAISY_LR=0.05
15
+ DAISY_OPTIMIZER=sgd
16
+ DAISY_BASE_BATCH=32
17
+ DAISY_STATUS_FILE=status.json
18
+ DAISY_SAVE=daisychain_model.pt
config/nodes.example.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [
2
+ { "name": "rank0 (coordinator)", "host": "rank0", "agent_port": 8900, "gloo_port": 29560 },
3
+ { "name": "rank1", "host": "rank1", "agent_port": 8900, "gloo_port": 29560 },
4
+ { "name": "rank2", "host": "rank2", "agent_port": 8900, "gloo_port": 29560 }
5
+ ]
daisychain/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DaisyChain — Old Hardware Training Pipeline.
2
+
3
+ Chain spare/old machines into a data-parallel training cluster in minutes.
4
+ Bring your own model+data as a Task; DaisyChain handles device selection,
5
+ capacity-weighted sharding, gradient sync, and a live dashboard.
6
+ """
7
+ from .cluster import (DaisyCluster, survey_node, cluster_plan, pick_device,
8
+ capacity_score, configure_cpu)
9
+ from .task import Task, load_task
10
+
11
+ __version__ = "0.1.0"
12
+ __all__ = ["DaisyCluster", "survey_node", "cluster_plan", "pick_device",
13
+ "capacity_score", "configure_cpu", "Task", "load_task"]
daisychain/cluster.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DaisyChain cluster core — data-parallel CPU/GPU training across spare machines.
2
+
3
+ Design: distribute the *parallel* axis (the batch) across nodes; keep each node's
4
+ work local. Every node holds a full model replica and trains on its shard; a
5
+ capacity-weighted gradient all-reduce combines them into the exact full-batch
6
+ gradient, so replicas stay bit-identical.
7
+
8
+ - Each node uses ~90% of its cores (and its GPU if it has one).
9
+ - Capacity is MEASURED (matmuls/sec) so a strong node auto-takes a bigger
10
+ batch. Gradients are reduced on CPU copies, so CPU and GPU nodes mix.
11
+
12
+ Pools compute, not memory: the model must fit on one node. Honest limits are in
13
+ docs/LIMITS.md.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import socket
20
+ import time
21
+
22
+ import torch
23
+ import torch.distributed as dist
24
+
25
+
26
+ # ---------------------------------------------------------------- resources ---
27
+ def configure_cpu(fraction: float = 0.9) -> int:
28
+ cores = os.cpu_count() or 1
29
+ n = max(1, int(round(cores * fraction)))
30
+ torch.set_num_threads(n)
31
+ return n
32
+
33
+
34
+ def pick_device() -> "torch.device":
35
+ if os.environ.get("DAISY_FORCE_CPU") == "1":
36
+ return torch.device("cpu")
37
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
38
+
39
+
40
+ def _gpu_info():
41
+ if not torch.cuda.is_available():
42
+ return None
43
+ p = torch.cuda.get_device_properties(0)
44
+ return {"name": p.name, "vram_gb": round(p.total_memory / 1e9, 1),
45
+ "capability": f"{p.major}.{p.minor}"}
46
+
47
+
48
+ def _available_ram_gb():
49
+ try:
50
+ import psutil
51
+ return round(psutil.virtual_memory().available / 1e9, 1)
52
+ except Exception:
53
+ return None
54
+
55
+
56
+ def capacity_score(device=None, secs: float = 0.3) -> float:
57
+ """Measured throughput: fixed matmuls/sec on the local device. Self-calibrating
58
+ (a GPU scores far higher), so capacity weighting hands it a bigger batch."""
59
+ dev = device or pick_device()
60
+ try:
61
+ a = torch.randn(512, 512, device=dev)
62
+ b = torch.randn(512, 512, device=dev)
63
+ _ = a @ b
64
+ if dev.type == "cuda":
65
+ torch.cuda.synchronize()
66
+ t0, it = time.time(), 0
67
+ while time.time() - t0 < secs:
68
+ a = a @ b
69
+ it += 1
70
+ if dev.type == "cuda":
71
+ torch.cuda.synchronize()
72
+ return it / (time.time() - t0)
73
+ except Exception:
74
+ return float(os.cpu_count() or 1)
75
+
76
+
77
+ def survey_node(cpu_fraction: float = 0.9, measure: bool = True) -> dict:
78
+ cores = int(os.environ.get("DAISY_CORES", os.cpu_count() or 1))
79
+ dev = pick_device()
80
+ gpu = _gpu_info() if dev.type == "cuda" else None
81
+ if "DAISY_CAPACITY" in os.environ:
82
+ cap = float(os.environ["DAISY_CAPACITY"])
83
+ elif measure:
84
+ cap = capacity_score(dev)
85
+ else:
86
+ cap = float(cores)
87
+ return {"host": socket.gethostname(), "cores": cores,
88
+ "threads": max(1, int(round(cores * cpu_fraction))),
89
+ "ram_gb": _available_ram_gb(), "device": dev.type,
90
+ "gpu": gpu, "capacity": cap}
91
+
92
+
93
+ # ------------------------------------------------------------------ cluster ---
94
+ def init_cluster(backend: str = "gloo"):
95
+ os.environ.setdefault("USE_LIBUV", "0")
96
+ if not dist.is_initialized():
97
+ dist.init_process_group(backend=backend)
98
+ return dist.get_rank(), dist.get_world_size()
99
+
100
+
101
+ def cluster_plan(cpu_fraction: float = 0.9, base_batch: int = 32) -> dict:
102
+ rank, world = dist.get_rank(), dist.get_world_size()
103
+ me = survey_node(cpu_fraction)
104
+ gathered = [None] * world
105
+ dist.all_gather_object(gathered, me)
106
+
107
+ caps = [float(g.get("capacity") or g["cores"]) for g in gathered]
108
+ total_cap = sum(caps) or world
109
+ global_batch = base_batch * world
110
+ batches = [max(1, round(global_batch * c / total_cap)) for c in caps]
111
+ total_batch = sum(batches)
112
+ weights = [b / total_batch for b in batches]
113
+ rams = [g["ram_gb"] for g in gathered if g["ram_gb"] is not None]
114
+ return {"rank": rank, "world": world, "nodes": gathered,
115
+ "weights": weights, "local_batches": batches,
116
+ "my_weight": weights[rank], "my_local_batch": batches[rank],
117
+ "total_cores": sum(g["cores"] for g in gathered),
118
+ "total_ram_gb": (sum(rams) if rams else None),
119
+ "global_batch": sum(batches),
120
+ "devices": [g.get("device", "cpu") for g in gathered],
121
+ "capacities": [round(c, 1) for c in caps],
122
+ "gpus": [g.get("gpu") for g in gathered]}
123
+
124
+
125
+ @torch.no_grad()
126
+ def broadcast_params(model, src: int = 0):
127
+ for p in model.parameters():
128
+ cpu = p.data.detach().to("cpu")
129
+ dist.broadcast(cpu, src=src)
130
+ p.data.copy_(cpu.to(p.data.device))
131
+
132
+
133
+ @torch.no_grad()
134
+ def capacity_weighted_allreduce_grads(model, weight: float):
135
+ """Σ_i w_i g_i with w_i = n_i/Σn_j == the true full-batch mean gradient.
136
+ Reduced on CPU copies so mixed CPU/GPU nodes interoperate over gloo."""
137
+ for p in model.parameters():
138
+ if p.grad is None:
139
+ p.grad = torch.zeros_like(p.data)
140
+ g = p.grad.detach().to("cpu").mul_(weight)
141
+ dist.all_reduce(g, op=dist.ReduceOp.SUM)
142
+ p.grad.copy_(g.to(p.grad.device))
143
+
144
+
145
+ class DaisyCluster:
146
+ """One node's handle on the cluster. Same code runs on every machine."""
147
+
148
+ def __init__(self, cpu_fraction: float = 0.9, base_batch: int = 32):
149
+ self.threads = configure_cpu(cpu_fraction)
150
+ self.device = pick_device()
151
+ self.rank, self.world = init_cluster()
152
+ self.plan = cluster_plan(cpu_fraction, base_batch)
153
+
154
+ def is_master(self):
155
+ return self.rank == 0
156
+
157
+ def _write_status(self, path, **kw):
158
+ payload = {"rank": self.rank, "world": self.world,
159
+ "plan": {"total_cores": self.plan["total_cores"],
160
+ "total_ram_gb": self.plan["total_ram_gb"],
161
+ "weights": self.plan["weights"],
162
+ "devices": self.plan["devices"],
163
+ "local_batches": self.plan["local_batches"]}, **kw}
164
+ try:
165
+ with open(path, "w") as f:
166
+ json.dump(payload, f)
167
+ except Exception:
168
+ pass
169
+
170
+ def fit(self, model, task, steps=500, lr=1e-2, optimizer="sgd",
171
+ status_path=None, step_delay=0.0):
172
+ """Train `model` on `task` (build_model already called). task.sample(n)
173
+ draws this node's shard; task.loss(model, X, y) returns a scalar."""
174
+ model.to(self.device)
175
+ broadcast_params(model)
176
+ if optimizer == "adam":
177
+ opt = torch.optim.Adam(model.parameters(), lr=lr)
178
+ else:
179
+ opt = torch.optim.SGD(model.parameters(), lr=lr)
180
+ w, nb = self.plan["my_weight"], self.plan["my_local_batch"]
181
+ for s in range(steps):
182
+ X, y = task.sample(nb)
183
+ X, y = X.to(self.device), y.to(self.device)
184
+ opt.zero_grad(set_to_none=False)
185
+ loss = task.loss(model, X, y)
186
+ loss.backward()
187
+ capacity_weighted_allreduce_grads(model, w)
188
+ opt.step()
189
+ if step_delay:
190
+ time.sleep(step_delay)
191
+ if s % max(1, steps // 20) == 0 or s == steps - 1:
192
+ lt = loss.detach().to("cpu").clone()
193
+ dist.all_reduce(lt, op=dist.ReduceOp.SUM)
194
+ if self.is_master():
195
+ cl = lt.item() / self.world
196
+ print(f" step {s:5d} cluster-avg loss {cl:.6f}", flush=True)
197
+ if status_path:
198
+ self._write_status(status_path, step=s, total_steps=steps,
199
+ cluster_avg_loss=cl, done=(s == steps - 1))
200
+ return model
201
+
202
+ def replica_diff(self, model):
203
+ vec = torch.cat([p.data.reshape(-1).to("cpu") for p in model.parameters()])
204
+ bucket = [torch.zeros_like(vec) for _ in range(self.world)]
205
+ dist.all_gather(bucket, vec)
206
+ return max((bucket[i] - bucket[0]).abs().max().item() for i in range(self.world))
207
+
208
+ def shutdown(self):
209
+ if dist.is_initialized():
210
+ dist.barrier()
211
+ dist.destroy_process_group()
daisychain/dashboard/__init__.py ADDED
File without changes
daisychain/dashboard/agent.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-node agent (CLI: `daisychain-agent`). Serves health/resources/status so
2
+ the dashboard can scan this machine. Pure stdlib."""
3
+ import json
4
+ import os
5
+ import socket
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+
8
+ try:
9
+ from daisychain.cluster import survey_node
10
+ except Exception:
11
+ def survey_node(cpu_fraction=0.9):
12
+ cores = int(os.environ.get("DAISY_CORES", os.cpu_count() or 1))
13
+ return {"host": socket.gethostname(), "cores": cores,
14
+ "threads": max(1, int(cores * cpu_fraction)),
15
+ "ram_gb": None, "device": "cpu", "gpu": None, "capacity": cores}
16
+
17
+ RANK = int(os.environ.get("RANK", "0"))
18
+ STATUS_FILE = os.environ.get("DAISY_STATUS_FILE", "status.json")
19
+ PORT = int(os.environ.get("DAISY_AGENT_PORT", "8900"))
20
+
21
+
22
+ def _status():
23
+ try:
24
+ with open(STATUS_FILE) as f:
25
+ return json.load(f)
26
+ except Exception:
27
+ return {}
28
+
29
+
30
+ class Handler(BaseHTTPRequestHandler):
31
+ def _send(self, obj, code=200):
32
+ body = json.dumps(obj).encode()
33
+ self.send_response(code)
34
+ self.send_header("Content-Type", "application/json")
35
+ self.send_header("Access-Control-Allow-Origin", "*")
36
+ self.send_header("Content-Length", str(len(body)))
37
+ self.end_headers()
38
+ self.wfile.write(body)
39
+
40
+ def do_GET(self):
41
+ if self.path.startswith("/health"):
42
+ self._send({"ok": True, "rank": RANK, "host": socket.gethostname()})
43
+ elif self.path.startswith("/resources"):
44
+ r = survey_node(); r["rank"] = RANK; self._send(r)
45
+ elif self.path.startswith("/status"):
46
+ self._send(_status())
47
+ else:
48
+ self._send({"error": "not found"}, 404)
49
+
50
+ def log_message(self, *a):
51
+ pass
52
+
53
+
54
+ def main():
55
+ print(f"[agent] rank {RANK} on :{PORT}", flush=True)
56
+ ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
daisychain/dashboard/scanner.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """P2P cluster scanner: probe each node's agent, measure latency, gather
2
+ resources + live status, compute the capacity plan and readiness verdict."""
3
+ import json
4
+ import socket
5
+ import time
6
+ import urllib.request
7
+
8
+
9
+ def _get(url, timeout=2.0):
10
+ with urllib.request.urlopen(url, timeout=timeout) as r:
11
+ return json.load(r)
12
+
13
+
14
+ def _latency_ms(host, port, timeout=2.0):
15
+ t = time.time()
16
+ try:
17
+ with socket.create_connection((host, int(port)), timeout=timeout):
18
+ return (time.time() - t) * 1000.0
19
+ except Exception:
20
+ return None
21
+
22
+
23
+ def scan_node(node):
24
+ host, ap = node["host"], node.get("agent_port", 8900)
25
+ base = f"http://{host}:{ap}"
26
+ out = {"name": node["name"], "host": host, "reachable": False,
27
+ "latency_ms": None, "resources": None, "status": None,
28
+ "gloo_port_open": None}
29
+ lat = _latency_ms(host, ap)
30
+ out["latency_ms"] = round(lat, 1) if lat is not None else None
31
+ if lat is None:
32
+ return out
33
+ try:
34
+ h = _get(f"{base}/health")
35
+ out["reachable"] = bool(h.get("ok"))
36
+ out["rank"] = h.get("rank")
37
+ out["resources"] = _get(f"{base}/resources")
38
+ out["status"] = _get(f"{base}/status")
39
+ except Exception as e:
40
+ out["error"] = str(e)
41
+ gp = node.get("gloo_port")
42
+ if gp:
43
+ out["gloo_port_open"] = _latency_ms(host, gp) is not None
44
+ return out
45
+
46
+
47
+ def capacity_plan(scans, base_batch=32):
48
+ live = [n for n in scans if n.get("resources")]
49
+ caps = [float(n["resources"].get("capacity") or n["resources"].get("cores", 1))
50
+ for n in live]
51
+ total = sum(caps) or 1
52
+ world = len(live)
53
+ gb = base_batch * max(1, world)
54
+ batches = [max(1, round(gb * c / total)) for c in caps]
55
+ tb = sum(batches) or 1
56
+ weights = [b / tb for b in batches]
57
+ rams = [n["resources"].get("ram_gb") for n in live
58
+ if n["resources"].get("ram_gb") is not None]
59
+ return {"world": world, "total_cores": sum(n["resources"].get("cores", 0) for n in live),
60
+ "total_ram_gb": round(sum(rams), 1) if rams else None,
61
+ "per_node": [{"name": n["name"], "device": n["resources"].get("device", "cpu"),
62
+ "cores": n["resources"].get("cores"),
63
+ "gpu": (n["resources"].get("gpu") or {}).get("name"),
64
+ "capacity": round(c, 1), "weight": round(w, 3), "batch": b,
65
+ "ram_gb": n["resources"].get("ram_gb")}
66
+ for n, c, w, b in zip(live, caps, weights, batches)],
67
+ "global_batch": sum(batches)}
68
+
69
+
70
+ def scan_cluster(nodes, expected_world=None, base_batch=32):
71
+ scans = [scan_node(n) for n in nodes]
72
+ plan = capacity_plan(scans, base_batch)
73
+ reachable = [s for s in scans if s["reachable"]]
74
+ want = expected_world if expected_world is not None else len(nodes)
75
+ ready = (len(reachable) == len(nodes) == want)
76
+ train = None
77
+ for s in scans:
78
+ st = s.get("status") or {}
79
+ if st.get("rank") == 0 and st:
80
+ train = st
81
+ break
82
+ return {"nodes": scans, "plan": plan, "ready": ready,
83
+ "reachable": len(reachable), "total": len(nodes),
84
+ "expected_world": want, "training": train,
85
+ "scanned_at": time.strftime("%H:%M:%S")}
daisychain/dashboard/server.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DaisyChain dashboard (CLI: `daisychain-dashboard`). Tailwind-styled page with
2
+ a readiness banner, P2P connectivity scan, pooled resource + capacity plan, and
3
+ live training status. Config via DAISY_NODES_FILE. Serves on :8080."""
4
+ import json
5
+ import os
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+
8
+ try:
9
+ from daisychain.dashboard.scanner import scan_cluster
10
+ except Exception:
11
+ from scanner import scan_cluster # running from the folder directly
12
+
13
+ NODES_FILE = os.environ.get("DAISY_NODES_FILE", "config/nodes.example.json")
14
+ PORT = int(os.environ.get("DAISY_DASH_PORT", "8080"))
15
+ EXPECTED_WORLD = os.environ.get("DAISY_EXPECTED_WORLD")
16
+ BASE_BATCH = int(os.environ.get("DAISY_BASE_BATCH", "32"))
17
+
18
+
19
+ def load_nodes():
20
+ with open(NODES_FILE) as f:
21
+ return json.load(f)
22
+
23
+
24
+ def _chip(ok, yes="OK", no="DOWN"):
25
+ cls = ("bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" if ok
26
+ else "bg-rose-500/20 text-rose-600 dark:text-rose-400")
27
+ return f'<span class="px-2 py-0.5 rounded text-xs font-semibold {cls}">{yes if ok else no}</span>'
28
+
29
+
30
+ def render(d):
31
+ ready = d["ready"]
32
+ banner = ("bg-emerald-500", "✓ CLUSTER READY — all nodes connected") if ready \
33
+ else ("bg-rose-500", f"✗ NOT READY — {d['reachable']}/{d['total']} nodes reachable")
34
+ rows = ""
35
+ for n in d["nodes"]:
36
+ lat = f'{n["latency_ms"]} ms' if n["latency_ms"] is not None else "—"
37
+ res = n.get("resources") or {}
38
+ dev = res.get("device", "—")
39
+ gpu = (res.get("gpu") or {}).get("name", "")
40
+ devlabel = f"{dev}" + (f" ({gpu})" if gpu else "")
41
+ rows += f"""<tr class="border-b border-slate-100 dark:border-slate-800">
42
+ <td class="py-2 px-3 font-medium">{n['name']}</td>
43
+ <td class="py-2 px-3 text-slate-500">{n['host']}</td>
44
+ <td class="py-2 px-3">{_chip(n['reachable'],'reachable','unreachable')}</td>
45
+ <td class="py-2 px-3">{devlabel}</td>
46
+ <td class="py-2 px-3 tabular-nums">{lat}</td></tr>"""
47
+ plan = d["plan"]
48
+ pr = ""
49
+ for p in plan["per_node"]:
50
+ dv = p["device"] + (f" ({p['gpu']})" if p.get("gpu") else "")
51
+ ram = f'{p["ram_gb"]} GB' if p.get("ram_gb") is not None else "—"
52
+ pr += f"""<tr class="border-b border-slate-100 dark:border-slate-800">
53
+ <td class="py-2 px-3 font-medium">{p['name']}</td>
54
+ <td class="py-2 px-3">{dv}</td>
55
+ <td class="py-2 px-3 tabular-nums">{ram}</td>
56
+ <td class="py-2 px-3 tabular-nums">{p['capacity']}</td>
57
+ <td class="py-2 px-3 tabular-nums">{p['weight']}</td>
58
+ <td class="py-2 px-3 tabular-nums">{p['batch']}</td></tr>"""
59
+ tot_ram = f'{plan["total_ram_gb"]} GB' if plan["total_ram_gb"] is not None else "—"
60
+ t = d["training"]
61
+ if t:
62
+ step, total = t.get("step", 0), t.get("total_steps", 1)
63
+ pct = int(100 * (step + 1) / max(1, total)); loss = t.get("cluster_avg_loss")
64
+ badge = _chip(True, "DONE") if t.get("done") else '<span class="text-xs text-sky-500 animate-pulse">training…</span>'
65
+ train = f"""<div class="flex items-center justify-between mb-2">
66
+ <span class="text-sm text-slate-500">step {step} / {total}</span>{badge}</div>
67
+ <div class="w-full h-3 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden">
68
+ <div class="h-3 bg-sky-500" style="width:{pct}%"></div></div>
69
+ <p class="mt-3 text-2xl font-semibold tabular-nums">{loss:.5f}
70
+ <span class="text-sm font-normal text-slate-500">cluster-avg loss</span></p>"""
71
+ else:
72
+ train = '<p class="text-slate-400 text-sm">No active run detected (waiting for rank 0 status)…</p>'
73
+ return f"""<!doctype html><html><head><meta charset="utf-8">
74
+ <meta name="viewport" content="width=device-width, initial-scale=1">
75
+ <meta http-equiv="refresh" content="3"><title>DaisyChain — Cluster</title>
76
+ <script src="https://cdn.tailwindcss.com"></script></head>
77
+ <body class="bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-100 min-h-screen">
78
+ <div class="max-w-5xl mx-auto p-6 space-y-6">
79
+ <header class="flex items-center justify-between">
80
+ <div><h1 class="text-2xl font-bold">\U0001f33c DaisyChain</h1>
81
+ <p class="text-sm text-slate-500">Old Hardware Training Pipeline · scanned {d['scanned_at']}</p></div>
82
+ <span class="text-xs text-slate-400">auto-refresh 3s</span></header>
83
+ <div class="{banner[0]} text-white rounded-xl px-5 py-4 font-semibold text-lg shadow">{banner[1]}</div>
84
+ <div class="grid md:grid-cols-3 gap-4">
85
+ <div class="rounded-xl border border-slate-200 dark:border-slate-700 p-4"><p class="text-xs uppercase tracking-wide text-slate-400">Nodes</p><p class="text-3xl font-bold tabular-nums">{d['reachable']}/{d['total']}</p></div>
86
+ <div class="rounded-xl border border-slate-200 dark:border-slate-700 p-4"><p class="text-xs uppercase tracking-wide text-slate-400">Total cores</p><p class="text-3xl font-bold tabular-nums">{plan['total_cores']}</p></div>
87
+ <div class="rounded-xl border border-slate-200 dark:border-slate-700 p-4"><p class="text-xs uppercase tracking-wide text-slate-400">Total RAM</p><p class="text-3xl font-bold tabular-nums">{tot_ram}</p></div></div>
88
+ <section class="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
89
+ <h2 class="px-4 py-3 font-semibold border-b border-slate-100 dark:border-slate-800">Connectivity scan</h2>
90
+ <table class="w-full text-sm"><thead class="text-left text-slate-400"><tr>
91
+ <th class="py-2 px-3">Node</th><th class="py-2 px-3">Host</th><th class="py-2 px-3">Agent</th>
92
+ <th class="py-2 px-3">Device</th><th class="py-2 px-3">Latency</th></tr></thead><tbody>{rows}</tbody></table></section>
93
+ <div class="grid md:grid-cols-2 gap-6">
94
+ <section class="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
95
+ <h2 class="px-4 py-3 font-semibold border-b border-slate-100 dark:border-slate-800">Capacity plan · global batch {plan['global_batch']}</h2>
96
+ <table class="w-full text-sm"><thead class="text-left text-slate-400"><tr>
97
+ <th class="py-2 px-3">Node</th><th class="py-2 px-3">Device</th><th class="py-2 px-3">RAM</th>
98
+ <th class="py-2 px-3">Capacity</th><th class="py-2 px-3">Weight</th><th class="py-2 px-3">Batch</th></tr></thead><tbody>{pr}</tbody></table></section>
99
+ <section class="rounded-xl border border-slate-200 dark:border-slate-700 p-4">
100
+ <h2 class="font-semibold mb-3">Live training</h2>{train}</section></div>
101
+ <footer class="text-center text-xs text-slate-400 pt-2">DaisyChainAI · pools compute, not memory · small models on spare hardware</footer>
102
+ </div></body></html>"""
103
+
104
+
105
+ class Handler(BaseHTTPRequestHandler):
106
+ def do_GET(self):
107
+ data = scan_cluster(load_nodes(),
108
+ int(EXPECTED_WORLD) if EXPECTED_WORLD else None, BASE_BATCH)
109
+ if self.path.startswith("/api"):
110
+ body = json.dumps(data).encode(); ctype = "application/json"
111
+ else:
112
+ body = render(data).encode(); ctype = "text/html; charset=utf-8"
113
+ self.send_response(200); self.send_header("Content-Type", ctype)
114
+ self.send_header("Content-Length", str(len(body))); self.end_headers()
115
+ self.wfile.write(body)
116
+
117
+ def log_message(self, *a):
118
+ pass
119
+
120
+
121
+ def main():
122
+ print(f"[dashboard] :{PORT} nodes={NODES_FILE}", flush=True)
123
+ ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
daisychain/example_task.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The default example task: fit a small MLP to a synthetic function.
2
+
3
+ It exists so `daisychain-train` runs out of the box and you can confirm the
4
+ cluster works end to end. Replace it with your own task (see docs/CUSTOM_TASK.md)
5
+ -- copy this file, change build_model / sample / loss, and set DAISY_TASK.
6
+ """
7
+ import torch
8
+ import torch.nn as nn
9
+
10
+
11
+ class ExampleTask:
12
+ def __init__(self):
13
+ # fixed target so every node's shard is consistent
14
+ g = torch.Generator().manual_seed(1234)
15
+ self.W = torch.randn(8, 1, generator=g)
16
+
17
+ def build_model(self):
18
+ torch.manual_seed(0) # identical init on every node
19
+ return nn.Sequential(nn.Linear(8, 32), nn.ReLU(), nn.Linear(32, 1))
20
+
21
+ def sample(self, n):
22
+ X = torch.randn(n, 8)
23
+ return X, X @ self.W + 0.05 * torch.randn(n, 1)
24
+
25
+ def loss(self, model, X, y):
26
+ return nn.functional.mse_loss(model(X), y)
daisychain/task.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Task interface — bring your own model + data.
2
+
3
+ A DaisyChain task is any object with three methods:
4
+
5
+ build_model() -> torch.nn.Module # the model to train (identical on every node)
6
+ sample(n) -> (X, y) # draw n training samples (this node's shard)
7
+ loss(model, X, y) -> scalar tensor # mean loss over the batch
8
+
9
+ Point DaisyChain at your task with DAISY_TASK="my_module:MyTask" (or --task).
10
+ An example lives in examples/example_task.py. Keep build_model deterministic
11
+ (seed it) so every node starts from the same weights.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import importlib
16
+ from typing import Protocol, Tuple
17
+
18
+ import torch
19
+
20
+
21
+ class Task(Protocol):
22
+ def build_model(self) -> torch.nn.Module: ...
23
+ def sample(self, n: int) -> Tuple[torch.Tensor, torch.Tensor]: ...
24
+ def loss(self, model: torch.nn.Module, X: torch.Tensor, y: torch.Tensor) -> torch.Tensor: ...
25
+
26
+
27
+ def load_task(spec: str):
28
+ """spec = 'package.module:ClassName' -> instantiated task object."""
29
+ if ":" not in spec:
30
+ raise ValueError(f"task spec must be 'module:Class', got {spec!r}")
31
+ mod_name, cls_name = spec.split(":", 1)
32
+ mod = importlib.import_module(mod_name)
33
+ return getattr(mod, cls_name)()
daisychain/train.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DaisyChain training entry point (CLI: `daisychain-train`).
2
+
3
+ Reads cluster settings from env (set on each machine, changing only RANK):
4
+
5
+ MASTER_ADDR, MASTER_PORT, WORLD_SIZE, RANK -- standard torch.distributed
6
+ GLOO_SOCKET_IFNAME -- the NIC to use (e.g. tailscale0)
7
+ DAISY_TASK = "module:Class" -- your task (default: example)
8
+ DAISY_STEPS = 300
9
+ DAISY_LR = 0.05
10
+ DAISY_OPTIMIZER = sgd | adam
11
+ DAISY_BASE_BATCH = 32
12
+ DAISY_STATUS_FILE = status.json -- rank 0 writes live status here
13
+ DAISY_STEP_SLEEP = 0 -- demo pacing
14
+ DAISY_SAVE = daisychain_model.pt -- rank 0 saves here
15
+ """
16
+ import os
17
+
18
+ import torch
19
+
20
+ from .cluster import DaisyCluster
21
+ from .task import load_task
22
+
23
+
24
+ def _report_verified_counts(cluster):
25
+ """All-reduce verified-unit invocation counts across nodes (if any fired)."""
26
+ try:
27
+ from .verified import instrument
28
+ import torch.distributed as dist
29
+ counts = instrument.report()
30
+ if not counts:
31
+ return
32
+ keys = sorted(counts)
33
+ t = torch.tensor([counts[k] for k in keys], dtype=torch.float64)
34
+ dist.all_reduce(t, op=dist.ReduceOp.SUM)
35
+ if cluster.is_master():
36
+ print("[verified] CLUSTER-WIDE verified-unit invocations (trained through them):")
37
+ for k, v in zip(keys, t.tolist()):
38
+ print(f"[verified] {k:34s} {int(v):,}")
39
+ except Exception:
40
+ pass
41
+
42
+
43
+ def main():
44
+ # Default: train THROUGH the emulated GPU logic (verified INT8 units).
45
+ # Set DAISY_TASK=daisychain.example_task:ExampleTask for a plain-float run.
46
+ task_spec = os.environ.get("DAISY_TASK", "daisychain.verified_task:VerifiedTask")
47
+ task = load_task(task_spec)
48
+
49
+ cluster = DaisyCluster(
50
+ cpu_fraction=float(os.environ.get("DAISY_CPU_FRACTION", "0.9")),
51
+ base_batch=int(os.environ.get("DAISY_BASE_BATCH", "32")),
52
+ )
53
+
54
+ if cluster.is_master():
55
+ p = cluster.plan
56
+ print(f"[daisychain] task={task_spec}")
57
+ print(f"[plan] world={p['world']} devices={p['devices']} "
58
+ f"total_cores={p['total_cores']} total_ram_gb={p['total_ram_gb']}")
59
+ print(f"[plan] capacities={p['capacities']} weights={[round(w,3) for w in p['weights']]}")
60
+ print(f"[plan] local_batches={p['local_batches']} global_batch={p['global_batch']}")
61
+
62
+ model = task.build_model()
63
+ cluster.fit(
64
+ model, task,
65
+ steps=int(os.environ.get("DAISY_STEPS", "300")),
66
+ lr=float(os.environ.get("DAISY_LR", "0.05")),
67
+ optimizer=os.environ.get("DAISY_OPTIMIZER", "sgd"),
68
+ status_path=os.environ.get("DAISY_STATUS_FILE", "status.json"),
69
+ step_delay=float(os.environ.get("DAISY_STEP_SLEEP", "0")),
70
+ )
71
+
72
+ # if the task trained THROUGH the verified units, report cluster-wide counts
73
+ _report_verified_counts(cluster)
74
+
75
+ diff = cluster.replica_diff(model)
76
+ if cluster.is_master():
77
+ print(f"[check] replica max param diff across nodes: {diff:.2e}")
78
+ save = os.environ.get("DAISY_SAVE", "daisychain_model.pt")
79
+ torch.save({"state_dict": model.state_dict()}, save)
80
+ print(f"[save] {save}")
81
+ cluster.shutdown()
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
daisychain/verified/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verified units — the trained, N/N bit-exact INT8 building blocks bundled with
2
+ DaisyChain (from the neural-gpu-verify project). Build models with VerifiedLinear
3
+ and their FORWARD compute runs through these verified units (multiply / requant /
4
+ ReLU), materialized as lookup tables for practical speed (fast=True).
5
+
6
+ These are pre-trained; users do NOT train new units — they train THROUGH them.
7
+ """
8
+ from .qat import VerifiedLinear, load_units, build_luts
9
+ from .mul8 import NeuralMul8
10
+ from .ops import NeuralReLU8, NeuralRequant16
11
+ from . import instrument
12
+
13
+ __all__ = ["VerifiedLinear", "load_units", "build_luts",
14
+ "NeuralMul8", "NeuralReLU8", "NeuralRequant16", "instrument"]
daisychain/verified/backends.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compute backends for the bridge -- pure Python, no external toolchain.
2
+
3
+ NumpyBackend -- int32 GEMM on CPU SIMD (numpy). Real throughput, CPU-capped.
4
+ NeuralBackend -- GEMM where every multiply is the N/N-verified neural unit and
5
+ accumulation is exact integer sum. The compute path is itself
6
+ a net: bit-exact, but functional (slow), not a speed path.
7
+
8
+ Whatever backend runs, the bridge's self-certify gate proves its output bit-exact
9
+ to the verified op before any result is trusted.
10
+
11
+ (An optional Go/GUDA backend lives in backends_go.py; it is not imported here and
12
+ not required -- this package stands alone without Go.)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+
18
+ from .kernel import gemm_int8
19
+
20
+
21
+ class NumpyBackend:
22
+ name = "numpy-int8"
23
+
24
+ def available(self) -> bool:
25
+ return True
26
+
27
+ def gemm(self, A: np.ndarray, B: np.ndarray) -> np.ndarray:
28
+ return gemm_int8(A, B)
29
+
30
+
31
+ class NeuralBackend:
32
+ """GEMM computed entirely by the verified neural multiply (+ exact sum).
33
+
34
+ Pass a trained NeuralMul8 (or anything with `mul_array(a, b)`), e.g. loaded
35
+ from mul8.pt. Products come from the net; accumulation is exact int64.
36
+ """
37
+ name = "neural-mul"
38
+
39
+ def __init__(self, mul):
40
+ self.mul = mul
41
+
42
+ def available(self) -> bool:
43
+ return hasattr(self.mul, "mul_array")
44
+
45
+ def gemm(self, A: np.ndarray, B: np.ndarray) -> np.ndarray:
46
+ A = np.asarray(A).astype(np.int64)
47
+ B = np.asarray(B).astype(np.int64)
48
+ m, k = A.shape
49
+ _, n = B.shape
50
+ # all (A[i,t], B[t,j]) pairs -> one batched neural multiply -> sum over k
51
+ Ai = np.broadcast_to(A[:, None, :], (m, n, k)) # (m,n,k)
52
+ Bj = np.broadcast_to(B.T[None, :, :], (m, n, k)) # (m,n,k)
53
+ prod = self.mul.mul_array(Ai.reshape(-1), Bj.reshape(-1)).reshape(m, n, k)
54
+ return prod.sum(axis=2).astype(np.int64)
55
+
56
+
57
+ def pick_backend(neural=None):
58
+ """NeuralBackend(mul) if a multiplier is given, else the numpy throughput path."""
59
+ if neural is not None:
60
+ return NeuralBackend(neural)
61
+ return NumpyBackend()
daisychain/verified/common.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for verified neural units.
2
+
3
+ Same discipline as the neural-aarch64 / neural-photonic / neural-ddr units:
4
+ a small MLP is trained until it is *bit-identical to a golden reference over its
5
+ entire finite input domain* (N/N verification).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import torch
10
+ import torch.nn as nn
11
+
12
+
13
+ def bits_of(v: int, n: int) -> torch.Tensor:
14
+ """LSB-first bit vector of length n."""
15
+ return torch.tensor([(v >> i) & 1 for i in range(n)], dtype=torch.float32)
16
+
17
+
18
+ def int_of(bits: torch.Tensor) -> int:
19
+ """Inverse of bits_of: LSB-first bit vector -> int."""
20
+ v = 0
21
+ for i, b in enumerate(bits.tolist()):
22
+ if b >= 0.5:
23
+ v |= (1 << i)
24
+ return v
25
+
26
+
27
+ def pm(bits: torch.Tensor) -> torch.Tensor:
28
+ """Map {0,1} bits to {-1,+1} for a friendlier input scale."""
29
+ return bits * 2.0 - 1.0
30
+
31
+
32
+ def mlp(inp: int, out: int, h: int = 128, layers: int = 3) -> nn.Sequential:
33
+ mods: list[nn.Module] = [nn.Linear(inp, h), nn.ReLU()]
34
+ for _ in range(layers - 1):
35
+ mods += [nn.Linear(h, h), nn.ReLU()]
36
+ mods += [nn.Linear(h, out)]
37
+ return nn.Sequential(*mods)
38
+
39
+
40
+ @torch.no_grad()
41
+ def verify(net: nn.Module, X: torch.Tensor, Ybits: torch.Tensor) -> tuple[int, int]:
42
+ """Return (n_correct, n_total) over the full enumerated domain."""
43
+ net.eval()
44
+ pred = (net(X) > 0).float()
45
+ ok = (pred == Ybits).all(dim=1).sum().item()
46
+ return int(ok), X.shape[0]
47
+
48
+
49
+ def train(net: nn.Module, X: torch.Tensor, Ybits: torch.Tensor,
50
+ steps: int = 4000, lr: float = 2e-3, tag: str = "") -> nn.Module:
51
+ opt = torch.optim.Adam(net.parameters(), lr=lr)
52
+ lossf = nn.BCEWithLogitsLoss()
53
+ net.train()
54
+ for s in range(steps):
55
+ opt.zero_grad()
56
+ loss = lossf(net(X), Ybits)
57
+ loss.backward()
58
+ opt.step()
59
+ if tag and (s % 1000 == 0 or s == steps - 1):
60
+ ok, tot = verify(net, X, Ybits)
61
+ net.train()
62
+ print(f" [{tag}] step {s:5d} loss {loss.item():.5f} verify {ok}/{tot}")
63
+ return net
daisychain/verified/instrument.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Invocation counters for the verified units.
2
+
3
+ Turn it on and every verified unit records how many times its neural forward
4
+ actually ran (and how many scalar ops it produced). This is the evidence that a
5
+ training/inference pass genuinely computed *through* the verified GUDA logic --
6
+ not around it.
7
+ """
8
+ from collections import Counter
9
+
10
+ COUNTS = Counter()
11
+ _ENABLED = False
12
+
13
+
14
+ def enable():
15
+ global _ENABLED
16
+ _ENABLED = True
17
+
18
+
19
+ def disable():
20
+ global _ENABLED
21
+ _ENABLED = False
22
+
23
+
24
+ def reset():
25
+ COUNTS.clear()
26
+
27
+
28
+ def bump(key: str, n: int = 1):
29
+ if _ENABLED:
30
+ COUNTS[key] += int(n)
31
+
32
+
33
+ def report() -> dict:
34
+ return dict(COUNTS)
daisychain/verified/kernel.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The GUDA-role path: a fast, vectorized INT8 GEMM on real CPU silicon.
2
+
3
+ This is where the *throughput* comes from -- real CPU SIMD (via numpy/torch
4
+ int32 accumulation), exactly GUDA's job. It is NOT a neural net and NOT
5
+ GPU-fast; its ceiling is the CPU. The neural verified op certifies that this
6
+ kernel is bit-faithful to the reference (verify_kernel.py) -- turning GUDA-style
7
+ "IEEE parity within tolerance" into "provably equal on this finite integer op".
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+
14
+ def gemm_int8(A: np.ndarray, B: np.ndarray) -> np.ndarray:
15
+ """Signed INT8 GEMM with exact int32 accumulation (tensor-core semantics)."""
16
+ a = A.astype(np.int32)
17
+ b = B.astype(np.int32)
18
+ return a @ b # exact integer matmul, no overflow for our sizes
19
+
20
+
21
+ def random_int8(shape, rng) -> np.ndarray:
22
+ return rng.integers(-128, 128, size=shape, dtype=np.int16).astype(np.int8)
daisychain/verified/lut.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Native-speed deployment of the verified units, without losing the guarantee.
2
+
3
+ A verified unit is a finite function. Its neural net is only needed to *prove*
4
+ correctness (N/N). For SPEED you materialize the proven function as a lookup
5
+ table -- run the net once over its whole (small) domain -- then every later call
6
+ is an array index at native memory speed. Because the net is N/N-verified, the
7
+ LUT is bit-identical to the net, which is bit-identical to the true op. So:
8
+
9
+ neural forward == LUT == native integer op (all bit-exact)
10
+
11
+ That's the "freeze the mesh to its matrix" lesson: verify once (slow, offline),
12
+ deploy native (fast). The LUTs are tiny: mul 256x256, requant 65536, relu 256.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+
18
+
19
+ def build_mul8_lut(mul) -> np.ndarray:
20
+ """[256,256] signed-product table, indexed by unsigned bytes. Net runs once."""
21
+ a = np.repeat(np.arange(256), 256)
22
+ b = np.tile(np.arange(256), 256)
23
+ prod = mul.mul_array(a, b) # verified neural multiply, ONCE
24
+ return prod.reshape(256, 256).astype(np.int64)
25
+
26
+
27
+ def build_requant16_lut(rq) -> np.ndarray:
28
+ """[65536] int16->int8 table, indexed by acc & 0xFFFF."""
29
+ return rq.requant_array(np.arange(65536)).astype(np.int64)
30
+
31
+
32
+ def build_relu8_lut(relu) -> np.ndarray:
33
+ """[256] int8 ReLU table, indexed by unsigned byte."""
34
+ return relu.relu_array(np.arange(256)).astype(np.int64)
35
+
36
+
37
+ class LUTBackend:
38
+ """GEMM via the materialized (verified) multiply table + integer accumulate."""
39
+ name = "lut"
40
+
41
+ def __init__(self, mul):
42
+ self.mul_lut = build_mul8_lut(mul)
43
+
44
+ def available(self):
45
+ return True
46
+
47
+ def gemm(self, A: np.ndarray, B: np.ndarray) -> np.ndarray:
48
+ au = (A.astype(np.int64) & 0xFF)
49
+ bu = (B.astype(np.int64) & 0xFF)
50
+ # products via table lookup, then sum over the contraction axis
51
+ prod = self.mul_lut[au[:, None, :], bu.T[None, :, :]] # (m, n, k)
52
+ from . import instrument
53
+ instrument.bump("VerifiedMul(LUT).gemms", 1)
54
+ instrument.bump("VerifiedMul(LUT).products", prod.size)
55
+ return prod.sum(axis=2)
daisychain/verified/mul8.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verified neural INT8 multiplier -- the atom of a GPU tensor core / VNNI lane.
2
+
3
+ A monolithic MLP cannot learn a bit-exact 8x8 multiply (the high-order product
4
+ bits are too nonlinear). So -- exactly like the byte-slice / ripple trick the
5
+ other projects use for hard functions -- we shrink the *verified atom* to a
6
+ 4-bit unsigned multiply and compose everything exactly:
7
+
8
+ atom: NeuralMul4 -- unsigned 4x4 -> 8, domain 16*16 = 256, verified N/N.
9
+ 8x8: a*b = ah*bh<<8 + (ah*bl + al*bh)<<4 + al*bl (unsigned), exact.
10
+ signed: a_s = a_u - 256*a7 ; Baugh-Wooley correction, exact integer glue.
11
+
12
+ Only the 4x4 multiply is neural (and N/N-proven); the shifts, adds and sign
13
+ correction are exact composition. The parallel *throughput* of thousands of such
14
+ lanes is NOT here -- that needs real silicon (kernel.py, the GUDA-role path).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+ import torch
20
+
21
+ from .common import bits_of, int_of, pm, mlp, verify, train
22
+
23
+
24
+ class NeuralMul4:
25
+ """N/N-verified unsigned 4x4 -> 8 multiplier (the finite atom)."""
26
+
27
+ def __init__(self, h: int = 128, layers: int = 3):
28
+ self.net = mlp(8, 8, h=h, layers=layers)
29
+
30
+ def dataset(self) -> tuple[torch.Tensor, torch.Tensor]:
31
+ X, Y = [], []
32
+ for a in range(16):
33
+ ab = bits_of(a, 4)
34
+ for b in range(16):
35
+ X.append(pm(torch.cat([ab, bits_of(b, 4)])))
36
+ Y.append(bits_of(a * b, 8))
37
+ return torch.stack(X), torch.stack(Y)
38
+
39
+ def fit(self, steps: int = 4000, lr: float = 2e-3, tag: str = "mul4"):
40
+ X, Y = self.dataset()
41
+ train(self.net, X, Y, steps=steps, lr=lr, tag=tag)
42
+ return self
43
+
44
+ def verify(self) -> tuple[int, int]:
45
+ X, Y = self.dataset()
46
+ return verify(self.net, X, Y)
47
+
48
+ @torch.no_grad()
49
+ def mul(self, a: int, b: int) -> int:
50
+ self.net.eval()
51
+ x = pm(torch.cat([bits_of(a & 0xF, 4), bits_of(b & 0xF, 4)])).unsqueeze(0)
52
+ return int_of((self.net(x)[0] > 0).float())
53
+
54
+ @torch.no_grad()
55
+ def mul_array(self, a: np.ndarray, b: np.ndarray) -> np.ndarray:
56
+ """Batched unsigned 4x4 -> 8 over arrays (one neural forward for all)."""
57
+ self.net.eval()
58
+ a = (np.asarray(a).astype(np.int64) & 0xF)
59
+ b = (np.asarray(b).astype(np.int64) & 0xF)
60
+ idx = np.arange(4)
61
+ bits_a = (a[:, None] >> idx) & 1
62
+ bits_b = (b[:, None] >> idx) & 1
63
+ x = np.concatenate([bits_a, bits_b], axis=1).astype(np.float32) * 2.0 - 1.0
64
+ out = (self.net(torch.from_numpy(x)) > 0).to(torch.int64).numpy()
65
+ from . import instrument
66
+ instrument.bump("NeuralMul4.forward_calls", 1)
67
+ instrument.bump("NeuralMul4.products", a.shape[0])
68
+ return (out * (1 << np.arange(8))).sum(axis=1)
69
+
70
+
71
+ class NeuralMul8:
72
+ """Signed 8x8 -> 16 multiply, composed exactly from the verified 4x4 atom."""
73
+
74
+ def __init__(self, h: int = 128, layers: int = 3):
75
+ self.atom = NeuralMul4(h=h, layers=layers)
76
+
77
+ def fit(self, steps: int = 4000, lr: float = 2e-3, tag: str = "mul4"):
78
+ self.atom.fit(steps=steps, lr=lr, tag=tag)
79
+ return self
80
+
81
+ def verify_atom(self) -> tuple[int, int]:
82
+ return self.atom.verify()
83
+
84
+ def _umul8(self, a_u: int, b_u: int) -> int:
85
+ """Unsigned 8x8 -> 16 via four verified 4x4 sub-products (exact glue)."""
86
+ al, ah = a_u & 0xF, (a_u >> 4) & 0xF
87
+ bl, bh = b_u & 0xF, (b_u >> 4) & 0xF
88
+ ll = self.atom.mul(al, bl)
89
+ lh = self.atom.mul(al, bh)
90
+ hl = self.atom.mul(ah, bl)
91
+ hh = self.atom.mul(ah, bh)
92
+ return ll + ((lh + hl) << 4) + (hh << 8)
93
+
94
+ def mul(self, a: int, b: int) -> int:
95
+ """Signed product via the verified atom + exact Baugh-Wooley correction."""
96
+ a_u, b_u = a & 0xFF, b & 0xFF
97
+ a7, b7 = (a_u >> 7) & 1, (b_u >> 7) & 1
98
+ prod = self._umul8(a_u, b_u) - (a7 * b_u << 8) - (b7 * a_u << 8) + (a7 * b7 << 16)
99
+ prod &= 0xFFFF # 16-bit two's complement
100
+ return prod - 65536 if prod >= 32768 else prod
101
+
102
+ @torch.no_grad()
103
+ def mul_array(self, a: np.ndarray, b: np.ndarray) -> np.ndarray:
104
+ """Batched signed 8x8 -> 16 over arrays, via the verified 4x4 atom.
105
+
106
+ Four nibble sub-products are batched into ONE neural forward, then the
107
+ exact shift/add glue and Baugh-Wooley sign correction are applied in
108
+ numpy. Result is bit-exact (the atom is N/N-verified)."""
109
+ a = np.asarray(a).astype(np.int64).ravel()
110
+ b = np.asarray(b).astype(np.int64).ravel()
111
+ au, bu = a & 0xFF, b & 0xFF
112
+ al, ah = au & 0xF, (au >> 4) & 0xF
113
+ bl, bh = bu & 0xF, (bu >> 4) & 0xF
114
+ n = au.shape[0]
115
+ pa = np.concatenate([al, al, ah, ah])
116
+ pb = np.concatenate([bl, bh, bl, bh])
117
+ prod = self.atom.mul_array(pa, pb)
118
+ ll, lh, hl, hh = prod[:n], prod[n:2*n], prod[2*n:3*n], prod[3*n:4*n]
119
+ u = ll + ((lh + hl) << 4) + (hh << 8)
120
+ a7, b7 = (au >> 7) & 1, (bu >> 7) & 1
121
+ p = (u - (a7 * bu << 8) - (b7 * au << 8) + (a7 * b7 << 16)) & 0xFFFF
122
+ return np.where(p >= 32768, p - 65536, p)
123
+
124
+ @torch.no_grad()
125
+ def verify(self, full: bool = True) -> tuple[int, int]:
126
+ """Exhaustively check the composed signed multiply over all 65536 inputs."""
127
+ ok = 0
128
+ for a in range(-128, 128):
129
+ for b in range(-128, 128):
130
+ if self.mul(a, b) == a * b:
131
+ ok += 1
132
+ return ok, 256 * 256
daisychain/verified/mul8_tied.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TIED neural multiplier -- one small cell, iterated (a la neural-raytracing).
2
+
3
+ Multiply is inherently iterative (shift-and-add), so instead of four separate
4
+ 4x4 atoms we use a SINGLE weight-tied cell applied across the 8 steps -- exactly
5
+ the raytracing pattern of marching one shared cell rather than stacking many.
6
+
7
+ The tied cell is the conditional-add step of a shift-add multiplier:
8
+
9
+ cell(acc, a, enable) = acc + (a if enable else 0) # 8+8+1 -> 9 bits
10
+
11
+ Its domain is 2^8 * 2^8 * 2 = 131072 -- enumerable, so the cell is verified
12
+ BIT-EXACT over its whole domain (N/N). The shift and re-assembly between steps
13
+ are exact wiring, not neural. Looping the one tied cell 8x yields the full
14
+ unsigned 8x8 -> 16 product; signed uses the exact Baugh-Wooley correction.
15
+
16
+ One verified cell, reused 8 times -- the smallest possible learned core.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import torch
21
+
22
+ from .common import bits_of, int_of, pm, mlp, verify, train
23
+
24
+
25
+ class NeuralMACStep:
26
+ """N/N-verified tied cell: acc8 + (a8 if enable else 0) -> 9-bit sum."""
27
+
28
+ def __init__(self, h: int = 128, layers: int = 3):
29
+ self.net = mlp(17, 9, h=h, layers=layers) # acc(8) + a(8) + enable(1) -> 9
30
+
31
+ def dataset(self) -> tuple[torch.Tensor, torch.Tensor]:
32
+ X, Y = [], []
33
+ for acc in range(256):
34
+ ab = bits_of(acc, 8)
35
+ for a in range(256):
36
+ aa = bits_of(a, 8)
37
+ for en in (0, 1):
38
+ X.append(pm(torch.cat([ab, aa, torch.tensor([float(en)])])))
39
+ Y.append(bits_of(acc + (a if en else 0), 9))
40
+ return torch.stack(X), torch.stack(Y)
41
+
42
+ def fit(self, steps: int = 5000, lr: float = 2e-3, tag: str = "macstep"):
43
+ X, Y = self.dataset()
44
+ train(self.net, X, Y, steps=steps, lr=lr, tag=tag)
45
+ return self
46
+
47
+ def verify(self) -> tuple[int, int]:
48
+ X, Y = self.dataset()
49
+ return verify(self.net, X, Y)
50
+
51
+ @torch.no_grad()
52
+ def step(self, acc: int, a: int, enable: int) -> int:
53
+ self.net.eval()
54
+ x = pm(torch.cat([bits_of(acc & 0xFF, 8), bits_of(a & 0xFF, 8),
55
+ torch.tensor([float(enable)])])).unsqueeze(0)
56
+ return int_of((self.net(x)[0] > 0).float())
57
+
58
+
59
+ class TiedMul8:
60
+ """Signed 8x8 -> 16 multiply from ONE tied MAC-step cell, iterated 8x."""
61
+
62
+ def __init__(self, h: int = 128, layers: int = 3):
63
+ self.cell = NeuralMACStep(h=h, layers=layers)
64
+
65
+ def fit(self, steps: int = 5000, lr: float = 2e-3, tag: str = "macstep"):
66
+ self.cell.fit(steps=steps, lr=lr, tag=tag)
67
+ return self
68
+
69
+ def verify_cell(self) -> tuple[int, int]:
70
+ return self.cell.verify()
71
+
72
+ def _umul8(self, a_u: int, b_u: int) -> int:
73
+ """Unsigned product via the tied shift-add loop (cell reused 8x)."""
74
+ combined = b_u & 0xFF # low byte holds b, high byte acc
75
+ for _ in range(8):
76
+ enable = combined & 1 # current LSB of b
77
+ hi = (combined >> 8) & 0xFF
78
+ s = self.cell.step(hi, a_u, enable) # 9-bit: hi + (a if enable)
79
+ combined = (combined & 0xFF) | (s << 8)
80
+ combined >>= 1 # shift right one place
81
+ return combined & 0xFFFF
82
+
83
+ def mul(self, a: int, b: int) -> int:
84
+ a_u, b_u = a & 0xFF, b & 0xFF
85
+ a7, b7 = (a_u >> 7) & 1, (b_u >> 7) & 1
86
+ prod = self._umul8(a_u, b_u) - (a7 * b_u << 8) - (b7 * a_u << 8) + (a7 * b7 << 16)
87
+ prod &= 0xFFFF
88
+ return prod - 65536 if prod >= 32768 else prod
89
+
90
+ @torch.no_grad()
91
+ def verify_unsigned(self) -> tuple[int, int]:
92
+ ok = 0
93
+ for a in range(256):
94
+ for b in range(256):
95
+ if self._umul8(a, b) == a * b:
96
+ ok += 1
97
+ return ok, 256 * 256
98
+
99
+ @torch.no_grad()
100
+ def verify(self) -> tuple[int, int]:
101
+ ok = 0
102
+ for a in range(-128, 128):
103
+ for b in range(-128, 128):
104
+ if self.mul(a, b) == a * b:
105
+ ok += 1
106
+ return ok, 256 * 256
daisychain/verified/ops.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verified INT8 activation / requantize units -- the rest of a quantized layer.
2
+
3
+ A quantized linear layer is: int8 x int8 -> int32 accumulate -> requantize to
4
+ int8 -> activation. The multiply/accumulate is covered by mul8 + exact int32
5
+ sum. These two units cover the tail, each over a FINITE, enumerable domain so
6
+ they are verified BIT-EXACT (N/N):
7
+
8
+ NeuralReLU8 int8 -> int8, y = max(0, x) domain 256
9
+ NeuralRequant16 int16 -> int8, y = sat_int8(x >> shift) domain 65536
10
+
11
+ The int32 -> int16 narrowing that precedes requantize is an EXACT saturating
12
+ clamp (integer wiring, not neural). So a full qlinear is exact/verified through
13
+ every stage.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+ import torch
19
+
20
+ from .common import bits_of, int_of, pm, mlp, verify, train
21
+ from . import instrument
22
+
23
+
24
+ def _s(v: int, bits: int) -> int:
25
+ """two's-complement raw -> signed."""
26
+ m = 1 << (bits - 1)
27
+ return v - (1 << bits) if v >= m else v
28
+
29
+
30
+ def sat_int8(x: int) -> int:
31
+ return -128 if x < -128 else (127 if x > 127 else x)
32
+
33
+
34
+ class NeuralReLU8:
35
+ """N/N-verified INT8 ReLU: y = max(0, x)."""
36
+
37
+ def __init__(self, h: int = 64, layers: int = 2):
38
+ self.net = mlp(8, 8, h=h, layers=layers)
39
+
40
+ def dataset(self):
41
+ X, Y = [], []
42
+ for b in range(256):
43
+ X.append(pm(bits_of(b, 8)))
44
+ Y.append(bits_of(max(0, _s(b, 8)) & 0xFF, 8))
45
+ return torch.stack(X), torch.stack(Y)
46
+
47
+ def fit(self, steps: int = 3000, lr: float = 2e-3, tag: str = "relu8"):
48
+ X, Y = self.dataset(); train(self.net, X, Y, steps=steps, lr=lr, tag=tag); return self
49
+
50
+ def verify(self):
51
+ X, Y = self.dataset(); return verify(self.net, X, Y)
52
+
53
+ @torch.no_grad()
54
+ def relu(self, x: int) -> int:
55
+ self.net.eval()
56
+ raw = int_of((self.net(pm(bits_of(x & 0xFF, 8)).unsqueeze(0))[0] > 0).float())
57
+ return _s(raw, 8)
58
+
59
+ @torch.no_grad()
60
+ def relu_array(self, arr: np.ndarray) -> np.ndarray:
61
+ """Batched int8 ReLU over an array (one neural forward)."""
62
+ self.net.eval()
63
+ a = np.asarray(arr).astype(np.int64).ravel() & 0xFF
64
+ bits = ((a[:, None] >> np.arange(8)) & 1).astype(np.float32) * 2.0 - 1.0
65
+ out = (self.net(torch.from_numpy(bits)) > 0).to(torch.int64).numpy()
66
+ raw = (out * (1 << np.arange(8))).sum(axis=1)
67
+ instrument.bump("NeuralReLU8.forward_calls", 1)
68
+ instrument.bump("NeuralReLU8.elements", a.shape[0])
69
+ return np.where(raw >= 128, raw - 256, raw).reshape(np.asarray(arr).shape)
70
+
71
+
72
+ class NeuralRequant16:
73
+ """N/N-verified requantize: int16 -> int8, y = sat_int8(x >> shift)."""
74
+
75
+ def __init__(self, shift: int = 8, h: int = 256, layers: int = 3):
76
+ self.shift = int(shift)
77
+ self.net = mlp(16, 8, h=h, layers=layers)
78
+
79
+ def _ref(self, x_signed: int) -> int:
80
+ return sat_int8(x_signed >> self.shift) # arithmetic shift, saturate
81
+
82
+ def dataset(self):
83
+ X, Y = [], []
84
+ for b in range(65536):
85
+ X.append(pm(bits_of(b, 16)))
86
+ Y.append(bits_of(self._ref(_s(b, 16)) & 0xFF, 8))
87
+ return torch.stack(X), torch.stack(Y)
88
+
89
+ def fit(self, steps: int = 6000, lr: float = 2e-3, tag: str = "requant16"):
90
+ X, Y = self.dataset(); train(self.net, X, Y, steps=steps, lr=lr, tag=tag); return self
91
+
92
+ def verify(self):
93
+ X, Y = self.dataset(); return verify(self.net, X, Y)
94
+
95
+ @torch.no_grad()
96
+ def requant(self, x: int) -> int:
97
+ self.net.eval()
98
+ raw = int_of((self.net(pm(bits_of(x & 0xFFFF, 16)).unsqueeze(0))[0] > 0).float())
99
+ return _s(raw, 8)
100
+
101
+ @torch.no_grad()
102
+ def requant_array(self, arr: np.ndarray) -> np.ndarray:
103
+ """Batched int16->int8 requantize over an array (one neural forward)."""
104
+ self.net.eval()
105
+ a = np.asarray(arr).astype(np.int64).ravel() & 0xFFFF
106
+ bits = ((a[:, None] >> np.arange(16)) & 1).astype(np.float32) * 2.0 - 1.0
107
+ out = (self.net(torch.from_numpy(bits)) > 0).to(torch.int64).numpy()
108
+ raw = (out * (1 << np.arange(8))).sum(axis=1)
109
+ instrument.bump("NeuralRequant16.forward_calls", 1)
110
+ instrument.bump("NeuralRequant16.elements", a.shape[0])
111
+ return np.where(raw >= 128, raw - 256, raw).reshape(np.asarray(arr).shape)
daisychain/verified/qat.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quantization-aware training THROUGH the verified units.
2
+
3
+ This is the piece that was missing: a trainable layer whose FORWARD compute
4
+ actually runs on the verified GUDA logic --
5
+
6
+ quantize -> NeuralMul (verified INT8 multiply) GEMM -> NeuralRequant16 ->
7
+ NeuralReLU8 -> dequantize
8
+
9
+ -- while the BACKWARD uses a straight-through estimator (the integer path has no
10
+ gradient), so ordinary float weights still learn. With instrument.enable(), each
11
+ unit records how many times its neural forward ran, so a training run leaves
12
+ hard evidence (call counts) that it computed through the units, not around them.
13
+
14
+ Honest cost: every forward multiply is a neural forward pass -> this is SLOW
15
+ (functional, not fast). It is a correctness/《evidence》demo, not a speed path.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import numpy as np
20
+ import torch
21
+ import torch.nn as nn
22
+
23
+ from .backends import NeuralBackend
24
+
25
+
26
+ class _VerifiedQGEMM(torch.autograd.Function):
27
+ @staticmethod
28
+ def forward(ctx, x, w, mul, requant, relu_unit, use_relu, luts):
29
+ ctx.save_for_backward(x, w)
30
+ ctx.device = x.device # verified units run on CPU;
31
+ xnp, wnp = x.detach().cpu().numpy(), w.detach().cpu().numpy()
32
+ sx = max(float(np.abs(xnp).max()) / 127.0, 1e-8)
33
+ sw = max(float(np.abs(wnp).max()) / 127.0, 1e-8)
34
+ xq = np.clip(np.round(xnp / sx), -128, 127).astype(np.int8)
35
+ wq = np.clip(np.round(wnp / sw), -128, 127).astype(np.int8)
36
+
37
+ if luts is not None:
38
+ # FAST path: the verified units, materialized as lookup tables
39
+ # (bit-identical to the neural forward, ~500x faster).
40
+ from . import instrument
41
+ acc = luts["backend"].gemm(xq, wq) # counts LUT products
42
+ acc16 = np.clip(acc, -32768, 32767).astype(np.int64)
43
+ yq = luts["requant"][acc16 & 0xFFFF]
44
+ instrument.bump("VerifiedRequant16(LUT).elements", acc16.size)
45
+ if use_relu:
46
+ yq = luts["relu"][yq & 0xFF]
47
+ instrument.bump("VerifiedReLU8(LUT).elements", yq.size)
48
+ else:
49
+ acc = NeuralBackend(mul).gemm(xq, wq) # verified multiply fires
50
+ acc16 = np.clip(acc, -32768, 32767).astype(np.int64)
51
+ yq = requant.requant_array(acc16) # requant16 fires
52
+ if use_relu:
53
+ yq = relu_unit.relu_array(yq) # relu8 fires
54
+ dequant = sx * sw * 256.0 # undo requant's >>8
55
+ return torch.from_numpy(yq.astype(np.float32) * dequant).to(ctx.device)
56
+
57
+ @staticmethod
58
+ def backward(ctx, gy):
59
+ # straight-through: treat the quantized path as y ≈ x @ w
60
+ x, w = ctx.saved_tensors
61
+ return gy @ w.t(), x.t() @ gy, None, None, None, None, None
62
+
63
+
64
+ def build_luts(mul, requant, relu_unit):
65
+ """Materialize the verified units as lookup tables (one-time). The result is
66
+ bit-identical to the neural forward but ~500x faster to run."""
67
+ from .lut import LUTBackend, build_requant16_lut, build_relu8_lut
68
+ return {"backend": LUTBackend(mul),
69
+ "requant": build_requant16_lut(requant),
70
+ "relu": build_relu8_lut(relu_unit)}
71
+
72
+
73
+ class VerifiedLinear(nn.Module):
74
+ """Linear layer whose forward is computed by the verified units.
75
+
76
+ fast=True materializes the units as LUTs (bit-identical, ~500x faster) so
77
+ verified training is practical; fast=False runs the neural forward (proof).
78
+ """
79
+
80
+ def __init__(self, in_f, out_f, mul, requant, relu_unit, use_relu=True,
81
+ fast=False):
82
+ super().__init__()
83
+ self.weight = nn.Parameter(torch.randn(in_f, out_f) * 0.3)
84
+ self.bias = nn.Parameter(torch.zeros(out_f))
85
+ self.mul, self.requant, self.relu_unit = mul, requant, relu_unit
86
+ self.use_relu = use_relu
87
+ self.luts = build_luts(mul, requant, relu_unit) if fast else None
88
+
89
+ def forward(self, x):
90
+ y = _VerifiedQGEMM.apply(x, self.weight, self.mul, self.requant,
91
+ self.relu_unit, self.use_relu, self.luts)
92
+ return y + self.bias
93
+
94
+
95
+ def _weights_dir():
96
+ import os
97
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), "weights")
98
+
99
+
100
+ def load_units(mul_pt=None, requant_pt=None, relu_pt=None):
101
+ """Load the TRAINED, N/N-verified units bundled with DaisyChain."""
102
+ import os
103
+ wd = _weights_dir()
104
+ mul_pt = mul_pt or os.path.join(wd, "mul8.pt")
105
+ requant_pt = requant_pt or os.path.join(wd, "requant16.pt")
106
+ relu_pt = relu_pt or os.path.join(wd, "relu8.pt")
107
+ from .mul8 import NeuralMul8
108
+ from .ops import NeuralReLU8, NeuralRequant16
109
+ mul = NeuralMul8()
110
+ mul.atom.net.load_state_dict(torch.load(mul_pt)["state_dict"]); mul.atom.net.eval()
111
+ relu = NeuralReLU8()
112
+ relu.net.load_state_dict(torch.load(relu_pt)["state_dict"]); relu.net.eval()
113
+ ck = torch.load(requant_pt)
114
+ rq = NeuralRequant16(shift=ck["shift"])
115
+ rq.net.load_state_dict(ck["state_dict"]); rq.net.eval()
116
+ return mul, rq, relu
daisychain/verified/weights/macstep.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2a0e771445a7bac7295af9156880e33693b6c5cf6ea0f1e24ae2daad3241c005
3
+ size 148608
daisychain/verified/weights/mul8.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:be360811bddcda90e7ffa8e6cb338673ab7c0902c5882f12bc0c89d84b05aef9
3
+ size 143452
daisychain/verified/weights/relu8.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b6806712b0302c003029b26c658c9a005850116308bc225d0a9a7e26c73e07ba
3
+ size 23284
daisychain/verified/weights/requant16.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0cca8f64e40b4969395b588d781a4ae49bca0ede8d13576ea03dbe0fe1cb2ea2
3
+ size 555160
daisychain/verified_task.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Example task that trains THROUGH the bundled verified units.
2
+
3
+ Same shape as ExampleTask, but the model is built from VerifiedLinear layers, so
4
+ every forward multiply/requant/ReLU runs on the trained N/N-verified INT8 units
5
+ (materialized as lookup tables for speed). Backward uses a straight-through
6
+ estimator, so ordinary weights still learn.
7
+
8
+ Run it with: DAISY_TASK=daisychain.verified_task:VerifiedTask daisychain-train
9
+ """
10
+ import torch
11
+ import torch.nn as nn
12
+
13
+ from .verified import VerifiedLinear, load_units, instrument
14
+
15
+
16
+ class VerifiedTask:
17
+ def __init__(self, fast: bool = True):
18
+ self.mul, self.rq, self.relu = load_units() # bundled trained weights
19
+ instrument.enable() # count unit invocations
20
+ self._fast = fast
21
+ g = torch.Generator().manual_seed(1234)
22
+ self.W = torch.randn(8, 1, generator=g)
23
+
24
+ def build_model(self):
25
+ torch.manual_seed(0)
26
+ return nn.Sequential(
27
+ VerifiedLinear(8, 8, self.mul, self.rq, self.relu, use_relu=True, fast=self._fast),
28
+ VerifiedLinear(8, 1, self.mul, self.rq, self.relu, use_relu=False, fast=self._fast),
29
+ )
30
+
31
+ def sample(self, n):
32
+ X = torch.randn(n, 8)
33
+ return X, X @ self.W
34
+
35
+ def loss(self, model, X, y):
36
+ return nn.functional.mse_loss(model(X), y)
docker/Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DaisyChain node image (CPU torch keeps it lean; GPU nodes use a real machine).
2
+ FROM python:3.11-slim
3
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
4
+ && pip install --no-cache-dir numpy psutil
5
+ WORKDIR /app
6
+ COPY daisychain/ ./daisychain/
7
+ COPY examples/ ./examples/
8
+ COPY config/ ./config/
9
+ COPY docker/node_entrypoint.sh ./node_entrypoint.sh
10
+ RUN chmod +x ./node_entrypoint.sh
11
+ ENV GLOO_SOCKET_IFNAME=eth0
12
+ ENV USE_LIBUV=0
13
+ ENV PYTHONUNBUFFERED=1
14
+ ENV DAISY_STATUS_FILE=/app/status.json
15
+ CMD ["./node_entrypoint.sh"]
docker/dashboard.Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # DaisyChain dashboard image — pure stdlib, no torch. Runs server.py standalone
2
+ # (its scanner import falls back to a flat import, so no torch package init).
3
+ FROM python:3.11-slim
4
+ WORKDIR /app
5
+ COPY daisychain/dashboard/server.py daisychain/dashboard/scanner.py ./
6
+ COPY config/ ./config/
7
+ ENV PYTHONUNBUFFERED=1
8
+ ENV DAISY_NODES_FILE=config/nodes.example.json
9
+ EXPOSE 8080
10
+ CMD ["python", "server.py"]
docker/docker-compose.yml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DaisyChain demo cluster: 3 node containers + a dashboard on one Docker network.
2
+ # In production each node is a real machine (see docs/TAILSCALE.md); this brings
3
+ # the whole thing up on one box so you can see it work.
4
+ #
5
+ # docker compose -f docker/docker-compose.yml up --build
6
+ # open http://localhost:8080
7
+ name: daisychain
8
+ x-node: &node
9
+ build: { context: .., dockerfile: docker/Dockerfile }
10
+ image: daisychain:latest
11
+ environment: &env
12
+ MASTER_ADDR: rank0
13
+ MASTER_PORT: "29560"
14
+ WORLD_SIZE: "3"
15
+ DAISY_STEPS: "300"
16
+ DAISY_STEP_SLEEP: "0.1" # demo pacing so the dashboard shows it live
17
+ # train through the emulated GPU logic (verified INT8 units) by default
18
+ DAISY_TASK: "daisychain.verified_task:VerifiedTask"
19
+ networks: [cluster]
20
+
21
+ services:
22
+ rank0:
23
+ <<: *node
24
+ environment: { <<: *env, RANK: "0", DAISY_CAPACITY: "8000" }
25
+ rank1:
26
+ <<: *node
27
+ depends_on: [rank0]
28
+ environment: { <<: *env, RANK: "1", DAISY_CAPACITY: "4000" }
29
+ rank2:
30
+ <<: *node
31
+ depends_on: [rank0]
32
+ environment: { <<: *env, RANK: "2", DAISY_CAPACITY: "2000" }
33
+ dashboard:
34
+ build: { context: .., dockerfile: docker/dashboard.Dockerfile }
35
+ image: daisychain-dashboard:latest
36
+ depends_on: [rank0, rank1, rank2]
37
+ environment:
38
+ DAISY_NODES_FILE: config/nodes.example.json
39
+ DAISY_EXPECTED_WORLD: "3"
40
+ DAISY_BASE_BATCH: "32"
41
+ ports: ["8080:8080"]
42
+ networks: [cluster]
43
+
44
+ networks:
45
+ cluster: { driver: bridge }
docker/node_entrypoint.sh ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # A DaisyChain node: run the dashboard agent in the background, then train.
3
+ # After training, keep the agent alive so the dashboard can still show status.
4
+ set -e
5
+ python -m daisychain.dashboard.agent &
6
+ python -m daisychain.train || echo "[node] training exited"
7
+ echo "[node] training done — agent still serving status"
8
+ wait
docs/CUSTOM_TASK.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Training your own model
2
+
3
+ DaisyChain trains any **Task** — an object with three methods:
4
+
5
+ ```python
6
+ import torch, torch.nn as nn
7
+
8
+ class MyTask:
9
+ def build_model(self) -> nn.Module:
10
+ torch.manual_seed(0) # deterministic -> identical on every node
11
+ return nn.Sequential(nn.Linear(16, 64), nn.ReLU(), nn.Linear(64, 10))
12
+
13
+ def sample(self, n): # this node's data shard
14
+ X = torch.randn(n, 16)
15
+ y = torch.randint(0, 10, (n,))
16
+ return X, y
17
+
18
+ def loss(self, model, X, y): # mean loss over the batch
19
+ return nn.functional.cross_entropy(model(X), y)
20
+ ```
21
+
22
+ ## Point DaisyChain at it
23
+
24
+ ```bash
25
+ export DAISY_TASK="my_task:MyTask" # module:Class, must be importable
26
+ daisychain-train
27
+ ```
28
+
29
+ Copy `examples/my_task_template.py` to start.
30
+
31
+ ## Rules that matter
32
+
33
+ 1. **`build_model` must be deterministic** (seed it). Every node builds the model
34
+ independently, then rank 0's weights are broadcast — but seeding keeps shapes
35
+ and buffers consistent.
36
+ 2. **`sample(n)` should return this node's shard.** For real datasets, split by
37
+ `RANK` (e.g. different files/row-ranges per rank) so nodes don't all train on
38
+ the same rows. Read `os.environ["RANK"]` / `WORLD_SIZE`.
39
+ 3. **The model must fit on one node.** DaisyChain pools compute, not memory.
40
+ 4. Keep it **small.** See [LIMITS.md](LIMITS.md).
41
+
42
+ ## Knobs (env)
43
+
44
+ | var | default | meaning |
45
+ |-----|---------|---------|
46
+ | `DAISY_TASK` | example | `module:Class` |
47
+ | `DAISY_STEPS` | 300 | training steps |
48
+ | `DAISY_LR` | 0.05 | learning rate |
49
+ | `DAISY_OPTIMIZER` | sgd | `sgd` or `adam` |
50
+ | `DAISY_BASE_BATCH` | 32 | per-node base batch (scaled by capacity) |
51
+ | `DAISY_SAVE` | daisychain_model.pt | where rank 0 saves |
52
+ | `DAISY_FORCE_CPU` | – | set `1` to ignore a local GPU |
docs/LIMITS.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Honest limits — read before you rely on DaisyChain
2
+
3
+ DaisyChain is a real tool for a **specific** job: chaining spare/old machines to
4
+ train **small** models faster by pooling their compute. It is not magic, and it
5
+ is easy to misapply. Here's the honest envelope.
6
+
7
+ ## What it does
8
+ - **Data-parallel** training: every node holds a full copy of the model and
9
+ trains on its slice of the data; gradients are averaged across the network.
10
+ - **Capacity-weighted**: each node measures its own speed (CPU or GPU) and takes
11
+ a proportional batch, so a faster machine does more. GPU nodes auto-join and
12
+ carry more load; CPU-only nodes still participate.
13
+
14
+ ## What it does NOT do (expect these)
15
+ - ❌ **Not GPU-class training.** N pooled old CPUs/GPUs are still that class of
16
+ hardware. A single modern GPU will beat the whole cluster for real models.
17
+ - ❌ **Pools compute, not memory.** Every node needs the *whole* model in RAM
18
+ (or VRAM). You **cannot** train a model bigger than a single node can hold.
19
+ Chaining 5 laptops does not give you one big machine.
20
+ - ❌ **Sublinear scaling.** Gradient sync over WiFi is bandwidth-bound, and the
21
+ **slowest node paces every step** (synchronous). Each added node helps less
22
+ than the last; a slow link can erase the benefit.
23
+ - ❌ **Not for huge/modern models.** Transformers/large CNNs at CPU (or old-GPU)
24
+ speed are impractically slow.
25
+
26
+ ## The sweet spot
27
+ Small models (small MLPs, tabular/classifier tasks, small fine-tunes),
28
+ a handful of machines, model fits on each, you want more throughput and don't
29
+ have a GPU handy. Education, research, retro/old-hardware hobby clusters.
30
+
31
+ ## Hardware / OS
32
+ - **Multi-node reliably runs on Linux/macOS.** On **Windows**, gloo tensor
33
+ collectives are unstable — use **Docker** or **WSL**. Single-machine use is
34
+ fine on Windows.
35
+ - **Old GPUs** must be new enough for current PyTorch (compute capability ≳ 5.0,
36
+ roughly GTX 900 / Maxwell and up). Kepler/Fermi-era cards won't work with
37
+ modern torch.
38
+ - Put all nodes on the same **interface** (Tailscale `tailscale0`, or a real LAN
39
+ NIC) via `GLOO_SOCKET_IFNAME` — not a VPN/Docker/WSL virtual NIC.
40
+
41
+ ## The one-line version
42
+ You can chain old machines to train a **small** model together, faster by
43
+ throughput, with honest sublinear scaling — and DaisyChain won't pretend to be
44
+ more than that.
docs/QUICKSTART.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DaisyChain Quickstart
2
+
3
+ Two ways to run. **Docker** is the most reliable (especially on Windows).
4
+
5
+ ## A. Docker — one command (demo cluster on one machine)
6
+
7
+ ```bash
8
+ docker compose -f docker/docker-compose.yml up --build
9
+ # open http://localhost:8080
10
+ ```
11
+
12
+ This starts 3 node containers + the dashboard on a Docker network — the whole
13
+ pipeline, so you can see connectivity, the capacity plan, and live training.
14
+ Stop with `docker compose -f docker/docker-compose.yml down`.
15
+
16
+ Windows: just run `scripts\setup.bat` and pick **[1] Docker**.
17
+
18
+ ## B. Python — real machines
19
+
20
+ On **every** machine:
21
+
22
+ ```bash
23
+ pip install torch numpy psutil
24
+ pip install -e . # from the repo, or `pip install daisychain`
25
+ ```
26
+
27
+ Set the cluster env (copy `config/cluster.example.env`), changing only `RANK`
28
+ per machine, then run:
29
+
30
+ ```bash
31
+ export MASTER_ADDR=100.101.102.10 # coordinator IP (Tailscale 100.x recommended)
32
+ export MASTER_PORT=29560
33
+ export WORLD_SIZE=3
34
+ export RANK=0 # 1, 2, ... on the others
35
+ export GLOO_SOCKET_IFNAME=tailscale0 # your mesh/LAN NIC
36
+ daisychain-train
37
+ ```
38
+
39
+ Windows: run `scripts\setup.bat` and pick **[2] Python** (it prompts for these).
40
+
41
+ ## Watch it
42
+
43
+ Run the dashboard anywhere that can reach the nodes:
44
+
45
+ ```bash
46
+ # edit config/nodes.example.json with your node hosts, then:
47
+ daisychain-dashboard # http://localhost:8080
48
+ ```
49
+
50
+ ## Train your own model
51
+
52
+ The default is a tiny example task. To train **your** model, see
53
+ [CUSTOM_TASK.md](CUSTOM_TASK.md) — copy `examples/my_task_template.py`, fill in
54
+ `build_model` / `sample` / `loss`, and set `DAISY_TASK=your_module:YourTask`.
55
+
56
+ **Before you rely on it, read [LIMITS.md](LIMITS.md).** DaisyChain pools compute,
57
+ not memory, and is for *small* models on spare hardware — not GPU-class training.
docs/TAILSCALE.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Connecting machines with Tailscale (recommended)
2
+
3
+ DaisyChain needs every node reachable by a **stable IP on one interface**.
4
+ Tailscale gives you exactly that: a private mesh where each machine gets a
5
+ `100.x.y.z` address on the `tailscale0` interface, reachable P2P across NATs and
6
+ different networks — no port-forwarding.
7
+
8
+ ## 1. Install on every machine
9
+ - Linux: `curl -fsSL https://tailscale.com/install.sh | sh`
10
+ - Windows / macOS: <https://tailscale.com/download>
11
+
12
+ Bring each up (same tailnet / account) and note its IP:
13
+
14
+ ```bash
15
+ sudo tailscale up
16
+ tailscale ip -4 # e.g. 100.101.102.10
17
+ tailscale status # see every machine + IP
18
+ ```
19
+
20
+ ## 2. Configure the cluster
21
+ Pick one machine as **rank 0** and use its Tailscale IP as `MASTER_ADDR`. On
22
+ every machine:
23
+
24
+ ```bash
25
+ export MASTER_ADDR=100.101.102.10
26
+ export MASTER_PORT=29560
27
+ export WORLD_SIZE=3
28
+ export GLOO_SOCKET_IFNAME=tailscale0 # pin gloo to the mesh NIC
29
+ export RANK=0 # 1, 2, ... on the others
30
+ daisychain-train
31
+ ```
32
+
33
+ `GLOO_SOCKET_IFNAME=tailscale0` is the important line — it stops gloo from
34
+ wandering onto a LAN/VPN/Docker interface.
35
+
36
+ ## 3. Verify
37
+ Run `daisychain-dashboard` (point `config/nodes.example.json` at the Tailscale
38
+ IPs). Green banner = every node reachable and ports open → launch training.
39
+
40
+ > On Windows the Tailscale interface name differs and gloo tensor collectives are
41
+ > unstable anyway — prefer Linux nodes or Docker for the actual training.
examples/my_task_template.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Template: copy this, fill in your model + data, then set
2
+
3
+ DAISY_TASK=my_task_template:MyTask
4
+
5
+ (make sure the file is importable, e.g. run daisychain-train from this folder or
6
+ pip-install your package). Keep build_model deterministic so every node starts
7
+ from identical weights.
8
+ """
9
+ import torch
10
+ import torch.nn as nn
11
+
12
+
13
+ class MyTask:
14
+ def build_model(self) -> nn.Module:
15
+ torch.manual_seed(0) # identical init on every node
16
+ # TODO: return YOUR model
17
+ return nn.Sequential(nn.Linear(16, 64), nn.ReLU(), nn.Linear(64, 10))
18
+
19
+ def sample(self, n: int):
20
+ # TODO: return n training samples from THIS node's data shard as (X, y).
21
+ # For real data, shard by rank (e.g. different files/rows per RANK).
22
+ X = torch.randn(n, 16)
23
+ y = torch.randint(0, 10, (n,))
24
+ return X, y
25
+
26
+ def loss(self, model, X, y):
27
+ # TODO: your loss (mean over the batch)
28
+ return nn.functional.cross_entropy(model(X), y)
pyproject.toml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "daisychain"
7
+ version = "0.1.0"
8
+ description = "DaisyChain — Old Hardware Training Pipeline: chain spare machines into a data-parallel training cluster."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Dean Byrne (Quazim0t0)" }]
13
+ keywords = ["distributed-training", "old-hardware", "cluster", "cpu", "gpu", "data-parallel"]
14
+ dependencies = ["torch>=2.0", "numpy>=1.23"]
15
+
16
+ [project.optional-dependencies]
17
+ dashboard = ["psutil>=5.9"]
18
+
19
+ [project.scripts]
20
+ daisychain-train = "daisychain.train:main"
21
+ daisychain-agent = "daisychain.dashboard.agent:main"
22
+ daisychain-dashboard = "daisychain.dashboard.server:main"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/quzi93/daisychain"
26
+ HuggingFace = "https://huggingface.co/DaisyChainAI/old-hw-train"
27
+
28
+ [tool.setuptools]
29
+ packages = ["daisychain", "daisychain.dashboard", "daisychain.verified"]
30
+
31
+ [tool.setuptools.package-data]
32
+ "daisychain.verified" = ["weights/*.pt"]
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ torch>=2.0
2
+ numpy>=1.23
3
+ psutil>=5.9
scripts/setup.bat ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ REM ============================================================
3
+ REM DaisyChain - Old Hardware Training Pipeline : setup helper
4
+ REM ============================================================
5
+ setlocal enabledelayedexpansion
6
+ cd /d "%~dp0\.."
7
+
8
+ echo.
9
+ echo ____ _ ____ _ _
10
+ echo ^| _ \ __ _^(_^)___ _ _^/ ___^| ^|__ __ _^(_^)_ __
11
+ echo ^| ^| ^| / _` ^| / __^| ^| ^| ^| ^| ^| '_ \ / _` ^| ^| '_ \
12
+ echo ^| ^|_^| ^(_^| ^| \__ \ ^|_^| ^| ^|___^| ^| ^| ^| ^(_^| ^| ^| ^| ^| ^|
13
+ echo ^|____/ \__,_^|_^|___/\__, ^|\____^|_^| ^|_^|\__,_^|_^|_^| ^|_^|
14
+ echo ^|___/ Old Hardware Training Pipeline
15
+ echo.
16
+ echo Choose how to run DaisyChain on this machine:
17
+ echo [1] Docker (recommended on Windows - most reliable)
18
+ echo [2] Python (native; multi-node gloo is unstable on Windows - use WSL/Linux)
19
+ echo [3] Just install Python deps
20
+ echo [Q] Quit
21
+ echo.
22
+ set /p choice=" Your choice: "
23
+
24
+ if /i "%choice%"=="1" goto docker
25
+ if /i "%choice%"=="2" goto python
26
+ if /i "%choice%"=="3" goto deps
27
+ goto end
28
+
29
+ :docker
30
+ where docker >nul 2>nul
31
+ if errorlevel 1 (
32
+ echo [!] Docker not found. Install Docker Desktop from https://docker.com and re-run.
33
+ goto end
34
+ )
35
+ echo Building and starting the demo cluster ^(3 nodes + dashboard^)...
36
+ docker compose -f docker/docker-compose.yml up --build -d
37
+ echo.
38
+ echo [OK] Cluster starting. Opening the dashboard at http://localhost:8080
39
+ timeout /t 4 >nul
40
+ start "" http://localhost:8080
41
+ echo To stop: docker compose -f docker/docker-compose.yml down
42
+ goto end
43
+
44
+ :deps
45
+ where python >nul 2>nul
46
+ if errorlevel 1 ( echo [!] Python not found. Install Python 3.9+ first. & goto end )
47
+ echo Installing dependencies...
48
+ python -m pip install --upgrade pip
49
+ python -m pip install torch numpy psutil
50
+ python -m pip install -e .
51
+ echo [OK] Installed. You can now run: daisychain-train
52
+ goto end
53
+
54
+ :python
55
+ call :deps
56
+ echo.
57
+ echo ---- Configure this node ----
58
+ set /p master=" Coordinator IP (MASTER_ADDR, e.g. Tailscale 100.x): "
59
+ set /p world=" Total number of machines (WORLD_SIZE): "
60
+ set /p rank=" This machine's RANK (0 = coordinator): "
61
+ set /p iface=" Network interface (GLOO_SOCKET_IFNAME, e.g. tailscale0): "
62
+ set MASTER_ADDR=%master%
63
+ set MASTER_PORT=29560
64
+ set WORLD_SIZE=%world%
65
+ set RANK=%rank%
66
+ set GLOO_SOCKET_IFNAME=%iface%
67
+ set USE_LIBUV=0
68
+ echo.
69
+ echo [!] Note: native multi-node training over gloo is unstable on Windows.
70
+ echo If it hangs, use the Docker option or run the nodes on Linux/WSL.
71
+ echo Launching node RANK=%rank% ...
72
+ python -m daisychain.train
73
+ goto end
74
+
75
+ :end
76
+ echo.
77
+ pause
78
+ endlocal
scripts/setup.sh ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # DaisyChain setup helper (Linux/macOS).
3
+ set -e
4
+ cd "$(dirname "$0")/.."
5
+
6
+ echo "🌼 DaisyChain — Old Hardware Training Pipeline"
7
+ echo " [1] Docker (demo cluster on this box)"
8
+ echo " [2] Python node (join a real cluster)"
9
+ echo " [3] Install deps only"
10
+ read -rp " Choice: " c
11
+
12
+ case "$c" in
13
+ 1)
14
+ command -v docker >/dev/null || { echo "Install Docker first."; exit 1; }
15
+ docker compose -f docker/docker-compose.yml up --build -d
16
+ echo "Dashboard: http://localhost:8080 (stop: docker compose -f docker/docker-compose.yml down)"
17
+ ;;
18
+ 3|2)
19
+ python3 -m pip install --upgrade pip
20
+ python3 -m pip install torch numpy psutil
21
+ python3 -m pip install -e .
22
+ echo "Installed."
23
+ if [ "$c" = "2" ]; then
24
+ read -rp " MASTER_ADDR (coordinator IP): " MA
25
+ read -rp " WORLD_SIZE: " WS
26
+ read -rp " RANK (0=coordinator): " RK
27
+ read -rp " GLOO_SOCKET_IFNAME (e.g. tailscale0): " IF
28
+ export MASTER_ADDR="$MA" MASTER_PORT=29560 WORLD_SIZE="$WS" RANK="$RK"
29
+ export GLOO_SOCKET_IFNAME="$IF" USE_LIBUV=0
30
+ python3 -m daisychain.train
31
+ fi
32
+ ;;
33
+ *) echo "bye" ;;
34
+ esac