Files
StarPilot/tinygrad_repo/tinygrad/device.py
T
firestar5683 22a8575199 Vendor dependencies and resources for standalone builds
Keep official openpilot ancestry and dependency licenses. Store resources directly in Git, remove LFS setup requirements, and leave the downloadable big model outside the source tree.
2026-10-03 21:01:18 +00:00

594 lines
32 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, replace, field
from collections import defaultdict
from typing import Any, Callable, Generic, TypeVar, Iterator, Generator, Self, TYPE_CHECKING
import importlib, inspect, functools, pathlib, os, contextlib, re, atexit, pickle, decimal, subprocess, struct, mmap, time, statistics
from tinygrad.helpers import mv_address, LRU, getenv, diskcache_get, diskcache_put, DEBUG, GlobalCounters, PROFILE, temp, colored
from tinygrad.helpers import Context, CCACHE, ALLOW_DEVICE_USAGE, MAX_BUFFER_SIZE, cpu_events, ProfileEvent, ProfilePointEvent, suppress_finalizing
from tinygrad.helpers import select_by_name, select_first_inited, DEV, TracingKey, size_to_str, pluralize, Target, unwrap, round_up, is_numpy_ndarray
from tinygrad.helpers import cpu_profile, perf_counter_us, ContextVar
from tinygrad.dtype import dtypes, DType, _to_np_dtype
from tinygrad.runtime.support.memory import BumpAllocator, MMIOInterface
if TYPE_CHECKING:
from tinygrad.renderer import Renderer
from tinygrad.uop.ops import UOp
# **************** Device ****************
HCQ_RUNTIME_DEV = ContextVar("HCQ_RUNTIME_DEV", "PYTHON" if DEV.interface.startswith("MOCK") else "CPU")
ALL_DEVICES = ["METAL", "AMD", "NV", "CUDA", "QCOM", "CL", "CPU", "DSP", "WEBGPU"]
class _Device:
def __init__(self) -> None:
self._devices = [x.stem[len("ops_"):].upper() for x in (pathlib.Path(__file__).parent/"runtime").iterdir() if x.stem.startswith("ops_")]
self._opened_devices:set[str] = set()
@functools.cache # this class is a singleton, pylint: disable=method-cache-max-size-none
def _canonicalize(self, device:str) -> str: return re.sub(r":0$", "", (d:=device.split(":", 1)[0].upper()) + device[len(d):])
# NOTE: you can't cache canonicalize in case Device.DEFAULT changes
def canonicalize(self, device:str|None) -> str: return self._canonicalize(device if device is not None else Device.DEFAULT)
def __getitem__(self, ix:str) -> Compiled:
ix = self.canonicalize(ix)
assert ALLOW_DEVICE_USAGE or ix.split(":")[0] in ["DISK", "NPY", "PYTHON"], f"usage of device {ix} disallowed"
return self.__get_canonicalized_item(ix)
@functools.cache # this class is a singleton, pylint: disable=method-cache-max-size-none
def get_class(self, ix:str):
base = (__package__ or __name__).split('.')[0] # tinygrad
x = ix.split(":")[0].lower()
return [cls for cname, cls in inspect.getmembers(importlib.import_module(f'{base}.runtime.ops_{x}')) if (cname.lower() == x + "device")][0]
@functools.cache # this class is a singleton, pylint: disable=method-cache-max-size-none
def __get_canonicalized_item(self, ix:str) -> Compiled:
ret = self.get_class(ix)(ix)
if DEBUG >= 1: print(f"opened device {ix} from pid:{os.getpid()}")
self._opened_devices.add(ix)
return ret
@property
def default(self) -> Compiled: return self[self.DEFAULT]
def get_available_devices(self) -> Iterator[str]:
for device in ALL_DEVICES:
with contextlib.suppress(Exception): yield self[device].device
@property
def DEFAULT(self) -> str: return DEV.device or self._select_device
@DEFAULT.setter
def DEFAULT(self, v): raise AttributeError(f'setting Device.DEFAULT is deprecated, use "with Context(DEV={v!r})" or "DEV.value = {v!r}"')
@functools.cached_property
def _select_device(self) -> str:
assert (dev:=next((d for d in self._devices if d not in ["DISK", "NPY"] and getenv(d) == 1), None)) is None, \
f"{dev}=1 is deprecated, use DEV={dev} instead"
try:
device = next(self.get_available_devices())
os.environ["DEV"] = device # we set this in environment for spawned children
return device
except StopIteration as exc: raise RuntimeError("no usable devices") from exc
Device: _Device = _Device()
atexit.register(lambda: [Device[dn].finalize() for dn in tuple(Device._opened_devices)])
def canonicalize_device(device:str|tuple|list|None) -> str|tuple[str, ...]:
if not isinstance(device, (tuple, list)): return Device.canonicalize(device)
return canonical[0] if len(canonical:=tuple(Device.canonicalize(d) for d in device)) == 1 else canonical
# **************** Profile ****************
@dataclass(frozen=True)
class ProfileDeviceEvent(ProfileEvent): device:str; tdiff:decimal.Decimal=decimal.Decimal(0); props:dict[str,Any]|None=None # noqa: E702
@dataclass(frozen=True)
class ProfileProgramEvent(ProfileEvent): device:str; name:str; lib:bytes|None; base:int|None; tag:int|None=None; profile_key:bytes|None=None # noqa: E702
@dataclass(frozen=True)
class ProfileGraphEntry: device:str; name:str|TracingKey; st_id:int; en_id:int; profile_key:bytes|None=None # noqa: E702
@dataclass(frozen=True)
class ProfileGraphEvent(ProfileEvent): ents:list[ProfileGraphEntry]; deps:list[list[int]]; sigs:list[decimal.Decimal] # noqa: E702
# **************** Buffer + Allocators ****************
@dataclass(frozen=True, eq=True)
class BufferSpec:
# TODO: move device, size, dtype here?
uncached: bool = False
cpu_access: bool = False
host: bool = False
nolru: bool = False
zero: bool = False
external_ptr: int|None = None
class MultiBuffer:
def __init__(self, device:tuple[str, ...], size:int, dtype:DType):
self.bufs = [Buffer(d, size, dtype) for d in device]
@property
def size(self): return self.bufs[0].size
@property
def dtype(self): return self.bufs[0].dtype
def is_allocated(self): return all(x.is_allocated() for x in self.bufs)
def __repr__(self): return f"<multibuf real:{self.is_allocated()} device:{tuple(x.device for x in self.bufs)} size:{self.size} dtype:{self.dtype}>"
@dataclass(frozen=True)
class BufferStorage: buf:Any; meta:Any=None; host:MMIOInterface|None=None; maps:dict[Compiled, BufferStorage]=field(default_factory=dict) # noqa: E702
class Buffer:
profile_events:list[ProfileEvent] = []
def __init__(self, device:str, size:int, dtype:DType, opaque:Any=None, options:BufferSpec|None=None,
initial_value:bytes|pickle.PickleBuffer|None=None, base:Buffer|None=None, offset:int=0, preallocate=False,
allocator:Allocator|None=None):
assert isinstance(dtype, DType)
self.device, self.size, self.dtype, self.offset, self.allocated_views, self._base = Device.canonicalize(device), size, dtype, offset, 0, base
if allocator is not None: self.allocator = allocator
self.options = options if options is not None else BufferSpec()
self._storage:BufferStorage|None = None
if base is None:
assert offset == 0, "base buffers can't have offset"
if opaque is not None: self.allocate(opaque)
if initial_value is not None:
self.allocate()
if (host:=self.get_storage().host) is not None: host[:] = memoryview(initial_value).cast('B')
else: self.copy_from(Buffer("PYTHON", self.size, self.dtype, opaque=memoryview(bytearray(initial_value))))
if isinstance(initial_value, pickle.PickleBuffer): initial_value.release()
else:
assert base._base is None, "base can't have a base"
assert self.device == base.device, "base must have the same device"
if preallocate: self.allocate()
@suppress_finalizing
def __del__(self): self._storage is None or self.deallocate()
def __repr__(self):
return f"<buf real:{self.is_allocated()} device:{self.device} size:{self.size} dtype:{self.dtype}" + \
(f" offset:{self.offset}" if self._base is not None else "") + (f" {self.options=}" if self.options != BufferSpec() else "") + ">"
@property
def base(self) -> Buffer: return self._base if self._base is not None else self
@functools.cached_property
def allocator(self) -> Allocator: return self.base.allocator if self._base is not None else Device[self.device].allocator
@property
def _buf(self) -> Any: return self.get_storage().buf
@property
def host(self) -> MMIOInterface: return unwrap(self.get_storage().host)
@property
def meta(self) -> Any: return self.get_storage().meta
@property
def nbytes(self): return self.size * self.dtype.itemsize
def get_storage(self, device:str|None=None) -> BufferStorage:
storage = unwrap(self.ensure_allocated()._storage)
device = Device.canonicalize(device) if device is not None else self.device
if device == self.device: return storage
if (dev:=Device[device]) not in storage.maps:
alloc = dev.allocator
storage.maps[dev] = BufferStorage(alloc._offset(self.base.get_buf(device), self.nbytes, self.offset)) if self._base else alloc.map(self)
if storage.maps[dev].host is not storage.host: storage.maps[dev] = replace(storage.maps[dev], host=storage.host)
return storage.maps[dev]
def get_buf(self, device:str) -> Any: return self.get_storage(device).buf
def is_allocated(self) -> bool: return self._storage is not None and (self._base is None or self._base_storage is self.base._storage)
def ensure_allocated(self) -> Buffer: return self.allocate() if not self.is_allocated() else self
def allocate(self, opaque=None, external_ptr=None) -> Buffer:
assert not self.is_allocated(), "can't allocate already allocated buffer"
if DEBUG >= 7: print(f"buffer: allocate {self.nbytes} bytes on {self.device}")
if not self.device.startswith("NULL") and self.size > MAX_BUFFER_SIZE > 0 and self.options.external_ptr is None:
raise RuntimeError(f"buffer of size {self.size/1e6:.2f}M is too large")
if external_ptr is not None: self.options = replace(self.options, external_ptr=external_ptr)
if self._base is not None:
storage = replace(self.base.get_storage(), buf=self.allocator._offset(self.base._buf, self.nbytes, self.offset), maps={})
elif opaque is not None:
self.options = replace(self.options, nolru=True)
if is_numpy_ndarray(opaque):
if not opaque.flags.c_contiguous: opaque = opaque.copy(order='C')
opaque = BufferStorage(addr:=opaque.ctypes.data, memoryview(opaque), MMIOInterface(addr, self.nbytes))
elif isinstance(opaque, memoryview):
opaque = BufferStorage(addr:=mv_address(opaque) if self.nbytes else 0, opaque, MMIOInterface(addr, self.nbytes))
storage = opaque if isinstance(opaque, BufferStorage) else BufferStorage(opaque)
else: storage = self.allocator.alloc(self.nbytes, self.options)
storage = replace(storage, host=storage.host.view(self.offset, self.nbytes, fmt='B') if storage.host is not None else None)
if self._base is None:
if not self.device.startswith("DISK") and self.options.external_ptr is None:
GlobalCounters.mem_used += self.nbytes
GlobalCounters.mem_used_per_device[self.device] += self.nbytes
if PROFILE: Buffer.profile_events.append(ProfilePointEvent(self.device, "alloc", self.trace_num, {"dtype":self.dtype, "sz":self.size}))
elif self._storage is None: self.base.allocated_views += 1
self._storage, self._base_storage = storage, self.base._storage if self._base else None
return self
def deallocate(self):
assert self._storage is not None, "buffer must be allocated to deallocate"
if DEBUG is not None and DEBUG >= 7: print(f"buffer: deallocate {self.nbytes} bytes on {self.device}")
if self._base is None:
if GlobalCounters is not None and not self.device.startswith("DISK") and self.options.external_ptr is None:
GlobalCounters.mem_used -= self.nbytes
GlobalCounters.mem_used_per_device[self.device] -= self.nbytes
if PROFILE: Buffer.profile_events.append(ProfilePointEvent(self.device, "free", self.trace_num))
self.allocator.free(self._storage, self.nbytes, self.options)
else: self.base.allocated_views -= 1
self._storage, self._base_storage = None, None
def __reduce_ex__(self, protocol):
buf:bytearray|pickle.PickleBuffer|None = None
if self._base is not None:
return self.__class__, (self.device, self.size, self.dtype, None, None, None, self.base, self.offset, self.is_allocated())
if self.device == "NPY":
import numpy as np
arr = np.frombuffer(self.meta, _to_np_dtype(self.dtype)) # over the storage itself, so an out-of-band pickle buffer keeps it alive
return self.__class__, (self.device, self.size, self.dtype, arr, self.options, None)
if self.is_allocated():
buf = pickle.PickleBuffer(self.as_memoryview()) if protocol >= 5 else bytearray(self.as_memoryview())
return self.__class__, (self.device, self.size, self.dtype, None, self.options, buf)
@property
def trace_num(self) -> int:
if not hasattr(self, '_trace_num'): self._trace_num = len(Buffer.profile_events)
return self._trace_num
def _host_mv(self) -> memoryview|None:
if self.is_allocated() and hasattr(host:=self.get_storage().host, 'mv'): return unwrap(host).view(fmt='B').mv
if self.is_allocated() and hasattr(self.allocator, '_as_buffer'): return self.allocator._as_buffer(self._buf)
return None
def as_memoryview(self, allow_zero_copy=False) -> memoryview:
if (mv:=self._host_mv()) is not None:
self.allocator.dev.synchronize()
if allow_zero_copy: return mv
with cpu_profile(f"{self.device} -> TINY", f"{self.device}:COPY"): return memoryview(bytearray(mv))
Buffer("PYTHON", self.size, self.dtype, opaque=(mv:=memoryview(bytearray(self.nbytes)))).copy_from(self)
return mv
def numpy(self) -> 'np.ndarray': # type: ignore [name-defined] # noqa: F821
import numpy as np
assert _to_np_dtype(self.dtype) is not None, f"no np dtype for {self.dtype}"
return np.frombuffer(self.as_memoryview(), dtype=_to_np_dtype(self.dtype))
def copy_from(self, src:Buffer) -> Buffer:
assert self.nbytes == src.nbytes, f"copy size mismatch, {self.nbytes} != {src.nbytes}"
assert self.is_allocated() and src.is_allocated(), "copy requires allocated buffers"
from tinygrad.engine.realize import run_linear
from tinygrad.uop.ops import UOp, Ops
du, su = UOp.from_buffer(self), UOp.from_buffer(src)
run_linear(UOp(Ops.LINEAR, src=(su.param_like(1).copy_to_device(self.device).call(du, su),)), update_stats=False)
return self
def view(self, size:int, dtype:DType, offset:int) -> Buffer:
assert offset < self.nbytes, "offset must be less than nbytes"
return Buffer(self.device, size, dtype, base=self.base, offset=self.offset+offset)
DeviceType = TypeVar('DeviceType', bound='Compiled')
# TODO: size, dest, src are the same type. can we enforce this?
class Allocator(Generic[DeviceType]):
lru = True
def __init__(self, dev:DeviceType, supports_copy_from_disk:bool=True, supports_transfer:bool=True):
self.dev: DeviceType = dev
self.default_buffer_spec: BufferSpec = BufferSpec()
self.cache:dict[tuple[int, BufferSpec|None], list[BufferStorage]] = defaultdict(list)
self.supports_copy_from_disk, self.supports_transfer = supports_copy_from_disk, supports_transfer
def alloc(self, size:int, options:BufferSpec|None=None) -> BufferStorage:
assert size > 0, f"alloc size must be positive, getting {size}"
if len(c:=self.cache[(size, options)]): return c.pop()
spec = options if options is not None else self.default_buffer_spec
try: return self._alloc(size, spec)
except (RuntimeError, MemoryError): self.free_cache()
try: return self._alloc(size, spec)
except (RuntimeError, MemoryError) as e: raise MemoryError(f"Allocation of {size_to_str(size)} failed on {self.dev.device}. "
f"Used: {size_to_str(GlobalCounters.mem_used_per_device[self.dev.device])}") from e
def free(self, storage:BufferStorage, size:int, options:BufferSpec|None=None):
spec = options if options is not None else self.default_buffer_spec
if LRU and self.lru and not (spec.nolru or spec.zero) and spec.external_ptr is None: self.cache[(size, options)].append(storage)
else: self.do_free(storage, spec)
def free_cache(self):
for (_, options), storages in self.cache.items():
for storage in storages: self.do_free(storage, options if options is not None else self.default_buffer_spec)
storages.clear()
def do_free(self, storage:BufferStorage, options:BufferSpec):
for dev in storage.maps: dev.synchronize()
for dev, mb in storage.maps.items(): dev.allocator._unmap(mb)
if options.external_ptr is None: self._free(storage, options)
def map(self, buf:Buffer) -> BufferStorage: return self._map(buf.ensure_allocated())
# implemented by the runtime
def _alloc(self, size:int, options:BufferSpec) -> BufferStorage: raise NotImplementedError("need alloc")
def _free(self, storage:BufferStorage, options:BufferSpec): pass # if opaque is a Python object, you don't need a free
def _copyin(self, dest, src:memoryview): raise NotImplementedError("need copyin")
def _copyout(self, dest:memoryview, src): raise NotImplementedError("need copyout")
def _map(self, buf) -> BufferStorage: raise NotImplementedError("need map")
def _unmap(self, mb): pass # default no-op; override if _map allocates iface-side state
def _offset(self, buf, size:int, offset:int): raise NotImplementedError("need offset")
# def _transfer(self, dest, src, sz:int, src_dev, dest_dev):
def _encode_decode(self, bufout, bufin, desc, hist:list, shape:tuple[int,...], frame_pos:int): raise NotImplementedError("need encdec") # optional
class HostAllocator(Allocator):
def __init__(self, dev): super().__init__(dev, supports_copy_from_disk=False, supports_transfer=False)
def _alloc(self, size:int, options:BufferSpec) -> BufferStorage:
if options.external_ptr is not None: view, meta = self._view(options.external_ptr, size), None
elif (remote:=getattr(self.dev, "remote", None)) is not None: view, meta = remote.alloc_sysmem(round_up(size, mmap.PAGESIZE))
else: view = self._view(mv_address(meta:=mmap.mmap(-1, size, access=mmap.ACCESS_WRITE)), size)
return BufferStorage(view.addr, meta, view)
def _free(self, storage:BufferStorage, options:BufferSpec):
if (remote:=getattr(self.dev, "remote", None)) is not None:
self.dev.synchronize()
remote.free_sysmem(storage.host)
def _view(self, addr:int, size:int) -> MMIOInterface:
return remote.cpu_view(addr, size) if (remote:=getattr(self.dev, "remote", None)) is not None else MMIOInterface(addr, size, fmt='B')
def _copyin(self, dest:int, src:memoryview):
self.dev.synchronize()
with cpu_profile(f"TINY -> {self.dev.device}", f"{self.dev.device}:COPY"): self._view(dest, src.nbytes)[:] = src.cast('B')
def _copyout(self, dest:memoryview, src:int):
self.dev.synchronize()
with cpu_profile(f"{self.dev.device} -> TINY", f"{self.dev.device}:COPY"): dest[:] = self._view(src, dest.nbytes)[:]
def _map(self, buf:Buffer) -> BufferStorage:
if Device[buf.device].host != self.dev.host: raise RuntimeError(f"host memory is not on the node of {self.dev.device}")
return BufferStorage(buf.host.addr)
def _offset(self, buf:int, size:int, offset:int) -> int: return buf + offset
class DepsTracker:
def __init__(self):
# tracks (offset, end, dep) ranges per base buffer id to handle suballocated buffers correctly.
self.w_dependency_map: dict[int, list[tuple[int, int, Any]]] = defaultdict(list)
self.r_dependency_map: dict[int, list[tuple[int, int, Any]]] = defaultdict(list)
@staticmethod
def _key(buf:Any) -> tuple[Any, int, int]: return id(buf.base), buf.offset, buf.offset + buf.nbytes
def access_resources(self, bufs:list[Any], write:list[int], new_dependency:Any):
wait_nodes = []
for i,buf in enumerate(bufs):
key, s, e = self._key(buf)
wait_nodes += [dep for st,en,dep in self.w_dependency_map[key] if st < e and s < en]
if i in write: wait_nodes += [dep for st,en,dep in self.r_dependency_map[key] if st < e and s < en]
for i,buf in enumerate(bufs):
key, s, e = self._key(buf)
if i in write:
for dmap in [self.w_dependency_map, self.r_dependency_map]:
kept = []
for entry in dmap[key]:
st, en, dep = entry
if st == en: continue
if en <= s or e <= st: kept.append(entry)
else:
if st < s: kept.append((st, s, dep))
if e < en: kept.append((e, en, dep))
dmap[key] = kept
self.w_dependency_map[key].append((s, e, new_dependency))
else: self.r_dependency_map[key].append((s, e, new_dependency))
return list({id(x):x for x in wait_nodes}.values())
# **************** for Compiled Devices ****************
class CompileError(Exception): pass
class Compiler:
def __init__(self, cachekey:str|None=None): self.cachekey = cachekey if CCACHE else None
def compile(self, src:str) -> bytes: return src.encode() # NOTE: empty compiler is the default
def compile_cached(self, src:str) -> bytes:
if self.cachekey is None or (lib := diskcache_get(self.cachekey, src)) is None:
assert not getenv("ASSERT_COMPILE"), f"tried to compile with ASSERT_COMPILE set\n{src}"
lib = self.compile(src)
if self.cachekey is not None: diskcache_put(self.cachekey, src, lib)
return lib
def disassemble(self, lib:bytes): pass
def server(self, cmd:str, arch:str, *args) -> subprocess.Popen:
argv = f"{cmd} {pathlib.Path(__file__).parent}/runtime/support/compileserver.py {type(self).__module__}:{type(self).__name__} {arch}"
return subprocess.Popen(argv.split() + [str(a) for a in args], stdout=subprocess.PIPE, stdin=subprocess.PIPE, bufsize=0)
def compile_server(self, src:str, proc:subprocess.Popen) -> bytes:
unwrap(proc.stdin).write(struct.pack("I", len(src.encode())) + src.encode())
if (lib:=unwrap(proc.stdout).read(struct.unpack("I", unwrap(proc.stdout).read(4))[0])): return lib
raise CompileError("Compilation Error")
@dataclass
class TinyELF:
lib: bytes
name: str
target: Target
# tuple of (name, slot, dtype, shape)
signature: tuple[tuple[str|None, int, DType, tuple], ...]
profile_key: bytes|None = None
@staticmethod
def iter_sig(signature:tuple[tuple[str|None, int, DType, tuple], ...], offset:int=0) -> Generator[tuple[int, DType], None, None]:
for _,_,dt,_ in signature:
yield (offset:=round_up(offset, dt.itemsize)), dt
offset += dt.itemsize
class Program(Generic[DeviceType]):
def __init__(self, dev:DeviceType, obj:TinyELF): pass
def __call__(self, *bufs, global_size:tuple[int,int,int]=(1,1,1), local_size:tuple[int,int,int]=(1,1,1), vals:tuple[int, ...]=(),
wait=False) -> float|None: pass
class Compiled:
ifaces:list[Callable] = []
profile_events:list[ProfileEvent] = [ProfileDeviceEvent("CPU")] # NOTE: CPU is the default device.
timestamp_divider: float = 1000.0
wait_timeout_ms: float = 30000.0
sleep_timeout_ms: int|None = None
can_recover:bool = False
rtalloc_size:int = 64<<20 # the pool every per-linear buffer is carved out of
var_vals: dict[str, int] = {}
# hcq2
pm_batch:Any = None
pm_encode:Any = None
pm_lower:Any = None
def __init__(self, device:str, allocator:Allocator, renderers:list[type[Renderer]], runtime:type[Program[Self]]|None, graph=None, arch=None):
from tinygrad.renderer import Renderer
from tinygrad.uop.ops import Ops, UPat, PatternMatcher
from tinygrad.runtime.support.hcq2 import cfunc_buf
self.device, self.allocator, self.runtime_t, self.graph, self.renderers = device, allocator, runtime, graph, renderers or [Renderer]
self.device_id, self.arch = (int(idx) if ":" in device and (idx:=device.split(":")[1]).isdigit() else 0), arch
self.peer_group = getattr(getattr(self, 'iface', None), 'peer_group', device.split(":")[0])
self.cached_renderer:dict[Any, Renderer] = {}
self.pending:dict[Compiled, int] = {} # timeline values of the devices that touched our memory
# hcq2
self.pm_bufferize = PatternMatcher([
(UPat(Ops.PARAM, tag="timeline"), lambda ctx: ctx.timeline),
(UPat(Ops.PARAM, tag="program", name="b"),
lambda ctx, b: ctx.prog_bufs.setdefault(b, Buffer(ctx.device, b.max_numel(), b.dtype, options=BufferSpec(cpu_access=True, nolru=True)))),
(UPat(Ops.PARAM, name="b"), lambda b, cfunc_buf=cfunc_buf: cfunc_buf(*b.tag[1:]) if isinstance(b.tag, tuple) and b.tag[0] == "cfunc" else None),
])
# profiling
self.prog_bufs:dict[UOp, Buffer] = {} # cache bufferized for programs
self.prof_ents:dict[tuple[Buffer, int], ProfileGraphEntry] = {} # (a batch's timestamps, start slot) -> entry, read at synchronize
@property
def has_copy_queue(self) -> bool: return True
@property
def host(self) -> str: return f"CPU:{self.peer_group[7:]}" if self.peer_group.startswith("remote:") else HCQ_RUNTIME_DEV.value
@property
def renderer(self) -> Renderer: return self._select_renderer()
@property
def compiler(self) -> Compiler:
if (ret:=self.renderer.compiler) is None: raise RuntimeError(f"no compiler for {self.device}")
return ret
def runtime(self, obj:TinyELF) -> Program[Self]: return unwrap(self.runtime_t)(self, obj)
@functools.cache
def rt_allocator(self, uncached:bool=True, host:bool=False) -> BumpAllocator: return BumpAllocator(self.rtalloc_size)
@functools.cache
def rt_buffer(self, uncached:bool=True, host:bool=False) -> Buffer:
spec = BufferSpec(host=host, uncached=uncached, cpu_access=True)
return Buffer(self.device, self.rt_allocator(uncached, host).size, dtypes.uint8, options=spec, preallocate=True)
@functools.cached_property
def timeline(self) -> Buffer: # [the signal, the value the last submitted batch signals]
return Buffer(self.device, 2, dtypes.uint64, options=BufferSpec(host=True, uncached=True, cpu_access=True), initial_value=bytes(16))
def _wait_signal(self, sig:MMIOInterface|memoryview, value:int, timeout:int|None=None):
timeout = timeout if timeout is not None and self.can_recover else None
st, done = time.perf_counter(), sig[0]
while done < value:
if done != (done:=sig[0]): st = time.perf_counter()
elif (elapsed:=time.perf_counter() - st) > (timeout or self.wait_timeout_ms) / 1000: raise RuntimeError(f"{self.device} signal wait timed out")
elif self.sleep_timeout_ms is not None and elapsed > self.sleep_timeout_ms / 1000: self.on_sleep()
def synchronize(self, timeout:int|None=None):
try:
self._wait_signal(tl:=self.timeline.host.view(fmt='Q'), tl[1], timeout)
for d, v in self.pending.items(): d._wait_signal(d.timeline.host.view(fmt='Q'), v, timeout)
except RuntimeError:
self.on_device_hang()
raise
if self.prof_ents: self.collect_prof()
def count(self) -> int:
"""
Returns the number of physical accelerators available to the runtime.
"""
return self.iface.count if hasattr(self, 'iface') else 1
def on_device_hang(self): raise RuntimeError(f"{self.device} hang detected")
def on_sleep(self):
if (iface:=getattr(self, "iface", None)) is not None and hasattr(iface, "sleep"): iface.sleep(self.sleep_timeout_ms)
def device_props(self) -> dict[str,Any]: return {} # to be overridden if needed. dict keys are backend dependent.
def finalize(self):
"""
Called at the end of process lifetime to allow the device to finalize.
"""
try: self.synchronize() # try to finalize the device in any case
except RuntimeError as e: print(f"{self.device} synchronization failed before finalizing: {e}")
if hasattr(self, 'iface') and hasattr(self.iface, 'device_fini'): self.iface.device_fini()
# helpers
def _renderer_name(self, r:type[Renderer]) -> str:
return r.__name__.upper().removesuffix("RENDERER").removeprefix(devname:=self.device.split(':')[0].upper()) or devname
def _select_renderer(self) -> Renderer:
assert (rn:=next((self._renderer_name(r) for r in self.renderers if getenv(f"{self.device}_{self._renderer_name(r)}")), None)) is None, \
f"{self.device}_{rn}=1 is deprecated, use DEV={self.device}:{rn} instead"
t = DEV.target(self.device.split(':')[0], **({"arch":self.arch} if self.arch else {}))
return select_first_inited(select_by_name(self.renderers, self._renderer_name, t.renderer, f"{self.device} has no renderer {t.renderer!r}"),
f"No renderer for {self.device} is available", self.cached_renderer, t)
def _select_iface(self, device:str):
self.device_id = int(device.split(":")[1]) if ":" in device else 0
assert (v:=getenv(k:=f'{type(self).__name__[:-6].upper()}_IFACE', "")) == "", \
f"{k}={v} is deprecated, use DEV={replace(DEV.target(type(self).__name__[:-6]), interface=v)} instead"
t = DEV.target(dev:=type(self).__name__[:-6])
filtered = select_by_name(self.ifaces, lambda i: i.__name__[:-5], t.interface, f"{dev} has no interface {t.interface!r}")
filtered = [i for i in filtered if t.interface.startswith("MOCK") or not i.__name__[:-5].startswith("MOCK")] # never fallback to mock ifaces
return select_first_inited([functools.partial(iface, self, self.device_id) for iface in filtered],
f"No interface for {dev}:{self.device_id} is available")
# profiling
def collect_prof(self):
if PROFILE:
es = list(self.prof_ents.items())
sigs = [buf.host.view(fmt='Q')[i]/decimal.Decimal(self.timestamp_divider) for (buf, _), e in es for i in (e.st_id, e.en_id)]
Compiled.profile_events.append(ProfileGraphEvent([replace(e, st_id=2*i, en_id=2*i+1) for i,(_, e) in enumerate(es)], [], sigs))
self.prof_ents.clear()
def _at_profile_finalize(self):
if self.pm_encode is None: return
from tinygrad.tensor import Tensor
tdiffs = []
for _ in range(5):
with Context(DEBUG=0, BEAM=0, TRACK_MATCH_STATS=0): Tensor.ones(1, device=self.device).contiguous().realize()
if not (ents:=list(self.prof_ents.items())): return
self.prof_ents.clear()
st = perf_counter_us()
self.synchronize()
gpu = max(buf.host.view(fmt='Q')[e.en_id] for (buf, _), e in ents)/decimal.Decimal(self.timestamp_divider)
tdiffs.append((st+perf_counter_us())/2 - gpu)
Compiled.profile_events.append(ProfileDeviceEvent(self.device, statistics.median(tdiffs), self.device_props()))
if PROFILE:
@atexit.register
def finalize_profile():
devs = [Device[d] for d in Device._opened_devices]
for dev in devs: dev.synchronize()
for dev in devs: dev._at_profile_finalize()
with open(fn:=temp("profile.pkl", append_user=True), "wb") as f: pickle.dump(cpu_events+Compiled.profile_events+Buffer.profile_events, f)
PROFILE.value = 0
from tinygrad.uop.ops import launch_viz
launch_viz("PROFILE", fn)
def enumerate_devices_str() -> Generator[str, None, None]:
from tinygrad import Tensor, Device
for device in ALL_DEVICES:
ren_results, iface_results = [], []
try:
d = Device[device]
for iface in [i for i in d.ifaces if not i.__name__.startswith("MOCK")]:
try:
name = iface.__name__[:-5]
default_text, count = ("(default)", d.count()) if type(d.iface) is iface else (f"(DEV={name}+{device} to make default)", iface(d, 0).count) # type: ignore
iface_results.append(f"{colored('+', 'green')} {name}: {pluralize('device', count)} {default_text}")
except Exception as e: iface_results.append(f"{colored('-', 'red')} {iface.__name__[:-5]}: {e}")
for r in d.renderers:
try:
with Context(CACHELEVEL=0, DEV=f"{device}:{d._renderer_name(r)}"): test = (Tensor([1,2,3], device=device) * 2).tolist()
if test != [2,4,6]: raise ValueError(f"got {test} instead of [2, 4, 6]")
default_text = '(default)' if type(d.renderer) is r else f'(DEV={device}:{d._renderer_name(r)} to make default)'
ren_results.append(f"{colored('+', 'green')} {d._renderer_name(r)} {default_text}")
except Exception as e: ren_results.append(f"{colored('-', 'red')} {d._renderer_name(r)}: {e}")
result = (colored('PASS', 'green') + ("\n"+" "*12+"interfaces:\n" if iface_results else "") + '\n'.join([" "*13+x for x in iface_results]) +
(("\n"+" "*12+"renderers:\n") + '\n'.join([" "*13+x for x in ren_results]) if len(ren_results) > 1 else ""))
except Exception as e: result = f"{colored('FAIL', 'red')} {e}"
yield f"{'*' if device == Device.DEFAULT else ' '} {device:8s}: {result}"
if __name__ == "__main__":
for s in enumerate_devices_str(): print(s)