Old-hardware training through emulated GPU logic
Browse files- .gitignore +9 -0
- LICENSE +21 -0
- README.md +144 -0
- config/cluster.example.env +18 -0
- config/nodes.example.json +5 -0
- daisychain/__init__.py +13 -0
- daisychain/cluster.py +211 -0
- daisychain/dashboard/__init__.py +0 -0
- daisychain/dashboard/agent.py +60 -0
- daisychain/dashboard/scanner.py +85 -0
- daisychain/dashboard/server.py +127 -0
- daisychain/example_task.py +26 -0
- daisychain/task.py +33 -0
- daisychain/train.py +85 -0
- daisychain/verified/__init__.py +14 -0
- daisychain/verified/backends.py +61 -0
- daisychain/verified/common.py +63 -0
- daisychain/verified/instrument.py +34 -0
- daisychain/verified/kernel.py +22 -0
- daisychain/verified/lut.py +55 -0
- daisychain/verified/mul8.py +132 -0
- daisychain/verified/mul8_tied.py +106 -0
- daisychain/verified/ops.py +111 -0
- daisychain/verified/qat.py +116 -0
- daisychain/verified/weights/macstep.pt +3 -0
- daisychain/verified/weights/mul8.pt +3 -0
- daisychain/verified/weights/relu8.pt +3 -0
- daisychain/verified/weights/requant16.pt +3 -0
- daisychain/verified_task.py +36 -0
- docker/Dockerfile +15 -0
- docker/dashboard.Dockerfile +10 -0
- docker/docker-compose.yml +45 -0
- docker/node_entrypoint.sh +8 -0
- docs/CUSTOM_TASK.md +52 -0
- docs/LIMITS.md +44 -0
- docs/QUICKSTART.md +57 -0
- docs/TAILSCALE.md +41 -0
- examples/my_task_template.py +28 -0
- pyproject.toml +32 -0
- requirements.txt +3 -0
- scripts/setup.bat +78 -0
- scripts/setup.sh +34 -0
.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
|