Files
2026-10-20 03:55:45 +00:00

289 lines
16 KiB
Python

import unittest, gc, struct, ctypes, threading, numpy as np
from tinygrad import Device, Tensor, TinyJit, Variable, dtypes, GlobalCounters
from tinygrad.device import Buffer, BufferSpec
from tinygrad.dtype import AddrSpace
from tinygrad.helpers import Context
from tinygrad.uop.ops import Ops, UOp, uopfunc
from tinygrad.engine.realize import compile_linear, link_linear, lower_and_compile, run_linear
from tinygrad.renderer.cstyle import CStyleLanguage
from tinygrad.renderer.llvmir import LLVMRenderer
from tinygrad.runtime.autogen import libc
from tinygrad.runtime.support.c import init_c_struct_t
import tinygrad.runtime.support.hcq2 as hcq2
from tinygrad.runtime.support.hcq2 import HCQ_DEVS, all_devices_in, hcq_compile_cache
from test.null.test_hcq2 import chain, chain_input, compiled_chain, lower_hcq
def cpu_buf(size:int=1, dtype=dtypes.uint8, tag=None, **kwargs) -> UOp: return UOp.alloc((size,), dtype, device="CPU", **kwargs).rtag(tag)
@unittest.skipUnless(all_devices_in(Device.DEFAULT, HCQ_DEVS) and not Device.DEFAULT.startswith("NULL"), "hcq2 device required")
class TestHCQ2Schedule(unittest.TestCase):
def test_compile_and_link_are_idempotent(self):
for jit in (False, True):
with self.subTest(jit=jit):
out, compiled, inputs = compiled_chain(2, jit=jit, device=Device.DEFAULT)
linked = link_linear(compiled, input_uops=inputs, allow_cache=not jit)
before = tuple(inputs)
for linear in (compiled, linked): self.assertIs(compile_linear(linear, input_uops=inputs, cache=not jit), linear)
self.assertIs(link_linear(linked, input_uops=inputs, allow_cache=not jit), linked)
self.assertEqual(tuple(inputs), before)
run_linear(linked, input_uops=inputs, jit=True, wait=True)
self.assertEqual(out.tolist(), [4] * 4)
def test_jit_new_inputs_each_call(self):
@TinyJit
def f(a, b): return (a * b + a).contiguous().realize()
ins = [(Tensor.full((23,), float(i)).contiguous().realize(), Tensor.full((23,), 2.0).contiguous().realize()) for i in range(6)]
for a, b in ins[:3]: f(a, b).tolist() # warm the jit and the copyout
before = len(hcq_compile_cache)
for i, (a, b) in enumerate(ins[3:], 3): self.assertEqual(f(a, b).tolist(), [i * 3.0] * 23)
self.assertEqual(len(hcq_compile_cache), before)
def test_jit_symbolic(self):
@TinyJit
def f(a): return (a + 1).sum().contiguous().realize()
a = Tensor.rand(3, 10).contiguous().realize()
for i in range(1, 5):
vi = Variable("i", 1, 10).bind(i)
np.testing.assert_allclose(f(a[:, :vi]).item(), (a[:, :i] + 1).sum().item(), atol=1e-5, rtol=1e-5)
def test_repeated_copy(self):
vram, host, new = [Buffer(d, 4096, preallocate=True) for d in (Device.DEFAULT, "CPU", "CPU")]
new.host[:] = bytes(range(256)) * 16
hu, vu, nu = [UOp.from_buffer(b, dtypes.uint8) for b in (host, vram, new)]
copyout, copyin = hu.store_call(vu), vu.store_call(nu)
run_linear(UOp(Ops.LINEAR, src=(copyout, copyin, copyout)), wait=True)
self.assertEqual(bytes(host.host[:]), bytes(new.host[:]))
@unittest.skipIf(Device.DEFAULT == "METAL", "unified memory: METAL copies on the host and maps nothing")
def test_map_cpu_buffer_preserves_contents(self):
src = Buffer("CPU", 16, preallocate=True)
data = bytes(range(16))
src.host[:] = data
src.get_buf(Device.DEFAULT)
self.assertEqual(bytes(src.as_memoryview()), data)
def test_caches_hold_no_buffers(self):
# an eager template caches without its buffers and the jit's linear compiles once uncached: freeing the tensors frees the device memory
def step(i):
buf = Buffer("NPY", 4096, initial_value=struct.pack("f", i) * 1024)
x = Tensor(UOp.from_buffer(buf, dtypes.float32)).to(Device.DEFAULT).realize()
@TinyJit
def f(a): return (a * 2 + 1).contiguous().realize()
for _ in range(3): out = f(x)
self.assertEqual(out.to("CPU").tolist(), [2.0 * i + 1] * 1024)
step(1) # warms the programs, templates and rings
gc.collect()
used = GlobalCounters.mem_used
for i in range(2, 5): step(i)
gc.collect()
self.assertEqual(GlobalCounters.mem_used, used)
def test_jit_has_no_rt_buffers(self):
# a one shot link borrows ring slots, a jit's link owns its buffers: nothing it keeps may come from the ring
dev = Device[Device.DEFAULT]
specs = (BufferSpec(cpu_access=True), BufferSpec(host=True, uncached=True, cpu_access=True)) # device data, signals
ranges = [((b:=dev.rt_buffer(spec))._buf, b._buf + b.nbytes) for spec in specs]
x, f = chain_input(device=dev.device), TinyJit(lambda a: chain(a, 2).realize())
for _ in range(2): f(x)
for u in f.captured.linear.toposort(enter_calls=True):
if u.op is Ops.BUFFER and u.addrspace is AddrSpace.GLOBAL and (buf:=u.buffer).device == dev.device:
self.assertFalse(any(buf._buf < end and start < buf._buf + buf.nbytes for start, end in ranges))
# the fence and the ffi run on the CPU runtime, which is always available
@unittest.skipUnless(isinstance(Device["CPU"].renderer, (CStyleLanguage, LLVMRenderer)), "CALL is rendered in C style and LLVM only")
class TestHCQ2Fence(unittest.TestCase):
def setUp(self):
self.enterContext(Context(HCQ_RUNTIME_DEV="CPU"))
self.tl = Device["CPU"].timeline.host.view(fmt='Q')
self.addCleanup(lambda: self.tl.__setitem__(0, self.tl[1]))
def test_a_schedule_waits_for_its_previous_run(self):
slots = UOp.alloc((4,), dtypes.uint64, device="CPU").rtag("slots")
program = lower_and_compile(lower_hcq(UOp.custom_function("hcq_fence").call(slots[0:2], slots[2:4])))
linked = hcq2.hcq_link(program, allow_cache=False)
(i,) = [i for i, p in enumerate(program.src[0].without_after.src[1:]) if p.without_after.tag == "slots"]
slots_mv = linked.src[0].without_after.src[1 + i].buffer.host.view(fmt='Q')
slots_mv[2], base = 7, self.tl[1]
run_linear(linked, jit=True)
self.assertEqual((self.tl[1], slots_mv[0], slots_mv[2]), (base + 1, base + 1, 0), "the run is announced, recorded, the signal re-armed")
t = threading.Thread(target=run_linear, args=(linked,), kwargs={"jit": True}, daemon=True)
t.start()
t.join(0.2)
self.assertTrue(t.is_alive(), "the second run must wait for the first to finish")
self.tl[0] = base + 1
t.join(5)
self.assertFalse(t.is_alive())
self.assertEqual(self.tl[1], base + 2)
@unittest.skipUnless(isinstance(Device["CPU"].renderer, (CStyleLanguage, LLVMRenderer)), "CALL is rendered in C style and LLVM only")
class TestHCQ2FFI(unittest.TestCase):
@staticmethod
def _run(body:UOp) -> list[UOp]:
linear = hcq2.hcq_link(lower_and_compile(lower_hcq(body)), allow_cache=False)
assert hcq2.hcq_link(linear, allow_cache=False) is linear, "a linked linear links to itself, with the refs its call keeps"
run_linear(linear, jit=True)
return [u for u in linear.src[0].without_after.src[1:] if u.op is Ops.BUFFER]
def test_ffi_ccall(self):
with Context(HCQ_RUNTIME_DEV="CPU"):
out = cpu_buf(dtype=dtypes.int32, slot=1, tag="ffi_result")
bufs = self._run(out.index(0).store(hcq2.ccall(libc.dll.ffs, 0x10)))
self.assertEqual(next(b for b in bufs if b.dtype is dtypes.int).buffer.host.view(fmt='i')[0], 5)
def test_nested_ffi_call(self, host="CPU"): # a function calls a C function: no pointer to pass, the symbol links
@uopfunc
def copy(dst:UOp, src:UOp): return hcq2.ccall(libc.memcpy, dst.index(0), src.index(0), 4).sink()
@uopfunc
def copy_pair(dst:UOp, src:UOp): return copy(dst.after(copy(dst, src)).index(1), src).sink()
with Context(HCQ_RUNTIME_DEV=host):
src = hcq2.cstruct(init_c_struct_t(4, (("value", ctypes.c_uint32, 0),)), value=42)
out = cpu_buf(2, dtypes.uint32, tag="ffi_result")
bufs = self._run(copy_pair(out, src.bitcast(dtypes.uint32)))
self.assertEqual(list(next(b for b in bufs if b.dtype is dtypes.uint32).buffer.host.view(fmt='I')), [42, 42])
def test_nested_ffi_call_python(self): self.test_nested_ffi_call("PYTHON")
def test_ffi_cstruct(self):
struct_t = init_c_struct_t(16, (("u8", ctypes.c_uint8, 0), ("u16", ctypes.c_uint16, 2),
("u32", ctypes.c_uint32, 4), ("u64", ctypes.c_uint64, 8)))
cpu_buf() # reserve slot zero for device-owned placeholders
with Context(HCQ_RUNTIME_DEV="CPU"):
s = hcq2.cstruct(struct_t, u8=0x12, u16=UOp.const(0x3456, dtypes.uint16), u32=0x789ABCDE, u64=0xFEDCBA9876543210)
bufs = self._run(s.index(0).load())
got = struct_t.from_buffer_copy(bytes(next(b for b in bufs if b.nbytes() == ctypes.sizeof(struct_t)).buffer.host.view(fmt='B')))
self.assertEqual((got.u8, got.u16, got.u32, got.u64), (0x12, 0x3456, 0x789ABCDE, 0xFEDCBA9876543210))
def test_nested_cstruct_patches(self):
with Context(HCQ_RUNTIME_DEV="CPU"):
inner = hcq2.cstruct(init_c_struct_t(8, (("pad", ctypes.c_uint32, 0), ("value", ctypes.c_uint32, 4))), value=42)
outer = hcq2.cstruct(init_c_struct_t(8, (("ptr", ctypes.c_uint64, 0),)), ptr=inner[4:8].getaddr("CPU"))
out = cpu_buf(dtype=dtypes.uint32, tag="result")
copied = hcq2.ccall(libc.memcpy, out.index(0), outer.bitcast(dtypes.uint64).index(0).load(), 4)
bufs = self._run(out.after(copied).index(0).load())
self.assertEqual(next(b for b in bufs if b.dtype is dtypes.uint32).buffer.host.view(fmt='I')[0], 42)
@uopfunc
def addr_of(o:UOp, b:UOp): return o.index(0).store(b.getaddr("CPU")).sink() # o[0] = &b
# host functions in a batch
@unittest.skipUnless(isinstance(Device["CPU"].renderer, (CStyleLanguage, LLVMRenderer)), "CALL is rendered in C style and LLVM only")
class TestHostCalls(unittest.TestCase):
def setUp(self): self.enterContext(Context(HCQ_RUNTIME_DEV="CPU"))
@staticmethod
def _buf(n:int, dtype=dtypes.uint64) -> UOp:
return UOp.from_buffer(Buffer("CPU", n * dtype.itemsize, initial_value=bytes(n * dtype.itemsize)), dtype)
@staticmethod
def _run(fxn, *bufs:UOp, **var_vals:int) -> list: # first buffer is the output
run_linear(hcq2.hcq_link(lower_and_compile(lower_hcq(fxn(*bufs))), allow_cache=False), var_vals, jit=True)
return bufs[0].buffer.host.view(fmt=bufs[0].dtype.fmt)[:]
def test_no_addrs_no_placeholders(self):
@uopfunc
def inc(o:UOp, a:UOp): return o.index(0).store(a.index(0).load() + 1).sink()
a = UOp.from_buffer(Buffer("CPU", 8, initial_value=struct.pack("Q", 41)), dtypes.uint64)
self.assertEqual(self._run(inc, self._buf(1), a), [42])
def test_addr_of_arg(self):
b = self._buf(16, dtypes.uint8)
self.assertEqual(self._run(addr_of, self._buf(1), b), [b.buffer.get_buf("CPU")])
def test_addr_of_view(self):
@uopfunc
def addr(o:UOp, b:UOp): return o.index(0).store(b[4:8].getaddr("CPU")).sink()
b = self._buf(16, dtypes.uint8)
self.assertEqual(self._run(addr, self._buf(1), b), [b.buffer.get_buf("CPU") + 4])
def test_sink_calls_function(self): # a bare sink with args calls a function that writes one arg and takes the address of the other
def sink(o:UOp, b:UOp): return addr_of(UOp.param(0, dtypes.uint64, 1), UOp.param(1, dtypes.uint8, 16)).sink().call(o, b, name="sink")
outs, b = [self._buf(1) for _ in range(2)], self._buf(16, dtypes.uint8)
for out in outs: self.assertEqual(self._run(sink, out, b), [b.buffer.get_buf("CPU")]) # the second run reuses the program
def test_nested_functions(self): # the address is taken two calls deep
@uopfunc
def mid(o:UOp, b:UOp): return addr_of(o, b).sink()
@uopfunc
def top(o:UOp, b:UOp): return mid(o, b).sink()
b = self._buf(16, dtypes.uint8)
self.assertEqual(self._run(top, self._buf(1), b), [b.buffer.get_buf("CPU")])
def test_call_sites(self): # one function, called on different args
@uopfunc
def both(o:UOp, a:UOp, b:UOp): return UOp.sink(addr_of(o[0:1], a), addr_of(o[1:2], b))
a, b = self._buf(16, dtypes.uint8), self._buf(16, dtypes.uint8)
self.assertEqual(self._run(both, self._buf(2), a, b), [a.buffer.get_buf("CPU"), b.buffer.get_buf("CPU")])
def test_nested_placeholders(self): # storage a function keeps for itself
@uopfunc
def keep(o:UOp):
tmps = [cpu_buf(dtype=dtypes.uint64, tag="tmp") for _ in range(2)]
return UOp.sink(*[o.index(i).store(t.after(t.index(0).store(7 + i)).index(0).load()) for i, t in enumerate(tmps)])
@uopfunc
def top(o:UOp): return keep(o).sink()
self.assertEqual(self._run(top, self._buf(2)), [7, 8])
def test_addr_of_placeholder(self): # the address is the one of the buffer the placeholder links to
@uopfunc
def keep(o:UOp):
tmp = cpu_buf(dtype=dtypes.uint64, tag="tmp")
return UOp.sink(tmp.index(0).store(7), o.index(0).store(tmp.getaddr("CPU")))
self.assertEqual(ctypes.c_uint64.from_address(self._run(keep, self._buf(1))[0]).value, 7)
def test_cstruct(self): # a patched placeholder inside a function
@uopfunc
def read(o:UOp):
s = hcq2.cstruct(init_c_struct_t(8, (("pad", ctypes.c_uint32, 0), ("value", ctypes.c_uint32, 4))), value=42)
return o.index(0).store(s.bitcast(dtypes.uint32).index(1).load().cast(dtypes.uint64)).sink()
self.assertEqual(self._run(read, self._buf(1)), [42])
def test_variable_in_function(self): # a variable is passed to a function, the program binds it by name
@uopfunc
def scale(o:UOp, a:UOp, k:UOp): return o.index(0).store(a.index(0).load() * k).sink()
@uopfunc
def top(o:UOp, a:UOp): return scale(o, a, UOp.variable("k", 0, 10, dtypes.uint64)).sink()
a = UOp.from_buffer(Buffer("CPU", 8, initial_value=struct.pack("Q", 7)), dtypes.uint64)
self.assertEqual(self._run(top, self._buf(1), a, k=6), [42])
def test_weak_variable_in_function(self): # a weak variable reached in a function
@uopfunc
def put(o:UOp): return o.index(0).store(UOp.variable("n", 1, 10).cast(dtypes.uint64)).sink()
self.assertEqual(self._run(put, self._buf(1), n=7), [7])
def test_weak_variable_as_arg(self): # a weak arg commits its dtype, like lift does
@uopfunc
def put(o:UOp, v:UOp): return o.index(0).store(v.cast(dtypes.uint64)).sink()
@uopfunc
def top(o:UOp): return put(o, UOp.variable("n", 1, 10)).sink()
self.assertEqual(self._run(top, self._buf(1), n=7), [7])
def test_addr_of_arg_after_a_write(self): # the arg contains the param it replaces
@uopfunc
def top(o:UOp, b:UOp): return addr_of(o, b.after(b.index(0).store(2))).sink()
b = self._buf(1)
self.assertEqual(self._run(top, self._buf(1), b), [b.buffer.get_buf("CPU")])
def test_inputs_out_of_slot_order(self): # inputs reached out of slot order
p = [UOp.param(i, dtypes.uint64, 1, "CPU") for i in range(2)]
self.assertEqual(lower_hcq(p[1].index(0).load(), p[0].index(0).load()).src[0].without_after.src[1:], (p[1], p[0]))
def test_one_function_for_any_placeholder(self): # a function names its params: what it is called on does not make another function
@uopfunc
def put(out:UOp, v:UOp): return out.index(0).store(v).sink()
a, b = [cpu_buf(dtype=dtypes.uint64, tag=t) for t in ("cb", "enc")]
lowered = lower_hcq(put(a, UOp.const(1, dtypes.uint64)), put(b, UOp.const(2, dtypes.uint64)))
self.assertEqual(len({c.body for c in lowered.toposort(enter_calls=True) if c.op is Ops.CALL and c.arg.name == "put"}), 1)
def test_one_function_with_registers(self): # a body numbers its own registers and loops: two traces are one function
@uopfunc
def put(out:UOp, v:UOp):
r = UOp.placeholder((1,), dtypes.uint64, addrspace=AddrSpace.REG)
return out.index(0).store(r.after(r.index(0).store(v)).index(0).load()).sink()
calls = [put(cpu_buf(dtype=dtypes.uint64), UOp.const(1, dtypes.uint64)) for _ in range(2)]
self.assertIs(calls[0].body, calls[1].body)
if __name__ == "__main__":
unittest.main()