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

287 lines
18 KiB
Python

from __future__ import annotations
import subprocess, pathlib, struct, ctypes, tempfile, functools, platform, weakref, threading, array, sys
from tinygrad.helpers import to_mv, round_up, cache_dir, unwrap, prod, dedup, to_tuple
import tinygrad.runtime.support.objc as objc
from tinygrad.device import Buffer, BufferStorage, BufferSpec, Allocator, Compiled, Compiler, CompileError, MMIOInterface
from tinygrad.dtype import dtypes, AddrSpace
from tinygrad.renderer.cstyle import MetalRenderer
from tinygrad.runtime.autogen import metal
from tinygrad.runtime.support.c import DLL
from tinygrad.runtime.support.hcq2 import HWQueue, ccall, patch, layout_args, to_name, bufferize_buf
from tinygrad.uop.ops import Ops, UOp, UPat, PatternMatcher, uopfunc
from tinygrad.engine.realize import get_call_arg_uops, get_call_var_uops
# 13 is requestType that metal uses to compile source code into MTLB, there aren't any docs or symbols.
REQUEST_TYPE_COMPILE = 13
# Must be loaded for default Metal Device: https://developer.apple.com/documentation/metal/1433401-mtlcreatesystemdefaultdevice?language=objc
DLL("CoreGraphics", "CoreGraphics")
# FIXME: these need autogen to support objc categories
# https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjectiveC/Chapters/ocCategories.html
@functools.cache
def to_ns_str(s:str): return ctypes.cast(objc.msg("stringWithUTF8String:")(metal.NSString._objc_class_, s.encode()), metal.NSString).own()
def checked(fn, *args): # fn(*args, &error), raised if set
ret = fn(*args, ctypes.byref(err:=metal.NSError()))
if err.value is not None: raise RuntimeError(bytes(objc.msg("UTF8String", ctypes.c_char_p)(err.localizedDescription())).decode())
return ret
class MetalCompiler(Compiler):
# Opening METAL after LLVM doesn't fail because ctypes.CDLL opens with RTLD_LOCAL but MTLCompiler opens it's own llvm with RTLD_GLOBAL
# This means that MTLCompiler's llvm will create it's own instances of global state because RTLD_LOCAL doesn't export symbols, but if RTLD_GLOBAL
# library is loaded first then RTLD_LOCAL library will just use it's symbols. On linux there is RTLD_DEEPBIND to prevent that, but on macos there
# doesn't seem to be anything we can do.
import tinygrad.runtime.autogen.llvm as _
support = DLL("MTLCompiler", "MTLCompiler")
support.MTLCodeGenServiceCreate.restype = ctypes.c_void_p
def __init__(self):
self.cgs = ctypes.c_void_p(MetalCompiler.support.MTLCodeGenServiceCreate(b"tinygrad"))
super().__init__("compile_metal_direct")
def __reduce__(self): return (MetalCompiler,()) # force pickle to create new instance for each multiprocessing fork
def compile(self, src:str) -> bytes:
ret: Exception|bytes = CompileError("MTLCodeGenServiceBuildRequest returned without calling the callback")
@ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_int32, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_char_p)
def callback(blockptr, error, dataPtr, dataLen, errorMessage):
nonlocal ret
if error == 0:
reply = bytes(to_mv(dataPtr, dataLen))
# offset from beginning to data = header size + warning size
ret = reply[sum(struct.unpack('<LL', reply[8:16])):]
else:
ret = CompileError(errorMessage.decode())
# no changes for compute in 2.0 - 2.4 specs, use 2.0 as default for old versions.
macos_major = int(platform.mac_ver()[0].split('.')[0])
metal_version = "metal4.0" if macos_major >= 26 else "metal3.1" if macos_major >= 14 else "metal3.0" if macos_major >= 13 else "macos-metal2.0"
# llvm will create modules.timestamp in cache path and cache compilation of metal stdlib (250ms => 8ms compilation time)
# note that llvm won't necessarily create anything else here as apple has prebuilt versions of many standard libraries
params = f'-fno-fast-math -std={metal_version} --driver-mode=metal -x metal -fmodules-cache-path="{cache_dir}" -fno-caret-diagnostics'
# source blob has to be padded to multiple of 4 but at least one 'b\x00' should be added, params blob just has to be null terminated
src_padded, params_padded = src.encode() + b'\x00'*(round_up(len(src) + 1, 4) - len(src)), params.encode() + b'\x00'
request = struct.pack('<QQ', len(src_padded), len(params_padded)) + src_padded + params_padded
# The callback is actually not a callback but a block which is apple's non-standard extension to add closures to C.
# See https://clang.llvm.org/docs/Block-ABI-Apple.html#high-level for struct layout.
# Fields other than invoke are unused in this case so we can just use ctypes.byref with negative offset to invoke field, add blockptr as a first
# argument and pretend it's a normal callback
MetalCompiler.support.MTLCodeGenServiceBuildRequest(self.cgs, None, REQUEST_TYPE_COMPILE, request, len(request), ctypes.byref(callback, -0x10))
if isinstance(ret, Exception): raise ret
assert ret[:4] == b"MTLB" and ret[-4:] == b"ENDT", f"Invalid Metal library. {ret!r}"
return ret
def disassemble(self, lib:bytes):
with tempfile.NamedTemporaryFile(delete=True) as shader:
shader.write(lib)
shader.flush()
proc = subprocess.Popen(f"cd {pathlib.Path(__file__).parents[2]}/extra/disassemblers/applegpu && python3 compiler_explorer.py {shader.name}",
stdout=subprocess.PIPE, shell=True, text=True, bufsize=1)
for line in unwrap(proc.stdout): print(line, end="")
ret = proc.wait()
if ret: print("Disassembler Error: Make sure you have https://github.com/dougallj/applegpu cloned to tinygrad/extra/disassemblers/applegpu")
# *****************
# queue
HANDLES = ("queue", "event", "fence", "resources", "count")
MSGSEND, SELNAME = [metal.dll.bind(ctypes.c_void_p)(f) for f in (metal.dll.objc_msgSend, metal.dll.sel_registerName)]
# the slot of a handle of the device
def mtl_handle(d, name:str) -> UOp:
return UOp.alloc((len(HANDLES),), dtypes.uint64, 0, device=to_tuple(d)[0]).rtag(to_name(to_tuple(d)[0], "handles"))[(i:=HANDLES.index(name)):i+1]
@uopfunc
def mtl_send(obj:UOp, sel:UOp, a:UOp, b:UOp, c:UOp, out:UOp|None=None) -> UOp: # objc_msgSend by the selector's name, the result to out
c = ccall(MSGSEND, obj, ccall(SELNAME, sel), a, b, c)
return (out.index(0).store(c) if out is not None else c).sink()
def mtl_msg(obj:UOp, sel:str, *args:UOp|int, out:UOp|None=None) -> UOp:
words = [UOp.const(a, dtypes.uint64) if isinstance(a, int) else a for a in (*args, 0, 0, 0)][:3] # the extra args are ignored
return mtl_send(obj.index(0).load(), UOp(Ops.BINARY, arg=sel.encode() + b"\0").index(0), *words, out=out)
@uopfunc
def mtl_run(icb:UOp, value:UOp, first:UOp|int, count:int, last:bool, q:MetalQueue, stamp:UOp|None=None) -> UOp:
cmds, hdr, devs, dev = q.cmds, round_up(q.nbytes, 8) + 24, q.devs, q.dev # a command buffer for the commands [first, first + count)
cb, enc = [UOp.alloc((1,), dtypes.uint64, 0, device=devs[0]).rtag(t) for t in ("mtl_cb", "mtl_enc")]
fence, event = [mtl_handle(devs, h).index(0).load() for h in ("fence", "event")]
c = mtl_msg(mtl_handle(devs, "queue"), "commandBuffer", out=cb)
c = mtl_msg(cb.after(c), "computeCommandEncoder", out=enc)
c = mtl_msg(enc.after(c), "waitForFence:", fence)
if dev.residency.value is None: # no residency set: declare the buffers
c = mtl_msg(enc.after(c), "useResources:count:usage:", *[mtl_handle(devs, h).index(0).load() for h in ("resources", "count")], 3)
# before apple9 the encoder must use the pipelines
if not dev.arch.startswith("Apple") or int(dev.arch[5:]) < 9:
r = UOp.range(len(dedup(c[:2] for c in cmds)), next(UOp.unique_num), dtype=dtypes.uint64)
c = mtl_msg(enc.after(c), "setComputePipelineState:", icb.bitcast(dtypes.uint64).index(hdr // 8 + 1 + len(cmds) + r).load())
c = mtl_msg(enc.after(c), "dispatchThreadgroups:threadsPerThreadgroup:", icb.index(hdr - 24), icb.index(hdr - 24)).end(r)
c = mtl_msg(enc.after(c), "executeCommandsInBuffer:withRange:", icb.bitcast(dtypes.uint64).index(hdr // 8).load(), first, count)
c = mtl_msg(enc.after(c), "updateFence:", fence)
c = mtl_msg(enc.after(c), "endEncoding")
if stamp is not None: # write meta to collect timestamps: [command buffer, 0] until synchronize reads its times. MTL4 solves that dance
c = stamp.after(c).index(3).store(0)
c = stamp.after(c).index(1).store(cb.after(c).index(0).load())
if last: c = mtl_msg(cb.after(c), "encodeSignalEvent:value:", event, value)
return mtl_msg(cb.after(c), "commit").sink()
class MetalQueue(HWQueue):
dev:MetalDevice
def __init__(self, submit:UOp):
super().__init__(submit)
self.rows, self.cmds, self.sizes, self.stamps, self.nbytes = list[tuple[int, UOp]](), list[tuple](), list[tuple[int, int]](), list[UOp](), 0
def exec(self, call:UOp, prg:UOp):
bufs, vals, obj = get_call_arg_uops(call), get_call_var_uops(call, prg), prg.to_elf()
args = [bufs[i].getaddr(self.devs) for i in prg.arg.globals] + [v.ccast(var.dtype) for v, var in zip(vals, prg.arg.vars)]
self.rows += (rows:=layout_args(args, off:=round_up(self.nbytes, 256)))
self.nbytes = max([o + w.dtype.itemsize for o, w in rows], default=off + 8)
# symbolic sizes, set on the command at run time
dims = (*prg.arg.global_size, *prg.arg.local_size)
if any(isinstance(d, UOp) for d in dims):
self.sizes.append((len(self.cmds), at:=round_up(self.nbytes, 8)))
self.rows += layout_args([UOp.const(d, dtypes.uint64) for d in dims], at)
self.nbytes = at + 48
self.cmds.append((obj.lib, obj.name, tuple(1 if isinstance(d, UOp) else int(d) for d in dims), off))
def wait(self, dst:UOp, val:UOp, eq=False): pass # the fence orders the queue
def timestamp(self, dst:UOp): self.stamps.append(dst)
def signal(self, dst:UOp, val:UOp): self.value = val
def submit(self, cmdbuf:UOp) -> UOp:
n, zero, pipes = len(self.cmds), round_up(self.nbytes, 8), dedup(c[:2] for c in self.cmds)
tag = (self.dev.tag("mtl_icb"), tuple(self.cmds), zero + 24)
buf = UOp.alloc((zero + 24 + 8 * (1 + n + len(pipes)),), dtypes.uint8, device=self.devs[0]).rtag(tag).after(*self.deps)
icb = patch(buf, self.rows + [(zero + 8 * i, UOp.const(0, dtypes.uint64)) for i in range(3)])
# symbolic sizes
for ci, off in self.sizes:
cmd = icb.bitcast(dtypes.uint64)[zero // 8 + 4 + ci:zero // 8 + 5 + ci]
icb = icb.after(mtl_msg(cmd, "concurrentDispatchThreadgroups:threadsPerThreadgroup:", icb.index(off), icb.index(off + 24)))
# collect timestamps using cmdbuf metrics, so sep cmdbufs
if not self.stamps: return mtl_run(icb, self.value, 0, n, True, self)
slots, r = self.stamps[0].src[0], UOp.range(n - 1, next(UOp.unique_num), dtype=dtypes.uint64) # slots: [signal, timeline, [x, cb, x, end]...]
if n > 1: icb = icb.after(mtl_run(icb.after(r), self.value, r, 1, False, self, slots.shrink(((4 + 4 * r, 8 + 4 * r),))).end(r))
return mtl_run(icb, self.value, n - 1, 1, True, self, slots.shrink(((4 * n, 4 * n + 4),)))
# *****************
# device
class MetalAllocator(Allocator['MetalDevice']):
def _alloc(self, size:int, options:BufferSpec) -> BufferStorage:
mtl = metal.MTLBuffer(options.external_ptr) if options.external_ptr else \
self.dev.sysdevice.newBufferWithLength_options(size, metal.MTLResourceStorageModeShared)
if mtl.value is None: raise MemoryError(f"Metal OOM while allocating {size=}")
self.dev.mark_resident(mtl, True)
return BufferStorage(mtl.gpuAddress(), mtl, MMIOInterface(c, size) if (c:=mtl.contents()) else None)
def _free(self, storage:BufferStorage, options:BufferSpec): # metal doesn't track what a kernel reaches
self.dev.synchronize()
self.dev.mark_resident(storage.meta, False)
storage.meta.retain = False
storage.meta.release()
def _offset(self, buf:int, size:int, offset:int) -> int: return buf + offset
class MetalDevice(Compiled):
has_copy_queue = False
pm_encode = PatternMatcher([
(UPat(Ops.CALL, src=(UPat.custom_function("submit_metal_compute"), UPat()), name="s"), lambda s: MetalQueue(s).encode()),
])
pm_lower = PatternMatcher([
(UPat(Ops.PARAM, name="p").f(Ops.AFTER, allow_any_len=True, name="t").index(UPat.const(0)).load(), lambda p, t: None if p.arg.name != "tl" else \
(r:=UOp.placeholder((1,), dtypes.uint64, None, AddrSpace.REG)).after(mtl_msg(mtl_handle(p.device, "event").after(t), "signaledValue", out=r))[0])
])
def __init__(self, device:str=""):
self.sysdevice = metal.MTLCreateSystemDefaultDevice()
# queue allocation
self.queue = self.sysdevice.newCommandQueueWithMaxCommandBufferCount(1024)
if self.queue.value is None: raise RuntimeError("Cannot allocate a new command queue")
# try to use residency set when supported
rsd = ctypes.cast(objc.msg("new", clsmeth=True)(metal.MTLResidencySetDescriptor), metal.MTLResidencySetDescriptor)
self.residency = self.sysdevice.newResidencySetWithDescriptor_error(rsd, None)
if self.residency.value is not None: self.queue.addResidencySet(self.residency)
self.resources, self.table = list[int](), (ctypes.c_uint64 * 1)()
self.event, self.fence = self.sysdevice.newSharedEvent(), self.sysdevice.newFence()
self.icbs, self.profile_slots = weakref.WeakKeyDictionary[Buffer, tuple](), weakref.WeakSet[Buffer]()
# https://developer.apple.com/documentation/metal/mtlgpufamily
def check_family(f): return next(filter(self.sysdevice.supportsFamily, reversed([v for v, nm in metal.enum_MTLGPUFamily.items() if f in nm])), 0)
super().__init__(device, MetalAllocator(self), [MetalRenderer], None,
arch=metal.enum_MTLGPUFamily[check_family("Apple") or check_family("Mac")][12:])
Compiled.pm_bufferize += PatternMatcher([
(UPat(Ops.ALLOC, tag=self.tag("handles")), lambda d=self: d.handles),
(UPat(Ops.ALLOC, tag="slots", name="b"), # with stamps
lambda b, d=self: d.new_slots(b.max_numel()) if b.device == d.device and b.max_numel() > 4 else None),
(UPat(Ops.ALLOC, name="b"), lambda ctx, b, d=self:
d.new_icb(bufferize_buf(ctx, b.rtag(None)).buffer, *b.tag[1:]) if isinstance(b.tag, tuple) and b.tag[0] == d.tag("mtl_icb") else None)])
@functools.cached_property
def handles(self) -> Buffer:
vals = [self.queue.value, self.event.value, self.fence.value, ctypes.addressof(self.table), len(self.resources)]
return Buffer(self.host, len(vals) * 8, initial_value=struct.pack(f"{len(vals)}Q", *vals))
def mark_resident(self, mtl:metal.MTLBuffer, add:bool):
if self.residency.value is not None:
objc.msg("addAllocation:" if add else "removeAllocation:", None, [objc.id_])(self.residency, mtl)
return objc.msg("commit", None)(self.residency)
self.resources.append(unwrap(mtl.value)) if add else self.resources.remove(unwrap(mtl.value))
self.table = (ctypes.c_uint64 * max(len(self.resources), 1))(*self.resources)
# update sels table
if "handles" in self.__dict__: self.handles.host.view(fmt='Q')[3:5] = array.array('Q', [ctypes.addressof(self.table), len(self.resources)])
def new_slots(self, n:int) -> Buffer:
self.profile_slots.add(buf:=Buffer(self.host, n * 8, initial_value=bytes(8 * n)))
return buf
@functools.cache
def pipeline(self, lib:bytes, name:str) -> metal.MTLComputePipelineState:
library = checked(self.sysdevice.newLibraryWithData_error, objc.dispatch_data_create(lib, len(lib), None, None))
descriptor = metal.MTLComputePipelineDescriptor.new()
descriptor.setComputeFunction(library.newFunctionWithName(to_ns_str(name)))
descriptor.setSupportIndirectCommandBuffers(True)
return checked(self.sysdevice.newComputePipelineStateWithDescriptor_options_reflection_error, descriptor, metal.MTLPipelineOptionNone, None)
def new_icb(self, buf:Buffer, cmds:tuple[tuple[bytes, str, tuple[int, ...], int], ...], header:int) -> Buffer:
pipes = dedup(c[:2] for c in cmds)
desc = metal.MTLIndirectCommandBufferDescriptor.new()
desc.setCommandTypes(metal.MTLIndirectCommandTypeConcurrentDispatch)
desc.setMaxKernelBufferBindCount(1)
icb = self.sysdevice.newIndirectCommandBufferWithDescriptor_maxCommandCount_options(desc, max(len(cmds), 1), 0)
if icb.value is None: raise RuntimeError("create indirect command buffer failed, does your system support this?")
commands = [icb.indirectComputeCommandAtIndex(i).own() for i in range(len(cmds))]
for cmd, (lib, name, dims, off) in zip(commands, cmds):
cmd.setComputePipelineState(state:=self.pipeline(lib, name))
if prod(dims[3:]) > (mx:=state.maxTotalThreadsPerThreadgroup()): raise RuntimeError(f"local size {dims[3:]} bigger than {mx}")
cmd.setKernelBuffer_offset_atIndex(buf.meta, buf.offset + off, 0)
cmd.concurrentDispatchThreadgroups_threadsPerThreadgroup(metal.MTLSize(*dims[:3]), metal.MTLSize(*dims[3:]))
cmd.setBarrier()
buf.host.view(fmt='Q')[header // 8:] = array.array('Q', [icb.value, *[c.value for c in commands], *[self.pipeline(*p).value for p in pipes]])
self.icbs[buf] = (icb, commands)
return buf
def _wait_signal(self, sig:MMIOInterface|memoryview, value:int, timeout:int|None=None):
if sys.is_finalizing(): return # the event doesn't wake at exit
wait = objc.msg("waitUntilSignaledValue:timeoutMS:", ctypes.c_bool, [ctypes.c_uint64, ctypes.c_uint64])
if not wait(self.event, value, int(self.wait_timeout_ms)): raise RuntimeError(f"{self.device} signal wait timed out")
def synchronize(self, timeout:int|None=None):
for buf in list(self.profile_slots): # pending: [command buffer, 0]
slots = buf.host.view(fmt='Q')
for start in range(5, buf.nbytes // 8, 4):
if slots[start] and not slots[start + 2]:
(cb:=metal.MTLCommandBuffer(slots[start])).waitUntilCompleted()
slots[start], slots[start + 2] = int(cb.GPUStartTime() * 1e9), int(cb.GPUEndTime() * 1e9)
super().synchronize(timeout)
if (pool:=getattr(pools, "pool", None)) is not None: objc.lib.objc_autoreleasePoolPop(pool)
pools.pool = objc.lib.objc_autoreleasePoolPush()
pools = threading.local()