Add bread-utils and bread-onnx: shared crates for ecosystem-wide duplication
bread-utils extracts genuinely duplicated logic found across breadbox, breadclip, breadmon, breadcrumbs, bos-settings, and breadhelp: - hypr: Hyprland socket1 request/response client (breadbox's get_active_workspace + breadclip's position.rs hyprctl_json were near-identical), socket2 path resolution (breadmon), and a version-tolerant `fullscreen` field parser (Hyprland has shipped both bool and int representations across versions). - singleton: correct flock-based single-instance toggle, replacing the TOCTOU-prone read-pid/check-proc/kill/write-pid pattern duplicated verbatim between breadbox and breadclip (breadclip's own comment says "matches breadbox pattern"). - proc: breadcrumbs' timeout-guarded subprocess runner, promoted verbatim as the one implementation in the ecosystem that already got this right. - atomic + xdg: atomic (temp-then-rename) file writes with an optional .bak-before-overwrite variant, and XDG path helpers that never fall back to a literal "~/..." string (the exact breadclip-core and breadpad-shared bug: PathBuf never expands `~`). - tomlcfg (feature "toml"): the load_doc/save_doc TOML-editing discipline bos-settings and breadhelp both implemented byte-for-byte identically in the same fix pass that introduced it. - gtk_popup (feature "gtk"): layer-shell overlay window setup, visible-row navigation, and click-outside-close, deduplicated from breadbox and breadclip (~150 duplicated lines, per both apps' own "same as breadbox" comments). bread-onnx extracts the embedding pipeline (tokenize -> tensor build -> mean-pool -> L2-normalize) duplicated near-verbatim between breadarr and breadsearch, a shared execution-provider session builder with loud EP- registration logging, and a model download+integrity helper. Defaults AMD iGPU acceleration to ort::ep::MIGraphX (not ROCm) per this machine's own breadsearch-gpu-backends lesson: ROCMExecutionProvider silently no-ops to CPU on distro ROCm onnxruntime builds compiled with --use_migraphx. Both crates build and pass their own test suites standalone. Consumer migrations follow in subsequent commits.
This commit is contained in:
parent
394a252f9e
commit
853ee33415
17 changed files with 2503 additions and 5 deletions
34
bread-onnx/Cargo.toml
Normal file
34
bread-onnx/Cargo.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
[package]
|
||||
name = "bread-onnx"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Shared ONNX Runtime plumbing for the bread ecosystem: session building, execution-provider fallback with loud diagnostics, embedding-pipeline tensor math, and verified model downloads"
|
||||
repository = "https://github.com/Breadway/bread-ecosystem"
|
||||
keywords = ["onnx", "onnxruntime", "ml", "embeddings"]
|
||||
|
||||
[dependencies]
|
||||
bread-utils = { path = "../bread-utils" }
|
||||
# Left at default-features = false, with no api-XX/download-binaries/
|
||||
# load-dynamic/tls-native features of our own: those choices (how each app
|
||||
# obtains/links its onnxruntime .so, and which ONNX Runtime C API version to
|
||||
# bind) are consumer-build-environment decisions that stay in each app's own
|
||||
# Cargo.toml (breadarr, breadmill, and breadpad already each pin different
|
||||
# ones). Cargo's feature unification means this crate's minimal declaration
|
||||
# just rides along with whatever the consuming app already selected.
|
||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing"] }
|
||||
# Default features left on (unlike `ort` above) — breadarr and breadmill
|
||||
# both already build against plain default-featured tokenizers; only
|
||||
# breadpad customizes this (http, fancy-regex), and Cargo's feature
|
||||
# unification only ever adds features on top of this minimal baseline, so
|
||||
# breadpad's own selection still applies in its own build.
|
||||
tokenizers = "0.23"
|
||||
tracing = { workspace = true }
|
||||
ureq = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
112
bread-onnx/src/download.rs
Normal file
112
bread-onnx/src/download.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//! Model download + integrity checking.
|
||||
//!
|
||||
//! `breadarrd/src/matcher/mod.rs::download` (async, `reqwest`) and
|
||||
//! `breadmill/src/main.rs::download_if_missing` (sync, `ureq`) independently
|
||||
//! implement "download to a temp file, then rename over the destination"
|
||||
//! for fetching an ONNX model/tokenizer if it isn't already present —
|
||||
//! genuinely duplicated intent, different HTTP clients. Neither verifies
|
||||
//! the download's integrity beyond "the response wasn't empty". This module
|
||||
//! is a fresh, shared implementation (sync, `ureq` — matching this
|
||||
//! workspace's existing `bakery` convention for downloads) that adds an
|
||||
//! optional SHA-256 check, built on [`bread_utils::atomic::write_atomic_bytes`]
|
||||
//! for the same crash-safety property both originals already had.
|
||||
//!
|
||||
//! `breadarrd`'s async caller should wrap a call to [`ensure_file`] in
|
||||
//! `tokio::task::spawn_blocking` rather than block its async runtime
|
||||
//! directly — see that crate's migration for the concrete pattern.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Download `url` to `dest` if `dest` doesn't already exist. If
|
||||
/// `expected_sha256` is given, verifies the downloaded bytes against it
|
||||
/// (case-insensitive hex) before the atomic rename and returns an error on
|
||||
/// mismatch — the temp file is discarded, `dest` is left untouched. An
|
||||
/// already-present `dest` is trusted as-is and not re-verified (matches
|
||||
/// both original implementations' "if it exists, skip" behavior; re-hashing
|
||||
/// a ~90MB+ model file on every startup would be wasted work for the common
|
||||
/// case of a stable, previously-verified file).
|
||||
pub fn ensure_file(url: &str, dest: &Path, expected_sha256: Option<&str>) -> anyhow::Result<PathBuf> {
|
||||
if dest.exists() {
|
||||
return Ok(dest.to_path_buf());
|
||||
}
|
||||
|
||||
tracing::info!("bread-onnx: downloading {url} -> {}", dest.display());
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build();
|
||||
let response = agent
|
||||
.get(url)
|
||||
.call()
|
||||
.map_err(|e| anyhow::anyhow!("failed to download {url}: {e}"))?;
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read response body from {url}: {e}"))?;
|
||||
|
||||
if bytes.is_empty() {
|
||||
anyhow::bail!("empty download from {url}");
|
||||
}
|
||||
|
||||
if let Some(expected) = expected_sha256 {
|
||||
let actual = sha256_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
anyhow::bail!(
|
||||
"checksum mismatch for {url}: expected {expected}, got {actual} — refusing to install"
|
||||
);
|
||||
}
|
||||
tracing::info!("bread-onnx: verified sha256 for {}", dest.display());
|
||||
}
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
bread_utils::atomic::write_atomic_bytes(dest, &bytes, None)
|
||||
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", dest.display()))?;
|
||||
|
||||
tracing::info!(
|
||||
"bread-onnx: saved {} ({:.1} MB)",
|
||||
dest.display(),
|
||||
bytes.len() as f64 / 1_048_576.0
|
||||
);
|
||||
Ok(dest.to_path_buf())
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_matches_known_vector() {
|
||||
// sha256("") — well-known empty-input digest.
|
||||
assert_eq!(
|
||||
sha256_hex(b""),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_file_skips_download_when_already_present() {
|
||||
let dir = std::env::temp_dir().join(format!("bread-onnx-download-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("model.onnx");
|
||||
std::fs::write(&dest, b"already here").unwrap();
|
||||
|
||||
// A bogus URL would fail if actually requested — success here proves
|
||||
// the existing-file short-circuit fired instead of dialing out.
|
||||
let result = ensure_file("http://127.0.0.1:1/unreachable", &dest, None);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"already here");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
220
bread-onnx/src/embedding.rs
Normal file
220
bread-onnx/src/embedding.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
//! Shared BERT-family embedding pipeline: tokenize → build `input_ids`/
|
||||
//! `attention_mask`/`token_type_ids` tensors → run → mean-pool the
|
||||
//! non-padded positions of `last_hidden_state` → L2-normalize → clamp/pad to
|
||||
//! a configured output dimension.
|
||||
//!
|
||||
//! This is extracted from two independently-written but essentially
|
||||
//! byte-identical implementations:
|
||||
//! - `breadarrd/src/matcher/embed.rs::OrtEmbedder::embed` (lines 45-111) and
|
||||
//! its `l2_normalize` (lines 114-121)
|
||||
//! - `breadmill/src/embed.rs::OrtEmbedder::embed_with_prefix` (lines 65-153)
|
||||
//! and its `l2_normalize` (lines 156-163)
|
||||
//!
|
||||
//! Both truncate to a max sequence length, build the same three `i64`
|
||||
//! tensors, run the same `input_ids`/`attention_mask`/`token_type_ids` →
|
||||
//! `last_hidden_state` shape contract, mean-pool over `actual_seq.min(mask.len())`
|
||||
//! positions (both already independently arrived at the same `.min()` guard
|
||||
//! for execution providers that pad the output sequence dimension), and
|
||||
//! L2-normalize with the same `1e-10` epsilon. `breadmill`'s only real
|
||||
//! difference is prepending a document/query prefix string before
|
||||
//! tokenizing, which stays the caller's responsibility here — pass the
|
||||
//! already-prefixed text to [`EmbeddingSession::embed`].
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ort::session::builder::GraphOptimizationLevel;
|
||||
use ort::session::Session;
|
||||
use ort::value::Tensor;
|
||||
use tokenizers::Tokenizer;
|
||||
|
||||
use crate::provider::Provider;
|
||||
use crate::session::build_session;
|
||||
|
||||
pub struct EmbeddingSession {
|
||||
session: Session,
|
||||
tokenizer: Tokenizer,
|
||||
dim: usize,
|
||||
max_seq_len: usize,
|
||||
}
|
||||
|
||||
impl EmbeddingSession {
|
||||
/// Load a BERT-family embedding model + tokenizer, selecting execution
|
||||
/// providers via [`build_session`]. `dim` is the output embedding
|
||||
/// dimension (results are truncated/zero-padded to it — matches how
|
||||
/// both original implementations handled a model whose `dim` config
|
||||
/// might not exactly match `last_hidden_state`'s actual width). `max_seq_len`
|
||||
/// caps tokenized input length before inference (truncating, not
|
||||
/// erroring) to bound attention memory on pathological inputs.
|
||||
pub fn load(
|
||||
model_path: &Path,
|
||||
tokenizer_path: &Path,
|
||||
dim: usize,
|
||||
max_seq_len: usize,
|
||||
providers: &[Provider],
|
||||
) -> anyhow::Result<Self> {
|
||||
let session = build_session(model_path, GraphOptimizationLevel::Level3, providers)?;
|
||||
let tokenizer = Tokenizer::from_file(tokenizer_path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to load tokenizer: {e}"))?;
|
||||
Ok(Self { session, tokenizer, dim, max_seq_len })
|
||||
}
|
||||
|
||||
/// Embed `text` (already prefixed by the caller, if the model expects a
|
||||
/// document/query prefix). Returns an L2-normalized vector of length
|
||||
/// `dim`.
|
||||
pub fn embed(&mut self, text: &str) -> anyhow::Result<Vec<f32>> {
|
||||
let encoding = self
|
||||
.tokenizer
|
||||
.encode(text, true)
|
||||
.map_err(|e| anyhow::anyhow!("tokenization failed: {e}"))?;
|
||||
|
||||
let mut ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
|
||||
let mut mask: Vec<i64> = encoding.get_attention_mask().iter().map(|&x| x as i64).collect();
|
||||
let mut type_ids: Vec<i64> = encoding.get_type_ids().iter().map(|&x| x as i64).collect();
|
||||
|
||||
ids.truncate(self.max_seq_len);
|
||||
mask.truncate(self.max_seq_len);
|
||||
type_ids.truncate(self.max_seq_len);
|
||||
|
||||
let seq_len = ids.len() as i64;
|
||||
let id_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], ids))
|
||||
.map_err(|e| anyhow::anyhow!("failed to build input_ids tensor: {e}"))?;
|
||||
let mask_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], mask.clone()))
|
||||
.map_err(|e| anyhow::anyhow!("failed to build attention_mask tensor: {e}"))?;
|
||||
let type_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], type_ids))
|
||||
.map_err(|e| anyhow::anyhow!("failed to build token_type_ids tensor: {e}"))?;
|
||||
|
||||
let outputs = self
|
||||
.session
|
||||
.run(ort::inputs! {
|
||||
"input_ids" => id_tensor,
|
||||
"attention_mask" => mask_tensor,
|
||||
"token_type_ids" => type_tensor,
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("ort inference failed: {e}"))?;
|
||||
|
||||
let (shape, data) = outputs["last_hidden_state"]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| anyhow::anyhow!("failed to extract last_hidden_state: {e}"))?;
|
||||
|
||||
let actual_seq = shape[1] as usize;
|
||||
let actual_dim = shape[2] as usize;
|
||||
|
||||
Ok(mean_pool_normalize(data, &mask, actual_seq, actual_dim, self.dim))
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean-pool `data` (flattened `[1, actual_seq, actual_dim]`) over the
|
||||
/// positions `mask` marks as non-padding, L2-normalize the result, then
|
||||
/// clamp/zero-pad to `target_dim`. `actual_seq.min(mask.len())` guards
|
||||
/// against execution providers (MIGraphX observed doing this) that pad the
|
||||
/// output sequence dimension for kernel efficiency, making `actual_seq`
|
||||
/// exceed the caller's own `mask` length.
|
||||
fn mean_pool_normalize(data: &[f32], mask: &[i64], actual_seq: usize, actual_dim: usize, target_dim: usize) -> Vec<f32> {
|
||||
let mut result = vec![0.0f32; actual_dim];
|
||||
let mut count = 0usize;
|
||||
for t in 0..actual_seq.min(mask.len()) {
|
||||
if mask[t] > 0 {
|
||||
for d in 0..actual_dim {
|
||||
result[d] += data[t * actual_dim + d];
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
for x in &mut result {
|
||||
*x /= count as f32;
|
||||
}
|
||||
}
|
||||
|
||||
l2_normalize(&mut result);
|
||||
result.truncate(target_dim);
|
||||
while result.len() < target_dim {
|
||||
result.push(0.0);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn l2_normalize(v: &mut [f32]) {
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 1e-10 {
|
||||
for x in v.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
a.iter().zip(b).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn l2_normalize_produces_unit_vector() {
|
||||
let mut v = vec![3.0, 4.0];
|
||||
l2_normalize(&mut v);
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l2_normalize_leaves_zero_vector_untouched() {
|
||||
let mut v = vec![0.0, 0.0, 0.0];
|
||||
l2_normalize(&mut v);
|
||||
assert_eq!(v, vec![0.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_of_identical_unit_vectors_is_one() {
|
||||
let mut v = vec![1.0, 2.0, 3.0];
|
||||
l2_normalize(&mut v);
|
||||
let sim = cosine_similarity(&v, &v);
|
||||
assert!((sim - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_of_orthogonal_vectors_is_zero() {
|
||||
let a = vec![1.0, 0.0];
|
||||
let b = vec![0.0, 1.0];
|
||||
assert!(cosine_similarity(&a, &b).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mean_pool_ignores_padded_positions() {
|
||||
// actual_dim = 2, 3 positions: two real tokens + one padded (mask=0)
|
||||
let data = vec![
|
||||
1.0, 1.0, // t0: real
|
||||
9.0, 9.0, // t1: padded, should be ignored
|
||||
3.0, 3.0, // t2: real
|
||||
];
|
||||
let mask = vec![1, 0, 1];
|
||||
let pooled = mean_pool_normalize(&data, &mask, 3, 2, 2);
|
||||
// Mean of (1,1) and (3,3) is (2,2), normalized to unit length.
|
||||
let expected_norm = (2.0f32 * 2.0 + 2.0 * 2.0).sqrt();
|
||||
assert!((pooled[0] - 2.0 / expected_norm).abs() < 1e-5);
|
||||
assert!((pooled[1] - 2.0 / expected_norm).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mean_pool_clamps_actual_seq_to_mask_len_for_padded_ep_output() {
|
||||
// Regression guard for the MIGraphX-padded-output-sequence case both
|
||||
// original implementations independently guarded against: actual_seq
|
||||
// (4) exceeds mask.len() (2) — must not index out of the mask.
|
||||
let data = vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
|
||||
let mask = vec![1, 1];
|
||||
let pooled = mean_pool_normalize(&data, &mask, 4, 2, 2);
|
||||
assert!(pooled.iter().all(|x| x.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mean_pool_pads_short_result_to_target_dim() {
|
||||
let data = vec![1.0, 1.0];
|
||||
let mask = vec![1];
|
||||
let pooled = mean_pool_normalize(&data, &mask, 1, 1, 4);
|
||||
assert_eq!(pooled.len(), 4);
|
||||
assert_eq!(pooled[2], 0.0);
|
||||
assert_eq!(pooled[3], 0.0);
|
||||
}
|
||||
}
|
||||
32
bread-onnx/src/lib.rs
Normal file
32
bread-onnx/src/lib.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//! Shared ONNX Runtime plumbing for the bread ecosystem.
|
||||
//!
|
||||
//! Extracted from breadarr, breadsearch, and breadpad during the
|
||||
//! 2026-07-16 ecosystem-wide utility audit — see each module's doc comment
|
||||
//! for the original file:line duplication it replaces.
|
||||
//!
|
||||
//! **Important**: [`session::build_session`] logs execution-provider
|
||||
//! selection via the `tracing` crate, but does *not* initialize a
|
||||
//! subscriber itself. Without one, ONNX Runtime's own "successfully
|
||||
//! registered `XExecutionProvider`" log line (and this crate's own
|
||||
//! selection logging) go nowhere — which is exactly how a GPU execution
|
||||
//! provider can silently no-op back to CPU with zero visible error (see
|
||||
//! [`provider`]'s doc comment for the concrete history behind this). All
|
||||
//! three current consumers already call `tracing_subscriber::fmt().init()`
|
||||
//! (or an `EnvFilter`-configured equivalent) at startup; any new consumer
|
||||
//! must do the same before calling [`session::build_session`].
|
||||
//!
|
||||
//! - [`provider`] — the [`provider::Provider`] enum and the
|
||||
//! MIGraphX-not-ROCm default rationale.
|
||||
//! - [`session`] — session construction with EP fallback + loud logging.
|
||||
//! - [`embedding`] — the shared tokenize → tensor → mean-pool → normalize
|
||||
//! pipeline for BERT-family embedding models.
|
||||
//! - [`download`] — model download with atomic write + optional SHA-256
|
||||
//! integrity check.
|
||||
|
||||
pub mod download;
|
||||
pub mod embedding;
|
||||
pub mod provider;
|
||||
pub mod session;
|
||||
|
||||
pub use provider::Provider;
|
||||
pub use session::build_session;
|
||||
113
bread-onnx/src/provider.rs
Normal file
113
bread-onnx/src/provider.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
//! Execution-provider selection.
|
||||
//!
|
||||
//! This crate defaults AMD iGPU acceleration to
|
||||
//! [`ort::ep::MIGraphX`](ort::ep::MIGraphX), *not*
|
||||
//! [`ort::ep::ROCm`](ort::ep::ROCm), on purpose. `breadpad-shared/src/
|
||||
//! classifier.rs::try_load_session` used the classic `ROCMExecutionProvider`
|
||||
//! and — per the hard-won lesson recorded in this machine's own operator
|
||||
//! notes (`breadsearch-gpu-backends`, from `breadsearch`'s own history) —
|
||||
//! that EP silently no-ops on this class of system and falls back to CPU
|
||||
//! with zero visible error: distro ROCm ONNX Runtime builds (e.g. Arch's
|
||||
//! `onnxruntime-rocm`) are commonly compiled with `--use_migraphx`, not
|
||||
//! `--use_rocm`, so `ROCMExecutionProvider` never actually registers, and
|
||||
//! nothing surfaces that fact unless a `tracing` subscriber is initialized
|
||||
//! to catch ONNX Runtime's own EP-registration log line. `breadmill/src/
|
||||
//! embed.rs::rocm_session` already got this right; this module promotes
|
||||
//! that provider choice (and the loud logging around it) to the shared
|
||||
//! crate so it can't silently regress in any consumer again.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A requested execution provider, in the shared vocabulary consumers use.
|
||||
/// Convert to an `ort` dispatch entry with [`Provider::to_dispatch`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Provider {
|
||||
Cpu,
|
||||
/// AMD iGPU/dGPU via MIGraphX (ROCm-backed onnxruntime builds). See this
|
||||
/// module's doc comment for why this — not `ROCm` — is the correct
|
||||
/// choice on this class of system.
|
||||
MiGraphX { device_id: i32 },
|
||||
/// NVIDIA GPU via CUDA.
|
||||
Cuda { device_id: i32 },
|
||||
/// Intel iGPU/dGPU (Arc) via OpenVINO. `cache_dir` stores OpenVINO's
|
||||
/// compiled-model blobs between runs.
|
||||
OpenVino { device_type: String, cache_dir: PathBuf },
|
||||
/// AMD XDNA NPU via the VitisAI execution provider (Ryzen AI SDK).
|
||||
/// `cache_dir` stores the compiled NPU model between runs.
|
||||
Vitis {
|
||||
config_file: PathBuf,
|
||||
cache_dir: PathBuf,
|
||||
cache_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Provider::Cpu => "CPU",
|
||||
Provider::MiGraphX { .. } => "MIGraphX (AMD iGPU/dGPU)",
|
||||
Provider::Cuda { .. } => "CUDA (NVIDIA GPU)",
|
||||
Provider::OpenVino { .. } => "OpenVINO (Intel iGPU/dGPU)",
|
||||
Provider::Vitis { .. } => "VitisAI (AMD XDNA NPU)",
|
||||
}
|
||||
}
|
||||
|
||||
/// The literal execution-provider name ONNX Runtime's own log line
|
||||
/// reports on successful registration (e.g. `"Successfully registered
|
||||
/// \`MIGraphXExecutionProvider\`"`) — used to build the loud log hint in
|
||||
/// [`crate::session::build_session`].
|
||||
fn ort_registration_name(&self) -> &'static str {
|
||||
match self {
|
||||
Provider::Cpu => "CPUExecutionProvider",
|
||||
Provider::MiGraphX { .. } => "MIGraphXExecutionProvider",
|
||||
Provider::Cuda { .. } => "CUDAExecutionProvider",
|
||||
Provider::OpenVino { .. } => "OpenVINOExecutionProvider",
|
||||
Provider::Vitis { .. } => "VitisAIExecutionProvider",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_dispatch(&self) -> anyhow::Result<ort::ep::ExecutionProviderDispatch> {
|
||||
Ok(match self {
|
||||
Provider::Cpu => ort::ep::CPU::default().build(),
|
||||
Provider::MiGraphX { device_id } => {
|
||||
ort::ep::MIGraphX::default().with_device_id(*device_id).build()
|
||||
}
|
||||
Provider::Cuda { device_id } => {
|
||||
ort::ep::CUDA::default().with_device_id(*device_id).build()
|
||||
}
|
||||
Provider::OpenVino { device_type, cache_dir } => {
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
ort::ep::OpenVINO::default()
|
||||
.with_device_type(device_type.clone())
|
||||
.with_cache_dir(cache_dir.to_string_lossy())
|
||||
.build()
|
||||
}
|
||||
Provider::Vitis { config_file, cache_dir, cache_key } => {
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
ort::ep::Vitis::default()
|
||||
.with_config_file(config_file.to_string_lossy())
|
||||
.with_cache_dir(cache_dir.to_string_lossy())
|
||||
.with_cache_key(cache_key.clone())
|
||||
.build()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Log a loud, consistent "using X" line plus (for non-CPU providers) a
|
||||
/// reminder of exactly what to grep ONNX Runtime's own log output for —
|
||||
/// this is the "at minimum log EP registration success/failure loudly
|
||||
/// by default" half of the fix, independent of whether the caller has
|
||||
/// wired up `tracing_subscriber` (see [`crate::init_tracing`]).
|
||||
pub(crate) fn log_selection(&self) {
|
||||
tracing::info!("bread-onnx: requesting {} execution provider", self.name());
|
||||
if !matches!(self, Provider::Cpu) {
|
||||
tracing::info!(
|
||||
"bread-onnx: check ONNX Runtime's own log output for \"Successfully registered \
|
||||
`{}`\" — if it's missing, the ONNX Runtime build in use wasn't compiled/shipped \
|
||||
with this provider and inference silently fell back to CPU. This line only \
|
||||
appears if a `tracing` subscriber is initialized (see `bread_onnx::init_tracing`).",
|
||||
self.ort_registration_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
bread-onnx/src/session.rs
Normal file
49
bread-onnx/src/session.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! Session construction with execution-provider fallback.
|
||||
//!
|
||||
//! Builds one `ort::session::Session` whose execution-provider dispatch
|
||||
//! list is exactly `providers` (in order) with an implicit `CPU` appended
|
||||
//! if the caller didn't already include one — ONNX Runtime tries each
|
||||
//! listed EP per-node and falls through the list on failure, so this
|
||||
//! mirrors (and replaces) the identical `.with_execution_providers([primary,
|
||||
//! CPU])` pattern already proven out in `breadmill/src/embed.rs::rocm_session`
|
||||
//! /`cuda_session`/`openvino_session`/`npu_session`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ort::session::builder::GraphOptimizationLevel;
|
||||
use ort::session::Session;
|
||||
|
||||
use crate::provider::Provider;
|
||||
|
||||
/// Build a session, trying each of `providers` in order (ONNX Runtime falls
|
||||
/// through per-node on registration failure) with a trailing `CPU` fallback
|
||||
/// implicitly appended if not already present. Always logs which provider
|
||||
/// was requested — see [`Provider::log_selection`] — regardless of whether
|
||||
/// `tracing_subscriber` is initialized, so at minimum the *attempt* is
|
||||
/// visible even without wired-up logging; the actual per-EP success/failure
|
||||
/// detail only surfaces once a subscriber is listening.
|
||||
pub fn build_session(
|
||||
model_path: &Path,
|
||||
opt_level: GraphOptimizationLevel,
|
||||
providers: &[Provider],
|
||||
) -> anyhow::Result<Session> {
|
||||
let mut dispatch = Vec::with_capacity(providers.len() + 1);
|
||||
for p in providers {
|
||||
p.log_selection();
|
||||
dispatch.push(p.to_dispatch()?);
|
||||
}
|
||||
if !providers.iter().any(|p| matches!(p, Provider::Cpu)) {
|
||||
dispatch.push(Provider::Cpu.to_dispatch()?);
|
||||
}
|
||||
|
||||
let mut builder = Session::builder()
|
||||
.map_err(|e| anyhow::anyhow!("failed to create ort session builder: {e}"))?
|
||||
.with_optimization_level(opt_level)
|
||||
.map_err(|e| anyhow::anyhow!("failed to set optimization level: {e}"))?
|
||||
.with_execution_providers(dispatch)
|
||||
.map_err(|e| anyhow::anyhow!("failed to configure execution providers: {e}"))?;
|
||||
|
||||
builder
|
||||
.commit_from_file(model_path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to load model from {}: {e}", model_path.display()))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue