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