Skip to main content

qsm_core/models/
onnx.rs

1//! ONNX inference via the pure-Rust [`tract`](https://docs.rs/tract-onnx) engine
2//! (`onnx` feature).
3//!
4//! The model is loaded from a **byte buffer**, never a path, so the identical
5//! code path runs natively (bytes from the [`super::download`] cache) and in WASM
6//! (bytes fetched by JavaScript and handed back in). All tensors are `f32`
7//! (NCDHW for the volumetric nets); callers convert to/from the crate's `f64`
8//! volumes and handle model-specific normalization and padding.
9//!
10//! With the `parallel` feature, each inference spreads tract's matrix kernels over a thread pool:
11//! a dedicated one natively, and rayon's global pool on WASM (the one
12//! `wasm_bindgen_rayon::init_thread_pool` sets up, once the host reports it via
13//! [`onnx::set_wasm_threads_available`]). Calls from
14//! inside a rayon worker — the tiled drivers, which already run one tile per thread — stay on the
15//! calling thread.
16//!
17//! On `wasm32` the build **must** enable SIMD128 (`-C target-feature=+simd128`): since tract
18//! 0.23, `tract-linalg` registers its matmul kernels on wasm only under that target feature, so a
19//! build without it compiles fine and then fails on the first convolution at runtime with
20//! `No matmul found`. The guard below turns that into a build error instead.
21//!
22//! ```no_run
23//! # #[cfg(feature = "onnx")] {
24//! use qsm_core::models::onnx::{OnnxModel, Tensor};
25//! # fn go(model_bytes: &[u8], field: Vec<f32>, d: usize, h: usize, w: usize) -> Result<(), Box<dyn std::error::Error>> {
26//! let model = OnnxModel::load(model_bytes)?;
27//! let out = model.run_single(&Tensor::new(vec![1, 1, d, h, w], field))?;
28//! # let _ = out; Ok(()) }
29//! # }
30//! ```
31
32#[cfg(all(target_arch = "wasm32", not(target_feature = "simd128")))]
33compile_error!(
34    "the `onnx` feature on wasm32 needs SIMD128: build with `-C target-feature=+simd128` \
35     (RUSTFLAGS). tract-linalg registers matmul kernels on wasm only under `simd128`, so \
36     without it every convolution fails at runtime with \"No matmul found\"."
37);
38
39use tract_onnx::prelude::*;
40
41/// A dense `f32` tensor: row-major `data` interpreted with `shape`.
42#[derive(Clone, Debug)]
43pub struct Tensor {
44    /// Dimensions, e.g. `[1, 1, D, H, W]` for a single-channel volume.
45    pub shape: Vec<usize>,
46    /// Row-major values, length = product of `shape`.
47    pub data: Vec<f32>,
48}
49
50impl Tensor {
51    /// Construct a tensor, panicking if `data.len()` disagrees with `shape`.
52    pub fn new(shape: Vec<usize>, data: Vec<f32>) -> Self {
53        let n: usize = shape.iter().product();
54        assert_eq!(n, data.len(), "tensor shape {shape:?} does not match data len {}", data.len());
55        Self { shape, data }
56    }
57}
58
59/// Error from loading or running an ONNX model.
60#[derive(Debug)]
61pub enum OnnxError {
62    /// Failed to parse/optimize the ONNX graph.
63    Load(String),
64    /// Input shape or dtype could not be reconciled with the graph.
65    Shape(String),
66    /// Failure during the forward pass.
67    Run(String),
68}
69
70impl std::fmt::Display for OnnxError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Load(m) => write!(f, "onnx load error: {m}"),
74            Self::Shape(m) => write!(f, "onnx shape error: {m}"),
75            Self::Run(m) => write!(f, "onnx run error: {m}"),
76        }
77    }
78}
79
80impl std::error::Error for OnnxError {}
81
82/// Run one inference with tract's matrix kernels spread over a thread pool when the `parallel`
83/// feature is on (see [`tract_executor`]). Calls made from inside a rayon worker — the tiled
84/// drivers, which already run one tile per thread — stay on the calling thread.
85fn run_threaded<R>(f: impl FnOnce() -> R) -> R {
86    #[cfg(feature = "parallel")]
87    {
88        if rayon::current_thread_index().is_none() {
89            if let Some(exec) = tract_executor() {
90                return tract_linalg::multithread::multithread_tract_scope(exec, f);
91            }
92        }
93    }
94    f()
95}
96
97/// Whether the WASM host has started its rayon thread pool; see [`set_wasm_threads_available`].
98#[cfg(all(feature = "parallel", target_family = "wasm"))]
99static WASM_THREADS_AVAILABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
100
101/// Tell the crate that a WASM host's rayon thread pool is up, so inference may use it.
102///
103/// On `wasm32-unknown-unknown` rayon cannot start threads by itself — the pool comes from
104/// `wasm_bindgen_rayon::init_thread_pool`, which only works on a cross-origin-isolated page. A
105/// threaded build served without isolation therefore has the `parallel` feature but **no** pool,
106/// and touching rayon's global pool there fails. So this defaults to `false`: call it with `true`
107/// once `init_thread_pool` has resolved, and inference stays on the calling thread until then.
108///
109/// Each WASM module instance has its own flag (a lazily-loaded inference bundle is separate from
110/// the main one), so call it on whichever module you initialised.
111#[cfg(all(feature = "parallel", target_family = "wasm"))]
112pub fn set_wasm_threads_available(available: bool) {
113    WASM_THREADS_AVAILABLE.store(available, std::sync::atomic::Ordering::Relaxed);
114}
115
116/// The executor [`run_threaded`] installs, or `None` to stay on the calling thread.
117///
118/// Native: one pool sized to rayon's, built once. WASM: rayon's **global** pool — tract cannot
119/// build its own there (`ThreadPoolBuilder::build` calls `std::thread::spawn`, unsupported on
120/// `wasm32-unknown-unknown`), so `Executor::RayonGlobal` uses the pool
121/// `wasm_bindgen_rayon::init_thread_pool` sets up, once the host reports it via
122/// [`set_wasm_threads_available`].
123#[cfg(feature = "parallel")]
124fn tract_executor() -> Option<tract_linalg::multithread::Executor> {
125    use tract_linalg::multithread::Executor;
126    #[cfg(target_family = "wasm")]
127    {
128        WASM_THREADS_AVAILABLE
129            .load(std::sync::atomic::Ordering::Relaxed)
130            .then_some(Executor::RayonGlobal)
131    }
132    #[cfg(not(target_family = "wasm"))]
133    {
134        use std::sync::OnceLock;
135        static EXECUTOR: OnceLock<Option<Executor>> = OnceLock::new();
136        EXECUTOR
137            .get_or_init(|| {
138                let n = rayon::current_num_threads();
139                (n > 1).then(|| Executor::multithread_with_name(n, "qsm-tract"))
140            })
141            .clone()
142    }
143}
144
145/// A parsed ONNX model, ready to run at any spatial size.
146///
147/// The graph is kept in tract's shape-inferring form and specialized to the
148/// concrete input shapes on each [`run`](OnnxModel::run) call, so one instance
149/// serves volumes of different dimensions (fully-convolutional nets).
150pub struct OnnxModel {
151    model: InferenceModel,
152}
153
154impl OnnxModel {
155    /// Parse an ONNX model from its serialized bytes.
156    pub fn load(bytes: &[u8]) -> Result<Self, OnnxError> {
157        let model = tract_onnx::onnx()
158            .model_for_read(&mut std::io::Cursor::new(bytes))
159            .map_err(|e| OnnxError::Load(format!("{e:#}")))?;
160        Ok(Self { model })
161    }
162
163    /// Run the model with `inputs` bound to graph inputs in order; returns every
164    /// graph output as an `f32` [`Tensor`].
165    pub fn run(&self, inputs: &[Tensor]) -> Result<Vec<Tensor>, OnnxError> {
166        let mut model = self.model.clone();
167        for (i, inp) in inputs.iter().enumerate() {
168            model
169                .set_input_fact(i, f32::fact(inp.shape.as_slice()).into())
170                .map_err(|e| OnnxError::Shape(format!("{e:#}")))?;
171        }
172        // Prefer the optimized plan; if an optimization pass rejects the graph
173        // (e.g. `PushSliceUp` on the backprop-as-forward graphs used by NeXtQSM),
174        // fall back to the un-optimized typed plan, which still runs correctly.
175        let plan = match model.clone().into_optimized().and_then(|m| m.into_runnable()) {
176            Ok(p) => p,
177            Err(_) => model
178                .into_typed()
179                .and_then(|m| m.into_runnable())
180                .map_err(|e| OnnxError::Load(format!("{e:#}")))?,
181        };
182
183        let mut feeds: TVec<TValue> = tvec!();
184        for inp in inputs {
185            let t = tract_onnx::prelude::Tensor::from_shape(&inp.shape, &inp.data)
186                .map_err(|e| OnnxError::Shape(format!("{e:#}")))?;
187            feeds.push(t.into());
188        }
189
190        let result = run_threaded(|| plan.run(feeds)).map_err(|e| OnnxError::Run(format!("{e:#}")))?;
191
192        result
193            .iter()
194            .map(|t| {
195                let view = t
196                    .to_plain_array_view::<f32>()
197                    .map_err(|e| OnnxError::Run(format!("{e:#}")))?;
198                Ok(Tensor {
199                    shape: view.shape().to_vec(),
200                    data: view.iter().copied().collect(),
201                })
202            })
203            .collect()
204    }
205
206    /// Convenience for single-input / single-output nets.
207    pub fn run_single(&self, input: &Tensor) -> Result<Tensor, OnnxError> {
208        let mut out = self.run(std::slice::from_ref(input))?;
209        if out.is_empty() {
210            return Err(OnnxError::Run("model produced no outputs".into()));
211        }
212        Ok(out.swap_remove(0))
213    }
214
215    /// Compile a **reusable** execution plan specialized to fixed input shapes.
216    ///
217    /// [`run`](Self::run) re-clones and re-optimizes the whole graph on *every* call — fine for
218    /// one-shot whole-volume inference, but wasteful when running many equal-shaped tensors
219    /// (e.g. every patch of a tiled inversion). Build one [`OnnxPlan`] for the patch shape and
220    /// reuse it: tract's optimizer (`into_optimized` — constant-folding conv weights, operator
221    /// fusion, plan building) then runs exactly once instead of per patch.
222    pub fn plan_for(&self, input_shapes: &[&[usize]]) -> Result<OnnxPlan, OnnxError> {
223        let mut model = self.model.clone();
224        for (i, shape) in input_shapes.iter().enumerate() {
225            model
226                .set_input_fact(i, f32::fact(*shape).into())
227                .map_err(|e| OnnxError::Shape(format!("{e:#}")))?;
228        }
229        // Same optimize-or-fallback strategy as `run`, but paid once here, not per call.
230        let plan = match model.clone().into_optimized().and_then(|m| m.into_runnable()) {
231            Ok(p) => p,
232            Err(_) => model
233                .into_typed()
234                .and_then(|m| m.into_runnable())
235                .map_err(|e| OnnxError::Load(format!("{e:#}")))?,
236        };
237        Ok(OnnxPlan { plan })
238    }
239}
240
241/// A compiled execution plan for fixed input shapes, built by [`OnnxModel::plan_for`]. Running
242/// it skips graph optimization, so reusing one plan across many equal-shaped inputs (tiled
243/// inference) amortizes the optimizer to a single up-front cost.
244pub struct OnnxPlan {
245    plan: std::sync::Arc<TypedRunnableModel>,
246}
247
248impl OnnxPlan {
249    /// Run the plan with `inputs` bound to graph inputs in order (shapes must match the ones
250    /// this plan was compiled for); returns every graph output as an `f32` [`Tensor`].
251    pub fn run(&self, inputs: &[Tensor]) -> Result<Vec<Tensor>, OnnxError> {
252        let mut feeds: TVec<TValue> = tvec!();
253        for inp in inputs {
254            let t = tract_onnx::prelude::Tensor::from_shape(&inp.shape, &inp.data)
255                .map_err(|e| OnnxError::Shape(format!("{e:#}")))?;
256            feeds.push(t.into());
257        }
258        let result = run_threaded(|| self.plan.run(feeds)).map_err(|e| OnnxError::Run(format!("{e:#}")))?;
259        result
260            .iter()
261            .map(|t| {
262                let view = t
263                    .to_plain_array_view::<f32>()
264                    .map_err(|e| OnnxError::Run(format!("{e:#}")))?;
265                Ok(Tensor {
266                    shape: view.shape().to_vec(),
267                    data: view.iter().copied().collect(),
268                })
269            })
270            .collect()
271    }
272
273    /// Convenience for single-input / single-output nets.
274    pub fn run_single(&self, input: &Tensor) -> Result<Tensor, OnnxError> {
275        let mut out = self.run(std::slice::from_ref(input))?;
276        if out.is_empty() {
277            return Err(OnnxError::Run("model produced no outputs".into()));
278        }
279        Ok(out.swap_remove(0))
280    }
281}