Skip to main content

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;