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}