import subprocess, pathlib, struct, ctypes, tempfile, functools, decimal, platform from tinygrad.helpers import prod, to_mv, round_up, cache_dir, PROFILE, ProfileRangeEvent, cpu_profile, unwrap, suppress_finalizing import tinygrad.runtime.support.objc as objc from tinygrad.device import Compiled, Compiler, CompileError, LRUAllocator, ProfileDeviceEvent from tinygrad.renderer.cstyle import MetalRenderer from tinygrad.runtime.autogen import metal from tinygrad.runtime.support.c import DLL # 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) def from_ns_str(s): return bytes(objc.msg("UTF8String", ctypes.c_char_p)(s)).decode() def wait_check(cbuf:metal.MTLCommandBuffer): cbuf.waitUntilCompleted() error_check(cbuf.error().retained()) def cmdbuf_label(cbuf:metal.MTLCommandBuffer) -> str|None: return from_ns_str(label) if (label:=cbuf.label()).value is not None else None def error_check(error: metal.NSError, error_constructor: type[Exception] = RuntimeError): if error.value is None: return None raise error_constructor(from_ns_str(error.localizedDescription().retained())) class MetalDevice(Compiled): def __init__(self, device:str): self.sysdevice = metal.MTLCreateSystemDefaultDevice() self.mtl_queue = self.sysdevice.newCommandQueueWithMaxCommandBufferCount(1024) if self.mtl_queue is None: raise RuntimeError("Cannot allocate a new command queue") self.mtl_buffers_in_flight: list[metal.MTLCommandBuffer] = [] self.timeline_signal = self.sysdevice.newSharedEvent() self.timeline_value = 0 # probe GPU family: Apple9=M3/M4, Apple8=M2, Apple7=M1, etc. values are 1000+N. self.gpu_family = 0 for i in range(15, 0, -1): if self.sysdevice.supportsFamily(1000 + i): self.gpu_family = i break Compiled.profile_events += [ProfileDeviceEvent(device)] from tinygrad.runtime.graph.metal import MetalGraph # NOTE: GitHub CI macOS runners use paravirtualized metal which is broken with graph. # This can be reproduced locally with any virtualization software (like utm) that can create macOS VMs with apple's own virtualization framework. super().__init__(device, MetalAllocator(self), [MetalRenderer], functools.partial(MetalProgram, self), MetalGraph if 'virtual' not in from_ns_str(self.sysdevice.name()).lower() else None, arch=platform.machine()) def synchronize(self): for cbuf in self.mtl_buffers_in_flight: wait_check(cbuf) st, en = decimal.Decimal(cbuf.GPUStartTime()) * 1000000, decimal.Decimal(cbuf.GPUEndTime()) * 1000000 # NOTE: command buffers from MetalGraph are not profiled here if PROFILE and (lb:=cmdbuf_label(cbuf)) is not None and not lb.startswith("batched"): Compiled.profile_events += [ProfileRangeEvent(self.device, lb, st, en)] self.mtl_buffers_in_flight.clear() 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('= 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(' self.max_total_threads: exec_width = self.pipeline_state.threadExecutionWidth() memory_length = self.pipeline_state.staticThreadgroupMemoryLength() raise RuntimeError(f"local size {local_size} bigger than {self.max_total_threads} with exec width {exec_width} memory length {memory_length}") # commandBuffer/computeCommandEncoder returns +0 (autoreleased), so we can retain here. # https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/mmRules.html command_buffer = self.dev.mtl_queue.commandBuffer().retained() encoder = command_buffer.computeCommandEncoder().retained() encoder.setComputePipelineState(self.pipeline_state) for i,a in enumerate(bufs): encoder.setBuffer_offset_atIndex(a.buf, a.offset, i) for i,a in enumerate(vals, start=len(bufs)): encoder.setBytes_length_atIndex(bytes(ctypes.c_int(a)), 4, i) encoder.dispatchThreadgroups_threadsPerThreadgroup(metal.MTLSize(*global_size), metal.MTLSize(*local_size)) encoder.endEncoding() command_buffer.setLabel(to_ns_str(self.name)) # TODO: is this always needed? command_buffer.commit() self.dev.mtl_buffers_in_flight.append(command_buffer) if wait: wait_check(command_buffer) return command_buffer.GPUEndTime() - command_buffer.GPUStartTime() class MetalBuffer: def __init__(self, buf:metal.MTLBuffer, size:int, offset=0): self.buf, self.size, self.offset = buf, size, offset class MetalAllocator(LRUAllocator[MetalDevice]): def _alloc(self, size:int, options) -> MetalBuffer: if options.external_ptr: return MetalBuffer(metal.MTLBuffer(options.external_ptr), size) # Buffer is explicitly released in _free() rather than garbage collected via reference count ret = self.dev.sysdevice.newBufferWithLength_options(size, metal.MTLResourceStorageModeShared) ret.retain = False if ret.value is None: raise MemoryError(f"Metal OOM while allocating {size=}") return MetalBuffer(ret, size) @suppress_finalizing def _free(self, opaque:MetalBuffer, options): if not options.external_ptr: opaque.buf.release() def _transfer(self, dest:MetalBuffer, src:MetalBuffer, sz:int, src_dev:MetalDevice, dest_dev:MetalDevice): dest_dev.synchronize() src_command_buffer = src_dev.mtl_queue.commandBuffer().retained() encoder = src_command_buffer.blitCommandEncoder().retained() encoder.copyFromBuffer_sourceOffset_toBuffer_destinationOffset_size(src.buf, src.offset, dest.buf, dest.offset, sz) encoder.endEncoding() if src_dev != dest_dev: src_command_buffer.encodeSignalEvent_value(ctypes.cast(src_dev.timeline_signal, metal.MTLEvent), src_dev.timeline_value) dest_command_buffer = dest_dev.mtl_queue.commandBuffer().retained() dest_command_buffer.encodeWaitForEvent_value(ctypes.cast(src_dev.timeline_signal, metal.MTLEvent), src_dev.timeline_value) dest_command_buffer.commit() dest_dev.mtl_buffers_in_flight.append(dest_command_buffer) src_dev.timeline_value += 1 src_command_buffer.setLabel(to_ns_str(f"COPY {src_dev.device} -> {dest_dev.device}")) src_command_buffer.commit() src_dev.mtl_buffers_in_flight.append(src_command_buffer) # Transfers currently synchronize the completion. Otherwise, copies can sometimes lead to incorrect values. # There is no real metal multidevice support for now, so transfer is used only for tests. src_dev.synchronize() def _cp_mv(self, dst, src, prof_desc): with cpu_profile(prof_desc, f"{self.dev.device}:COPY"): dst[:] = src def _as_buffer(self, src:MetalBuffer) -> memoryview: self.dev.synchronize() return to_mv(src.buf.contents(), src.size + src.offset)[src.offset:] def _copyin(self, dest:MetalBuffer, src:memoryview): self._cp_mv(self._as_buffer(dest), src, "TINY -> METAL") def _copyout(self, dest:memoryview, src:MetalBuffer): self._cp_mv(dest, self._as_buffer(src), "METAL -> TINY") def _offset(self, buf:MetalBuffer, size:int, offset:int): return MetalBuffer(buf.buf, size, offset)