use anyhow::{Context, Result, bail};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
const SOFTWARE_VENDORS: &[u32] = &[
0x1414, 0x10005, 0x1ae0, 0x1234, 0x15ad, 0x1af4, 0x80ee, 0x1b36, ];
const SOFTWARE_NAMES: &[&str] = &[
"llvmpipe",
"lavapipe",
"swiftshader",
"warp",
"basic render",
"basic display",
"hyper-v",
"virtio",
"vmware",
"virtualbox",
];
pub fn is_software(vendor: u32, name: &str) -> bool {
let name = name.to_ascii_lowercase();
SOFTWARE_VENDORS.contains(&vendor) || SOFTWARE_NAMES.iter().any(|n| name.contains(n))
}
pub fn detect() -> std::result::Result<String, String> {
platform::detect()
}
#[cfg(target_os = "macos")]
mod platform {
pub fn detect() -> std::result::Result<String, String> {
if cfg!(target_arch = "aarch64") {
Ok(format!("{} GPU (Metal)", crate::system::cpu_name()))
} else {
Err("no Metal GPU semlith supports on an Intel Mac".to_string())
}
}
}
#[cfg(target_os = "linux")]
mod platform {
use super::*;
pub fn detect() -> std::result::Result<String, String> {
let mut found = Vec::new();
for entry in std::fs::read_dir("/sys/class/drm")
.into_iter()
.flatten()
.flatten()
{
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with("card") || name.contains('-') {
continue;
}
let Ok(vendor) = std::fs::read_to_string(entry.path().join("device/vendor")) else {
continue;
};
let Ok(vendor) = u32::from_str_radix(vendor.trim().trim_start_matches("0x"), 16) else {
continue;
};
if !is_software(vendor, "") {
found.push(vendor_name(vendor));
}
}
let Some(first) = found.first() else {
return Err("no hardware GPU found".to_string());
};
let loader = unsafe {
let handle = libc::dlopen(
c"libvulkan.so.1".as_ptr(),
libc::RTLD_NOW | libc::RTLD_LOCAL,
);
if !handle.is_null() {
libc::dlclose(handle);
}
!handle.is_null()
};
if !loader {
return Err(format!(
"{first} found, but no Vulkan loader (libvulkan.so.1) is installed"
));
}
Ok(first.clone())
}
fn vendor_name(vendor: u32) -> String {
match vendor {
0x10de => "NVIDIA GPU".to_string(),
0x1002 => "AMD GPU".to_string(),
0x8086 => "Intel GPU".to_string(),
other => format!("GPU (PCI vendor {other:04x})"),
}
}
}
#[cfg(windows)]
mod platform {
use super::*;
pub fn detect() -> std::result::Result<String, String> {
let out = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name + '|' + $_.PNPDeviceID }",
])
.output()
.map_err(|e| format!("could not list display adapters: {e}"))?;
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
let Some((name, pnp)) = line.split_once('|') else {
continue;
};
let vendor = pnp
.to_ascii_uppercase()
.split("VEN_")
.nth(1)
.and_then(|rest| u32::from_str_radix(rest.get(..4)?, 16).ok())
.unwrap_or(0);
if vendor != 0 && !is_software(vendor, name) {
return Ok(name.trim().to_string());
}
}
Err("no hardware GPU found".to_string())
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
mod platform {
pub fn detect() -> std::result::Result<String, String> {
Err("no GPU support on this platform".to_string())
}
}
pub fn cuda_unavailable() -> String {
cuda_unavailable_here().unwrap_or_else(|| "the CUDA lane is not part of this build".to_string())
}
pub fn cuda_unavailable_here() -> Option<String> {
crate::cuda::unavailable_here()
}
pub fn cuda_dir(cache: &Path) -> PathBuf {
crate::cuda::pack_dir(cache)
}
pub fn cuda_pack_bytes() -> u64 {
crate::cuda::PACK_BYTES
}
pub const WEBGPU_VERSION: &str = "0.4.0";
struct Wheel {
url: &'static str,
sha256: &'static str,
size: u64,
}
fn wheel() -> Option<Wheel> {
if cfg!(target_os = "macos") {
Some(Wheel {
url: "https://files.pythonhosted.org/packages/c3/9f/1e09865bbe4c9202ff1334545535be32dfdbeffc46e7d4c36c5de67ffafe/onnxruntime_ep_webgpu-0.4.0-py3-none-macosx_14_0_universal2.whl",
sha256: "8d0a91d44b43d931c3068b9134aed9770f27803e353c2b58c93e1700bb68bcca",
size: 5_200_679,
})
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
Some(Wheel {
url: "https://files.pythonhosted.org/packages/c1/96/2a18a45079250afcd825687aef2895de266d5abc1cff9f52d2dede7598f2/onnxruntime_ep_webgpu-0.4.0-py3-none-manylinux_2_28_x86_64.whl",
sha256: "5d8c961eda91a88961cad1fb7621f389f006b18497d6ea9ecf96f729f066cbfb",
size: 6_892_120,
})
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
Some(Wheel {
url: "https://files.pythonhosted.org/packages/4e/ca/00c70322c19913c81a6bb2239aca83781b97a818b55c2ec3d25cec72dc4c/onnxruntime_ep_webgpu-0.4.0-py3-none-manylinux_2_28_aarch64.whl",
sha256: "17f660db53b1a509c63e721ac6784a2daa1c4e91a968523ee9986505ee5b0a55",
size: 6_096_222,
})
} else if cfg!(all(windows, target_arch = "x86_64")) {
Some(Wheel {
url: "https://files.pythonhosted.org/packages/d7/a4/c98a9e9433b3eeb576977b26c5c1cd0364f15ba9d198bb16101e7563ab06/onnxruntime_ep_webgpu-0.4.0-py3-none-win_amd64.whl",
sha256: "7db646669d1a2390551da675115ea125e68cb3d4d2c3a2d6e463372bf8d6ea87",
size: 13_149_847,
})
} else {
None
}
}
pub const FP16_FILES: &[(&str, &str, u64)] = &[
(
"onnx/model_fp16.onnx",
"ee200de55cb2f94e858aabca54be7697a9c0805a14c858ee26ad0922b05f57d7",
200_792,
),
(
"onnx/model_fp16.onnx_data",
"28d16e29cd623f25cc6fa0968700c5bc31036466091a5fa06d1353c1777f050e",
97_402_880,
),
];
pub fn plugin_file() -> &'static str {
if cfg!(target_os = "macos") {
"libonnxruntime_providers_webgpu.dylib"
} else if cfg!(windows) {
"onnxruntime_providers_webgpu.dll"
} else {
"libonnxruntime_providers_webgpu.so"
}
}
pub fn fetch_webgpu(cache: &Path, progress: &mut dyn FnMut(u8)) -> Result<PathBuf> {
let dir = crate::accel::component_dir(cache, &format!("webgpu-{WEBGPU_VERSION}"));
let stamp = dir.join(".semlith-verified");
if stamp.exists() && dir.join(plugin_file()).exists() {
return Ok(dir);
}
if crate::embed::airgap() {
bail!(
"{} is set and the WebGPU components are not in {} — pre-seed them on a \
connected machine, or drop --airgap",
crate::embed::AIRGAP_ENV,
crate::plain(&dir.display().to_string())
);
}
let Some(wheel) = wheel() else {
bail!("unavailable — no WebGPU plugin is published for this platform");
};
crate::embed::check_cache_dir(cache)?;
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let total = wheel.size + FP16_FILES.iter().map(|(_, _, size)| size).sum::<u64>();
let mut done = 0u64;
let mut report = |bytes: u64| {
done += bytes;
progress(((done * 100) / total.max(1)).min(100) as u8);
};
let wheel_path = dir.join("plugin.whl");
download(wheel.url, &wheel_path, wheel.sha256, &mut report)?;
extract_plugin(&wheel_path, &dir)?;
let _ = std::fs::remove_file(&wheel_path);
fetch_fp16_into(&dir, &mut report)?;
crate::home::write_private(&stamp, WEBGPU_VERSION.as_bytes())?;
Ok(dir)
}
pub fn fetch_fp16(cache: &Path, progress: &mut dyn FnMut(u8)) -> Result<PathBuf> {
let dir = crate::accel::component_dir(cache, &format!("webgpu-{WEBGPU_VERSION}"));
let total: u64 = FP16_FILES.iter().map(|(_, _, size)| size).sum();
let present = FP16_FILES.iter().all(|(file, _, _)| {
Path::new(file)
.file_name()
.is_some_and(|name| dir.join(name).exists())
});
if present {
return Ok(dir);
}
if crate::embed::airgap() {
bail!(
"{} is set and the fp16 model is not in {}",
crate::embed::AIRGAP_ENV,
crate::plain(&dir.display().to_string())
);
}
crate::embed::check_cache_dir(cache)?;
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let mut done = 0u64;
fetch_fp16_into(&dir, &mut |bytes| {
done += bytes;
progress(((done * 100) / total.max(1)).min(100) as u8);
})?;
Ok(dir)
}
fn fetch_fp16_into(dir: &Path, report: &mut dyn FnMut(u64)) -> Result<()> {
let base = format!(
"https://huggingface.co/{}/resolve/{}",
crate::embed::GRANITE_REPO,
crate::embed::GRANITE_REVISION
);
for (file, sha256, _) in FP16_FILES {
let name = Path::new(file).file_name().expect("a file name");
download(&format!("{base}/{file}"), &dir.join(name), sha256, report)?;
}
Ok(())
}
pub fn plugin_bytes() -> u64 {
wheel().map(|w| w.size).unwrap_or(0)
}
pub(crate) fn download(
url: &str,
to: &Path,
sha256: &str,
progress: &mut dyn FnMut(u64),
) -> Result<()> {
use sha2::{Digest, Sha256};
if to.exists() && crate::embed::digest(&std::fs::read(to)?) == sha256 {
progress(std::fs::metadata(to)?.len());
return Ok(());
}
let agent: ureq::Agent = ureq::Agent::config_builder()
.https_only(true)
.timeout_connect(Some(std::time::Duration::from_secs(20)))
.timeout_global(Some(std::time::Duration::from_secs(1800)))
.build()
.into();
crate::add::note_outbound("pack", url);
let mut response = agent
.get(url)
.call()
.with_context(|| format!("fetching {url}"))?;
let partial = to.with_extension("partial");
let mut out = std::fs::File::create(&partial)
.with_context(|| format!("writing {}", partial.display()))?;
let mut reader = response.body_mut().as_reader();
let mut hasher = Sha256::new();
let mut buffer = vec![0u8; 1 << 16];
loop {
let n = reader
.read(&mut buffer)
.with_context(|| format!("reading {url}"))?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
out.write_all(&buffer[..n])?;
progress(n as u64);
}
out.flush()?;
drop(out);
let actual: String = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
if actual != sha256 {
let _ = std::fs::remove_file(&partial);
bail!(
"{url} does not match the digest semlith pins for it (expected {sha256}, got {actual}); \
it was deleted"
);
}
std::fs::rename(&partial, to).with_context(|| format!("placing {}", to.display()))?;
crate::home::tighten_file(to);
Ok(())
}
fn extract_plugin(wheel: &Path, dir: &Path) -> Result<()> {
let file = std::fs::File::open(wheel)?;
let mut archive = zip::ZipArchive::new(file).context("reading the plugin wheel")?;
let mut placed = 0;
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let name = entry.name().to_string();
let Some(base) = name.strip_prefix("onnxruntime_ep_webgpu/") else {
continue;
};
let library = [".dylib", ".so", ".dll"]
.iter()
.any(|ext| base.ends_with(ext));
if !library || base.contains('/') || base.contains("..") {
continue;
}
let target = dir.join(base);
let mut out = std::fs::File::create(&target)?;
std::io::copy(&mut entry, &mut out)?;
placed += 1;
}
if placed == 0 || !dir.join(plugin_file()).exists() {
bail!("the plugin wheel held no {}", plugin_file());
}
Ok(())
}
pub enum Session {
Cpu(Box<crate::session::CpuSession>, String),
Gpu(Box<GpuSession>),
Cuda(Box<crate::cuda::Session>),
#[cfg(target_os = "macos")]
CoreMl(Box<crate::coreml::Session>),
Trt(Box<crate::trt::Session>),
OpenVino(Box<crate::openvino::Session>),
Llama(Box<crate::llama::Session>),
}
pub struct GpuSession {
session: ort::session::Session,
device: String,
}
pub const ADAPTER_ENV: &str = "SEMLITH_GPU_ADAPTER";
impl Session {
pub fn open(
lane: &str,
dir: Option<&Path>,
adapter: Option<&str>,
progress: &mut dyn FnMut(usize, usize),
) -> Result<Self> {
#[cfg(not(target_os = "macos"))]
let _ = progress;
let cache = crate::model_cache_dir()?;
if lane == "cuda" {
let pack = dir.context("the cuda lane needs its pack directory")?;
let model = crate::accel::component_dir(&cache, &format!("webgpu-{WEBGPU_VERSION}"))
.join("model_fp16.onnx");
return Ok(Session::Cuda(Box::new(crate::cuda::Session::open(
pack, &model,
)?)));
}
#[cfg(target_os = "macos")]
if matches!(lane, "ane" | "gpu-coreml") {
let pack = dir.context("a Core ML lane needs its pack directory")?;
let kind = if lane == "ane" {
crate::coreml::Kind::NeuralEngine
} else {
crate::coreml::Kind::Gpu
};
return Ok(Session::CoreMl(Box::new(crate::coreml::Session::open(
pack, kind, progress,
)?)));
}
if lane == "llama" {
let pack = dir.context("the llama lane needs its pack directory")?;
return Ok(Session::Llama(Box::new(crate::llama::Session::open(pack)?)));
}
crate::embed::link_runtime()?;
match lane {
"worker" => {
let model = crate::session::CpuSession::open(
&cache,
crate::embed::Variant::Int8,
crate::embed::embed_threads(),
true,
)?;
Ok(Session::Cpu(Box::new(model), crate::system::cpu_name()))
}
"gpu" => {
let dir = dir.context("the gpu lane needs its component directory")?;
Ok(Session::Gpu(Box::new(GpuSession::open(dir, adapter)?)))
}
"trt" => {
let pack = dir.context("the trt lane needs its pack directory")?;
Ok(Session::Trt(Box::new(crate::trt::Session::open(pack)?)))
}
"openvino" => {
let pack = dir.context("the openvino lane needs its pack directory")?;
Ok(Session::OpenVino(Box::new(crate::openvino::Session::open(
pack,
)?)))
}
other => bail!("unavailable — no lane called {other}"),
}
}
pub fn device(&self) -> String {
match self {
Session::Cpu(_, name) => name.clone(),
Session::Gpu(gpu) => gpu.device.clone(),
Session::Cuda(cuda) => cuda.device(),
#[cfg(target_os = "macos")]
Session::CoreMl(coreml) => coreml.device(),
Session::Trt(trt) => trt.device(),
Session::OpenVino(openvino) => openvino.device(),
Session::Llama(llama) => llama.device(),
}
}
pub fn variant(&self) -> &'static str {
match self {
Session::Cpu(..) => "int8-cpu",
Session::Gpu(_) => "fp16-webgpu",
Session::Cuda(_) => "fp16-cuda",
#[cfg(target_os = "macos")]
Session::CoreMl(coreml) => coreml.variant(),
Session::Trt(trt) => trt.variant(),
Session::OpenVino(openvino) => openvino.variant(),
Session::Llama(llama) => llama.variant(),
}
}
pub fn embed(&mut self, batch: &[&[u32]]) -> Result<Vec<Vec<f32>>> {
match self {
Session::Cpu(model, _) => model.embed(batch),
Session::Gpu(gpu) => crate::session::run(&mut gpu.session, batch)
.map_err(|e| anyhow::anyhow!("on the GPU: {e:#}")),
Session::Cuda(cuda) => cuda.embed(batch),
#[cfg(target_os = "macos")]
Session::CoreMl(coreml) => coreml.embed(batch),
Session::Trt(trt) => trt.embed(batch),
Session::OpenVino(openvino) => openvino.embed(batch),
Session::Llama(llama) => llama.embed(batch),
}
}
}
impl GpuSession {
fn open(dir: &Path, named: Option<&str>) -> Result<Self> {
use ort::AsPointer;
use ort::environment::Environment;
let env = Environment::current().context("starting ONNX Runtime")?;
let library = env
.register_ep_library("webgpu", dir.join(plugin_file()))
.context("registering the WebGPU plugin")?;
std::mem::forget(library);
let mut devices = Vec::new();
let mut candidates = Vec::new();
let mut refused = Vec::new();
for device in env.devices() {
if device.ep().ok() == Some("CPUExecutionProvider") {
continue;
}
let hardware = device.hardware_device();
if hardware.ty() != ort::memory::DeviceType::GPU {
continue;
}
let vendor_id = unsafe { (ort::api().HardwareDevice_VendorId)(hardware.ptr()) };
let name = hardware.vendor().unwrap_or("unknown").to_string();
if is_software(vendor_id, &name) {
refused.push(format!("{name} (vendor {vendor_id:04x})"));
continue;
}
candidates.push(Adapter {
name,
vendor: vendor_id,
});
devices.push(device);
}
let Some((at, why)) = choose(&candidates, named) else {
if refused.is_empty() {
bail!("unavailable — the WebGPU plugin found no hardware GPU");
}
bail!(
"unavailable — only a software renderer was found ({}), which is never used",
refused.join(", ")
);
};
let label = format!("{} GPU (WebGPU; {why})", candidates[at].name);
let devices = vec![devices.swap_remove(at)];
let session = ort::session::Session::builder()
.map_err(|e| anyhow::anyhow!("{e}"))?
.with_devices(devices, None)
.map_err(|e| anyhow::anyhow!("{e}"))?
.commit_from_file(dir.join("model_fp16.onnx"))
.map_err(|e| anyhow::anyhow!("loading the fp16 model on the GPU: {e}"))?;
Ok(Self {
session,
device: label,
})
}
}
#[derive(Debug, Clone)]
pub struct Adapter {
pub name: String,
pub vendor: u32,
}
fn discrete(vendor: u32) -> bool {
matches!(vendor, 0x10de | 0x1002)
}
pub fn choose(adapters: &[Adapter], named: Option<&str>) -> Option<(usize, String)> {
if adapters.is_empty() {
return None;
}
if let Some(named) = named.map(str::to_ascii_lowercase)
&& let Some(at) = adapters
.iter()
.position(|a| a.name.to_ascii_lowercase().contains(&named))
{
return Some((at, format!("named by the gpu_adapter setting ({named})")));
}
if adapters.len() == 1 {
return Some((0, "the only hardware GPU".to_string()));
}
match adapters.iter().position(|a| discrete(a.vendor)) {
Some(at) => Some((
at,
"a discrete GPU, preferred over an integrated one".to_string(),
)),
None => Some((0, "the first of several integrated GPUs".to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_discrete_adapter_is_chosen_over_an_integrated_one_unless_one_is_named() {
let adapters = vec![
Adapter {
name: "Intel(R) UHD Graphics 770".into(),
vendor: 0x8086,
},
Adapter {
name: "NVIDIA GeForce RTX 4070".into(),
vendor: 0x10de,
},
];
let (at, why) = choose(&adapters, None).unwrap();
assert_eq!(at, 1, "{why}");
assert!(why.contains("discrete"));
let (at, why) = choose(&adapters, Some("uhd")).unwrap();
assert_eq!(at, 0);
assert!(why.contains("named"));
assert_eq!(choose(&adapters, Some("radeon")).unwrap().0, 1);
assert!(choose(&[], None).is_none());
}
#[test]
fn software_renderers_are_refused_by_vendor_or_name() {
assert!(is_software(0x10005, "llvmpipe (LLVM 17.0.6, 256 bits)"));
assert!(is_software(0x1414, "Microsoft Basic Render Driver"));
assert!(is_software(0, "SwiftShader Device (Subzero)"));
assert!(is_software(0x1414, "Microsoft Hyper-V Video"));
assert!(!is_software(0x10de, "NVIDIA GeForce RTX 3060"));
assert!(!is_software(0x106b, "Apple"));
assert!(!is_software(0x1002, "AMD Radeon RX 7800 XT"));
}
}