Files
zeekayandHanzo Dev 501e62a4f8 perf(metal): cache compiled MPSGraph conv per shape -> fixes UNet regression
Per-conv MPSGraph::new()+compile was a net loss on the UNets many small convs
(64->88ms). Caching the compiled graph+placeholders per (shape,dtype,params):
UNet back to 62ms (baseline parity), FULL-FAST 5.38->10.42 fps, full-VAE
1.04->2.24 fps. cosine still 1.000000.

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-16 11:29:14 -07:00

309 lines
11 KiB
Rust

use crate::{DType, Result};
#[cfg(feature = "ug")]
use hanzo_metal_kernels::metal::ComputePipeline;
use hanzo_metal_kernels::{
metal::{
BlitCommandEncoder, Buffer, BufferMap, Commands, ComputeCommandEncoder, Device,
MTLResourceOptions,
},
Kernels,
};
use objc2_foundation::NSURL;
use objc2_metal::{MTLCaptureDescriptor, MTLCaptureDestination, MTLCaptureManager};
use std::path::Path;
use std::sync::{Arc, Mutex, RwLock};
use super::MetalError;
/// Unique identifier for metal devices.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct DeviceId(usize);
impl DeviceId {
pub(crate) fn new() -> Self {
// https://users.rust-lang.org/t/idiomatic-rust-way-to-generate-unique-id/33805
use std::sync::atomic;
static COUNTER: atomic::AtomicUsize = atomic::AtomicUsize::new(1);
Self(COUNTER.fetch_add(1, atomic::Ordering::Relaxed))
}
}
#[derive(Clone)]
pub struct MetalDevice {
/// Unique identifier, the registryID is not sufficient as it identifies the GPU rather than
/// the device itself.
pub(crate) id: DeviceId,
/// Raw metal device: <https://developer.apple.com/documentation/metal/mtldevice?language=objc>
pub(crate) device: Device,
pub(crate) commands: Arc<RwLock<Commands>>,
/// Simple allocator struct.
/// The buffers are stored in size buckets since ML tends to use similar shapes over and over.
/// We store the buffers in [`Arc`] because it's much faster than Obj-c internal ref counting
/// (could be linked to FFI communication overhead).
///
/// Whenever a buffer has a strong_count==1, we can reuse it, it means it was dropped in the
/// graph calculation, and only we the allocator kept a reference to it, therefore it's free
/// to be reused. However, in order for this to work, we need to guarantee the order of
/// operation, so that this buffer is not being used by another kernel at the same time.
/// Arc is the CPU reference count, it doesn't mean anything on the GPU side of things.
///
/// Whenever we actually allocate a new buffer, we make a full sweep to clean up unused buffers
/// (strong_count = 1).
pub(crate) buffers: Arc<RwLock<BufferMap>>,
/// Simple keeper struct to keep track of the already compiled kernels so we can reuse them.
/// Heavily used by [`hanzo_metal_kernels`]
pub(crate) kernels: Arc<Kernels>,
/// Seed for random number generation.
pub(crate) seed: Arc<Mutex<Buffer>>,
/// Last seed value set on this device.
pub(crate) seed_value: Arc<RwLock<u64>>,
/// Lazily-created dedicated command queue for the MPSGraph conv path (see `mps_conv.rs`).
/// Kept separate from `commands` so MPSGraph work doesn't interleave with candle's encoders;
/// correctness is via `wait_until_completed()` before each MPSGraph run.
pub(crate) mps_queue: Arc<std::sync::OnceLock<hanzo_metal_kernels::metal::CommandQueue>>,
/// Compiled-MPSGraph cache keyed by conv shape/dtype (see `mps_conv.rs`). MPSGraph build +
/// compile is not free; caching keeps the UNet's many small convs fast.
pub(crate) mps_conv_cache: Arc<RwLock<super::mps_conv::ConvGraphCache>>,
}
// Resource options used for creating buffers. Shared storage mode allows both CPU and GPU to access the buffer.
pub const RESOURCE_OPTIONS: MTLResourceOptions =
objc2_metal::MTLResourceOptions(MTLResourceOptions::StorageModeShared.bits());
//| MTLResourceOptions::HazardTrackingModeUntracked.bits(),
//);
// Resource options used for `new_private_buffer`. This uses `private` where supported.
#[cfg(target_os = "ios")]
pub const PRIVATE_RESOURCE_OPTIONS: MTLResourceOptions = MTLResourceOptions::StorageModeShared;
#[cfg(not(target_os = "ios"))]
pub const PRIVATE_RESOURCE_OPTIONS: MTLResourceOptions = MTLResourceOptions::StorageModePrivate;
impl std::fmt::Debug for MetalDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "MetalDevice({:?})", self.id)
}
}
impl std::ops::Deref for MetalDevice {
type Target = Device;
fn deref(&self) -> &Self::Target {
&self.device
}
}
impl MetalDevice {
#[cfg(all(feature = "ug", not(target_arch = "wasm32"), not(target_os = "ios")))]
pub fn compile(
&self,
func_name: &'static str,
kernel: hanzo_ug::lang::ssa::Kernel,
) -> Result<ComputePipeline> {
let mut buf = vec![];
hanzo_ug::metal::code_gen::gen(&mut buf, func_name, &kernel)?;
let metal_code = String::from_utf8(buf)?;
let lib = self
.device
.new_library_with_source(&metal_code, None)
.map_err(MetalError::from)?;
let func = lib
.get_function(func_name, None)
.map_err(MetalError::from)?;
let pl = self
.device
.new_compute_pipeline_state_with_function(&func)
.map_err(MetalError::from)?;
Ok(pl)
}
pub fn id(&self) -> DeviceId {
self.id
}
pub fn metal_device(&self) -> &Device {
&self.device
}
fn drop_unused_buffers(&self) -> Result<()> {
let mut buffers = self.buffers.write().map_err(MetalError::from)?;
for subbuffers in buffers.values_mut() {
let newbuffers = subbuffers
.iter()
.filter(|s| Arc::strong_count(*s) > 1)
.map(Arc::clone)
.collect();
*subbuffers = newbuffers;
}
Ok(())
}
pub fn command_encoder(&self) -> Result<ComputeCommandEncoder> {
let commands = self.commands.write().map_err(MetalError::from)?;
let (flush, command_encoder) = commands.command_encoder().map_err(MetalError::from)?;
if flush {
self.drop_unused_buffers()?
}
Ok(command_encoder)
}
pub fn blit_command_encoder(&self) -> Result<BlitCommandEncoder> {
let commands = self.commands.write().map_err(MetalError::from)?;
let (flush, command_encoder) = commands.blit_command_encoder().map_err(MetalError::from)?;
if flush {
self.drop_unused_buffers()?
}
Ok(command_encoder)
}
pub fn wait_until_completed(&self) -> Result<()> {
let commands = self.commands.write().map_err(MetalError::from)?;
commands.wait_until_completed().map_err(MetalError::from)?;
Ok(())
}
pub fn kernels(&self) -> &Kernels {
&self.kernels
}
pub fn device(&self) -> &Device {
&self.device
}
/// Dedicated, lazily-created command queue for the MPSGraph conv path. Created once per device
/// (queue creation is not free) and reused across convs.
pub fn mps_command_queue(
&self,
) -> Result<hanzo_metal_kernels::metal::CommandQueue> {
if let Some(q) = self.mps_queue.get() {
return Ok(q.clone());
}
let q = self.device.new_command_queue().map_err(MetalError::from)?;
// Race-safe: if another thread set it first, keep theirs.
let _ = self.mps_queue.set(q);
Ok(self.mps_queue.get().unwrap().clone())
}
/// Creates a new buffer (not necessarily zeroed).
pub fn new_buffer(
&self,
element_count: usize,
dtype: DType,
_name: &str,
) -> Result<Arc<Buffer>> {
let size = element_count * dtype.size_in_bytes();
self.allocate_buffer(size)
}
/// Creates a new private buffer (not necessarily zeroed).
///
/// This is intentionally not in the Metal buffer pool to allow the efficient implementation of persistent buffers.
pub fn new_private_buffer(
&self,
element_count: usize,
dtype: DType,
_name: &str,
) -> Result<Arc<Buffer>> {
let size = element_count * dtype.size_in_bytes();
let buffer = self
.device
.new_buffer(size, PRIVATE_RESOURCE_OPTIONS)
.map_err(MetalError::from)?;
Ok(Arc::new(buffer))
}
/// Creates a new buffer from data.
///
/// Does not require synchronization, as [newBufferWithBytes](https://developer.apple.com/documentation/metal/mtldevice/1433429-newbufferwithbytes)
/// allocates the buffer and copies over the existing data before returning the MTLBuffer.
pub fn new_buffer_with_data<T>(&self, data: &[T]) -> Result<Arc<Buffer>> {
let size = core::mem::size_of_val(data);
let new_buffer = self
.device
.new_buffer_with_data(data.as_ptr().cast(), size, RESOURCE_OPTIONS)
.map_err(MetalError::from)?;
let mut buffers = self.buffers.write().map_err(MetalError::from)?;
let subbuffers = buffers.entry(size).or_insert(vec![]);
let new_buffer = Arc::new(new_buffer);
subbuffers.push(new_buffer.clone());
Ok(new_buffer)
}
pub fn allocate_zeros(&self, size_in_bytes: usize) -> Result<Arc<Buffer>> {
let buffer = self.allocate_buffer(size_in_bytes)?;
let blit = self.blit_command_encoder()?;
blit.set_label("zeros");
blit.fill_buffer(&buffer, (0, buffer.length()), 0);
blit.end_encoding();
Ok(buffer)
}
/// The critical allocator algorithm
pub fn allocate_buffer(&self, size: usize) -> Result<Arc<Buffer>> {
let mut buffers = self.buffers.write().map_err(MetalError::from)?;
if let Some(b) = find_available_buffer(size, &buffers) {
// Cloning also ensures we increment the strong count
return Ok(b.clone());
}
let size = buf_size(size);
let subbuffers = buffers.entry(size).or_insert(vec![]);
let new_buffer = self
.device
.new_buffer(size, RESOURCE_OPTIONS)
.map_err(MetalError::from)?;
let new_buffer = Arc::new(new_buffer);
subbuffers.push(new_buffer.clone());
Ok(new_buffer)
}
/// Create a metal GPU capture trace on [`path`].
pub fn capture<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let capture = unsafe { MTLCaptureManager::sharedCaptureManager() };
let descriptor = MTLCaptureDescriptor::new();
descriptor.setDestination(MTLCaptureDestination::GPUTraceDocument);
descriptor.set_capture_device(self.device().as_ref());
// The [set_output_url] call requires an absolute path so we convert it if needed.
if path.as_ref().is_absolute() {
let url = NSURL::from_file_path(path);
descriptor.setOutputURL(url.as_deref());
} else {
let path = std::env::current_dir()?.join(path);
let url = NSURL::from_file_path(path);
descriptor.setOutputURL(url.as_deref());
}
capture
.startCaptureWithDescriptor_error(&descriptor)
.map_err(|e| MetalError::from(e.to_string()))?;
Ok(())
}
}
fn buf_size(size: usize) -> usize {
size.saturating_sub(1).next_power_of_two()
}
fn find_available_buffer(size: usize, buffers: &BufferMap) -> Option<Arc<Buffer>> {
let mut best_buffer: Option<&Arc<Buffer>> = None;
let mut best_buffer_size = usize::MAX;
for (buffer_size, subbuffers) in buffers.iter() {
if buffer_size >= &size && buffer_size < &best_buffer_size {
for sub in subbuffers {
if Arc::strong_count(sub) == 1 {
best_buffer = Some(sub);
best_buffer_size = *buffer_size;
}
}
}
}
best_buffer.cloned()
}