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 +25 -1
- daisychain/cluster.py +40 -5
- test_cluster_reductions.py +111 -0
- web/TEST_RESULTS.md +3 -1
- web/package.json +1 -1
- web/public/app.js +9 -1
- web/public/traincore.js +17 -1
- web/public/verified_core.js +22 -1
- web/public/webgpu.js +6 -0
- web/test_reductions.js +85 -0
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
|
| 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
|
| 38 |
|
| 39 |
|
| 40 |
def _gpu_info():
|
| 41 |
-
if not
|
| 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 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.");
|