One arithmetic on every device: mixed CPU+GPU optimizer, replica hash check, FTZ pin, subnormal gate, live f32 audit, probe/fragment fixes (2026-10-14)
Browse files- README.md +588 -322
- daisychain/cluster.py +116 -14
- test_cluster_mixed.py +98 -0
- test_cluster_reductions.py +13 -0
- web/package.json +1 -1
- web/public/app.js +30 -6
- web/public/transformer.js +9 -1
- web/public/verified_core.js +35 -1
- web/public/webgpu.js +62 -6
- web/test_fgemm_audit.js +46 -0
- web/test_probe_unproven.js +36 -0
README.md
CHANGED
|
@@ -1,322 +1,588 @@
|
|
| 1 |
-
---
|
| 2 |
-
license: mit
|
| 3 |
-
tags:
|
| 4 |
-
- distributed-training
|
| 5 |
-
- old-hardware
|
| 6 |
-
- int8
|
| 7 |
-
- webgpu
|
| 8 |
-
- webrtc
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
# DaisyChain-Train
|
| 12 |
-
|
| 13 |
-
Part of DaisyChain on Hugging Face
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
-
|
| 40 |
-
|
| 41 |
-
##
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
- **
|
| 45 |
-
|
| 46 |
-
-
|
| 47 |
-
- **
|
| 48 |
-
|
| 49 |
-
- **
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
- **
|
| 53 |
-
|
| 54 |
-
- **
|
| 55 |
-
|
| 56 |
-
- **
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
- **
|
| 62 |
-
- **
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
- **
|
| 66 |
-
|
| 67 |
-
- **
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
- **
|
| 72 |
-
-
|
| 73 |
-
|
| 74 |
-
- **
|
| 75 |
-
|
| 76 |
-
- **
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
-
|
| 82 |
-
|
| 83 |
-
---
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
``
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
###
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
**
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
**
|
| 261 |
-
|
| 262 |
-
- **
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
- **
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- distributed-training
|
| 5 |
+
- old-hardware
|
| 6 |
+
- int8
|
| 7 |
+
- webgpu
|
| 8 |
+
- webrtc
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# 🌼 DaisyChain-Train — Old Hardware Training Pipeline
|
| 12 |
+
|
| 13 |
+
**Part of DaisyChain on 🤗 Hugging Face → https://huggingface.co/DaisyChainAI**
|
| 14 |
+
Model page (weights + card): https://huggingface.co/DaisyChainAI/DaisyChain-Train
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
> **In plain terms:** DaisyChain-Train lets you use **old / spare machines** to
|
| 19 |
+
> train neural networks. The training runs through **emulated GPU logic** —
|
| 20 |
+
> verified INT8 units (GUDA-style) that stand in for a GPU's math — so machines
|
| 21 |
+
> *without* a modern GPU can still do the work. Chain several together and they
|
| 22 |
+
> train one shared model as a cluster.
|
| 23 |
+
> Before you rely on it, see what it **can't** do → [Limitations](docs/LIMITS.md).
|
| 24 |
+
|
| 25 |
+
**Use the hardware you already have to train.** Each machine runs the emulated
|
| 26 |
+
GPU logic (verified INT8 units — multiply / requantize / ReLU) to compute the
|
| 27 |
+
model, and DaisyChain pools the machines data-parallel: device selection,
|
| 28 |
+
capacity-weighted sharding, gradient sync, a P2P setup, and a live dashboard.
|
| 29 |
+
Two ways to run — **Docker** or **Python**.
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## ⚠️ Read this first
|
| 34 |
+
DaisyChain-Train is for **small models on spare hardware**. It **pools compute,
|
| 35 |
+
not memory** (the model must fit on one machine), scaling is **sublinear**, and
|
| 36 |
+
it is **not** a substitute for a real GPU on large models. Full envelope in
|
| 37 |
+
**[docs/LIMITS.md](docs/LIMITS.md)** — please read it before relying on it.
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## Feature list
|
| 42 |
+
|
| 43 |
+
### Python cluster trainer (`daisychain/`)
|
| 44 |
+
- **Data-parallel training across mixed machines** — each node trains its own
|
| 45 |
+
shard; gradients combine into the exact full-batch gradient, replicas stay
|
| 46 |
+
bit-identical.
|
| 47 |
+
- **Capacity-weighted sharding** — faster machines automatically take a
|
| 48 |
+
bigger share of the batch.
|
| 49 |
+
- **Emulated GPU compute (verified INT8 units)** — `VerifiedLinear` layers
|
| 50 |
+
run every forward multiply / requantize / ReLU through the bundled trained
|
| 51 |
+
units; cluster-wide unit-invocation counts printed by rank 0.
|
| 52 |
+
- **Bring your own model** — any `Task` (`build_model` / `sample` / `loss`)
|
| 53 |
+
via `DAISY_TASK`; template in `examples/my_task_template.py`.
|
| 54 |
+
- **Plain-float alternative task** — same cluster and pooling with ordinary
|
| 55 |
+
float math.
|
| 56 |
+
- **Live dashboard** (`daisychain-dashboard`) — readiness banner, P2P
|
| 57 |
+
connectivity scan, pooled cores/RAM, per-node capacity plan, live loss.
|
| 58 |
+
- **SpikeWhale control panel** (`spikewhale_panel`, `localhost:8899`) —
|
| 59 |
+
sliders for model size / training settings, any HF dataset you can access
|
| 60 |
+
(default streamed FineWeb-Edu), start/stop/re-adjust, live loss.
|
| 61 |
+
- **Docker demo cluster** — 3 nodes + dashboard in one command.
|
| 62 |
+
- **Windows helper** (`scripts\setup.bat`) and Tailscale mesh guide.
|
| 63 |
+
|
| 64 |
+
### DaisyChain-Web (`web/`) — browser P2P training
|
| 65 |
+
- **Zero-install nodes** — opening the page IS joining; devices on one
|
| 66 |
+
network auto-group (Snapdrop-style, by public IP).
|
| 67 |
+
- **Private cross-network rooms** — `?room=CODE` with **host approval** for
|
| 68 |
+
every join.
|
| 69 |
+
- **Full WebRTC mesh** — gradients travel peer-to-peer; the server only
|
| 70 |
+
signals and serves static files, it never sees weights or gradients.
|
| 71 |
+
- **Leader-follower runs** — whoever presses Start sets width / sequence /
|
| 72 |
+
batch-per-device / steps / learning rate for the whole group; config
|
| 73 |
+
broadcast on the wire.
|
| 74 |
+
- **Mid-run join** — late devices are synced in (weights + step) and
|
| 75 |
+
contribute from the next step.
|
| 76 |
+
- **Bit-identical replicas** — same seeded init, strict roster-order gradient
|
| 77 |
+
averaging, deterministic Adam with identical state on every peer; verified
|
| 78 |
+
live by per-step weight hashes.
|
| 79 |
+
- **Sync guard** — any weight-hash mismatch stops the run instead of
|
| 80 |
+
training past a fork; the step roster forbids silent partial averages.
|
| 81 |
+
- **Gradient repair** — a follower missing a roster gradient re-requests it
|
| 82 |
+
from the leader (8 steps retained), bit-exact, and the run continues.
|
| 83 |
+
- **Cross-device kernel probe** — every step, every device re-hashes a fixed
|
| 84 |
+
seeded int8 GEMM through its live kernel; catches broken arithmetic that
|
| 85 |
+
weight hashes cannot see.
|
| 86 |
+
- **Hardcoded FineWeb-Edu streaming** — the server reads random slices of
|
| 87 |
+
the 10BT parquet shards straight off the HF CDN via HTTP range requests
|
| 88 |
+
(pure-JS `hyparquet`); built-in corpus fallback offline.
|
| 89 |
+
- **Checkpoints** — download `.pt`, upload → **broadcast to the whole
|
| 90 |
+
group**; validated (magic, dims, tokenizer vocab) before accepting.
|
| 91 |
+
- **Inference kit** — one self-contained HTML file with the trained weights
|
| 92 |
+
baked in; generations offline, anywhere.
|
| 93 |
+
- **In-page generation** — prompt box on the trained model.
|
| 94 |
+
- **Old-hardware tier** — no WebGPU? The identical units run on CPU (same
|
| 95 |
+
bits, so CPU and GPU devices co-train in one group). There is no
|
| 96 |
+
plain-float path.
|
| 97 |
+
- **Large-message fragmentation** — multi-MB gradients/checkpoints chunked
|
| 98 |
+
at 48 KB over the data channels.
|
| 99 |
+
|
| 100 |
+
### Verified compute & kernels (web)
|
| 101 |
+
- **Verified INT8 units everywhere** — block-scaled int8 GEMM: exact LUT
|
| 102 |
+
products, exact int32 accumulation, bit-exact f32 epilogue with a pinned
|
| 103 |
+
rounding schedule; scales derived in JS f64 (division never runs on GPU).
|
| 104 |
+
- **Backends, best-first** — DP4A hardware int8 dot → LUT compute shader →
|
| 105 |
+
CPU mirror; every kernel **exact-gated at init** (bit-level compares) and
|
| 106 |
+
demoted to the mirror on any mismatch.
|
| 107 |
+
- **Continuous random-cell audit** at live training shapes.
|
| 108 |
+
- **Fused attention kernels** — gather/scatter head-strided q·kᵀ and a·v
|
| 109 |
+
straight from BT×C layout (CUTLASS ex. 36/52 style).
|
| 110 |
+
- **QKV dual-GEMM fusion** — shared left operand quantized once, one batch-3
|
| 111 |
+
dispatch (ex. 45); bit-identical.
|
| 112 |
+
- **B2B MLP chain** — both MLP GEMMs back-to-back on GPU with fused per-row
|
| 113 |
+
absmax reduction and on-device quantize (ex. 13 + 23); WGSL-exact respec
|
| 114 |
+
with a fround-stepped JS mirror; fma-contraction-immune by construction.
|
| 115 |
+
- **Dispatch-optimized backward** — overlapped independent GEMMs, batch-3
|
| 116 |
+
sibling fusions (ex. 05/24); bit-identical gradients; optional int8 STE
|
| 117 |
+
backward path (dormant, 1.21× vs float).
|
| 118 |
+
|
| 119 |
+
### Verification stack (web)
|
| 120 |
+
- **Exact init gates** on every kernel, every device, every boot — including
|
| 121 |
+
gates that "gate the gate" with discriminating boundary inputs.
|
| 122 |
+
- **IEEE-754 binary32 oracle** in exact BigInt arithmetic — proves the JS
|
| 123 |
+
epilogue mirror is spec-correct (rejects the old mirror on 34% of inputs).
|
| 124 |
+
- **Metamorphic property suite** — reference-free relations + definitional
|
| 125 |
+
absolutes; **4/4** on an externally-authored bug corpus, matching the
|
| 126 |
+
differential gate.
|
| 127 |
+
- **RDNA2 ISA audit hardenings** — bit-level (−0-aware) gate comparisons;
|
| 128 |
+
proof that FMA contraction cannot change the quantize.
|
| 129 |
+
- **Twelve-suite test chain** (`cd web && npm test`) — convergence, replicas,
|
| 130 |
+
oracle, gates, properties, external corpus, **self-corpus** (the instruments
|
| 131 |
+
scored against my own bugs), B2B, optimizer, transformer LM, int8 backward,
|
| 132 |
+
non-finite quantize refusal;
|
| 133 |
+
results in [web/TEST_RESULTS.md](web/TEST_RESULTS.md).
|
| 134 |
+
- **Dirty-buffer gate** — the pool is poisoned before a re-sweep so state bugs
|
| 135 |
+
(a kernel assuming zeroed memory) are caught deterministically rather than
|
| 136 |
+
by ordering luck.
|
| 137 |
+
|
| 138 |
+
### Documentation
|
| 139 |
+
- Python: [QUICKSTART](docs/QUICKSTART.md), [LIMITS](docs/LIMITS.md),
|
| 140 |
+
[CUSTOM_TASK](docs/CUSTOM_TASK.md), [TAILSCALE](docs/TAILSCALE.md).
|
| 141 |
+
- Web: [Getting started](web/docs/GETTING_STARTED.md),
|
| 142 |
+
[Architecture](web/docs/ARCHITECTURE.md),
|
| 143 |
+
[Verification](web/docs/VERIFICATION.md),
|
| 144 |
+
[Troubleshooting](web/docs/TROUBLESHOOTING.md).
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## Quick start
|
| 149 |
+
|
| 150 |
+
### Docker (most reliable — one command)
|
| 151 |
+
```bash
|
| 152 |
+
docker compose -f docker/docker-compose.yml up --build
|
| 153 |
+
# open http://localhost:8080
|
| 154 |
+
```
|
| 155 |
+
Brings up a 3-node demo cluster + dashboard on one machine.
|
| 156 |
+
|
| 157 |
+
### Python (real machines)
|
| 158 |
+
On every machine (`pip install -e .`):
|
| 159 |
+
```bash
|
| 160 |
+
export MASTER_ADDR=100.101.102.10 # coordinator IP (Tailscale 100.x recommended)
|
| 161 |
+
export MASTER_PORT=29560
|
| 162 |
+
export WORLD_SIZE=3
|
| 163 |
+
export RANK=0 # 1, 2, ... on the others
|
| 164 |
+
export GLOO_SOCKET_IFNAME=tailscale0 # your mesh / LAN NIC
|
| 165 |
+
daisychain-train
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
### Windows helper
|
| 169 |
+
```bat
|
| 170 |
+
scripts\setup.bat
|
| 171 |
+
```
|
| 172 |
+
An interactive menu: Docker, Python node, or just install deps.
|
| 173 |
+
|
| 174 |
+
Full walkthrough: **[docs/QUICKSTART.md](docs/QUICKSTART.md)**.
|
| 175 |
+
|
| 176 |
+
### 🐋 SpikeWhale control panel (sliders → real training)
|
| 177 |
+
```bash
|
| 178 |
+
python -m daisychain.spikewhale_panel
|
| 179 |
+
# open http://localhost:8899
|
| 180 |
+
```
|
| 181 |
+
A web control panel: pick model size / training settings with sliders, choose any
|
| 182 |
+
HuggingFace dataset you have access to (default: streamed FineWeb-Edu), hit
|
| 183 |
+
Start, and watch the live loss. Stop and re-adjust any time with
|
| 184 |
+
**← Back to settings**. Launches the real DaisyChain training underneath.
|
| 185 |
+
|
| 186 |
+
### 🌐 DaisyChain-Web (train by opening a browser tab)
|
| 187 |
+
```bash
|
| 188 |
+
cd web && npm install && node server.js
|
| 189 |
+
# open http://localhost:8787 on every device
|
| 190 |
+
```
|
| 191 |
+
Zero-install browser training: devices on the same network auto-group
|
| 192 |
+
(Snapdrop-style) and train a shared model **peer-to-peer over WebRTC**, computing
|
| 193 |
+
through the same verified INT8 units (WebGPU, with the identical units on CPU
|
| 194 |
+
for machines without it — there is no plain-float path). Private cross-network
|
| 195 |
+
rooms via `?room=CODE` with **host approval** — the room creator accepts each
|
| 196 |
+
device before it can join. Includes gradient averaging with a deterministic
|
| 197 |
+
Adam optimizer (identical state on every peer, nothing extra over the wire),
|
| 198 |
+
checkpoint **download** (.pt) and **upload → broadcast** so one device can
|
| 199 |
+
restore the whole group after a failure.
|
| 200 |
+
|
| 201 |
+
**Live demo:** https://huggingface.co/spaces/Quazim0t0/DaisyChain-Web
|
| 202 |
+
|
| 203 |
+
### Recent updates (July 2026) — DaisyChain-Web
|
| 204 |
+
|
| 205 |
+
**Verification stack** — the browser trainer's correctness is now *checked by
|
| 206 |
+
things that run*, not argued ([full results](web/TEST_RESULTS.md)):
|
| 207 |
+
- **IEEE-754 oracle** (`web/test_ieee.js`): a binary32 oracle built from the
|
| 208 |
+
standard in exact BigInt arithmetic proves the JS epilogue mirror is
|
| 209 |
+
spec-correct — and rejects the old round-once mirror on 34% of inputs.
|
| 210 |
+
- **Metamorphic properties + oracle mutation scoring** (`test_metamorphic.js`,
|
| 211 |
+
`test_corpus.js`): properties needing no reference implementation, scored
|
| 212 |
+
against an externally-authored bug taxonomy — **4/4**, matching the exact
|
| 213 |
+
differential gate's 4/4. Relations own the loop bugs; two definitional
|
| 214 |
+
absolutes (ReLU output range, a unit-scale integer anchor) own the value
|
| 215 |
+
bugs no relation can see.
|
| 216 |
+
- **Exact kernel gates on every live kernel**, a continuous audit at live
|
| 217 |
+
shapes, and a cross-device kernel probe (same seeded int8 GEMM, same hash on
|
| 218 |
+
every honest device, any backend). The audit's sampling was rebuilt against
|
| 219 |
+
a named bug class: its old constants (6 cells, 2% of GEMMs) bounded an
|
| 220 |
+
overhead that had never been measured (auditing *every* GEMM costs <0.01% of
|
| 221 |
+
a step), and uniform random cells cannot see a last-row/column bug at a
|
| 222 |
+
16512-wide output. Sampling is now **stratified** — the first cells are the
|
| 223 |
+
structural danger points, chosen deliberately. On a last-column bug, same
|
| 224 |
+
cell budget: uniform caught **5/300** audits, stratified **300/300**, with
|
| 225 |
+
zero false positives.
|
| 226 |
+
- **RDNA2 ISA audit**: reading a real GPU's shader ISA against our
|
| 227 |
+
determinism assumptions confirmed three of them on silicon (exact packed
|
| 228 |
+
int8 dot; correctly-rounded f32 add/mul; 1-ULP reciprocal — division stays
|
| 229 |
+
off the GPU) and produced two hardenings. (1) Real ISAs have non-IEEE
|
| 230 |
+
variants that **flush −0 to +0**; JS `!==` can't see that (`-0 !== 0` is
|
| 231 |
+
false), so all gates and audits now compare **bit patterns** — exactly what
|
| 232 |
+
the replica hash sees. (2) FMA contraction of the quantize's `x·inv + 0.5`
|
| 233 |
+
(one rounding instead of two) turned out to be **floor-invisible by
|
| 234 |
+
construction** — proven in `test_b2b.js` with 175k+ last-ulp anomalies at
|
| 235 |
+
binade edges, zero surviving `floor()`. Rounding mode and denorm flushing
|
| 236 |
+
are runtime driver state on real hardware, which is why every device
|
| 237 |
+
re-runs the exact gates at every init.
|
| 238 |
+
|
| 239 |
+
**Training data** — FineWeb-Edu (10BT sample) is the hardcoded dataset. The
|
| 240 |
+
Space reads random slices of the parquet shards straight off the HF CDN with
|
| 241 |
+
range requests (pure-JS `hyparquet`, SNAPPY) and serves plain text at `/data`
|
| 242 |
+
— no dependency on the datasets-server rows API and its 503s.
|
| 243 |
+
|
| 244 |
+
**Resilience** — the sync guard now *repairs* instead of halting: a roster
|
| 245 |
+
gradient that reached the leader but not some follower (asymmetric WebRTC
|
| 246 |
+
mesh) is re-requested from the leader, bit-exact, and the run continues. The
|
| 247 |
+
guard still stops anything that would fork the weights.
|
| 248 |
+
|
| 249 |
+
**CUTLASS-style kernel work**, each step proven bit-identical or exact-gated:
|
| 250 |
+
- **Dispatch-optimized backward** (ex. 05/24): independent GEMMs overlapped,
|
| 251 |
+
sibling trios fused into batch-3 dispatches — bit-identical gradients,
|
| 252 |
+
dormant int8-backward path down from 1.63× to 1.21× vs float.
|
| 253 |
+
- **QKV dual-GEMM fusion** (ex. 45): q/k/v share one left operand — quantized
|
| 254 |
+
once, one batched dispatch, zero changed bits.
|
| 255 |
+
- **B2B MLP chain** (ex. 13 + 23): both MLP GEMMs back-to-back on the GPU with
|
| 256 |
+
a fused per-row absmax reduction; the intermediate is quantized on-device
|
| 257 |
+
via a WGSL-exact respec (`floor(f32(x·invScale)+0.5)` — no GPU division)
|
| 258 |
+
whose fround-stepped JS mirror keeps mixed GPU/CPU fleets bit-identical.
|
| 259 |
+
|
| 260 |
+
**Profile-driven speed work** — every change below is bit-identical (gradient
|
| 261 |
+
and loss hashes unchanged), so none of it trades correctness for wall clock:
|
| 262 |
+
- **Buffer pooling**: GPU buffers are recycled by size bucket instead of being
|
| 263 |
+
created and destroyed per dispatch (~19 per MLP call, per layer, per step).
|
| 264 |
+
**6–10% faster**, every hash unchanged.
|
| 265 |
+
- **Shared-operand embedding GEMMs**: profiling put two f32 backward GEMMs at
|
| 266 |
+
**55% of the entire step**, and both consumed the same `dlogits` operand —
|
| 267 |
+
~17 MB at the 16512-token vocab, uploaded *twice*. One upload, one encoder,
|
| 268 |
+
one submit: that pair went 205 → 90 ms and the step **12% faster**. The
|
| 269 |
+
fusion is gated bit-for-bit against the two calls it replaces.
|
| 270 |
+
- **A negative result, kept on purpose**: the remaining hot kernel looked
|
| 271 |
+
cache-hostile (adjacent lanes wrote 66 KB apart), but making the writes
|
| 272 |
+
contiguous changed nothing. Two probes explain why — holding the output at
|
| 273 |
+
17 MB while cutting compute 32× barely moved the time. The logits GEMM is
|
| 274 |
+
**transfer-bound, not compute-bound**, and the readback cannot be removed
|
| 275 |
+
because softmax must stay in JS (WGSL's `exp` is not correctly rounded, and
|
| 276 |
+
a per-vendor `exp` would fork replicas). The real lever there is the
|
| 277 |
+
vocabulary, not the kernel.
|
| 278 |
+
- **An init backend race was tried and removed**: it tied on the shipped path,
|
| 279 |
+
cost ~430 ms of init, and made the backend vary between page loads, which
|
| 280 |
+
silently invalidated three A/B comparisons before it was caught. A knob that
|
| 281 |
+
changes what you are measuring is worse than a fixed choice.
|
| 282 |
+
|
| 283 |
+
**Dirty-buffer gate — and the assumption it falsified.** Pooling introduced a
|
| 284 |
+
bug class the gates predate: a pooled buffer is *not* zero-initialized, so a
|
| 285 |
+
kernel that assumes zeros is right on step one and wrong on step two. That is
|
| 286 |
+
a **state** bug, where no single call is wrong and the *sequence* is, which is
|
| 287 |
+
the family no oracle can reach. The assumption was that the gates were blind
|
| 288 |
+
to it. Mutation-testing the gate proved otherwise: deleting the zeroing made
|
| 289 |
+
the plain gate fail at its *second* shape, because the sweep's own shapes
|
| 290 |
+
recycle each other's buffers. The suite had **incidental** coverage nobody
|
| 291 |
+
designed, which is coverage nobody can rely on — shorten the shape list and it
|
| 292 |
+
evaporates with the gate still green. It is now deliberate: the pool is
|
| 293 |
+
poisoned with 1e4-magnitude residue before a re-sweep, so detection no longer
|
| 294 |
+
depends on ordering luck. ~90 ms one-time.
|
| 295 |
+
|
| 296 |
+
**Scoring the oracles against my OWN bugs** (`web/test_selfcorpus.js`) — the
|
| 297 |
+
external corpus measures kernel bugs someone else wrote down, so this suite
|
| 298 |
+
asks the harder question: what do the instruments score against the four real
|
| 299 |
+
bugs of the month? Properties **0/2** on the data-plane pair (the `c·out`
|
| 300 |
+
theorem again), differential **2/2** — but half the bugs were not in the
|
| 301 |
+
kernels at all. A dead gate is a bug in a *checker*, caught only by mutating
|
| 302 |
+
the gate; a stalled roster gradient is a bug in the *protocol*, where every
|
| 303 |
+
computed value on every peer was correct, so no data oracle could fire. Those
|
| 304 |
+
needed different instruments, not better oracles.
|
| 305 |
+
|
| 306 |
+
All thirteen test suites (`cd web && npm test`) pass; results with methodology in
|
| 307 |
+
[`web/TEST_RESULTS.md`](web/TEST_RESULTS.md).
|
| 308 |
+
|
| 309 |
+
---
|
| 310 |
+
|
| 311 |
+
## How it works
|
| 312 |
+
|
| 313 |
+
Each machine runs the **same** command; they form a cluster and train one shared
|
| 314 |
+
model. Two things happen:
|
| 315 |
+
|
| 316 |
+
1. **The compute runs through the emulated GPU logic.** By default the model is
|
| 317 |
+
built from `VerifiedLinear` layers, so every forward multiply / requantize /
|
| 318 |
+
ReLU is done by the **bundled verified INT8 units** (`daisychain/verified/`) —
|
| 319 |
+
the emulated GPU math. Rank 0 prints **cluster-wide unit-invocation counts**
|
| 320 |
+
so you can see the emulated logic doing the work.
|
| 321 |
+
2. **The machines are pooled data-parallel.** Each node trains on its own shard;
|
| 322 |
+
gradients are capacity-weighted and combined into the exact full-batch
|
| 323 |
+
gradient, so replicas stay **bit-identical**. Faster machines automatically
|
| 324 |
+
take a bigger share.
|
| 325 |
+
|
| 326 |
+
```
|
| 327 |
+
old machine A ─┐
|
| 328 |
+
old machine B ─┼─► each runs the emulated GPU logic on its shard ─► one model
|
| 329 |
+
old machine C ─┘ (gradients combined across the cluster)
|
| 330 |
+
```
|
| 331 |
+
|
| 332 |
+
## Bring your own model
|
| 333 |
+
DaisyChain-Train trains any **Task** (`build_model` / `sample` / `loss`). Copy
|
| 334 |
+
`examples/my_task_template.py`, set `DAISY_TASK=your_module:YourTask`. Use
|
| 335 |
+
`VerifiedLinear` (see `daisychain/verified_task.py`) to run your model's compute
|
| 336 |
+
through the emulated units. See **[docs/CUSTOM_TASK.md](docs/CUSTOM_TASK.md)**.
|
| 337 |
+
|
| 338 |
+
## Plain-float alternative
|
| 339 |
+
To skip the emulated units and train with normal float math on each machine, set
|
| 340 |
+
`DAISY_TASK=daisychain.example_task:ExampleTask`. Same cluster, same pooling —
|
| 341 |
+
the model math just runs as ordinary float instead of through the verified units.
|
| 342 |
+
|
| 343 |
+
## The dashboard
|
| 344 |
+
`daisychain-dashboard` (or the Docker service) serves a Tailwind page at `:8080`
|
| 345 |
+
— readiness banner, P2P connectivity scan, pooled cores/RAM + capacity plan
|
| 346 |
+
(per-node device, weight, batch), and live training loss.
|
| 347 |
+
|
| 348 |
+
## Networking
|
| 349 |
+
Use **Tailscale** for a P2P mesh so machines on different networks get stable IPs
|
| 350 |
+
on one interface — **[docs/TAILSCALE.md](docs/TAILSCALE.md)**.
|
| 351 |
+
|
| 352 |
+
---
|
| 353 |
+
|
| 354 |
+
## Layout
|
| 355 |
+
```
|
| 356 |
+
daisychain/cluster.py capacity-weighted CPU/GPU data-parallel trainer
|
| 357 |
+
daisychain/train.py entry point (daisychain-train)
|
| 358 |
+
daisychain/verified/ bundled trained N/N units + VerifiedLinear (train through them)
|
| 359 |
+
daisychain/verified_task.py default task: forward runs on the verified units
|
| 360 |
+
daisychain/example_task.py plain-float alternative task
|
| 361 |
+
daisychain/task.py the Task interface + loader
|
| 362 |
+
daisychain/dashboard/ agent + P2P scanner + Tailwind server
|
| 363 |
+
docker/ Dockerfile, dashboard image, compose (demo cluster)
|
| 364 |
+
scripts/setup.bat / setup.sh interactive setup helpers
|
| 365 |
+
config/ nodes + cluster env examples
|
| 366 |
+
examples/my_task_template.py starting point for your own model
|
| 367 |
+
test_verified_units.py full-domain checks of every shipped verified path (25)
|
| 368 |
+
docs/ QUICKSTART, LIMITS, CUSTOM_TASK, TAILSCALE
|
| 369 |
+
daisychain/spikewhale_task.py trains the real SpikeWhale on streamed HF datasets
|
| 370 |
+
daisychain/spikewhale_panel.py slider control panel (localhost:8899)
|
| 371 |
+
web/ DaisyChain-Web: P2P browser training (WebRTC + WebGPU)
|
| 372 |
+
export_luts_web.py regenerates web/public LUTs from the trained units
|
| 373 |
+
```
|
| 374 |
+
|
| 375 |
+
## Recent updates (14 October 2026) — one arithmetic, whatever the device
|
| 376 |
+
|
| 377 |
+
Lessons from making an emulator give one set of semantics on two backends, applied here and
|
| 378 |
+
measured on real hardware:
|
| 379 |
+
|
| 380 |
+
- **Mixed CPU + GPU clusters forked at step 1.** On identical gradients, CUDA Adam and CPU Adam
|
| 381 |
+
differ in about 2% of weights after 20 steps. That holds for the default, `foreach=False` and
|
| 382 |
+
`fused=True` alike: same rule, two implementations, two roundings. `replica_diff` only ran
|
| 383 |
+
after training, so nothing noticed. Now:
|
| 384 |
+
- In a mixed fleet, GPU ranks step the optimizer on CPU master copies, so every rank runs one
|
| 385 |
+
implementation (`DAISY_CPU_OPT=0/1` overrides the choice).
|
| 386 |
+
- Every rank compares a hash of its parameter bits at the logging cadence and stops on any
|
| 387 |
+
difference.
|
| 388 |
+
- `test_cluster_mixed.py` (real 2-rank gloo, one CUDA rank and one CPU rank) checks that the
|
| 389 |
+
old behaviour is stopped by the check and that the new one ends bit-identical.
|
| 390 |
+
- **Host floating-point mode is pinned.** With flush-to-zero on, the same seed gives different
|
| 391 |
+
weights (measured), and any library built with `-ffast-math` can switch it on just by loading.
|
| 392 |
+
`pin_fp_mode()` turns it off in `configure_cpu` and `fit`.
|
| 393 |
+
- **Subnormals in WebGPU.** WGSL permits flushing f32 subnormals, and the JS mirror never
|
| 394 |
+
flushes. The f32 GEMM gate's inputs (~0.1) never produced one. Measured in a real browser: the
|
| 395 |
+
Intel gen-9 iGPU **flushes**, and the old gate called it "bit-exact". The gate now includes a
|
| 396 |
+
subnormal case, so a flushing GPU keeps its backward in JS instead of forking from
|
| 397 |
+
non-flushing peers. The app also asks for the `high-performance` adapter now; the default
|
| 398 |
+
picked that iGPU over a discrete GPU.
|
| 399 |
+
- **Capacity in one unit.** A node whose benchmark failed reported its *core count* as
|
| 400 |
+
capacity, which was then weighed against other nodes' matmuls/sec. Mixed units now fall back
|
| 401 |
+
to core counts for every node, with a warning.
|
| 402 |
+
- **The kernel probe can no longer pass by not running.** If a device's probe threw, it kept its
|
| 403 |
+
previous hash (or 0), and a `phash` of 0 made the peer comparison *skip* that device. A failed
|
| 404 |
+
probe now reports 0, and 0 halts the run (`web/test_probe_unproven.js`).
|
| 405 |
+
- **Partial fragments of a closed channel are dropped.** Before, they were kept on the side that
|
| 406 |
+
doesn't start the reconnect. Repeated or out-of-range fragment numbers are rejected instead of
|
| 407 |
+
being counted.
|
| 408 |
+
|
| 409 |
+
Verified in-browser: 2 peers over WebRTC, 100/100 steps, loss 9.76 → 6.26, no weight, kernel or
|
| 410 |
+
audit halts.
|
| 411 |
+
|
| 412 |
+
### The f32 backward GEMM is audited too
|
| 413 |
+
|
| 414 |
+
Three gaps from a review of which checks actually cover each live WebGPU kernel:
|
| 415 |
+
|
| 416 |
+
- **The transposed f32 GEMM was compared only with itself.** The init gate checked the plain
|
| 417 |
+
split-K shape against the exact mirror. The transposed index pattern (`A[p*m+row]`, used for
|
| 418 |
+
the embedding gradient) was only ever compared as `fgemm2`'s leg against an `fgemm` call that
|
| 419 |
+
runs the same shader, which is circular. It is now gated against `fgemmMirror`, including a
|
| 420 |
+
transposed split-K shape (k > 4096).
|
| 421 |
+
- **The f32 GEMM had no live-shape audit.** Every int8 kernel samples cells at live shapes. The
|
| 422 |
+
f32 pair, whose output *is* the embedding gradient, had been checked only at 7×4500×5 and
|
| 423 |
+
9×300×6. It is now audited by `auditFgemm` (structural corners first, then random cells) on
|
| 424 |
+
the mirror's partition and rounding schedule. The device's schedule (`fgemmFma`) is recorded
|
| 425 |
+
at init and passed through.
|
| 426 |
+
- **Dirty-pool re-gates for the attention kernels and the f32 GEMM.** The bgemm and MLP gates
|
| 427 |
+
already re-ran on pooled buffers poisoned with large residue. The attention and f32 kernels
|
| 428 |
+
were only ever gated on fresh buffers; now they are re-gated on poisoned ones too.
|
| 429 |
+
|
| 430 |
+
`web/test_fgemm_audit.js` (in `npm test`) checks that the audit accepts the mirror at live-like
|
| 431 |
+
shapes under both schedules and catches a 1-ulp error in a corner and in an interior cell. It
|
| 432 |
+
also checks that the two schedules really differ on split-K data, so the flag is load-bearing.
|
| 433 |
+
|
| 434 |
+
## Recent updates (30 September 2026) — reductions refuse what they used to accept
|
| 435 |
+
|
| 436 |
+
Six fixes, each found by applying a lesson from differential-testing an RDNA2 shader
|
| 437 |
+
translator: *a second implementation nobody checks is not verified*, *inactive or absent
|
| 438 |
+
participants must not leak into a reduction*, *a max over NaN is undefined*, and *know your
|
| 439 |
+
accumulator width*. Every one is covered by a check that fails on the old code.
|
| 440 |
+
`python test_verified_units.py` + `python test_cluster_reductions.py` and
|
| 441 |
+
`cd web && npm test` (thirteen suites) reproduce all of it.
|
| 442 |
+
|
| 443 |
+
| # | where | what was wrong | now |
|
| 444 |
+
| --- | --- | --- | --- |
|
| 445 |
+
| 1 | browser, WebGPU MLP chain | the row max comes from an `atomicMax` over `bitcast(abs(h))` and never passed the non-finite guard the CPU path has; a NaN/Inf `h1` became `inv = 0`/NaN and the GPU quantized `floor(NaN)` (implementation-defined) where a CPU peer threw | `scalesFromAbsMax` refuses a non-finite max. NaN and Inf bits order above every finite value as u32, so the row max is non-finite **iff** some element is — one check covers the whole row (checked on 2000 random rows) |
|
| 446 |
+
| 2 | browser, `averageGrads` | a gradient of a different length was silently averaged over a prefix (typed arrays ignore out-of-range writes); a NaN/Inf gradient went straight into DaisyAdam, poisoning `m`/`v` on **every** peer identically, so no fork check could see it | refuses both with `GradientError`; the training loop halts at that step with weights and Adam state untouched (still a valid checkpoint) |
|
| 447 |
+
| 3 | browser, int8 GEMMs (JS and WGSL) | the `Int32Array` and WGSL `i32` accumulators wrap past K = 131,071 — CPU and GPU agree, on a wrong sum | refused with `AccumulatorBoundError` at every GEMM entry point; the same bound as the Python kernel's `MAX_INT32_K` |
|
| 448 |
+
| 4 | Python, logged cluster loss | `sum(loss_i) / world` — an equal-weight mean of **unequal**, capacity-weighted batches | `cluster_mean_loss` = Σ wᵢ·lossᵢ with the gradient's own weights. Measured on 3- and 13-sample nodes: old **1.418**, true full-batch loss **0.959**, new 0.959 |
|
| 449 |
+
| 5 | Python, gradient all-reduce | one node's NaN was summed into every replica and applied | `check_finite_grads` refuses the reduced gradient before `opt.step()`; every rank holds the same sum, so every rank stops at the same step with no extra communication |
|
| 450 |
+
| 6 | Python, `pick_device` | with `CUDA_VISIBLE_DEVICES` set but **empty** (a common "CPU only"), torch reports `is_available() = True` and `device_count() = 0`, and the node crashed at startup | a device must actually exist; falls back to CPU |
|
| 451 |
+
|
| 452 |
+
Unchanged for valid inputs: finite gradients, finite activations, and K ≤ 131,071 produce
|
| 453 |
+
bit-identical results to before, so updated and older peers still co-train. A 2-rank
|
| 454 |
+
end-to-end `DaisyCluster.fit` run stays bit-identical across replicas (`replica_diff 0.0`).
|
| 455 |
+
The WebGPU-side changes are host-side JavaScript (checked by `node --check` and by the
|
| 456 |
+
Node suites through the shared mirror functions); the WGSL kernels themselves are unchanged.
|
| 457 |
+
|
| 458 |
+
## Recent updates (September 2026) — requant rounding, bounded units, non-finite guards
|
| 459 |
+
|
| 460 |
+
Everything below is measured, and `python test_verified_units.py` (25 checks) plus
|
| 461 |
+
`cd web && npm test` (twelve suites) reproduce it.
|
| 462 |
+
|
| 463 |
+
**The requant rounds instead of truncating.** `NeuralRequant16` was
|
| 464 |
+
`sat_int8(x >> 8)`, and Python's `>>` on negatives is an arithmetic floor, so every
|
| 465 |
+
requantized activation carried a systematic bias. Over the full int16 domain:
|
| 466 |
+
|
| 467 |
+
| requant | mean error | disagrees with the other on |
|
| 468 |
+
| --- | --- | --- |
|
| 469 |
+
| floor `x >> 8` (old) | **-0.4981 LSB** | 32,640 / 65,536 |
|
| 470 |
+
| round-half-up `(x + 128) >> 8` (now) | +0.0020 LSB | |
|
| 471 |
+
|
| 472 |
+
The shipped `requant16.pt` implemented the floor exactly, so this was real behaviour,
|
| 473 |
+
not a stale docstring. It was **retrained** against the new reference and saved only
|
| 474 |
+
at 65,536/65,536 (a 99.9% unit would silently break the LUT equivalence the browser
|
| 475 |
+
relies on), and `web/public/requant_lut.bin` was **re-exported** to match: the
|
| 476 |
+
invariant is *neural forward == LUT == native integer op*, so a table that lags its
|
| 477 |
+
unit is a fork. Tie rule, stated precisely: this is half-UP; Python/numpy/torch
|
| 478 |
+
`round` is half-to-even. The two differ on exactly the 128 exact ties (0.2% of
|
| 479 |
+
inputs) and both are unbiased to within 0.002 LSB. The Python fleet quantizes with
|
| 480 |
+
`np.round` and the browser with `floor(x + 0.5)`; the two fleets never co-train, so
|
| 481 |
+
that cannot fork a group.
|
| 482 |
+
|
| 483 |
+
**Bounded elementwise units.** The GEMM backends already capped their temporaries;
|
| 484 |
+
the units right after them did not. `relu_array` / `requant_array` received the
|
| 485 |
+
whole activation matrix in the proof path at ~1.5–2 KB per element (262,144
|
| 486 |
+
elements = **407 MB**), and the multiply-LUT build pushed 262,144 rows through the
|
| 487 |
+
atom net at startup (**277 MB RSS on every node**). Both are now blocked:
|
| 488 |
+
407 MB → **2 MB, flat with N**; 277 MB → **1 MB**. Elementwise blocking is exact
|
| 489 |
+
(no accumulation order), verified bit-identical at N = 1 / 999 / 32768 / 32769 /
|
| 490 |
+
100000. `dataset()` builders are vectorised (65,536 Python iterations → one numpy
|
| 491 |
+
pass, 0.015 s), which is what makes retraining a unit practical at all.
|
| 492 |
+
|
| 493 |
+
**A NaN or Inf no longer quantizes to zeros.** A float → int8 conversion has no
|
| 494 |
+
answer for NaN/Inf, and both trainers gave a silent wrong one:
|
| 495 |
+
|
| 496 |
+
| path | input | old result |
|
| 497 |
+
| --- | --- | --- |
|
| 498 |
+
| Python `qat.py` | `[0.5, nan, 3.0]` | scale = NaN → **`[0, 0, 0]`** (only a RuntimeWarning) |
|
| 499 |
+
| Python `qat.py` | `[0.5, inf, 3.0]` | scale = Inf → **`[0, 0, 0]`** |
|
| 500 |
+
| browser quantizers | `[0.5, Inf, 3]` | scale = Infinity → **`[0, 0, 0]`** |
|
| 501 |
+
| browser quantizers | `[0.5, NaN, 3]` | NaN skipped by the \|max\| scan, stored as **0** |
|
| 502 |
+
|
| 503 |
+
One bad value zeroed the whole layer's int8 input and training carried on. Now
|
| 504 |
+
`VerifiedLinear` raises `FloatingPointError`, and every browser quantizer
|
| 505 |
+
(`quantize`, `quantizeRows`, `quantizeCols`, `rowAbsMax`, `quantizeHeadCols`, and
|
| 506 |
+
the transformer's `quantizeColsAsRows`) throws `NonFiniteError`, naming how many
|
| 507 |
+
values were bad. Finite inputs are untouched, so builds with and without the guard
|
| 508 |
+
still co-train. Both new tests fail against the old code (Python: 2 checks; web:
|
| 509 |
+
`test_nonfinite.js`, 12) and pass now.
|
| 510 |
+
|
| 511 |
+
## Recent updates (August 2026) — verified compute path
|
| 512 |
+
|
| 513 |
+
Three changes to `daisychain/verified/`, each measured rather than asserted.
|
| 514 |
+
`python test_verified_units.py` (24 checks) reproduces all of it.
|
| 515 |
+
|
| 516 |
+
**Bounded GEMM memory.** `LUTBackend.gemm` and `NeuralBackend.gemm` materialized
|
| 517 |
+
the whole `(m, n, k)` product block before reducing it, so peak memory was cubic
|
| 518 |
+
in layer width. At the SpikeWhale panel's maximum (hidden 768, sequence 512) a
|
| 519 |
+
single layer's forward allocated **2.4 GB** — on the spare hardware this project
|
| 520 |
+
exists to use. Both now block the `m` axis. The contraction axis `k` is untouched,
|
| 521 |
+
so the sum and its order are unchanged and results stay bit-identical:
|
| 522 |
+
|
| 523 |
+
| GEMM | before | after |
|
| 524 |
+
| --- | --- | --- |
|
| 525 |
+
| 64x64x64 | 2.3 MB | 2.3 MB (unchanged path) |
|
| 526 |
+
| 256x256x256 | 135.8 MB | 68.9 MB |
|
| 527 |
+
| 512x768x768 (panel max) | 2426.9 MB | **77.2 MB** |
|
| 528 |
+
| neural backend, 64x96x96 | 519.2 MB | **57.5 MB** |
|
| 529 |
+
|
| 530 |
+
Caps are class attributes (`LUTBackend.max_block_bytes`,
|
| 531 |
+
`NeuralBackend.max_products`), tunable per instance. Below the cap the LUT path
|
| 532 |
+
takes the original single-block branch unchanged.
|
| 533 |
+
|
| 534 |
+
The two backends need *different* accounting, which is worth knowing before tuning
|
| 535 |
+
them: a LUT product costs 8 bytes, but a neural product is split into four nibble
|
| 536 |
+
pairs through a 128-wide net and costs ~880 bytes. Applying the LUT's byte budget
|
| 537 |
+
to the neural path computes a block 110x too large and never splits at all.
|
| 538 |
+
|
| 539 |
+
**The multiply LUT is certified, not trusted.** `build_luts()` returned its tables
|
| 540 |
+
unchecked while the docs described a self-certify gate. Because the table *is* the
|
| 541 |
+
complete finite domain, certifying it **is** the exhaustive verification:
|
| 542 |
+
`LUTBackend.__init__` now checks all 65536 entries against signed integer multiply
|
| 543 |
+
and raises on mismatch. Cost: **0.5 ms**.
|
| 544 |
+
|
| 545 |
+
**Full-domain tests for the paths that ship.** The units' exhaustive verification
|
| 546 |
+
covers the scalar entry points (`NeuralMul8.verify()` walks `mul()`); production
|
| 547 |
+
runs the batched ones — `mul_array`, `relu_array`, `requant_array` — and the LUTs
|
| 548 |
+
built from them, which are different code. `test_verified_units.py` checks every
|
| 549 |
+
shipped path against golden integer arithmetic over its complete domain. All pass
|
| 550 |
+
today; nothing would have caught a regression before.
|
| 551 |
+
|
| 552 |
+
**Two guards against silent no-ops.** `gemm_int8` enforces the int32 accumulator
|
| 553 |
+
bound (`MAX_INT32_K = 131071`) instead of asserting it in a comment, since layer
|
| 554 |
+
width is user-selectable and an overflow would produce wrong numbers rather than
|
| 555 |
+
raise. `instrument.require(**minimums)` fails when a verified unit was
|
| 556 |
+
under-invoked, and refuses to report at all while counting is disabled — a zero
|
| 557 |
+
from a probe that was switched off is not evidence that the units did not run.
|
| 558 |
+
|
| 559 |
+
---
|
| 560 |
+
|
| 561 |
+
## Install
|
| 562 |
+
```bash
|
| 563 |
+
pip install torch numpy psutil
|
| 564 |
+
pip install -e . # exposes: daisychain-train, daisychain-agent, daisychain-dashboard
|
| 565 |
+
```
|
| 566 |
+
Requires Python ≥ 3.9, PyTorch ≥ 2.0. Multi-node is reliable on **Linux/macOS**;
|
| 567 |
+
on **Windows use Docker/WSL** (see [Limitations](docs/LIMITS.md)).
|
| 568 |
+
|
| 569 |
+
---
|
| 570 |
+
|
| 571 |
+
## Links
|
| 572 |
+
- **DaisyChain on Hugging Face:** https://huggingface.co/DaisyChainAI
|
| 573 |
+
- **This model:** https://huggingface.co/DaisyChainAI/DaisyChain-Train
|
| 574 |
+
|
| 575 |
+
**License:** MIT · **Author:** Dean Byrne (Quazim0t0) · **Org:** DaisyChainAI
|
| 576 |
+
|
| 577 |
+
## Citation
|
| 578 |
+
```bibtex
|
| 579 |
+
@misc{byrne2026daisychain,
|
| 580 |
+
title = {DaisyChain-Train: An Old Hardware Training Pipeline},
|
| 581 |
+
author = {Byrne, Dean (Quazim0t0)},
|
| 582 |
+
year = {2026},
|
| 583 |
+
howpublished = {\url{https://huggingface.co/DaisyChainAI/DaisyChain-Train}},
|
| 584 |
+
note = {Chain spare/old machines into a data-parallel training cluster}
|
| 585 |
+
}
|
| 586 |
+
```
|
| 587 |
+
|
| 588 |
+
**Dean Byrne (Quazim0t0)** · 2026
|
daisychain/cluster.py
CHANGED
|
@@ -28,9 +28,20 @@ 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 _cuda_usable() -> bool:
|
| 35 |
"""is_available() alone is not enough: with CUDA_VISIBLE_DEVICES set but EMPTY (a common way
|
| 36 |
to say "CPU only") torch reports available=True and device_count()=0, and the node crashed
|
|
@@ -60,9 +71,14 @@ def _available_ram_gb():
|
|
| 60 |
return None
|
| 61 |
|
| 62 |
|
| 63 |
-
def capacity_score(device=None, secs: float = 0.3)
|
| 64 |
"""Measured throughput: fixed matmuls/sec on the local device. Self-calibrating
|
| 65 |
-
(a GPU scores far higher), so capacity weighting hands it a bigger batch.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
dev = device or pick_device()
|
| 67 |
try:
|
| 68 |
a = torch.randn(512, 512, device=dev)
|
|
@@ -78,7 +94,7 @@ def capacity_score(device=None, secs: float = 0.3) -> float:
|
|
| 78 |
torch.cuda.synchronize()
|
| 79 |
return it / (time.time() - t0)
|
| 80 |
except Exception:
|
| 81 |
-
return
|
| 82 |
|
| 83 |
|
| 84 |
def survey_node(cpu_fraction: float = 0.9, measure: bool = True) -> dict:
|
|
@@ -86,15 +102,36 @@ def survey_node(cpu_fraction: float = 0.9, measure: bool = True) -> dict:
|
|
| 86 |
dev = pick_device()
|
| 87 |
gpu = _gpu_info() if dev.type == "cuda" else None
|
| 88 |
if "DAISY_CAPACITY" in os.environ:
|
| 89 |
-
cap = float(os.environ["DAISY_CAPACITY"])
|
| 90 |
elif measure:
|
| 91 |
-
cap = capacity_score(dev)
|
|
|
|
|
|
|
| 92 |
else:
|
| 93 |
-
cap = float(cores)
|
| 94 |
return {"host": socket.gethostname(), "cores": cores,
|
| 95 |
"threads": max(1, int(round(cores * cpu_fraction))),
|
| 96 |
"ram_gb": _available_ram_gb(), "device": dev.type,
|
| 97 |
-
"gpu": gpu, "capacity": cap}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
# ------------------------------------------------------------------ cluster ---
|
|
@@ -111,7 +148,10 @@ def cluster_plan(cpu_fraction: float = 0.9, base_batch: int = 32) -> dict:
|
|
| 111 |
gathered = [None] * world
|
| 112 |
dist.all_gather_object(gathered, me)
|
| 113 |
|
| 114 |
-
caps =
|
|
|
|
|
|
|
|
|
|
| 115 |
total_cap = sum(caps) or world
|
| 116 |
global_batch = base_batch * world
|
| 117 |
batches = [max(1, round(global_batch * c / total_cap)) for c in caps]
|
|
@@ -125,10 +165,39 @@ def cluster_plan(cpu_fraction: float = 0.9, base_batch: int = 32) -> dict:
|
|
| 125 |
"total_ram_gb": (sum(rams) if rams else None),
|
| 126 |
"global_batch": sum(batches),
|
| 127 |
"devices": [g.get("device", "cpu") for g in gathered],
|
| 128 |
-
"capacities": [round(c, 1) for c in caps],
|
| 129 |
"gpus": [g.get("gpu") for g in gathered]}
|
| 130 |
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
@torch.no_grad()
|
| 133 |
def broadcast_params(model, src: int = 0):
|
| 134 |
for p in model.parameters():
|
|
@@ -204,15 +273,38 @@ class DaisyCluster:
|
|
| 204 |
pass
|
| 205 |
|
| 206 |
def fit(self, model, task, steps=500, lr=1e-2, optimizer="sgd",
|
| 207 |
-
status_path=None, step_delay=0.0):
|
| 208 |
"""Train `model` on `task` (build_model already called). task.sample(n)
|
| 209 |
-
draws this node's shard; task.loss(model, X, y) returns a scalar.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
model.to(self.device)
|
|
|
|
| 211 |
broadcast_params(model)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
if optimizer == "adam":
|
| 213 |
-
opt = torch.optim.Adam(
|
| 214 |
else:
|
| 215 |
-
opt = torch.optim.SGD(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
w, nb = self.plan["my_weight"], self.plan["my_local_batch"]
|
| 217 |
for s in range(steps):
|
| 218 |
X, y = task.sample(nb)
|
|
@@ -222,7 +314,17 @@ class DaisyCluster:
|
|
| 222 |
loss.backward()
|
| 223 |
capacity_weighted_allreduce_grads(model, w)
|
| 224 |
check_finite_grads(model, s)
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
if step_delay:
|
| 227 |
time.sleep(step_delay)
|
| 228 |
if s % max(1, steps // 20) == 0 or s == steps - 1:
|
|
|
|
| 28 |
cores = os.cpu_count() or 1
|
| 29 |
n = max(1, int(round(cores * fraction)))
|
| 30 |
torch.set_num_threads(n)
|
| 31 |
+
pin_fp_mode()
|
| 32 |
return n
|
| 33 |
|
| 34 |
|
| 35 |
+
def pin_fp_mode() -> None:
|
| 36 |
+
"""Pin the host floating-point mode: subnormals ON (no flush-to-zero / denormals-are-zero).
|
| 37 |
+
|
| 38 |
+
FTZ/DAZ live in the thread's MXCSR, and any library built with -ffast-math can switch them
|
| 39 |
+
on just by being loaded (the emulator found exactly this leaking into Python, 2026-09).
|
| 40 |
+
MEASURED 2026-10-14: the same seed and gradients through 5 Adam steps give DIFFERENT weights
|
| 41 |
+
with torch.set_flush_denormal(True) -- so one node with a leaked FTZ forks its replica."""
|
| 42 |
+
torch.set_flush_denormal(False)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
def _cuda_usable() -> bool:
|
| 46 |
"""is_available() alone is not enough: with CUDA_VISIBLE_DEVICES set but EMPTY (a common way
|
| 47 |
to say "CPU only") torch reports available=True and device_count()=0, and the node crashed
|
|
|
|
| 71 |
return None
|
| 72 |
|
| 73 |
|
| 74 |
+
def capacity_score(device=None, secs: float = 0.3):
|
| 75 |
"""Measured throughput: fixed matmuls/sec on the local device. Self-calibrating
|
| 76 |
+
(a GPU scores far higher), so capacity weighting hands it a bigger batch.
|
| 77 |
+
|
| 78 |
+
Returns None if the measurement fails. It used to return the core count instead -- a number
|
| 79 |
+
in a DIFFERENT UNIT that cluster_plan then weighed against other nodes' matmuls/sec, so a node
|
| 80 |
+
whose benchmark threw got ~8 "capacity" next to a peer's ~200 and a sliver of the batch, with
|
| 81 |
+
nothing in the plan saying why (2026-10-14: a fallback that looks like a result)."""
|
| 82 |
dev = device or pick_device()
|
| 83 |
try:
|
| 84 |
a = torch.randn(512, 512, device=dev)
|
|
|
|
| 94 |
torch.cuda.synchronize()
|
| 95 |
return it / (time.time() - t0)
|
| 96 |
except Exception:
|
| 97 |
+
return None
|
| 98 |
|
| 99 |
|
| 100 |
def survey_node(cpu_fraction: float = 0.9, measure: bool = True) -> dict:
|
|
|
|
| 102 |
dev = pick_device()
|
| 103 |
gpu = _gpu_info() if dev.type == "cuda" else None
|
| 104 |
if "DAISY_CAPACITY" in os.environ:
|
| 105 |
+
cap, unit = float(os.environ["DAISY_CAPACITY"]), "user"
|
| 106 |
elif measure:
|
| 107 |
+
cap, unit = capacity_score(dev), "matmul/s"
|
| 108 |
+
if cap is None:
|
| 109 |
+
cap, unit = float(cores), "cores"
|
| 110 |
else:
|
| 111 |
+
cap, unit = float(cores), "cores"
|
| 112 |
return {"host": socket.gethostname(), "cores": cores,
|
| 113 |
"threads": max(1, int(round(cores * cpu_fraction))),
|
| 114 |
"ram_gb": _available_ram_gb(), "device": dev.type,
|
| 115 |
+
"gpu": gpu, "capacity": cap, "capacity_unit": unit}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def plan_capacities(nodes):
|
| 119 |
+
"""Per-node capacities in ONE unit, plus the unit used.
|
| 120 |
+
|
| 121 |
+
Measured matmuls/sec and core counts are not comparable. If the measured nodes do not all
|
| 122 |
+
share a unit, every node falls back to its core count (a coarser but consistent weighting)
|
| 123 |
+
instead of mixing the two. A DAISY_CAPACITY override ("user") is taken as stated: the
|
| 124 |
+
operator chose its scale. Nodes from an older survey without a unit count as "cores" if
|
| 125 |
+
their capacity equals their core count, else as measured."""
|
| 126 |
+
def unit_of(g):
|
| 127 |
+
u = g.get("capacity_unit")
|
| 128 |
+
if u:
|
| 129 |
+
return u
|
| 130 |
+
return "cores" if float(g.get("capacity") or 0) == float(g["cores"]) else "matmul/s"
|
| 131 |
+
units = {unit_of(g) for g in nodes} - {"user"}
|
| 132 |
+
if len(units) > 1:
|
| 133 |
+
return [float(g["cores"]) for g in nodes], "cores (mixed units: %s)" % ", ".join(sorted(units))
|
| 134 |
+
return [float(g.get("capacity") or g["cores"]) for g in nodes], (units.pop() if units else "user")
|
| 135 |
|
| 136 |
|
| 137 |
# ------------------------------------------------------------------ cluster ---
|
|
|
|
| 148 |
gathered = [None] * world
|
| 149 |
dist.all_gather_object(gathered, me)
|
| 150 |
|
| 151 |
+
caps, cap_unit = plan_capacities(gathered)
|
| 152 |
+
if cap_unit.startswith("cores (mixed") and rank == 0:
|
| 153 |
+
print(" [plan] capacity measured in different units across nodes -- weighting every node "
|
| 154 |
+
"by core count instead (%s)" % cap_unit, flush=True)
|
| 155 |
total_cap = sum(caps) or world
|
| 156 |
global_batch = base_batch * world
|
| 157 |
batches = [max(1, round(global_batch * c / total_cap)) for c in caps]
|
|
|
|
| 165 |
"total_ram_gb": (sum(rams) if rams else None),
|
| 166 |
"global_batch": sum(batches),
|
| 167 |
"devices": [g.get("device", "cpu") for g in gathered],
|
| 168 |
+
"capacities": [round(c, 1) for c in caps], "capacity_unit": cap_unit,
|
| 169 |
"gpus": [g.get("gpu") for g in gathered]}
|
| 170 |
|
| 171 |
|
| 172 |
+
@torch.no_grad()
|
| 173 |
+
def param_hash(params) -> int:
|
| 174 |
+
"""64-bit BLAKE2b digest of the exact parameter BITS (CPU copies), for replica checks."""
|
| 175 |
+
import hashlib
|
| 176 |
+
h = hashlib.blake2b(digest_size=8)
|
| 177 |
+
for p in params:
|
| 178 |
+
h.update(p.detach().to("cpu").contiguous().view(-1).numpy().tobytes())
|
| 179 |
+
return int.from_bytes(h.digest(), "little", signed=True)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@torch.no_grad()
|
| 183 |
+
def check_replicas(params, step: int) -> None:
|
| 184 |
+
"""Refuse to continue if any replica's parameter bits differ from rank 0's. A collective.
|
| 185 |
+
|
| 186 |
+
replica_diff used to run once, AFTER training: a fork at step 1 surfaced only as a final
|
| 187 |
+
number. The web client hashes weights every step; this is the same check for the cluster."""
|
| 188 |
+
mine = torch.tensor([param_hash(params)], dtype=torch.int64)
|
| 189 |
+
allh = [torch.zeros_like(mine) for _ in range(dist.get_world_size())]
|
| 190 |
+
dist.all_gather(allh, mine)
|
| 191 |
+
bad = [r for r, h in enumerate(allh) if int(h) != int(allh[0])]
|
| 192 |
+
if bad:
|
| 193 |
+
raise RuntimeError("step %d: replica(s) %s diverged from rank 0 (parameter bits differ); "
|
| 194 |
+
"refusing to continue on forked weights" % (step, bad))
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def mixed_devices(plan) -> bool:
|
| 198 |
+
return len(set(plan.get("devices") or [])) > 1
|
| 199 |
+
|
| 200 |
+
|
| 201 |
@torch.no_grad()
|
| 202 |
def broadcast_params(model, src: int = 0):
|
| 203 |
for p in model.parameters():
|
|
|
|
| 273 |
pass
|
| 274 |
|
| 275 |
def fit(self, model, task, steps=500, lr=1e-2, optimizer="sgd",
|
| 276 |
+
status_path=None, step_delay=0.0, check_every=None):
|
| 277 |
"""Train `model` on `task` (build_model already called). task.sample(n)
|
| 278 |
+
draws this node's shard; task.loss(model, X, y) returns a scalar.
|
| 279 |
+
|
| 280 |
+
Mixed CPU/GPU fleets: the optimizer runs on CPU MASTER copies on every rank. MEASURED
|
| 281 |
+
2026-10-14 (RTX, torch 2.x): CUDA Adam vs CPU Adam on identical gradients differ in ~2% of
|
| 282 |
+
weights after 20 steps (default, foreach=False and fused=True alike) -- the same update
|
| 283 |
+
rule, two implementations, two roundings. Replicas forked at step 1 and nothing checked
|
| 284 |
+
until replica_diff after training. One implementation everywhere, like an emulator that
|
| 285 |
+
must give one semantics on two backends. Homogeneous fleets keep the on-device optimizer.
|
| 286 |
+
Every `check_every` steps (default: the logging cadence) all ranks compare a hash of
|
| 287 |
+
their parameter bits and stop on any difference (DAISY_CPU_OPT=0/1 overrides the choice)."""
|
| 288 |
model.to(self.device)
|
| 289 |
+
pin_fp_mode() # fit may run on a thread configure_cpu never saw
|
| 290 |
broadcast_params(model)
|
| 291 |
+
params = list(model.parameters())
|
| 292 |
+
force = os.environ.get("DAISY_CPU_OPT")
|
| 293 |
+
cpu_opt = (force == "1") if force in ("0", "1") else \
|
| 294 |
+
(mixed_devices(self.plan) and self.device.type != "cpu")
|
| 295 |
+
if cpu_opt:
|
| 296 |
+
master = [torch.nn.Parameter(p.detach().to("cpu").clone()) for p in params]
|
| 297 |
+
opt_params = master
|
| 298 |
+
else:
|
| 299 |
+
master, opt_params = None, params
|
| 300 |
if optimizer == "adam":
|
| 301 |
+
opt = torch.optim.Adam(opt_params, lr=lr)
|
| 302 |
else:
|
| 303 |
+
opt = torch.optim.SGD(opt_params, lr=lr)
|
| 304 |
+
if self.is_master() and mixed_devices(self.plan):
|
| 305 |
+
print(" [fit] mixed devices %s: optimizer on CPU master copies on GPU ranks"
|
| 306 |
+
% sorted(set(self.plan["devices"])), flush=True)
|
| 307 |
+
every = check_every or max(1, steps // 20)
|
| 308 |
w, nb = self.plan["my_weight"], self.plan["my_local_batch"]
|
| 309 |
for s in range(steps):
|
| 310 |
X, y = task.sample(nb)
|
|
|
|
| 314 |
loss.backward()
|
| 315 |
capacity_weighted_allreduce_grads(model, w)
|
| 316 |
check_finite_grads(model, s)
|
| 317 |
+
if master is not None:
|
| 318 |
+
with torch.no_grad():
|
| 319 |
+
for mp, p in zip(master, params):
|
| 320 |
+
mp.grad = p.grad.detach().to("cpu")
|
| 321 |
+
opt.step()
|
| 322 |
+
for mp, p in zip(master, params):
|
| 323 |
+
p.data.copy_(mp.data.to(p.data.device))
|
| 324 |
+
else:
|
| 325 |
+
opt.step()
|
| 326 |
+
if s % every == 0 or s == steps - 1:
|
| 327 |
+
check_replicas(params, s) # collective: every rank calls it
|
| 328 |
if step_delay:
|
| 329 |
time.sleep(step_delay)
|
| 330 |
if s % max(1, steps // 20) == 0 or s == steps - 1:
|
test_cluster_mixed.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mixed CPU + GPU fleet: replicas must stay bit-identical (2026-10-14).
|
| 2 |
+
|
| 3 |
+
Real 2-rank gloo run: rank 0 trains on CUDA, rank 1 on CPU, with Adam.
|
| 4 |
+
* MEASURED first: CUDA Adam and CPU Adam on identical gradients differ in ~2% of weights after
|
| 5 |
+
20 steps, so with the optimizer on each rank's own device the replicas fork at step 1.
|
| 6 |
+
With DAISY_CPU_OPT=0 (the old behaviour) the new per-interval replica check must STOP the run.
|
| 7 |
+
* Default: GPU ranks step the optimizer on CPU master copies, so every rank runs the same
|
| 8 |
+
implementation; the run completes and the final parameter bits agree exactly.
|
| 9 |
+
Also: pin_fp_mode turns flush-to-zero off even if something switched it on.
|
| 10 |
+
Needs a CUDA device; skips (exit 0, says so) without one.
|
| 11 |
+
"""
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
import tempfile
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
import torch.distributed as dist
|
| 18 |
+
import torch.multiprocessing as mp
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 21 |
+
from daisychain import cluster as C # noqa: E402
|
| 22 |
+
|
| 23 |
+
FAILURES = []
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def ck(name, cond, detail=""):
|
| 27 |
+
print((" ok " if cond else " FAIL ") + name + ("" if cond else " [%s]" % (detail,)))
|
| 28 |
+
if not cond:
|
| 29 |
+
FAILURES.append(name)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Task:
|
| 33 |
+
def __init__(self, rank):
|
| 34 |
+
self.g = torch.Generator().manual_seed(1000 + rank)
|
| 35 |
+
|
| 36 |
+
def sample(self, n):
|
| 37 |
+
X = torch.randn(n, 32, generator=self.g)
|
| 38 |
+
return X, X[:, :1] * 0.5 - X[:, 1:2]
|
| 39 |
+
|
| 40 |
+
def loss(self, model, X, y):
|
| 41 |
+
return torch.nn.functional.mse_loss(model(X), y)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def worker(rank, world, init_file, out_dir, cpu_opt):
|
| 45 |
+
if cpu_opt is not None:
|
| 46 |
+
os.environ["DAISY_CPU_OPT"] = cpu_opt
|
| 47 |
+
dist.init_process_group("gloo", init_method="file://" + init_file, rank=rank, world_size=world)
|
| 48 |
+
cl = C.DaisyCluster.__new__(C.DaisyCluster)
|
| 49 |
+
cl.threads, cl.rank, cl.world = 2, rank, world
|
| 50 |
+
cl.device = torch.device("cuda" if rank == 0 else "cpu")
|
| 51 |
+
cl.plan = {"devices": ["cuda", "cpu"], "my_weight": 0.5, "my_local_batch": 64,
|
| 52 |
+
"weights": [0.5, 0.5], "local_batches": [64, 64], "total_cores": 2, "total_ram_gb": None}
|
| 53 |
+
torch.manual_seed(3)
|
| 54 |
+
model = torch.nn.Sequential(torch.nn.Linear(32, 64), torch.nn.ReLU(), torch.nn.Linear(64, 1))
|
| 55 |
+
err = None
|
| 56 |
+
try:
|
| 57 |
+
cl.fit(model, Task(rank), steps=40, lr=1e-3, optimizer="adam", check_every=5)
|
| 58 |
+
except RuntimeError as e:
|
| 59 |
+
err = str(e)
|
| 60 |
+
torch.save({"err": err, "hash": C.param_hash(list(model.parameters()))},
|
| 61 |
+
os.path.join(out_dir, "r%d.pt" % rank))
|
| 62 |
+
dist.destroy_process_group()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def run(cpu_opt):
|
| 66 |
+
with tempfile.TemporaryDirectory() as td:
|
| 67 |
+
mp.spawn(worker, args=(2, os.path.join(td, "init"), td, cpu_opt), nprocs=2, join=True)
|
| 68 |
+
return [torch.load(os.path.join(td, "r%d.pt" % r)) for r in range(2)]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main():
|
| 72 |
+
if not C._cuda_usable():
|
| 73 |
+
print("SKIPPED: no CUDA device (this test needs one GPU rank and one CPU rank)")
|
| 74 |
+
return 0
|
| 75 |
+
print("old behaviour (DAISY_CPU_OPT=0): optimizer on each rank's own device")
|
| 76 |
+
old = run("0")
|
| 77 |
+
ck("the replica check stops the run on both ranks", all(r["err"] and "diverged" in r["err"] for r in old),
|
| 78 |
+
[r["err"] for r in old])
|
| 79 |
+
print("default: GPU ranks step on CPU master copies")
|
| 80 |
+
new = run(None)
|
| 81 |
+
ck("the run completes on both ranks", all(r["err"] is None for r in new), [r["err"] for r in new])
|
| 82 |
+
ck("final parameter bits are identical across the CUDA and CPU ranks", new[0]["hash"] == new[1]["hash"])
|
| 83 |
+
|
| 84 |
+
print("pin_fp_mode")
|
| 85 |
+
torch.set_flush_denormal(True)
|
| 86 |
+
C.pin_fp_mode()
|
| 87 |
+
ck("subnormals survive after pin_fp_mode (FTZ off)",
|
| 88 |
+
float(torch.tensor([1e-39], dtype=torch.float32) * 1.0) != 0.0)
|
| 89 |
+
print()
|
| 90 |
+
if FAILURES:
|
| 91 |
+
print("FAILED: %d" % len(FAILURES))
|
| 92 |
+
return 1
|
| 93 |
+
print("all checks passed")
|
| 94 |
+
return 0
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
sys.exit(main())
|
test_cluster_reductions.py
CHANGED
|
@@ -99,6 +99,19 @@ def main():
|
|
| 99 |
ck("the weights were never touched",
|
| 100 |
all(torch.equal(a, b) for a, b in zip(before, lin.parameters())))
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
print()
|
| 103 |
if FAILURES:
|
| 104 |
print("FAILED: %d" % len(FAILURES))
|
|
|
|
| 99 |
ck("the weights were never touched",
|
| 100 |
all(torch.equal(a, b) for a, b in zip(before, lin.parameters())))
|
| 101 |
|
| 102 |
+
print("plan_capacities -- never weigh matmuls/sec against core counts (2026-10-14)")
|
| 103 |
+
from daisychain.cluster import plan_capacities
|
| 104 |
+
meas = {"cores": 8, "capacity": 210.0, "capacity_unit": "matmul/s"}
|
| 105 |
+
fell = {"cores": 8, "capacity": 8.0, "capacity_unit": "cores"} # its benchmark threw
|
| 106 |
+
caps, unit = plan_capacities([meas, dict(meas, capacity=190.0)])
|
| 107 |
+
ck("all measured: the measured capacities are used", caps == [210.0, 190.0] and unit == "matmul/s")
|
| 108 |
+
caps, unit = plan_capacities([meas, fell])
|
| 109 |
+
ck("one fallback: EVERY node weighs by cores, not 210 vs 8", caps == [8.0, 8.0] and "mixed" in unit, (caps, unit))
|
| 110 |
+
caps, unit = plan_capacities([meas, {"cores": 4, "capacity": 999.0, "capacity_unit": "user"}])
|
| 111 |
+
ck("a DAISY_CAPACITY override is taken as stated", caps == [210.0, 999.0], caps)
|
| 112 |
+
caps, unit = plan_capacities([{"cores": 8, "capacity": 8.0}, {"cores": 8, "capacity": 150.0}])
|
| 113 |
+
ck("old-format surveys (no unit) are classified, and mixed -> cores", caps == [8.0, 8.0], caps)
|
| 114 |
+
|
| 115 |
print()
|
| 116 |
if FAILURES:
|
| 117 |
print("FAILED: %d" % len(FAILURES))
|
web/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
| 6 |
"author": "Dean Byrne (Quazim0t0) / DaisyChainAI",
|
| 7 |
"scripts": {
|
| 8 |
"start": "node server.js",
|
| 9 |
-
"test": "node test_core.js && node test_verified.js && node test_ieee.js && node test_gates.js && node test_metamorphic.js && node test_corpus.js && node test_selfcorpus.js && node test_b2b.js && node test_optimizer.js && node test_transformer.js && node test_unit_backward.js && node test_nonfinite.js && node test_reductions.js"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
"hyparquet": "^1.26.2",
|
|
|
|
| 6 |
"author": "Dean Byrne (Quazim0t0) / DaisyChainAI",
|
| 7 |
"scripts": {
|
| 8 |
"start": "node server.js",
|
| 9 |
+
"test": "node test_core.js && node test_verified.js && node test_ieee.js && node test_gates.js && node test_metamorphic.js && node test_corpus.js && node test_selfcorpus.js && node test_b2b.js && node test_optimizer.js && node test_transformer.js && node test_unit_backward.js && node test_nonfinite.js && node test_reductions.js && node test_fgemm_audit.js && node test_probe_unproven.js"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
"hyparquet": "^1.26.2",
|
web/public/app.js
CHANGED
|
@@ -268,15 +268,22 @@ function setupChannel(peerId, dc) {
|
|
| 268 |
// the next step boundary instead of making them wait for the next run
|
| 269 |
if (training && leaderId === null) { pendingJoins.add(peerId); log(`${nmeOf(peerId)} will be synced into the running training`); }
|
| 270 |
};
|
| 271 |
-
dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
|
| 272 |
dc.onmessage = (e) => onGrad(peerId, e.data);
|
| 273 |
}
|
| 274 |
function cleanupPeer(id) {
|
| 275 |
const pc = pcs.get(id); if (pc) pc.close();
|
| 276 |
pcs.delete(id); chans.delete(id); pendingCand.delete(id); pendingJoins.delete(id);
|
| 277 |
-
|
| 278 |
wake();
|
| 279 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
// ---- checkpoints ------------------------------------------------------------
|
| 282 |
// File layout (also the broadcast payload after the sentinel):
|
|
@@ -555,8 +562,13 @@ function onFragment(peerId, buf) { // returns full message when co
|
|
| 555 |
const key = peerId + ":" + id;
|
| 556 |
let st = fragIn.get(key);
|
| 557 |
if (!st) { st = { id, parts: [], got: 0, total }; fragIn.set(key, st); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
st.parts[seq] = new Uint8Array(buf, 16).slice(0);
|
| 559 |
-
st.got++;
|
| 560 |
if (st.got < st.total) return null;
|
| 561 |
fragIn.delete(key);
|
| 562 |
let len = 0; for (const p of st.parts) len += p.length;
|
|
@@ -612,8 +624,12 @@ async function refreshProbe() {
|
|
| 612 |
mathHash = Compute.mathProbe();
|
| 613 |
log(`JS math library hash: ${mathHash.toString(16)} (peers must match; ECMA-262 does not require exp/log/cos/sin to be correctly rounded)`);
|
| 614 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
try { probeHash = await Compute.kernelProbe(compute, L); }
|
| 616 |
-
catch (e) { log(`kernel probe failed to run: ${e.message}`); }
|
| 617 |
return probeHash;
|
| 618 |
}
|
| 619 |
function hashWeights() { // FNV-1a over the flat param bytes
|
|
@@ -898,8 +914,16 @@ async function train(cfg, resume) {
|
|
| 898 |
if (bad) { halted = `weights diverged from ${nmeOf(bad)} (detected at step ${s + 1})`; break; }
|
| 899 |
// kernel disagreement: same seeded probe, same exact integer arithmetic
|
| 900 |
// expected on every backend. This is the check the weight hash can't make.
|
| 901 |
-
|
| 902 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
if (badK) {
|
| 904 |
halted = `kernel disagreement with ${nmeOf(badK)} at step ${s + 1} — their verified-unit probe ` +
|
| 905 |
`hashes ${hs.get(badK).phash} vs mine ${probeHash}. One of us is computing different ` +
|
|
|
|
| 268 |
// the next step boundary instead of making them wait for the next run
|
| 269 |
if (training && leaderId === null) { pendingJoins.add(peerId); log(`${nmeOf(peerId)} will be synced into the running training`); }
|
| 270 |
};
|
| 271 |
+
dc.onclose = () => { chans.delete(peerId); dropFragments(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
|
| 272 |
dc.onmessage = (e) => onGrad(peerId, e.data);
|
| 273 |
}
|
| 274 |
function cleanupPeer(id) {
|
| 275 |
const pc = pcs.get(id); if (pc) pc.close();
|
| 276 |
pcs.delete(id); chans.delete(id); pendingCand.delete(id); pendingJoins.delete(id);
|
| 277 |
+
dropFragments(id);
|
| 278 |
wake();
|
| 279 |
}
|
| 280 |
+
// A message cut off by a closed channel is never completed (the sender does not
|
| 281 |
+
// resume it), so its parts must go when the channel does. Before 2026-10-14 only
|
| 282 |
+
// cleanupPeer dropped them, and only the reconnect INITIATOR calls that: the
|
| 283 |
+
// other side kept up to a resume bundle's worth of dead parts per drop.
|
| 284 |
+
function dropFragments(id) {
|
| 285 |
+
for (const k of [...fragIn.keys()]) if (k.startsWith(id + ":")) fragIn.delete(k);
|
| 286 |
+
}
|
| 287 |
|
| 288 |
// ---- checkpoints ------------------------------------------------------------
|
| 289 |
// File layout (also the broadcast payload after the sentinel):
|
|
|
|
| 562 |
const key = peerId + ":" + id;
|
| 563 |
let st = fragIn.get(key);
|
| 564 |
if (!st) { st = { id, parts: [], got: 0, total }; fragIn.set(key, st); }
|
| 565 |
+
if (seq < 0 || seq >= st.total || total !== st.total) { // malformed: drop the whole message, loudly
|
| 566 |
+
log(`dropped malformed message ${id} from ${nmeOf(peerId)} (fragment ${seq}/${total}, expected /${st.total})`);
|
| 567 |
+
fragIn.delete(key);
|
| 568 |
+
return null;
|
| 569 |
+
}
|
| 570 |
+
if (!st.parts[seq]) st.got++; // a repeated part must not count twice
|
| 571 |
st.parts[seq] = new Uint8Array(buf, 16).slice(0);
|
|
|
|
| 572 |
if (st.got < st.total) return null;
|
| 573 |
fragIn.delete(key);
|
| 574 |
let len = 0; for (const p of st.parts) len += p.length;
|
|
|
|
| 624 |
mathHash = Compute.mathProbe();
|
| 625 |
log(`JS math library hash: ${mathHash.toString(16)} (peers must match; ECMA-262 does not require exp/log/cos/sin to be correctly rounded)`);
|
| 626 |
}
|
| 627 |
+
// A failed probe must not leave the PREVIOUS hash in place: that hash would
|
| 628 |
+
// go out with every gradient as a proof of kernels this run never re-checked.
|
| 629 |
+
// 0 means "unproven", and the roster check halts on it (2026-10-14; the same
|
| 630 |
+
// shape as an emulator call that reported success without doing the work).
|
| 631 |
try { probeHash = await Compute.kernelProbe(compute, L); }
|
| 632 |
+
catch (e) { probeHash = 0; log(`kernel probe failed to run: ${e.message} — this device's kernels are UNPROVEN`); }
|
| 633 |
return probeHash;
|
| 634 |
}
|
| 635 |
function hashWeights() { // FNV-1a over the flat param bytes
|
|
|
|
| 914 |
if (bad) { halted = `weights diverged from ${nmeOf(bad)} (detected at step ${s + 1})`; break; }
|
| 915 |
// kernel disagreement: same seeded probe, same exact integer arithmetic
|
| 916 |
// expected on every backend. This is the check the weight hash can't make.
|
| 917 |
+
// phash 0 = that device's probe did not run. It used to make this
|
| 918 |
+
// comparison skip the peer (`phash && ...`), so a device whose kernels
|
| 919 |
+
// could not be proven trained on unchecked; now it stops the run.
|
| 920 |
+
const unproven = roster.find(id => hs.has(id) && !hs.get(id).phash);
|
| 921 |
+
if (unproven) {
|
| 922 |
+
halted = `${unproven === myId ? "this device's" : nmeOf(unproven) + "'s"} kernel probe failed to run ` +
|
| 923 |
+
`at step ${s + 1} — its arithmetic is unproven, so its gradients cannot be trusted`;
|
| 924 |
+
break;
|
| 925 |
+
}
|
| 926 |
+
const badK = roster.find(id => id !== myId && hs.has(id) && hs.get(id).phash !== probeHash);
|
| 927 |
if (badK) {
|
| 928 |
halted = `kernel disagreement with ${nmeOf(badK)} at step ${s + 1} — their verified-unit probe ` +
|
| 929 |
`hashes ${hs.get(badK).phash} vs mine ${probeHash}. One of us is computing different ` +
|
web/public/transformer.js
CHANGED
|
@@ -182,7 +182,7 @@
|
|
| 182 |
cfg: { ...cfg, layers, heads, hidden, vocab: vocabSize() },
|
| 183 |
ctx: { L, bgemm: (engine && engine.bgemm) || null,
|
| 184 |
att: (engine && engine.att) || null, fgemm: (engine && engine.fgemm) || null,
|
| 185 |
-
fgemm2: (engine && engine.fgemm2) || null,
|
| 186 |
mlp: (engine && engine.mlp) || null,
|
| 187 |
audit: audit || null, unitBackward: !!cfg.unitBackward },
|
| 188 |
emb: add("emb", mk(vocabSize() * c, 0.08)),
|
|
@@ -425,6 +425,14 @@
|
|
| 425 |
bmm(cache.dlogits, m.emb, BT, vocab, C),
|
| 426 |
]);
|
| 427 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
let dx = lnBwd(dlnfIn, cache.lnf.y, cache.lnf.sig, BT, C);
|
| 429 |
const scale = 1 / Math.sqrt(hd);
|
| 430 |
// concat helper for fusing sibling GEMMs into one batched dispatch
|
|
|
|
| 182 |
cfg: { ...cfg, layers, heads, hidden, vocab: vocabSize() },
|
| 183 |
ctx: { L, bgemm: (engine && engine.bgemm) || null,
|
| 184 |
att: (engine && engine.att) || null, fgemm: (engine && engine.fgemm) || null,
|
| 185 |
+
fgemm2: (engine && engine.fgemm2) || null, fgemmFma: !!(engine && engine.fgemmFma),
|
| 186 |
mlp: (engine && engine.mlp) || null,
|
| 187 |
audit: audit || null, unitBackward: !!cfg.unitBackward },
|
| 188 |
emb: add("emb", mk(vocabSize() * c, 0.08)),
|
|
|
|
| 425 |
bmm(cache.dlogits, m.emb, BT, vocab, C),
|
| 426 |
]);
|
| 427 |
}
|
| 428 |
+
// live-shape audit of the f32 GEMM pair (both legs; gated only at small shapes)
|
| 429 |
+
if ((m.ctx.fgemm2 || m.ctx.fgemm) && !units && m.ctx.audit && m.ctx.audit.due()) {
|
| 430 |
+
const bad = V.auditFgemm(cache.dlogits, cache.lnf.y, { m: vocab, k: BT, n: C, transA: true },
|
| 431 |
+
g[gi.emb], m.ctx.fgemmFma, m.ctx.audit.cells) ||
|
| 432 |
+
V.auditFgemm(cache.dlogits, m.emb, { m: BT, k: vocab, n: C },
|
| 433 |
+
dlnfIn, m.ctx.fgemmFma, m.ctx.audit.cells);
|
| 434 |
+
if (bad) m.ctx.audit.fail(bad);
|
| 435 |
+
}
|
| 436 |
let dx = lnBwd(dlnfIn, cache.lnf.y, cache.lnf.sig, BT, C);
|
| 437 |
const scale = 1 / Math.sqrt(hd);
|
| 438 |
// concat helper for fusing sibling GEMMs into one batched dispatch
|
web/public/verified_core.js
CHANGED
|
@@ -334,6 +334,40 @@
|
|
| 334 |
return out;
|
| 335 |
}
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
// ---- live audits for the fused attention kernels ---------------------------
|
| 338 |
// The attention kernels had exact INIT gates but nothing at live shapes —
|
| 339 |
// the exact gap the GEMM audit exists to close, left open on the kernels with
|
|
@@ -538,7 +572,7 @@
|
|
| 538 |
const api = { assertFinite, accBound, MAX_INT32_K, quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
|
| 539 |
bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply,
|
| 540 |
rowAbsMax, scalesFromAbsMax, quantizeRowsInv, vmlpBlock, bitDiff,
|
| 541 |
-
auditAttScores, auditAttCtx, fgemmMirror };
|
| 542 |
if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
|
| 543 |
else { TC = root.TrainCore; root.Verified = api; }
|
| 544 |
})(typeof self !== "undefined" ? self : this);
|
|
|
|
| 334 |
return out;
|
| 335 |
}
|
| 336 |
|
| 337 |
+
// ---- live audit for the split-K f32 GEMM -----------------------------------
|
| 338 |
+
// Every int8 kernel had a live-shape audit; the f32 backward GEMM -- whose
|
| 339 |
+
// output IS the embedding gradient -- had only its init gate at 7x4500x5 and
|
| 340 |
+
// 9x300x6, while training runs it at vocab x BT x C. Same remedy as auditTile:
|
| 341 |
+
// recompute a few cells (structural corners first) with the mirror's exact
|
| 342 |
+
// partition and rounding schedule, compare bit-for-bit (2026-10-14 review).
|
| 343 |
+
function fgemmCell(A, Bm, d, fma, row, col) {
|
| 344 |
+
const { m, k, n } = d, transA = !!d.transA;
|
| 345 |
+
const S = k > 4096 ? Math.min(16, Math.ceil(k / 2048)) : 1;
|
| 346 |
+
const ks = Math.ceil(k / S);
|
| 347 |
+
let acc = 0;
|
| 348 |
+
for (let z = 0; z < S; z++) {
|
| 349 |
+
const p0 = z * ks, p1 = Math.min(k, p0 + ks);
|
| 350 |
+
let s = 0;
|
| 351 |
+
for (let p = p0; p < p1; p++) {
|
| 352 |
+
const a = transA ? A[p * m + row] : A[row * k + p];
|
| 353 |
+
s = fma ? f32(s + a * Bm[p * n + col]) : f32(s + f32(a * Bm[p * n + col]));
|
| 354 |
+
}
|
| 355 |
+
acc = f32(acc + s);
|
| 356 |
+
}
|
| 357 |
+
return acc;
|
| 358 |
+
}
|
| 359 |
+
function auditFgemm(A, Bm, d, got, fma, nCells) {
|
| 360 |
+
const { m, n } = d, N = nCells || 8;
|
| 361 |
+
const edges = [[m - 1, n - 1], [0, n - 1], [m - 1, 0], [0, 0]];
|
| 362 |
+
for (let t = 0; t < N; t++) {
|
| 363 |
+
const [i, j] = t < edges.length ? edges[t] : [(Math.random() * m) | 0, (Math.random() * n) | 0];
|
| 364 |
+
const v = fgemmCell(A, Bm, d, fma, i, j);
|
| 365 |
+
if (bitDiff(got[i * n + j], v))
|
| 366 |
+
return `f32 GEMM audit failed at [${i},${j}] shape ${m}x${d.k}x${n}${d.transA ? " transA" : ""}: kernel ${got[i * n + j]} vs mirror ${v}`;
|
| 367 |
+
}
|
| 368 |
+
return null;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
// ---- live audits for the fused attention kernels ---------------------------
|
| 372 |
// The attention kernels had exact INIT gates but nothing at live shapes —
|
| 373 |
// the exact gap the GEMM audit exists to close, left open on the kernels with
|
|
|
|
| 572 |
const api = { assertFinite, accBound, MAX_INT32_K, quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
|
| 573 |
bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply,
|
| 574 |
rowAbsMax, scalesFromAbsMax, quantizeRowsInv, vmlpBlock, bitDiff,
|
| 575 |
+
auditAttScores, auditAttCtx, fgemmMirror, auditFgemm };
|
| 576 |
if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
|
| 577 |
else { TC = root.TrainCore; root.Verified = api; }
|
| 578 |
})(typeof self !== "undefined" ? self : this);
|
web/public/webgpu.js
CHANGED
|
@@ -286,7 +286,11 @@
|
|
| 286 |
matmulInt8: (Xq, Wq, m, k, n, LL) => root.Verified.lutMatmulJS(Xq, Wq, m, k, n, LL) };
|
| 287 |
if (!(root.navigator && navigator.gpu)) return cpu;
|
| 288 |
try {
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
if (!adapter) return cpu;
|
| 291 |
const device = await adapter.requestDevice();
|
| 292 |
const info = adapter.info || {};
|
|
@@ -330,9 +334,27 @@
|
|
| 330 |
// because head-strided addressing is where these kernels can go wrong.
|
| 331 |
let att = { scores: (qq, kq, qs, ks, d) => gpuAttScores(device, d.acc ? scoresVPipe : scoresPipe, qq, kq, qs, ks, d),
|
| 332 |
ctx: (aq, vq, as, vs, d) => gpuAttCtx(device, d.acc ? ctxVPipe : ctxPipe, aq, vq, as, vs, d) };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
try {
|
| 334 |
-
for (const
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
const nQ = d0.B * d0.T * d0.heads * d0.hd;
|
| 337 |
const qq = new Int8Array(nQ), kq = new Int8Array(nQ), vq = new Int8Array(nQ);
|
| 338 |
for (let i = 0; i < nQ; i++) { qq[i] = (Math.random() * 256 - 128) | 0; kq[i] = (Math.random() * 256 - 128) | 0; vq[i] = (Math.random() * 256 - 128) | 0; }
|
|
@@ -353,7 +375,8 @@
|
|
| 353 |
const refS = root.Verified.attScoresJS(qq, kq, qs, ks, d0, L);
|
| 354 |
const refC = root.Verified.attCtxJS(aq, vq, as, vs, d0, L);
|
| 355 |
for (let i = 0; i < refS.length; i++) if (bitDiff(hwS[i], refS[i])) throw new Error(`scores epilogue mismatch @${i}`);
|
| 356 |
-
for (let i = 0; i < refC.length; i++) if (bitDiff(hwC[i], refC[i])) throw new Error(`ctx epilogue mismatch @${i}`);
|
|
|
|
| 357 |
}
|
| 358 |
} catch (e) {
|
| 359 |
console.warn("fused attention kernels failed verification — using CPU LUT mirrors:", e.message);
|
|
@@ -398,6 +421,39 @@
|
|
| 398 |
const Bt = Float32Array.from({ length: mT * nT }, () => Math.random() - 0.5);
|
| 399 |
const Bn = Float32Array.from({ length: kT * nT }, () => Math.random() - 0.5);
|
| 400 |
const dT = { m: kT, k: mT, n: nT, transA: true }, dN = { m: mT, k: kT, n: nT };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
const [f1, f2] = await fgemm2(A2, Bt, dT, Bn, dN);
|
| 402 |
const [r1, r2] = [await fgemm(A2, Bt, dT), await fgemm(A2, Bn, dN)];
|
| 403 |
for (let i = 0; i < r1.length; i++) if (bitDiff(f1[i], r1[i])) throw new Error("fgemm2 transA leg differs");
|
|
@@ -559,7 +615,7 @@
|
|
| 559 |
|
| 560 |
const viaLUT = { backend: "webgpu", label: `${gpuName} (LUT shader · exact-gated)`,
|
| 561 |
matmulInt8: (Xq, Wq, m, k, n) => gpuMatmulLUT(device, lutPipe, lutBuf, Xq, Wq, m, k, n),
|
| 562 |
-
bgemm: bgLut, att, fgemm, fgemm2, mlp: mlpLut };
|
| 563 |
|
| 564 |
// DP4A pipeline — only if the WGSL feature exists AND its batched kernel
|
| 565 |
// reproduces the verified units exactly across the shape sweep
|
|
@@ -587,7 +643,7 @@
|
|
| 587 |
// worse than a fixed choice. DP4A ships: it wins the int8-backward mode
|
| 588 |
// by ~12% and ties elsewhere.
|
| 589 |
return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot · exact-gated vs units)`,
|
| 590 |
-
bgemm: bg, att, fgemm, fgemm2, mlp: mlpDp4 };
|
| 591 |
} catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
|
| 592 |
}
|
| 593 |
|
|
|
|
| 286 |
matmulInt8: (Xq, Wq, m, k, n, LL) => root.Verified.lutMatmulJS(Xq, Wq, m, k, n, LL) };
|
| 287 |
if (!(root.navigator && navigator.gpu)) return cpu;
|
| 288 |
try {
|
| 289 |
+
// Ask for the discrete GPU: the default adapter on a laptop is the iGPU
|
| 290 |
+
// (measured 2026-10-14: Intel gen-9 picked while an RTX sat idle). The
|
| 291 |
+
// browser may still hand back the iGPU; the gates decide what runs.
|
| 292 |
+
const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ||
|
| 293 |
+
(await navigator.gpu.requestAdapter());
|
| 294 |
if (!adapter) return cpu;
|
| 295 |
const device = await adapter.requestDevice();
|
| 296 |
const info = adapter.info || {};
|
|
|
|
| 334 |
// because head-strided addressing is where these kernels can go wrong.
|
| 335 |
let att = { scores: (qq, kq, qs, ks, d) => gpuAttScores(device, d.acc ? scoresVPipe : scoresPipe, qq, kq, qs, ks, d),
|
| 336 |
ctx: (aq, vq, as, vs, d) => gpuAttCtx(device, d.acc ? ctxVPipe : ctxPipe, aq, vq, as, vs, d) };
|
| 337 |
+
// Two sweeps: the second runs on RECYCLED pool buffers left full of
|
| 338 |
+
// large-magnitude residue (same shapes => same buckets, LIFO free list),
|
| 339 |
+
// the dirty-buffer gate the bgemm and MLP kernels already had. Without it
|
| 340 |
+
// a kernel that skips writing some output cell would pass on fresh
|
| 341 |
+
// buffers and corrupt live steps (2026-10-14 review).
|
| 342 |
+
const ATT_SHAPES = [{ B: 2, T: 8, heads: 2, hd: 8 }, { B: 1, T: 32, heads: 2, hd: 16 },
|
| 343 |
+
{ B: 3, T: 7, heads: 3, hd: 5 }, { B: 2, T: 33, heads: 4, hd: 8 }];
|
| 344 |
try {
|
| 345 |
+
for (const dirty of [false, true]) {
|
| 346 |
+
if (dirty) for (const d0 of ATT_SHAPES) {
|
| 347 |
+
const nQ = d0.B * d0.T * d0.heads * d0.hd, nA = d0.B * d0.heads * d0.T * d0.T;
|
| 348 |
+
const big = (n) => new Float32Array(n).fill(1e4);
|
| 349 |
+
for (const acc of [false, true]) {
|
| 350 |
+
const dv = { ...d0, acc };
|
| 351 |
+
await att.scores(new Int8Array(nQ).fill(127), new Int8Array(nQ).fill(127),
|
| 352 |
+
big(d0.B * d0.T * d0.heads), big(d0.B * d0.T * d0.heads), dv);
|
| 353 |
+
await att.ctx(new Int8Array(nA).fill(127), new Int8Array(nQ).fill(127),
|
| 354 |
+
big(d0.B * d0.heads * d0.T), big(d0.B * d0.heads * d0.hd), dv);
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
for (const d0 of ATT_SHAPES) {
|
| 358 |
const nQ = d0.B * d0.T * d0.heads * d0.hd;
|
| 359 |
const qq = new Int8Array(nQ), kq = new Int8Array(nQ), vq = new Int8Array(nQ);
|
| 360 |
for (let i = 0; i < nQ; i++) { qq[i] = (Math.random() * 256 - 128) | 0; kq[i] = (Math.random() * 256 - 128) | 0; vq[i] = (Math.random() * 256 - 128) | 0; }
|
|
|
|
| 375 |
const refS = root.Verified.attScoresJS(qq, kq, qs, ks, d0, L);
|
| 376 |
const refC = root.Verified.attCtxJS(aq, vq, as, vs, d0, L);
|
| 377 |
for (let i = 0; i < refS.length; i++) if (bitDiff(hwS[i], refS[i])) throw new Error(`scores epilogue mismatch @${i}`);
|
| 378 |
+
for (let i = 0; i < refC.length; i++) if (bitDiff(hwC[i], refC[i])) throw new Error(`ctx epilogue mismatch @${i}${dirty ? " (dirty pool)" : ""}`);
|
| 379 |
+
}
|
| 380 |
}
|
| 381 |
} catch (e) {
|
| 382 |
console.warn("fused attention kernels failed verification — using CPU LUT mirrors:", e.message);
|
|
|
|
| 421 |
const Bt = Float32Array.from({ length: mT * nT }, () => Math.random() - 0.5);
|
| 422 |
const Bn = Float32Array.from({ length: kT * nT }, () => Math.random() - 0.5);
|
| 423 |
const dT = { m: kT, k: mT, n: nT, transA: true }, dN = { m: mT, k: kT, n: nT };
|
| 424 |
+
// transA against the MIRROR, not just against fgemm: before 2026-10-14
|
| 425 |
+
// the transA index pattern was only ever compared with itself (fgemm2
|
| 426 |
+
// leg vs fgemm call, same shader), so a wrong A[p*m+row] passed. The
|
| 427 |
+
// second shape puts transA through split-K (k > 4096) as well.
|
| 428 |
+
const fma = fgemmFma;
|
| 429 |
+
for (const [mA, kA, nA] of [[kT, mT, nT], [6, 4500, 3]]) {
|
| 430 |
+
const At = Float32Array.from({ length: mA * kA }, () => Math.random() - 0.5);
|
| 431 |
+
const Bx = Float32Array.from({ length: kA * nA }, () => Math.random() - 0.5);
|
| 432 |
+
const dX = { m: mA, k: kA, n: nA, transA: true };
|
| 433 |
+
const got = await fgemm(At, Bx, dX), want = root.Verified.fgemmMirror(At, Bx, dX, fma);
|
| 434 |
+
for (let i = 0; i < want.length; i++) if (bitDiff(got[i], want[i])) throw new Error(`fgemm transA differs from the mirror @${i} (${mA}x${kA}x${nA})`);
|
| 435 |
+
}
|
| 436 |
+
// SUBNORMALS: WGSL permits an implementation to flush f32 subnormals to
|
| 437 |
+
// zero, and the JS mirror never flushes. The random inputs above are
|
| 438 |
+
// ~0.1 in magnitude and never produce one, so a flushing GPU passed this
|
| 439 |
+
// gate and then forked from non-flushing peers on tiny gradients
|
| 440 |
+
// (2026-10-14; the emulator's FTZ/DAZ lesson). Products here are ~1e-40.
|
| 441 |
+
{
|
| 442 |
+
const dS = { m: 3, k: 64, n: 2 };
|
| 443 |
+
const As = Float32Array.from({ length: dS.m * dS.k }, (_, i) => (i % 2 ? 1 : -1) * (1 + (i % 7)) * 1e-20);
|
| 444 |
+
const Bs = Float32Array.from({ length: dS.k * dS.n }, (_, i) => (1 + (i % 5)) * 1e-20);
|
| 445 |
+
const got = await fgemm(As, Bs, dS), want = root.Verified.fgemmMirror(As, Bs, dS, fma);
|
| 446 |
+
let sub = 0;
|
| 447 |
+
for (let i = 0; i < want.length; i++) if (want[i] !== 0 && Math.abs(want[i]) < 1.1754943508222875e-38) sub++;
|
| 448 |
+
if (!sub) throw new Error("subnormal gate case produced no subnormal (test is vacuous)");
|
| 449 |
+
for (let i = 0; i < want.length; i++) if (bitDiff(got[i], want[i]))
|
| 450 |
+
throw new Error(`fgemm flushes or rounds subnormals differently @${i}: ${got[i]} vs mirror ${want[i]}`);
|
| 451 |
+
}
|
| 452 |
+
// dirty re-gate: poison the partial/output buckets at the gate's shapes, then re-check
|
| 453 |
+
await fgemm(new Float32Array(m0 * k0).fill(1e4), new Float32Array(k0 * n0).fill(1e4), dG);
|
| 454 |
+
const hw2 = await fgemm(A, Bm, dG);
|
| 455 |
+
const mRef = fma ? mFma : mStep;
|
| 456 |
+
for (let i = 0; i < hw2.length; i++) if (bitDiff(hw2[i], mRef[i])) throw new Error(`fgemm differs on a recycled (dirty) pool @${i}`);
|
| 457 |
const [f1, f2] = await fgemm2(A2, Bt, dT, Bn, dN);
|
| 458 |
const [r1, r2] = [await fgemm(A2, Bt, dT), await fgemm(A2, Bn, dN)];
|
| 459 |
for (let i = 0; i < r1.length; i++) if (bitDiff(f1[i], r1[i])) throw new Error("fgemm2 transA leg differs");
|
|
|
|
| 615 |
|
| 616 |
const viaLUT = { backend: "webgpu", label: `${gpuName} (LUT shader · exact-gated)`,
|
| 617 |
matmulInt8: (Xq, Wq, m, k, n) => gpuMatmulLUT(device, lutPipe, lutBuf, Xq, Wq, m, k, n),
|
| 618 |
+
bgemm: bgLut, att, fgemm, fgemm2, fgemmFma, mlp: mlpLut };
|
| 619 |
|
| 620 |
// DP4A pipeline — only if the WGSL feature exists AND its batched kernel
|
| 621 |
// reproduces the verified units exactly across the shape sweep
|
|
|
|
| 643 |
// worse than a fixed choice. DP4A ships: it wins the int8-backward mode
|
| 644 |
// by ~12% and ties elsewhere.
|
| 645 |
return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot · exact-gated vs units)`,
|
| 646 |
+
bgemm: bg, att, fgemm, fgemm2, fgemmFma, mlp: mlpDp4 };
|
| 647 |
} catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
|
| 648 |
}
|
| 649 |
|
web/test_fgemm_audit.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Live audit of the split-K f32 GEMM (2026-10-14 review).
|
| 2 |
+
// auditFgemm recomputes single cells with the mirror's partition and rounding
|
| 3 |
+
// schedule; it must (a) accept the mirror's own output at the LIVE shapes
|
| 4 |
+
// (transA embedding gradient, split-K dlnf), and (b) reject a one-ulp error
|
| 5 |
+
// in a corner cell and in an interior cell, under both rounding schedules.
|
| 6 |
+
const V = require("./public/verified_core.js");
|
| 7 |
+
|
| 8 |
+
let pass = 0, fail = 0;
|
| 9 |
+
const t = (name, ok, detail) => {
|
| 10 |
+
if (ok) { pass++; console.log(" PASS " + name); }
|
| 11 |
+
else { fail++; console.log(` FAIL ${name} [${detail || ""}]`); }
|
| 12 |
+
};
|
| 13 |
+
const rnd = (n) => Float32Array.from({ length: n }, () => Math.random() - 0.5);
|
| 14 |
+
const bumpUlp = (x) => { const f = new Float32Array([x]), u = new Uint32Array(f.buffer); u[0] += 1; return f[0]; };
|
| 15 |
+
|
| 16 |
+
// live-like shapes, scaled down: transA leg (m=vocab, k=BT) and the split-K leg (k=vocab > 4096)
|
| 17 |
+
const shapes = [{ m: 600, k: 64, n: 12, transA: true }, { m: 64, k: 5000, n: 12 }, { m: 3, k: 9000, n: 2, transA: true }];
|
| 18 |
+
for (const fma of [false, true]) {
|
| 19 |
+
for (const d of shapes) {
|
| 20 |
+
const A = rnd(d.m * d.k), B = rnd(d.k * d.n);
|
| 21 |
+
const out = V.fgemmMirror(A, B, d, fma);
|
| 22 |
+
const tag = `${d.m}x${d.k}x${d.n}${d.transA ? " transA" : ""} fma=${fma}`;
|
| 23 |
+
t(`audit accepts the mirror (${tag})`, V.auditFgemm(A, B, d, out, fma, 16) === null);
|
| 24 |
+
const corner = out.slice(); corner[d.m * d.n - 1] = bumpUlp(corner[d.m * d.n - 1]);
|
| 25 |
+
t(`audit catches a 1-ulp error in the last cell (${tag})`, V.auditFgemm(A, B, d, corner, fma, 4) !== null);
|
| 26 |
+
// interior: the random cells must eventually find it; 0 corners, 400 random draws on a small grid
|
| 27 |
+
const small = { m: 5, k: d.k, n: 3, transA: d.transA };
|
| 28 |
+
const As = rnd(small.m * small.k), Bs = rnd(small.k * small.n);
|
| 29 |
+
const o2 = V.fgemmMirror(As, Bs, small, fma); o2[1 * 3 + 1] = bumpUlp(o2[1 * 3 + 1]);
|
| 30 |
+
t(`audit's random cells find an interior error (${tag})`, V.auditFgemm(As, Bs, small, o2, fma, 400) !== null);
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
// The schedule matters: on split-K data the two schedules differ somewhere, so auditing
|
| 34 |
+
// with the wrong schedule must be able to fail -- otherwise the fma flag is untested.
|
| 35 |
+
{
|
| 36 |
+
const d = { m: 4, k: 6000, n: 4 };
|
| 37 |
+
const A = Float32Array.from({ length: d.m * d.k }, () => (Math.random() - 0.5) * 3);
|
| 38 |
+
const B = Float32Array.from({ length: d.k * d.n }, () => (Math.random() - 0.5) * 3);
|
| 39 |
+
const step = V.fgemmMirror(A, B, d, false), fused = V.fgemmMirror(A, B, d, true);
|
| 40 |
+
let differ = false;
|
| 41 |
+
for (let i = 0; i < step.length; i++) if (step[i] !== fused[i]) { differ = true; break; }
|
| 42 |
+
t("the two rounding schedules differ on split-K data (fma flag is load-bearing)", differ);
|
| 43 |
+
t("auditing fused output with the two-rounding schedule fails", V.auditFgemm(A, B, d, fused, false, 200) !== null);
|
| 44 |
+
}
|
| 45 |
+
console.log(`test_fgemm_audit.js: ${pass} passed, ${fail} failed`);
|
| 46 |
+
process.exit(fail ? 1 : 0);
|
web/test_probe_unproven.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// "0 = unproven" for the kernel probe hash (2026-10-14).
|
| 2 |
+
// app.js now treats phash 0 as "this device's probe did not run" and halts on it
|
| 3 |
+
// (the comparison used to SKIP such a peer). That is only sound if a probe that
|
| 4 |
+
// DID run can never hash to 0: check the canonical probe on the CPU backend and
|
| 5 |
+
// on a fake bgemm that returns the same result, and that a wrong kernel moves it.
|
| 6 |
+
const fs = require("fs");
|
| 7 |
+
const path = require("path");
|
| 8 |
+
const V = require("./public/verified_core.js");
|
| 9 |
+
|
| 10 |
+
const root = { Verified: V };
|
| 11 |
+
new Function("self", fs.readFileSync(path.join(__dirname, "public", "webgpu.js"), "utf8"))(root);
|
| 12 |
+
const mul = new Int16Array(fs.readFileSync(path.join(__dirname, "public", "mul_lut.bin")).buffer.slice(0));
|
| 13 |
+
const L = { mul };
|
| 14 |
+
|
| 15 |
+
let pass = 0, fail = 0;
|
| 16 |
+
const t = (name, ok, detail) => {
|
| 17 |
+
if (ok) { pass++; console.log(" PASS " + name); }
|
| 18 |
+
else { fail++; console.log(` FAIL ${name} [${detail || ""}]`); }
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
(async () => {
|
| 22 |
+
const cpu = await root.Compute.kernelProbe(null, L);
|
| 23 |
+
t("the canonical probe on the CPU mirror is non-zero", cpu !== 0, cpu);
|
| 24 |
+
t("...and deterministic", (await root.Compute.kernelProbe(null, L)) === cpu);
|
| 25 |
+
const same = { bgemm: async (Xq, Wq, rs, cs, d) => V.bgemmJS(Xq, Wq, rs, cs, d, L) };
|
| 26 |
+
t("a backend computing the same values hashes the same", (await root.Compute.kernelProbe(same, L)) === cpu);
|
| 27 |
+
const off = { bgemm: async (Xq, Wq, rs, cs, d) => {
|
| 28 |
+
const o = V.bgemmJS(Xq, Wq, rs, cs, d, L); o[o.length - 1] += 1; return o; } };
|
| 29 |
+
const h = await root.Compute.kernelProbe(off, L);
|
| 30 |
+
t("a kernel wrong in one cell hashes differently (and non-zero)", h !== cpu && h !== 0, h);
|
| 31 |
+
const app = fs.readFileSync(path.join(__dirname, "public", "app.js"), "utf8");
|
| 32 |
+
t("app.js resets probeHash to 0 when the probe throws", /catch \(e\) \{ probeHash = 0;/.test(app));
|
| 33 |
+
t("app.js halts on a roster member with phash 0", app.includes("roster.find(id => hs.has(id) && !hs.get(id).phash)"));
|
| 34 |
+
console.log(`test_probe_unproven.js: ${pass} passed, ${fail} failed`);
|
| 35 |
+
process.exit(fail ? 1 : 0);
|
| 36 |
+
})();
|