use std::{
env::{self, VarError},
mem,
path::Path,
sync::{
Mutex, MutexGuard, OnceLock, PoisonError,
mpsc::{self, Sender},
},
thread,
};
use anyhow::{Context, Result, bail, ensure};
use image::{DynamicImage, RgbImage};
use rten::Value;
use tract::prelude::*;
use wgpu::{AdapterInfo, Backend, DeviceType};
use super::{BATCH, SIZE, Tower, normalize, pixels};
use crate::{catch, guard, models};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Device {
Auto,
Gpu,
Cpu,
}
impl Device {
pub(super) fn from_env() -> Result<Self> {
match env::var("SCRY_DEVICE").as_deref() {
Err(VarError::NotPresent) | Ok("" | "auto") => Ok(Self::Auto),
Ok("gpu") => Ok(Self::Gpu),
Ok("cpu") => Ok(Self::Cpu),
_ => bail!("SCRY_DEVICE must be auto, gpu or cpu"),
}
}
}
pub const API: &str = if cfg!(target_os = "macos") {
"Metal"
} else {
"Vulkan"
};
const APPLE_SILICON: bool = cfg!(all(target_os = "macos", target_arch = "aarch64"));
pub const AUTO: &str = if APPLE_SILICON {
"the GPU"
} else {
"a discrete GPU"
};
static FAILURE: OnceLock<String> = OnceLock::new();
pub fn failure() -> Option<&'static str> {
FAILURE.get().map(String::as_str)
}
static BUSY: Mutex<()> = Mutex::new(());
fn busy() -> MutexGuard<'static, ()> {
BUSY.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn stop() {
mem::forget(busy());
}
const LIMIT: f32 = 1e-3;
type Job = (Vec<f32>, Sender<Result<Vec<Vec<f32>>>>);
const STOPPED: &str = "the GPU thread stopped";
pub(super) struct Vision {
jobs: Sender<Job>,
}
impl Vision {
pub(super) fn open(path: &Path, device: Device, cpu: &Tower) -> Result<Option<Self>> {
let open = || {
if !adapter().is_some_and(|a| usable(&a, device)) {
ensure!(
device != Device::Gpu,
"SCRY_DEVICE is gpu, but scry found no {API} GPU"
);
return Ok(None);
}
Self::load(path, cpu).map(Some)
};
match catch(open) {
Err(e) if device == Device::Auto => {
let _ = FAILURE.set(format!("{e:#}"));
Ok(None)
}
result => result,
}
}
fn load(path: &Path, cpu: &Tower) -> Result<Self> {
let (jobs, queue) = mpsc::channel::<Job>();
let (ready, started) = mpsc::channel();
let path = path.to_owned();
thread::spawn(move || {
let model = {
let _busy = busy();
guard(|| prepare(&path))
};
let (runnable, output) = match model {
Ok(model) => model,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
let _ = ready.send(Ok(()));
for (pixels, reply) in queue {
let _busy = busy();
let _ = reply.send(guard(|| embed(&runnable, output, pixels)));
}
let _busy = busy();
drop(runnable);
});
started.recv().context(STOPPED)??;
let vision = Self { jobs };
vision.check(cpu)?;
Ok(vision)
}
pub(super) fn run(&self, pixels: Vec<f32>) -> Result<Vec<Vec<f32>>> {
let (reply, answer) = mpsc::channel();
self.jobs.send((pixels, reply)).ok().context(STOPPED)?;
answer.recv().context(STOPPED)?
}
fn check(&self, cpu: &Tower) -> Result<()> {
let pixels: Vec<f32> = [pattern(64, 64, 4), pattern(48, 80, 9)]
.iter()
.flat_map(pixels)
.collect();
let want = cpu.run(Value::from_shape([2, 3, SIZE, SIZE], pixels.clone())?)?;
for (want, got) in want.iter().zip(self.run(pixels)?) {
let d = distance(want, &got);
ensure!(d < LIMIT, "the GPU gives wrong embeddings (off by {d})");
}
Ok(())
}
}
fn prepare(path: &Path) -> Result<(Runnable, usize)> {
let mut options = OnnxOptions::new();
options.ignore_value_info = true;
let mut model = tract::onnx()?.load_with_options(path, options)?;
model.set_input_fact(0, format!("{BATCH},3,{SIZE},{SIZE},f32").as_str())?;
let mut model = model.into_model()?;
let output = (0..model.output_count()?)
.find(|&i| {
model
.output_fact(i)
.and_then(|f| f.rank())
.is_ok_and(|r| r == 2)
})
.with_context(|| models::cannot_load(path))?;
model.transform(FloatPrecision::new(DatumType::F16, DatumType::F32))?;
Ok((tract::runtime_for_name("wgpu")?.prepare(model)?, output))
}
fn embed(runnable: &Runnable, output: usize, mut pixels: Vec<f32>) -> Result<Vec<Vec<f32>>> {
let n = pixels.len() / (3 * SIZE * SIZE);
pixels.resize(BATCH * 3 * SIZE * SIZE, 0.0);
let out = runnable.run([Tensor::from_slice(&[BATCH, 3, SIZE, SIZE], &pixels)?])?;
let rows = out[output].as_slice::<f32>()?;
Ok(rows
.chunks(rows.len() / BATCH)
.take(n)
.map(normalize)
.collect())
}
fn pattern(width: u32, height: u32, step: u32) -> DynamicImage {
let image = RgbImage::from_fn(width, height, |x, y| {
[x * step, y * 7, x ^ y].map(|c| c as u8).into()
});
DynamicImage::ImageRgb8(image)
}
fn distance(a: &[f32], b: &[f32]) -> f32 {
a.iter()
.zip(b)
.map(|(x, y)| (x - y).powi(2))
.sum::<f32>()
.sqrt()
}
fn adapter() -> Option<AdapterInfo> {
let _busy = busy();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let options = wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
..Default::default()
};
let adapter = pollster::block_on(instance.request_adapter(&options)).ok()?;
Some(adapter.get_info())
}
fn usable(info: &AdapterInfo, device: Device) -> bool {
let fast = match info.device_type {
DeviceType::DiscreteGpu => true,
DeviceType::IntegratedGpu => APPLE_SILICON,
DeviceType::Other | DeviceType::VirtualGpu | DeviceType::Cpu => false,
};
matches!(info.backend, Backend::Vulkan | Backend::Metal) && (fast || device == Device::Gpu)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embed::VISION;
#[test]
fn auto_takes_only_fast_gpus() {
use Backend::{Gl, Metal, Vulkan};
use Device::{Auto, Gpu};
use DeviceType::{Cpu as Software, DiscreteGpu as Discrete, IntegratedGpu as Integrated};
for (kind, backend, device, want) in [
(Discrete, Vulkan, Auto, true),
(Discrete, Metal, Auto, true),
(Integrated, Metal, Auto, APPLE_SILICON),
(Integrated, Vulkan, Gpu, true),
(Software, Vulkan, Auto, false),
(Software, Vulkan, Gpu, true),
(Discrete, Gl, Gpu, false),
] {
let info = AdapterInfo::new(kind, backend);
assert_eq!(
usable(&info, device),
want,
"{kind:?} {backend:?} {device:?}"
);
}
}
#[test]
fn distance_fails_on_nan() {
assert_eq!(distance(&[3.0, 4.0], &[0.0, 0.0]), 5.0);
assert!(distance(&[f32::NAN, 0.0], &[0.0, 0.0]).is_nan());
}
#[test]
#[ignore = "downloads the SigLIP 2 image model and needs Vulkan or Metal (lavapipe works)"]
fn gpu_matches_cpu() {
let path = models::fetch(&VISION).unwrap();
let cpu = Tower::load(&path, "pixel_values").unwrap();
let gpu = Vision::open(&path, Device::Gpu, &cpu).unwrap().unwrap();
let pixels: Vec<f32> = (0..BATCH as u32 + 3)
.flat_map(|i| pixels(&pattern(40 + i, 30, i)))
.collect();
for batch in pixels.chunks(BATCH * 3 * SIZE * SIZE) {
let n = batch.len() / (3 * SIZE * SIZE);
let want = cpu
.run(Value::from_shape([n, 3, SIZE, SIZE], batch.to_vec()).unwrap())
.unwrap();
let got = gpu.run(batch.to_vec()).unwrap();
assert_eq!(got.len(), n);
assert!(want.iter().zip(&got).all(|(w, g)| distance(w, g) < LIMIT));
}
}
}