use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2::AnyThread;
use objc2_foundation::NSInteger;
use objc2_metal::{
MTLBuffer, MTLDevice, MTLResourceID, MTLResourceOptions, MTLSizeAndAlign, MTLTensor,
MTLTensorDataType, MTLTensorDescriptor, MTLTensorExtents, MTLTensorUsage,
};
use std::sync::Arc;
use crate::dispatch::Binder;
use crate::runtime::{GpuRuntime, ARGUMENT_TABLE_MAX_BUFFERS};
use crate::tensor::GpuBuffer;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QuantDType {
Int8,
Int4,
Fp8E8M0,
}
impl QuantDType {
pub fn to_mtl(self) -> Result<MTLTensorDataType, String> {
match self {
QuantDType::Int8 => Ok(MTLTensorDataType::Int8),
QuantDType::Int4 => Err(
"MTLTensorDataType Int4 is not in objc2-metal 0.3 (WWDC26-330), so a \
host-created Int4 MTLTensor cannot be described. Note this gates only \
the host descriptor path: TensorOps itself accepts int4b_format, and \
kernels building tensors from device pointers are unaffected."
.into(),
),
QuantDType::Fp8E8M0 => Err(
"FP8 E8M0 MTLTensor scale planes require newer Metal SDK (macOS 27+ notes)".into(),
),
}
}
pub fn bytes_per_elem_hint(self) -> f32 {
match self {
QuantDType::Int8 => 1.0,
QuantDType::Int4 => 0.5,
QuantDType::Fp8E8M0 => 1.0,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NaxVerifyReadiness {
pub int8_tensorops_dtype: bool,
pub int4_tensorops_dtype: bool,
pub fp8_e8m0_tensorops_dtype: bool,
pub quant_prefill_gemm_wired: bool,
pub note: &'static str,
}
pub fn nax_verify_readiness() -> NaxVerifyReadiness {
NaxVerifyReadiness {
int8_tensorops_dtype: QuantDType::Int8.to_mtl().is_ok(),
int4_tensorops_dtype: QuantDType::Int4.to_mtl().is_ok(),
fp8_e8m0_tensorops_dtype: QuantDType::Fp8E8M0.to_mtl().is_ok(),
quant_prefill_gemm_wired: QUANT_PREFILL_GEMM_WIRED,
note: "Int4 unbound in objc2-metal 0.3; verify(M) = hand simdgroup Q4; TensorOps Q4 not shipped",
}
}
pub const QUANT_PREFILL_GEMM_WIRED: bool = false;
pub struct GpuTensor {
pub tensor: Retained<ProtocolObject<dyn MTLTensor>>,
pub dtype: MTLTensorDataType,
pub dims: Vec<usize>,
#[allow(dead_code)]
pub(crate) storage: Option<GpuBuffer>,
#[allow(dead_code)]
pub(crate) runtime: Arc<GpuRuntime>,
}
impl GpuTensor {
pub fn gpu_resource_id(&self) -> MTLResourceID {
self.tensor.gpuResourceID()
}
pub fn data_type(&self) -> MTLTensorDataType {
self.tensor.dataType()
}
}
pub fn bind_mtl_tensor(bnd: &mut Binder<'_>, t: &GpuTensor, index: usize) -> Result<(), String> {
if index >= ARGUMENT_TABLE_MAX_BUFFERS {
return Err(format!(
"MTLTensor bind index {index} out of range: argument table has \
{ARGUMENT_TABLE_MAX_BUFFERS} buffer slots (0..={})",
ARGUMENT_TABLE_MAX_BUFFERS - 1
));
}
unsafe {
bnd.bind_resource_id(t.gpu_resource_id(), index);
}
Ok(())
}
pub fn probe_tensor_support(
rt: &GpuRuntime,
dtype: QuantDType,
dims: &[usize],
) -> Result<MTLSizeAndAlign, String> {
let mtl_dtype = dtype.to_mtl()?;
let desc = make_descriptor(dims, mtl_dtype, MTLTensorUsage::Compute)?;
Ok(rt.device.tensorSizeAndAlignWithDescriptor(&desc))
}
pub fn alloc_device_tensor(
rt: &Arc<GpuRuntime>,
dims: &[usize],
dtype: QuantDType,
) -> Result<GpuTensor, String> {
let mtl_dtype = dtype.to_mtl()?;
let desc = make_descriptor(dims, mtl_dtype, MTLTensorUsage::Compute)?;
let tensor = rt
.device
.newTensorWithDescriptor_error(&desc)
.map_err(|e| format!("newTensorWithDescriptor: {e}"))?;
Ok(GpuTensor {
tensor,
dtype: mtl_dtype,
dims: dims.to_vec(),
storage: None,
runtime: Arc::clone(rt),
})
}
pub unsafe fn tensor_from_buffer(
rt: &Arc<GpuRuntime>,
buf: &GpuBuffer,
byte_offset: usize,
dims: &[usize],
dtype: QuantDType,
) -> Result<GpuTensor, String> {
let mtl_dtype = dtype.to_mtl()?;
let desc = make_descriptor(dims, mtl_dtype, MTLTensorUsage::Compute)?;
let align = rt.device.tensorSizeAndAlignWithDescriptor(&desc);
if byte_offset % align.align != 0 {
return Err(format!(
"tensor buffer offset {byte_offset} not aligned to {}",
align.align
));
}
let tensor = buf
.metal()
.newTensorWithDescriptor_offset_error(&desc, byte_offset as _)
.map_err(|e| format!("buffer.newTensor: {e}"))?;
Ok(GpuTensor {
tensor,
dtype: mtl_dtype,
dims: dims.to_vec(),
storage: Some(buf.clone()),
runtime: Arc::clone(rt),
})
}
fn make_descriptor(
dims: &[usize],
dtype: MTLTensorDataType,
usage: MTLTensorUsage,
) -> Result<Retained<MTLTensorDescriptor>, String> {
if dims.is_empty() || dims.len() > 16 {
return Err(format!("MTLTensor rank {} out of range 1..=16", dims.len()));
}
let desc = MTLTensorDescriptor::new();
let extents = extents_from_dims(dims)?;
desc.setDimensions(&extents);
desc.setDataType(dtype);
desc.setUsage(usage);
desc.setResourceOptions(MTLResourceOptions::StorageModeShared);
Ok(desc)
}
fn extents_from_dims(dims: &[usize]) -> Result<Retained<MTLTensorExtents>, String> {
let values: Vec<NSInteger> = dims.iter().map(|&d| d as NSInteger).collect();
let extents = unsafe {
MTLTensorExtents::initWithRank_values(
MTLTensorExtents::alloc(),
values.len() as _,
values.as_ptr(),
)
}
.ok_or_else(|| "MTLTensorExtents::initWithRank_values failed".to_string())?;
Ok(extents)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quant_dtype_int8_maps() {
assert!(QuantDType::Int8.to_mtl().is_ok());
let err = QuantDType::Int4.to_mtl().unwrap_err();
assert!(err.contains("Int4"), "{err}");
assert!(
err.contains("unbound") || err.contains("not in objc2"),
"{err}"
);
assert!(QuantDType::Fp8E8M0.to_mtl().is_err());
}
#[test]
fn nax_verify_readiness_int4_unbound() {
let r = nax_verify_readiness();
assert!(r.int8_tensorops_dtype);
assert!(!r.int4_tensorops_dtype);
assert!(!r.fp8_e8m0_tensorops_dtype);
assert!(!r.quant_prefill_gemm_wired);
assert!(r.note.contains("Int4 unbound"));
}
#[test]
fn bind_mtl_tensor_rejects_out_of_range_index() {
let rt = GpuRuntime::new().expect("runtime");
let t = alloc_device_tensor(&rt, &[16, 16], QuantDType::Int8).expect("int8 tensor");
let mut outcome = None;
rt.with_binder(|bnd| {
outcome = Some((
bind_mtl_tensor(bnd, &t, 0),
bind_mtl_tensor(bnd, &t, ARGUMENT_TABLE_MAX_BUFFERS - 1),
bind_mtl_tensor(bnd, &t, ARGUMENT_TABLE_MAX_BUFFERS),
bind_mtl_tensor(bnd, &t, usize::MAX),
));
Ok(())
})
.expect("binder scope");
let (first, last, past_end, huge) = outcome.expect("binder body ran");
first.expect("index 0 is a valid slot");
last.expect("the last slot must still bind");
let err = past_end.expect_err("index 31 is past the end of a 31-slot table");
assert!(err.contains("out of range"), "{err}");
huge.expect_err("usize::MAX must never reach setResource:atBufferIndex:");
}
#[test]
fn quant_descriptor_builds_for_int8() {
let desc = make_descriptor(&[64, 64], MTLTensorDataType::Int8, MTLTensorUsage::Compute)
.expect("descriptor");
assert_eq!(desc.dataType(), MTLTensorDataType::Int8);
assert_eq!(desc.dimensions().rank(), 2);
}
}