bge_m3_embedding_server/config.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//! Server configuration loaded from environment variables at startup.
16//!
17//! All settings are read once via [`Config::from_env`] and then immutable
18//! for the server's lifetime. See each field's doc comment for the
19//! corresponding environment variable name and default value.
20
21use crate::binpack::CostModel;
22use crate::sysinfo;
23use std::env;
24use std::time::Duration;
25use tracing::{info, warn};
26
27/// ONNX model variant to load.
28///
29/// Controlled by `BGE_M3_MODEL`. Defaults to [`ModelVariant::Fp16`].
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ModelVariant {
32 /// BAAI/bge-m3 FP32 model (~2.16 GB per session).
33 ///
34 /// Set `BGE_M3_MODEL=fp32` to enable. Recommended for Apple Silicon `CoreML`
35 /// deployments where latency is the primary constraint: the FP32 ONNX graph
36 /// contains no Cast nodes, so ORT can dispatch the entire multi-head
37 /// attention + FFN block as one contiguous `CoreML` subgraph to the GPU —
38 /// delivering 20–61% lower latency than the MLAS CPU baseline.
39 ///
40 /// **Not the default.** Linux/Intel (MLAS-only) deployments should prefer
41 /// [`ModelVariant::Fp16`] for lower RAM and fleet-wide embedding consistency.
42 Fp32,
43 /// Xenova/bge-m3 FP16 model (~1.08 GB per session). **Default.**
44 /// Halves per-session memory vs FP32 (~50% reduction; ~1.08 GB vs ~2.16 GB).
45 ///
46 /// This is the fleet default: all Apple Silicon `LaunchAgent` deployments set
47 /// `BGE_M3_MODEL=fp16` explicitly, and the server default matches so that
48 /// Linux/Docker deployments produce consistent embeddings without any
49 /// additional configuration.
50 ///
51 /// **Latency caveat (`CoreML` only).** The Xenova FP16 ONNX model contains
52 /// FP16↔FP32 Cast nodes at every transformer-layer boundary. ORT's `CoreML` EP
53 /// cannot fuse these into the attention/FFN subgraphs; each Cast executes on
54 /// CPU and the transformer block never forms a single contiguous GPU subgraph.
55 /// Result: FP16 + `CoreML` EP runs 6–10× slower than FP32 + `CoreML`. On
56 /// MLAS/CPU EP (Linux, Intel), this Cast overhead is similarly present but
57 /// the MLAS FP16 penalty (~6–9×) is the accepted trade-off for lower RAM and
58 /// fleet consistency. Use `BGE_M3_MODEL=fp32` on Apple Silicon to recover
59 /// `CoreML` GPU acceleration.
60 Fp16,
61 /// Xenova/bge-m3 INT8 quantized model (~568 MB per session).
62 /// Weights-only quantization; ORT dequantizes to f32 internally.
63 /// Reduces peak memory by ~74% per worker vs FP32.
64 ///
65 /// Embedding quality validated: dense cosine similarity ≥ 0.963 vs FP32
66 /// reference across a 184-text corpus — suitable for ANN search and semantic
67 /// ranking. Avoid for applications requiring ranking precision within very
68 /// small similarity margins (< 0.05 apart).
69 ///
70 /// **Use with MLAS (CPU EP) only.** `DequantizeLinear` nodes fragment the
71 /// `CoreML` execution plan identically to FP16 Cast nodes; INT8 + `CoreML` EP
72 /// runs 42–79% slower than INT8 + MLAS with no GPU benefit.
73 Int8,
74}
75
76impl std::fmt::Display for ModelVariant {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 Self::Fp32 => f.write_str("fp32"),
80 Self::Fp16 => f.write_str("fp16"),
81 Self::Int8 => f.write_str("int8"),
82 }
83 }
84}
85
86/// VRAM workspace budget used for GPU EPs when `BGE_M3_GPU_VRAM_BUDGET_BYTES` is unset.
87///
88/// 10 GiB is a conservative ceiling for NVIDIA GPUs with ≥ 16 GiB VRAM (A10G, L4, H100 80GB).
89/// Override with `BGE_M3_GPU_VRAM_BUDGET_BYTES` for GPUs with less VRAM.
90const DEFAULT_GPU_VRAM_BUDGET_BYTES: usize = 10 * 1024 * 1024 * 1024;
91
92/// Advisory upper-bound for VRAM byte values (128 GiB).
93///
94/// Current max GPU VRAM is ~96 GiB (H100 SXM). Values above this threshold
95/// almost certainly indicate a unit error (e.g. GiB instead of bytes).
96/// We warn but do not clamp, so intentional overrides still work.
97const VRAM_WARN_THRESHOLD_BYTES: usize = 128 * 1024 * 1024 * 1024;
98
99/// ONNX Runtime execution provider selection.
100///
101/// Controlled by `BGE_M3_EP`. Defaults to [`EpSelection::Cpu`].
102///
103/// On macOS the `CoreML` EP is always used regardless of this setting.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum EpSelection {
106 /// CPU inference via MLAS (default). Works everywhere, no GPU required.
107 Cpu,
108 /// NVIDIA CUDA execution provider (requires `cuda` feature and a CUDA ORT build).
109 ///
110 /// Set `BGE_M3_EP=cuda` to enable. `BGE_M3_WORKERS` is clamped to
111 /// `BGE_M3_GPU_COUNT` so each worker is pinned to a distinct CUDA device.
112 /// Set `BGE_M3_GPU_COUNT` to match the number of GPUs on the instance for
113 /// maximum parallel inference throughput.
114 Cuda,
115 /// NVIDIA `TensorRT` execution provider (requires `tensorrt` feature and a TRT ORT build).
116 ///
117 /// Set `BGE_M3_EP=tensorrt` to enable. Falls back to CUDA for ops TRT cannot
118 /// handle. `BGE_M3_WORKERS` is clamped to `BGE_M3_GPU_COUNT`; each worker
119 /// compiles its own per-GPU TRT shard of the warmup shapes during startup,
120 /// enabling parallel engine compilation across GPUs.
121 TensorRt,
122}
123
124impl std::fmt::Display for EpSelection {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 match self {
127 Self::Cpu => f.write_str("cpu"),
128 Self::Cuda => f.write_str("cuda"),
129 Self::TensorRt => f.write_str("tensorrt"),
130 }
131 }
132}
133
134/// Maximum sequence length supported by the model architecture.
135/// BGE-M3's positional embedding table extends to 8192; this is the hard upper
136/// bound used to validate `BGE_M3_MAX_SEQ_LENGTH`.
137pub const MODEL_MAX_SEQ: usize = 8192;
138
139/// Runtime configuration loaded from environment variables.
140///
141/// All fields are read once at startup via [`Config::from_env`]. Changes to
142/// environment variables after startup have no effect.
143#[allow(clippy::struct_excessive_bools)]
144pub struct Config {
145 /// Path to the directory where ONNX model files are cached.
146 ///
147 /// Set with `BGE_M3_CACHE_DIR`. Defaults to `/cache`.
148 pub cache_dir: String,
149 /// TCP bind address for the HTTP server.
150 ///
151 /// Set with `BGE_M3_BIND`. Defaults to `0.0.0.0:8081`.
152 /// The `0.0.0.0` default is intentional for Docker container deployments.
153 pub bind_addr: String,
154 /// Path to the TLS certificate PEM file.
155 ///
156 /// Set with `BGE_M3_TLS_CERT_PATH`. When set together with
157 /// `BGE_M3_TLS_KEY_PATH` and the `tls` Cargo feature is compiled in,
158 /// the server binds HTTPS instead of HTTP.
159 pub tls_cert_path: Option<std::path::PathBuf>,
160 /// Path to the TLS private key PEM file.
161 ///
162 /// Set with `BGE_M3_TLS_KEY_PATH`.
163 pub tls_key_path: Option<std::path::PathBuf>,
164 /// Number of embedding worker threads to spawn.
165 ///
166 /// Set with `BGE_M3_WORKERS`. Defaults to `2`. Minimum effective value is `1`.
167 /// Each worker loads its own model instance.
168 pub workers: usize,
169 /// Number of intra-op threads each ORT session may use for a single
170 /// `session.run()` call (matmul / attention kernels).
171 ///
172 /// Set with `BGE_M3_INTRA_THREADS`. Defaults to `1`. Minimum effective
173 /// value is `1`.
174 ///
175 /// The default of `1` preserves predictable per-worker RSS (the workspace
176 /// probe and quadratic cost model are calibrated against single-threaded
177 /// MLAS runs). Raise this on under-utilized hosts where `BGE_M3_WORKERS *
178 /// intra_threads <= num_cpus`: e.g. on an 8 vCPU task with `workers=2`,
179 /// setting `intra_threads=4` lets each worker fan out to four cores during
180 /// inference, taking CPU utilization from ~25% to ~100% under load. Going
181 /// above `floor(num_cpus / workers)` causes thread oversubscription and
182 /// hurts throughput.
183 ///
184 /// Re-run the startup probe (do not pin coefficients) after changing this
185 /// value so the cost model captures any new scratch-buffer overhead.
186 pub intra_threads: usize,
187 /// Maximum number of input texts accepted in a single request.
188 ///
189 /// Set with `BGE_M3_MAX_BATCH`. Defaults to `256`. Minimum effective value is `1`.
190 pub max_batch: usize,
191 /// Maximum sequence length (tokens) for a single text.
192 ///
193 /// Set with `BGE_M3_MAX_SEQ_LENGTH`. Defaults to `8192` (BGE-M3's published max).
194 /// Range: `[1, 8192]`. Set lower to reduce memory footprint on constrained hardware.
195 ///
196 /// The tokenizer will silently truncate any input exceeding this length.
197 /// The probe and bin-packer use this as the upper bound when computing
198 /// workspace costs.
199 pub max_seq_length: usize,
200 /// Duration of inactivity after which workers unload their model instances from memory.
201 ///
202 /// Set with `BGE_M3_IDLE_TIMEOUT_SECS`. Defaults to `300` (5 minutes).
203 /// Set to `0` to disable idle unloading entirely.
204 ///
205 /// When unloaded, models are automatically reloaded on the next incoming request.
206 /// The reload blocks the request until complete (~5–10 s from `CoreML` compiled
207 /// cache; ~15–30 s cold).
208 pub idle_timeout: Option<Duration>,
209 /// ONNX model variant to load.
210 ///
211 /// Set with `BGE_M3_MODEL`. Accepts `"fp32"`, `"fp16"`, or `"int8"`.
212 /// Defaults to `"fp16"` for fleet-wide embedding consistency and reduced RAM
213 /// on Linux/Intel deployments. Set `BGE_M3_MODEL=fp32` on Apple Silicon to
214 /// recover `CoreML` GPU acceleration. See [`ModelVariant`] for per-variant
215 /// performance and memory trade-offs.
216 pub model_variant: ModelVariant,
217
218 // --- auto-budget and cost-model knobs ---
219 /// Fraction of estimated available workspace to actually use per worker.
220 ///
221 /// Set with `BGE_M3_MEMORY_SAFETY_FACTOR`. Defaults to `0.7` (30% headroom
222 /// for ORT arena fragmentation and spike overhead not captured by the probe).
223 /// Range: `0.1..=1.0`.
224 pub memory_safety_factor: f64,
225
226 /// If `Some`, skip the startup probe and use this cost model directly.
227 ///
228 /// Populated when:
229 /// - `BGE_M3_DISABLE_AUTO_BUDGET=1` is set (uses conservative defaults), or
230 /// - `BGE_M3_TOKEN_BUDGET` is set (translates the legacy token count to a
231 /// `max_workspace_bytes` using conservative `a`/`b` coefficients), or
232 /// - `BGE_M3_COST_MODEL_A` and `BGE_M3_COST_MODEL_B` are both set with
233 /// `BGE_M3_AVAILABLE_MEMORY_BYTES` (full explicit override).
234 pub cost_model_override: Option<CostModel>,
235 /// Interval (seconds) between periodic heartbeat log events.
236 ///
237 /// Set with `BGE_M3_HEARTBEAT_SECS`. Defaults to `60`.
238 /// Set to `0` to disable heartbeat logging entirely.
239 ///
240 /// Heartbeat events log RSS, live/loaded worker counts, queue depth,
241 /// available request permits, and current probe status — useful for
242 /// detecting slow memory leaks or queue saturation between requests.
243 pub heartbeat_secs: u64,
244
245 /// ONNX Runtime execution provider to use.
246 ///
247 /// Set with `BGE_M3_EP`. Accepts `"cpu"`, `"cuda"`, or `"tensorrt"`.
248 /// Defaults to `"cpu"`. On macOS, `CoreML` is always used regardless of
249 /// this setting. Requires the corresponding Cargo feature (`cuda` or
250 /// `tensorrt`) to be enabled at build time for GPU EPs.
251 ///
252 /// When set to `"cuda"` or `"tensorrt"`, the host-RAM probe is bypassed
253 /// in favour of the VRAM budget, and `BGE_M3_WORKERS` is clamped to
254 /// `BGE_M3_GPU_COUNT` in `EmbedPool::spawn`.
255 pub ep: EpSelection,
256
257 /// Number of GPU devices available on this instance.
258 ///
259 /// Set with `BGE_M3_GPU_COUNT`. When a GPU execution provider (`cuda` or
260 /// `tensorrt`) is active, `BGE_M3_WORKERS` is clamped to this value in
261 /// `EmbedPool::spawn` so each worker is pinned to a distinct CUDA device
262 /// (`device_id = worker_index % gpu_count`).
263 ///
264 /// Auto-detected on Linux from `/proc/driver/nvidia/gpus/` entry count.
265 /// Defaults to `1` on macOS (`CoreML` is always single-device) and on
266 /// Linux when the driver proc path is absent. Override explicitly on
267 /// multi-GPU ECS instances: `BGE_M3_GPU_COUNT=8`.
268 pub gpu_count: usize,
269
270 /// VRAM workspace ceiling (bytes) when a GPU execution provider is active.
271 ///
272 /// Set with `BGE_M3_GPU_VRAM_BUDGET_BYTES`. Ignored when `ep == Cpu`.
273 /// Defaults to `None`, which causes the server to use 10 GiB as the
274 /// ceiling (suitable for GPUs with ≥ 16 GiB VRAM such as A10G / L4).
275 /// Lower this on GPUs with less VRAM (e.g. `8589934592` for 8 GiB).
276 pub gpu_vram_budget_bytes: Option<usize>,
277
278 /// TRT EP workspace size cap in bytes.
279 ///
280 /// Set with `BGE_M3_TRT_MAX_WORKSPACE_BYTES`. When `None`, TRT uses its
281 /// default "as large as possible" workspace — which can OOM on saturated
282 /// VRAM. Set to a value that leaves room for resident model weights and
283 /// cached engine plans (e.g. 4 GiB = `4294967296` on an L40S with 4 workers).
284 pub trt_max_workspace_bytes: Option<usize>,
285
286 /// CUDA EP device-level memory limit in bytes.
287 ///
288 /// Set with `BGE_M3_GPU_MEM_LIMIT_BYTES`. When `None`, CUDA EP uses all
289 /// available device memory. Symmetric to `trt_max_workspace_bytes`.
290 pub gpu_mem_limit_bytes: Option<usize>,
291
292 /// Enable the in-process adaptive background warmup loop.
293 ///
294 /// Set with `BGE_M3_ADAPTIVE_WARMUP_ENABLED=1`. When enabled, the server
295 /// detects unseen `(batch, seq)` shapes during live inference and compiles
296 /// TRT engines for them during idle windows.
297 pub adaptive_warmup_enabled: bool,
298
299 /// Enable cross-worker engine cache propagation via broadcast channel.
300 ///
301 /// When true, after any worker writes a new TRT engine plan to EFS (via the
302 /// `adaptive_warmup` loop or a real-inference JIT compile), a `(batch, seq)`
303 /// shape notification is broadcast to every peer worker so they eagerly run
304 /// `trt_prewarm` against their own session (~1-3s fast disk-load).
305 ///
306 /// Defaults to `adaptive_warmup_enabled`. Set `BGE_M3_ENGINE_PROPAGATION_ENABLED=0`
307 /// to disable propagation while keeping adaptive warmup active (debugging).
308 pub engine_propagation_enabled: bool,
309
310 /// Seconds the server must be idle (`queue_depth == 0`, all workers free)
311 /// before the adaptive warmup loop fires a shape.
312 ///
313 /// Set with `BGE_M3_ADAPTIVE_WARMUP_QUIET_SECS`. Default: 3.
314 pub adaptive_warmup_quiet_secs: u64,
315
316 /// Maximum number of shapes the adaptive warmup loop may compile per hour.
317 ///
318 /// Set with `BGE_M3_ADAPTIVE_WARMUP_MAX_SHAPES_PER_HOUR`. Default: 12.
319 /// Prevents pathological traffic patterns from compiling indefinitely.
320 pub adaptive_warmup_max_shapes_per_hour: u32,
321
322 /// List of `(batch_size, seq_len)` shapes to pre-compile as `TensorRT` engine
323 /// files during worker startup.
324 ///
325 /// Set with `BGE_M3_TRT_WARMUP_SHAPES` as a comma-separated list of `BxL`
326 /// tokens (e.g. `"1x128,4x512,16x2048,32x8192"`). Only used when
327 /// `ep == EpSelection::TensorRt`. Invalid tokens are skipped with a `WARN`.
328 /// An empty or all-invalid value falls back to the default set. Operators
329 /// can shrink the grid for local development (e.g. `"1x128"`) — the env
330 /// var override path is the canonical way to keep cold-start tractable on
331 /// workstations.
332 ///
333 /// Default: a 2D `{1, 2, 4, 8, 16, 32} × {128, 512, 2048, 8192}` grid
334 /// (24 shapes) composed in batch-major order so the smallest batches finish
335 /// first and the most common router shapes (single-text and two-text
336 /// requests) hit a pre-compiled engine on the very first real request. The
337 /// expensive `_ × 8192` shapes compile last (~30–170 s each) — total
338 /// cold-cache compile budget is roughly 9–18 minutes on first deploy.
339 /// Subsequent starts on the same EC2 instance reuse cached engine files
340 /// (seconds).
341 ///
342 /// Each shape may take 30–170 s to compile on the first run; the worker
343 /// signals ready only after all shapes finish, so `/health` returns `503`
344 /// during this window.
345 pub trt_warmup_shapes: Vec<(usize, usize)>,
346
347 /// Exit cleanly after `TensorRT` engine compilation and cache flush.
348 ///
349 /// Set with `BGE_M3_WARMUP_ONLY`. Default `false`.
350 ///
351 /// When `true` the server initialises the model and ORT session exactly as
352 /// normal — loading ONNX weights, configuring the TRT EP, and running the
353 /// pre-warm shape compilation via the existing warmup path. After all
354 /// engines have been compiled and fsynced to the EFS cache the process
355 /// logs a single `INFO` line and calls `process::exit(0)`. No TCP listener
356 /// is bound; the HTTP server never starts.
357 ///
358 /// Primary use-case: ECS init container that pre-populates the shared EFS
359 /// engine cache before the main container starts, so the main container
360 /// always sees a warm cache and skips the 6–12 minute cold-compile window.
361 ///
362 /// A `WARN` is logged if this flag is set with `BGE_M3_EP` other than
363 /// `tensorrt` — warmup-only on CPU is a no-op (there is nothing to compile)
364 /// but the server still exits 0 cleanly rather than erroring.
365 pub warmup_only: bool,
366
367 /// When `true`, prewarm postcondition failures cause workers to refuse
368 /// to signal ready (the pool init handle errors out and the readiness
369 /// probe triggers a hard process exit). When `false`, postcondition
370 /// failures only log a WARN and workers still signal ready — preserving
371 /// pre-fix behaviour for debugging and operators who explicitly opt
372 /// out of fail-loud startup.
373 ///
374 /// Set with `BGE_M3_PREWARM_STRICT`. Defaults to `true`.
375 ///
376 /// A production incident motivated this default: every worker
377 /// on a multi-GPU Blackwell task hit a TRT autotuner workspace OOM mid-build
378 /// (`IBuilder::buildSerializedNetwork: Error Code 10`), the postcondition
379 /// logged a WARN, `/health` returned `200 ok`, and the task served HTTP
380 /// 500 traffic on the same shape that failed prewarm. Strict-mode would
381 /// have forced an immediate task exit, which ECS retries — far better
382 /// than routing traffic to a known-broken pool.
383 pub prewarm_strict: bool,
384
385 /// Maximum HTTP request body size in bytes.
386 ///
387 /// Set with `BGE_M3_MAX_BODY_BYTES`. Defaults to `33_554_432` (32 MiB).
388 /// Raise this value when embedding large batches with long function bodies
389 /// that exceed the default limit (HTTP 413 Content Too Large).
390 pub max_body_bytes: usize,
391
392 /// Number of consecutive inference failures that trips the per-worker
393 /// circuit breaker.
394 ///
395 /// Set with `BGE_M3_CIRCUIT_BREAKER_THRESHOLD`. Defaults to `5`.
396 ///
397 /// When a worker returns N consecutive errors from `embed_dense`,
398 /// `embed_sparse`, or `embed_both`, it unloads its ORT session (dropping
399 /// the CUDA arena) and decrements `loaded_workers`. `/health` transitions
400 /// to `idle` (200) when `loaded_workers == 0` and `fail` (503) when
401 /// `live_workers == 0`. On the next incoming request the worker reloads
402 /// from the on-disk model cache, resetting the counter. This limits blast
403 /// radius from a broken GPU state to ~5 requests before self-healing.
404 pub circuit_breaker_threshold: usize,
405
406 /// Enables the in-band `TensorRT` JIT admission guard.
407 ///
408 /// Set with `BGE_M3_TRT_INBAND_JIT_GUARD`. Defaults to `true`.
409 ///
410 /// When enabled (and `ep == tensorrt`), the worker refuses any chunk whose
411 /// padded sequence length is at/above [`Self::trt_inband_jit_guard_seq`]
412 /// and exceeds the pool's warmed engine coverage, returning HTTP `503`
413 /// instead of issuing the `session.run()` that would trigger an in-band
414 /// TRT JIT. On the fused `/v1/embeddings:both` graph at `seq=8192` that
415 /// JIT can request a pathological autotuner allocation (tens of GiB to
416 /// multiple TiB) that crashes the worker via SIGSEGV / OOM-kill — a hard
417 /// process death no `Result`-based safety net can catch. Refusing the rare
418 /// uncovered request is strictly safer. Set to `0` to disable (restoring
419 /// the pre-guard crash-on-uncovered-large-shape behaviour).
420 pub trt_inband_jit_guard_enabled: bool,
421
422 /// Sequence-length threshold for the in-band JIT guard.
423 ///
424 /// Set with `BGE_M3_TRT_INBAND_JIT_GUARD_SEQ`. Defaults to `4096`.
425 ///
426 /// Chunks with `seq < guard_seq` are always admitted (a cold JIT at small
427 /// or medium sequence lengths is bounded and lets the engine profile grow
428 /// naturally); only `seq >= guard_seq` chunks that are *also* uncovered by
429 /// the warmed profile are refused. The default sits between the second-
430 /// highest (`2048`) and highest (`8192`) default warmup tiers, so it
431 /// targets the genuinely-pathological large-sequence region and is a
432 /// no-op for deployments whose `max_seq_length` is below it.
433 pub trt_inband_jit_guard_seq: usize,
434
435 /// When `true`, scan the TRT engine cache at worker startup and
436 /// **destructively delete** plan files whose `_smXX` suffix does not
437 /// match the current device. Sourced from `BGE_M3_TRT_CACHE_GC_ENABLED`,
438 /// defaults to `false`.
439 ///
440 /// # ⚠️ HAZARD — multi-SM ASG cache coexistence
441 ///
442 /// This field exists only when the `cache-gc` Cargo feature is
443 /// compiled in. Production binaries are built without the feature so
444 /// this field is physically absent and `BGE_M3_TRT_CACHE_GC_ENABLED`
445 /// is silently ignored.
446 ///
447 /// Even with the feature compiled in, defaulting to `false` is
448 /// deliberate: ORT's TRT EP namespaces engine plans by SM so plans
449 /// for different compute capabilities coexist safely; an ASG that
450 /// shares an EFS engine cache across instance families
451 /// (T4 / A10G / L4 / L40S / Blackwell) **relies on that coexistence**.
452 /// A binary that enables this flag against a shared multi-SM cache
453 /// will delete plans that are still in active use by peer tasks. Only
454 /// enable on a dedicated maintenance or dev binary whose cache
455 /// directory is not shared with production traffic.
456 ///
457 /// See `src/embedder/trt_cache_gc.rs` and the README section
458 /// "Stale-SM Cache GC" for the full hazard model.
459 #[cfg(feature = "cache-gc")]
460 pub trt_cache_gc_enabled: bool,
461}
462
463impl Config {
464 /// Creates a [`Config`] by reading environment variables.
465 ///
466 /// Unrecognized or missing variables fall back to their defaults.
467 ///
468 /// # Errors
469 ///
470 /// Returns `Err` when exactly one of `BGE_M3_TLS_CERT_PATH` /
471 /// `BGE_M3_TLS_KEY_PATH` is set: a half-configured TLS pair would cause
472 /// the server to silently fall back to plain HTTP rather than fail loudly.
473 pub fn from_env() -> anyhow::Result<Self> {
474 let cfg = Self::from_lookup(|key| env::var(key).ok());
475 cfg.validate()?;
476 Ok(cfg)
477 }
478
479 /// Validates configuration invariants that cannot be enforced by the
480 /// type system alone.
481 ///
482 /// # Errors
483 ///
484 /// Returns `Err` when exactly one of `tls_cert_path` / `tls_key_path` is
485 /// `Some`. Both must be present or both must be absent.
486 pub(crate) fn validate(&self) -> anyhow::Result<()> {
487 match (&self.tls_cert_path, &self.tls_key_path) {
488 (Some(_), None) | (None, Some(_)) => {
489 anyhow::bail!(
490 "TLS misconfiguration: BGE_M3_TLS_CERT_PATH and \
491 BGE_M3_TLS_KEY_PATH must both be set or both be absent"
492 );
493 }
494 _ => {}
495 }
496 Ok(())
497 }
498
499 #[allow(clippy::too_many_lines)]
500 /// Creates a [`Config`] by resolving each setting through `lookup`.
501 ///
502 /// `lookup` receives an env-var name and returns its value if set, or
503 /// `None` to fall back to the default for that setting. Used by
504 /// [`Config::from_env`] with the real environment and in tests with a
505 /// closure over a `HashMap`.
506 ///
507 /// **Side effect**: when `BGE_M3_EP=tensorrt`, emits a `WARN` via
508 /// `tracing` if the resolved `trt_warmup_shapes` grid does not cover
509 /// batch=1 or batch=2. Tests that construct a `Config` with TRT EP and a
510 /// partial grid will see this log output.
511 pub(crate) fn from_lookup<F: Fn(&str) -> Option<String>>(lookup: F) -> Self {
512 let workers = lookup("BGE_M3_WORKERS")
513 .and_then(|v| v.parse::<usize>().ok())
514 .unwrap_or(2)
515 .max(1);
516
517 let intra_threads = lookup("BGE_M3_INTRA_THREADS")
518 .and_then(|v| v.parse::<usize>().ok())
519 .unwrap_or(1)
520 .max(1);
521
522 let max_batch = lookup("BGE_M3_MAX_BATCH")
523 .and_then(|v| v.parse::<usize>().ok())
524 .unwrap_or(256)
525 .max(1);
526
527 let max_seq_length = {
528 let raw = lookup("BGE_M3_MAX_SEQ_LENGTH")
529 .and_then(|v| v.parse::<usize>().ok())
530 .unwrap_or(MODEL_MAX_SEQ);
531 if raw == 0 || raw > MODEL_MAX_SEQ {
532 warn!(
533 requested = raw,
534 clamped = MODEL_MAX_SEQ,
535 "BGE_M3_MAX_SEQ_LENGTH out of range [1, {MODEL_MAX_SEQ}]; clamping"
536 );
537 MODEL_MAX_SEQ
538 } else {
539 raw
540 }
541 };
542
543 let idle_timeout_secs = lookup("BGE_M3_IDLE_TIMEOUT_SECS")
544 .and_then(|v| v.parse::<u64>().ok())
545 .unwrap_or(300);
546 let idle_timeout = (idle_timeout_secs > 0).then(|| Duration::from_secs(idle_timeout_secs));
547
548 let model_variant = match lookup("BGE_M3_MODEL").as_deref() {
549 Some("fp32") => ModelVariant::Fp32,
550 Some("int8") => ModelVariant::Int8,
551 _ => ModelVariant::Fp16,
552 };
553
554 let memory_safety_factor = {
555 let raw = lookup("BGE_M3_MEMORY_SAFETY_FACTOR")
556 .and_then(|v| v.parse::<f64>().ok())
557 .unwrap_or(0.7);
558 raw.clamp(0.1, 1.0)
559 };
560
561 // --- cost model override resolution ---
562 // Priority:
563 // 1. BGE_M3_DISABLE_AUTO_BUDGET → conservative defaults
564 // 2. BGE_M3_TOKEN_BUDGET (legacy) → translates to max_workspace_bytes
565 // 3. BGE_M3_COST_MODEL_A + BGE_M3_COST_MODEL_B + BGE_M3_AVAILABLE_MEMORY_BYTES
566 // 4. None → probe at startup
567
568 let cost_model_override = resolve_cost_model_override(&lookup, max_seq_length);
569
570 // --- legacy BGE_M3_ONNX_BATCH_SIZE deprecation ---
571 if lookup("BGE_M3_ONNX_BATCH_SIZE").is_some() {
572 warn!(
573 "BGE_M3_ONNX_BATCH_SIZE is deprecated and will be removed in a future release. \
574 The server now uses a quadratic-aware cost model and auto-budget probe. \
575 Set BGE_M3_TOKEN_BUDGET to pin a specific workspace ceiling, or remove the \
576 variable to enable fully automatic tuning."
577 );
578 }
579
580 let heartbeat_secs = lookup("BGE_M3_HEARTBEAT_SECS")
581 .and_then(|v| v.parse::<u64>().ok())
582 .unwrap_or(60);
583
584 let ep = match lookup("BGE_M3_EP").as_deref() {
585 Some("cuda") => EpSelection::Cuda,
586 Some("tensorrt") => EpSelection::TensorRt,
587 _ => EpSelection::Cpu,
588 };
589
590 let gpu_vram_budget_bytes =
591 lookup("BGE_M3_GPU_VRAM_BUDGET_BYTES").and_then(|v| v.parse::<usize>().ok());
592
593 let trt_max_workspace_bytes = lookup("BGE_M3_TRT_MAX_WORKSPACE_BYTES").and_then(|v| {
594 v.parse::<usize>()
595 .inspect_err(|e| {
596 tracing::warn!(
597 raw = %v,
598 error = %e,
599 "BGE_M3_TRT_MAX_WORKSPACE_BYTES parse failed — TRT workspace cap disabled"
600 );
601 })
602 .ok()
603 .inspect(|&bytes| {
604 if bytes > VRAM_WARN_THRESHOLD_BYTES {
605 tracing::warn!(
606 bytes,
607 threshold = VRAM_WARN_THRESHOLD_BYTES,
608 "BGE_M3_TRT_MAX_WORKSPACE_BYTES exceeds 128 GiB — \
609 verify units are bytes, not GiB"
610 );
611 }
612 })
613 });
614
615 let gpu_mem_limit_bytes = lookup("BGE_M3_GPU_MEM_LIMIT_BYTES").and_then(|v| {
616 v.parse::<usize>()
617 .inspect_err(|e| {
618 tracing::warn!(
619 raw = %v,
620 error = %e,
621 "BGE_M3_GPU_MEM_LIMIT_BYTES parse failed — CUDA memory limit disabled"
622 );
623 })
624 .ok()
625 .inspect(|&bytes| {
626 if bytes > VRAM_WARN_THRESHOLD_BYTES {
627 tracing::warn!(
628 bytes,
629 threshold = VRAM_WARN_THRESHOLD_BYTES,
630 "BGE_M3_GPU_MEM_LIMIT_BYTES exceeds 128 GiB — \
631 verify units are bytes, not GiB"
632 );
633 }
634 })
635 });
636
637 let adaptive_warmup_enabled = lookup("BGE_M3_ADAPTIVE_WARMUP_ENABLED")
638 .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes"));
639
640 let engine_propagation_enabled = lookup("BGE_M3_ENGINE_PROPAGATION_ENABLED")
641 .and_then(|v| match v.as_str() {
642 "0" => Some(false),
643 "1" => Some(true),
644 other => {
645 tracing::warn!(
646 value = other,
647 default = adaptive_warmup_enabled,
648 "BGE_M3_ENGINE_PROPAGATION_ENABLED: unrecognized value \
649 (expected \"0\" or \"1\"); defaulting to \
650 BGE_M3_ADAPTIVE_WARMUP_ENABLED ({})",
651 adaptive_warmup_enabled
652 );
653 None
654 }
655 })
656 .unwrap_or(adaptive_warmup_enabled);
657
658 let adaptive_warmup_quiet_secs = lookup("BGE_M3_ADAPTIVE_WARMUP_QUIET_SECS")
659 .and_then(|v| v.parse::<u64>().ok())
660 .unwrap_or(3);
661
662 let adaptive_warmup_max_shapes_per_hour =
663 lookup("BGE_M3_ADAPTIVE_WARMUP_MAX_SHAPES_PER_HOUR")
664 .and_then(|v| v.parse::<u32>().ok())
665 .unwrap_or(12);
666
667 // When a GPU EP is active, the host-RAM probe is meaningless — VRAM is
668 // the constraint. Override the cost model unconditionally so the probe
669 // is skipped and the VRAM budget drives bin-packing instead.
670 let cost_model_override = if ep == EpSelection::Cpu {
671 cost_model_override
672 } else {
673 let vram_budget = gpu_vram_budget_bytes.unwrap_or(DEFAULT_GPU_VRAM_BUDGET_BYTES);
674 info!(
675 ep = %ep,
676 vram_budget_bytes = vram_budget,
677 "GPU execution provider selected — bypassing host-RAM probe; \
678 using VRAM budget as the workspace ceiling"
679 );
680 Some(CostModel::conservative(vram_budget))
681 };
682
683 let gpu_count = sysinfo::detect_gpu_count(
684 lookup("BGE_M3_GPU_COUNT").and_then(|v| v.parse::<usize>().ok()),
685 );
686
687 let trt_warmup_shapes = parse_trt_warmup_shapes(lookup("BGE_M3_TRT_WARMUP_SHAPES"));
688 if ep == EpSelection::TensorRt {
689 warn_if_small_batch_coverage_missing(&trt_warmup_shapes);
690 }
691
692 let max_body_bytes = lookup("BGE_M3_MAX_BODY_BYTES")
693 .and_then(|v| v.parse::<usize>().ok())
694 .unwrap_or(33_554_432);
695
696 let circuit_breaker_threshold = lookup("BGE_M3_CIRCUIT_BREAKER_THRESHOLD")
697 .and_then(|v| v.parse::<usize>().ok())
698 .unwrap_or(5)
699 .max(1);
700
701 // BGE_M3_TRT_INBAND_JIT_GUARD: default ON. Only an explicit disable
702 // token (`0`/`false`/`no`) turns it off, so fat-fingered values keep
703 // the protective behaviour.
704 let trt_inband_jit_guard_enabled = !matches!(
705 lookup("BGE_M3_TRT_INBAND_JIT_GUARD").as_deref(),
706 Some("0" | "false" | "no")
707 );
708
709 let trt_inband_jit_guard_seq = lookup("BGE_M3_TRT_INBAND_JIT_GUARD_SEQ")
710 .and_then(|v| v.parse::<usize>().ok())
711 .unwrap_or(4096)
712 .max(1);
713
714 let tls_cert_path = lookup("BGE_M3_TLS_CERT_PATH").map(std::path::PathBuf::from);
715 let tls_key_path = lookup("BGE_M3_TLS_KEY_PATH").map(std::path::PathBuf::from);
716
717 let warmup_only = lookup("BGE_M3_WARMUP_ONLY")
718 .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes"));
719
720 // BGE_M3_PREWARM_STRICT: default ON. Anything that isn't an explicit
721 // disable token (`0`/`false`/`no`) leaves the safe default in place
722 // — fat-fingered values get the protective behaviour, not a silent
723 // disable.
724 let prewarm_strict = !matches!(
725 lookup("BGE_M3_PREWARM_STRICT").as_deref(),
726 Some("0" | "false" | "no")
727 );
728
729 // BGE_M3_TRT_CACHE_GC_ENABLED: strict opt-in, default OFF. Only
730 // parsed when the `cache-gc` Cargo feature is enabled — in normal
731 // production builds the env var has zero effect because the field
732 // it would populate does not exist. See the `Config::
733 // trt_cache_gc_enabled` field docs for the hazard model.
734 #[cfg(feature = "cache-gc")]
735 let trt_cache_gc_enabled = lookup("BGE_M3_TRT_CACHE_GC_ENABLED")
736 .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes"));
737
738 if warmup_only && ep != EpSelection::TensorRt {
739 warn!(
740 ep = %ep,
741 "BGE_M3_WARMUP_ONLY=1 is set but BGE_M3_EP is not tensorrt — \
742 warmup-only is a no-op on non-TRT EPs (nothing to compile); \
743 the server will exit 0 without performing any engine compilation"
744 );
745 }
746
747 Self {
748 cache_dir: lookup("BGE_M3_CACHE_DIR").unwrap_or_else(|| "/cache".to_string()),
749 bind_addr: lookup("BGE_M3_BIND").unwrap_or_else(|| "0.0.0.0:8081".to_string()),
750 tls_cert_path,
751 tls_key_path,
752 workers,
753 intra_threads,
754 max_batch,
755 max_seq_length,
756 idle_timeout,
757 model_variant,
758 memory_safety_factor,
759 cost_model_override,
760 heartbeat_secs,
761 ep,
762 gpu_vram_budget_bytes,
763 trt_max_workspace_bytes,
764 gpu_mem_limit_bytes,
765 adaptive_warmup_enabled,
766 engine_propagation_enabled,
767 adaptive_warmup_quiet_secs,
768 adaptive_warmup_max_shapes_per_hour,
769 gpu_count,
770 trt_warmup_shapes,
771 max_body_bytes,
772 circuit_breaker_threshold,
773 trt_inband_jit_guard_enabled,
774 trt_inband_jit_guard_seq,
775 warmup_only,
776 prewarm_strict,
777 #[cfg(feature = "cache-gc")]
778 trt_cache_gc_enabled,
779 }
780 }
781}
782
783/// Default TRT warmup shapes: a 2D `{1, 2, 4, 8, 16, 32} × {128, 512, 2048, 8192}`
784/// grid composed in batch-major order.
785///
786/// Batch is the outer dimension so the smallest batches (which dominate
787/// real router traffic — single-text and two-text requests are the most
788/// common pattern for both ad-hoc queries and bulk indexers) are fully
789/// compiled first; larger batches typical of bulk re-indexing fill in
790/// afterwards. Within each batch the sequence dimension grows monotonically
791/// so the cheap `_ × 128` shape comes before the expensive `_ × 8192` shape —
792/// operators watching `/health` see progress quickly.
793///
794/// Previously-unseen shapes trigger in-band engine compilation in the middle
795/// of a real request, producing tens-to-hundreds-of-seconds `inference_ms`
796/// values. In the worst case, TRT JIT for the dual-output
797/// `/v1/embeddings:both` graph at unseen small-batch shapes can request
798/// pathological autotuner allocations (multiple terabytes on a fused
799/// `LayerNorm` + `MatMul` foreign-node) that the CUDA allocator cannot
800/// satisfy, producing a fatal `failed to create engine from network` error.
801/// Including `(1, _)`, `(2, _)`, `(4, _)`, and `(8, _)` rows closes the JIT
802/// window for the common router pack-sizes.
803///
804/// This 24-shape grid covers the full realistic shape space so every router
805/// request hits a pre-compiled engine.
806const DEFAULT_TRT_WARMUP_SHAPES: &[(usize, usize)] = &[
807 (1, 128),
808 (1, 512),
809 (1, 2048),
810 (1, 8192),
811 (2, 128),
812 (2, 512),
813 (2, 2048),
814 (2, 8192),
815 (4, 128),
816 (4, 512),
817 (4, 2048),
818 (4, 8192),
819 (8, 128),
820 (8, 512),
821 (8, 2048),
822 (8, 8192),
823 (16, 128),
824 (16, 512),
825 (16, 2048),
826 (16, 8192),
827 (32, 128),
828 (32, 512),
829 (32, 2048),
830 (32, 8192),
831];
832
833/// Parses `BGE_M3_TRT_WARMUP_SHAPES` from its raw env-var value.
834///
835/// Accepts a comma-separated list of `BxL` tokens (e.g. `"1x128,1x512"`).
836/// Invalid tokens are skipped with a `WARN`. Returns the default shape set
837/// when `raw` is `None`, empty, or all tokens are invalid.
838pub(crate) fn parse_trt_warmup_shapes(raw: Option<String>) -> Vec<(usize, usize)> {
839 let Some(val) = raw else {
840 return DEFAULT_TRT_WARMUP_SHAPES.to_vec();
841 };
842 if val.trim().is_empty() {
843 return DEFAULT_TRT_WARMUP_SHAPES.to_vec();
844 }
845 let parsed: Vec<(usize, usize)> = val
846 .split(',')
847 .filter_map(|token| {
848 let token = token.trim();
849 let mut parts = token.splitn(2, 'x');
850 let batch = parts.next()?.parse::<usize>().ok()?;
851 let seq = parts.next()?.parse::<usize>().ok()?;
852 Some((batch, seq))
853 })
854 .collect();
855
856 if parsed.is_empty() {
857 warn!(
858 raw = %val,
859 "BGE_M3_TRT_WARMUP_SHAPES contained no valid BxL tokens; \
860 falling back to default warmup shapes"
861 );
862 DEFAULT_TRT_WARMUP_SHAPES.to_vec()
863 } else {
864 parsed
865 }
866}
867
868/// Emits a startup `WARN` when the resolved warmup shape grid does not cover
869/// the small-batch shapes that real router traffic routinely bin-packs to.
870///
871/// Concretely, real `/v1/embeddings`, `/v1/sparse-embeddings`, and
872/// `/v1/embeddings:both` calls produce chunk batches of 1–2 (single-text or
873/// two-text requests are the dominant traffic pattern for both ad-hoc queries
874/// and bulk indexers). When the warmup grid omits both batch=1 and batch=2,
875/// the first such request triggers in-band TRT JIT compilation, which on the
876/// `:both` route has been observed to trigger pathological autotuner
877/// allocation requests (multiple terabytes) and a fatal `failed to create
878/// engine from network` error.
879///
880/// Greppable tag: `trt_warmup_shape_coverage_gap`. Operators are not blocked
881/// from deploying a batch-1-only grid (e.g. local dev workstations) — the
882/// surface is informational so legitimate edge configurations still start.
883pub(crate) fn warn_if_small_batch_coverage_missing(shapes: &[(usize, usize)]) {
884 let covers_batch_1 = shapes.iter().any(|(b, _)| *b == 1);
885 let covers_batch_2 = shapes.iter().any(|(b, _)| *b == 2);
886 if !covers_batch_1 || !covers_batch_2 {
887 let batches: std::collections::BTreeSet<usize> = shapes.iter().map(|(b, _)| *b).collect();
888 warn!(
889 target: "bge_m3_embedding_server::trt_warmup",
890 tag = "trt_warmup_shape_coverage_gap",
891 covers_batch_1,
892 covers_batch_2,
893 configured_batches = ?batches,
894 shape_count = shapes.len(),
895 "BGE_M3_TRT_WARMUP_SHAPES is missing coverage for batch=1 or \
896 batch=2 (or both) — real router traffic routinely bin-packs to \
897 these shapes and the first such request will trigger in-band TRT \
898 JIT, which can produce a pathological autotuner allocation failure \
899 on the /v1/embeddings:both route. Add `1x…` and `2x…` rows to \
900 BGE_M3_TRT_WARMUP_SHAPES, or unset it to use the default \
901 24-shape grid."
902 );
903 }
904}
905
906/// Resolves an optional `CostModel` from env vars that explicitly override auto-tuning.
907///
908/// Returns `None` when the server should run the startup probe.
909//
910// cast_precision_loss: token_budget and max_seq_length are small integers (≤ 8192)
911// that are well within f64 mantissa range; cost-per-position is an estimate.
912// cast_possible_truncation / cast_sign_loss: the workspace result is always positive
913// (products of positive coefficients and non-negative token counts), and fractional
914// bytes are intentionally floored when converting back to usize.
915#[allow(
916 clippy::cast_precision_loss,
917 clippy::cast_possible_truncation,
918 clippy::cast_sign_loss
919)]
920fn resolve_cost_model_override<F: Fn(&str) -> Option<String>>(
921 lookup: &F,
922 max_seq_length: usize,
923) -> Option<CostModel> {
924 // 1. BGE_M3_DISABLE_AUTO_BUDGET — skip probe, use conservative defaults.
925 // max_workspace_bytes comes from BGE_M3_AVAILABLE_MEMORY_BYTES if set,
926 // otherwise uses the built-in default (2 GiB).
927 if lookup("BGE_M3_DISABLE_AUTO_BUDGET")
928 .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes"))
929 {
930 let max_workspace = lookup("BGE_M3_AVAILABLE_MEMORY_BYTES")
931 .and_then(|v| v.parse::<usize>().ok())
932 .unwrap_or(CostModel::DEFAULT_MAX_WORKSPACE);
933 return Some(CostModel::conservative(max_workspace));
934 }
935
936 // 2. BGE_M3_TOKEN_BUDGET — legacy token-count ceiling.
937 // Translates: max_workspace = token_budget × cost_per_token
938 // using conservative coefficients at the configured max_seq_length.
939 if let Some(token_budget) = lookup("BGE_M3_TOKEN_BUDGET").and_then(|v| v.parse::<usize>().ok())
940 {
941 // cost_per_position at max_seq = a + b * max_seq
942 let cost_per_position =
943 CostModel::CONSERVATIVE_A + CostModel::CONSERVATIVE_B * max_seq_length as f64;
944 let max_workspace = (token_budget as f64 * cost_per_position) as usize;
945 return Some(CostModel {
946 a: CostModel::CONSERVATIVE_A,
947 b: CostModel::CONSERVATIVE_B,
948 max_workspace_bytes: max_workspace,
949 });
950 }
951
952 // 3. Explicit coefficient override — requires A, B, AND available memory.
953 if let (Some(a_str), Some(b_str)) =
954 (lookup("BGE_M3_COST_MODEL_A"), lookup("BGE_M3_COST_MODEL_B"))
955 && let (Ok(a), Ok(b)) = (a_str.parse::<f64>(), b_str.parse::<f64>())
956 {
957 let max_workspace = lookup("BGE_M3_AVAILABLE_MEMORY_BYTES")
958 .and_then(|v| v.parse::<usize>().ok())
959 .unwrap_or(CostModel::DEFAULT_MAX_WORKSPACE);
960 return Some(CostModel {
961 a,
962 b,
963 max_workspace_bytes: max_workspace,
964 });
965 }
966
967 // 4. No override — run the startup probe.
968 None
969}
970
971#[cfg(test)]
972mod tests;