Skip to main content

bge_m3_embedding_server/embedder/worker/
startup.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//! Worker startup: model load, optional cache GC, TRT prewarm, readiness signal.
16
17use std::path::Path;
18use std::sync::atomic::Ordering;
19
20use anyhow::Result;
21use tokio::runtime::Handle;
22use tokio::sync::mpsc;
23use tracing::{info, info_span};
24
25use super::config::WorkerConfig;
26use super::prewarm_strict::should_fail_readiness;
27use super::probe::probe_run_dense;
28use crate::config::EpSelection;
29use crate::embedder::session::{GpuSessionConfig, load_models};
30use crate::embedder::sm_detect::detect_sm_for_device;
31use crate::embedder::trt_cache;
32#[cfg(feature = "cache-gc")]
33use crate::embedder::trt_cache_gc;
34use crate::embedder::trt_warmup::{
35    prewarm_persistence_postcondition_failed, prewarm_persistence_suspicious_undercount,
36    trt_prewarm,
37};
38use crate::sysinfo;
39
40/// Outcome of the blocking startup sequence before the request loop begins.
41pub(super) struct StartupOutcome {
42    pub initial_models: (ort::session::Session, tokenizers::Tokenizer),
43    pub detected_sm: Option<String>,
44}
45
46/// Returns the GPU compute capability for TRT EP workers, or `None` on CPU/CUDA.
47pub(super) fn detected_sm_for_ep(ep: EpSelection, device_id: u32) -> Option<String> {
48    if ep == EpSelection::TensorRt {
49        detect_sm_for_device(device_id)
50    } else {
51        None
52    }
53}
54
55/// Loads models, runs optional TRT prewarm, and signals readiness.
56#[allow(clippy::too_many_lines)]
57pub(super) fn startup_worker(
58    id: usize,
59    cache_dir: &Path,
60    ready_tx: &mpsc::Sender<Result<usize>>,
61    config: &WorkerConfig,
62    rt: &Handle,
63) -> Result<StartupOutcome> {
64    let span = info_span!("worker", id = id);
65    let _span_guard = span.enter();
66
67    tracing::debug!(
68        worker_id = id,
69        gpu_device = config.device_id,
70        ep = %config.ep,
71        "worker assigned to GPU device"
72    );
73    info!("Loading models (worker {id})...");
74    let load_start = std::time::Instant::now();
75
76    let pre_load_rss = sysinfo::read_process_rss_bytes().unwrap_or(0);
77    let mut initial_models = match load_models(
78        &GpuSessionConfig {
79            cache_dir,
80            model_variant: config.model_variant,
81            max_seq_length: config.max_seq_length,
82            intra_threads: config.intra_threads,
83            ep: config.ep,
84            device_id: config.device_id,
85            trt_max_workspace_bytes: config.trt_max_workspace_bytes,
86            gpu_mem_limit_bytes: config.gpu_mem_limit_bytes,
87        },
88        id == 0,
89    ) {
90        Ok(mut models) => {
91            // Prime the ORT session arena with a tiny session.run() BEFORE
92            // measuring post-load RSS. ORT lazily allocates ~1 GiB of arena
93            // bookkeeping on the first run() call regardless of input size;
94            // priming here folds that allocation into the per-worker model
95            // RSS measurement so the workspace-budget math on the main thread
96            // sees the realistic per-worker memory footprint, AND so the
97            // probe sweep's per-shape `rss_delta` readings reflect only the
98            // incremental workspace attributable to that shape.
99            //
100            // Without per-worker priming, the probe could dispatch shapes to
101            // workers that have not yet done a session.run(), and each such
102            // first-touch contributes ~1 GiB of arena init noise to its
103            // delta — which buries the per-shape workspace signal in the
104            // OLS fit.
105            let prime_ids = ndarray::Array2::<i64>::zeros((1, 8));
106            let prime_mask = ndarray::Array2::<i64>::ones((1, 8));
107            match probe_run_dense(&mut models.0, &prime_ids, &prime_mask) {
108                Ok(_) => {
109                    tracing::debug!("Worker {id} arena primed");
110                }
111                Err(e) => {
112                    tracing::warn!(
113                        error = %e,
114                        "Worker {id} arena prime failed; first probe shape on this \
115                         worker will include arena init delta"
116                    );
117                }
118            }
119
120            let post_load_rss = sysinfo::read_process_rss_bytes().unwrap_or(pre_load_rss);
121            tracing::info!(
122                elapsed_ms = load_start.elapsed().as_millis(),
123                rss_delta_mb = post_load_rss.saturating_sub(pre_load_rss) / (1024 * 1024),
124                "Models loaded (worker {id})"
125            );
126            models
127        }
128        Err(e) => {
129            let _ =
130                rt.block_on(ready_tx.send(Err(anyhow::anyhow!("Worker {id} failed to load: {e}"))));
131            return Err(e);
132        }
133    };
134
135    // Destructive stale-SM cache GC (feature-gated `cache-gc` + runtime
136    // `BGE_M3_TRT_CACHE_GC_ENABLED=1`). Both gates must be on for any
137    // deletion to occur. When the feature is OFF this entire block is
138    // physically absent from the binary. See `trt_cache_gc.rs` for the
139    // multi-SM ASG hazard model.
140    // Only the leader worker (id == 0) runs GC. Workers load sequentially
141    // (CLAUDE.md invariant), so worker 0 always finishes GC before worker 1
142    // starts — this guard is race-free and prevents spurious WARN cascades on
143    // workers 1..N that would otherwise each attempt (and log) the same sweep.
144    #[cfg(feature = "cache-gc")]
145    if id == 0 && config.trt_cache_gc_enabled && config.ep != EpSelection::Cpu {
146        let engine_cache_dir = trt_cache::engine_cache_path(cache_dir);
147        match detect_sm_for_device(config.device_id) {
148            Some(current_sm) => {
149                tracing::warn!(
150                    target: "bge_m3_embedding_server::trt_cache_gc",
151                    worker_id = id,
152                    gpu_device = config.device_id,
153                    current_sm = %current_sm,
154                    cache_path = %engine_cache_dir.display(),
155                    sidecar_suffixes = ?trt_cache_gc::ENGINE_SIDE_SUFFIXES,
156                    "destructive cache GC about to run: will delete every \
157                     `_smXX.engine` whose XX != current SM, plus aligned \
158                     sidecars. DO NOT use this build against a shared EFS \
159                     engine cache in a multi-SM ASG"
160                );
161                let stats = trt_cache_gc::gc_stale_sm_plans(&engine_cache_dir, &current_sm);
162                tracing::warn!(
163                    target: "bge_m3_embedding_server::trt_cache_gc",
164                    worker_id = id,
165                    gpu_device = config.device_id,
166                    current_sm = %current_sm,
167                    plans_deleted = stats.plans_deleted,
168                    bytes_freed = stats.bytes_freed,
169                    other_sms_observed = ?stats.other_sms_observed,
170                    cache_path = %engine_cache_dir.display(),
171                    "destructive cache GC ran: deleted other-SM engine plans \
172                     from shared cache"
173                );
174            }
175            None => {
176                tracing::warn!(
177                    target: "bge_m3_embedding_server::trt_cache_gc",
178                    worker_id = id,
179                    gpu_device = config.device_id,
180                    "destructive cache GC requested but current GPU compute \
181                     capability could not be detected (no nvidia-smi or \
182                     unparseable output); skipping GC"
183                );
184            }
185        }
186    }
187
188    // Detect this worker's GPU compute capability (e.g. `sm89`, `sm120`) once,
189    // before TRT prewarm, so every subsequent cache enumeration restricts
190    // itself to plans matching this GPU. Without this, a worker on `sm120`
191    // would count a stale `_sm89.engine` plan toward `cache_hit` and report
192    // a misleading `engine_count_before:3` on a fresh Blackwell deploy with
193    // leftover L40S plans on the shared EFS cache — a common heterogeneous-SM
194    // false-positive failure mode. `None` (detection failed) keeps legacy
195    // unfiltered semantics so an operator missing `nvidia-smi` mid-deploy
196    // does not see a hard regression.
197    let detected_sm: Option<String> = if config.ep == EpSelection::TensorRt {
198        let sm = detected_sm_for_ep(config.ep, config.device_id);
199        if sm.is_none() {
200            tracing::warn!(
201                worker_id = id,
202                gpu_device = config.device_id,
203                "trt cache: nvidia-smi compute-capability detection failed; \
204                 falling back to unfiltered engine cache counts. This re-introduces \
205                 the heterogeneous-SM false-positive risk — install nvidia-smi on \
206                 the container or check that the GPU is accessible."
207            );
208        }
209        sm
210    } else {
211        None
212    };
213
214    // TensorRT engine pre-warming: compile engine files for each configured
215    // shape before signaling readiness.  This runs BEFORE ready_tx.send() so
216    // the worker is not marked ready until all TRT engines are cached —
217    // `/health` correctly returns `503 loading` during the compile window.
218    //
219    // Each shape may take 30–120 s on first deploy; subsequent starts reuse
220    // the cached `.engine` files from `{cache_dir}/trt-engines/` (seconds).
221    if config.ep == EpSelection::TensorRt && !config.trt_warmup_shapes.is_empty() {
222        let engine_cache_dir = trt_cache::engine_cache_path(cache_dir);
223        let total_engine_count = trt_cache::count_engine_files(&engine_cache_dir);
224        let matching_engine_count =
225            trt_cache::count_engine_files_for_sm(&engine_cache_dir, detected_sm.as_deref());
226        // Greppable structured log line (target=bge_m3_embedding_server::trt_cache):
227        // operators searching CloudWatch for `detected_sm` / `matching_engine_count`
228        // get an immediate picture of the heterogeneous-cache situation. A line
229        // showing `matching_engine_count:0, total_engine_count:3` is the visual
230        // signature of the bug this commit fixes.
231        tracing::info!(
232            target: "bge_m3_embedding_server::trt_cache",
233            worker_id = id,
234            device_id = config.device_id,
235            detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
236            cache_path = %engine_cache_dir.display(),
237            matching_engine_count,
238            total_engine_count,
239            "trt cache: SM-filtered engine plan enumeration"
240        );
241        tracing::info!(
242            worker_id = id,
243            gpu_device = config.device_id,
244            detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
245            shape_count = config.trt_warmup_shapes.len(),
246            shapes = ?config.trt_warmup_shapes,
247            "TensorRT pre-warm: worker compiling shard \
248             (first run per shape takes 30–170 s; subsequent starts reuse cache)"
249        );
250        trt_cache::log_engine_basenames_before_prewarm_for_sm(
251            &engine_cache_dir,
252            detected_sm.as_deref(),
253        );
254        let stats = trt_prewarm(
255            &mut initial_models.0,
256            &config.trt_warmup_shapes,
257            id,
258            cache_dir,
259            detected_sm.as_deref(),
260        );
261        // Per-worker postcondition: if the shard reported one or more fresh
262        // (non-cache-hit) compiles but the on-disk engine count did not
263        // increase, surface an ERROR. This is the in-process counterpart to
264        // the postcondition check at the end of the warmup-only path in
265        // lib.rs, intended to catch the silent-persistence failure mode that
266        // produced silent-persistence startup failures in production.
267        if prewarm_persistence_postcondition_failed(stats.fresh_compiles, stats.engine_count_after)
268        {
269            tracing::error!(
270                worker_id = id,
271                gpu_device = config.device_id,
272                detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
273                fresh_compiles = stats.fresh_compiles,
274                engine_count_before = stats.engine_count_before,
275                engine_count_after = stats.engine_count_after,
276                engine_count_delta = stats.engine_count_delta,
277                cache_path = %engine_cache_dir.display(),
278                "TensorRT pre-warm postcondition failed: \
279                 compile-success events present but no .engine files on disk \
280                 for this SM; TRT EP may be silently failing to persist engine \
281                 plan files (or building plans for a different SM than this \
282                 worker's device)"
283            );
284        } else if prewarm_persistence_suspicious_undercount(
285            stats.fresh_compiles,
286            stats.engine_count_after,
287        ) {
288            // Non-fatal: TRT EP can legitimately reuse a single `.engine`
289            // file across many input shapes (engine plans are keyed by
290            // fused-subgraph identity + precision + GPU SM, not by
291            // `(batch, seq)`). A 1:2 ratio is tolerated silently; this
292            // WARN fires only when delta * 2 < fresh_compiles AND the
293            // postcondition above did not already trigger. Greppable
294            // tag: "engine_count_delta is suspiciously low".
295            tracing::warn!(
296                worker_id = id,
297                gpu_device = config.device_id,
298                detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
299                fresh_compiles = stats.fresh_compiles,
300                engine_count_before = stats.engine_count_before,
301                engine_count_after = stats.engine_count_after,
302                engine_count_delta = stats.engine_count_delta,
303                cache_path = %engine_cache_dir.display(),
304                "TensorRT pre-warm: engine_count_delta is suspiciously low \
305                 relative to fresh_compiles (delta * 2 < fresh_compiles); \
306                 some engine plans may not have persisted to disk despite \
307                 session.run() reporting Ok — investigate cache path \
308                 resolution and EFS mount durability"
309            );
310        }
311        tracing::info!(
312            worker_id = id,
313            detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
314            warmed = stats.warmed,
315            skipped = stats.skipped,
316            fully_cached = stats.fully_cached,
317            fresh_compiles = stats.fresh_compiles,
318            engine_count_before = stats.engine_count_before,
319            engine_count_after = stats.engine_count_after,
320            engine_count_delta = stats.engine_count_delta,
321            total = config.trt_warmup_shapes.len(),
322            total_compile_ms = stats.total_compile_ms,
323            total_fsync_ms = stats.total_fsync_ms,
324            max_warmed_seq = stats.max_warmed_seq,
325            "TensorRT pre-warm complete"
326        );
327
328        // Raise the pool-wide in-band JIT guard ceiling to the highest
329        // sequence tier this worker successfully warmed. With seq-homogeneous
330        // sharding (worker N gets one full seq tier) the union across workers
331        // reconstructs the full grid coverage; a tier that failed on every
332        // worker leaves the ceiling below it so real requests at that tier are
333        // refused (HTTP 503) instead of triggering a process-killing in-band
334        // JIT. See `jit_guard.rs`.
335        let prev_ceiling = config
336            .warmed_seq_ceiling
337            .fetch_max(stats.max_warmed_seq, Ordering::AcqRel);
338        tracing::info!(
339            target: "bge_m3_embedding_server::trt_warmup",
340            worker_id = id,
341            max_warmed_seq = stats.max_warmed_seq,
342            warmed_seq_ceiling = prev_ceiling.max(stats.max_warmed_seq),
343            "in-band JIT guard: warmed-seq ceiling updated after prewarm"
344        );
345
346        // Strict-mode escalation (BGE_M3_PREWARM_STRICT): when the prewarm
347        // postcondition signals that compile-success events occurred but no
348        // engine plan files persisted, refuse to signal ready. The pool's
349        // init task converts the worker error into an init-handle failure,
350        // and `bootstrap::readiness::run_readiness_probe` then triggers a
351        // hard process exit. ECS retries the task, which is the correct
352        // response to "every worker hit TRT autotuner OOM mid-build" rather
353        // than serving HTTP 500 traffic from a known-broken pool.
354        if should_fail_readiness(
355            stats.fresh_compiles,
356            stats.engine_count_after,
357            config.prewarm_strict,
358        ) {
359            tracing::error!(
360                target: "bge_m3_embedding_server::prewarm",
361                worker_id = id,
362                gpu_device = config.device_id,
363                fresh_compiles = stats.fresh_compiles,
364                engine_count_before = stats.engine_count_before,
365                engine_count_after = stats.engine_count_after,
366                cache_path = %trt_cache::engine_cache_path(cache_dir).display(),
367                prewarm_strict = config.prewarm_strict,
368                "Prewarm postcondition failed and prewarm_strict=1: \
369                 refusing to signal ready"
370            );
371            let err = anyhow::anyhow!(
372                "Worker {id} prewarm postcondition failed \
373                 (fresh_compiles={}, engine_count_after={}); \
374                 prewarm_strict=1: refusing to signal ready",
375                stats.fresh_compiles,
376                stats.engine_count_after,
377            );
378            return Err(err);
379        }
380    }
381
382    // Report the RSS delta so EmbedPool can derive the true per-worker
383    // model footprint for workspace-budget calculations.
384    let post_load_rss = sysinfo::read_process_rss_bytes().unwrap_or(pre_load_rss);
385    let rss_delta = post_load_rss.saturating_sub(pre_load_rss);
386    info!(
387        "Worker {id} models loaded — signaling ready (rss_delta_mb={})",
388        rss_delta / (1024 * 1024)
389    );
390    let _ = rt.block_on(ready_tx.send(Ok(rss_delta)));
391
392    Ok(StartupOutcome {
393        initial_models,
394        detected_sm,
395    })
396}