1#![warn(missing_docs)]
23#![warn(rustdoc::missing_crate_level_docs)]
24#![warn(rustdoc::unescaped_backticks)]
25#![deny(rustdoc::broken_intra_doc_links)]
26#![deny(rustdoc::invalid_html_tags)]
27#![deny(rustdoc::bare_urls)]
28#![warn(rustdoc::redundant_explicit_links)]
29#![warn(rustdoc::private_doc_tests)]
30
31pub mod binpack;
32pub mod bootstrap;
33pub mod config;
34pub mod embedder;
35pub mod error;
36pub mod gpu_stats;
37pub mod handler;
38pub mod logging;
39pub mod models;
40pub mod probe;
41pub mod state;
42pub mod sysinfo;
43pub mod weights;
44
45use std::path::PathBuf;
46use std::sync::Arc;
47use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
48use std::time::Duration;
49
50use arc_swap::ArcSwap;
51use tokio::sync::Semaphore;
52use tracing::info;
53
54use crate::binpack::CostModel;
55use crate::bootstrap::{build_router, run_readiness_probe};
56use crate::config::{Config, EpSelection};
57use crate::embedder::adaptive_warmup::AdaptiveWarmupConfig;
58use crate::embedder::{EmbedPool, JitSuspectSender, WorkerConfig};
59use crate::gpu_stats::GpuStatsCollector;
60use crate::state::{AppState, ProbeStatus};
61
62pub const WARMUP_POSTCONDITION_FAILED_EXIT_CODE: i32 = 2;
70
71#[must_use]
82pub fn warmup_postcondition_failed(ep: EpSelection, engine_count: usize) -> bool {
83 ep == EpSelection::TensorRt && engine_count == 0
84}
85
86async fn wait_for_workers_to_exit(live_workers: &Arc<AtomicUsize>, timeout: Duration) {
101 let deadline = std::time::Instant::now() + timeout;
102 while live_workers.load(Ordering::Acquire) > 0 {
103 if std::time::Instant::now() >= deadline {
104 tracing::warn!(
105 live_workers = live_workers.load(Ordering::Acquire),
106 timeout_secs = timeout.as_secs(),
107 "warmup-only mode: timed out waiting for workers to exit; \
108 proceeding to fsync and process exit"
109 );
110 return;
111 }
112 tokio::time::sleep(Duration::from_millis(100)).await;
113 }
114}
115
116#[allow(clippy::too_many_lines)]
126pub async fn run() -> anyhow::Result<()> {
127 info!(
128 version = env!("CARGO_PKG_VERSION"),
129 git_sha = env!("BGE_M3_GIT_SHA"),
130 target_arch = std::env::consts::ARCH,
131 target_os = std::env::consts::OS,
132 profile = if cfg!(debug_assertions) {
133 "debug"
134 } else {
135 "release"
136 },
137 "bge-m3-embedding-server build info"
138 );
139
140 #[cfg(feature = "tls")]
156 {
157 rustls::crypto::aws_lc_rs::default_provider()
158 .install_default()
159 .ok();
160 }
161
162 let cfg = Config::from_env()?;
163
164 let disable_probe_cache = std::env::var("BGE_M3_DISABLE_PROBE_CACHE")
165 .is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "yes"));
166
167 info!(
168 bind = %cfg.bind_addr,
169 workers = cfg.workers,
170 max_batch = cfg.max_batch,
171 max_seq_length = cfg.max_seq_length,
172 cache_dir = %cfg.cache_dir,
173 idle_timeout_secs = cfg.idle_timeout.map(|d| d.as_secs()),
174 model_variant = ?cfg.model_variant,
175 memory_safety_factor = cfg.memory_safety_factor,
176 auto_budget = cfg.cost_model_override.is_none(),
177 disable_probe_cache,
178 "Starting bge-m3-embedding-server"
179 );
180
181 let initial_cost_model = cfg
186 .cost_model_override
187 .unwrap_or_else(|| CostModel::conservative(CostModel::DEFAULT_MAX_WORKSPACE));
188 let cost_model_handle = Arc::new(ArcSwap::from_pointee(initial_cost_model));
189
190 let initial_permits = cfg.workers.saturating_sub(1).max(1);
196 let request_permits = Arc::new(Semaphore::new(initial_permits));
197
198 let (jit_suspect_tx, jit_suspect_rx): (
203 Option<JitSuspectSender>,
204 Option<tokio::sync::mpsc::Receiver<(usize, usize)>>,
205 ) = if cfg.adaptive_warmup_enabled && cfg.ep == EpSelection::TensorRt {
206 let (tx, rx) = tokio::sync::mpsc::channel::<(usize, usize)>(64);
207 (Some(tx), Some(rx))
208 } else {
209 if cfg.adaptive_warmup_enabled {
210 tracing::warn!(
211 ep = %cfg.ep,
212 "BGE_M3_ADAPTIVE_WARMUP_ENABLED=1 has no effect when the execution \
213 provider is not TensorRT — adaptive warmup only compiles TRT engine \
214 files. Set BGE_M3_EP=tensorrt or unset BGE_M3_ADAPTIVE_WARMUP_ENABLED."
215 );
216 }
217 (None, None)
218 };
219
220 let engine_propagation_tx = if cfg.engine_propagation_enabled {
226 let (tx, _initial_rx) = tokio::sync::broadcast::channel::<(usize, usize)>(32);
227 Some(tx)
228 } else {
229 None
230 };
231
232 let (pool, init_handle) = EmbedPool::spawn(
233 cfg.workers,
234 PathBuf::from(&cfg.cache_dir),
235 WorkerConfig {
236 cost_model: Arc::clone(&cost_model_handle),
237 idle_timeout: cfg.idle_timeout,
238 model_variant: cfg.model_variant,
239 max_seq_length: cfg.max_seq_length,
240 intra_threads: cfg.intra_threads,
241 ep: cfg.ep,
242 trt_warmup_shapes: cfg.trt_warmup_shapes,
243 device_id: 0,
246 gpu_count: cfg.gpu_count,
247 trt_max_workspace_bytes: cfg.trt_max_workspace_bytes,
248 gpu_mem_limit_bytes: cfg.gpu_mem_limit_bytes,
249 jit_suspect_tx,
250 engine_propagation_tx,
251 prewarm_strict: cfg.prewarm_strict,
252 circuit_breaker_threshold: cfg.circuit_breaker_threshold,
253 trt_inband_jit_guard_enabled: cfg.trt_inband_jit_guard_enabled,
254 trt_inband_jit_guard_seq: cfg.trt_inband_jit_guard_seq,
255 warmed_seq_ceiling: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
260 #[cfg(feature = "cache-gc")]
261 trt_cache_gc_enabled: cfg.trt_cache_gc_enabled,
262 },
263 );
264
265 if cfg.warmup_only {
271 let warmup_hb_handle = if cfg.heartbeat_secs > 0 {
276 let gpu_stats = GpuStatsCollector::init(cfg.gpu_count);
277 let heartbeat_secs = cfg.heartbeat_secs;
278 Some(tokio::spawn(async move {
279 let mut tick = tokio::time::interval(Duration::from_secs(heartbeat_secs));
280 tick.tick().await; loop {
282 tick.tick().await;
283 gpu_stats.emit_heartbeat();
284 }
285 }))
286 } else {
287 None
288 };
289
290 init_handle
291 .await
292 .map_err(|e| anyhow::anyhow!("Worker pool task panicked: {e}"))?
293 .map_err(|e| anyhow::anyhow!("Worker pool initialization failed: {e}"))?;
294
295 if let Some(h) = warmup_hb_handle {
296 h.abort();
297 }
298
299 let cache_dir_path = PathBuf::from(&cfg.cache_dir);
314 let live_workers = pool.live_worker_count();
315 let live_workers_arc = pool.live_workers_for_shutdown();
316 drop(pool);
317
318 wait_for_workers_to_exit(&live_workers_arc, Duration::from_secs(10)).await;
319 let live_workers_after = live_workers_arc.load(Ordering::Acquire);
320 info!(
321 live_workers_before = live_workers,
322 live_workers_after, "warmup-only mode: pool dropped, waited for worker teardown"
323 );
324
325 let engine_cache_dir = crate::embedder::trt_cache::engine_cache_path(&cache_dir_path);
330 crate::embedder::trt_cache::fsync_cache_dir(&engine_cache_dir);
331
332 let trt_info = crate::embedder::trt_cache::ensure_and_inspect(&cache_dir_path);
333
334 let detected_sm: Option<String> = if cfg.ep == EpSelection::TensorRt {
345 crate::embedder::sm_detect::detect_sm_for_device(0)
346 } else {
347 None
348 };
349 let matching_engine_count = crate::embedder::trt_cache::count_engine_files_for_sm(
350 &engine_cache_dir,
351 detected_sm.as_deref(),
352 );
353 info!(
354 target: "bge_m3_embedding_server::trt_cache",
355 ep = %cfg.ep,
356 device_id = 0,
357 detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
358 cache_path = %engine_cache_dir.display(),
359 matching_engine_count,
360 total_engine_count = trt_info.engine_count,
361 "trt cache: SM-filtered engine plan enumeration (warmup-only mode)"
362 );
363
364 if warmup_postcondition_failed(cfg.ep, matching_engine_count) {
365 tracing::error!(
366 matching_engine_count,
367 total_engine_count = trt_info.engine_count,
368 detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
369 cache_path = %trt_info.path.display(),
370 ep = %cfg.ep,
371 "warmup-only postcondition failed: compile-success events \
372 present but no .engine files matching this host's SM on disk; \
373 check TRT EP construction, EFS mount, engine cache path resolution, \
374 and that the warmup container ran on the same GPU family as the \
375 serving fleet"
376 );
377 std::process::exit(WARMUP_POSTCONDITION_FAILED_EXIT_CODE);
378 }
379
380 info!(
381 matching_engine_count,
382 total_engine_count = trt_info.engine_count,
383 profile_count = trt_info.profile_count,
384 detected_sm = detected_sm.as_deref().unwrap_or("unfiltered"),
385 cache_path = %trt_info.path.display(),
386 ep = %cfg.ep,
387 "warmup-only mode: all TRT engines compiled and cached, exiting"
388 );
389 return Ok(());
390 }
391
392 if let Some(rx) = jit_suspect_rx {
397 let adaptive_cfg = AdaptiveWarmupConfig {
398 enabled: cfg.adaptive_warmup_enabled,
399 quiet_secs: cfg.adaptive_warmup_quiet_secs,
400 max_shapes_per_hour: cfg.adaptive_warmup_max_shapes_per_hour,
401 };
402 crate::embedder::adaptive_warmup::spawn_adaptive_warmup(adaptive_cfg, pool.clone(), rx);
403 info!(
404 quiet_secs = cfg.adaptive_warmup_quiet_secs,
405 max_shapes_per_hour = cfg.adaptive_warmup_max_shapes_per_hour,
406 "adaptive warmup task spawned"
407 );
408 }
409
410 let state = Arc::new(AppState {
411 pool,
412 ready: AtomicBool::new(false),
413 max_batch: cfg.max_batch,
414 total_workers: cfg.workers,
415 max_seq_length: cfg.max_seq_length,
416 tuning: std::sync::OnceLock::new(),
417 cost_model: cost_model_handle,
418 probe_status: AtomicU8::new(ProbeStatus::Running as u8),
419 request_permits,
420 });
421
422 let app = build_router(Arc::clone(&state), cfg.max_body_bytes);
423
424 let state_for_readiness = Arc::clone(&state);
425 let cfg_max_seq = cfg.max_seq_length;
426 let cfg_workers = cfg.workers;
427 let cfg_safety = cfg.memory_safety_factor;
428 let cost_model_override = cfg.cost_model_override;
429 let cache_dir = PathBuf::from(&cfg.cache_dir);
430 let model_variant_str = cfg.model_variant.to_string();
431
432 tokio::spawn(async move {
433 if let Err(e) = run_readiness_probe(
434 init_handle,
435 state_for_readiness,
436 cfg_max_seq,
437 cfg_workers,
438 cfg_safety,
439 cost_model_override,
440 cache_dir,
441 model_variant_str,
442 disable_probe_cache,
443 )
444 .await
445 {
446 tracing::error!("{e}");
447 std::process::exit(1);
448 }
449 });
450
451 let heartbeat_secs = cfg.heartbeat_secs;
455 if heartbeat_secs > 0 {
456 let gpu_stats = GpuStatsCollector::init(cfg.gpu_count);
457 let state_hb = Arc::clone(&state);
458 tokio::spawn(async move {
459 let mut tick = tokio::time::interval(Duration::from_secs(heartbeat_secs));
460 tick.tick().await;
463 loop {
464 tick.tick().await;
465 let rss_mb = sysinfo::read_process_rss_bytes().unwrap_or(0) / (1024 * 1024);
466 info!(
467 rss_mb,
468 live_workers = state_hb.pool.live_worker_count(),
469 loaded_workers = state_hb.pool.loaded_worker_count(),
470 queue_depth = state_hb.pool.queue_depth(),
471 available_permits = state_hb.request_permits.available_permits(),
472 probe_status =
473 ProbeStatus::from_u8(state_hb.probe_status.load(Ordering::Acquire))
474 .as_str(),
475 "heartbeat"
476 );
477 gpu_stats.emit_heartbeat();
478 }
479 });
480 }
481
482 #[cfg(feature = "tls")]
483 if let (Some(cert), Some(key)) = (cfg.tls_cert_path.as_ref(), cfg.tls_key_path.as_ref()) {
484 use axum_server::Handle;
485 use axum_server::tls_rustls::RustlsConfig;
486 let tls_config = RustlsConfig::from_pem_file(cert, key)
487 .await
488 .map_err(|e| anyhow::anyhow!("TLS config error: {e}"))?;
489 let addr: std::net::SocketAddr = cfg
490 .bind_addr
491 .parse()
492 .map_err(|e| anyhow::anyhow!("invalid bind addr: {e}"))?;
493 info!(bind = %cfg.bind_addr, mode = "tls", "Listening");
494 let handle = Handle::new();
495 let h = handle.clone();
496 tokio::spawn(async move {
497 tokio::signal::ctrl_c().await.ok();
498 tracing::info!("TLS shutdown signal received, draining connections");
499 h.graceful_shutdown(Some(std::time::Duration::from_secs(30)));
500 });
501 axum_server::bind_rustls(addr, tls_config)
502 .handle(handle)
503 .serve(app.into_make_service())
504 .await?;
505 return Ok(());
506 }
507
508 let listener = tokio::net::TcpListener::bind(&cfg.bind_addr).await?;
509 info!(bind = %cfg.bind_addr, mode = "plain", "Listening");
510 axum::serve(listener, app).await?;
511 Ok(())
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
521 fn postcondition_passes_for_tensorrt_with_engines_present() {
522 assert!(!warmup_postcondition_failed(EpSelection::TensorRt, 1));
523 assert!(!warmup_postcondition_failed(EpSelection::TensorRt, 16));
524 }
525
526 #[test]
527 fn postcondition_fails_for_tensorrt_with_zero_engines() {
528 assert!(
529 warmup_postcondition_failed(EpSelection::TensorRt, 0),
530 "TRT EP + 0 engines must be flagged as a postcondition failure"
531 );
532 }
533
534 #[test]
535 fn postcondition_passes_for_cpu_ep_regardless_of_engine_count() {
536 assert!(!warmup_postcondition_failed(EpSelection::Cpu, 0));
537 assert!(!warmup_postcondition_failed(EpSelection::Cpu, 16));
538 }
539
540 #[test]
541 fn postcondition_passes_for_cuda_ep_regardless_of_engine_count() {
542 assert!(!warmup_postcondition_failed(EpSelection::Cuda, 0));
543 assert!(!warmup_postcondition_failed(EpSelection::Cuda, 16));
544 }
545
546 #[test]
547 fn warmup_postcondition_exit_code_is_distinct_from_general_failure() {
548 assert_eq!(
549 WARMUP_POSTCONDITION_FAILED_EXIT_CODE, 2,
550 "operators rely on exit_code=2 being specific to the warmup-only \
551 postcondition; do not collapse it back into 1"
552 );
553 }
554
555 #[tokio::test(flavor = "current_thread")]
565 async fn wait_for_workers_returns_immediately_when_already_zero() {
566 let live = Arc::new(AtomicUsize::new(0));
567 let start = std::time::Instant::now();
568 wait_for_workers_to_exit(&live, Duration::from_mins(1)).await;
569 assert!(
570 start.elapsed() < Duration::from_millis(200),
571 "wait should return immediately when live_workers is already zero; \
572 elapsed={:?}",
573 start.elapsed()
574 );
575 }
576
577 #[tokio::test(flavor = "current_thread")]
580 async fn wait_for_workers_returns_when_count_reaches_zero() {
581 let live = Arc::new(AtomicUsize::new(2));
582 let live_for_task = Arc::clone(&live);
583 let drainer = tokio::spawn(async move {
584 tokio::time::sleep(Duration::from_millis(250)).await;
585 live_for_task.store(0, Ordering::Release);
586 });
587
588 let start = std::time::Instant::now();
589 wait_for_workers_to_exit(&live, Duration::from_mins(1)).await;
590 let elapsed = start.elapsed();
591 drainer.await.unwrap();
592
593 assert_eq!(live.load(Ordering::Acquire), 0);
594 assert!(
595 elapsed < Duration::from_secs(5),
596 "wait should have returned soon after counter reached zero; \
597 elapsed={elapsed:?}"
598 );
599 }
600
601 #[tokio::test(flavor = "current_thread")]
604 async fn wait_for_workers_times_out_when_count_stays_positive() {
605 let live = Arc::new(AtomicUsize::new(3));
606 let start = std::time::Instant::now();
607 wait_for_workers_to_exit(&live, Duration::from_millis(300)).await;
608 let elapsed = start.elapsed();
609 assert!(
610 elapsed >= Duration::from_millis(280),
611 "wait should have honored the timeout; elapsed={elapsed:?}"
612 );
613 assert!(
614 elapsed < Duration::from_secs(2),
615 "wait should not block past the timeout by more than one poll; \
616 elapsed={elapsed:?}"
617 );
618 assert_eq!(
619 live.load(Ordering::Acquire),
620 3,
621 "live counter must be untouched after timeout"
622 );
623 }
624
625 #[cfg(feature = "tls")]
636 #[tokio::test(flavor = "current_thread")]
637 async fn tls_rustls_config_loads_valid_pem_files() {
638 use axum_server::tls_rustls::RustlsConfig;
639 use rcgen::{CertifiedKey, generate_simple_self_signed};
640
641 rustls::crypto::aws_lc_rs::default_provider()
645 .install_default()
646 .ok();
647
648 let CertifiedKey { cert, signing_key } =
649 generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
650 let cert_pem = cert.pem();
651 let key_pem = signing_key.serialize_pem();
652
653 let cert_file = tempfile::NamedTempFile::new().unwrap();
654 let key_file = tempfile::NamedTempFile::new().unwrap();
655 std::fs::write(cert_file.path(), &cert_pem).unwrap();
656 std::fs::write(key_file.path(), &key_pem).unwrap();
657
658 let result = RustlsConfig::from_pem_file(cert_file.path(), key_file.path()).await;
659 assert!(
660 result.is_ok(),
661 "RustlsConfig::from_pem_file must succeed with valid PEM files"
662 );
663 }
664
665 #[cfg(feature = "tls")]
669 #[tokio::test(flavor = "current_thread")]
670 async fn tls_rustls_config_errors_on_missing_cert_file() {
671 use axum_server::tls_rustls::RustlsConfig;
672
673 rustls::crypto::aws_lc_rs::default_provider()
674 .install_default()
675 .ok();
676
677 let result = RustlsConfig::from_pem_file(
678 "/nonexistent/path/server.crt",
679 "/nonexistent/path/server.key",
680 )
681 .await;
682 assert!(
683 result.is_err(),
684 "RustlsConfig::from_pem_file must return Err for missing files"
685 );
686 }
687}