use std::cell::RefCell;
use std::ffi::c_void;
use std::ptr::NonNull;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_foundation::NSString;
use objc2_metal::{
MTLBuffer, MTLCommandBuffer, MTLCommandBufferStatus, MTLCommandEncoder, MTLCommandQueue, MTLCompileOptions,
MTLComputeCommandEncoder, MTLComputePipelineState, MTLCopyAllDevices, MTLCreateSystemDefaultDevice, MTLDevice,
MTLLibrary, MTLMathMode, MTLResourceOptions, MTLSize,
};
use silvic_core::{Result, SilvicError};
use crate::backend::GpuBackend;
use crate::device::{Arg, GpuDevice};
pub fn is_available() -> bool {
MTLCreateSystemDefaultDevice().is_some()
}
pub fn backend(index: Option<usize>) -> Result<GpuBackend<MetalDevice>> {
let mut backend = GpuBackend::new(MetalDevice::new(index)?);
backend.compile()?;
Ok(backend)
}
pub struct MetalBuffer {
buffer: Retained<ProtocolObject<dyn MTLBuffer>>,
len: usize,
}
unsafe impl Send for MetalBuffer {}
impl MetalBuffer {
fn check_range(&self, offset: usize, len: usize) -> Result<()> {
match offset.checked_add(len) {
Some(end) if end <= self.len => Ok(()),
_ => Err(SilvicError::device(format!(
"buffer access {offset}..{} out of bounds of {} bytes",
offset.saturating_add(len),
self.len
))),
}
}
}
struct Pending {
command_buffer: Retained<ProtocolObject<dyn MTLCommandBuffer>>,
encoder: Retained<ProtocolObject<dyn MTLComputeCommandEncoder>>,
}
pub struct MetalDevice {
device: Retained<ProtocolObject<dyn MTLDevice>>,
queue: Retained<ProtocolObject<dyn MTLCommandQueue>>,
pipelines: Vec<Retained<ProtocolObject<dyn MTLComputePipelineState>>>,
pending: RefCell<Option<Pending>>,
committed: RefCell<Vec<Retained<ProtocolObject<dyn MTLCommandBuffer>>>>,
}
unsafe impl Send for MetalDevice {}
impl MetalDevice {
pub fn new(index: Option<usize>) -> Result<MetalDevice> {
let device = match index {
None => MTLCreateSystemDefaultDevice().ok_or_else(|| SilvicError::device("no metal device"))?,
Some(index) => {
let devices = MTLCopyAllDevices();
if index >= devices.count() {
return Err(SilvicError::device(format!(
"metal device {index} requested, {} present",
devices.count()
)));
}
devices.objectAtIndex(index)
}
};
let queue = device
.newCommandQueue()
.ok_or_else(|| SilvicError::device("cannot create a metal command queue"))?;
Ok(MetalDevice {
device,
queue,
pipelines: Vec::new(),
pending: RefCell::new(None),
committed: RefCell::new(Vec::new()),
})
}
fn encoder(&self) -> Result<std::cell::RefMut<'_, Pending>> {
let mut pending = self.pending.borrow_mut();
if pending.is_none() {
let command_buffer = self
.queue
.commandBuffer()
.ok_or_else(|| SilvicError::device("cannot create a metal command buffer"))?;
let encoder = command_buffer
.computeCommandEncoder()
.ok_or_else(|| SilvicError::device("cannot create a metal compute encoder"))?;
*pending = Some(Pending {
command_buffer,
encoder,
});
}
Ok(std::cell::RefMut::map(pending, |pending| {
pending.as_mut().expect("open command buffer")
}))
}
fn pipeline(&self, kernel: usize) -> Result<&ProtocolObject<dyn MTLComputePipelineState>> {
self.pipelines
.get(kernel)
.map(|pipeline| &**pipeline)
.ok_or_else(|| SilvicError::device(format!("kernel {kernel} is not compiled")))
}
}
impl GpuDevice for MetalDevice {
type Buffer = MetalBuffer;
const PRELUDE: &'static str = include_str!("../kernels/prelude_metal.h");
fn name(&self) -> String {
self.device.name().to_string()
}
fn max_threads_per_group(&self) -> u32 {
let device_max = self.device.maxThreadsPerThreadgroup().width;
let pipeline_max = self.pipelines.iter().map(|p| p.maxTotalThreadsPerThreadgroup()).min();
pipeline_max.map_or(device_max, |max| max.min(device_max)) as u32
}
fn alloc(&self, bytes: usize) -> Result<MetalBuffer> {
let len = bytes.max(4);
if len > self.device.maxBufferLength() {
return Err(SilvicError::device(format!(
"metal buffer of {len} bytes exceeds the device limit of {} bytes",
self.device.maxBufferLength()
)));
}
let buffer = self
.device
.newBufferWithLength_options(len, MTLResourceOptions::StorageModeShared)
.ok_or_else(|| SilvicError::device(format!("cannot allocate a metal buffer of {len} bytes")))?;
Ok(MetalBuffer { buffer, len })
}
fn upload(&self, buffer: &MetalBuffer, offset: usize, data: &[u8]) -> Result<()> {
buffer.check_range(offset, data.len())?;
self.sync()?;
unsafe {
let dst = buffer.buffer.contents().as_ptr().cast::<u8>().add(offset);
std::ptr::copy_nonoverlapping(data.as_ptr(), dst, data.len());
}
Ok(())
}
fn download(&self, buffer: &MetalBuffer, offset: usize, out: &mut [u8]) -> Result<()> {
buffer.check_range(offset, out.len())?;
self.sync()?;
unsafe {
let src = buffer.buffer.contents().as_ptr().cast::<u8>().add(offset);
std::ptr::copy_nonoverlapping(src, out.as_mut_ptr(), out.len());
}
Ok(())
}
fn compile(&mut self, source: &str, kernels: &[&str]) -> Result<()> {
let options = MTLCompileOptions::new();
options.setMathMode(MTLMathMode::Safe);
let library = self
.device
.newLibraryWithSource_options_error(&NSString::from_str(source), Some(&options))
.map_err(|err| {
SilvicError::device(format!(
"metal kernel compilation failed: {}",
err.localizedDescription()
))
})?;
self.pipelines = kernels
.iter()
.map(|&name| compile_pipeline(&self.device, &library, name))
.collect::<Result<_>>()?;
Ok(())
}
fn launch(&self, kernel: usize, grid: [u32; 3], block: [u32; 3], args: &[Arg<'_, MetalBuffer>]) -> Result<()> {
let pipeline = self.pipeline(kernel)?;
let threads = block.iter().map(|&b| b as usize).product::<usize>();
if threads == 0 || threads > pipeline.maxTotalThreadsPerThreadgroup() {
return Err(SilvicError::device(format!(
"kernel {kernel}: {threads} threads per group, at most {} allowed",
pipeline.maxTotalThreadsPerThreadgroup()
)));
}
if grid.contains(&0) {
return Ok(());
}
let pending = self.encoder()?;
let encoder = &pending.encoder;
encoder.setComputePipelineState(pipeline);
for (index, arg) in args.iter().enumerate() {
match arg {
Arg::Buffer(buffer) => unsafe { encoder.setBuffer_offset_atIndex(Some(&buffer.buffer), 0, index) },
Arg::Bytes(bytes) => {
let ptr = NonNull::new(bytes.as_ptr().cast_mut().cast::<c_void>())
.ok_or_else(|| SilvicError::device("null parameter bytes"))?;
unsafe { encoder.setBytes_length_atIndex(ptr, bytes.len(), index) }
}
}
}
encoder.dispatchThreadgroups_threadsPerThreadgroup(mtl_size(grid), mtl_size(block));
Ok(())
}
fn sync(&self) -> Result<()> {
self.flush()?;
let committed = std::mem::take(&mut *self.committed.borrow_mut());
for command_buffer in &committed {
command_buffer.waitUntilCompleted();
}
for command_buffer in &committed {
if command_buffer.status() == MTLCommandBufferStatus::Error {
let reason = command_buffer.error().map_or_else(
|| "unknown error".to_string(),
|err| err.localizedDescription().to_string(),
);
return Err(SilvicError::device(format!("metal command buffer failed: {reason}")));
}
}
Ok(())
}
fn flush(&self) -> Result<()> {
if let Some(pending) = self.pending.borrow_mut().take() {
pending.encoder.endEncoding();
pending.command_buffer.commit();
self.committed.borrow_mut().push(pending.command_buffer);
}
Ok(())
}
}
impl Drop for MetalDevice {
fn drop(&mut self) {
let _ = self.sync();
}
}
fn compile_pipeline(
device: &ProtocolObject<dyn MTLDevice>,
library: &ProtocolObject<dyn MTLLibrary>,
name: &str,
) -> Result<Retained<ProtocolObject<dyn MTLComputePipelineState>>> {
let function = library
.newFunctionWithName(&NSString::from_str(name))
.ok_or_else(|| SilvicError::device(format!("metal kernel {name} not found in the library")))?;
device
.newComputePipelineStateWithFunction_error(&function)
.map_err(|err| SilvicError::device(format!("metal pipeline {name}: {}", err.localizedDescription())))
}
fn mtl_size(size: [u32; 3]) -> MTLSize {
MTLSize {
width: size[0] as usize,
height: size[1] as usize,
depth: size[2] as usize,
}
}