Quazim0t0 commited on
Commit
63780b7
·
verified ·
1 Parent(s): 346b731

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 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 - old hardware training pipeline
12
-
13
- Part of DaisyChain on Hugging Face: https://huggingface.co/DaisyChainAI
14
-
15
- Model page (weights + card): https://huggingface.co/DaisyChainAI/DaisyChain-Train
16
-
17
- I built this so spare / old machines can train a shared model. The training runs through emulated GPU logic: verified INT8 units (GUDA-style) that stand in for a GPU's math. Machines without a modern GPU still do the work. Chain several together and they train one model as a cluster.
18
-
19
- Before you rely on it, read what it cannot do: [Limitations](docs/LIMITS.md).
20
-
21
- Use the hardware you already have. Each machine runs the emulated GPU logic (verified INT8 units: multiply / requantize / ReLU). DaisyChain pools the machines data-parallel: device selection, capacity-weighted sharding, gradient sync, a P2P setup, and a live dashboard. Two ways to run: **Docker** or **Python**.
22
-
23
- ## Read this first
24
-
25
- DaisyChain-Train is for **small models on spare hardware**. It **pools compute, not memory** (the model must fit on one machine). Scaling is **sublinear**. It is **not** a substitute for a real GPU on large models. Full envelope: **[docs/LIMITS.md](docs/LIMITS.md)**. Read that before you depend on it.
26
-
27
- ## Feature list
28
-
29
- ### Python cluster trainer (`daisychain/`)
30
-
31
- - **Data-parallel training across mixed machines.** Each node trains its own shard. Gradients combine into the exact full-batch gradient. Replicas stay bit-identical.
32
- - **Capacity-weighted sharding.** Faster machines automatically take a bigger share of the batch.
33
- - **Emulated GPU compute (verified INT8 units).** `VerifiedLinear` layers run every forward multiply / requantize / ReLU through the bundled trained units. Rank 0 prints cluster-wide unit-invocation counts.
34
- - **Bring your own model.** Any `Task` (`build_model` / `sample` / `loss`) via `DAISY_TASK`. Template in `examples/my_task_template.py`.
35
- - **Plain-float alternative task.** Same cluster and pooling with ordinary float math.
36
- - **Live dashboard** (`daisychain-dashboard`). Readiness banner, P2P connectivity scan, pooled cores/RAM, per-node capacity plan, live loss.
37
- - **SpikeWhale control panel** (`spikewhale_panel`, `localhost:8899`). Sliders for model size / training settings, any HF dataset you can access (default streamed FineWeb-Edu), start/stop/re-adjust, live loss.
38
- - **Docker demo cluster.** 3 nodes + dashboard in one command.
39
- - **Windows helper** (`scripts\setup.bat`) and Tailscale mesh guide.
40
-
41
- ### DaisyChain-Web (`web/`) - browser P2P training
42
-
43
- - **Zero-install nodes.** Opening the page IS joining. Devices on one network auto-group (Snapdrop-style, by public IP).
44
- - **Private cross-network rooms.** `?room=CODE` with **host approval** for every join.
45
- - **Full WebRTC mesh.** Gradients travel peer-to-peer. The server only signals and serves static files. It never sees weights or gradients.
46
- - **Leader-follower runs.** Whoever presses Start sets width / sequence / batch-per-device / steps / learning rate for the whole group. Config is broadcast on the wire.
47
- - **Mid-run join.** Late devices are synced in (weights + step) and contribute from the next step.
48
- - **Bit-identical replicas.** Same seeded init, strict roster-order gradient averaging, deterministic Adam with identical state on every peer. Verified live by per-step weight hashes.
49
- - **Sync guard.** Any weight-hash mismatch stops the run instead of training past a fork. The step roster forbids silent partial averages.
50
- - **Gradient repair.** A follower missing a roster gradient re-requests it from the leader (8 steps retained), bit-exact, and the run continues.
51
- - **Cross-device kernel probe.** Every step, every device re-hashes a fixed seeded int8 GEMM through its live kernel. Catches broken arithmetic that weight hashes cannot see.
52
- - **Hardcoded FineWeb-Edu streaming.** The server reads random slices of the 10BT parquet shards straight off the HF CDN via HTTP range requests (pure-JS `hyparquet`). Built-in corpus fallback offline.
53
- - **Checkpoints.** Download `.pt`. Upload -> **broadcast to the whole group**. Validated (magic, dims, tokenizer vocab) before accepting.
54
- - **Inference kit.** One self-contained HTML file with the trained weights baked in. Generations offline, anywhere.
55
- - **In-page generation.** Prompt box on the trained model.
56
- - **Old-hardware tier.** No WebGPU? The identical units run on CPU (same bits, so CPU and GPU devices co-train in one group). There is no plain-float path.
57
- - **Large-message fragmentation.** Multi-MB gradients/checkpoints chunked at 48 KB over the data channels.
58
-
59
- ### Verified compute and kernels (web)
60
-
61
- - **Verified INT8 units everywhere.** Block-scaled int8 GEMM: exact LUT products, exact int32 accumulation, bit-exact f32 epilogue with a pinned rounding schedule. Scales derived in JS f64 (division never runs on GPU).
62
- - **Backends, best-first.** DP4A hardware int8 dot -> LUT compute shader -> CPU mirror. Every kernel is **exact-gated at init** (bit-level compares) and demoted to the mirror on any mismatch.
63
- - **Continuous random-cell audit** at live training shapes.
64
- - **Fused attention kernels.** Gather/scatter head-strided q·kᵀ and a·v straight from BT×C layout (CUTLASS ex. 36/52 style).
65
- - **QKV dual-GEMM fusion.** Shared left operand quantized once, one batch-3 dispatch (ex. 45). Bit-identical.
66
- - **B2B MLP chain.** Both MLP GEMMs back-to-back on GPU with fused per-row absmax reduction and on-device quantize (ex. 13 + 23). WGSL-exact respec with a fround-stepped JS mirror. FMA-contraction-immune by construction.
67
- - **Dispatch-optimized backward.** Overlapped independent GEMMs, batch-3 sibling fusions (ex. 05/24). Bit-identical gradients. Optional int8 STE backward path (dormant, 1.21x vs float).
68
-
69
- ### Verification stack (web)
70
-
71
- - **Exact init gates** on every kernel, every device, every boot. Including gates that "gate the gate" with discriminating boundary inputs.
72
- - **IEEE-754 binary32 oracle** in exact BigInt arithmetic. Proves the JS epilogue mirror is spec-correct (rejects the old mirror on 34% of inputs).
73
- - **Metamorphic property suite.** Reference-free relations + definitional absolutes. **4/4** on an externally-authored bug corpus, matching the differential gate.
74
- - **RDNA2 ISA audit hardenings.** Bit-level (-0-aware) gate comparisons. Proof that FMA contraction cannot change the quantize.
75
- - **Twelve-suite test chain** (`cd web && npm test`): convergence, replicas, oracle, gates, properties, external corpus, **self-corpus** (the instruments scored against my own bugs), B2B, optimizer, transformer LM, int8 backward, non-finite quantize refusal. Results in [web/TEST_RESULTS.md](web/TEST_RESULTS.md).
76
- - **Dirty-buffer gate.** The pool is poisoned before a re-sweep so state bugs (a kernel assuming zeroed memory) are caught deterministically rather than by ordering luck.
77
-
78
- ### Documentation
79
-
80
- - Python: [QUICKSTART](docs/QUICKSTART.md), [LIMITS](docs/LIMITS.md), [CUSTOM_TASK](docs/CUSTOM_TASK.md), [TAILSCALE](docs/TAILSCALE.md).
81
- - Web: [Getting started](web/docs/GETTING_STARTED.md), [Architecture](web/docs/ARCHITECTURE.md), [Verification](web/docs/VERIFICATION.md), [Troubleshooting](web/docs/TROUBLESHOOTING.md).
82
-
83
- ---
84
-
85
- ## Quick start
86
-
87
- ### Docker (most reliable - one command)
88
-
89
- ```bash
90
- docker compose -f docker/docker-compose.yml up --build
91
- # open http://localhost:8080
92
- ```
93
-
94
- Brings up a 3-node demo cluster + dashboard on one machine.
95
-
96
- ### Python (real machines)
97
-
98
- On every machine (`pip install -e .`):
99
-
100
- ```bash
101
- export MASTER_ADDR=100.101.102.10 # coordinator IP (Tailscale 100.x recommended)
102
- export MASTER_PORT=29560
103
- export WORLD_SIZE=3
104
- export RANK=0 # 1, 2, ... on the others
105
- export GLOO_SOCKET_IFNAME=tailscale0 # your mesh / LAN NIC
106
- daisychain-train
107
- ```
108
-
109
- ### Windows helper
110
-
111
- ```bat
112
- scripts\setup.bat
113
- ```
114
-
115
- An interactive menu: Docker, Python node, or just install deps.
116
-
117
- Full walkthrough: **[docs/QUICKSTART.md](docs/QUICKSTART.md)**.
118
-
119
- ### SpikeWhale control panel (sliders -> real training)
120
-
121
- ```bash
122
- python -m daisychain.spikewhale_panel
123
- # open http://localhost:8899
124
- ```
125
-
126
- A web control panel: pick model size / training settings with sliders, choose any Hugging Face dataset you have access to (default: streamed FineWeb-Edu), hit Start, and watch the live loss. Stop and re-adjust any time with **<- Back to settings**. Launches the real DaisyChain training underneath.
127
-
128
- ### DaisyChain-Web (train by opening a browser tab)
129
-
130
- ```bash
131
- cd web && npm install && node server.js
132
- # open http://localhost:8787 on every device
133
- ```
134
-
135
- Zero-install browser training. Devices on the same network auto-group (Snapdrop-style) and train a shared model **peer-to-peer over WebRTC**, computing through the same verified INT8 units (WebGPU, with the identical units on CPU for machines without it - there is no plain-float path). Private cross-network rooms via `?room=CODE` with **host approval**. The room creator accepts each device before it can join. Includes gradient averaging with a deterministic Adam optimizer (identical state on every peer, nothing extra over the wire), checkpoint **download** (`.pt`) and **upload -> broadcast** so one device can restore the whole group after a failure.
136
-
137
- **Live demo:** https://huggingface.co/spaces/Quazim0t0/DaisyChain-Web
138
-
139
- ---
140
-
141
- ## How it works
142
-
143
- Each machine runs the **same** command. They form a cluster and train one shared model. Two things happen:
144
-
145
- 1. **The compute runs through the emulated GPU logic.** By default the model is built from `VerifiedLinear` layers, so every forward multiply / requantize / ReLU is done by the **bundled verified INT8 units** (`daisychain/verified/`) - the emulated GPU math. Rank 0 prints **cluster-wide unit-invocation counts** so you can see the emulated logic doing the work.
146
- 2. **The machines are pooled data-parallel.** Each node trains on its own shard. Gradients are capacity-weighted and combined into the exact full-batch gradient, so replicas stay **bit-identical**. Faster machines automatically take a bigger share.
147
-
148
- ```
149
- old machine A -+
150
- old machine B -+- each runs the emulated GPU logic on its shard -> one model
151
- old machine C -+ (gradients combined across the cluster)
152
- ```
153
-
154
- ## Bring your own model
155
-
156
- DaisyChain-Train trains any **Task** (`build_model` / `sample` / `loss`). Copy `examples/my_task_template.py`, set `DAISY_TASK=your_module:YourTask`. Use `VerifiedLinear` (see `daisychain/verified_task.py`) to run your model's compute through the emulated units. See **[docs/CUSTOM_TASK.md](docs/CUSTOM_TASK.md)**.
157
-
158
- ## Plain-float alternative
159
-
160
- To skip the emulated units and train with normal float math on each machine, set `DAISY_TASK=daisychain.example_task:ExampleTask`. Same cluster, same pooling. The model math just runs as ordinary float instead of through the verified units.
161
-
162
- ## The dashboard
163
-
164
- `daisychain-dashboard` (or the Docker service) serves a Tailwind page at `:8080`: readiness banner, P2P connectivity scan, pooled cores/RAM + capacity plan (per-node device, weight, batch), and live training loss.
165
-
166
- ## Networking
167
-
168
- Use **Tailscale** for a P2P mesh so machines on different networks get stable IPs on one interface. **[docs/TAILSCALE.md](docs/TAILSCALE.md)**.
169
-
170
- ---
171
-
172
- ## Layout
173
-
174
- ```
175
- daisychain/cluster.py capacity-weighted CPU/GPU data-parallel trainer
176
- daisychain/train.py entry point (daisychain-train)
177
- daisychain/verified/ bundled trained N/N units + VerifiedLinear (train through them)
178
- daisychain/verified_task.py default task: forward runs on the verified units
179
- daisychain/example_task.py plain-float alternative task
180
- daisychain/task.py the Task interface + loader
181
- daisychain/dashboard/ agent + P2P scanner + Tailwind server
182
- docker/ Dockerfile, dashboard image, compose (demo cluster)
183
- scripts/setup.bat / setup.sh interactive setup helpers
184
- config/ nodes + cluster env examples
185
- examples/my_task_template.py starting point for your own model
186
- test_verified_units.py full-domain checks of every shipped verified path (25)
187
- docs/ QUICKSTART, LIMITS, CUSTOM_TASK, TAILSCALE
188
- daisychain/spikewhale_task.py trains the real SpikeWhale on streamed HF datasets
189
- daisychain/spikewhale_panel.py slider control panel (localhost:8899)
190
- web/ DaisyChain-Web: P2P browser training (WebRTC + WebGPU)
191
- export_luts_web.py regenerates web/public LUTs from the trained units
192
- ```
193
-
194
- ## Recent updates (30 September 2026) - reductions refuse what they used to accept
195
-
196
- Six fixes, each found by applying a lesson from differential-testing an RDNA2 shader translator: *a second implementation nobody checks is not verified*, *inactive or absent participants must not leak into a reduction*, *a max over NaN is undefined*, and *know your accumulator width*. Every one is covered by a check that fails on the old code. `python test_verified_units.py` + `python test_cluster_reductions.py` and `cd web && npm test` (thirteen suites) reproduce all of it.
197
-
198
- | # | where | what was wrong | now |
199
- | --- | --- | --- | --- |
200
- | 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) |
201
- | 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) |
202
- | 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` |
203
- | 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 |
204
- | 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 |
205
- | 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 |
206
-
207
- Unchanged for valid inputs: finite gradients, finite activations, and K <= 131,071 produce bit-identical results to before, so updated and older peers still co-train. A 2-rank end-to-end `DaisyCluster.fit` run stays bit-identical across replicas (`replica_diff 0.0`). The WebGPU-side changes are host-side JavaScript (checked by `node --check` and by the Node suites through the shared mirror functions). The WGSL kernels themselves are unchanged.
208
-
209
- ## Recent updates (September 2026) - requant rounding, bounded units, non-finite guards
210
-
211
- Everything below is measured. `python test_verified_units.py` (25 checks) plus `cd web && npm test` (twelve suites) reproduce it.
212
-
213
- **The requant rounds instead of truncating.** `NeuralRequant16` was `sat_int8(x >> 8)`, and Python's `>>` on negatives is an arithmetic floor, so every requantized activation carried a systematic bias. Over the full int16 domain:
214
-
215
- | requant | mean error | disagrees with the other on |
216
- | --- | --- | --- |
217
- | floor `x >> 8` (old) | **-0.4981 LSB** | 32,640 / 65,536 |
218
- | round-half-up `(x + 128) >> 8` (now) | +0.0020 LSB | |
219
-
220
- The shipped `requant16.pt` implemented the floor exactly, so this was real behaviour, not a stale docstring. It was **retrained** against the new reference and saved only at 65,536/65,536 (a 99.9% unit would silently break the LUT equivalence the browser relies on), and `web/public/requant_lut.bin` was **re-exported** to match. The invariant is *neural forward == LUT == native integer op*, so a table that lags its unit is a fork. Tie rule, stated precisely: this is half-UP. Python/numpy/torch `round` is half-to-even. The two differ on exactly the 128 exact ties (0.2% of inputs) and both are unbiased to within 0.002 LSB. The Python fleet quantizes with `np.round` and the browser with `floor(x + 0.5)`. The two fleets never co-train, so that cannot fork a group.
221
-
222
- **Bounded elementwise units.** The GEMM backends already capped their temporaries; the units right after them did not. `relu_array` / `requant_array` received the whole activation matrix in the proof path at ~1.5-2 KB per element (262,144 elements = **407 MB**), and the multiply-LUT build pushed 262,144 rows through the atom net at startup (**277 MB RSS on every node**). Both are now blocked: 407 MB -> **2 MB, flat with N**; 277 MB -> **1 MB**. Elementwise blocking is exact (no accumulation order), verified bit-identical at N = 1 / 999 / 32768 / 32769 / 100000. `dataset()` builders are vectorised (65,536 Python iterations -> one numpy pass, 0.015 s), which is what makes retraining a unit practical at all.
223
-
224
- **A NaN or Inf no longer quantizes to zeros.** A float -> int8 conversion has no answer for NaN/Inf, and both trainers gave a silent wrong one:
225
-
226
- | path | input | old result |
227
- | --- | --- | --- |
228
- | Python `qat.py` | `[0.5, nan, 3.0]` | scale = NaN -> **`[0, 0, 0]`** (only a RuntimeWarning) |
229
- | Python `qat.py` | `[0.5, inf, 3.0]` | scale = Inf -> **`[0, 0, 0]`** |
230
- | browser quantizers | `[0.5, Inf, 3]` | scale = Infinity -> **`[0, 0, 0]`** |
231
- | browser quantizers | `[0.5, NaN, 3]` | NaN skipped by the \|max\| scan, stored as **0** |
232
-
233
- One bad value zeroed the whole layer's int8 input and training carried on. Now `VerifiedLinear` raises `FloatingPointError`, and every browser quantizer (`quantize`, `quantizeRows`, `quantizeCols`, `rowAbsMax`, `quantizeHeadCols`, and the transformer's `quantizeColsAsRows`) throws `NonFiniteError`, naming how many values were bad. Finite inputs are untouched, so builds with and without the guard still co-train. Both new tests fail against the old code (Python: 2 checks; web: `test_nonfinite.js`, 12) and pass now.
234
-
235
- ## Recent updates (August 2026) - verified compute path
236
-
237
- Three changes to `daisychain/verified/`, each measured rather than asserted. `python test_verified_units.py` (24 checks) reproduces all of it.
238
-
239
- **Bounded GEMM memory.** `LUTBackend.gemm` and `NeuralBackend.gemm` materialized the whole `(m, n, k)` product block before reducing it, so peak memory was cubic in layer width. At the SpikeWhale panel's maximum (hidden 768, sequence 512) a single layer's forward allocated **2.4 GB** - on the spare hardware this project exists to use. Both now block the `m` axis. The contraction axis `k` is untouched, so the sum and its order are unchanged and results stay bit-identical:
240
-
241
- | GEMM | before | after |
242
- | --- | --- | --- |
243
- | 64x64x64 | 2.3 MB | 2.3 MB (unchanged path) |
244
- | 256x256x256 | 135.8 MB | 68.9 MB |
245
- | 512x768x768 (panel max) | 2426.9 MB | **77.2 MB** |
246
- | neural backend, 64x96x96 | 519.2 MB | **57.5 MB** |
247
-
248
- Caps are class attributes (`LUTBackend.max_block_bytes`, `NeuralBackend.max_products`), tunable per instance. Below the cap the LUT path takes the original single-block branch unchanged.
249
-
250
- The two backends need *different* accounting, which is worth knowing before tuning them: a LUT product costs 8 bytes, but a neural product is split into four nibble pairs through a 128-wide net and costs ~880 bytes. Applying the LUT's byte budget to the neural path computes a block 110x too large and never splits at all.
251
-
252
- **The multiply LUT is certified, not trusted.** `build_luts()` returned its tables unchecked while the docs described a self-certify gate. Because the table *is* the complete finite domain, certifying it **is** the exhaustive verification: `LUTBackend.__init__` now checks all 65536 entries against signed integer multiply and raises on mismatch. Cost: **0.5 ms**.
253
-
254
- **Full-domain tests for the paths that ship.** The units' exhaustive verification covers the scalar entry points (`NeuralMul8.verify()` walks `mul()`). Production runs the batched ones: `mul_array`, `relu_array`, `requant_array`, and the LUTs built from them, which are different code. `test_verified_units.py` checks every shipped path against golden integer arithmetic over its complete domain. All pass today. Nothing would have caught a regression before.
255
-
256
- **Two guards against silent no-ops.** `gemm_int8` enforces the int32 accumulator bound (`MAX_INT32_K = 131071`) instead of asserting it in a comment, since layer width is user-selectable and an overflow would produce wrong numbers rather than raise. `instrument.require(**minimums)` fails when a verified unit was under-invoked, and refuses to report at all while counting is disabled. A zero from a probe that was switched off is not evidence that the units did not run.
257
-
258
- ## Recent updates (July 2026) - DaisyChain-Web
259
-
260
- **Verification stack.** The browser trainer's correctness is now checked by things that run, not argued. Full results: [web/TEST_RESULTS.md](web/TEST_RESULTS.md).
261
-
262
- - **IEEE-754 oracle** (`web/test_ieee.js`). A binary32 oracle built from the standard in exact BigInt arithmetic proves the JS epilogue mirror is spec-correct, and rejects the old round-once mirror on 34% of inputs.
263
- - **Metamorphic properties + oracle mutation scoring** (`test_metamorphic.js`, `test_corpus.js`). Properties needing no reference implementation, scored against an externally-authored bug taxonomy. **4/4**, matching the exact differential gate's 4/4. Relations own the loop bugs. Two definitional absolutes (ReLU output range, a unit-scale integer anchor) own the value bugs no relation can see.
264
- - **Exact kernel gates on every live kernel**, a continuous audit at live shapes, and a cross-device kernel probe (same seeded int8 GEMM, same hash on every honest device, any backend). The audit's sampling was rebuilt against a named bug class. Its old constants (6 cells, 2% of GEMMs) bounded an overhead that had never been measured (auditing *every* GEMM costs <0.01% of a step), and uniform random cells cannot see a last-row/column bug at a 16512-wide output. Sampling is now **stratified**: the first cells are the structural danger points, chosen deliberately. On a last-column bug, same cell budget: uniform caught **5/300** audits, stratified **300/300**, with zero false positives.
265
- - **RDNA2 ISA audit.** Reading a real GPU's shader ISA against our determinism assumptions confirmed three of them on silicon (exact packed int8 dot; correctly-rounded f32 add/mul; 1-ULP reciprocal - division stays off the GPU) and produced two hardenings. (1) Real ISAs have non-IEEE variants that **flush -0 to +0**. JS `!==` cannot see that (`-0 !== 0` is false), so all gates and audits now compare **bit patterns** - exactly what the replica hash sees. (2) FMA contraction of the quantize's `x·inv + 0.5` (one rounding instead of two) turned out to be **floor-invisible by construction** - proven in `test_b2b.js` with 175k+ last-ulp anomalies at binade edges, zero surviving `floor()`. Rounding mode and denorm flushing are runtime driver state on real hardware, which is why every device re-runs the exact gates at every init.
266
-
267
- **Training data.** FineWeb-Edu (10BT sample) is the hardcoded dataset. The Space reads random slices of the parquet shards straight off the HF CDN with range requests (pure-JS `hyparquet`, SNAPPY) and serves plain text at `/data`. No dependency on the datasets-server rows API and its 503s.
268
-
269
- **Resilience.** The sync guard now *repairs* instead of halting. A roster gradient that reached the leader but not some follower (asymmetric WebRTC mesh) is re-requested from the leader, bit-exact, and the run continues. The guard still stops anything that would fork the weights.
270
-
271
- **CUTLASS-style kernel work**, each step proven bit-identical or exact-gated:
272
-
273
- - **Dispatch-optimized backward** (ex. 05/24): independent GEMMs overlapped, sibling trios fused into batch-3 dispatches. Bit-identical gradients. Dormant int8-backward path down from 1.63x to 1.21x vs float.
274
- - **QKV dual-GEMM fusion** (ex. 45): q/k/v share one left operand. Quantized once, one batched dispatch, zero changed bits.
275
- - **B2B MLP chain** (ex. 13 + 23): both MLP GEMMs back-to-back on the GPU with a fused per-row absmax reduction. The intermediate is quantized on-device via a WGSL-exact respec (`floor(f32(x·invScale)+0.5)` - no GPU division) whose fround-stepped JS mirror keeps mixed GPU/CPU fleets bit-identical.
276
-
277
- **Profile-driven speed work.** Every change below is bit-identical (gradient and loss hashes unchanged), so none of it trades correctness for wall clock:
278
-
279
- - **Buffer pooling.** GPU buffers are recycled by size bucket instead of being created and destroyed per dispatch (~19 per MLP call, per layer, per step). **6-10% faster**, every hash unchanged.
280
- - **Shared-operand embedding GEMMs.** Profiling put two f32 backward GEMMs at **55% of the entire step**, and both consumed the same `dlogits` operand: ~17 MB at the 16512-token vocab, uploaded *twice*. One upload, one encoder, one submit: that pair went 205 -> 90 ms and the step **12% faster**. The fusion is gated bit-for-bit against the two calls it replaces.
281
- - **A negative result, kept on purpose.** The remaining hot kernel looked cache-hostile (adjacent lanes wrote 66 KB apart), but making the writes contiguous changed nothing. Two probes explain why: holding the output at 17 MB while cutting compute 32x barely moved the time. The logits GEMM is **transfer-bound, not compute-bound**, and the readback cannot be removed because softmax must stay in JS (WGSL's `exp` is not correctly rounded, and a per-vendor `exp` would fork replicas). The real lever there is the vocabulary, not the kernel.
282
- - **An init backend race was tried and removed.** It tied on the shipped path, cost ~430 ms of init, and made the backend vary between page loads, which silently invalidated three A/B comparisons before it was caught. A knob that changes what you are measuring is worse than a fixed choice.
283
-
284
- **Dirty-buffer gate - and the assumption it falsified.** Pooling introduced a bug class the gates predate: a pooled buffer is *not* zero-initialized, so a kernel that assumes zeros is right on step one and wrong on step two. That is a **state** bug, where no single call is wrong and the *sequence* is, which is the family no oracle can reach. The assumption was that the gates were blind to it. Mutation-testing the gate proved otherwise: deleting the zeroing made the plain gate fail at its *second* shape, because the sweep's own shapes recycle each other's buffers. The suite had **incidental** coverage nobody designed, which is coverage nobody can rely on. Shorten the shape list and it evaporates with the gate still green. It is now deliberate: the pool is poisoned with 1e4-magnitude residue before a re-sweep, so detection no longer depends on ordering luck. ~90 ms one-time.
285
-
286
- **Scoring the oracles against my OWN bugs** (`web/test_selfcorpus.js`). The external corpus measures kernel bugs someone else wrote down, so this suite asks the harder question: what do the instruments score against the four real bugs of the month? Properties **0/2** on the data-plane pair (the `c·out` theorem again), differential **2/2** - but half the bugs were not in the kernels at all. A dead gate is a bug in a *checker*, caught only by mutating the gate. A stalled roster gradient is a bug in the *protocol*, where every computed value on every peer was correct, so no data oracle could fire. Those needed different instruments, not better oracles.
287
-
288
- All thirteen test suites (`cd web && npm test`) pass. Results with methodology in [`web/TEST_RESULTS.md`](web/TEST_RESULTS.md).
289
-
290
- ---
291
-
292
- ## Install
293
-
294
- ```bash
295
- pip install torch numpy psutil
296
- pip install -e . # exposes: daisychain-train, daisychain-agent, daisychain-dashboard
297
- ```
298
-
299
- Requires Python >= 3.9, PyTorch >= 2.0. Multi-node is reliable on **Linux/macOS**. On **Windows use Docker/WSL** (see [Limitations](docs/LIMITS.md)).
300
-
301
- ---
302
-
303
- ## Links
304
-
305
- - **DaisyChain on Hugging Face:** https://huggingface.co/DaisyChainAI
306
- - **This model:** https://huggingface.co/DaisyChainAI/DaisyChain-Train
307
-
308
- **License:** MIT · **Author:** Dean Byrne (Quazim0t0) · **Org:** DaisyChainAI
309
-
310
- ## Citation
311
-
312
- ```bibtex
313
- @misc{byrne2026daisychain,
314
- title = {DaisyChain-Train: An Old Hardware Training Pipeline},
315
- author = {Byrne, Dean (Quazim0t0)},
316
- year = {2026},
317
- howpublished = {\url{https://huggingface.co/DaisyChainAI/DaisyChain-Train}},
318
- note = {Chain spare/old machines into a data-parallel training cluster}
319
- }
320
- ```
321
-
322
- **Dean Byrne (Quazim0t0)** · 2026
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) -> float:
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 float(os.cpu_count() or 1)
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 = [float(g.get("capacity") or g["cores"]) for g in gathered]
 
 
 
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(model.parameters(), lr=lr)
214
  else:
215
- opt = torch.optim.SGD(model.parameters(), lr=lr)
 
 
 
 
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
- opt.step()
 
 
 
 
 
 
 
 
 
 
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
- for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
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
- const badK = roster.find(id => id !== myId && hs.has(id) && hs.get(id).phash &&
902
- probeHash && hs.get(id).phash !== probeHash);
 
 
 
 
 
 
 
 
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
- const adapter = await navigator.gpu.requestAdapter();
 
 
 
 
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 d0 of [{ B: 2, T: 8, heads: 2, hd: 8 }, { B: 1, T: 32, heads: 2, hd: 16 },
335
- { B: 3, T: 7, heads: 3, hd: 5 }, { B: 2, T: 33, heads: 4, hd: 8 }]) {
 
 
 
 
 
 
 
 
 
 
 
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
+ })();