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}