mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
1204 lines
60 KiB
Python
1204 lines
60 KiB
Python
import unittest, functools
|
|
from tinygrad import Tensor, UOp, GlobalCounters, Context, Device, Variable, TinyJit
|
|
from tinygrad.engine.realize import run_linear, get_runtime
|
|
from tinygrad.codegen import to_program
|
|
import numpy as np
|
|
from tinygrad.dtype import AddrSpace, dtypes, Invalid
|
|
from tinygrad.helpers import getenv
|
|
from tinygrad.schedule.rangeify import BufferizeOpts
|
|
from tinygrad.uop.ops import KernelInfo, AxisType, Ops, uopfunc
|
|
from tinygrad.codegen.opt import Opt, OptOps
|
|
from tinygrad.renderer.ptx import PTXRenderer
|
|
from tinygrad.renderer.cstyle import CStyleLanguage
|
|
from tinygrad.renderer.llvmir import LLVMRenderer
|
|
from test.helpers import assert_kernel_count
|
|
from test.null.test_custom_kernel import custom_elementwise_add_kernel, custom_elementwise_addmul_kernel, custom_gemm
|
|
|
|
# **** kernels ****
|
|
|
|
def custom_arange_kernel(C:UOp) -> UOp:
|
|
i = UOp.range(C.shape[0], 0)
|
|
return C[i].store(i.cast(C.dtype)).end(i).sink(arg=KernelInfo(name=f"custom_arange_{C.shape[0]}"))
|
|
|
|
def custom_eye_kernel(C:UOp) -> UOp:
|
|
i = UOp.range(C.shape[0], 0)
|
|
j = UOp.range(C.shape[1], 1)
|
|
return C[i, j].store((i.eq(j)).cast(C.dtype)).end(i, j).sink(arg=KernelInfo(name=f"custom_eye_{C.numel()}"))
|
|
|
|
def custom_add_one_kernel(B:UOp, A:UOp) -> UOp:
|
|
A,B = A.flatten(), B.flatten()
|
|
assert B.numel() == A.numel()
|
|
i = UOp.range(A.numel(), 0)
|
|
return B[i].store(A[i] + 1).end(i).sink(arg=KernelInfo(name=f"add_one_{A.numel()}"))
|
|
|
|
def custom_ignore_first_kernel(C:UOp, A:UOp, B:UOp) -> UOp:
|
|
# A is unused on purpose: the kernel takes call buffers 0 and 2, not 0, 1, 2
|
|
C, B = C.flatten(), B.flatten()
|
|
i = UOp.range(C.numel(), 0)
|
|
return C[i].store(B[i] + 1).end(i).sink(arg=KernelInfo(name=f"ignore_first_{C.numel()}"))
|
|
|
|
def custom_add_var_kernel(*srcs:UOp, n_slot:int) -> UOp:
|
|
C, *Bs = [s.flatten() for i,s in enumerate(srcs) if i != n_slot]
|
|
i = UOp.range(C.numel(), 0)
|
|
out = functools.reduce(lambda a,b: a+b, [B[i] for B in Bs]) + srcs[n_slot]
|
|
return C[i].store(out).end(i).sink(arg=KernelInfo(name=f"add_var_{n_slot}_{len(Bs)}"))
|
|
|
|
def run_kernel_args(out:Tensor, name:str) -> list[str|None]:
|
|
linear, var_vals = out.linear_with_vars()
|
|
sink = [u for u in linear.toposort(enter_calls=True) if u.op is Ops.SINK and isinstance(u.arg, KernelInfo) and u.arg.name == name][0]
|
|
sig = to_program(sink, Device[Device.DEFAULT].renderer).to_elf().signature
|
|
run_linear(linear, var_vals)
|
|
return [nm for nm, *_ in sig]
|
|
|
|
def custom_ignore_first_var_kernel(C:UOp, A:UOp, B:UOp, n:UOp) -> UOp:
|
|
C, B = C.flatten(), B.flatten()
|
|
k = UOp.variable("k", 0, 100, dtype=dtypes.int)
|
|
i = UOp.range(C.numel(), 0)
|
|
return C[i].store(B[i] + n + k).end(i).sink(arg=KernelInfo(name=f"ignore_first_var_{C.numel()}"))
|
|
|
|
def custom_sum(B:UOp, A:UOp) -> UOp:
|
|
i = UOp.range(A.shape[0], 0, axis_type=AxisType.LOOP)
|
|
B = B[0].set(0.0)
|
|
B = B[0].set(B.after(i)[0] + A[i], end=i)
|
|
return B.sink(arg=KernelInfo(name=f"custom_sum_{A.shape[0]}", opts_to_apply=()))
|
|
|
|
def flip_contract_kernel(dest:UOp, src:UOp):
|
|
i = UOp.range(dest.shape[0], 0)
|
|
j = UOp.range(dest.shape[1], 1, AxisType.UPCAST)
|
|
vec = src[i, j].contract(j)
|
|
store = UOp.group(*[dest[i, k].store(vec.index(3-k)) for k in range(4)])
|
|
return store.end(i, j).sink(arg=KernelInfo(name=f"flip_contract_{dest.numel()}", opts_to_apply=()))
|
|
|
|
def slice_sum_kernel(dest:UOp, src:UOp):
|
|
G = UOp.range(src.shape[0], 0, dtype=dtypes.int)
|
|
slice_src = src[G, :]
|
|
reg = UOp.placeholder((1,), dest.dtype, 0, addrspace=AddrSpace.REG)
|
|
reg = reg.after(G)[0].set(0)
|
|
R = UOp.range(src.shape[1], 1, AxisType.LOOP)
|
|
reg = reg[0].set(reg.after(R)[0] + slice_src[R], end=R)
|
|
ast = dest[G].set(reg[0], end=G)
|
|
return ast.sink(arg=KernelInfo(name=f"slice_sum_{src.shape[0]}_{src.shape[1]}", opts_to_apply=()))
|
|
|
|
def simple_qkv_kernel(O:UOp, Q:UOp, K:UOp, V:UOp) -> UOp:
|
|
# attention without softmax
|
|
N, d = Q.shape[0], Q.shape[1]
|
|
|
|
i = UOp.range(N, 0) # output row
|
|
d_out = UOp.range(d, 1) # output column
|
|
j = UOp.range(N, 2, axis_type=AxisType.LOOP)
|
|
|
|
k_inner = UOp.range(d, 3, axis_type=AxisType.LOOP)
|
|
qk_acc = UOp.placeholder((1,), Q.dtype, 0, addrspace=AddrSpace.REG)
|
|
qk_acc = qk_acc.after(i, j)[0].set(0.0)
|
|
qk_acc = qk_acc[0].set(qk_acc.after(k_inner)[0] + Q[i, k_inner] * K[j, k_inner], end=k_inner)
|
|
qk_score = qk_acc[0] / (d ** 0.5)
|
|
|
|
out_acc = UOp.placeholder((1,), Q.dtype, 1, addrspace=AddrSpace.REG)
|
|
out_acc = out_acc.after(i, d_out)[0].set(0.0)
|
|
out_acc = out_acc[0].set(out_acc.after(j)[0] + qk_score * V[j, d_out], end=j)
|
|
|
|
store = O[i, d_out].store(out_acc[0])
|
|
return store.end(d_out).end(i).sink(arg=KernelInfo(name=f"simple_qkv_{N}_{d}", opts_to_apply=()))
|
|
|
|
# **** backward callbacks ****
|
|
|
|
def backward_gemm(gradient:UOp, kernel:UOp) -> tuple[UOp, UOp]:
|
|
out, a, b = kernel.src[1:]
|
|
grad_a = (Tensor(gradient) @ Tensor(b).T).uop
|
|
grad_b = (Tensor(a).T @ Tensor(gradient)).uop
|
|
return (None, grad_a, grad_b)
|
|
|
|
def backward_gemm_custom(gradient:UOp, kernel:UOp) -> tuple[UOp, UOp]:
|
|
out, a, b = kernel.src[1:]
|
|
grad_a = Tensor.empty_like(Tensor(a)).custom_kernel(Tensor(gradient), Tensor(b).T, fxn=custom_gemm)[0].uop
|
|
grad_b = Tensor.empty_like(Tensor(b)).custom_kernel(Tensor(a).T, Tensor(gradient), fxn=custom_gemm)[0].uop
|
|
return (None, grad_a, grad_b)
|
|
|
|
# **** tests ****
|
|
|
|
class TestCustomKernel(unittest.TestCase):
|
|
def test_empty(self):
|
|
a = Tensor.empty(1)
|
|
a = Tensor.custom_kernel(a, fxn=lambda _: UOp.sink(arg=KernelInfo()))[0]
|
|
a.realize()
|
|
|
|
def test_simple(self):
|
|
a = Tensor.ones(16, 16).contiguous()
|
|
b = Tensor.ones(16, 16).contiguous()
|
|
c = Tensor.empty(16, 16)
|
|
|
|
c = Tensor.custom_kernel(c,a,b, fxn=custom_elementwise_add_kernel)[0]
|
|
|
|
out = c.flatten().tolist()
|
|
assert all(x == 2 for x in out), "all 2"
|
|
|
|
def test_duplicate_call_arg(self):
|
|
x = Tensor.arange(4).clone().realize()
|
|
x = Tensor.custom_kernel(x, x, fxn=custom_add_one_kernel)[0]
|
|
# webgpu silently errors when a kernel has duplicate buffer args, so the list stays the same.
|
|
# https://gpuweb.github.io/gpuweb/#abstract-opdef-encoder-bind-groups-alias-a-writable-resource
|
|
self.assertEqual(x.tolist(), [1, 2, 3, 4] if Device.DEFAULT != "WEBGPU" else [0, 1, 2, 3])
|
|
|
|
def test_simple_sharded(self):
|
|
devs = ("CPU:0", "CPU:1")
|
|
|
|
a = Tensor.ones(16, 16).contiguous().shard(devs, axis=0)
|
|
b = Tensor.ones(16, 16).contiguous().shard(devs, axis=0)
|
|
# ugly construction to get a sharded empty tensor
|
|
c = Tensor(Tensor.empty(8, 16, device=devs).uop.unshard(0), device=devs)
|
|
c = Tensor.custom_kernel(c,a,b, fxn=custom_elementwise_add_kernel)[0]
|
|
out = c.flatten().tolist()
|
|
assert all(x == 2 for x in out), "all 2"
|
|
|
|
def test_sharded_add_one(self):
|
|
# PYTHON backend explicitly checks for OOB access for wrong multi shape regression
|
|
devs = ("PYTHON:0", "PYTHON:1")
|
|
a = Tensor.ones(4, 4).contiguous().shard(devs, axis=0)
|
|
c = Tensor(Tensor.empty(2, 4, device=devs).uop.unshard(0), device=devs)
|
|
c = Tensor.custom_kernel(c, a, fxn=custom_add_one_kernel)[0]
|
|
assert (c == 2).all().item()
|
|
|
|
def test_multioutput(self):
|
|
a = Tensor.full((16, 16), 3.).contiguous()
|
|
b = Tensor.full((16, 16), 3.).contiguous()
|
|
c = Tensor.empty(16, 16)
|
|
d = Tensor.empty(16, 16)
|
|
|
|
c,d = Tensor.custom_kernel(c,d,a,b, fxn=custom_elementwise_addmul_kernel)[:2]
|
|
Tensor.realize(c,d)
|
|
|
|
assert all(x == 6 for x in c.flatten().tolist()), "all 6"
|
|
assert all(x == 9 for x in d.flatten().tolist()), "all 9"
|
|
|
|
def test_arange(self):
|
|
ref = Tensor.arange(100)
|
|
tst = Tensor.empty_like(ref)
|
|
tst = tst.custom_kernel(fxn=custom_arange_kernel)[0]
|
|
self.assertTrue((ref == tst).all().item())
|
|
|
|
def test_eye(self):
|
|
ref = Tensor.eye(1024).clone().realize()
|
|
tst = Tensor.empty_like(ref)
|
|
tst = tst.custom_kernel(fxn=custom_eye_kernel)[0]
|
|
self.assertTrue((ref == tst).all().item())
|
|
|
|
@unittest.skip("contract shouldn't be supported here")
|
|
def test_flip_contract(self):
|
|
a = Tensor.randn(10,4)
|
|
b = Tensor.empty_like(a)
|
|
b = b.custom_kernel(a, fxn=flip_contract_kernel)[0]
|
|
self.assertTrue((a.flip(1) == b).all().item())
|
|
|
|
def test_noncontig(self):
|
|
a = Tensor.ones(16, 16).contiguous()
|
|
tst = Tensor.empty_like(a)
|
|
b = a+1
|
|
b_p1 = Tensor.custom_kernel(tst, b, fxn=custom_add_one_kernel)[0]
|
|
self.assertTrue((b_p1 == 3).all().item())
|
|
|
|
def test_unused_buffer_arg(self):
|
|
a, b = Tensor([100.0, 200, 300, 400]), Tensor([1.0, 2, 3, 4])
|
|
out = Tensor.custom_kernel(Tensor.empty(4), a, b, fxn=custom_ignore_first_kernel)[0]
|
|
self.assertEqual(out.tolist(), [2, 3, 4, 5])
|
|
|
|
def test_scalar_arg_any_position(self):
|
|
for n_slot in range(3):
|
|
with self.subTest(n_slot=n_slot):
|
|
srcs = [Tensor.empty(4, dtype=dtypes.int), Tensor([1, 2, 3, 4], dtype=dtypes.int).realize()]
|
|
srcs.insert(n_slot, Tensor(Variable("n", 0, 100, dtypes.int).bind(5)))
|
|
out = Tensor.custom_kernel(*srcs, fxn=functools.partial(custom_add_var_kernel, n_slot=n_slot))[1 if n_slot == 0 else 0]
|
|
self.assertEqual(run_kernel_args(out, f"add_var_{n_slot}_1"), ["n" if i == n_slot else None for i in range(3)])
|
|
self.assertEqual(out.tolist(), [6, 7, 8, 9])
|
|
|
|
@unittest.skipIf(Device.DEFAULT == "PYTHON", "needs a device that isnt the default")
|
|
def test_scalar_arg_first_other_device(self):
|
|
srcs = [Tensor(Variable("n", 0, 100, dtypes.int).bind(5), device="PYTHON"), Tensor.empty(4, dtype=dtypes.int, device="PYTHON"),
|
|
Tensor([1, 2, 3, 4], dtype=dtypes.int, device="PYTHON").realize()]
|
|
out = Tensor.custom_kernel(*srcs, fxn=functools.partial(custom_add_var_kernel, n_slot=0))[1]
|
|
self.assertEqual(out.tolist(), [6, 7, 8, 9])
|
|
|
|
def test_scalar_arg_with_other_variable(self):
|
|
other = (Tensor([1, 2, 3, 4], dtype=dtypes.int) + Tensor(Variable("m", 0, 100, dtypes.int).bind(70))).contiguous()
|
|
srcs = [Tensor.empty(4, dtype=dtypes.int), Tensor([1, 2, 3, 4], dtype=dtypes.int).realize(), Tensor(Variable("n", 0, 100, dtypes.int).bind(5))]
|
|
out = Tensor.custom_kernel(*srcs, fxn=functools.partial(custom_add_var_kernel, n_slot=2))[0]
|
|
Tensor.realize(other, out)
|
|
self.assertEqual(out.tolist(), [6, 7, 8, 9])
|
|
|
|
def test_scalar_arg_past_registers(self):
|
|
# 9 inputs with the scalar between pointers, x86 passes the ones after the 6th (4th on windows) on the stack. every buffer differs
|
|
srcs = [Tensor.empty(4, dtype=dtypes.int)] + [Tensor([k, 2*k, 3*k, 4*k], dtype=dtypes.int).realize() for k in range(1, 8)]
|
|
srcs.insert(4, Tensor(Variable("n", 0, 100, dtypes.int).bind(5)))
|
|
out = Tensor.custom_kernel(*srcs, fxn=functools.partial(custom_add_var_kernel, n_slot=4))[0]
|
|
self.assertEqual(run_kernel_args(out, "add_var_4_7"), [None]*4 + ["n"] + [None]*4)
|
|
self.assertEqual(out.tolist(), [33, 61, 89, 117])
|
|
|
|
def test_unused_arg_then_var(self):
|
|
srcs = (Tensor.empty(4, dtype=dtypes.int), Tensor.empty(4, dtype=dtypes.int), Tensor([1, 2, 3, 4], dtype=dtypes.int).realize(),
|
|
Tensor(Variable("n", 0, 100, dtypes.int).bind(5)))
|
|
out = Tensor.custom_kernel(*srcs, fxn=custom_ignore_first_var_kernel)[0]
|
|
linear, var_vals = out.linear_with_vars()
|
|
run_linear(linear, {**var_vals, "k": 10})
|
|
self.assertEqual(out.tolist(), [16, 17, 18, 19])
|
|
|
|
def test_scalar_arg_jit(self):
|
|
# the scalar changes every call, a replay must not keep the value it was captured with
|
|
kernel = functools.partial(custom_add_var_kernel, n_slot=0)
|
|
jf = TinyJit(lambda B, n: Tensor.custom_kernel(Tensor(n), Tensor.empty(4, dtype=dtypes.int), B, fxn=kernel)[1].realize())
|
|
B, n = Tensor([1, 2, 3, 4], dtype=dtypes.int).realize(), Variable("n", 0, 100, dtypes.int)
|
|
for k in range(1, 6): self.assertEqual(jf(B, n.bind(k)).tolist(), [1+k, 2+k, 3+k, 4+k])
|
|
|
|
def test_scalar_arg_jit_fixed_bind(self):
|
|
# m is bound inside the jitted function so it's not an input, a replay keeps m=10 while the input n changes
|
|
kernel = functools.partial(custom_add_var_kernel, n_slot=0)
|
|
def f(B, n):
|
|
Bm = Tensor.custom_kernel(Tensor(Variable("m", 0, 100, dtypes.int).bind(10)), Tensor.empty(4, dtype=dtypes.int), B, fxn=kernel)[1]
|
|
return Tensor.custom_kernel(Tensor(n), Tensor.empty(4, dtype=dtypes.int), Bm, fxn=kernel)[1].realize()
|
|
jf, B, n = TinyJit(f), Tensor([1, 2, 3, 4], dtype=dtypes.int).realize(), Variable("n", 0, 100, dtypes.int)
|
|
for k in range(1, 6): self.assertEqual(jf(B, n.bind(k)).tolist(), [11+k, 12+k, 13+k, 14+k])
|
|
|
|
def test_sum(self):
|
|
a = Tensor([1.0, 2, 3, 4, 5])
|
|
tst = Tensor.empty(1)
|
|
b = Tensor.custom_kernel(tst, a, fxn=custom_sum)[0]
|
|
self.assertEqual(b.item(), 15)
|
|
|
|
def test_sum_outside(self):
|
|
a = Tensor([1.0, 2, 3, 4, 5])+1
|
|
tst = Tensor.empty(1)
|
|
b = Tensor.custom_kernel(tst, a, fxn=custom_sum)[0]
|
|
self.assertEqual(b.item(), 20)
|
|
|
|
def test_sum_int(self):
|
|
a = Tensor([1, 2, 3, 4, 5])
|
|
tst = Tensor.empty(1, dtype=a.dtype)
|
|
b = Tensor.custom_kernel(tst, a, fxn=custom_sum)[0]
|
|
self.assertEqual(b.item(), 15)
|
|
|
|
def test_slice_sum(self):
|
|
A = Tensor.randn(16, 16).contiguous()
|
|
B = Tensor.empty(16)
|
|
B = Tensor.custom_kernel(B, A, fxn=slice_sum_kernel)[0]
|
|
self.assertTrue(B.allclose(A.sum(1)).item())
|
|
|
|
def test_gemm(self):
|
|
N = 16
|
|
a = Tensor.randn(N, N)
|
|
b = Tensor.randn(N, N)
|
|
c = Tensor.empty(N, N)
|
|
|
|
tst = Tensor.custom_kernel(c, a, b, fxn=custom_gemm)[0]
|
|
self.assertTrue(tst.allclose(a@b, atol=1e-3).item())
|
|
|
|
def test_unused_reduce_dependency(self):
|
|
for value in (UOp.const(2), UOp.const((1, 2, 3, 4)).reshape((2, 2))):
|
|
with self.subTest(shape=value.shape):
|
|
def kernel(C:UOp) -> UOp:
|
|
i = UOp.range(3, 0, AxisType.LOOP)
|
|
cell = C.after(i)[0]
|
|
v = value.after(cell.store(cell + 1))
|
|
return C.after(v.reduce(i, arg=Ops.ADD)).sink(arg=KernelInfo(opts_to_apply=()))
|
|
self.assertEqual(Tensor.zeros(1, dtype=dtypes.int).contiguous().custom_kernel(fxn=kernel)[0].tolist(), [3])
|
|
|
|
def test_range_merge_keeps_after_domain(self):
|
|
# C is read once per i or once per o, so it counts to 4, and merging o and i must not make that 16
|
|
o, i = UOp.range(4, 0, AxisType.LOOP), UOp.range(4, 1, AxisType.LOOP)
|
|
stores = {"after_i": lambda C,A: (c:=C.after(i)[0]).store(c+1),
|
|
"after_o": lambda C,A: UOp.group((c:=C.after(o)[0]).store(c+1), A.after(o, i)[0].store(1)),
|
|
"load_after_o": lambda C,A: C.after(o, i)[0].store(C.after(o)[0].load()+1)}
|
|
for name, store in stores.items():
|
|
with self.subTest(name):
|
|
def kernel(C:UOp, A:UOp, store=store) -> UOp: return store(C, A).end(o, i).sink(arg=KernelInfo(opts_to_apply=()))
|
|
self.assertEqual(Tensor.zeros(1).contiguous().custom_kernel(Tensor.empty(1), fxn=kernel)[0].item(), 4)
|
|
|
|
def test_range_merge_keeps_index_domain(self):
|
|
# C[c] is indexed by o alone, so each cell counts to 2 while the B store merges o and i
|
|
o, i = UOp.range(4, 0, AxisType.LOOP), UOp.range(4, 1, AxisType.LOOP)
|
|
for name, c in {"div": o//2, "cmp": (o<2).cast(dtypes.int)}.items():
|
|
with self.subTest(name):
|
|
def kernel(C:UOp, B:UOp, c=c) -> UOp:
|
|
return UOp.group(C[c].store(C[c]+1), B[o*4+i].store(1)).end(o, i).sink(arg=KernelInfo(opts_to_apply=()))
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.zeros(2).contiguous(), Tensor.zeros(16).contiguous(), fxn=kernel)[0].tolist(), [2, 2])
|
|
|
|
def test_range_merge_keeps_inner_reduce(self):
|
|
# the inner reduce ends r1 alone, so r0 and r1 don't merge. the inner sum is 4, the outer adds it 4*4*4 times
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
r0, r1, r2 = [UOp.range(4, n, AxisType.UPCAST) for n in range(3)]
|
|
return C[0].store(A[r2].reduce(r1, arg=Ops.ADD).reduce(r0, r1, r2, arg=Ops.ADD)).sink(arg=KernelInfo(opts_to_apply=()))
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(1), Tensor.ones(4).contiguous(), fxn=kernel)[0].item(), 256)
|
|
|
|
def test_range_merge_keeps_reduce_order(self):
|
|
# the reduce body stores, so it loops a, k, b in order, and the digits of S are the A[a*2+b] it visits
|
|
def kernel(O:UOp, S:UOp, A:UOp) -> UOp:
|
|
a, k, b = [UOp.range(2, n, AxisType.LOOP) for n in range(3)]
|
|
v = A[a*2+b].after((cell:=S.after(a, k, b)[0]).store(cell*10+A[a*2+b]))
|
|
return O[0].store(v.reduce(a, k, b, arg=Ops.ADD)).sink(arg=KernelInfo(opts_to_apply=()))
|
|
o, s = Tensor.custom_kernel(Tensor.empty(1, dtype=dtypes.int), Tensor.zeros(1, dtype=dtypes.int).contiguous(),
|
|
Tensor([1, 2, 3, 4], dtype=dtypes.int), fxn=kernel)[:2]
|
|
Tensor.realize(o, s)
|
|
self.assertEqual(s.item(), 12123434)
|
|
|
|
def test_range_merge_keeps_loop_order(self):
|
|
# loops nest by axis id whatever order END lists them, so x is outer and the digits of S are A[0], A[2], A[1], A[3]
|
|
def kernel(S:UOp, A:UOp) -> UOp:
|
|
x, y = UOp.range(2, 0, AxisType.LOOP), UOp.range(2, 1, AxisType.LOOP)
|
|
return (cell:=S.after(x, y)[0]).store(cell*10+A[y*2+x]).end(y, x).sink(arg=KernelInfo(opts_to_apply=()))
|
|
s = Tensor.custom_kernel(Tensor.zeros(1, dtype=dtypes.int).contiguous(), Tensor([1, 2, 3, 4], dtype=dtypes.int), fxn=kernel)[0]
|
|
self.assertEqual(s.item(), 1324)
|
|
|
|
def test_range_merge_then_shrink(self):
|
|
# r0<2 shrinks the merged range of 8 to 4, which is the same UOp as r0, so it isn't shrunk again to 2
|
|
def kernel(C:UOp) -> UOp:
|
|
r0, r1 = UOp.range(4, 0, AxisType.LOOP), UOp.range(2, 1, AxisType.LOOP)
|
|
return (cell:=C[(r0*2+r1).valid(r0<2)]).store(cell+1).end(r0, r1).sink(arg=KernelInfo(opts_to_apply=()))
|
|
self.assertEqual(Tensor.zeros(8, dtype=dtypes.int).contiguous().custom_kernel(fxn=kernel)[0].tolist(), [1, 1, 1, 1, 0, 0, 0, 0])
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "test requires locals")
|
|
def test_group_reduce_split_range(self):
|
|
# j%2 splits j into two ranges, both are still LOCAL
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
i, j = UOp.range(4, 0), UOp.range(8, 1, AxisType.LOCAL)
|
|
return C[i].store((A[i, j] * (j%2).cast(A.dtype)).reduce(j, arg=Ops.ADD)).end(i).sink(arg=KernelInfo(opts_to_apply=()))
|
|
a = Tensor.arange(32).reshape(4, 8).float().contiguous().realize()
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4), a, fxn=kernel)[0].tolist(), a[:, 1::2].sum(1).tolist())
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "test requires locals")
|
|
def test_nested_group_reduce(self):
|
|
# the inner group's stage is indexed by the outer group's range, which is live at the inner reduce
|
|
def kernel(C:UOp, B:UOp) -> UOp:
|
|
i, g1, g2 = UOp.range(4, 0), UOp.range(4, 1, AxisType.LOCAL), UOp.range(8, 2, AxisType.LOCAL)
|
|
return C[i].store(B[i, g1, g2].reduce(g2, arg=Ops.ADD).reduce(g1, arg=Ops.ADD)).end(i).sink(arg=KernelInfo(opts_to_apply=()))
|
|
b = Tensor.arange(128).reshape(4, 4, 8).float().contiguous().realize()
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4), b, fxn=kernel)[0].tolist(), b.sum((1, 2)).tolist())
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "test requires locals")
|
|
def test_local_reduce(self):
|
|
# a reduce over a thread range combines across threads
|
|
a = Tensor.arange(32).reshape(4, 8).float().contiguous().realize()
|
|
for at in (AxisType.LOCAL, AxisType.WARP):
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
i, j = UOp.range(4, 0), UOp.range(8, 1, at)
|
|
return C[i].store(A[i, j].reduce(j, arg=Ops.ADD)).end(i).sink(arg=KernelInfo(opts_to_apply=()))
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4), a, fxn=kernel)[0].tolist(), a.sum(1).tolist())
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_shared, "LOCAL STAGE needs shared memory")
|
|
def test_stage_then_reduce(self):
|
|
# the STAGE ends j, so the accumulator of the reduce over jj is initialized before the jj loop, not inside it
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
i, j, jj = UOp.range(4, 0), UOp.range(8, 1, AxisType.LOOP), UOp.range(8, 2, AxisType.LOOP)
|
|
stage = (A[i, j] * 2).bufferize(j, arg=BufferizeOpts(None, AddrSpace.LOCAL))
|
|
return C[i].store(stage.index(jj).reduce(jj, arg=Ops.ADD)).end(i).sink(arg=KernelInfo(opts_to_apply=()))
|
|
a = Tensor.arange(32).reshape(4, 8).float().contiguous().realize()
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4), a, fxn=kernel)[0].tolist(), (a*2).sum(1).tolist())
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "test requires locals")
|
|
def test_stage_in_thread_range(self):
|
|
# the STAGE is inside the thread range i, so every thread stages its own row
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
i, j, jj = UOp.range(4, 0, AxisType.LOCAL), UOp.range(8, 1, AxisType.LOOP), UOp.range(8, 2, AxisType.LOOP)
|
|
stage = (A[i, j] * 2).bufferize(j, arg=BufferizeOpts(None, AddrSpace.LOCAL))
|
|
return C[i].store(stage.index(jj).reduce(jj, arg=Ops.ADD)).end(i).sink(arg=KernelInfo(opts_to_apply=()))
|
|
a = Tensor.arange(32).reshape(4, 8).float().contiguous().realize()
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4), a, fxn=kernel)[0].tolist(), (a*2).sum(1).tolist())
|
|
|
|
@unittest.skipIf(isinstance(Device[Device.DEFAULT].renderer, PTXRenderer), "PTX does not support dynamic register indexing")
|
|
def test_reg_stage_then_reduce(self):
|
|
# the REG buffer of the STAGE and the accumulator of the reduce are different buffers
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
i, j, jj = UOp.range(4, 0), UOp.range(8, 1, AxisType.LOOP), UOp.range(8, 2, AxisType.LOOP)
|
|
stage = (A[i, j] * 2).bufferize(j, arg=BufferizeOpts(None, AddrSpace.REG))
|
|
return C[i].store(stage.index(jj).reduce(jj, arg=Ops.ADD)).end(i).sink(arg=KernelInfo(opts_to_apply=()))
|
|
a = Tensor.arange(32).reshape(4, 8).float().contiguous().realize()
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4), a, fxn=kernel)[0].tolist(), (a*2).sum(1).tolist())
|
|
|
|
def test_reg_placeholder_then_reduce(self):
|
|
# the accumulator of the reduce does not reuse the slot of a REG placeholder in the kernel
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
i, j = UOp.range(4, 0), UOp.range(8, 1)
|
|
reg = UOp.placeholder((1,), dtypes.float, 0, addrspace=AddrSpace.REG)
|
|
reg = reg.after(i)[0].set(A[i, 0])
|
|
return C[i].store(A[i, j].reduce(j, arg=Ops.ADD) + reg[0]).end(i).sink(arg=KernelInfo(opts_to_apply=()))
|
|
a = Tensor.arange(32).reshape(4, 8).float().contiguous().realize()
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4), a, fxn=kernel)[0].tolist(), (a.sum(1) + a[:, 0]).tolist())
|
|
|
|
def test_split_range_id_free_of_loop(self):
|
|
# the UPCAST range minted by the split gets a fresh id, the while loop's id 1 is taken
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
r, l = UOp.range(4, 0), UOp.loop(1)
|
|
cnt = UOp.placeholder((1,), dtypes.int, slot=0, addrspace=AddrSpace.REG)
|
|
cnt = cnt.after(r)[0].set(0)
|
|
cnt = cnt.after(cnt[0].store(nxt:=cnt.after(l)[0] + 1).backedge(l, nxt < 3))
|
|
return C[r].set(A[r] + cnt[0].cast(C.dtype), end=r).sink(arg=KernelInfo(opts_to_apply=(Opt(OptOps.SPLIT, 0, (2, AxisType.UPCAST)),)))
|
|
a = Tensor([1., 2, 3, 4])
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4), a, fxn=kernel)[0].tolist(), [4., 5, 6, 7])
|
|
|
|
def test_range_bound_after_outer_range(self):
|
|
def kernel(C:UOp) -> UOp:
|
|
i = UOp.range(3, 0, AxisType.LOOP)
|
|
j = UOp.range(UOp.const(2, dtypes.weakint).after(i), 1, AxisType.LOOP)
|
|
return C[0].set(C.after(i, j)[0] + 1, end=(j, i)).sink(arg=KernelInfo())
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.zeros(1, dtype=dtypes.int).contiguous(), fxn=kernel)[0].tolist(), [6])
|
|
|
|
def test_gemm_multi(self):
|
|
devs = ("CPU:0", "CPU:1")
|
|
N = 16
|
|
a = Tensor.randn(N, N).shard_(devs, axis=0)
|
|
b = Tensor.randn(N, N).to(devs)
|
|
c = Tensor(Tensor.empty(N//2, N, device=devs).uop.unshard(0), device=devs)
|
|
tst = Tensor.custom_kernel(c, a, b, fxn=custom_gemm)[0]
|
|
self.assertTrue(tst.allclose(a@b, atol=1e-3).item())
|
|
|
|
def test_gemm_backward_custom(self): self.test_gemm_backward(True)
|
|
def test_gemm_backward(self, custom_backward_gemm=False):
|
|
N = 4
|
|
a_rand = Tensor.randn(N, 8)
|
|
b_rand = Tensor.randn(8, N)
|
|
Tensor.realize(a_rand, b_rand)
|
|
|
|
a, b = Tensor(a_rand.numpy()), Tensor(b_rand.numpy())
|
|
c = Tensor.empty(N, N)
|
|
tst = Tensor.custom_kernel(c, a, b, fxn=custom_gemm, grad_fxn=backward_gemm_custom if custom_backward_gemm else backward_gemm)[0]
|
|
tst.sum().backward()
|
|
grad_a, grad_b = a.grad, b.grad
|
|
Tensor.realize(tst, grad_a, grad_b)
|
|
|
|
a, b = Tensor(a_rand.numpy()), Tensor(b_rand.numpy())
|
|
ref = (a@b)
|
|
ref.sum().backward()
|
|
real_grad_a, real_grad_b = a.grad, b.grad
|
|
Tensor.realize(ref, real_grad_a, real_grad_b)
|
|
|
|
self.assertTrue(tst.allclose(ref, atol=1e-3).item())
|
|
self.assertTrue(grad_a.allclose(real_grad_a, atol=1e-3).item())
|
|
self.assertTrue(grad_b.allclose(real_grad_b, atol=1e-3).item())
|
|
|
|
def test_simple_qkv(self):
|
|
N, d = 8, 4
|
|
Q = Tensor.randn(N, d)
|
|
K = Tensor.randn(N, d)
|
|
V = Tensor.randn(N, d)
|
|
O = Tensor.empty(N, d)
|
|
|
|
O_custom = Tensor.custom_kernel(O, Q, K, V, fxn=lambda o,q,k,v: simple_qkv_kernel(o,q,k,v))[0]
|
|
O_ref = ((Q @ K.T) / (d ** 0.5)) @ V
|
|
|
|
Tensor.realize(O_custom, O_ref)
|
|
self.assertTrue(O_custom.allclose(O_ref, atol=1e-3).item())
|
|
|
|
def test_simple_reshape(self):
|
|
a = Tensor.ones(2,3,4).realize()
|
|
b = Tensor.custom_kernel(Tensor.empty_like(a), a, fxn=custom_add_one_kernel)[0]
|
|
b2 = b.reshape(2,12)
|
|
c = Tensor.custom_kernel(Tensor.empty_like(b2), b2, fxn=custom_add_one_kernel)[0]
|
|
GlobalCounters.reset()
|
|
c.realize()
|
|
assert all(i == 3. for i in c.flatten().tolist()), f"all 3 {c.tolist()}"
|
|
assert_kernel_count(2)
|
|
|
|
def test_multi_invalids_custom_kernel_no_copy(self):
|
|
devs = ("CPU:0", "CPU:1")
|
|
a = Tensor.ones(4, 4).shard(devs, axis=0).realize()
|
|
c = Tensor(Tensor.invalids(2, 4, dtype=dtypes.float, device=devs).uop.unshard(0), device=devs)
|
|
c = Tensor.custom_kernel(c, a, fxn=custom_add_one_kernel)[0]
|
|
GlobalCounters.reset()
|
|
c.realize()
|
|
assert_kernel_count(len(devs))
|
|
self.assertTrue((c == 2).all().item())
|
|
|
|
def test_partial_invalid_store_keeps_uncovered_reads(self):
|
|
x = Tensor([10., 20., 30., 40.]).realize()
|
|
after = x.uop.after(x.uop.shrink(((0, 2),)).store(Invalid))
|
|
self.assertEqual(Tensor(after).contiguous().tolist(), [10., 20., 30., 40.])
|
|
|
|
def test_multi_after_invalid_store_dep_removed(self):
|
|
x = Tensor.empty(4).uop
|
|
self.assertEqual(Tensor(x.after(x.store(5), x.store(Invalid))).tolist(), [5]*4)
|
|
|
|
def test_store_behind_value_after_kept(self):
|
|
def kernel(A:UOp, loop:bool) -> UOp:
|
|
x = UOp.const(1, dtypes.weakint).after(A[0].store(7))
|
|
return (A[(r:=UOp.range(x, 0))+1].store(9).end(r) if loop else A[x].store(9)).sink(arg=KernelInfo(opts_to_apply=()))
|
|
for loop in (False, True):
|
|
out = Tensor.custom_kernel(Tensor.zeros(2, dtype=dtypes.int).contiguous().realize(), fxn=functools.partial(kernel, loop=loop))[0]
|
|
self.assertEqual(out.tolist(), [7, 9])
|
|
|
|
def test_store_value_behind_coalesced_store(self):
|
|
def c(v): return UOp.const(v, dtypes.int)
|
|
for stores, want in ((lambda A: (A[0].store(7), A[1].store(A.after(A[0].store(7))[0]+5)), [7, 12, 5, 0]),
|
|
(lambda A: (A[1].store(c(1).after(A[0].store(7)) + A[2].load()),), [7, 6, 5, 0]),
|
|
(lambda A: (A[1].store(c(1).after(A[2].store(2))), A[3].store(c(3).after(A[0].store(10)))), [10, 1, 2, 3])):
|
|
out = Tensor([0, 0, 5, 0], dtype=dtypes.int).contiguous().custom_kernel(fxn=lambda A: UOp.sink(*stores(A), arg=KernelInfo(opts_to_apply=())))[0]
|
|
self.assertEqual(out.tolist(), want)
|
|
|
|
def test_deleted_store_keeps_deps(self):
|
|
for f in (lambda a, s: s[1].store(s[1].load()), lambda a, s: s[UOp.invalid()].store(5),
|
|
lambda a, s: s[1].store(UOp.invalid()), lambda a, s: a[UOp.invalid()].store(s[1].load())):
|
|
out = Tensor.custom_kernel(Tensor.zeros(3, dtype=dtypes.int).contiguous(),
|
|
fxn=lambda a: f(a, a.after(a[0].store(7))).sink(arg=KernelInfo(opts_to_apply=())))[0]
|
|
self.assertEqual(out.tolist(), [7, 0, 0])
|
|
|
|
def test_expand_view_invalid_assign_keeps_uncovered_reads(self):
|
|
x = Tensor([[10., 11., 12., 13.], [20., 21., 22., 23.], [30., 31., 32., 33.], [40., 41., 42., 43.]]).realize()
|
|
v = x[:1, :].expand(4, 4)
|
|
v.assign(Tensor.invalids(4, 4, dtype=dtypes.float))
|
|
self.assertEqual(v.contiguous().tolist(), [[10., 11., 12., 13.]]*4)
|
|
|
|
@unittest.expectedFailure
|
|
def test_gated_store_2d(self):
|
|
# TODO: broken now, the valid is dropped. the valid on one index of the 2d C gates the whole store, only j == 0 writes C[i, 0]
|
|
def kernel(C:UOp) -> UOp:
|
|
i, j = UOp.range(4, 0), UOp.range(4, 1, AxisType.LOOP)
|
|
return C[i.valid(j.eq(0)), 0].store((j+1).cast(C.dtype)).end(i, j).sink(arg=KernelInfo(opts_to_apply=()))
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4, 4), fxn=kernel)[0][:, 0].tolist(), [1.]*4)
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_shared, "LOCAL buffer needs shared memory")
|
|
@unittest.expectedFailure
|
|
def test_gated_local_store_2d(self):
|
|
# TODO: broken now, the valid is dropped. the valid on one index of the 2d LOCAL tmp gates the whole store, only j == 0 writes tmp[i, 0]
|
|
def kernel(C:UOp) -> UOp:
|
|
i, j = UOp.range(4, 0), UOp.range(4, 1, AxisType.LOOP)
|
|
tmp = UOp.placeholder((4, 4), dtypes.float, slot=0, addrspace=AddrSpace.LOCAL)
|
|
st = tmp[i.valid(j.eq(0)), 0].store((j+1).cast(dtypes.float)).end(j)
|
|
return C[i].store(tmp.after(st)[i, 0]).end(i).sink(arg=KernelInfo(opts_to_apply=()))
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4), fxn=kernel)[0].tolist(), [1.]*4)
|
|
|
|
@unittest.expectedFailure
|
|
def test_gated_load_2d(self):
|
|
# TODO: broken now, the valid is dropped. the valid on one index of the 2d A gates the whole load, it is 0 where j != 0
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
i, j = UOp.range(4, 0), UOp.range(4, 1)
|
|
return C[i, j].store(A[i.valid(j.eq(0)), 0]).end(i, j).sink(arg=KernelInfo(opts_to_apply=()))
|
|
a = Tensor.arange(16).reshape(4, 4).float().contiguous().realize()
|
|
self.assertEqual(Tensor.custom_kernel(Tensor.empty(4, 4), a, fxn=kernel)[0].tolist(), a[:, :1].pad(((0, 0), (0, 3))).tolist())
|
|
|
|
def test_shrink_range_keeps_ungated_index(self):
|
|
# q is ungated in C[q], so q keeps its 4 iterations after r shrinks to its gate r<2
|
|
q, r = UOp.range(4, 0, AxisType.LOOP), UOp.range(4, 1, AxisType.LOOP)
|
|
def kernel(C:UOp, D:UOp) -> UOp:
|
|
return UOp.group(C[q].store(1), D[(q*4+r).valid((q<2)&(r<2))].store(1)).end(q, r).sink(arg=KernelInfo(opts_to_apply=()))
|
|
c, d = Tensor.custom_kernel(Tensor.zeros(4, dtype=dtypes.int).contiguous(), Tensor.zeros(16, dtype=dtypes.int).contiguous(), fxn=kernel)[:2]
|
|
Tensor.realize(c, d)
|
|
self.assertEqual(c.tolist(), [1, 1, 1, 1])
|
|
self.assertEqual(d.tolist(), [1, 1, 0, 0, 1, 1, 0, 0] + [0]*8)
|
|
|
|
def test_shrink_range_keeps_ungated_store(self):
|
|
# C[0] stores r every iteration, so r keeps its 4 iterations though D gates it to r<2
|
|
r = UOp.range(4, 0, AxisType.LOOP)
|
|
def kernel(C:UOp, D:UOp) -> UOp:
|
|
return UOp.group(C[0].store(r.cast(dtypes.int)), D[r.valid(r<2)].store(1)).end(r).sink(arg=KernelInfo(opts_to_apply=()))
|
|
c, d = Tensor.custom_kernel(Tensor.zeros(1, dtype=dtypes.int).contiguous(), Tensor.zeros(4, dtype=dtypes.int).contiguous(), fxn=kernel)[:2]
|
|
Tensor.realize(c, d)
|
|
self.assertEqual((c.tolist(), d.tolist()), ([3], [1, 1, 0, 0]))
|
|
|
|
def test_shrink_range_keeps_store_gated_otherwise(self):
|
|
# C[0] is gated by r>=2, not r<c, so r keeps its 4 iterations though D gates it to r<2
|
|
r = UOp.range(4, 0, AxisType.LOOP)
|
|
def kernel(C:UOp, D:UOp) -> UOp:
|
|
return UOp.group(C[UOp.const(0).valid(r>=2)].store(r.cast(dtypes.int)), D[r.valid(r<2)].store(1)).end(r).sink(arg=KernelInfo(opts_to_apply=()))
|
|
c, d = Tensor.custom_kernel(Tensor.zeros(1, dtype=dtypes.int).contiguous(), Tensor.zeros(4, dtype=dtypes.int).contiguous(), fxn=kernel)[:2]
|
|
Tensor.realize(c, d)
|
|
self.assertEqual((c.tolist(), d.tolist()), ([3], [1, 1, 0, 0]))
|
|
|
|
def test_shrink_range_keeps_symbolic_end(self):
|
|
# D gates r to r<4, but r only runs to n=2
|
|
def kernel(D:UOp, N:UOp) -> UOp:
|
|
r = UOp.range(N, 0, AxisType.LOOP)
|
|
return D[r.valid(r<4)].store(1).end(r).sink(arg=KernelInfo(opts_to_apply=()))
|
|
d = Tensor.custom_kernel(Tensor.zeros(8, dtype=dtypes.int).contiguous(), Tensor(Variable("n", 1, 8, dtypes.int).bind(2)), fxn=kernel)[0]
|
|
self.assertEqual(d.tolist(), [1, 1, 0, 0, 0, 0, 0, 0])
|
|
|
|
@unittest.skipIf(Device.DEFAULT == "WEBGPU", "kernel timing not supported")
|
|
def test_invalids_into_custom_kernel_with_beam(self):
|
|
a = Tensor.full((4, 4), 3.).contiguous()
|
|
b = Tensor.full((4, 4), 2.).contiguous()
|
|
Tensor.realize(a, b)
|
|
with Context(BEAM=1, IGNORE_BEAM_CACHE=1):
|
|
out = Tensor.invalids(*a.shape, dtype=a.dtype)
|
|
out, *_ = Tensor.custom_kernel(out, a, b, fxn=custom_elementwise_add_kernel)
|
|
result = out.flatten().tolist()
|
|
self.assertTrue(all(x == 5 for x in result), f"expected all 5.0, got {result}")
|
|
|
|
@unittest.skip("what are anonymous buffers?")
|
|
def test_anonymous_buffers_in_function(self):
|
|
"""Test that custom kernels with anonymous output buffers work inside @function."""
|
|
a = Tensor.full((4, 4), 3.).contiguous()
|
|
b = Tensor.full((4, 4), 2.).contiguous()
|
|
Tensor.realize(a, b)
|
|
|
|
def custom_add_with_tmp(o1:UOp, o2:UOp, A:UOp, B:UOp) -> UOp:
|
|
o1,o2,A,B = o1.flatten(), o2.flatten(), A.flatten(), B.flatten()
|
|
i = UOp.range(o1.numel(), 0)
|
|
store_o1 = o1[i].store(A[i]+B[i])
|
|
store_o2 = o2[i].store(A[i]+B[i]+2)
|
|
return UOp.group(store_o1, store_o2).end(i).sink(arg=KernelInfo(name=f"add_with_tmp_{o1.numel()}")).simplify()
|
|
|
|
from tinygrad import function
|
|
@function(precompile=True)
|
|
def run(x:Tensor, w:Tensor) -> Tensor:
|
|
out = Tensor.invalids(*x.shape, dtype=x.dtype)
|
|
tmp = Tensor.invalids(*x.shape, dtype=x.dtype)
|
|
out, tmp = Tensor.custom_kernel(out, tmp, x, w, fxn=custom_add_with_tmp)[:2]
|
|
return out+tmp
|
|
|
|
result = run(a, b).flatten().tolist()
|
|
expected = (3+2)*2+2
|
|
assert all(x == expected for x in result), f"expected all {expected}, got {result}"
|
|
|
|
def test_custom_kernel_sched(self, use_custom=False):
|
|
x = Tensor.arange(32).reshape(8, 4).clone().realize()
|
|
y = Tensor.empty_like(x)
|
|
y = Tensor.custom_kernel(y, x, fxn=custom_add_one_kernel)[0]
|
|
if use_custom:
|
|
z = Tensor.empty_like(x)
|
|
z = Tensor.custom_kernel(z, y.T.T, fxn=custom_add_one_kernel)[0]
|
|
else: z = y.T.T+1
|
|
GlobalCounters.reset()
|
|
z.realize()
|
|
assert_kernel_count(2)
|
|
self.assertEqual(z.tolist(), x.add(2).tolist())
|
|
|
|
def test_custom_kernel_sched_copy(self): self.test_custom_kernel_sched(use_custom=True)
|
|
|
|
@unittest.skipIf(Device.DEFAULT == "CPU", "test needs to copy from CPU to another device")
|
|
def test_call_to_program(self):
|
|
t = Tensor([-1]).realize()
|
|
# render a source string
|
|
from tinygrad.codegen import do_to_program
|
|
source:str = do_to_program(custom_add_one_kernel(t.uop.param_like(0), t.uop.param_like(1)), Device[t.device].renderer).src[2].arg
|
|
assert isinstance(source, str)
|
|
# call to PROGRAM
|
|
def custom_source(out:UOp, inp:UOp) -> UOp:
|
|
sink = UOp.sink(out.base, inp.base, arg=KernelInfo("add_one_1"))
|
|
return UOp(Ops.PROGRAM, src=(sink, UOp(Ops.LINEAR, src=tuple(sink.toposort())), UOp(Ops.SOURCE, arg=source)))
|
|
cpu_src = Tensor([2], device="CPU").realize()
|
|
t = Tensor.custom_kernel(t, cpu_src.to(t.device), fxn=custom_source)[0]
|
|
cp = t.to("CPU").realize()
|
|
self.assertEqual(t.tolist(), [3])
|
|
self.assertEqual(cp.tolist(), [3])
|
|
|
|
def test_sliced_buffer_function(self):
|
|
x = Tensor.arange(32).reshape(8, 4).clone().realize()
|
|
from tinygrad import function
|
|
@function(precompile=True)
|
|
def run(x:Tensor) -> Tensor:
|
|
y = Tensor.invalids(*x.shape, dtype=x.dtype)
|
|
return Tensor.custom_kernel(y, x, fxn=custom_add_one_kernel)[0]
|
|
GlobalCounters.reset()
|
|
y = run(x[0]).realize()
|
|
# backends that support contiguous views don't launch extra kernels
|
|
assert_kernel_count(2 if x[0].uop.contiguous_view() is None else 1)
|
|
self.assertEqual(y.tolist(), [1, 2, 3, 4])
|
|
|
|
def test_inplace_transpose(self):
|
|
def custom_assign_row_max_kernel(A:UOp) -> UOp:
|
|
row = UOp.range(A.shape[0], 0)
|
|
col = UOp.range(A.shape[1], 1)
|
|
return A[row, col].store(A[row].max(axis=0)).end(col).end(row).sink(arg=KernelInfo(name=f"assign_row_max_{A.numel()}", opts_to_apply=()))
|
|
a = Tensor.arange(4).reshape(2, 2).clone().realize()
|
|
a = Tensor.custom_kernel(a.T, fxn=custom_assign_row_max_kernel)[0]
|
|
with self.assertRaises(AssertionError):
|
|
self.assertEqual(a.flatten().tolist(), [2, 2, 3, 3])
|
|
self.assertEqual(a.shape, (2, 2))
|
|
|
|
@unittest.skipUnless((isinstance(Device[Device.DEFAULT].renderer, (CStyleLanguage, LLVMRenderer)) or Device.DEFAULT == "PYTHON") and
|
|
Device.DEFAULT != "WEBGPU", "binary not supported on this backend")
|
|
def test_binary(self):
|
|
payload = bytes(range(256))
|
|
def view(x): return x.reshape((16, 16)).permute((1, 0)).reshape((256,))
|
|
def kernel(out:UOp):
|
|
i = UOp.range(len(payload), 0)
|
|
data = view(UOp(Ops.BINARY, arg=payload))
|
|
return out[i].store(data[i]).end(i).sink(arg=KernelInfo(name="binary", opts_to_apply=()))
|
|
self.assertEqual(Tensor.empty(len(payload), dtype=dtypes.uint8).custom_kernel(fxn=kernel)[0].tolist(),
|
|
view(Tensor(payload, dtype=dtypes.uint8)).tolist())
|
|
|
|
def test_one_lane_stack_runtime_index(self):
|
|
def kernel(A:UOp): return A[0].store(UOp.stack(UOp.const(42, dtypes.int)).index(A[1].load())).sink(arg=KernelInfo(opts_to_apply=()))
|
|
self.assertEqual(Tensor.zeros(2, dtype=dtypes.int).contiguous().custom_kernel(fxn=kernel)[0].tolist(), [42, 0])
|
|
|
|
@unittest.skipUnless((Device.DEFAULT == "CPU" and isinstance(Device[Device.DEFAULT].renderer, (CStyleLanguage, LLVMRenderer)))
|
|
or Device.DEFAULT == "AMD" and isinstance(Device[Device.DEFAULT].renderer, CStyleLanguage),
|
|
"calls in kernel not implemented in this renderer")
|
|
class TestCallInKernel(unittest.TestCase):
|
|
def test_simple(self):
|
|
N = getenv("N", 4)
|
|
|
|
@uopfunc
|
|
def add(out:UOp, x:UOp) -> UOp:
|
|
i = UOp.range(x.numel(), 0)
|
|
return out[i].store(x[i] + 1).end(i).sink()
|
|
|
|
@uopfunc
|
|
def sum_fn(out:UOp, x:UOp) -> UOp:
|
|
i = UOp.range(x.numel(), 0)
|
|
return out[0].store(x[i].reduce(i, arg=Ops.ADD)).sink()
|
|
|
|
def main(out:UOp, x:UOp) -> UOp:
|
|
tmp0, tmp1 = (UOp.alloc_like(x, addrspace=AddrSpace.REG) for _ in range(2))
|
|
call0 = add(tmp0, x)
|
|
call1 = add(tmp1, tmp0.after(call0))
|
|
return sum_fn(out, tmp1.after(call1)).sink(arg=KernelInfo(name="test_simple"))
|
|
|
|
x = Tensor.arange(N).clone().realize()
|
|
out = Tensor.custom_kernel(Tensor.empty_like(x), x, fxn=lambda out,x: add(out, x).sink(arg=KernelInfo(name="add_kernel")))[0]
|
|
out = Tensor.custom_kernel(Tensor.empty(1, dtype=x.dtype), out,
|
|
fxn=lambda out,x: sum_fn(out, x).sink(arg=KernelInfo(name="sum_kernel")))[0]
|
|
self.assertEqual(out.tolist(), [N*(N+1)//2])
|
|
out2 = Tensor.custom_kernel(Tensor.empty(1, dtype=x.dtype), x, fxn=main)[0]
|
|
self.assertEqual(out2.tolist(), [N*(N+3)//2])
|
|
|
|
def test_reduce_over_call_keeps_loop_order(self):
|
|
# a call can hide stores, so the reduce loops a, k, b in order, and the digits of S are the A[a*2+b] it visits
|
|
@uopfunc
|
|
def update(S:UOp, A:UOp) -> UOp: return S[0].store(S[0]*10+A[0]).sink()
|
|
def kernel(O:UOp, S:UOp, A:UOp) -> UOp:
|
|
a, k, b = [UOp.range(2, n, AxisType.LOOP) for n in range(3)]
|
|
return O[0].store(A[a*2+b].after(update(S.after(k), A[a*2+b])).reduce(a, k, b, arg=Ops.ADD)).sink(arg=KernelInfo(opts_to_apply=()))
|
|
o, s = Tensor.custom_kernel(Tensor.empty(1, dtype=dtypes.int), Tensor.zeros(1, dtype=dtypes.int).contiguous(),
|
|
Tensor([1, 2, 3, 4], dtype=dtypes.int), fxn=kernel)[:2]
|
|
Tensor.realize(o, s)
|
|
self.assertEqual(s.item(), 12123434)
|
|
|
|
def test_nested_call(self):
|
|
@uopfunc
|
|
def incr(out:UOp, A:UOp):
|
|
i = UOp.range(A.shape[0], 0)
|
|
return out[i].store(A[i]+1).end(i).sink()
|
|
|
|
@uopfunc
|
|
def square(out:UOp, A:UOp):
|
|
tmp0, tmp1 = (UOp.alloc_like(A, addrspace=AddrSpace.REG) for _ in range(2))
|
|
call0 = incr(tmp0, A)
|
|
call1 = incr(tmp1, A.after(call0))
|
|
assert call0.body is call1.body
|
|
i = UOp.range(A.shape[0], 1)
|
|
return out[i].store(tmp1.after(call1)[i]**2).end(i).sink()
|
|
|
|
def kernel(C:UOp, A:UOp):
|
|
tmp = UOp.alloc_like(A, addrspace=AddrSpace.REG)
|
|
call = square(tmp, A)
|
|
i = UOp.range(A.shape[0], 2)
|
|
return C[i].store(tmp.after(call)[i]+10).end(i).sink(arg=KernelInfo(name="nested_calls"))
|
|
|
|
a = Tensor.arange(4).clone().realize()
|
|
out = Tensor.custom_kernel(Tensor.empty_like(a), a, fxn=kernel)[0]
|
|
self.assertEqual(out.tolist(), [11, 14, 19, 26])
|
|
|
|
def test_call_loop_mini(self, opts=()):
|
|
@uopfunc
|
|
def mul(p:UOp, q:UOp): return p[0].store(q[0]*3).sink()
|
|
|
|
def kernel(C:UOp, A:UOp):
|
|
i = UOp.range(4, 0)
|
|
call = mul(C[i], A[i])
|
|
return call.end(i).sink(arg=KernelInfo(name="call_loop_mini", opts_to_apply=opts))
|
|
a = Tensor([1, 2, 3, 4], dtype=dtypes.int).realize()
|
|
out = Tensor.custom_kernel(Tensor.zeros(4, dtype=dtypes.int).clone().realize(), a, fxn=kernel)[0]
|
|
self.assertEqual(out.tolist(), [3, 6, 9, 12])
|
|
|
|
def test_call_scalar_arg(self):
|
|
@uopfunc
|
|
def add(out:UOp, src:UOp, val:UOp) -> UOp:
|
|
return out[0].store(src[0]+val).sink()
|
|
|
|
def kernel(out:UOp, src:UOp) -> UOp:
|
|
i = UOp.range(4, 0)
|
|
return add(out[i], src[i], i).end(i).sink(arg=KernelInfo(name="call_scalar_arg", opts_to_apply=()))
|
|
|
|
x = Tensor([1, 2, 3, 4], dtype=dtypes.int).realize()
|
|
self.assertEqual(Tensor.empty_like(x).custom_kernel(x, fxn=kernel)[0].tolist(), [1, 3, 5, 7])
|
|
|
|
def test_call_with_gated_store(self):
|
|
@uopfunc
|
|
def gated_store(out:UOp, idx:UOp):
|
|
i = idx[0]
|
|
return out[i.valid((i >= 0) & (i < out.shape[0]))].store(7).sink()
|
|
|
|
def kernel(C:UOp, A:UOp):
|
|
tmp = UOp.alloc_like(C, addrspace=AddrSpace.REG)
|
|
i = UOp.range(C.shape[0], 0)
|
|
init = tmp[i].store(0).end(i)
|
|
call = gated_store(tmp.after(init), A)
|
|
j = UOp.range(C.shape[0], 1)
|
|
return C[j].store(tmp.after(call)[j] + 1).end(j).sink(arg=KernelInfo(name="call_with_gated_store", opts_to_apply=()))
|
|
|
|
a = Tensor([2], dtype=dtypes.int).realize()
|
|
out = Tensor.custom_kernel(Tensor.zeros(4, dtype=dtypes.int).clone().realize(), a, fxn=kernel)[0]
|
|
self.assertEqual(out.tolist(), [1, 1, 8, 1])
|
|
|
|
def test_call_body_range_is_not_ours(self):
|
|
@uopfunc
|
|
def mul(out:UOp, A:UOp):
|
|
k = UOp.range(4, 0, AxisType.LOOP)
|
|
return out[k].store(A[k]*3).end(k).sink()
|
|
|
|
def kernel(C:UOp, A:UOp):
|
|
m, i, call = UOp.range(3, 1, AxisType.LOOP), UOp.range(4, 0, AxisType.LOOP), mul(C, A)
|
|
self.assertIn(i, call.body.toposort())
|
|
end_m = C.after(call)[m].store(A[m]).end(m)
|
|
return C.after(end_m)[i].store(A[i]+1).end(i).sink(arg=KernelInfo(name="call_body_range", opts_to_apply=()))
|
|
|
|
a = Tensor([1, 2, 3, 4], dtype=dtypes.int).realize()
|
|
out = Tensor.custom_kernel(Tensor.zeros(4, dtype=dtypes.int).clone().realize(), a, fxn=kernel)[0]
|
|
self.assertEqual(out.tolist(), [2, 3, 4, 5])
|
|
|
|
@unittest.expectedFailure
|
|
def test_call_loop_mini_opts(self): self.test_call_loop_mini(opts=None)
|
|
|
|
@unittest.expectedFailure
|
|
def test_call_loop_split(self): self.test_call_loop_mini((Opt(OptOps.SPLIT, 0, (4, AxisType.UPCAST)),))
|
|
|
|
@unittest.expectedFailure
|
|
def test_call_loop_pad(self):
|
|
@uopfunc
|
|
def add(p:UOp, q:UOp): return p[0].store(p[0]+q[0]).sink()
|
|
|
|
def kernel(C:UOp, A:UOp):
|
|
i = UOp.range(3, 0)
|
|
call = add(C[i], A[i])
|
|
return call.end(i).sink(arg=KernelInfo(name="call_loop_padto", opts_to_apply=(Opt(OptOps.PADTO, 0, 4),)))
|
|
|
|
a = Tensor([1, 2, 3, 4], dtype=dtypes.int).realize()
|
|
out = Tensor.custom_kernel(Tensor.zeros(4, dtype=dtypes.int).clone().realize(), a, fxn=kernel)[0]
|
|
self.assertEqual(out.tolist(), [1, 2, 3, 0])
|
|
|
|
def test_uopfunc(self):
|
|
@uopfunc
|
|
def axpy(out:UOp, x:UOp, a:int) -> UOp: # a is a trace-time constant: two bodies, two C functions
|
|
i = UOp.range(x.shape[0], 0, AxisType.LOOP)
|
|
return out[i].store(out[i] + x[i] * a).end(i).sink()
|
|
|
|
@uopfunc
|
|
def triple(out:UOp, x:UOp): return axpy(out, x, 3).sink()
|
|
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
C = C.after(triple(C.after(axpy(C, A, 2)), A))
|
|
i = UOp.range(A.shape[0], 1, AxisType.LOOP) # a loop after the calls
|
|
return C[i].store(C[i] + 1).end(i).sink(arg=KernelInfo(name="uopfunc", opts_to_apply=()))
|
|
|
|
a = Tensor([1, 2, 3, 4], dtype=dtypes.int).realize()
|
|
out = Tensor.custom_kernel(Tensor.zeros(4, dtype=dtypes.int).contiguous().realize(), a, fxn=kernel)[0]
|
|
self.assertEqual(out.tolist(), [6, 11, 16, 21])
|
|
|
|
def test_copy_offset_slices(self):
|
|
@uopfunc
|
|
def copy_block(dst:UOp, src:UOp) -> UOp:
|
|
i = UOp.range(4, 0, AxisType.UPCAST)
|
|
return dst[i].store(src[i]).end(i).sink()
|
|
|
|
def kernel(dst:UOp, src:UOp) -> UOp:
|
|
return copy_block(dst[1:5], src[1:5]).sink(arg=KernelInfo(opts_to_apply=()))
|
|
|
|
src = Tensor([0., 1., 2., 3., 4.]).realize()
|
|
dst = Tensor([-1.] * 6).realize()
|
|
out = dst.custom_kernel(src, fxn=kernel)[0]
|
|
self.assertEqual(out.tolist(), [-1., 1., 2., 3., 4., -1.])
|
|
|
|
@unittest.skipIf(Device.DEFAULT == "CPU", "test needs to copy from CPU to another device")
|
|
def test_call_to_copy(self):
|
|
@uopfunc
|
|
def copy(out:UOp, A:UOp):
|
|
i = UOp.range(out.shape[0], 0, AxisType.LOOP)
|
|
return out[i].store(A[i]).end(i).sink()
|
|
|
|
def forward(out1:UOp, out2:UOp, A:UOp):
|
|
call = copy(out1, A)
|
|
i = UOp.range(out2.shape[0], 0, AxisType.LOOP)
|
|
inline = out2[i].store(A[i]).end(i).sink()
|
|
return UOp.group(call, inline).sink(arg=KernelInfo(name="forward", opts_to_apply=()))
|
|
|
|
x = Tensor([1, 2, 3, 4], device="CPU").realize().to(Device.DEFAULT)
|
|
y, z = x.empty_like(), x.empty_like()
|
|
y, z, _ = Tensor.custom_kernel(y, z, x, fxn=forward)
|
|
y, z = y.to("CPU"), z.to("CPU")
|
|
Tensor.realize(y, z)
|
|
|
|
self.assertEqual(y.tolist(), z.tolist())
|
|
self.assertEqual(x.tolist(), z.tolist())
|
|
|
|
@unittest.skipIf(Device[Device.DEFAULT].renderer.inline_prefix is not None, "backend inlines calls instead of linking")
|
|
def test_linker(self):
|
|
@uopfunc
|
|
def plus2(out:UOp, A:UOp): return out[0].store(A[0]+2).sink()
|
|
|
|
@uopfunc
|
|
def copy(out:UOp, A:UOp): return out[0].store(A[0]).sink()
|
|
|
|
def kernel(A:UOp, extra=False):
|
|
A = A.flatten()
|
|
i = UOp.range(A.shape[0], 0)
|
|
tmp = UOp.alloc_like(A, addrspace=AddrSpace.REG)
|
|
if extra: tmp = tmp.after(copy(tmp[i], A[i]))
|
|
return A[i].store(tmp[i].after(plus2(tmp[i], A[i]))).end(i).sink(arg=KernelInfo(name="test_kernel", opts_to_apply=()))
|
|
|
|
x = Tensor.custom_kernel(Tensor.arange(4).clone().realize(), fxn=kernel)[0]
|
|
run_linear(lin:=x.schedule_linear())
|
|
|
|
prg = to_program(lin.src[0].body, Device[Device.DEFAULT].renderer)
|
|
assert "+2" not in prg.src[2].arg, f"found callee body in caller kernel {prg.src[2].arg}"
|
|
call = next(u for u in prg.src[1].src if u.op is Ops.CALL)
|
|
first_binary = call.body.src[3]
|
|
call_runtime = get_runtime(Device.DEFAULT, call.body)
|
|
self.assertListEqual(x.tolist(), Tensor.arange(4).add(2).tolist())
|
|
|
|
# launch a different caller with the same callee
|
|
x = Tensor.custom_kernel(Tensor.arange(5).clone().realize(), fxn=lambda A: kernel(A, extra=True))[0]
|
|
run_linear(lin:=x.schedule_linear())
|
|
prg2 = to_program(lin.src[0].body, Device[Device.DEFAULT].renderer).src[1]
|
|
self.assertIs(call.body, next(u.body for u in prg2.src if u.op is Ops.CALL and u.arg.name == "plus2"))
|
|
self.assertIs(first_binary, next(u.body.src[3] for u in prg2.src if u.op is Ops.CALL and u.arg.name == "plus2"))
|
|
self.assertIs(call_runtime, get_runtime(Device.DEFAULT, call.body))
|
|
self.assertListEqual(x.tolist(), Tensor.arange(5).add(2).tolist())
|
|
|
|
class TestCustomKernelInput(unittest.TestCase):
|
|
def _test_mop(self, mop_fxn, max_kernels):
|
|
# default: input is BUFFER
|
|
x = mop_fxn(Tensor.arange(32).clone().realize())
|
|
y = Tensor.custom_kernel(Tensor.empty_like(x), x, fxn=custom_add_one_kernel)[0]
|
|
GlobalCounters.reset()
|
|
y.realize()
|
|
assert_kernel_count(max_kernels)
|
|
self.assertEqual(y.tolist(), x.add(1).tolist())
|
|
# same test with @function, input is PARAM
|
|
from tinygrad import function
|
|
x0 = Tensor.arange(32).clone().realize()
|
|
@function(precompile=True)
|
|
def run(a:Tensor) -> Tensor:
|
|
xv = mop_fxn(a)
|
|
y = Tensor.invalids(*xv.shape, dtype=xv.dtype, device=a.device)
|
|
return Tensor.custom_kernel(y, xv, fxn=custom_add_one_kernel)[0]
|
|
GlobalCounters.reset()
|
|
y = run(x0).realize()
|
|
assert_kernel_count(max_kernels)
|
|
self.assertEqual(y.tolist(), mop_fxn(x0).add(1).tolist())
|
|
|
|
def test_reshape(self): self._test_mop(lambda x: x.reshape(16, 2), max_kernels=1)
|
|
def test_permute(self): self._test_mop(lambda x: x.reshape(4, 8).T, max_kernels=2)
|
|
def test_double_permute(self): self._test_mop(lambda x: x.reshape(4, 8).T.T, max_kernels=1)
|
|
def test_shrink(self): self._test_mop(lambda x: x[:4], max_kernels=1)
|
|
def test_pad(self): self._test_mop(lambda x: x[:4].pad(((0, 4),)), max_kernels=2)
|
|
def test_flip(self): self._test_mop(lambda x: x.flip(0), max_kernels=2)
|
|
def test_offset_shrink(self): self._test_mop(lambda x: x[4:8], max_kernels=2)
|
|
def test_2d_shrink(self): self._test_mop(lambda x: x.reshape(4, 8)[:, 2:6], max_kernels=2)
|
|
def test_expand(self): self._test_mop(lambda x: x.reshape(16, 2)[:, :1].expand(16, 2), max_kernels=2)
|
|
|
|
class TestUnshardIndex(unittest.TestCase):
|
|
"""Regression tests for INDEX on UNSHARD (fragment) resolution in schedule/multi.py.
|
|
|
|
A fragment is a per-thread REG buffer wrapped in UNSHARD over LOCAL thread ranges.
|
|
index_multi must resolve an INDEX on the UNSHARD view into an INDEX on the per-thread
|
|
shard. Two ownership patterns must work:
|
|
contiguous: idx = rng*shard_sz + local (thread rng owns [rng*shard_sz, ...))
|
|
strided: idx = rng + ir*shard_sz (thread rng owns {rng, rng+shard_sz, ...})
|
|
"""
|
|
def _run(self, kernel, shape=(8, 8)):
|
|
c = Tensor.empty(*shape)
|
|
out = Tensor.custom_kernel(c, fxn=kernel)[0]
|
|
try: return out.numpy()
|
|
except RuntimeError as e:
|
|
if isinstance(Device[Device.DEFAULT].renderer, PTXRenderer) and "dynamic register indexing" in str(e):
|
|
self.skipTest("PTX does not support dynamic register indexing")
|
|
raise
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "fragment tests need LOCAL ranges")
|
|
def test_contiguous_fragment_index(self):
|
|
# thread ty owns rows [ty*8, ty*8+8) of a 64-row fragment -- contiguous ownership.
|
|
# This is the pre-existing case that index_multi always handled.
|
|
def kernel(C:UOp) -> UOp:
|
|
ty = UOp.range(8, 0, AxisType.LOCAL)
|
|
ir = UOp.range(8, 1, AxisType.LOOP)
|
|
j = UOp.range(8, 2, AxisType.LOOP)
|
|
# 8x8 fragment, 8 threads -> 64x8 full tile. thread ty owns rows [ty*8, ty*8+8).
|
|
frag = UOp.placeholder((8, 8), dtypes.float32, 0, AddrSpace.REG).unshard((0,), (ty,))
|
|
return C[ty*8 + ir, j].store(frag[ty*8 + ir, j]).end(j, ir, ty).sink(arg=KernelInfo(name="contig_frag"))
|
|
out = self._run(kernel, (64, 8))
|
|
assert out.shape == (64, 8)
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "fragment tests need LOCAL ranges")
|
|
def test_strided_fragment_index(self):
|
|
# thread ty owns rows {ty, ty+8, ty+16, ty+24, ..., ty+56} of a 64-row fragment --
|
|
# strided ownership. idx = ty + ir*8 where shard_sz=8 (8 threads, shard rows=8).
|
|
# The contiguous check (idx - rng*shard_sz) fails; the strided check
|
|
# (idx-rng) % shard_sz == 0 must succeed. This is the pattern the index_multi fix adds.
|
|
def kernel(C:UOp) -> UOp:
|
|
ty = UOp.range(8, 0, AxisType.LOCAL)
|
|
ir = UOp.range(8, 1, AxisType.LOOP)
|
|
j = UOp.range(8, 2, AxisType.LOOP)
|
|
# 8x8 fragment, 8 threads -> 64x8 full tile. thread ty owns rows {ty, ty+8, ..., ty+56}.
|
|
frag = UOp.placeholder((8, 8), dtypes.float32, 0, AddrSpace.REG).unshard((0,), (ty,))
|
|
return C[ty + ir*8, j].store(frag[ty + ir*8, j]).end(j, ir, ty).sink(arg=KernelInfo(name="strided_frag"))
|
|
out = self._run(kernel, (64, 8))
|
|
assert out.shape == (64, 8)
|
|
|
|
def test_fragment_index_cannot_shard(self):
|
|
# thread ty indexing rows [ty, ty+8) overlaps with other threads' rows -- this matches neither
|
|
# the contiguous nor the strided ownership pattern, so index_multi must raise.
|
|
def kernel(C:UOp) -> UOp:
|
|
ty = UOp.range(8, 0, AxisType.LOCAL)
|
|
ir = UOp.range(8, 1, AxisType.LOOP)
|
|
j = UOp.range(8, 2, AxisType.LOOP)
|
|
frag = UOp.placeholder((8, 8), dtypes.float32, 0, AddrSpace.REG).unshard((0,), (ty,))
|
|
return C[ty + ir, j].store(frag[ty + ir, j]).end(j, ir, ty).sink(arg=KernelInfo(name="bad_frag"))
|
|
with self.assertRaisesRegex(RuntimeError, "cannot shard index"):
|
|
self._run(kernel, (64, 8))
|
|
|
|
def _run_fragment_kernel(testcase, kernel, out_shape, inputs=()):
|
|
c = Tensor.empty(*out_shape)
|
|
out = Tensor.custom_kernel(c, *inputs, fxn=kernel)[0]
|
|
try: return out.numpy()
|
|
except RuntimeError as e:
|
|
if isinstance(Device[Device.DEFAULT].renderer, PTXRenderer) and "dynamic register indexing" in str(e):
|
|
testcase.skipTest("PTX does not support dynamic register indexing")
|
|
raise
|
|
|
|
class TestUnshardAlu(unittest.TestCase):
|
|
"""Tests for ALU on (fragment) UNSHARD values in schedule/multi.py's alu_multi.
|
|
|
|
An ALU with UNSHARD srcs lowers to per-shard ops when every src is one of:
|
|
same sharding: peel the UNSHARD, keep the layout
|
|
scalar: broadcast to every shard
|
|
whole unsharded same-shape value: takes its per-shard sub-view (shard_subview)
|
|
"""
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "fragment tests need LOCAL ranges")
|
|
def test_alu_scalar_broadcast(self):
|
|
# scalar srcs broadcast to every shard: frag*2.0 where frag is 1.5 per thread -> 3.0 everywhere
|
|
def kernel(C:UOp) -> UOp:
|
|
ty = UOp.range(8, 0, AxisType.LOCAL)
|
|
# 8 values per thread, 8 threads -> 64-value full view
|
|
frag = UOp.placeholder((8,), dtypes.float32, 0, AddrSpace.LOCAL).unshard((0,), (ty,))
|
|
v = frag.after(frag.store(1.5)) * 2.0
|
|
return C.store(v).end(ty).sink(arg=KernelInfo(name="alu_scalar", opts_to_apply=()))
|
|
out = _run_fragment_kernel(self, kernel, (64,))
|
|
np.testing.assert_allclose(out, 3.0)
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "fragment tests need LOCAL ranges")
|
|
def test_alu_whole_value_subview(self):
|
|
# UNSHARD + whole unsharded same-shape value: each shard adds its own sub-view of A.
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
ty = UOp.range(8, 0, AxisType.LOCAL)
|
|
frag = UOp.placeholder((8,), dtypes.float32, 0, AddrSpace.LOCAL).unshard((0,), (ty,))
|
|
v = frag.after(frag.store(0.0)) + A
|
|
return C.store(v).end(ty).sink(arg=KernelInfo(name="alu_subview", opts_to_apply=()))
|
|
a = Tensor(np.arange(64, dtype=np.float32))
|
|
out = _run_fragment_kernel(self, kernel, (64,), inputs=(a,))
|
|
np.testing.assert_allclose(out, a.numpy(), atol=1e-4)
|
|
|
|
class TestUnshardStore(unittest.TestCase):
|
|
"""Tests for STORE of a sharded value into an unsharded dest (store_value_multi in schedule/multi.py).
|
|
|
|
Every shard stores its value into its own contiguous sub-view of the dest, one SHRINK per sharded axis.
|
|
"""
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "fragment tests need LOCAL ranges")
|
|
def test_store_unshard_value(self):
|
|
# single-axis: 8 threads each own 8 values of the 64-value output tile
|
|
def kernel(C:UOp) -> UOp:
|
|
ty = UOp.range(8, 0, AxisType.LOCAL)
|
|
frag = UOp.placeholder((8,), dtypes.float32, 0, AddrSpace.LOCAL).unshard((0,), (ty,))
|
|
v = frag.after(frag.store(0.0)) + 2.5
|
|
return C.store(v).end(ty).sink(arg=KernelInfo(name="store_unshard", opts_to_apply=()))
|
|
out = _run_fragment_kernel(self, kernel, (64,))
|
|
np.testing.assert_allclose(out, 2.5)
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "fragment tests need LOCAL ranges")
|
|
def test_store_unshard_value_2axis(self):
|
|
# two sharded axes (the gemm fragment layout): thread (ty, tx) owns the (2, 1, 1, 2) sub-view of the
|
|
# (2, 4, 2, 2) output tile; the store must SHRINK dest on both sharded axes
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
ty = UOp.range(4, 0, AxisType.LOCAL)
|
|
tx = UOp.range(2, 1, AxisType.LOCAL)
|
|
frag = UOp.placeholder((2, 1, 1, 2), dtypes.float32, 0, AddrSpace.REG).unshard((1, 2), (ty, tx))
|
|
v = frag.after(frag.store(0.0)) + A
|
|
return C.store(v).end(tx, ty).sink(arg=KernelInfo(name="store_unshard_2axis", opts_to_apply=()))
|
|
a = Tensor(np.arange(32, dtype=np.float32).reshape(2, 4, 2, 2))
|
|
out = _run_fragment_kernel(self, kernel, (2, 4, 2, 2), inputs=(a,))
|
|
np.testing.assert_allclose(out, a.numpy(), atol=1e-4)
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "fragment tests need LOCAL ranges")
|
|
def test_store_unshard_value_2axis_reshape(self):
|
|
# the reshape keeps both sharded axes, each with its own shard count (4 and 2)
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
ty = UOp.range(4, 0, AxisType.LOCAL)
|
|
tx = UOp.range(2, 1, AxisType.LOCAL)
|
|
frag = UOp.placeholder((2, 1, 1, 2), dtypes.float32, 0, AddrSpace.REG).unshard((1, 2), (ty, tx))
|
|
v = (frag.after(frag.store(0.0)) + A).reshape(2, 4, 2, 2, 1)
|
|
return C.store(v).end(tx, ty).sink(arg=KernelInfo(name="store_unshard_2axis_reshape", opts_to_apply=()))
|
|
a = Tensor(np.arange(32, dtype=np.float32).reshape(2, 4, 2, 2))
|
|
out = _run_fragment_kernel(self, kernel, (2, 4, 2, 2, 1), inputs=(a,))
|
|
np.testing.assert_allclose(out, a.numpy().reshape(2, 4, 2, 2, 1), atol=1e-4)
|
|
|
|
def _test_store_load_fragment(self, addrspace:AddrSpace):
|
|
# thread ty stores A[ty*8:ty*8+8] into its fragment, then reads it back into the same slice of C
|
|
def kernel(C:UOp, A:UOp) -> UOp:
|
|
ty = UOp.range(8, 0, AxisType.LOCAL)
|
|
frag = UOp.placeholder((8,), dtypes.float32, 0, addrspace).unshard((0,), (ty,))
|
|
return C.store(frag.after(frag.store(A))).end(ty).sink(arg=KernelInfo(name="store_load_fragment", opts_to_apply=()))
|
|
a = Tensor.arange(64, dtype=dtypes.float32)
|
|
out = _run_fragment_kernel(self, kernel, (64,), inputs=(a,))
|
|
np.testing.assert_equal(out, a.numpy())
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "fragment tests need LOCAL ranges")
|
|
def test_store_load_reg_fragment(self): self._test_store_load_fragment(AddrSpace.REG)
|
|
|
|
@unittest.skipIf(not Device[Device.DEFAULT].renderer.has_local, "fragment tests need LOCAL ranges")
|
|
@unittest.expectedFailure # TODO: should not fail silently
|
|
def test_store_load_local_fragment(self): self._test_store_load_fragment(AddrSpace.LOCAL)
|
|
|
|
class TestUOpReduce(unittest.TestCase):
|
|
def test_uop_sum(self):
|
|
a = Tensor([1.0, 2, 3, 4, 5])
|
|
self.assertAlmostEqual(Tensor(a.uop.sum(axis=0)).item(), 15.0)
|
|
|
|
def test_uop_sum_2d(self):
|
|
a = Tensor.arange(6).reshape(2, 3).float()
|
|
result = Tensor(a.uop.sum(axis=1)).numpy()
|
|
assert result[0] == 3 and result[1] == 12
|
|
|
|
def test_uop_sum_all(self):
|
|
a = Tensor.arange(6).reshape(2, 3).float()
|
|
self.assertAlmostEqual(Tensor(a.uop.sum()).item(), 15.0)
|
|
|
|
def test_uop_sum_negative_axis(self):
|
|
a = Tensor.arange(6).reshape(2, 3).float()
|
|
result = Tensor(a.uop.sum(axis=-1)).numpy()
|
|
assert result[0] == 3 and result[1] == 12
|
|
|
|
def test_uop_sum_multi_axis(self):
|
|
a = Tensor.arange(24).reshape(2, 3, 4).float()
|
|
ref = a.sum(axis=(0, 2)).numpy()
|
|
result = Tensor(a.uop.sum(axis=(0, 2))).numpy()
|
|
for i in range(3): self.assertAlmostEqual(result[i], ref[i])
|
|
|
|
def test_uop_sum_dtype(self):
|
|
a = Tensor([1.0, 2, 3], dtype=dtypes.float16)
|
|
result = Tensor(a.uop.sum(axis=0, dtype=dtypes.float32))
|
|
self.assertEqual(result.dtype, dtypes.float)
|
|
self.assertAlmostEqual(result.item(), 6.0, places=2)
|
|
|
|
def test_uop_prod(self):
|
|
a = Tensor([1.0, 2, 3, 4, 5])
|
|
self.assertAlmostEqual(Tensor(a.uop.prod(axis=0)).item(), 120.0)
|
|
|
|
def test_uop_max(self):
|
|
a = Tensor([1.0, 5, 3, 2, 4])
|
|
self.assertAlmostEqual(Tensor(a.uop.max(axis=0)).item(), 5.0)
|
|
|
|
def test_uop_max_2d(self):
|
|
a = Tensor([[1, 5, 3], [4, 2, 6]]).float()
|
|
result = Tensor(a.uop.max(axis=0)).numpy()
|
|
assert result[0] == 4 and result[1] == 5 and result[2] == 6
|
|
|
|
def test_uop_std(self):
|
|
a = Tensor([2.0, 4, 4, 4, 5, 5, 7, 9])
|
|
self.assertAlmostEqual(Tensor(a.uop.std()).item(), a.std().item(), places=5)
|
|
|
|
class TestUOpWhere(unittest.TestCase):
|
|
def test_uop_where_both_const(self):
|
|
cond = Tensor([True, False, True])
|
|
result = Tensor(cond.uop.where(1, 0))
|
|
self.assertEqual(result.tolist(), [1, 0, 1])
|
|
|
|
result = Tensor(cond.uop.where(1.5, 0))
|
|
self.assertEqual(result.tolist(), [1.5, 0, 1.5])
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|