Skip to main content

bge_m3_embedding_server/embedder/
adaptive_warmup.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//! Adaptive in-process background warmup loop.
16//!
17//! Detects TRT engine cache misses during live inference (shapes whose
18//! total `inference_ms >= CACHE_HIT_THRESHOLD_MS`) and compiles their engines
19//! during idle windows (queue empty for `quiet_secs` consecutive seconds)
20//! so subsequent requests hit the cache.
21//!
22//! ## Integration
23//!
24//! 1. **Before** spawning the worker pool, create a bounded channel with
25//!    [`mpsc::channel::<(usize, usize)>`] (capacity 64 is sufficient).
26//! 2. Store the sender half in [`crate::embedder::WorkerConfig::jit_suspect_tx`].  Workers call
27//!    `try_send((batch, seq))` after any inference whose `inference_ms` exceeds
28//!    the cache-hit threshold.
29//! 3. **After** spawning the pool, call [`spawn_adaptive_warmup`] with the
30//!    receiver half and a clone of the pool.
31//!
32//! The background task accumulates suspected miss shapes, waits for an idle
33//! window, and compiles one shape at a time via [`EmbedPool::send_adaptive_warmup`].
34//! Successfully compiled shapes are not re-submitted in the current session.
35//!
36//! ## Accepted tradeoffs
37//!
38//! **Non-atomic idle detection (L-4):** The `queue_depth == 0` check and the subsequent
39//! `send_adaptive_warmup` call are not atomic. A traffic burst arriving between these two
40//! operations will queue behind the adaptive warmup compile. This is intentional — the
41//! compile runs inside a normal worker slot and the burst simply waits, same as any other
42//! request. Introducing atomic coordination across the pool boundary would add complexity
43//! without meaningful latency improvement.
44//!
45//! **Homogeneous-SM assumption (L-5):** The adaptive warmup dispatches to whichever worker
46//! accepts the message first. For this to benefit all workers the instance must have a
47//! homogeneous GPU SM version (e.g. all L40S `sm_89`) so the compiled engine plan on EFS
48//! is valid for every worker on restart. Mixed-SM deployments (e.g. mixing g6e and g5
49//! instances in the same ASG) will compile plans only for the SM of whichever worker runs
50//! first. See CLAUDE.md "TRT plans are compute-capability-specific" for the full constraint.
51
52use std::collections::HashSet;
53use std::time::{Duration, Instant};
54
55use tokio::sync::mpsc;
56
57use super::pool::EmbedPool;
58
59/// Configuration for the adaptive background warmup task.
60pub(crate) struct AdaptiveWarmupConfig {
61    /// Whether adaptive warmup is enabled. When `false`, [`spawn_adaptive_warmup`]
62    /// is a no-op.
63    pub enabled: bool,
64    /// Seconds of continuous idle (zero queue depth) required before the task
65    /// fires a warmup shape. Prevents warmup interference with live inference.
66    /// When `0`, the quiet-window check is skipped entirely.
67    pub quiet_secs: u64,
68    /// Maximum number of shapes the task will compile per rolling hour. Acts as
69    /// a rate-limiter to prevent runaway warmup loops on high-traffic deployments.
70    /// When `0`, adaptive warmup is enabled but no shapes will be compiled — a
71    /// warning is emitted at startup.
72    pub max_shapes_per_hour: u32,
73}
74
75/// Spawns the adaptive background warmup task.
76///
77/// Must be called **after** [`EmbedPool::spawn`] so a pool clone is available.
78/// The caller creates the JIT suspect channel before spawning the pool and
79/// passes the receiver half here; the sender half is stored in
80/// [`crate::embedder::WorkerConfig::jit_suspect_tx`].
81///
82/// Does nothing if `config.enabled` is `false`.
83pub(crate) fn spawn_adaptive_warmup(
84    config: AdaptiveWarmupConfig,
85    pool: EmbedPool,
86    rx: mpsc::Receiver<(usize, usize)>,
87) {
88    if !config.enabled {
89        return;
90    }
91    if config.max_shapes_per_hour == 0 {
92        tracing::warn!(
93            "BGE_M3_ADAPTIVE_WARMUP_MAX_SHAPES_PER_HOUR=0 — adaptive warmup is enabled \
94             but the per-hour budget is zero; no shapes will be compiled. \
95             Set to a positive value or unset to use the default of 12."
96        );
97    }
98    tokio::spawn(async move {
99        run_adaptive_warmup_loop(config, pool, rx).await;
100    });
101}
102
103async fn run_adaptive_warmup_loop(
104    config: AdaptiveWarmupConfig,
105    pool: EmbedPool,
106    mut rx: mpsc::Receiver<(usize, usize)>,
107) {
108    let mut pending: indexmap::IndexSet<(usize, usize)> = indexmap::IndexSet::new();
109    let mut warmed: HashSet<(usize, usize)> = HashSet::new();
110    let mut shapes_this_hour: u32 = 0;
111    // NOTE (COR-6): hour_start uses std::time::Instant, which is NOT controlled
112    // by tokio::time::pause() in tests. The hourly reset logic is therefore
113    // validated via direct arithmetic in unit tests rather than virtual-time
114    // advancement. See tests/loop_control.rs.
115    let mut hour_start = Instant::now();
116
117    loop {
118        // Drain the JIT-suspect channel into pending (non-blocking).
119        drain_rx(&mut rx, &mut pending, &warmed);
120
121        // Reset hourly compile budget.
122        if hour_start.elapsed() >= Duration::from_hours(1) {
123            shapes_this_hour = 0;
124            hour_start = Instant::now();
125        }
126
127        // Nothing actionable right now — wait for new suspects or budget reset.
128        if pending.is_empty() || shapes_this_hour >= config.max_shapes_per_hour {
129            tokio::select! {
130                maybe = rx.recv() => {
131                    if let Some(shape) = maybe {
132                        if !warmed.contains(&shape) && !pending.contains(&shape) {
133                            pending.insert(shape);
134                        }
135                    } else {
136                        tracing::info!(
137                            "adaptive_warmup: JIT suspect channel closed, exiting"
138                        );
139                        return;
140                    }
141                }
142                () = tokio::time::sleep(Duration::from_secs(5)) => {}
143            }
144            continue;
145        }
146
147        // Wait until the request queue empties.
148        if pool.queue_depth() > 0 {
149            tokio::time::sleep(Duration::from_secs(1)).await;
150            continue;
151        }
152
153        // Confirm idle for `quiet_secs` consecutive seconds before firing.
154        // Accepted tradeoff (L-4): the queue_depth == 0 check here and the
155        // subsequent send_adaptive_warmup call are not atomic. A traffic burst
156        // arriving between these two operations queues behind the compile —
157        // the burst waits in the normal worker slot, same as any other request.
158        // See module-level doc comment for the full rationale.
159        let confirmed_idle =
160            wait_for_quiet_window(config.quiet_secs, &pool, &mut rx, &mut pending, &warmed).await;
161        if !confirmed_idle {
162            continue;
163        }
164
165        // Pop and fire one shape. The pending.is_empty() guard above ensures
166        // pending is non-empty here, so first() always returns Some.
167        if let Some(&shape) = pending.first() {
168            let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
169            if pool
170                .send_adaptive_warmup(shape.0, shape.1, ack_tx)
171                .await
172                .is_err()
173            {
174                tracing::warn!("adaptive_warmup: pool channel closed, exiting");
175                return;
176            }
177            match ack_rx.await {
178                Ok(Ok(compile_ms)) => {
179                    pending.shift_remove(&shape);
180                    warmed.insert(shape);
181                    shapes_this_hour += 1;
182                    // Broadcast only when compile_ms > 0, which indicates a
183                    // TRT worker actually compiled or confirmed the engine from
184                    // disk. Non-TRT workers return Ok(0) immediately (no plan
185                    // written to EFS), so broadcasting for Ok(0) would send a
186                    // spurious engine-ready notification (COR-7).
187                    if compile_ms > 0 {
188                        pool.broadcast_engine_ready(shape);
189                    }
190                    tracing::info!(
191                        batch = shape.0,
192                        seq = shape.1,
193                        compile_ms,
194                        shapes_this_hour,
195                        "adaptive_warmup_complete"
196                    );
197                }
198                Ok(Err(ref e)) => {
199                    tracing::warn!(
200                        batch = shape.0,
201                        seq = shape.1,
202                        error = %e,
203                        "adaptive_warmup_failed"
204                    );
205                    // Remove to avoid infinite retry in this session.
206                    pending.shift_remove(&shape);
207                }
208                Err(_) => {
209                    tracing::warn!(
210                        batch = shape.0,
211                        seq = shape.1,
212                        "adaptive_warmup_ack_dropped"
213                    );
214                    pending.shift_remove(&shape);
215                }
216            }
217        }
218    }
219}
220
221/// Drains any available items from `rx` into `pending`, skipping already-warmed
222/// and already-pending shapes. Non-blocking: returns as soon as `try_recv` fails.
223fn drain_rx(
224    rx: &mut mpsc::Receiver<(usize, usize)>,
225    pending: &mut indexmap::IndexSet<(usize, usize)>,
226    warmed: &HashSet<(usize, usize)>,
227) {
228    while let Ok(shape) = rx.try_recv() {
229        if !warmed.contains(&shape) && !pending.contains(&shape) {
230            pending.insert(shape);
231        }
232    }
233}
234
235/// Waits until the pool queue has been idle for `quiet_secs` consecutive seconds.
236///
237/// Continuously drains the JIT-suspect channel into `pending` while waiting.
238/// Returns `true` if the quiet window was reached, `false` if the queue became
239/// busy again and we should re-enter the outer idle-check loop.
240///
241/// When `quiet_secs` is `0`, returns `true` immediately without sleeping.
242async fn wait_for_quiet_window(
243    quiet_secs: u64,
244    pool: &EmbedPool,
245    rx: &mut mpsc::Receiver<(usize, usize)>,
246    pending: &mut indexmap::IndexSet<(usize, usize)>,
247    warmed: &HashSet<(usize, usize)>,
248) -> bool {
249    // quiet_secs == 0: return ready immediately (no sleep required)
250    if quiet_secs == 0 {
251        return true;
252    }
253
254    let mut consecutive_idle: u64 = 0;
255    loop {
256        tokio::time::sleep(Duration::from_secs(1)).await;
257        drain_rx(rx, pending, warmed);
258
259        if pool.queue_depth() == 0 {
260            consecutive_idle += 1;
261            if consecutive_idle >= quiet_secs {
262                return true;
263            }
264        } else {
265            // Queue became busy — abort this quiet-window check.
266            return false;
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests;