bge_m3_embedding_server/embedder/session.rs
1// Copyright (c) 2026 J. Patrick Fulton
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! ORT execution-provider configuration and session loading.
16
17use std::path::Path;
18
19use anyhow::Result;
20
21use super::error::ort_err;
22use super::model_files::download_model_files;
23use super::tokenize::load_tokenizer;
24#[cfg(all(not(target_os = "macos"), feature = "tensorrt"))]
25use super::trt_cache;
26use crate::config::{EpSelection, ModelVariant};
27
28/// Returns the execution providers to use for this platform and EP selection.
29///
30/// On macOS: always uses the `CoreML` EP with `MLProgram` format and
31/// `FastPrediction` specialisation strategy (overridable via
32/// `BGE_M3_COREML_STRATEGY=default`), regardless of `ep`.
33///
34/// On Linux with the `tensorrt` feature: selects `TensorRT` when
35/// `ep == EpSelection::TensorRt`, with engine caching, FP16, and the
36/// specified `device_id` enabled. When `trt_max_workspace_bytes` is `Some`,
37/// the workspace cap is forwarded to the TRT EP via `with_max_workspace_size`;
38/// otherwise ORT's built-in default is used.
39///
40/// On Linux with the `cuda` feature: selects CUDA when
41/// `ep == EpSelection::Cuda`, pinned to `device_id`. When
42/// `gpu_mem_limit_bytes` is `Some`, the device memory limit is forwarded via
43/// `with_memory_limit`; otherwise the EP uses all available device memory.
44///
45/// CPU fallback: returns an empty list so ORT falls back to MLAS.
46///
47/// `device_id` is computed by `EmbedPool::spawn` as
48/// `worker_index % gpu_count` and is ignored on CPU EP and macOS.
49///
50/// Emits a single `INFO` log line tagged `"ORT execution providers configured"`
51/// describing the configured EP, the EP that was actually built (the "active"
52/// EP), and any cache paths handed to it. This is the source of truth in
53/// `CloudWatch` for "is `TensorRT` really active or did we silently fall back?".
54pub(super) fn execution_providers(
55 cache_dir: &Path,
56 ep: EpSelection,
57 device_id: u32,
58 trt_max_workspace_bytes: Option<usize>,
59 gpu_mem_limit_bytes: Option<usize>,
60) -> Vec<ort::ep::ExecutionProviderDispatch> {
61 // macOS: always CoreML regardless of BGE_M3_EP.
62 // The cfg blocks are mutually exclusive so only one branch is compiled per target.
63 #[cfg(target_os = "macos")]
64 {
65 let _ = device_id;
66 let _ = (trt_max_workspace_bytes, gpu_mem_limit_bytes);
67 let coreml_cache = cache_dir.join("coreml");
68 let strategy = match std::env::var("BGE_M3_COREML_STRATEGY").ok().as_deref() {
69 Some("default") => ort::ep::coreml::SpecializationStrategy::Default,
70 _ => ort::ep::coreml::SpecializationStrategy::FastPrediction,
71 };
72 let builder = ort::ep::CoreML::default()
73 .with_model_format(ort::ep::coreml::ModelFormat::MLProgram)
74 .with_specialization_strategy(strategy)
75 .with_model_cache_dir(coreml_cache.display().to_string());
76 #[cfg(feature = "coreml-profile")]
77 let builder = builder.with_profile_compute_plan(true);
78 tracing::info!(
79 ep_selection = %ep,
80 ep_active = "CoreML",
81 coreml_cache_path = %coreml_cache.display(),
82 "ORT execution providers configured"
83 );
84 vec![builder.build()]
85 }
86
87 #[cfg(not(target_os = "macos"))]
88 {
89 // Linux TensorRT (feature-gated).
90 // ort 2.0.0-rc.12 uses `with_engine_cache` / `with_fp16` — not
91 // `with_engine_cache_enable` / `with_fp16_enable` which don't exist.
92 #[cfg(feature = "tensorrt")]
93 if ep == EpSelection::TensorRt {
94 // Inspect the cache directory BEFORE handing the path to ORT so
95 // operators see in CloudWatch whether engine reuse is working.
96 // Two consecutive cold starts producing the same compile time is
97 // the symptom of an EFS mount that isn't actually persisting —
98 // surfacing the count here is the fastest way to diagnose it.
99 let cache_info = trt_cache::ensure_and_inspect(cache_dir);
100 trt_cache::log_cache_state(&cache_info);
101
102 let timing_cache = trt_cache::timing_cache_path(cache_dir);
103 // The timing cache stores per-tactic kernel timings so the TRT
104 // builder can skip the tactic-selection step on each subsequent
105 // engine build. It is complementary to the engine cache — even
106 // a cold engine cache benefits from a warm timing cache when
107 // multiple shapes are compiled in the same warmup sweep.
108 tracing::info!(
109 ep_selection = %ep,
110 ep_active = "TensorRT",
111 device_id,
112 engine_cache_path = %cache_info.path.display(),
113 timing_cache_path = %timing_cache.display(),
114 fp16 = true,
115 error_on_failure = true,
116 "ORT execution providers configured"
117 );
118 // `.error_on_failure()` upgrades the default silent-CPU-fallback
119 // path to a hard error. ORT's `apply_execution_providers` defaults
120 // `error_on_failure = false`, which means a failed registration
121 // (e.g. `libonnxruntime_providers_tensorrt.so` missing from the
122 // image — a common silent-CPU-fallback root cause) is logged as
123 // a `WARN`/`ERROR` via the `ort` crate's internal tracing macros
124 // and the loop falls back to CPU/MLAS without surfacing the
125 // failure. With this set, `Session::builder().with_execution_providers(...)`
126 // returns the error verbatim, which `load_session` already
127 // converts into a worker-load failure — the worker exits non-zero
128 // instead of silently serving CPU inference. Greppable in
129 // CloudWatch via the new field `error_on_failure: true` on the
130 // "ORT execution providers configured" event.
131 let mut trt_ep = ort::ep::TensorRT::default()
132 .with_device_id(device_id.cast_signed())
133 .with_engine_cache(true)
134 .with_engine_cache_path(cache_info.path.display().to_string())
135 .with_timing_cache(true)
136 .with_timing_cache_path(timing_cache.display().to_string())
137 .with_fp16(true);
138 if let Some(cap) = trt_max_workspace_bytes {
139 trt_ep = trt_ep.with_max_workspace_size(cap);
140 }
141 return vec![trt_ep.build().error_on_failure()];
142 }
143
144 // Linux CUDA (feature-gated).
145 #[cfg(feature = "cuda")]
146 if ep == EpSelection::Cuda {
147 let _ = cache_dir;
148 tracing::info!(
149 ep_selection = %ep,
150 ep_active = "CUDA",
151 device_id,
152 error_on_failure = true,
153 "ORT execution providers configured"
154 );
155 let mut cuda_ep = ort::ep::CUDA::default().with_device_id(device_id.cast_signed());
156 if let Some(limit) = gpu_mem_limit_bytes {
157 cuda_ep = cuda_ep.with_memory_limit(limit);
158 }
159 return vec![cuda_ep.build().error_on_failure()];
160 }
161
162 // CPU fallback (always available).
163 let _ = (
164 cache_dir,
165 device_id,
166 trt_max_workspace_bytes,
167 gpu_mem_limit_bytes,
168 );
169 tracing::info!(
170 ep_selection = %ep,
171 ep_active = "CPU/MLAS",
172 "ORT execution providers configured"
173 );
174 vec![]
175 }
176}
177
178/// Builds an ORT session from the ONNX model file with the given execution providers.
179///
180/// `intra_threads` controls intra-op parallelism for matmul / attention kernels
181/// inside a single `session.run()` call. The default (`1`) keeps per-worker RSS
182/// predictable for the workspace probe; raise it to `floor(num_cpus / workers)`
183/// on under-utilized hosts to recover CPU headroom. See
184/// [`crate::config::Config::intra_threads`] for the operator-facing knob.
185pub(super) fn load_session(
186 model_path: &Path,
187 eps: Vec<ort::ep::ExecutionProviderDispatch>,
188 intra_threads: usize,
189) -> Result<ort::session::Session> {
190 let mut builder = ort::session::Session::builder().map_err(ort_err)?;
191 if !eps.is_empty() {
192 builder = builder.with_execution_providers(eps).map_err(ort_err)?;
193 }
194 let session = builder
195 .with_optimization_level(ort::session::builder::GraphOptimizationLevel::Level3)
196 .map_err(ort_err)?
197 .with_intra_threads(intra_threads.max(1))
198 .map_err(ort_err)?
199 .commit_from_file(model_path)
200 .map_err(ort_err)?;
201 Ok(session)
202}
203
204/// Configuration for loading an ORT session and tokenizer for a single GPU worker.
205///
206/// Bundles the parameters that were previously passed individually to [`load_models`],
207/// removing the 9-argument list and the associated `#[allow(clippy::too_many_arguments)]`
208/// suppression. All fields map 1:1 to the corresponding `WorkerConfig` fields.
209pub(super) struct GpuSessionConfig<'a> {
210 /// Path to the ONNX model cache directory.
211 pub cache_dir: &'a Path,
212 /// ONNX model variant (FP32, FP16, INT8).
213 pub model_variant: ModelVariant,
214 /// Maximum tokenized sequence length.
215 pub max_seq_length: usize,
216 /// Intra-op thread count for ORT sessions.
217 pub intra_threads: usize,
218 /// Execution provider selection.
219 pub ep: EpSelection,
220 /// GPU device ID for this worker.
221 pub device_id: u32,
222 /// Optional TRT EP workspace size cap in bytes.
223 pub trt_max_workspace_bytes: Option<usize>,
224 /// Optional CUDA EP device memory limit in bytes.
225 pub gpu_mem_limit_bytes: Option<usize>,
226}
227
228/// Downloads (if not already cached) and loads both the ORT session and the
229/// tokenizer for the given model variant, returning them as a pair.
230///
231/// `cfg.device_id` selects the CUDA/TRT GPU device for this session. Computed
232/// by `EmbedPool::spawn` as `worker_index % gpu_count`. Ignored on CPU EP and
233/// macOS.
234///
235/// `cfg.trt_max_workspace_bytes` and `cfg.gpu_mem_limit_bytes` are forwarded
236/// verbatim to [`execution_providers`]; see that function's documentation for
237/// semantics.
238pub(super) fn load_models(
239 cfg: &GpuSessionConfig<'_>,
240 show_download_progress: bool,
241) -> Result<(ort::session::Session, tokenizers::Tokenizer)> {
242 let files = download_model_files(cfg.cache_dir, show_download_progress, cfg.model_variant)?;
243 let tokenizer = load_tokenizer(&files.tokenizer_path, cfg.max_seq_length)?;
244 let eps = execution_providers(
245 cfg.cache_dir,
246 cfg.ep,
247 cfg.device_id,
248 cfg.trt_max_workspace_bytes,
249 cfg.gpu_mem_limit_bytes,
250 );
251 let session = load_session(&files.onnx_path, eps, cfg.intra_threads)?;
252 Ok((session, tokenizer))
253}