use std::ffi::{CString, c_char};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use super::{EmbeddingBatch, ModelRuntime, RuntimeError, RuntimeIdentity};
use crate::ObservedComputeUnits;
use crate::tower::TokenBatch;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ComputeUnits {
Cpu,
CpuAndGpu,
CpuAndNeuralEngine,
All,
}
impl ComputeUnits {
const fn code(self) -> i32 {
match self {
Self::Cpu => 0,
Self::CpuAndGpu => 1,
Self::CpuAndNeuralEngine => 2,
Self::All => 3,
}
}
const fn identity(self) -> &'static str {
match self {
Self::Cpu => "coreml-cpu",
Self::CpuAndGpu => "coreml-cpu-gpu",
Self::CpuAndNeuralEngine => "coreml-cpu-ane",
Self::All => "coreml-all",
}
}
}
#[repr(C)]
struct Handle {
_private: [u8; 0],
}
unsafe extern "C" {
fn ze_coreml_open(
path: *const c_char,
compute_units: i32,
error: *mut *mut c_char,
) -> *mut Handle;
fn ze_coreml_compute_plan(
path: *const c_char,
compute_units: i32,
observed: *mut ObservedComputeUnits,
has_observed: *mut i32,
error: *mut *mut c_char,
) -> i32;
fn ze_coreml_predict(
handle: *mut Handle,
ids: *const i32,
mask: *const i32,
sequence: usize,
out: *mut f32,
out_len: usize,
error: *mut *mut c_char,
) -> i32;
fn ze_coreml_close(handle: *mut Handle);
fn ze_coreml_sequence(handle: *mut Handle) -> usize;
fn ze_coreml_string_free(text: *mut c_char);
}
fn take_error(raw: *mut c_char, fallback: &str) -> String {
if raw.is_null() {
return fallback.to_owned();
}
let message = unsafe { std::ffi::CStr::from_ptr(raw) }
.to_string_lossy()
.into_owned();
unsafe { ze_coreml_string_free(raw) };
message
}
fn model_path(model: &Path) -> Result<CString, RuntimeError> {
let path = model
.to_str()
.ok_or_else(|| RuntimeError::Mlx("CoreML model path is not valid UTF-8".to_owned()))?;
CString::new(path).map_err(|_| RuntimeError::Mlx("CoreML model path contains NUL".to_owned()))
}
pub struct CoreMlComputePlan {
model: PathBuf,
compute_units: ComputeUnits,
observed: Mutex<Option<Option<ObservedComputeUnits>>>,
}
impl CoreMlComputePlan {
#[must_use]
pub fn new(model: &Path, compute_units: ComputeUnits) -> Self {
Self {
model: model.to_owned(),
compute_units,
observed: Mutex::new(None),
}
}
pub(crate) fn cached_observed_compute_units(&self) -> Option<ObservedComputeUnits> {
let cached = self.observed.try_lock().ok()?;
(*cached).flatten()
}
pub fn observed_compute_units(&self) -> Result<Option<ObservedComputeUnits>, RuntimeError> {
let mut cached = self
.observed
.lock()
.map_err(|_| RuntimeError::Mlx("CoreML compute plan cache lock poisoned".to_owned()))?;
if let Some(observed) = *cached {
return Ok(observed);
}
let path = model_path(&self.model)?;
let mut error = std::ptr::null_mut();
let mut observed = ObservedComputeUnits::default();
let mut has_observed = 0;
let code = unsafe {
ze_coreml_compute_plan(
path.as_ptr(),
self.compute_units.code(),
&mut observed,
&mut has_observed,
&mut error,
)
};
if code != 0 {
return Err(RuntimeError::Mlx(take_error(
error,
"CoreML compute plan failed to load",
)));
}
let observed = (has_observed != 0).then_some(observed);
*cached = Some(observed);
Ok(observed)
}
}
pub(crate) fn pad_query(tokens: &TokenBatch, sequence: usize) -> Result<TokenBatch, RuntimeError> {
if tokens.tokens_per_row == 0 || tokens.tokens_per_row > sequence {
return Err(RuntimeError::Shape(format!(
"CoreML query has {} tokens, expected 1..={sequence}",
tokens.tokens_per_row
)));
}
tokens.padded_to(sequence)
}
pub struct CoreMlRuntime {
handle: *mut Handle,
sequence: usize,
dims: usize,
compute_units: ComputeUnits,
}
unsafe impl Send for CoreMlRuntime {}
impl CoreMlRuntime {
pub fn load(
model: &Path,
dims: usize,
compute_units: ComputeUnits,
) -> Result<Self, RuntimeError> {
if dims == 0 {
return Err(RuntimeError::Shape(
"CoreML dims must be non-zero".to_owned(),
));
}
let path = model_path(model)?;
let mut error: *mut c_char = std::ptr::null_mut();
let handle = unsafe { ze_coreml_open(path.as_ptr(), compute_units.code(), &mut error) };
if handle.is_null() {
return Err(RuntimeError::Mlx(take_error(
error,
"CoreML model failed to load",
)));
}
let sequence = unsafe { ze_coreml_sequence(handle) };
if sequence == 0 {
unsafe { ze_coreml_close(handle) };
return Err(RuntimeError::Shape(
"CoreML sequence must be non-zero".to_owned(),
));
}
Ok(Self {
handle,
sequence,
dims,
compute_units,
})
}
#[must_use]
pub const fn sequence(&self) -> usize {
self.sequence
}
#[must_use]
pub const fn requested_compute_units(&self) -> zeppelin_embed::epoch::ComputeUnits {
use zeppelin_embed::epoch::ComputeUnits as Policy;
match self.compute_units {
ComputeUnits::Cpu => Policy::Cpu,
ComputeUnits::CpuAndGpu => Policy::CpuAndGpu,
ComputeUnits::CpuAndNeuralEngine => Policy::CpuAndNeuralEngine,
ComputeUnits::All => Policy::All,
}
}
fn embed_row(
&mut self,
ids: &[i32],
mask: &[i32],
out: &mut [f32],
) -> Result<(), RuntimeError> {
let mut error: *mut c_char = std::ptr::null_mut();
let code = unsafe {
ze_coreml_predict(
self.handle,
ids.as_ptr(),
mask.as_ptr(),
ids.len(),
out.as_mut_ptr(),
out.len(),
&mut error,
)
};
if code == 0 {
return Ok(());
}
Err(RuntimeError::Mlx(take_error(
error,
"CoreML prediction failed",
)))
}
}
impl Drop for CoreMlRuntime {
fn drop(&mut self) {
unsafe { ze_coreml_close(self.handle) };
}
}
impl ModelRuntime for CoreMlRuntime {
fn embed_batch(&mut self, tokens: &TokenBatch) -> Result<EmbeddingBatch, RuntimeError> {
let sequence = self.sequence;
let rows = tokens.rows;
let mut values = vec![
0.0_f32;
rows.checked_mul(self.dims).ok_or_else(|| {
RuntimeError::Shape("CoreML batch size overflows".to_owned())
})?
];
let padded;
let tokens = if tokens.tokens_per_row == sequence {
tokens
} else {
padded = pad_query(tokens, sequence)?;
&padded
};
let mut mask = vec![0_i32; sequence];
for row in 0..rows {
let start = row
.checked_mul(sequence)
.ok_or_else(|| RuntimeError::Shape("CoreML row offset overflows".to_owned()))?;
let end = start
.checked_add(sequence)
.ok_or_else(|| RuntimeError::Shape("CoreML row end overflows".to_owned()))?;
let ids = tokens
.token_ids
.get(start..end)
.ok_or_else(|| RuntimeError::Shape("token id row is out of range".to_owned()))?;
let source = tokens.attention_mask.get(start..end).ok_or_else(|| {
RuntimeError::Shape("attention mask row is out of range".to_owned())
})?;
for (slot, value) in mask.iter_mut().zip(source) {
*slot = if *value > 0.0 { 1 } else { 0 };
}
let out_start = row
.checked_mul(self.dims)
.ok_or_else(|| RuntimeError::Shape("CoreML output offset overflows".to_owned()))?;
let out_end = out_start
.checked_add(self.dims)
.ok_or_else(|| RuntimeError::Shape("CoreML output end overflows".to_owned()))?;
let mut scratch = vec![0.0_f32; self.dims];
self.embed_row(ids, &mask, &mut scratch)?;
let target = values.get_mut(out_start..out_end).ok_or_else(|| {
RuntimeError::Shape("CoreML output row is out of range".to_owned())
})?;
target.copy_from_slice(&scratch);
}
EmbeddingBatch::new(values, rows, self.dims)
}
fn warm(&mut self) -> Result<(), RuntimeError> {
let sequence = self.sequence;
let ids = vec![0_i32; sequence];
let mask = vec![1_i32; sequence];
let mut out = vec![0.0_f32; self.dims];
self.embed_row(&ids, &mask, &mut out)
}
fn identity(&self) -> RuntimeIdentity {
RuntimeIdentity {
name: self.compute_units.identity(),
gpu: matches!(
self.compute_units,
ComputeUnits::CpuAndGpu | ComputeUnits::All
),
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
#[test]
fn cached_compute_units_read_does_not_wait_for_a_plan_load() {
use super::{ComputeUnits, CoreMlComputePlan};
let plan =
CoreMlComputePlan::new(std::path::Path::new("unused.mlmodelc"), ComputeUnits::Cpu);
let mut loading = plan.observed.lock().expect("hold load lock");
std::thread::scope(|scope| {
let (send, receive) = std::sync::mpsc::channel();
let read_plan = &plan;
scope.spawn(move || {
send.send(read_plan.cached_observed_compute_units())
.expect("send snapshot")
});
let result = receive.recv_timeout(std::time::Duration::from_secs(5));
*loading = Some(Some(crate::ObservedComputeUnits {
cpu_ops: 7,
..Default::default()
}));
drop(loading);
assert_eq!(
result.expect("cache read must not wait for load lock"),
None
);
});
assert_eq!(
plan.cached_observed_compute_units()
.expect("cached counts")
.cpu_ops,
7
);
}
#[test]
fn query_rows_pad_to_96_and_reject_empty_or_overlength() {
for count in [1, 32, 33, 64, 65, 96] {
let tokens = crate::tower::TokenBatch::new(vec![7; count], vec![1.; count], 1, count)
.expect("row");
let padded = super::pad_query(&tokens, 96).expect("pad query");
assert_eq!(padded.tokens_per_row, 96);
assert_eq!(
padded.token_ids.iter().filter(|id| **id == 7).count(),
count
);
assert_eq!(padded.attention_mask.iter().sum::<f32>(), count as f32);
}
for count in [0, 97] {
let tokens = crate::tower::TokenBatch {
token_ids: vec![7; count],
attention_mask: vec![1.; count],
rows: 1,
tokens_per_row: count,
};
assert!(matches!(
super::pad_query(&tokens, 96),
Err(super::RuntimeError::Shape(_))
));
}
}
}