File size: 7,581 Bytes
8485f65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# SPDX-License-Identifier: CC-BY-NC-4.0
# Copyright (c) 2026 Jakub Ćwirlej
"""Correctness tests for eugen_sparse.

    python test_eugen_sparse.py                       # GPU: everything; CPU: PyTorch parts only
    TRITON_INTERPRET=1 python test_eugen_sparse.py    # CPU: the Triton kernels in the interpreter (fp32)

Kernel vs reference_sparse_swiglu_ffn with the SAME group selection: y, dX, dW_gate, dW_up, dW_down.
Geometries cover the fused path (group_size <= 64), the unfused path (group_size 128), several token
chunks (atomic weight gradients), a non-power-of-two number of groups and d_model not divisible by 128.
"""
import os
import sys

import torch

import eugen_sparse as es

INTERP = os.environ.get('TRITON_INTERPRET', '0') == '1'


def rel_err(a, b):
    a = a.float(); b = b.float()
    return ((a - b).abs().max() / b.abs().max().clamp(min=1e-12)).item()


def make(M, D, Fd, dev, dt, seed=0):
    torch.manual_seed(seed)
    x = torch.randn(M, D, device=dev, dtype=dt) * 0.5
    gw = torch.randn(Fd, D, device=dev, dtype=dt) * D ** -0.5
    uw = torch.randn(Fd, D, device=dev, dtype=dt) * D ** -0.5
    dw = torch.randn(D, Fd, device=dev, dtype=dt) * Fd ** -0.5
    return x, gw, uw, dw


def parity(tag, M, D, Fd, G, kg, dev, dt, tol_y, tol_g):
    x, gw, uw, dw = make(M, D, Fd, dev, dt)
    idx = es.grouped_topk_select(x, gw, G, kg)
    dy = torch.randn(M, D, device=dev, dtype=dt)
    res = []
    for fn in (lambda *a: es.sparse_swiglu_fwd(*a, idx, G)[0], lambda *a: es.reference_sparse_swiglu_ffn(*a, idx, G)):
        leaves = [t.clone().requires_grad_() for t in (x, gw, uw, dw)]
        y = fn(*leaves)
        y.backward(dy)
        res.append([y.detach()] + [t.grad for t in leaves])
    errs = [rel_err(a, b) for a, b in zip(*res)]
    ok = errs[0] <= tol_y and all(e <= tol_g for e in errs[1:])
    p = es.plan(M, D, Fd, G, kg)
    print(f'  {"PASS" if ok else "FAIL"}  {tag:30s} chunks={p["token_chunks"]} fused={p["fused"]} '
          f'tiles={tuple(p["tiles"].values())} | y/dX/dWg/dWu/dWd ' + ' '.join(f'{e:.1e}' for e in errs))
    return ok


def test_cpu_reference_and_selection():
    x, gw, uw, dw = make(64, 32, 256, 'cpu', torch.float32)
    sub, spg = es.make_subsample_index(256, 32, 32)                 # full "subsample" = exact energy
    a = es.grouped_topk_select(x, gw, 32, 2, subsample_idx=sub, samples_per_group=spg).sort(-1).values
    b = es.grouped_topk_select(x, gw, 32, 2).sort(-1).values
    sub8, spg8 = es.make_subsample_index(256, 32, 8, seed=1)
    ok_sub = sub8.numel() == 8 * 8 and bool(((sub8 // 32) == torch.arange(8).repeat_interleave(8)).all())
    y = es.sparse_swiglu_ffn(x, gw, uw, dw, es.grouped_topk_select(x, gw, 32, 8), 32)   # all groups active
    dense = torch.nn.functional.linear(torch.nn.functional.silu(x @ gw.t()) * (x @ uw.t()), dw)
    ok = torch.equal(a, b) and ok_sub and rel_err(y, dense) < 1e-6
    print(f'  {"PASS" if ok else "FAIL"}  CPU: full subsample == exact selection, subsample layout, '
          f'all groups == dense SwiGLU ({rel_err(y, dense):.1e})')
    return ok


def test_load_balance_bias():
    ng, target = 8, 0.25
    bias, ema = torch.zeros(ng), torch.full((ng,), target)
    cur = torch.tensor([1.0, 0.0, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25])
    for _ in range(200):
        es.update_load_balance_bias(bias, ema, cur, target, gamma=1e-3, bias_max=0.5, momentum=0.99)
    ok = bias[0] < 0 and bias[1] > 0 and bool((bias[2:] == 0).all()) and float(bias.abs().max()) <= 0.5
    print(f'  {"PASS" if ok else "FAIL"}  load-balance bias: overused -> down, unused -> up, dead zone untouched')
    return ok


def test_plan_invariants():
    """Number of token chunks must not depend on the counting-sort chunk (it decides whether the weight
    gradients are deterministic); in automatic mode every size gets the maximum number of CTAs."""
    if not es._HAS_TRITON:
        return True
    bad = []
    saved = es._CSR_CHUNK
    for chunk in (0, 8192, 4096, 2048, 1024, 512):
        es._CSR_CHUNK = chunk
        for M in (128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536):
            for kg in (1, 2, 3, 4, 8, 16):
                P = M * kg
                ncta, ch, r, c = es._plan_chunks(P)
                nref = -(-P // es._CHUNK)
                ref = min(es._CH, nref)
                while ref > 1 and nref % ref != 0:
                    ref -= 1
                if ch != ref or ncta % ch or ncta // ch != r:
                    bad.append((chunk, M, kg))
                if chunk == 0 and ncta < es._CSR_CTA and c > es._SUB:
                    bad.append(('boost', M, kg))
    es._CSR_CHUNK = saved
    print(f'  {"PASS" if not bad else "FAIL"}  plan: token chunks independent of CSR chunk, CTA boost {bad[:3]}')
    return not bad


def test_interpreter():
    ok = True
    for tag, M, D, Fd, G, kg in (('fused G=32', 256, 64, 512, 32, 4),
                                 ('unfused G=128', 192, 64, 1024, 128, 2),
                                 ('3 token chunks (atomic dW)', 2100, 64, 512, 32, 8),
                                 ('48 groups (not 2^n), d_model 96', 300, 96, 1536, 32, 3)):
        ok = parity(tag, M, D, Fd, G, kg, 'cpu', torch.float32, 1e-4, 1e-4) and ok
    return ok


def test_cuda():
    ok = True
    for G, ng, kg in ((128, 16, 3), (32, 128, 8)):
        for dt, ty, tg in ((torch.float32, 5e-3, 8e-3), (torch.bfloat16, 3e-2, 6e-2)):
            ok = parity(f'{str(dt)[6:]} G={G} ng={ng} kg={kg}', 512, 256, ng * G, G, kg, 'cuda', dt, ty, tg) and ok
    ok = parity('bf16 4 token chunks', 8192, 512, 4096, 32, 8, 'cuda', torch.bfloat16, 3e-2, 6e-2) and ok
    x, gw, uw, dw = make(64, 128, 256, 'cuda', torch.bfloat16)
    idx = es.grouped_topk_select(x, gw, 16, 4).to(torch.int32)
    try:
        torch.library.opcheck(es.sparse_swiglu_fwd, tuple(t.clone().requires_grad_() for t in (x, gw, uw, dw)) + (idx, 16))
        print('  PASS  opcheck')
    except Exception as exc:
        print(f'  FAIL  opcheck: {type(exc).__name__}: {exc}'); ok = False
    import torch._dynamo as dynamo
    x, gw, uw, dw = make(512, 512, 4096, 'cuda', torch.bfloat16)

    def block(a, b, c, d):
        return es.sparse_swiglu_ffn(a, b, c, d, es.grouped_topk_select(a, b, 32, 8), 32).float().pow(2).mean()
    ex = dynamo.explain(block)(x, gw, uw, dw)
    dynamo.reset()
    leaves = [t.clone().requires_grad_() for t in (x, gw, uw, dw)]
    lc = torch.compile(block, fullgraph=True)(*leaves)
    gc = torch.autograd.grad(lc, leaves)
    leaves_e = [t.clone().requires_grad_() for t in (x, gw, uw, dw)]
    le = block(*leaves_e)
    ge = torch.autograd.grad(le, leaves_e)
    d = max(rel_err(a, b) for a, b in zip(gc, ge))
    c_ok = ex.graph_count == 1 and ex.graph_break_count == 0 and d < 3e-2
    print(f'  {"PASS" if c_ok else "FAIL"}  torch.compile: graphs={ex.graph_count} breaks={ex.graph_break_count}, '
          f'compiled vs eager grads {d:.1e}')
    return ok and c_ok


if __name__ == '__main__':
    print(f'torch {torch.__version__} | triton {es._HAS_TRITON} | cuda {torch.cuda.is_available()} | interpreter {INTERP}')
    results = [test_cpu_reference_and_selection(), test_load_balance_bias(), test_plan_invariants()]
    if INTERP and es._HAS_TRITON:
        results.append(test_interpreter())
    elif torch.cuda.is_available() and es._HAS_TRITON:
        results.append(test_cuda())
    else:
        print('  (no CUDA: kernel tests skipped — run with TRITON_INTERPRET=1 to test the kernels on CPU)')
    print(f'\n{sum(results)}/{len(results)} test groups passed')
    sys.exit(0 if all(results) else 1)