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()