Quazim0t0 commited on
Commit
6e87247
·
verified ·
1 Parent(s): 28383f7

Reductions refuse what they used to accept: GPU row-max non-finite guard, averageGrads length/NaN refusal, int32 accumulator bound, capacity-weighted cluster loss, finite reduced-gradient check, empty CUDA_VISIBLE_DEVICES

Browse files
README.md CHANGED
@@ -303,7 +303,7 @@ 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 twelve test suites (`cd web && npm test`) pass; results with methodology in
307
  [`web/TEST_RESULTS.md`](web/TEST_RESULTS.md).
308
 
309
  ---
@@ -372,6 +372,30 @@ web/ DaisyChain-Web: P2P browser training (WebRTC + WebG
372
  export_luts_web.py regenerates web/public LUTs from the trained units
373
  ```
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  ## Recent updates (September 2026) — requant rounding, bounded units, non-finite guards
376
 
377
  Everything below is measured, and `python test_verified_units.py` (25 checks) plus
 
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
  ---
 
372
  export_luts_web.py regenerates web/public LUTs from the trained units
373
  ```
374
 
375
+ ## Recent updates (30 September 2026) — reductions refuse what they used to accept
376
+
377
+ Six fixes, each found by applying a lesson from differential-testing an RDNA2 shader
378
+ translator: *a second implementation nobody checks is not verified*, *inactive or absent
379
+ participants must not leak into a reduction*, *a max over NaN is undefined*, and *know your
380
+ accumulator width*. Every one is covered by a check that fails on the old code.
381
+ `python test_verified_units.py` + `python test_cluster_reductions.py` and
382
+ `cd web && npm test` (thirteen suites) reproduce all of it.
383
+
384
+ | # | where | what was wrong | now |
385
+ | --- | --- | --- | --- |
386
+ | 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) |
387
+ | 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) |
388
+ | 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` |
389
+ | 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 |
390
+ | 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 |
391
+ | 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 |
392
+
393
+ Unchanged for valid inputs: finite gradients, finite activations, and K ≤ 131,071 produce
394
+ bit-identical results to before, so updated and older peers still co-train. A 2-rank
395
+ end-to-end `DaisyCluster.fit` run stays bit-identical across replicas (`replica_diff 0.0`).
396
+ The WebGPU-side changes are host-side JavaScript (checked by `node --check` and by the
397
+ Node suites through the shared mirror functions); the WGSL kernels themselves are unchanged.
398
+
399
  ## Recent updates (September 2026) — requant rounding, bounded units, non-finite guards
400
 
401
  Everything below is measured, and `python test_verified_units.py` (25 checks) plus
daisychain/cluster.py CHANGED
@@ -31,14 +31,21 @@ def configure_cpu(fraction: float = 0.9) -> int:
31
  return n
32
 
33
 
 
 
 
 
 
 
 
34
  def pick_device() -> "torch.device":
35
  if os.environ.get("DAISY_FORCE_CPU") == "1":
36
  return torch.device("cpu")
37
- return torch.device("cuda" if torch.cuda.is_available() else "cpu")
38
 
39
 
40
  def _gpu_info():
41
- if not torch.cuda.is_available():
42
  return None
43
  p = torch.cuda.get_device_properties(0)
44
  return {"name": p.name, "vram_gb": round(p.total_memory / 1e9, 1),
@@ -130,6 +137,35 @@ def broadcast_params(model, src: int = 0):
130
  p.data.copy_(cpu.to(p.data.device))
131
 
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  @torch.no_grad()
134
  def capacity_weighted_allreduce_grads(model, weight: float):
135
  """Σ_i w_i g_i with w_i = n_i/Σn_j == the true full-batch mean gradient.
@@ -185,14 +221,13 @@ class DaisyCluster:
185
  loss = task.loss(model, X, y)
186
  loss.backward()
187
  capacity_weighted_allreduce_grads(model, w)
 
188
  opt.step()
189
  if step_delay:
190
  time.sleep(step_delay)
191
  if s % max(1, steps // 20) == 0 or s == steps - 1:
192
- lt = loss.detach().to("cpu").clone()
193
- dist.all_reduce(lt, op=dist.ReduceOp.SUM)
194
  if self.is_master():
195
- cl = lt.item() / self.world
196
  print(f" step {s:5d} cluster-avg loss {cl:.6f}", flush=True)
197
  if status_path:
198
  self._write_status(status_path, step=s, total_steps=steps,
 
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
37
+ at startup in get_device_properties(0). Require a device to actually exist (2026-09-30)."""
38
+ return torch.cuda.is_available() and torch.cuda.device_count() > 0
39
+
40
+
41
  def pick_device() -> "torch.device":
42
  if os.environ.get("DAISY_FORCE_CPU") == "1":
43
  return torch.device("cpu")
44
+ return torch.device("cuda" if _cuda_usable() else "cpu")
45
 
46
 
47
  def _gpu_info():
48
+ if not _cuda_usable():
49
  return None
50
  p = torch.cuda.get_device_properties(0)
51
  return {"name": p.name, "vram_gb": round(p.total_memory / 1e9, 1),
 
137
  p.data.copy_(cpu.to(p.data.device))
138
 
139
 
140
+ @torch.no_grad()
141
+ def check_finite_grads(model, step: int) -> None:
142
+ """Refuse a non-finite REDUCED gradient before the optimizer applies it.
143
+
144
+ After the all-reduce every rank holds the identical summed gradient, so every rank reaches
145
+ the same verdict with no extra communication and they all stop at the same step. Before
146
+ 2026-09-30 one node's NaN was summed into every replica and opt.step() wrote it into the
147
+ weights (and Adam's moments) everywhere -- the replicas still agreed, so replica_diff
148
+ could not see it."""
149
+ for name, p in model.named_parameters():
150
+ if p.grad is not None and not bool(torch.isfinite(p.grad).all()):
151
+ bad = int((~torch.isfinite(p.grad)).sum())
152
+ raise FloatingPointError(
153
+ "step %d: reduced gradient of %s has %d non-finite value(s); refusing to apply "
154
+ "it (weights and optimizer state unchanged)" % (step, name, bad))
155
+
156
+
157
+ @torch.no_grad()
158
+ def cluster_mean_loss(loss, weight: float) -> float:
159
+ """The full-batch mean loss: sum_i w_i * loss_i with the SAME capacity weights
160
+ (w_i = n_i / sum n) the gradient all-reduce uses. A collective -- call it on every rank.
161
+
162
+ It used to be sum(loss_i) / world: an equal-weight mean of UNEQUAL batches, so the logged
163
+ cluster loss leaned toward the small (slow) nodes whenever capacities differed (2026-09-30)."""
164
+ lt = loss.detach().to("cpu").clone().double() * weight
165
+ dist.all_reduce(lt, op=dist.ReduceOp.SUM)
166
+ return lt.item()
167
+
168
+
169
  @torch.no_grad()
170
  def capacity_weighted_allreduce_grads(model, weight: float):
171
  """Σ_i w_i g_i with w_i = n_i/Σn_j == the true full-batch mean gradient.
 
221
  loss = task.loss(model, X, y)
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:
229
+ cl = cluster_mean_loss(loss, w) # collective: every rank calls it
 
230
  if self.is_master():
 
231
  print(f" step {s:5d} cluster-avg loss {cl:.6f}", flush=True)
232
  if status_path:
233
  self._write_status(status_path, step=s, total_steps=steps,
test_cluster_reductions.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cluster reductions (2026-09-30). Run: python test_cluster_reductions.py (CPU only, ~10 s).
2
+
3
+ 1. cluster_mean_loss is the FULL-BATCH mean loss: sum_i w_i * loss_i with the capacity weights
4
+ the gradient all-reduce uses. It was sum(loss_i) / world -- checked on a real 2-rank gloo
5
+ group with unequal batches (3 and 13 samples), against the loss of the concatenated batch.
6
+ 2. check_finite_grads refuses a non-finite reduced gradient BEFORE opt.step(), and leaves the
7
+ weights untouched; a finite gradient passes.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ import tempfile
14
+
15
+ import torch
16
+ import torch.distributed as dist
17
+ import torch.multiprocessing as mp
18
+
19
+ from daisychain.cluster import (capacity_weighted_allreduce_grads, check_finite_grads,
20
+ cluster_mean_loss)
21
+
22
+ FAILURES = []
23
+
24
+
25
+ def ck(name, cond, detail=""):
26
+ print(" %s %s%s" % ("ok " if cond else "FAIL", name, (" " + str(detail)) if detail else ""))
27
+ if not cond:
28
+ FAILURES.append(name)
29
+
30
+
31
+ SIZES = (3, 13)
32
+
33
+
34
+ def data(rank):
35
+ g = torch.Generator().manual_seed(100 + rank)
36
+ X = torch.randn(SIZES[rank], 4, generator=g)
37
+ y = torch.randn(SIZES[rank], 1, generator=g)
38
+ return X, y
39
+
40
+
41
+ def model0():
42
+ torch.manual_seed(7)
43
+ return torch.nn.Linear(4, 1)
44
+
45
+
46
+ def worker(rank, world, init_file, out_file):
47
+ dist.init_process_group("gloo", init_method="file://" + init_file, rank=rank, world_size=world)
48
+ w = SIZES[rank] / sum(SIZES)
49
+ m = model0()
50
+ X, y = data(rank)
51
+ loss = torch.nn.functional.mse_loss(m(X), y)
52
+ loss.backward()
53
+ cl = cluster_mean_loss(loss, w)
54
+ old = loss.detach().clone()
55
+ dist.all_reduce(old, op=dist.ReduceOp.SUM) # the previous formula
56
+ capacity_weighted_allreduce_grads(m, w)
57
+ if rank == 0:
58
+ torch.save({"cl": cl, "old": old.item() / world,
59
+ "grad": [p.grad.clone() for p in m.parameters()]}, out_file)
60
+ dist.barrier()
61
+ dist.destroy_process_group()
62
+
63
+
64
+ def main():
65
+ print("cluster_mean_loss -- 2 ranks, batches of %d and %d samples" % SIZES)
66
+ with tempfile.TemporaryDirectory() as td:
67
+ init_file, out_file = os.path.join(td, "init"), os.path.join(td, "out.pt")
68
+ mp.spawn(worker, args=(2, init_file, out_file), nprocs=2, join=True)
69
+ got = torch.load(out_file)
70
+ m = model0()
71
+ Xa = torch.cat([data(0)[0], data(1)[0]])
72
+ ya = torch.cat([data(0)[1], data(1)[1]])
73
+ full = torch.nn.functional.mse_loss(m(Xa), ya)
74
+ full.backward()
75
+ ck("equals the loss of the concatenated batch (the full-batch mean)",
76
+ abs(got["cl"] - full.item()) < 1e-6, (got["cl"], full.item()))
77
+ ck("the old sum/world formula did NOT (it is the equal-weight mean of unequal batches)",
78
+ abs(got["old"] - full.item()) > 1e-4, (got["old"], full.item()))
79
+ ck("and the gradient all-reduce agrees with the same weighting (consistency check)",
80
+ all(torch.allclose(g, p.grad, atol=1e-6) for g, p in zip(got["grad"], m.parameters())))
81
+
82
+ print("check_finite_grads -- refuse a non-finite reduced gradient before opt.step()")
83
+ lin = model0()
84
+ before = [p.detach().clone() for p in lin.parameters()]
85
+ lin(torch.ones(2, 4)).sum().backward()
86
+ try:
87
+ check_finite_grads(lin, 0)
88
+ ck("a finite gradient passes", True)
89
+ except FloatingPointError as e:
90
+ ck("a finite gradient passes", False, e)
91
+ for bad in (float("nan"), float("inf")):
92
+ lin.weight.grad[0, 1] = bad
93
+ try:
94
+ check_finite_grads(lin, 5)
95
+ ck("%r in a reduced gradient raises" % bad, False, "applied silently")
96
+ except FloatingPointError as e:
97
+ ck("%r in a reduced gradient raises, naming the step and parameter" % bad,
98
+ "step 5" in str(e) and "weight" in str(e), str(e)[:80])
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))
105
+ return 1
106
+ print("all checks passed")
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())
web/TEST_RESULTS.md CHANGED
@@ -1,6 +1,6 @@
1
  # Test results — updated 2026-07-17
2
 
3
- All suites run with `npm test` (chains all twelve). Every suite exits 0.
4
  Hardware for GPU numbers: NVIDIA via WebGPU, DP4A int8 dot path, exact-gated
5
  against the verified units at init.
6
 
@@ -16,7 +16,9 @@ against the verified units at init.
16
  | `test_optimizer.js` | DaisyAdam beats SGD through the units, deterministic replicas | PASS — 1.59 vs 1.95, replica diff 0.000e+0 |
17
  | `test_transformer.js` | the transformer LM trains through the units end to end | PASS — loss 4.75 → 1.26 (baseline 4.56), replica diff 0.000e+0 |
18
  | `test_unit_backward.js` | int8 STE gradients do not damage convergence | PASS — units/float loss ratio 1.007 |
 
19
  | `test_nonfinite.js` | every quantizer refuses NaN/Inf instead of zeroing the layer | PASS — 13/13; the old quantizers fail 12 (added 2026-09-27) |
 
20
 
21
  ## The IEEE-754 oracle (`test_ieee.js`)
22
 
 
1
  # Test results — updated 2026-07-17
2
 
3
+ All suites run with `npm test` (chains all thirteen). Every suite exits 0.
4
  Hardware for GPU numbers: NVIDIA via WebGPU, DP4A int8 dot path, exact-gated
5
  against the verified units at init.
6
 
 
16
  | `test_optimizer.js` | DaisyAdam beats SGD through the units, deterministic replicas | PASS — 1.59 vs 1.95, replica diff 0.000e+0 |
17
  | `test_transformer.js` | the transformer LM trains through the units end to end | PASS — loss 4.75 → 1.26 (baseline 4.56), replica diff 0.000e+0 |
18
  | `test_unit_backward.js` | int8 STE gradients do not damage convergence | PASS — units/float loss ratio 1.007 |
19
+ | `test_b2b.js` | the chained GPU MLP's CPU mirror: gemm1 byte-identical to the un-chained GEMM, deterministic, +0.5 quantize FMA-contraction-immune | PASS — chained transformer converges 4.7384 → 2.0512 |
20
  | `test_nonfinite.js` | every quantizer refuses NaN/Inf instead of zeroing the layer | PASS — 13/13; the old quantizers fail 12 (added 2026-09-27) |
21
+ | `test_reductions.js` | the GPU row-max guard is complete, `averageGrads` refuses wrong-length / non-finite gradients, int32 accumulators refuse K > 131,071 | PASS — 17/17; the old code fails 11 (added 2026-09-30) |
22
 
23
  ## The IEEE-754 oracle (`test_ieee.js`)
24
 
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"
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"
10
  },
11
  "dependencies": {
12
  "hyparquet": "^1.26.2",
web/public/app.js CHANGED
@@ -914,7 +914,15 @@ async function train(cfg, resume) {
914
  else if (hs && hs.has(id)) { lossSum += hs.get(id).loss; lossN++; contrib.set(id, (contrib.get(id) || 0) + 1); }
915
  }
916
  const clusterLoss = lossSum / Math.max(1, lossN);
917
- const avg = TrainCore.averageGrads(all);
 
 
 
 
 
 
 
 
918
  const upd = opt.step(avg); // DaisyAdam on the cluster-avg grad
919
  Transformer.applyUpdate(model, upd); // W -= upd (lr folded into upd)
920
  // retain a few applied steps so stalled peers can repair (evict beyond that)
 
914
  else if (hs && hs.has(id)) { lossSum += hs.get(id).loss; lossN++; contrib.set(id, (contrib.get(id) || 0) + 1); }
915
  }
916
  const clusterLoss = lossSum / Math.max(1, lossN);
917
+ let avg;
918
+ try { avg = TrainCore.averageGrads(all); }
919
+ catch (e) {
920
+ // every device averages the same roster, so every device stops HERE, at the same step,
921
+ // with weights and Adam state untouched (still a valid checkpoint to resume from)
922
+ if (e.name !== "GradientError") throw e;
923
+ halted = `step ${s + 1}: ${e.message} -- refusing to apply it (weights and Adam state unchanged)`;
924
+ break;
925
+ }
926
  const upd = opt.step(avg); // DaisyAdam on the cluster-avg grad
927
  Transformer.applyUpdate(model, upd); // W -= upd (lr folded into upd)
928
  // retain a few applied steps so stalled peers can repair (evict beyond that)
web/public/traincore.js CHANGED
@@ -54,13 +54,29 @@
54
  // ORDER MATTERS: float addition is not associative, so every replica MUST
55
  // pass the gradients in the same order (the leader's roster order) or their
56
  // averages differ in the last bits and the weights fork. Never self-first.
 
 
 
 
 
 
57
  function averageGrads(grads) {
58
- const out = new Float32Array(grads[0].length);
 
 
 
 
 
 
 
 
59
  for (const g of grads) for (let i = 0; i < g.length; i++) out[i] += g[i];
60
  for (let i = 0; i < out.length; i++) out[i] /= grads.length;
61
  return out;
62
  }
63
 
 
 
64
  // DaisyAdam — Adam with bias correction, applied to the cluster-averaged
65
  // gradient. State is a pure function of the gradient sequence, so every peer
66
  // that averages the same gradients keeps bit-identical moments: no optimizer
 
54
  // ORDER MATTERS: float addition is not associative, so every replica MUST
55
  // pass the gradients in the same order (the leader's roster order) or their
56
  // averages differ in the last bits and the weights fork. Never self-first.
57
+ //
58
+ // REFUSES (throws GradientError) rather than averaging when (a) the gradients differ in
59
+ // length -- typed arrays silently ignore out-of-range writes, so a truncated gradient used
60
+ // to be averaged over a prefix with no error -- or (b) any value is non-finite: one NaN
61
+ // would enter DaisyAdam's m/v on EVERY peer (and every later checkpoint) identically, so no
62
+ // fork check could see it. Refusing BEFORE the optimizer keeps weights and moments clean.
63
  function averageGrads(grads) {
64
+ const len = grads[0].length;
65
+ for (let r = 0; r < grads.length; r++) {
66
+ const g = grads[r];
67
+ if (!g || g.length !== len) throw gradError("gradient " + r + " has " +
68
+ (g ? g.length : "no") + " values, gradient 0 has " + len);
69
+ for (let i = 0; i < len; i++) if (!Number.isFinite(g[i]))
70
+ throw gradError("gradient " + r + " is non-finite at index " + i + " (" + g[i] + ")");
71
+ }
72
+ const out = new Float32Array(len);
73
  for (const g of grads) for (let i = 0; i < g.length; i++) out[i] += g[i];
74
  for (let i = 0; i < out.length; i++) out[i] /= grads.length;
75
  return out;
76
  }
77
 
78
+ function gradError(msg) { const e = new Error(msg); e.name = "GradientError"; return e; }
79
+
80
  // DaisyAdam — Adam with bias correction, applied to the cluster-averaged
81
  // gradient. State is a pure function of the gradient sequence, so every peer
82
  // that averages the same gradients keeps bit-identical moments: no optimizer
web/public/verified_core.js CHANGED
@@ -35,8 +35,22 @@
35
  return { q, scale };
36
  }
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  // int8 matmul via the verified multiply LUT: acc(m×n) = sum_k mulLUT[Xq,Wq]
39
  function lutMatmulJS(Xq, Wq, m, k, n, L) {
 
40
  const C = new Int32Array(m * n), mul = L.mul;
41
  for (let i = 0; i < m; i++) {
42
  for (let p = 0; p < k; p++) {
@@ -151,6 +165,12 @@
151
  return mx;
152
  }
153
  function scalesFromAbsMax(mx) { // f64 divisions: exactly rounded, device-identical
 
 
 
 
 
 
154
  const scale = new Float32Array(mx.length), inv = new Float32Array(mx.length);
155
  for (let i = 0; i < mx.length; i++) {
156
  scale[i] = Math.max(mx[i] / 127, 1e-8);
@@ -221,6 +241,7 @@
221
  function bgemmJS(Xq, Wq, rs, cs, d, L) {
222
  const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
223
  const raw = !!d.acc;
 
224
  const out = raw ? new Int32Array(batch * m * n) : new Float32Array(batch * m * n);
225
  const acc = new Int32Array(n);
226
  for (let bz = 0; bz < batch; bz++) {
@@ -510,7 +531,7 @@
510
  for (let j = 0; j < W2.length; j++) W2[j] -= lr * gAvg[W1.length + j];
511
  }
512
 
513
- const api = { assertFinite, quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
514
  bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply,
515
  rowAbsMax, scalesFromAbsMax, quantizeRowsInv, vmlpBlock, bitDiff,
516
  auditAttScores, auditAttCtx, fgemmMirror };
 
35
  return { q, scale };
36
  }
37
 
38
+ // int32 accumulator headroom: |a*b| <= 128*128, so K*16384 must fit in int32. Same bound as
39
+ // the Python kernel (kernel.MAX_INT32_K). The Int32Array accumulators here WRAP exactly like
40
+ // WGSL i32, so CPU and GPU peers would agree -- on a silently wrong sum. Refuse instead.
41
+ const MAX_INT32_K = Math.floor((2 ** 31 - 1) / (128 * 128)); // 131071
42
+ function accBound(k, where) {
43
+ if (k > MAX_INT32_K) {
44
+ const e = new Error(where + ": contraction length " + k + " exceeds the int32 accumulator " +
45
+ "headroom " + MAX_INT32_K + " (the sum would wrap)");
46
+ e.name = "AccumulatorBoundError";
47
+ throw e;
48
+ }
49
+ }
50
+
51
  // int8 matmul via the verified multiply LUT: acc(m×n) = sum_k mulLUT[Xq,Wq]
52
  function lutMatmulJS(Xq, Wq, m, k, n, L) {
53
+ accBound(k, "lutMatmulJS");
54
  const C = new Int32Array(m * n), mul = L.mul;
55
  for (let i = 0; i < m; i++) {
56
  for (let p = 0; p < k; p++) {
 
165
  return mx;
166
  }
167
  function scalesFromAbsMax(mx) { // f64 divisions: exactly rounded, device-identical
168
+ // The GPU chain gets mx from an atomicMax over bitcast(abs(h)) and never passes through
169
+ // rowAbsMax, so this is where ITS non-finite guard lives: NaN bits (0x7FC00000) and Inf
170
+ // (0x7F800000) both order above every finite |h| as u32, so the row max is non-finite iff
171
+ // some element is. Before 2026-09-30 a GPU peer turned that into inv = 0/NaN and quantized
172
+ // floor(NaN) on the device (implementation-defined) where a CPU peer threw.
173
+ assertFinite(mx, "scalesFromAbsMax");
174
  const scale = new Float32Array(mx.length), inv = new Float32Array(mx.length);
175
  for (let i = 0; i < mx.length; i++) {
176
  scale[i] = Math.max(mx[i] / 127, 1e-8);
 
241
  function bgemmJS(Xq, Wq, rs, cs, d, L) {
242
  const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
243
  const raw = !!d.acc;
244
+ accBound(k, "bgemmJS");
245
  const out = raw ? new Int32Array(batch * m * n) : new Float32Array(batch * m * n);
246
  const acc = new Int32Array(n);
247
  for (let bz = 0; bz < batch; bz++) {
 
531
  for (let j = 0; j < W2.length; j++) W2[j] -= lr * gAvg[W1.length + j];
532
  }
533
 
534
+ const api = { assertFinite, accBound, MAX_INT32_K, quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
535
  bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply,
536
  rowAbsMax, scalesFromAbsMax, quantizeRowsInv, vmlpBlock, bitDiff,
537
  auditAttScores, auditAttCtx, fgemmMirror };
web/public/webgpu.js CHANGED
@@ -611,6 +611,7 @@
611
  }
612
 
613
  async function gpuMatmulLUT(device, pipeline, lutBuf, Xq, Wq, m, k, n) {
 
614
  const X32 = Int32Array.from(Xq), W32 = Int32Array.from(Wq); // byte -> i32
615
  const bufX = up(device, X32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
616
  const bufW = up(device, W32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
@@ -626,6 +627,7 @@
626
  // attention kernels: int8 (widened i32) in, f32 out, strided head indexing
627
  async function gpuAttScores(device, pipeline, qq, kq, qs, ks, d) {
628
  const { B, T, heads } = d;
 
629
  const bufQ = up(device, Int32Array.from(qq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
630
  const bufK = up(device, Int32Array.from(kq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
631
  const bufQs = up(device, qs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
@@ -640,6 +642,7 @@
640
  }
641
  async function gpuAttCtx(device, pipeline, aq, vq, as, vs, d) {
642
  const { B, T, heads, hd } = d;
 
643
  const bufA = up(device, Int32Array.from(aq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
644
  const bufV = up(device, Int32Array.from(vq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
645
  const bufAs = up(device, as, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
@@ -759,6 +762,7 @@
759
 
760
  async function gpuBgemmLUT(device, pipeline, lutBuf, Xq, Wq, rs, cs, d) {
761
  const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
 
762
  const bufX = up(device, Int32Array.from(Xq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
763
  const bufW = up(device, Int32Array.from(Wq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
764
  const bufR = up(device, rs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
@@ -775,6 +779,7 @@
775
 
776
  async function gpuBgemmDP4(device, pipeline, Xq, Wq, rs, cs, d) {
777
  const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
 
778
  const kw = Math.ceil(k / 4);
779
  const Xp = packRows(Xq, batch * m, k, kw); // rows are (batch·m)
780
  const Wp = new Uint32Array(batch * n * kw); // per-batch Wᵀ, packed
@@ -801,6 +806,7 @@
801
  // left operand is produced and consumed entirely on the GPU.
802
  async function gpuMlpChain(device, env, xq, w1q, w2q, xs, w1s, w2s, d) {
803
  const { m, k, h, n } = d, dp4 = !!env.dp4;
 
804
  const SU = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
805
  let bufX, bufW1, bufW2, kw1, hw;
806
  if (dp4) {
 
611
  }
612
 
613
  async function gpuMatmulLUT(device, pipeline, lutBuf, Xq, Wq, m, k, n) {
614
+ root.Verified.accBound(k, "gpuMatmulLUT"); // WGSL `var s : i32` wraps past this
615
  const X32 = Int32Array.from(Xq), W32 = Int32Array.from(Wq); // byte -> i32
616
  const bufX = up(device, X32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
617
  const bufW = up(device, W32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
 
627
  // attention kernels: int8 (widened i32) in, f32 out, strided head indexing
628
  async function gpuAttScores(device, pipeline, qq, kq, qs, ks, d) {
629
  const { B, T, heads } = d;
630
+ root.Verified.accBound(d.hd, "gpuAttScores");
631
  const bufQ = up(device, Int32Array.from(qq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
632
  const bufK = up(device, Int32Array.from(kq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
633
  const bufQs = up(device, qs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
 
642
  }
643
  async function gpuAttCtx(device, pipeline, aq, vq, as, vs, d) {
644
  const { B, T, heads, hd } = d;
645
+ root.Verified.accBound(T, "gpuAttCtx");
646
  const bufA = up(device, Int32Array.from(aq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
647
  const bufV = up(device, Int32Array.from(vq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
648
  const bufAs = up(device, as, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
 
762
 
763
  async function gpuBgemmLUT(device, pipeline, lutBuf, Xq, Wq, rs, cs, d) {
764
  const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
765
+ root.Verified.accBound(k, "gpuBgemmLUT");
766
  const bufX = up(device, Int32Array.from(Xq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
767
  const bufW = up(device, Int32Array.from(Wq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
768
  const bufR = up(device, rs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
 
779
 
780
  async function gpuBgemmDP4(device, pipeline, Xq, Wq, rs, cs, d) {
781
  const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
782
+ root.Verified.accBound(k, "gpuBgemmDP4");
783
  const kw = Math.ceil(k / 4);
784
  const Xp = packRows(Xq, batch * m, k, kw); // rows are (batch·m)
785
  const Wp = new Uint32Array(batch * n * kw); // per-batch Wᵀ, packed
 
806
  // left operand is produced and consumed entirely on the GPU.
807
  async function gpuMlpChain(device, env, xq, w1q, w2q, xs, w1s, w2s, d) {
808
  const { m, k, h, n } = d, dp4 = !!env.dp4;
809
+ root.Verified.accBound(k, "gpuMlpChain gemm1"); root.Verified.accBound(h, "gpuMlpChain gemm2");
810
  const SU = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
811
  let bufX, bufW1, bufW2, kw1, hw;
812
  if (dp4) {
web/test_reductions.js ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Reductions and accumulators that used to accept bad input silently (2026-09-30). Each check
2
+ // fails on the previous code:
3
+ // 1. the GPU MLP chain's row max comes from an atomicMax over bitcast(abs(h)) and never passed a
4
+ // non-finite guard -- scalesFromAbsMax now refuses, and the bit-order argument that makes
5
+ // that ONE check complete is checked here too;
6
+ // 2. averageGrads averaged a truncated gradient over a prefix, and fed NaN/Inf into DaisyAdam
7
+ // on every peer at once -- it now refuses both;
8
+ // 3. the Int32Array / WGSL i32 accumulators WRAP past K = 131071 (CPU and GPU agree on the wrong
9
+ // sum) -- the GEMMs now refuse, the same bound as the Python kernel's MAX_INT32_K.
10
+ "use strict";
11
+ const V = require("./public/verified_core.js");
12
+ const TC = require("./public/traincore.js");
13
+
14
+ let fails = 0;
15
+ function ck(name, cond) { console.log((cond ? " ok " : " FAIL ") + name); if (!cond) fails++; }
16
+ function throwsName(fn, name) { try { fn(); return false; } catch (e) { return e.name === name; } }
17
+
18
+ // ---- 1. the GPU row-max readback ------------------------------------------------------------
19
+ for (const bad of [NaN, Infinity, -Infinity]) {
20
+ ck("scalesFromAbsMax refuses a row max of " + bad,
21
+ throwsName(() => V.scalesFromAbsMax(Float32Array.from([1.5, bad, 0.25])), "NonFiniteError"));
22
+ }
23
+ const fin = V.scalesFromAbsMax(Float32Array.from([127, 0, 1e-12]));
24
+ ck("finite row maxes give the same scales as before (1, 1e-8 floor)",
25
+ fin.scale[0] === 1 && Math.abs(fin.scale[1] - 1e-8) < 1e-15 && fin.inv[0] === 1);
26
+
27
+ // What the WGSL kernel computes: atomicMax over the u32 bits of |h|. Emulated exactly with
28
+ // typed-array views. The guard above is complete iff "max bits non-finite <=> some h non-finite".
29
+ function gpuRowMax(h) {
30
+ const f = new Float32Array(1), u = new Uint32Array(f.buffer);
31
+ let mx = 0;
32
+ for (const v of h) { f[0] = Math.abs(v); if (u[0] > mx) mx = u[0]; }
33
+ u[0] = mx; return f[0];
34
+ }
35
+ let rng = 12345;
36
+ const rnd = () => ((rng = (rng * 1103515245 + 12345) >>> 0) / 4294967296);
37
+ let complete = true, exact = true;
38
+ for (let trial = 0; trial < 2000; trial++) {
39
+ const h = new Float32Array(16).map(() => (rnd() - 0.5) * Math.pow(2, Math.floor(rnd() * 200) - 100));
40
+ const want = h.reduce((a, v) => Math.max(a, Math.abs(v)), 0);
41
+ if (gpuRowMax(h) !== Math.fround(want)) exact = false;
42
+ if (trial % 2) h[Math.floor(rnd() * 16)] = [NaN, Infinity, -Infinity, -NaN][trial % 4];
43
+ const nonfinite = h.some(v => !Number.isFinite(v));
44
+ if (Number.isFinite(gpuRowMax(h)) === nonfinite) complete = false;
45
+ }
46
+ ck("bit-order row max equals the true |max| on finite rows (2000 rows, wide exponents)", exact);
47
+ ck("bit-order row max is non-finite exactly when some element is (NaN/Inf/-Inf injected)", complete);
48
+
49
+ // ---- 2. averageGrads ------------------------------------------------------------------------
50
+ const g1 = Float32Array.from([1, 2, 3, 4]), g2 = Float32Array.from([3, 2, 1, 0]);
51
+ const avg = TC.averageGrads([g1, g2]);
52
+ ck("equal-length finite gradients average as before", Array.from(avg).join() === "2,2,2,2");
53
+ ck("a truncated gradient is refused (was averaged over a prefix)",
54
+ throwsName(() => TC.averageGrads([g1, Float32Array.from([1, 2])]), "GradientError"));
55
+ ck("a longer gradient is refused (its tail was silently dropped)",
56
+ throwsName(() => TC.averageGrads([g1, Float32Array.from([1, 2, 3, 4, 5])]), "GradientError"));
57
+ for (const bad of [NaN, Infinity]) {
58
+ ck("a gradient containing " + bad + " is refused before DaisyAdam sees it",
59
+ throwsName(() => TC.averageGrads([g1, Float32Array.from([0, bad, 0, 0])]), "GradientError"));
60
+ }
61
+ // why refusing matters: one NaN step poisons Adam's moments for good
62
+ const adam = TC.makeAdam(1, { lr: 0.1 });
63
+ adam.step(Float32Array.from([NaN]));
64
+ ck("(context) one NaN step leaves Adam's moments NaN on a finite gradient afterwards",
65
+ Number.isNaN(adam.step(Float32Array.from([1]))[0]));
66
+
67
+ // ---- 3. int32 accumulator headroom ----------------------------------------------------------
68
+ ck("MAX_INT32_K matches the Python kernel: floor((2^31-1) / 16384) = 131071", V.MAX_INT32_K === 131071);
69
+ const L = { mul: new Int32Array(65536) };
70
+ for (let a = 0; a < 256; a++) for (let b = 0; b < 256; b++) L.mul[a * 256 + b] = (a << 24 >> 24) * (b << 24 >> 24);
71
+ const K = V.MAX_INT32_K + 1;
72
+ const worst = new Int8Array(K).fill(-128);
73
+ ck("lutMatmulJS refuses K = 131072 (all -128*-128: the sum 2^31 wraps to -2^31)",
74
+ throwsName(() => V.lutMatmulJS(worst, worst, 1, K, 1, L), "AccumulatorBoundError"));
75
+ const acc = new Int32Array(1); acc[0] = 2 ** 31;
76
+ ck("(context) Int32Array really wraps 2^31 to -2^31 -- the silent failure being refused", acc[0] === -(2 ** 31));
77
+ ck("bgemmJS refuses the same K",
78
+ throwsName(() => V.bgemmJS(worst, worst, new Float32Array(1), new Float32Array(1),
79
+ { m: 1, k: K, n: 1 }, L), "AccumulatorBoundError"));
80
+ const okK = worst.subarray(0, V.MAX_INT32_K);
81
+ ck("K = 131071 still runs and is exact (131071 * 16384)",
82
+ V.lutMatmulJS(okK, okK, 1, V.MAX_INT32_K, 1, L)[0] === 131071 * 16384);
83
+
84
+ if (fails) { console.log("REDUCTIONS TEST FAILED: " + fails); process.exit(1); }
85
+ console.log("REDUCTIONS TEST PASSED — non-finite row maxes, bad gradients and int32 overflow are refused.");