Fix silent ROCm-to-CPU fallback: switch to MIGraphX; fix XDG tilde bugs

This is the actual bug the night's ecosystem-utils audit was looking for.
classifier.rs::try_load_session requested ort::ep::ROCm (the classic
ROCMExecutionProvider) first. Per this machine's own breadsearch-gpu-
backends operator notes, that EP silently no-ops on this class of system:
distro ROCm onnxruntime builds (Arch's onnxruntime-rocm) are commonly
compiled with --use_migraphx, not --use_rocm, so ROCMExecutionProvider
never actually registers — active_provider could report "ROCm (iGPU)"
while every real inference secretly ran on CPU, with nothing surfacing
that fact anywhere. Switched to ort::ep::MIGraphX via
bread_onnx::build_session (path dependency for now, see the TODO in
Cargo.toml), matching breadsearch's own already-correct embed.rs.

Also fixed the same literal-tilde XDG fallback bug found across this
pass (breadclip-core, breadmon, breadarr-shared) in three more places:
classifier.rs::model_dir, config.rs::config_path, config.rs::style_css_path.

Bumped the workspace's tokenizers pin 0.21 -> 0.23 to unify with
bread-onnx's own requirement (breadarr already pins 0.23); verified via a
full workspace build + test pass, no API changes needed at any call site.

Validation, and an important finding: breadpad-shared's full test suite
(unit: 181/181, config: 26/26, classifier integration: 15/15) passes
clean. The pipeline.rs integration suite (16 tests, each building a real
classifier session) surfaced ONE genuine, 100%-reproducible failure:
plain_note_appears_in_store expects "retro went well today" to classify
as Note, but MIGraphX execution classifies it as Question (CPU execution
returns Note). This is not a regression from this change — it's proof the
fix works: the GPU path was never actually running before, so this
CPU-vs-GPU floating-point divergence on a borderline NLI classification
was always latent and simply never observable. Left the test as-is (its
failure now accurately reflects reality) rather than "fixing" it by
reverting to the broken EP or silently forcing a test-only CPU path —
that's a product/test-fixture decision for the owner, not something a
duplication-extraction pass should decide unilaterally. See bread-onnx's
companion fix (ORT_MIGRAPHX_MODEL_CACHE_PATH default) which cut this
suite's wall time from 905s to 112s by letting compiled kernels persist
across runs.
This commit is contained in:
Breadway 2026-07-17 13:53:00 +08:00
parent 369935515b
commit 6a06872f09
7 changed files with 183 additions and 57 deletions

View file

@ -26,6 +26,9 @@ regex.workspace = true
ureq.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
ical = "0.11"
# TODO(owner): switch to tag-pinned git dependency once bread-onnx/bread-utils are merged and tagged, matching the bread-theme pattern
bread-onnx = { path = "../../bread-ecosystem-fix-worktree/bread-onnx" }
bread-utils = { path = "../../bread-ecosystem-fix-worktree/bread-utils" }
[dev-dependencies]
tempfile = "3"

View file

@ -16,7 +16,7 @@ pub enum ExecutionProvider {
impl ExecutionProvider {
pub fn as_str(&self) -> &str {
match self {
ExecutionProvider::Gpu => "ROCm (iGPU)",
ExecutionProvider::Gpu => "MIGraphX (iGPU)",
ExecutionProvider::Cpu => "CPU",
}
}
@ -32,10 +32,13 @@ pub struct Classifier {
}
fn model_dir() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("breadpad")
.join("model")
// Was `dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"))`
// — the same literal-tilde-fallback bug flagged (but not fixed) in
// breadclip-core tonight: PathBuf/std::fs never expand `~`, so on the
// rare box where `dirs` can't resolve a home directory this silently
// resolved to a directory literally named `~` under the current working
// directory instead of the user's actual home.
bread_utils::xdg::data_dir("breadpad").join("model")
}
impl Classifier {
@ -246,21 +249,46 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
fn try_load_session(
path: &std::path::Path,
) -> (Option<ort::session::Session>, ExecutionProvider) {
// Try ROCm (iGPU) first, fall back to CPU.
let rocm_available = {
// AMD iGPU via MIGraphX, falling back to CPU. This used to request the
// classic `ort::ep::ROCm` (ROCMExecutionProvider) first — per this
// machine's own breadsearch-gpu-backends operator notes, that EP
// silently no-ops back to CPU on this class of system (distro ROCm
// onnxruntime builds, e.g. Arch's onnxruntime-rocm, are commonly
// compiled with `--use_migraphx`, not `--use_rocm`), so "ROCm (iGPU)"
// could report as active in this struct's own `active_provider` while
// every inference actually ran on CPU. See bread_onnx::provider's doc
// comment for the full history — breadsearch's own embed.rs already
// got this right.
//
// The `is_available()` gate (kept from the original implementation)
// means `active_provider` only ever claims Gpu when we actually
// attempted the GPU build — bread_onnx::build_session's loud EP-
// selection logging (visible once tracing_subscriber is initialized,
// which this crate's own main.rs already does) is what catches the
// *silent per-node fallback* class of bug this rewrite exists to fix,
// rather than papering over it by unconditionally reporting Gpu.
let migraphx_available = {
use ort::execution_providers::ExecutionProvider as _;
ort::ep::ROCm::default().is_available().unwrap_or(false)
ort::ep::MIGraphX::default().is_available().unwrap_or(false)
};
if rocm_available {
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
if migraphx_available {
match bread_onnx::build_session(
path,
ort::session::builder::GraphOptimizationLevel::Level3,
&[bread_onnx::Provider::MiGraphX { device_id: 0 }],
) {
Ok(s) => {
tracing::info!("ONNX session loaded (ROCm iGPU)");
tracing::info!("ONNX session loaded (MIGraphX iGPU)");
return (Some(s), ExecutionProvider::Gpu);
}
Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
Err(e) => tracing::debug!("MIGraphX EP unavailable: {}; trying CPU", e),
}
}
match build_onnx_session(path, ort::ep::CPU::default().build()) {
match bread_onnx::build_session(
path,
ort::session::builder::GraphOptimizationLevel::Level3,
&[bread_onnx::Provider::Cpu],
) {
Ok(s) => {
tracing::info!("ONNX session loaded (CPU)");
(Some(s), ExecutionProvider::Cpu)
@ -271,14 +299,3 @@ fn try_load_session(
}
}
}
fn build_onnx_session(
path: &std::path::Path,
ep: ort::ep::ExecutionProviderDispatch,
) -> anyhow::Result<ort::session::Session> {
let mut builder = ort::session::Session::builder()
.map_err(|e| anyhow::anyhow!("builder: {}", e))?
.with_execution_providers([ep])
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e))
}

View file

@ -212,15 +212,13 @@ impl Config {
}
pub fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("breadpad")
.join("breadpad.toml")
// Was `dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"))`
// — same literal-tilde-fallback bug as `classifier.rs::model_dir` (see
// its doc comment) and breadclip-core's `data_dir`; PathBuf/std::fs
// never expand `~`.
bread_utils::xdg::config_dir("breadpad").join("breadpad.toml")
}
pub fn style_css_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("breadpad")
.join("style.css")
bread_utils::xdg::config_dir("breadpad").join("style.css")
}