Skip to main content

gigastt_core/runtime/ort/
factory.rs

1#![allow(dead_code)]
2
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, OnceLock};
5
6use crate::runtime::{
7    error::RuntimeError,
8    factory::{Runtime, RuntimeFactory},
9};
10
11use super::session::OrtRuntime;
12
13#[cfg(all(feature = "coreml", feature = "cuda"))]
14compile_error!("features `coreml` and `cuda` are mutually exclusive");
15
16/// `ort` execution provider selector.
17///
18/// As of `ort` 2.0.0-rc.13 the CoreML / CUDA / NNAPI providers live behind
19/// `ort`'s own `coreml` / `cuda` / `nnapi` Cargo features, so the variants that
20/// name them are compiled in only when our matching feature (which enables the
21/// upstream one) is on. A default CPU build carries only `Cpu`. This type is
22/// crate-internal (`pub(crate) mod runtime`), so the feature-conditional variant
23/// set is not part of the public API.
24#[derive(Clone, Copy)]
25pub enum OrtExecutionProvider {
26    Cpu,
27    #[cfg(feature = "coreml")]
28    CoreML,
29    #[cfg(feature = "cuda")]
30    Cuda,
31    #[cfg(feature = "nnapi")]
32    Nnapi,
33}
34
35impl OrtExecutionProvider {
36    /// Returns the execution-provider list to register when loading a session.
37    ///
38    /// `model_path` is used to derive provider-specific cache directories (e.g.
39    /// CoreML's version-scoped `coreml_cache/ort-<minor>/` next to the model).
40    pub(crate) fn execution_providers(
41        self,
42        model_path: &Path,
43    ) -> Vec<ort::ep::ExecutionProviderDispatch> {
44        // Each non-CPU arm names a provider type that `ort` 2.0.0-rc.13 gates
45        // behind its own feature, so the arm is gated on the matching feature —
46        // the default build compiles a single `Cpu` arm and never references a
47        // type that is configured out. `model_path` is only read by the CoreML
48        // arm; the `let _` below keeps it accounted for on builds without it.
49        #[cfg(not(feature = "coreml"))]
50        let _ = model_path;
51        match self {
52            Self::Cpu => vec![ort::ep::CPU::default().build()],
53            #[cfg(feature = "coreml")]
54            Self::CoreML => {
55                // Version-scoped: `coreml_cache/ort-<minor>/`. The CoreML EP keys
56                // its compiled bundles by graph hash only, so an ORT upgrade would
57                // otherwise load a bundle a different ONNX Runtime compiled and
58                // fail into a silent CPU fallback. Scoping by ORT version makes the
59                // upgrade miss the stale entry and recompile once (self-healing).
60                let cache_dir = match model_path.parent() {
61                    Some(p) => crate::model::coreml_cache_dir(p),
62                    None => crate::model::coreml_cache_dir(Path::new(".")),
63                };
64                let coreml_ep = ort::ep::CoreML::default()
65                    .with_model_format(ort::ep::coreml::ModelFormat::MLProgram)
66                    .with_static_input_shapes(true)
67                    .with_compute_units(ort::ep::coreml::ComputeUnits::CPUAndNeuralEngine)
68                    .with_specialization_strategy(
69                        ort::ep::coreml::SpecializationStrategy::FastPrediction,
70                    )
71                    .with_model_cache_dir(cache_dir.to_string_lossy())
72                    .build();
73                vec![coreml_ep, ort::ep::CPU::default().build()]
74            }
75            #[cfg(feature = "cuda")]
76            Self::Cuda => vec![
77                ort::ep::CUDA::default().build(),
78                ort::ep::CPU::default().build(),
79            ],
80            #[cfg(feature = "nnapi")]
81            Self::Nnapi => vec![
82                ort::ep::NNAPI::default().build(),
83                ort::ep::CPU::default().build(),
84            ],
85        }
86    }
87
88    /// Whether this provider is the plain CPU execution provider.
89    pub(crate) fn is_cpu(self) -> bool {
90        matches!(self, Self::Cpu)
91    }
92}
93
94/// Factory that creates an `ort` runtime configured for a specific provider.
95pub struct OrtFactory {
96    provider: OrtExecutionProvider,
97    prepacked: Option<Arc<ort::session::builder::PrepackedWeights>>,
98    optimized_cache_dir: Option<PathBuf>,
99}
100
101impl OrtFactory {
102    fn with_provider(provider: OrtExecutionProvider) -> Self {
103        Self {
104            provider,
105            prepacked: None,
106            optimized_cache_dir: None,
107        }
108    }
109
110    pub fn cpu() -> Self {
111        Self::with_provider(OrtExecutionProvider::Cpu)
112    }
113
114    #[cfg(feature = "coreml")]
115    pub fn coreml() -> Self {
116        Self::with_provider(OrtExecutionProvider::CoreML)
117    }
118
119    #[cfg(feature = "cuda")]
120    pub fn cuda() -> Self {
121        Self::with_provider(OrtExecutionProvider::Cuda)
122    }
123
124    #[cfg(feature = "nnapi")]
125    pub fn nnapi() -> Self {
126        Self::with_provider(OrtExecutionProvider::Nnapi)
127    }
128
129    pub fn with_prepacked_weights(
130        mut self,
131        prepacked: Arc<ort::session::builder::PrepackedWeights>,
132    ) -> Self {
133        self.prepacked = Some(prepacked);
134        self
135    }
136
137    pub fn with_optimized_cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
138        self.optimized_cache_dir = Some(dir.into());
139        self
140    }
141}
142
143static ORT_INIT: OnceLock<bool> = OnceLock::new();
144
145fn ensure_ort_initialized() {
146    let initialized_by_us = ORT_INIT.get_or_init(|| ort::init().with_name("gigastt").commit());
147    if !initialized_by_us {
148        tracing::warn!(
149            "ort environment was already configured before gigastt initialization; execution provider settings may not apply"
150        );
151    }
152}
153
154impl RuntimeFactory for OrtFactory {
155    fn create(&self, intra_threads: usize) -> Result<Box<dyn Runtime>, RuntimeError> {
156        ensure_ort_initialized();
157        Ok(Box::new(OrtRuntime::new(
158            intra_threads,
159            self.provider,
160            self.prepacked.clone(),
161            self.optimized_cache_dir.clone(),
162        )))
163    }
164
165    fn cpu_fallback(&self) -> Box<dyn RuntimeFactory> {
166        Box::new(OrtFactory::cpu())
167    }
168}
169
170/// Returns the default factory for the active compile-time feature flags.
171///
172/// When `feature = "candle"` is enabled, returns a `CandleFactory` (Metal on
173/// Apple Silicon, CPU otherwise). Otherwise returns an `OrtFactory` selected
174/// by the active execution-provider feature.
175///
176/// NOTE: the Candle backend is rnnt-only (34-token char vocab,
177/// `EncoderConfig::v3_rnnt()`); it cannot serve an `e2e_rnnt` model. This entry
178/// point has no model directory to detect the variant from, so it always returns
179/// `CandleFactory` under the feature — callers that know the directory should use
180/// [`production_factory`], which falls back to the ort factory for non-rnnt
181/// models.
182pub fn default_factory() -> Box<dyn RuntimeFactory> {
183    #[cfg(feature = "candle")]
184    {
185        Box::new(crate::runtime::candle::factory::CandleFactory::new())
186    }
187    #[cfg(all(feature = "ane", target_os = "macos"))]
188    {
189        Box::new(crate::runtime::coreml::factory::AneFactory::new())
190    }
191    // Select the provider with `#[cfg]`, not a runtime `cfg!()`: since rc.13 the
192    // accelerated constructors don't exist unless their feature is on, so a
193    // `cfg!()` branch that merely evaluates false at runtime would still have to
194    // compile a call to a function that isn't there. The `not(...)` guards keep
195    // exactly one block active for any feature combination (coreml precedes cuda
196    // precedes nnapi; coreml+cuda is already a `compile_error!`).
197    #[cfg(not(any(feature = "candle", all(feature = "ane", target_os = "macos"))))]
198    {
199        #[cfg(feature = "coreml")]
200        {
201            Box::new(OrtFactory::coreml())
202        }
203        #[cfg(all(feature = "cuda", not(feature = "coreml")))]
204        {
205            Box::new(OrtFactory::cuda())
206        }
207        #[cfg(all(feature = "nnapi", not(feature = "coreml"), not(feature = "cuda")))]
208        {
209            Box::new(OrtFactory::nnapi())
210        }
211        #[cfg(not(any(feature = "coreml", feature = "cuda", feature = "nnapi")))]
212        {
213            Box::new(OrtFactory::cpu())
214        }
215    }
216}
217
218/// Returns a CPU-only `ort` factory for auxiliary models.
219pub fn cpu_factory() -> Box<dyn RuntimeFactory> {
220    Box::new(OrtFactory::cpu())
221}
222
223/// Returns a production `ort` factory that preserves the provider selection and
224/// disk-cache layout used by the engine before the runtime abstraction.
225///
226/// Public, stable 1-arg form: selects the backend from the variant detected on
227/// disk. The engine calls the crate-internal `production_factory_variant`
228/// instead, passing the head it has already resolved so an explicit
229/// `--model-variant` is honored.
230pub fn production_factory(model_dir: &Path) -> Box<dyn RuntimeFactory> {
231    production_factory_variant(
232        model_dir,
233        crate::model::ModelVariant::detect_in_dir(model_dir),
234    )
235}
236
237/// Which runtime backend [`production_factory_variant`] selects for a resolved head.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub(crate) enum BackendKind {
240    /// Default `ort` backend (CPU / CoreML EP / CUDA EP, by compile-time feature).
241    Ort,
242    /// Pure-Rust Candle backend — rnnt-only.
243    Candle,
244    /// Apple Neural Engine backend — rnnt-only, macOS-only.
245    Ane,
246}
247
248/// Pure backend selection for [`production_factory_variant`]. The rnnt-only
249/// Candle/ANE backends are chosen ONLY for a resolved `Rnnt` head on a build that
250/// compiled them in; every other head — and `None` — uses the `ort` backend.
251/// Extracted so the `--model-variant` → backend gate (the candle/ane half of the
252/// multi-head fix) is unit-testable without model files.
253pub(crate) fn select_backend(variant: Option<crate::model::ModelVariant>) -> BackendKind {
254    let is_rnnt = variant == Some(crate::model::ModelVariant::Rnnt);
255    #[cfg(feature = "candle")]
256    if is_rnnt {
257        return BackendKind::Candle;
258    }
259    #[cfg(all(feature = "ane", target_os = "macos"))]
260    if is_rnnt {
261        return BackendKind::Ane;
262    }
263    let _ = is_rnnt;
264    BackendKind::Ort
265}
266
267/// Like [`production_factory`], but the caller supplies the resolved recognition
268/// head. The rnnt-only candle/ane backends are gated on `variant` directly (see
269/// [`select_backend`]) — re-detecting from disk here would reintroduce the
270/// multi-head bug where an explicit `--model-variant` override is overruled by
271/// `rnnt`-precedence detection. `None` (nothing resolved/detected) selects the ort
272/// factory, never the rnnt-only backends — matching the historical
273/// `production_factory`.
274pub(crate) fn production_factory_variant(
275    model_dir: &Path,
276    variant: Option<crate::model::ModelVariant>,
277) -> Box<dyn RuntimeFactory> {
278    let backend = select_backend(variant);
279    // The Candle/ANE backends are rnnt-only (34-token char vocab,
280    // `EncoderConfig::v3_rnnt()`); for any other head they would produce wrong
281    // output / fail to load, so `select_backend` only picks them for `Rnnt`.
282    #[cfg(feature = "candle")]
283    if backend == BackendKind::Candle {
284        return Box::new(crate::runtime::candle::factory::CandleFactory::new());
285    }
286    #[cfg(all(feature = "ane", target_os = "macos"))]
287    if backend == BackendKind::Ane {
288        return Box::new(crate::runtime::coreml::factory::AneFactory::new());
289    }
290    let _ = backend;
291
292    // `#[cfg]` rather than a runtime `cfg!()` for the same reason as
293    // `default_factory`: the accelerated constructors are compiled out without
294    // their feature. Only the CPU branch reads `model_dir`, so it is marked used
295    // on the accelerated builds. This path selects coreml/cuda/cpu only — nnapi
296    // is a mobile target reached through `default_factory`, not the server.
297    #[cfg(feature = "coreml")]
298    let factory = OrtFactory::coreml();
299    #[cfg(all(feature = "cuda", not(feature = "coreml")))]
300    let factory = OrtFactory::cuda();
301    #[cfg(not(any(feature = "coreml", feature = "cuda")))]
302    let factory = {
303        // Shared PrepackedWeights across every session this factory creates.
304        // ORT still materializes per-session initializers for most graphs; the
305        // container shares prepacked kernel buffers when the EP supports it.
306        // Enabled as the weight-share spike: remeasure pool1→2 RSS after deploy
307        // (see specs/research theories T-002 / T-021). Safe no-op if unused.
308        let prepacked = std::sync::Arc::new(ort::session::builder::PrepackedWeights::new());
309        OrtFactory::cpu()
310            .with_optimized_cache_dir(model_dir.join("optimized_cache"))
311            .with_prepacked_weights(prepacked)
312    };
313    #[cfg(any(feature = "coreml", feature = "cuda"))]
314    let _ = model_dir;
315    Box::new(factory)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::model::ModelVariant;
322
323    // The rnnt-only candle/ane backends must NEVER be selected for a non-rnnt head
324    // or for `None`, on any build — this is the exact gate the multi-head
325    // `--model-variant` fix added so candle/ane honor the resolved head instead of
326    // re-detecting `rnnt` from disk. Model-free; runs in the PR-gating unit tests.
327    #[test]
328    fn select_backend_non_rnnt_and_none_are_always_ort() {
329        assert_eq!(
330            select_backend(Some(ModelVariant::E2eRnnt)),
331            BackendKind::Ort
332        );
333        assert_eq!(select_backend(Some(ModelVariant::MlCtc)), BackendKind::Ort);
334        assert_eq!(
335            select_backend(Some(ModelVariant::MlCtcLarge)),
336            BackendKind::Ort
337        );
338        assert_eq!(select_backend(None), BackendKind::Ort);
339    }
340
341    // On the default ort builds (cpu / coreml / cuda) even `Rnnt` uses the ort
342    // backend — the rnnt-only accelerated backends aren't compiled in.
343    #[cfg(not(any(feature = "candle", all(feature = "ane", target_os = "macos"))))]
344    #[test]
345    fn select_backend_rnnt_is_ort_without_accelerated_backend() {
346        assert_eq!(select_backend(Some(ModelVariant::Rnnt)), BackendKind::Ort);
347    }
348
349    #[test]
350    fn test_cpu_factory_can_attach_prepacked_weights() {
351        let pw = std::sync::Arc::new(ort::session::builder::PrepackedWeights::new());
352        let f = OrtFactory::cpu().with_prepacked_weights(pw);
353        // create() must succeed without a model path (runtime shell only).
354        let rt = f.create(1).expect("cpu runtime with prepacked");
355        drop(rt);
356    }
357
358    // On a candle build, `Rnnt` picks the Candle backend but every other head (and
359    // `None`) still falls through to ort — proving the fix's `is_rnnt` gate.
360    #[cfg(feature = "candle")]
361    #[test]
362    fn select_backend_candle_only_for_rnnt() {
363        assert_eq!(
364            select_backend(Some(ModelVariant::Rnnt)),
365            BackendKind::Candle
366        );
367        assert_eq!(
368            select_backend(Some(ModelVariant::E2eRnnt)),
369            BackendKind::Ort
370        );
371        assert_eq!(select_backend(Some(ModelVariant::MlCtc)), BackendKind::Ort);
372        assert_eq!(select_backend(None), BackendKind::Ort);
373    }
374
375    // Same for the ANE (macOS) build.
376    #[cfg(all(feature = "ane", target_os = "macos"))]
377    #[test]
378    fn select_backend_ane_only_for_rnnt() {
379        assert_eq!(select_backend(Some(ModelVariant::Rnnt)), BackendKind::Ane);
380        assert_eq!(
381            select_backend(Some(ModelVariant::E2eRnnt)),
382            BackendKind::Ort
383        );
384        assert_eq!(select_backend(None), BackendKind::Ort);
385    }
386}