Skip to main content

lean_ctx/core/
ort_execution_providers.rs

1//! ONNX Runtime execution provider selection: CPU default, opt-in GPU providers.
2//!
3//! Each GPU EP is gated behind its own Cargo feature (`ort-cuda`, `ort-rocm`, etc.).
4//! `LEAN_CTX_ORT_EXECUTION_PROVIDER=cpu|gpu|auto` controls runtime selection.
5//! By default, `auto` enables GPU only when the selected ORT dylib looks like a
6//! GPU runtime; otherwise CPU is used. ORT falls back to CPU when a registered
7//! GPU EP is unusable.
8
9use std::path::Path;
10
11const PROVIDER_ENV: &str = "LEAN_CTX_ORT_EXECUTION_PROVIDER";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum ProviderPolicy {
15    Cpu,
16    Gpu,
17    Auto,
18}
19
20/// Build the execution provider list for the current runtime policy.
21pub fn execution_providers() -> Vec<ort::ep::ExecutionProviderDispatch> {
22    match provider_policy() {
23        ProviderPolicy::Cpu => cpu_execution_providers(),
24        ProviderPolicy::Gpu => gpu_execution_providers(),
25        ProviderPolicy::Auto => {
26            if selected_runtime_looks_gpu() {
27                gpu_execution_providers()
28            } else {
29                tracing::debug!(
30                    env = PROVIDER_ENV,
31                    "ONNX Runtime GPU auto-detect did not find a GPU runtime; using CPU"
32                );
33                cpu_execution_providers()
34            }
35        }
36    }
37}
38
39pub fn execution_provider_status() -> String {
40    let policy = provider_policy_name();
41    let compiled = compiled_gpu_provider_names();
42    let compiled = if compiled.is_empty() {
43        "none".to_string()
44    } else {
45        compiled.join(",")
46    };
47    format!(
48        "ORT execution provider policy: {policy} (env {PROVIDER_ENV}; compiled GPU EPs: {compiled})"
49    )
50}
51
52/// Whether the current policy resolves to a GPU execution provider — regardless
53/// of whether that provider's runtime dependencies can actually be loaded.
54fn policy_wants_gpu() -> bool {
55    if compiled_gpu_provider_names().is_empty() {
56        return false;
57    }
58    match provider_policy() {
59        ProviderPolicy::Cpu => false,
60        ProviderPolicy::Gpu => true,
61        ProviderPolicy::Auto => selected_runtime_looks_gpu(),
62    }
63}
64
65/// Whether a real GPU execution provider will *actually* run inference — i.e.
66/// the policy wants a GPU **and** the provider's runtime libraries load. Used to
67/// scale batch size: small mini-batches under-utilize a GPU and pay
68/// kernel-launch/host↔device-copy overhead per call that isn't amortized
69/// (notably under WSL2 GPU passthrough), but oversizing batches for a GPU that
70/// silently fell back to CPU makes the CPU path dramatically slower — so this
71/// must reflect the EP that ORT will really register, not just the policy.
72pub fn gpu_active() -> bool {
73    if !policy_wants_gpu() {
74        return false;
75    }
76    // The shipped Linux/Windows GPU build compiles only the CUDA EP. If its
77    // runtime deps (libcudart/libcublas/libcudnn/…) can't be dlopen'd, ORT
78    // silently registers CPU instead; don't size batches for a phantom GPU.
79    #[cfg(feature = "ort-cuda")]
80    {
81        cuda_runtime_available()
82    }
83    #[cfg(not(feature = "ort-cuda"))]
84    {
85        true
86    }
87}
88
89/// When the policy expects a GPU but the CUDA runtime can't be loaded (so ORT
90/// falls back to CPU), returns a user-facing explanation with the exact install
91/// commands for the missing libraries. Returns `None` when the GPU actually
92/// works or when CPU was requested.
93pub fn gpu_fallback_warning() -> Option<String> {
94    #[cfg(feature = "ort-cuda")]
95    {
96        if !policy_wants_gpu() || cuda_runtime_available() {
97            return None;
98        }
99        let detail = probe_cuda_runtime().err().unwrap_or_default();
100        Some(cuda_missing_message(&detail))
101    }
102    #[cfg(not(feature = "ort-cuda"))]
103    {
104        None
105    }
106}
107
108/// Filename of the ORT CUDA provider shared library for the current platform.
109#[cfg(feature = "ort-cuda")]
110fn cuda_provider_lib_name() -> &'static str {
111    #[cfg(target_os = "windows")]
112    {
113        "onnxruntime_providers_cuda.dll"
114    }
115    #[cfg(target_os = "macos")]
116    {
117        "libonnxruntime_providers_cuda.dylib"
118    }
119    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
120    {
121        "libonnxruntime_providers_cuda.so"
122    }
123}
124
125/// Path to the ORT CUDA provider library, resolved next to the selected ORT dylib.
126#[cfg(feature = "ort-cuda")]
127fn cuda_provider_lib_path() -> Option<std::path::PathBuf> {
128    let dylib = crate::core::ort_environment::resolved_ort_dylib_path().ok()?;
129    Some(dylib.parent()?.join(cuda_provider_lib_name()))
130}
131
132/// Probe whether the CUDA provider library and its transitive CUDA runtime
133/// dependencies can be loaded — the same resolution ORT performs when it
134/// registers the EP. `Err` carries the loader message (e.g. the first missing
135/// `.so`), which surfaces in the fallback warning.
136#[cfg(feature = "ort-cuda")]
137fn probe_cuda_runtime() -> Result<(), String> {
138    let path = cuda_provider_lib_path()
139        .ok_or_else(|| "could not resolve the ORT CUDA provider library path".to_string())?;
140    if !path.exists() {
141        return Err(format!("{} not found", path.display()));
142    }
143    // SAFETY: loading the ORT CUDA provider shared library, exactly as ONNX
144    // Runtime itself does when registering the CUDA EP. We drop it immediately;
145    // this only checks that its runtime dependencies resolve.
146    unsafe { libloading::Library::new(&path) }
147        .map(|_lib| ())
148        .or_else(|e| {
149            let err = e.to_string();
150            // ORT provider plugins are normally loaded by libonnxruntime itself.
151            // A direct dlopen may fail on ORT host symbols after CUDA/cuDNN deps
152            // have resolved; that is still enough for this dependency probe.
153            if err.contains("Provider_GetHost") {
154                Ok(())
155            } else {
156                Err(err)
157            }
158        })
159}
160
161/// Cached result of [`probe_cuda_runtime`]; the dlopen runs at most once.
162#[cfg(feature = "ort-cuda")]
163fn cuda_runtime_available() -> bool {
164    static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
165    *CACHE.get_or_init(|| probe_cuda_runtime().is_ok())
166}
167
168/// User-facing message shown when the CUDA runtime is missing, including the
169/// exact commands to install the required libraries.
170#[cfg(feature = "ort-cuda")]
171fn cuda_missing_message(probe_err: &str) -> String {
172    format!(
173        "GPU requested (via {env}) but the CUDA runtime libraries required by ONNX Runtime \
174         could not be loaded — embedding is running on CPU. Loader error: {probe_err}\n\
175         ONNX Runtime 1.{ort_minor}.x needs CUDA 12 + cuDNN 9. Missing libraries typically \
176         include: libcudart.so.12, libcublas.so.12, libcublasLt.so.12, libcudnn.so.9, \
177         libcurand.so.10, libcufft.so.11.\n\
178         Install them on Ubuntu / WSL2:\n  \
179         wget -O /tmp/cuda-keyring_1.1-1_all.deb https://developer.download.nvidia.com/compute/cuda/repos/wsl-ubuntu/x86_64/cuda-keyring_1.1-1_all.deb\n  \
180         sudo dpkg -i /tmp/cuda-keyring_1.1-1_all.deb && rm -f /tmp/cuda-keyring_1.1-1_all.deb && sudo apt-get update\n  \
181         sudo apt-get install -y cuda-cudart-12-8 libcublas-12-8 libcurand-12-8 libcufft-12-8\n  \
182         python3 -m venv $HOME/.local/share/lean-ctx/cuda-libs\n  \
183         $HOME/.local/share/lean-ctx/cuda-libs/bin/python -m pip install nvidia-cudnn-cu12==9.8.0.87\n  \
184         # then ensure the loader can find them (if not already on the path):\n  \
185         export LD_LIBRARY_PATH=$($HOME/.local/share/lean-ctx/cuda-libs/bin/python -c 'import pathlib, nvidia.cudnn; print(pathlib.Path(nvidia.cudnn.__file__).parent / '\''lib'\'')'):/usr/local/cuda-12.8/targets/x86_64-linux/lib:/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH\n\
186         To silence this and stay on CPU, set {env}=cpu.",
187        env = PROVIDER_ENV,
188        ort_minor = ort::MINOR_VERSION,
189    )
190}
191
192pub fn execution_provider_help() -> &'static str {
193    "By default lean-ctx auto-detects GPU runtimes from ORT_DYLIB_PATH and otherwise uses CPU. Set LEAN_CTX_ORT_EXECUTION_PROVIDER=cpu|gpu|auto to override."
194}
195
196fn cpu_execution_providers() -> Vec<ort::ep::ExecutionProviderDispatch> {
197    vec![ort::ep::CPU::default().build()]
198}
199
200/// Build the list of GPU execution providers in registration-priority order.
201pub fn gpu_execution_providers() -> Vec<ort::ep::ExecutionProviderDispatch> {
202    #[allow(unused_mut)]
203    let mut eps: Vec<ort::ep::ExecutionProviderDispatch> = Vec::new();
204    let compiled_gpu_count = compiled_gpu_provider_names().len();
205
206    #[cfg(feature = "ort-cuda")]
207    {
208        tracing::info!("Enabling CUDA execution provider for ONNX Runtime");
209        eps.push(ort::ep::CUDA::default().build());
210    }
211
212    #[cfg(feature = "ort-rocm")]
213    {
214        tracing::info!("Enabling ROCm execution provider for ONNX Runtime");
215        eps.push(ort::ep::ROCm::default().build());
216    }
217
218    #[cfg(feature = "ort-webgpu")]
219    {
220        tracing::info!("Enabling WebGPU execution provider for ONNX Runtime");
221        eps.push(ort::ep::WebGPU::default().build());
222    }
223    #[cfg(all(target_os = "windows", feature = "ort-directml"))]
224    {
225        tracing::info!("Enabling DirectML execution provider for ONNX Runtime");
226        eps.push(ort::ep::DirectML::default().build());
227    }
228
229    #[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "ort-coreml"))]
230    {
231        tracing::info!("Enabling CoreML execution provider for ONNX Runtime");
232        eps.push(ort::ep::CoreML::default().build());
233    }
234
235    if compiled_gpu_count == 0 {
236        tracing::warn!(
237            "GPU execution provider requested, but this lean-ctx binary was built without ort-cuda/ort-rocm/etc.; using CPU only"
238        );
239    } else if eps.is_empty() {
240        tracing::debug!("No GPU execution providers configured — using CPU only");
241    }
242
243    eps.push(ort::ep::CPU::default().build());
244    eps
245}
246
247fn provider_policy() -> ProviderPolicy {
248    match std::env::var(PROVIDER_ENV) {
249        Ok(value) => provider_policy_from_value(&value),
250        Err(_) => ProviderPolicy::Auto,
251    }
252}
253
254fn provider_policy_name() -> &'static str {
255    match provider_policy() {
256        ProviderPolicy::Cpu => "cpu",
257        ProviderPolicy::Gpu => "gpu",
258        ProviderPolicy::Auto => "auto",
259    }
260}
261
262fn provider_policy_from_value(value: &str) -> ProviderPolicy {
263    match value.trim().to_lowercase().as_str() {
264        "gpu" | "cuda" | "rocm" | "webgpu" | "directml" | "coreml" => ProviderPolicy::Gpu,
265        "auto" => ProviderPolicy::Auto,
266        _ => ProviderPolicy::Cpu,
267    }
268}
269
270fn selected_runtime_looks_gpu() -> bool {
271    crate::core::ort_environment::resolved_ort_dylib_path()
272        .ok()
273        .as_deref()
274        .is_some_and(runtime_path_looks_gpu)
275}
276
277fn runtime_path_looks_gpu(path: &Path) -> bool {
278    let path_text = path.to_string_lossy().to_lowercase();
279    if path_text.contains("gpu") || path_text.contains("cuda") || path_text.contains("rocm") {
280        return true;
281    }
282    let Some(parent) = path.parent() else {
283        return false;
284    };
285    [
286        "libonnxruntime_providers_cuda.so",
287        "libonnxruntime_providers_rocm.so",
288        "onnxruntime_providers_cuda.dll",
289        "onnxruntime_providers_rocm.dll",
290        "libonnxruntime_providers_cuda.dylib",
291        "libonnxruntime_providers_rocm.dylib",
292    ]
293    .iter()
294    .any(|name| parent.join(name).exists())
295}
296
297fn compiled_gpu_provider_names() -> Vec<&'static str> {
298    let mut names = vec![
299        #[cfg(feature = "ort-cuda")]
300        "cuda",
301        #[cfg(feature = "ort-rocm")]
302        "rocm",
303        #[cfg(feature = "ort-webgpu")]
304        "webgpu",
305        #[cfg(all(target_os = "windows", feature = "ort-directml"))]
306        "directml",
307        #[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "ort-coreml"))]
308        "coreml",
309    ];
310    let _ = &mut names; // suppress unused_mut when no GPU feature is active
311    names
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn provider_policy_defaults_to_cpu_for_unknown_values() {
320        assert_eq!(provider_policy_from_value(""), ProviderPolicy::Cpu);
321        assert_eq!(provider_policy_from_value("bogus"), ProviderPolicy::Cpu);
322        assert_eq!(provider_policy_from_value("cpu"), ProviderPolicy::Cpu);
323    }
324
325    #[test]
326    fn provider_policy_accepts_gpu_and_auto_aliases() {
327        assert_eq!(provider_policy_from_value("gpu"), ProviderPolicy::Gpu);
328        assert_eq!(provider_policy_from_value("CUDA"), ProviderPolicy::Gpu);
329        assert_eq!(provider_policy_from_value("auto"), ProviderPolicy::Auto);
330    }
331}