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 the host and supplied with [`super::provide_weights`]). 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    /// The input lies outside the domain the weights were trained on — field strength,
69    /// resolution, orientation. The graph would run and return a well-formed, wrong answer, so
70    /// this is refused rather than reported in the output.
71    Domain(String),
72    /// The configuration needs more memory than the host can provide, e.g. a tile patch too big
73    /// for a 32-bit WASM heap. Refused up front, because running it stalls rather than failing.
74    Memory(String),
75}
76
77impl std::fmt::Display for OnnxError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::Load(m) => write!(f, "onnx load error: {m}"),
81            Self::Shape(m) => write!(f, "onnx shape error: {m}"),
82            Self::Run(m) => write!(f, "onnx run error: {m}"),
83            Self::Domain(m) => write!(f, "onnx domain error: {m}"),
84            Self::Memory(m) => write!(f, "onnx memory error: {m}"),
85        }
86    }
87}
88
89impl std::error::Error for OnnxError {}
90
91/// Run one inference with tract's matrix kernels spread over a thread pool when the `parallel`
92/// feature is on (see [`tract_executor`]). Calls made from inside a rayon worker — the tiled
93/// drivers, which already run one tile per thread — stay on the calling thread.
94fn run_threaded<R>(f: impl FnOnce() -> R) -> R {
95    #[cfg(feature = "parallel")]
96    {
97        if rayon::current_thread_index().is_none() {
98            if let Some(exec) = tract_executor() {
99                return tract_linalg::multithread::multithread_tract_scope(exec, f);
100            }
101        }
102    }
103    f()
104}
105
106/// Whether the WASM host has started its rayon thread pool; see [`set_wasm_threads_available`].
107#[cfg(all(feature = "parallel", target_family = "wasm"))]
108static WASM_THREADS_AVAILABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
109
110/// Tell the crate that a WASM host's rayon thread pool is up, so inference may use it.
111///
112/// On `wasm32-unknown-unknown` rayon cannot start threads by itself — the pool comes from
113/// `wasm_bindgen_rayon::init_thread_pool`, which only works on a cross-origin-isolated page. A
114/// threaded build served without isolation therefore has the `parallel` feature but **no** pool,
115/// and touching rayon's global pool there fails. So this defaults to `false`: call it with `true`
116/// once `init_thread_pool` has resolved, and inference stays on the calling thread until then.
117///
118/// Each WASM module instance has its own flag (a lazily-loaded inference bundle is separate from
119/// the main one), so call it on whichever module you initialised.
120#[cfg(all(feature = "parallel", target_family = "wasm"))]
121pub fn set_wasm_threads_available(available: bool) {
122    WASM_THREADS_AVAILABLE.store(available, std::sync::atomic::Ordering::Relaxed);
123}
124
125/// The executor [`run_threaded`] installs, or `None` to stay on the calling thread.
126///
127/// Native: one pool sized to rayon's, built once. WASM: rayon's **global** pool — tract cannot
128/// build its own there (`ThreadPoolBuilder::build` calls `std::thread::spawn`, unsupported on
129/// `wasm32-unknown-unknown`), so `Executor::RayonGlobal` uses the pool
130/// `wasm_bindgen_rayon::init_thread_pool` sets up, once the host reports it via
131/// [`set_wasm_threads_available`].
132#[cfg(feature = "parallel")]
133fn tract_executor() -> Option<tract_linalg::multithread::Executor> {
134    use tract_linalg::multithread::Executor;
135    #[cfg(target_family = "wasm")]
136    {
137        WASM_THREADS_AVAILABLE
138            .load(std::sync::atomic::Ordering::Relaxed)
139            .then_some(Executor::RayonGlobal)
140    }
141    #[cfg(not(target_family = "wasm"))]
142    {
143        use std::sync::OnceLock;
144        static EXECUTOR: OnceLock<Option<Executor>> = OnceLock::new();
145        EXECUTOR
146            .get_or_init(|| {
147                let n = rayon::current_num_threads();
148                (n > 1).then(|| Executor::multithread_with_name(n, "qsm-tract"))
149            })
150            .clone()
151    }
152}
153
154/// A parsed ONNX model, ready to run at any spatial size.
155///
156/// The graph is kept in tract's shape-inferring form and specialized to the
157/// concrete input shapes on each [`run`](OnnxModel::run) call, so one instance
158/// serves volumes of different dimensions (fully-convolutional nets).
159pub struct OnnxModel {
160    model: InferenceModel,
161}
162
163impl OnnxModel {
164    /// Parse an ONNX model from its serialized bytes.
165    pub fn load(bytes: &[u8]) -> Result<Self, OnnxError> {
166        let model = tract_onnx::onnx()
167            .model_for_read(&mut std::io::Cursor::new(bytes))
168            .map_err(|e| OnnxError::Load(format!("{e:#}")))?;
169        Ok(Self { model })
170    }
171
172    /// Like [`load`](Self::load), with `edit` applied to the parsed ONNX protobuf before tract
173    /// translates it: for a model whose graph must be restated so that tract computes it
174    /// accurately (see [`crate::bet::mindgrab`]), without re-hosting its weights.
175    pub fn load_edited(
176        bytes: &[u8],
177        edit: impl FnOnce(&mut tract_onnx::pb::ModelProto) -> Result<(), OnnxError>,
178    ) -> Result<Self, OnnxError> {
179        let onnx = tract_onnx::onnx();
180        let mut proto = onnx
181            .proto_model_for_read(&mut std::io::Cursor::new(bytes))
182            .map_err(|e| OnnxError::Load(format!("{e:#}")))?;
183        edit(&mut proto)?;
184        Self::from_proto(&proto)
185    }
186
187    /// Translate an already-parsed ONNX protobuf.
188    pub(crate) fn from_proto(proto: &tract_onnx::pb::ModelProto) -> Result<Self, OnnxError> {
189        let model =
190            tract_onnx::onnx().model_for_proto_model(proto).map_err(|e| OnnxError::Load(format!("{e:#}")))?;
191        Ok(Self { model })
192    }
193
194    /// Run the model with `inputs` bound to graph inputs in order; returns every
195    /// graph output as an `f32` [`Tensor`].
196    pub fn run(&self, inputs: &[Tensor]) -> Result<Vec<Tensor>, OnnxError> {
197        let mut model = self.model.clone();
198        for (i, inp) in inputs.iter().enumerate() {
199            model
200                .set_input_fact(i, f32::fact(inp.shape.as_slice()).into())
201                .map_err(|e| OnnxError::Shape(format!("{e:#}")))?;
202        }
203        // Prefer the optimized plan; if an optimization pass rejects the graph
204        // (e.g. `PushSliceUp` on the backprop-as-forward graphs used by NeXtQSM),
205        // fall back to the un-optimized typed plan, which still runs correctly.
206        let plan = match model.clone().into_optimized().and_then(|m| m.into_runnable()) {
207            Ok(p) => p,
208            Err(_) => model
209                .into_typed()
210                .and_then(|m| m.into_runnable())
211                .map_err(|e| OnnxError::Load(format!("{e:#}")))?,
212        };
213
214        let mut feeds: TVec<TValue> = tvec!();
215        for inp in inputs {
216            let t = tract_onnx::prelude::Tensor::from_shape(&inp.shape, &inp.data)
217                .map_err(|e| OnnxError::Shape(format!("{e:#}")))?;
218            feeds.push(t.into());
219        }
220
221        let result = run_threaded(|| plan.run(feeds)).map_err(|e| OnnxError::Run(format!("{e:#}")))?;
222
223        result
224            .iter()
225            .map(|t| {
226                let view = t
227                    .to_plain_array_view::<f32>()
228                    .map_err(|e| OnnxError::Run(format!("{e:#}")))?;
229                Ok(Tensor {
230                    shape: view.shape().to_vec(),
231                    data: view.iter().copied().collect(),
232                })
233            })
234            .collect()
235    }
236
237    /// Convenience for single-input / single-output nets.
238    pub fn run_single(&self, input: &Tensor) -> Result<Tensor, OnnxError> {
239        let mut out = self.run(std::slice::from_ref(input))?;
240        if out.is_empty() {
241            return Err(OnnxError::Run("model produced no outputs".into()));
242        }
243        Ok(out.swap_remove(0))
244    }
245
246    /// Compile a **reusable** execution plan specialized to fixed input shapes.
247    ///
248    /// [`run`](Self::run) re-clones and re-optimizes the whole graph on *every* call — fine for
249    /// one-shot whole-volume inference, but wasteful when running many equal-shaped tensors
250    /// (e.g. every patch of a tiled inversion). Build one [`OnnxPlan`] for the patch shape and
251    /// reuse it: tract's optimizer (`into_optimized` — constant-folding conv weights, operator
252    /// fusion, plan building) then runs exactly once instead of per patch.
253    pub fn plan_for(&self, input_shapes: &[&[usize]]) -> Result<OnnxPlan, OnnxError> {
254        let mut model = self.model.clone();
255        for (i, shape) in input_shapes.iter().enumerate() {
256            model
257                .set_input_fact(i, f32::fact(*shape).into())
258                .map_err(|e| OnnxError::Shape(format!("{e:#}")))?;
259        }
260        // Same optimize-or-fallback strategy as `run`, but paid once here, not per call.
261        let plan = match model.clone().into_optimized().and_then(|m| m.into_runnable()) {
262            Ok(p) => p,
263            Err(_) => model
264                .into_typed()
265                .and_then(|m| m.into_runnable())
266                .map_err(|e| OnnxError::Load(format!("{e:#}")))?,
267        };
268        Ok(OnnxPlan { plan })
269    }
270}
271
272/// A compiled execution plan for fixed input shapes, built by [`OnnxModel::plan_for`]. Running
273/// it skips graph optimization, so reusing one plan across many equal-shaped inputs (tiled
274/// inference) amortizes the optimizer to a single up-front cost.
275pub struct OnnxPlan {
276    plan: std::sync::Arc<TypedRunnableModel>,
277}
278
279impl OnnxPlan {
280    /// Run the plan with `inputs` bound to graph inputs in order (shapes must match the ones
281    /// this plan was compiled for); returns every graph output as an `f32` [`Tensor`].
282    pub fn run(&self, inputs: &[Tensor]) -> Result<Vec<Tensor>, OnnxError> {
283        let mut feeds: TVec<TValue> = tvec!();
284        for inp in inputs {
285            let t = tract_onnx::prelude::Tensor::from_shape(&inp.shape, &inp.data)
286                .map_err(|e| OnnxError::Shape(format!("{e:#}")))?;
287            feeds.push(t.into());
288        }
289        let result = run_threaded(|| self.plan.run(feeds)).map_err(|e| OnnxError::Run(format!("{e:#}")))?;
290        result
291            .iter()
292            .map(|t| {
293                let view = t
294                    .to_plain_array_view::<f32>()
295                    .map_err(|e| OnnxError::Run(format!("{e:#}")))?;
296                Ok(Tensor {
297                    shape: view.shape().to_vec(),
298                    data: view.iter().copied().collect(),
299                })
300            })
301            .collect()
302    }
303
304    /// Convenience for single-input / single-output nets.
305    pub fn run_single(&self, input: &Tensor) -> Result<Tensor, OnnxError> {
306        let mut out = self.run(std::slice::from_ref(input))?;
307        if out.is_empty() {
308            return Err(OnnxError::Run("model produced no outputs".into()));
309        }
310        Ok(out.swap_remove(0))
311    }
312}