File size: 9,168 Bytes
5abe544 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | """
CAN THE FOLD BE MADE BIT-REPRODUCIBLE, AND WHAT DOES IT COST?
Two runs of the same experiment, same seed, same configuration, produced
models that differed — by about 0.002 in final accuracy, diverging from
epoch 10 onward. The cause is the scatter-add: folding a gradient sums many
weights into one stored value using hardware atomics, atomics complete in
whatever order the scheduler picks, and floating-point addition is not
associative. The same numbers added in a different order give a different
answer.
That is a 3e-6 relative perturbation amplified by chaotic dynamics into
0.002 of accuracy. It is too small to be useful as noise — the minibatch
gradient noise already present is thirty thousand times larger — and too
large for a published digest to survive, so it sits in the gap where it is
neither an asset nor harmless.
It is also avoidable, and the fold is the reason: THE PARTITION NEVER
CHANGES DURING TRAINING. The accumulation order can therefore be fixed once
and reused for every step of every epoch. This script builds that fixed
order and answers three questions:
1. Is the current scatter actually non-deterministic on this hardware?
2. Is the fixed-order scatter bit-identical across repeats?
3. What does it cost per call?
and then trains a small folded model twice each way, on random data, to see
whether determinism survives a few hundred optimiser steps.
No dataset, no download. Random inputs are enough, because the question is
bit-exactness and not accuracy.
"""
import numpy as np
import time
try:
import cupy as _cp
_GPU = _cp.cuda.runtime.getDeviceCount() > 0
except Exception:
_GPU = False
xp = _cp if _GPU else np
DT = np.float32
def to_dev(a, dtype=DT):
a = np.asarray(a, dtype=dtype)
return xp.asarray(a) if _GPU else a
def to_host(a):
return _cp.asnumpy(a) if _GPU and isinstance(a, _cp.ndarray) else np.asarray(a)
def scatter_atomic(g, idx, K):
"""What every script in this programme uses. On a GPU this is an atomic
accumulation, so the summation order is whatever the hardware picks."""
out = xp.zeros(K, DT)
if _GPU:
import cupyx
cupyx.scatter_add(out, idx, g.reshape(-1))
else:
np.add.at(out, idx, g.reshape(-1))
return out
class FixedScatter:
"""The same sum, in an order fixed once and reused forever.
Sort the index array, then lay the gradients into a rectangular buffer
of shape (K, longest group) by ASSIGNMENT rather than accumulation --
every destination is written by exactly one source, so there is no race
at all -- and reduce along the second axis. A tree reduction over a
fixed layout gives the same answer every time.
Building this costs one sort. The partition never changes, so it is
paid once for the whole run rather than once per step."""
def __init__(self, idx, K):
h = to_host(idx).astype(np.int64)
order = np.argsort(h, kind="stable")
counts = np.bincount(h, minlength=K)
starts = np.cumsum(counts) - counts
pos = np.arange(len(h)) - np.repeat(starts, counts)
self.K, self.width = K, int(counts.max())
self.order = to_dev(order, np.int64) if _GPU else order
slot = h[order]*self.width + pos
self.slot = to_dev(slot, np.int64) if _GPU else slot
self.buf = xp.zeros(K*self.width, DT)
self.bytes = K*self.width*4
def __call__(self, g):
self.buf[:] = 0
self.buf[self.slot] = g.reshape(-1)[self.order]
return self.buf.reshape(self.K, self.width).sum(1)
def bits_equal(a, b):
return np.array_equal(to_host(a).view(np.uint8), to_host(b).view(np.uint8))
CFG = dict(D=768, hid=4096, K=433, reps=5, steps=200, batch=128, lr=1e-3)
def main(**over):
CFG.update(over)
D, hid, K = CFG["D"], CFG["hid"], CFG["K"]
print("=" * 78)
print("CAN THE FOLD BE MADE BIT-REPRODUCIBLE, AND WHAT DOES IT COST?")
print("=" * 78)
print(f" backend: {'cupy (GPU)' if _GPU else 'numpy (CPU)'}")
for k, v in CFG.items():
print(f" {k:7s} = {v}")
if not _GPU:
print()
print(" NOTE: on CPU numpy's add.at is already sequential and")
print(" therefore deterministic, so part 1 will show no difference.")
print(" The non-determinism this tests is a GPU atomics effect and")
print(" the timings here do not predict GPU timings either.")
print("=" * 78, flush=True)
rng = np.random.default_rng(0)
idx_h = rng.integers(0, K, D*hid)
idx = to_dev(idx_h, np.int64) if _GPU else idx_h
g = to_dev(rng.normal(size=D*hid))
print("\n 1. IS THE CURRENT SCATTER NON-DETERMINISTIC HERE?\n")
a0 = scatter_atomic(g, idx, K)
diffs = []
for _ in range(CFG["reps"]):
a = scatter_atomic(g, idx, K)
diffs.append(0 if bits_equal(a0, a)
else float(np.abs(to_host(a)-to_host(a0)).max()))
nd = sum(1 for d in diffs if d)
print(f" {nd} of {CFG['reps']} repeats differed from the first")
if nd:
print(f" largest difference {max(diffs):.3e}")
print(f" — the same additions, a different order, a different sum")
else:
print(f" all identical (expected on CPU; see the note above)")
print("\n 2. IS THE FIXED ORDER BIT-IDENTICAL?\n")
t0 = time.time()
fs = FixedScatter(idx, K)
prep = time.time()-t0
b0 = fs(g)
same = all(bits_equal(b0, fs(g)) for _ in range(CFG["reps"]))
print(f" preparation {prep*1000:.0f} ms, ONCE for the whole run")
print(f" buffer {fs.K} x {fs.width} = {fs.bytes/1e6:.1f} MB")
print(f" bit-identical across {CFG['reps']} repeats: {same}")
print(f" agrees with the atomic result to "
f"{float(np.abs(to_host(b0)-to_host(a0)).max()):.3e}")
print("\n 3. WHAT DOES IT COST?\n")
def timeit(f, n=20):
f()
if _GPU:
_cp.cuda.Stream.null.synchronize()
t = time.time()
for _ in range(n):
f()
if _GPU:
_cp.cuda.Stream.null.synchronize()
return (time.time()-t)/n*1000
ta = timeit(lambda: scatter_atomic(g, idx, K))
tf = timeit(lambda: fs(g))
# the matmul the scatter sits beside, for scale
x = to_dev(rng.normal(size=(CFG["batch"], D)))
W = to_dev(rng.normal(size=(D, hid)))
tm = timeit(lambda: x @ W)
print(f" {'atomic scatter':>22s} {ta:8.3f} ms")
print(f" {'fixed-order scatter':>22s} {tf:8.3f} ms "
f"{tf/ta:.2f}x")
print(f" {'one forward matmul':>22s} {tm:8.3f} ms (for scale)")
over_step = (tf-ta)/max(tm, 1e-9)
print(f"\n the extra cost is {tf-ta:+.3f} ms a step, which is "
f"{over_step:+.2f}")
print(f" of a forward matmul")
print("\n 4. DOES A TRAINING RUN BECOME REPRODUCIBLE?\n")
def run(fixed, seed=0):
r = np.random.default_rng(seed)
v = to_dev(r.normal(0, 0.05, K))
m = xp.zeros_like(v); vv = xp.zeros_like(v)
X = to_dev(r.normal(size=(CFG["batch"], D)))
s = FixedScatter(idx, K) if fixed else None
for t in range(1, CFG["steps"]+1):
W = v[idx].reshape(D, hid)
h = xp.maximum(X @ W, 0)
d = (h - 1.0)/CFG["batch"]
gW = X.T @ (d*(h > 0))
gr = s(gW) if fixed else scatter_atomic(gW, idx, K)
m = 0.9*m + 0.1*gr
vv = 0.999*vv + 0.001*gr*gr
v = v - CFG["lr"]*(m/(1-0.9**t))/(xp.sqrt(vv/(1-0.999**t))+1e-8)
return v
for lab, fixed in (("atomic", False), ("fixed order", True)):
r1, r2 = run(fixed), run(fixed)
eq = bits_equal(r1, r2)
dd = float(np.abs(to_host(r1)-to_host(r2)).max())
print(f" {lab:>12s}: two runs bit-identical after "
f"{CFG['steps']} steps: {eq}"
+ ("" if eq else f" (max difference {dd:.3e})"))
print("\n" + "=" * 78)
print(" READOUT")
print("=" * 78)
if not _GPU:
print(" CPU ONLY, so this run cannot answer the question it was")
print(" written for. Rerun where cupy sees a device.")
elif nd and same:
print(f" DETERMINISM IS AVAILABLE, at {tf/ta:.2f}x the scatter's own")
print(f" cost and {abs(over_step)*100:.0f}% of a forward matmul a step.")
print(f" The current scatter is genuinely non-deterministic here and")
print(f" the fixed order is exact. Whether it is worth paying for")
print(f" depends on whether anything downstream needs a base to have")
print(f" a stable digest.")
elif not nd:
print(" THE CURRENT SCATTER IS ALREADY DETERMINISTIC on this")
print(" hardware, so there is nothing to fix here — though the 0.002")
print(" drift measured across sessions says something else varies,")
print(" and it would be worth finding what.")
else:
print(" THE FIXED ORDER IS NOT REPRODUCING EXACTLY, which should not")
print(" happen — the reduction is over a fixed layout. Read the")
print(" numbers above before trusting either path.")
if __name__ == "__main__":
main()
|