from __future__ import annotations from typing import cast, Callable, TypeVar, Generic, Any, TYPE_CHECKING import struct, functools, time, collections from dataclasses import replace if TYPE_CHECKING: from tinygrad.engine.realize import ExecContext from tinygrad.helpers import DEV, getenv, select_first_inited, select_by_name, suppress_finalizing, mv_address, round_up, DEBUG, dedup from tinygrad.device import Device, Buffer, BufferSpec, Compiled, LRUAllocator from tinygrad.uop.ops import Ops, sint, UOp, UPat, PatternMatcher, KernelInfo, graph_rewrite, track_rewrites from tinygrad.dtype import dtypes from dataclasses import dataclass, field from tinygrad.runtime.support.memory import BumpAllocator from tinygrad.runtime.support.hcq import MMIOInterface from tinygrad.renderer import Renderer, Estimates from tinygrad.engine.realize import to_program, track_stats, get_call_arg_uops, resolve_params HCQDeviceType = TypeVar('HCQDeviceType', bound='HCQ2Compiled') class HCQ2Compiled(Compiled): """ A base class for devices compatible with the HCQ (Hardware Command Queue) API. """ timestamp_divider: float = 1000.0 # GPU timestamp counter ticks per microsecond; override per device def __init__(self, device:str, allocator:'HCQAllocator', compilers:list[type[Renderer]], runtime, kernargs_size=(16 << 20), can_recover:bool=False, arch=None): self.device_id:int = int(device.split(":")[1]) if ":" in device else 0 from extra.hcq2.graph.hcq import HCQ2Graph super().__init__(device, allocator, compilers, lambda *a, **kw: None, HCQ2Graph, arch=arch) self.kernargs_size = kernargs_size self.kernargs_offset_allocator:BumpAllocator = BumpAllocator(kernargs_size, wrap=True) @functools.cached_property def kernargs_buf(self) -> Buffer: return Buffer(self.device, self.kernargs_size, dtypes.uint8, options=BufferSpec(cpu_access=True), preallocate=True) @functools.cached_property def timeline_signal(self) -> Buffer: return Buffer(self.device, 0x100, dtypes.uint8, options=BufferSpec(host=True, uncached=True, cpu_access=True), preallocate=True) @functools.cached_property def timestamps_buf(self) -> Buffer: return Buffer(self.device, 0x100, dtypes.uint8, options=BufferSpec(cpu_access=True), preallocate=True) @functools.cached_property def timeline_value(self) -> Buffer: buf = Buffer("CPU", 1, dtypes.uint64, preallocate=True) buf.as_memoryview(force_zero_copy=True).cast('Q')[0] = 1 return buf def synchronize(self, timeout:int|None=None): if not hasattr(self, 'iface'): return sig = self.timeline_signal._buf.cpu_view().mv.cast('Q') tl = self.timeline_value.as_memoryview(force_zero_copy=True).cast('Q') st = time.perf_counter() while sig[0] < tl[0] - 1: if time.perf_counter() - st > (timeout or 3000) / 1000: self.on_device_hang() def device_props(self) -> dict[str,Any]: return {} # to be overridden if needed. dict keys are backend dependent. def _realloc(self, oldbuf:HCQ2Buffer|None, new_size:int, options:BufferSpec|None=None, force=False) -> tuple[HCQ2Buffer, bool]: if oldbuf is not None: self.allocator.free(oldbuf, oldbuf.size, options=options) try: buf, realloced = self.allocator.alloc(new_size, options=options), True except MemoryError: if force: raise buf, realloced = self.allocator.alloc(oldbuf.size if oldbuf is not None else new_size, options=options), False return buf, realloced def count(self) -> int: return self.iface.count if hasattr(self, 'iface') else 1 def _select_iface(self): 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" assert hasattr(self, "ifaces"), "must have ifaces to select an iface" 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 fall back to mock ifaces return select_first_inited([functools.partial(cast(Callable, iface), self, self.device_id) for iface in filtered], f"No interface for {dev}:{self.device_id} is available") def _is_cpu(self) -> bool: return hasattr(self, 'device') and self.device.split(":")[0] == "CPU" def finalize(self): 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 the device has an interface, call device_fini to clean up resources if hasattr(self, 'iface') and hasattr(self.iface, 'device_fini'): self.iface.device_fini() class HCQ2Buffer: def __init__(self, va_addr:sint, size:int, meta:Any=None, _base:HCQ2Buffer|None=None, view:MMIOInterface|None=None, owner:HCQ2Compiled|None=None): self.va_addr, self.size, self.meta, self._base, self.view, self.owner = va_addr, size, meta, _base, view, owner def offset(self, offset:int=0, size:int|None=None) -> HCQ2Buffer: return HCQ2Buffer(self.va_addr+offset, size or (self.size - offset), owner=self.owner, meta=self.meta, _base=self._base or self, view=(self.view.view(offset=offset, size=size) if self.view is not None else None)) def cpu_view(self) -> MMIOInterface: assert self.view is not None, "buffer has no cpu_view" return self.view @property def base(self) -> HCQ2Buffer: return self._base or self class HCQAllocator(LRUAllocator[HCQDeviceType], Generic[HCQDeviceType]): def _map(self, buf:HCQ2Buffer) -> HCQ2Buffer: if not hasattr(self, '_do_map'): raise NotImplementedError("map failed: no method implemented") return self._do_map(buf) @suppress_finalizing def _free(self, buf:HCQ2Buffer, options:BufferSpec|None=None): if options is not None and options.external_ptr is not None: return if hasattr(self, '_do_free'): self._do_free(buf, options) def _unmap(self, mb): self.dev.synchronize() self.dev.iface.dev_impl.mm.unmap_range(int(mb.va_addr), round_up(mb.size, 0x1000)) def _offset(self, buf, size:int, offset:int) -> HCQ2Buffer: return buf.offset(offset=offset, size=size) def _wrap(self, dev:str, sz:int, opaque:HCQ2Buffer) -> Buffer: return Buffer(dev, sz, dtypes.uint8, opaque=opaque, options=BufferSpec(external_ptr=1)) def _copy(self, dst:Buffer, src:Buffer): from tinygrad.engine.realize import run_linear su = UOp.from_buffer(src) run_linear(UOp(Ops.LINEAR, dtypes.void, (su.copy_to_device(dst.device).call(UOp.from_buffer(dst), su),)), jit=True, update_stats=False) def _copyin(self, dest:HCQ2Buffer, src:memoryview): s = Buffer(self.dev.device, len(src), dtypes.uint8, options=BufferSpec(host=True), preallocate=True) s._buf.cpu_view()[:len(src)] = src self._copy(self._wrap(self.dev.device, len(src), dest), s) def _copyout(self, dest:memoryview, src:HCQ2Buffer): d = Buffer(self.dev.device, len(dest), dtypes.uint8, options=BufferSpec(host=True), preallocate=True) self._copy(d, self._wrap(self.dev.device, len(dest), src)) self.dev.synchronize() dest[:] = d._buf.cpu_view()[:len(dest)] def _as_buffer(self, buf): return buf.cpu_view().mv # **************** lower context **************** @dataclass class HCQ2DeviceCtx: device:str # device name; resolve to instance via Device[device] kernargs_host:UOp # UOp whose .buffer is dev.kernargs_buf (BUFFER UOp in runtime, PARAM in graph) kernargs_gpu:UOp # va_addr const of dev.kernargs_buf kernargs_allocator:BumpAllocator = field(default_factory=lambda: BumpAllocator(2 << 20, wrap=False)) @dataclass class HCQ2LowerCtx: name:str inputs:list[Buffer] = field(default_factory=list) holds:list[UOp] = field(default_factory=list) devs:dict[str, HCQ2DeviceCtx] = field(default_factory=dict) def host_param(self, buf:Buffer) -> UOp: if buf not in self.inputs: self.inputs.append(buf) return UOp.placeholder((buf.size,), buf.dtype, self.inputs.index(buf)) class HCQEncoder: def __init__(self, ctx:HCQ2LowerCtx, dev:HCQ2Compiled): self.ctx, self.dev, self.blob, self.patches, self.deps = ctx, dev, b'', [], [] @property def src(self) -> tuple[UOp, ...]: return tuple(self.patches + dedup(self.deps)) def get_dev_addr(self, uop:UOp) -> sint|UOp: while uop.op is Ops.AFTER: self.deps.extend(uop.src[1:]) uop = uop.src[0] if isinstance(val:=uop.ssimplify(), UOp): self.deps.append(uop) return uop.buffer.get_buf(self.dev.device).va_addr if uop.op in (Ops.BUFFER, Ops.BUFFER_VIEW) else val def append(self, *data, dtype=dtypes.uint32): for d in data: if isinstance(d, int): self.blob += struct.pack(f'<{dtype.fmt}', d) elif d.op is Ops.CONST: self.blob += struct.pack(f'<{dtype.fmt}', d.arg) else: self.patches.append(UOp(Ops.PATCH, dtype, src=(d,), arg=len(self.blob))) self.blob += struct.pack(f'<{dtype.fmt}', 0) def q(self, *values): self.append(*values) # **************** prep runtime **************** pm_prep_runtime = PatternMatcher([ # device-specific lowering of the program (UPat(Ops.CALL, src=(UPat(Ops.PROGRAM, src=(UPat(), UPat(Ops.DEVICE), UPat(), UPat(), UPat(Ops.BINARY)), name="p"),), name="c", allow_any_len=True), lambda ctx, c, p: c.replace(src=(Device[p.src[1].arg].pm_lower.rewrite(p, ctx),) + c.src[1:])), ]) def calc_kernargs_sizes(ctx:dict[str,int], u:UOp) -> None: d = u.src[0].buffer.device ctx[d] = ctx.get(d, 0) + round_up(u.arg[0].kernargs_alloc_size, 16) pm_calc_kernargs_sizes = PatternMatcher([(UPat(Ops.PROGRAM, name="u"), calc_kernargs_sizes)]) # **************** lower kernargs **************** def lower_kernargs(ctx:HCQ2LowerCtx, call:UOp, prg:UOp) -> UOp: data, info = prg.arg # after amd_build_program, prg.src is (BUFFER_lib_gpu,); the buffer's device names the device dctx = ctx.devs[prg.src[0].buffer.device] enc = HCQEncoder(ctx, Device[dctx.device]) for gi in info.globals: enc.append(enc.get_dev_addr(call.src[1+gi]), dtype=dtypes.uint64) for v in info.vars: enc.append(v, dtype=dtypes.uint32) args_off = dctx.kernargs_allocator.alloc(data.kernargs_alloc_size, 16) dctx.kernargs_host.buffer.view(len(enc.blob), dtypes.uint8, args_off).ensure_allocated().as_memoryview(force_zero_copy=True)[:] = enc.blob args_uop = (dctx.kernargs_gpu + args_off).after(dctx.kernargs_host.after(*tuple(p.replace(arg=p.arg+args_off) for p in enc.patches))) return call.replace(src=(prg.replace(src=prg.src + (args_uop,), arg=(data, info)),) + call.src[1:]) pm_lower_kernargs = PatternMatcher([ (UPat(Ops.CALL, src=(UPat(Ops.PROGRAM, src=(UPat(Ops.BUFFER),), name="prg"),), name="call", allow_any_len=True), lower_kernargs), ]) # **************** lower ops **************** def lower_program(ctx:HCQ2LowerCtx, call:UOp, prg:UOp) -> UOp: q = UOp(Ops.LINEAR, dtypes.void, (prg,), arg=(prg.src[0].buffer.device, "COMPUTE")) return UOp(Ops.LINEAR, dtypes.void, (q,), tag=call.tag) def lower_copy(ctx:HCQ2LowerCtx, call:UOp, copy:UOp) -> UOp: dst, src = call.src[1], call.src[2] q = UOp(Ops.LINEAR, dtypes.void, (UOp(Ops.COPY, dtypes.void, src=(dst, src), arg=src.buffer.nbytes),), arg=(dst.buffer.device, "COPY")) return UOp(Ops.LINEAR, dtypes.void, (q,), tag=call.tag) pm_lower_ops = PatternMatcher([ (UPat(Ops.CALL, src=(UPat(Ops.PROGRAM, src=(UPat(Ops.BUFFER), UPat()), name="prg"),), name="call", allow_any_len=True), lower_program), (UPat(Ops.CALL, src=(UPat(Ops.COPY, name="copy"),), name="call", allow_any_len=True), lower_copy), ]) # **************** split into queues **************** def split_into_queues(ctx:HCQ2LowerCtx, outer:UOp) -> UOp: groups:dict[tuple, list[UOp]] = collections.defaultdict(list) for child in outer.src: wrapper = child.src[0] if child.op is Ops.AFTER else child for q in wrapper.src: groups[q.arg].extend(q.src) return outer.replace(src=tuple(UOp(Ops.LINEAR, dtypes.void, tuple(cmds), arg=k) for k, cmds in groups.items())) pm_split_into_queues = PatternMatcher([(UPat(Ops.LINEAR, src=UPat(Ops.LINEAR, src=UPat(Ops.LINEAR)).or_after(), name="outer"), split_into_queues)]) # **************** add signals (runtime) **************** def add_signals(ctx:HCQ2LowerCtx, outer:UOp) -> UOp: def wrap(q:UOp) -> UOp: (dev_name, qname), devs = q.arg, {q.arg[0]} | {u.buffer.device for u in q.toposort() if u.op in (Ops.BUFFER, Ops.BUFFER_VIEW)} sigs_tls = [(UOp.from_buffer(Device[d].timeline_signal), ctx.host_param(Device[d].timeline_value)) for d in sorted(devs) if d.startswith("AMD")] return q.replace(src=(*(s.wait(t[0]-1) for s,t in sigs_tls), *q.src, *(s.store(t[0]) for s,t in sigs_tls)), arg=qname) return outer.replace(src=tuple(wrap(q) for q in outer.src)) pm_add_barriers = PatternMatcher([(UPat(Ops.LINEAR, src=UPat(Ops.LINEAR), name="outer"), lambda ctx, outer: outer.replace(src=tuple(q.replace(src=(UOp(Ops.BARRIER, dtypes.void), *q.src)) for q in outer.src)))]) pm_add_signals = PatternMatcher([(UPat(Ops.LINEAR, src=UPat(Ops.LINEAR), name="outer"), add_signals)]) # **************** build host program **************** def resolve_cmdbuf(ctx:HCQ2LowerCtx, blob:UOp) -> UOp: inner = blob.src[0] if blob.op is Ops.AFTER else blob dev_name, qtype = inner.tag # prepare the cmdbuf and make it a param bb = Buffer("CPU", len(inner.arg)//4, dtypes.uint32, preallocate=True) bb.copyin(memoryview(bytearray(inner.arg))) bb_param = ctx.host_param(bb) submit_cf = UOp(Ops.CUSTOM_FUNCTION, dtypes.void, src=(bb_param.after(*(blob.src[1:] if blob.op is Ops.AFTER else ())),), arg=f"submit_{qtype.lower()}", tag=dev_name) # increment the timeline value tl = ctx.host_param(Device[dev_name].timeline_value) return tl.after(UOp(Ops.BARRIER, dtypes.void, src=(submit_cf,))).index(UOp.const(dtypes.int, 0), ptr=True).store(tl[0] + 1) def resolve_patches(ctx:HCQ2LowerCtx, buf:UOp) -> UOp|None: inner = buf.src[0] # buffer is accessed from the launcher, so transform it to a host param if inner.op is Ops.BUFFER: inner = ctx.host_param(inner.buffer) return inner.after(*(inner.index(UOp.const(dtypes.int, p.arg//inner.dtype.base.itemsize), ptr=True).cast(p.dtype.ptr()).store(p.src[0].cast(p.dtype)) if p.op is Ops.PATCH else p for p in buf.src[1:])) def resolve_ref_buffers(ctx:HCQ2LowerCtx, buf:UOp) -> UOp: if buf not in ctx.holds: ctx.holds.append(buf) return UOp(Ops.NOOP) def hcq_callify(ctx:HCQ2LowerCtx, sink:UOp) -> UOp: call = to_program(sink, Device["CPU"].renderer).call(*[UOp.from_buffer(b, "CPU") if isinstance(b, Buffer) else b for b in ctx.inputs]) return call.replace(src=call.src + (UOp(Ops.BIND, dtypes.void, src=tuple(ctx.holds)),)) if ctx.holds else call pm_create_host_sink = PatternMatcher([ (UPat(Ops.LINEAR, name="l", allow_any_len=True), lambda ctx, l: UOp.sink(*l.src, arg=KernelInfo(name=ctx.name, estimates=Estimates()), tag=1)) ]) # lower cmdbuf submits pm_lower_cmdbufs = PatternMatcher([ (UPat(Ops.AFTER, src=(UPat(Ops.BINARY),), name="blob", allow_any_len=True), resolve_cmdbuf), (UPat(Ops.BINARY, name="blob"), resolve_cmdbuf), ]) # transform patches attached to buffers and params pm_resolve_patches = PatternMatcher([ (UPat(Ops.AFTER, src=(UPat((Ops.BUFFER, Ops.PARAM)),), name="buf", allow_any_len=True), resolve_patches) ]) # replace referenced buffers with noops pm_resolve_ref_buffers = PatternMatcher([(UPat((Ops.BUFFER, Ops.BUFFER_VIEW), name="buf"), resolve_ref_buffers)]) pm_callify = PatternMatcher([(UPat(Ops.SINK, name="sink"), hcq_callify)]) # **************** schedule **************** def prep_runtime(ctx:HCQ2LowerCtx, linear:UOp) -> tuple[UOp, dict[str,int]]: linear = graph_rewrite(linear, pm_prep_runtime, ctx=ctx, name="hcq: prepare runtime") graph_rewrite(linear, pm_calc_kernargs_sizes, ctx=(sizes:={}), enter_calls=True) return linear, sizes def build_host_program(ctx:HCQ2LowerCtx, linear:UOp, ast:UOp, dev:HCQ2Compiled) -> UOp: sink = graph_rewrite(linear, pm_create_host_sink, ctx=ctx, name="hcq: create host sink", walk=True) sink = graph_rewrite(sink, pm_lower_cmdbufs, ctx=ctx, bottom_up=True, name="hcq: lower cmdbufs") sink = graph_rewrite(sink, pm_resolve_patches, ctx=ctx, bottom_up=True, name="hcq: resolve patches") sink = graph_rewrite(sink, pm_resolve_ref_buffers, ctx=ctx, bottom_up=True, name="hcq: resolve ref buffers") sink = graph_rewrite(sink, dev.pm_lower, ctx=ctx, name=f"hcq: device lower {dev.device}", walk=True) return graph_rewrite(sink, pm_callify, ctx=ctx, name="hcq: callify") @track_rewrites(name=lambda ctx,linear,ast,dev,**kw: f"hcq schedule {getattr(ast.arg, 'name', ast.op.name.lower())}") def hcq_schedule(ctx:HCQ2LowerCtx, linear:UOp, ast:UOp, dev:HCQ2Compiled) -> UOp: linear, sizes = prep_runtime(ctx, linear) for dev_name, sz in sizes.items(): off = dev.kernargs_offset_allocator.alloc(sz, 16) ctx.devs[dev_name] = HCQ2DeviceCtx(dev_name, UOp.from_buffer(dev.kernargs_buf.view(sz, dtypes.uint8, off), dev_name), UOp.const(dtypes.uint64, dev.kernargs_buf.get_buf(dev_name).va_addr + off)) linear = graph_rewrite(linear, pm_lower_kernargs + pm_lower_ops, ctx=ctx, name="hcq: lower ops") linear = graph_rewrite(linear, pm_split_into_queues, ctx=ctx, name="hcq: split into queues") linear = graph_rewrite(linear, pm_add_barriers, ctx=ctx, name="hcq: add barriers", walk=True) linear = graph_rewrite(linear, pm_add_signals, ctx=ctx, name="hcq: add signals", walk=True) linear = graph_rewrite(linear, dev.pm_lower, ctx=ctx, name=f"hcq: encode cmdbuf {dev.device}", walk=True) return build_host_program(ctx, linear, ast, dev) def ensure_accessible(ctx:HCQ2LowerCtx, call:UOp, copy:UOp) -> UOp|None: src_buf = call.src[2].buffer # TODO: cleanup dev = call.src[1].buffer.device try: src_buf.get_buf(dev) except Exception: (cpubuf := Buffer("CPU", src_buf.nbytes, dtypes.uint8, preallocate=True)).copyin(src_buf.ensure_allocated().as_memoryview()) ctx.holds.append(buf_uop:=UOp.from_buffer(cpubuf, dev)) return call.replace(src=call.src[:2] + (buf_uop,) + call.src[3:]) pm_ensure_bufs_accessible = PatternMatcher([(UPat(Ops.CALL, src=(UPat(Ops.COPY, name="copy"),), name="call", allow_any_len=True), ensure_accessible)]) def hcq_exec(ctx:ExecContext, call:UOp, ast:UOp) -> float|None: from tinygrad.engine.realize import run_linear if ast.src[1].arg.split(":")[0] != "AMD": return None # TODO: this mess should gone resolved_call = call.replace(src=(ast,) + tuple(resolve_params(call, ctx.input_uops)) + tuple(s for s in call.src[1:] if s.op is Ops.BIND)) bufs = [cast(Buffer, resolved_call.src[1+gi].buffer) for gi in ast.arg.globals] if ast.op is Ops.PROGRAM \ else [cast(Buffer, resolved_call.src[i].buffer) for i in range(1, len(resolved_call.src))] dev = cast(HCQ2Compiled, Device[bufs[0].device]) hcq_ctx = HCQ2LowerCtx(name="submit") linear = graph_rewrite(UOp(Ops.LINEAR, dtypes.void, (resolved_call,)), pm_ensure_bufs_accessible, ctx=hcq_ctx) host_call = hcq_schedule(hcq_ctx, linear, ast, dev) with track_stats(ctx, call, dev.device, bufs, ctx.var_vals) as tm: st = time.perf_counter() if ctx.wait else 0.0 run_linear(UOp(Ops.LINEAR, dtypes.void, (host_call,)), var_vals=ctx.var_vals, jit=True, update_stats=DEBUG>=3) if ctx.wait: dev.synchronize() tm[0] = time.perf_counter() - st return tm[0] if tm[0] is not None else 0.0 pm_hcq_exec = PatternMatcher([ (UPat(Ops.CALL, src=(UPat({Ops.PROGRAM, Ops.COPY}, name="ast"),), name="call", allow_any_len=True), hcq_exec), ])