Skip to main content

bge_m3_embedding_server/embedder/
trt_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//! `TensorRT` engine pre-warming: compiles and caches engine files during
16//! worker startup so the first real request hits a cached engine instead
17//! of triggering an on-demand 30–170 s compile.
18//!
19//! ## Durability
20//!
21//! After each shape compiles, the engine cache directory is fsynced so an
22//! unexpected SIGKILL (ECS OOM-kill, host crash) cannot strand a
23//! partially-written engine plan in the page cache.  See `trt_cache.rs`.
24//!
25//! ## Cache-hit fast path (warm cache skip)
26//!
27//! ORT's TRT EP caches engines with per-dimension `[min, max]` ranges — not
28//! one engine per shape.  A `session.run()` is a cache **hit** (fast, no
29//! compile) when every input dimension falls within the cached `[min, max]`
30//! range; it is a cache **miss** (slow compile) only when a dimension falls
31//! outside that range and the engine must be rebuilt with an extended range.
32//!
33//! After a full first-deploy warmup sweep, the cached profile records:
34//! `input_ids.dim_0 ∈ [min_batch, max_batch]` and
35//! `input_ids.dim_1 ∈ [min_seq, max_seq]` — covering every shape in the
36//! warmup grid.  On subsequent container starts, every warmup
37//! `session.run()` is a cache hit and finishes in ≤ 3 s.
38//!
39//! Rather than paying 24 × 1–3 s = 24–72 s of redundant cache-hit loads,
40//! `trt_prewarm` runs at most **4 "dimensional-extreme" shapes** (the shapes
41//! that exercise the minimum and maximum of each input dimension
42//! independently) and measures wall-clock time.  If all extremes complete
43//! under [`CACHE_HIT_THRESHOLD_MS`], the profile is guaranteed to cover the
44//! entire shard and the remaining shapes are skipped.
45//!
46//! ### Why this has zero false positives
47//!
48//! For shape `(b, s)` to be a TRT cache hit it must satisfy:
49//! ```text
50//! profile.min_batch ≤ b ≤ profile.max_batch   (batch dimension)
51//! profile.min_seq   ≤ s ≤ profile.max_seq      (sequence dimension)
52//! ```
53//!
54//! The four extreme shapes bound all four inequalities independently:
55//!
56//! | Check shape          | Fact established when it is a cache hit  |
57//! |----------------------|------------------------------------------|
58//! | `(min_batch, any_s)` | `profile.min_batch ≤ min_batch`          |
59//! | `(max_batch, any_s)` | `profile.max_batch ≥ max_batch`          |
60//! | `(any_b, min_seq)`   | `profile.min_seq   ≤ min_seq`            |
61//! | `(any_b, max_seq)`   | `profile.max_seq   ≥ max_seq`            |
62//!
63//! Together these four facts guarantee that every shard shape `(b, s)` with
64//! `b ∈ [min_batch, max_batch]` and `s ∈ [min_seq, max_seq]` is a cache
65//! hit.  If any extreme shape is **slow** (≥ `CACHE_HIT_THRESHOLD_MS`) the
66//! engine must be rebuilt for that dimension → the fast path is suppressed
67//! and all remaining shapes are compiled normally.
68
69use std::path::Path;
70
71use super::trt_cache;
72
73mod postcondition;
74mod runner;
75#[cfg(test)]
76mod tests;
77
78pub(super) use postcondition::{
79    prewarm_persistence_postcondition_failed, prewarm_persistence_suspicious_undercount,
80};
81// Constants are re-exported only for the sibling `tests` module; production
82// callers reach them transitively through the postcondition predicates above.
83#[cfg(test)]
84pub(super) use postcondition::SUSPICIOUS_UNDERCOUNT_MIN_FRESH;
85use runner::run_warmup_shape;
86
87/// Threshold (ms) below which a `session.run()` is classified as a TRT
88/// engine cache hit (loaded from disk) rather than a fresh compile.
89///
90/// Cold compiles take 30 000–170 000 ms; warm cache loads finish in
91/// ≤ 3 000 ms even for `32 × 8192` shapes.  A 5 000 ms threshold gives a
92/// comfortable margin above the observed maximum warm-load time while
93/// remaining well below the minimum observed cold-compile time.
94pub(super) const CACHE_HIT_THRESHOLD_MS: u64 = 5_000;
95
96/// Aggregate per-worker statistics returned by [`trt_prewarm`].
97///
98/// `total_compile_ms` and `total_fsync_ms` sum across only the shapes that
99/// completed successfully on this worker's shard, whether they were cache
100/// hits or fresh compiles.  They are intended for the `"TensorRT pre-warm
101/// complete"` summary log emitted by the worker.
102pub(super) struct PrewarmStats {
103    pub warmed: usize,
104    pub total_compile_ms: u64,
105    pub total_fsync_ms: u64,
106    /// `true` when the dimensional-extreme coverage check determined the
107    /// entire shard was already cached and the remaining shapes were skipped.
108    /// `false` on cold cache, on a fresh compile, or when the check phase
109    /// detected at least one slow (≥ `CACHE_HIT_THRESHOLD_MS`) shape.
110    pub fully_cached: bool,
111    /// Number of shapes in the shard that were skipped because
112    /// `fully_cached` was determined to be true.  Zero on cold cache or
113    /// when all shapes were run.
114    pub skipped: usize,
115    /// Number of shapes that reported a fresh compile (`!cache_hit` and
116    /// `Ok(_)` from `session.run()`).  Used together with `engine_count_delta`
117    /// by the worker to decide whether the on-disk artifacts match what the
118    /// per-shape logs claimed.
119    pub fresh_compiles: usize,
120    /// Net `.engine` file count change across this worker's prewarm sweep
121    /// (`count_after_last_shape - count_before_first_shape`). SM-filtered:
122    /// reflects only plans matching the worker's GPU compute capability,
123    /// so a stale `_sm89.engine` next to a fresh `_sm120.engine` does not
124    /// silently zero out the delta on a Blackwell worker. Compared against
125    /// `fresh_compiles` to detect compile-success-without-persistence.
126    pub engine_count_delta: i64,
127    /// `.engine` file count observed in `engine_cache_dir` before the worker
128    /// ran any of its shard's shapes. SM-filtered: counts only plans matching
129    /// the worker's GPU compute capability (see the `sm` parameter on
130    /// [`trt_prewarm`]). When SM detection failed and `sm == None`, falls
131    /// back to the legacy unfiltered count.
132    pub engine_count_before: usize,
133    /// `.engine` file count observed in `engine_cache_dir` after the worker
134    /// finished its shard (post final fsync). SM-filtered with the same
135    /// semantics as `engine_count_before`.
136    pub engine_count_after: usize,
137    /// Largest sequence length among the shapes this worker successfully
138    /// warmed (fresh compile **or** warm-cache hit). Zero when no shape
139    /// succeeded (e.g. every compile failed, the worker-3 `seq=8192` failure
140    /// mode). Folded into the pool-wide `warmed_seq_ceiling` atomic by the
141    /// worker so [`super::jit_guard::TrtJitGuard`] knows the highest sequence
142    /// tier with a persisted engine plan. See `worker.rs`.
143    pub max_warmed_seq: usize,
144}
145
146/// Selects the minimal set of shapes needed to verify that an ORT TRT EP
147/// cached profile covers all shapes in `shapes`.
148///
149/// ORT's TRT EP stores engine profiles as per-dimension `[min, max]` ranges.
150/// Verifying complete coverage requires bounding all four dimension extremes
151/// independently.  This function returns at most 4 representative shapes —
152/// one with `min_batch`, one with `max_batch`, one with `min_seq`, one with
153/// `max_seq` — deduplicated so the same shape is never run twice.
154///
155/// When the shard has only one unique extreme in a dimension (e.g. all
156/// shapes share the same batch size), the duplicates collapse and the
157/// returned set is smaller.
158pub(super) fn coverage_check_shapes(shapes: &[(usize, usize)]) -> Vec<(usize, usize)> {
159    if shapes.is_empty() {
160        return vec![];
161    }
162    let min_batch = shapes.iter().map(|(b, _)| *b).min().expect("non-empty");
163    let max_batch = shapes.iter().map(|(b, _)| *b).max().expect("non-empty");
164    let min_seq = shapes.iter().map(|(_, s)| *s).min().expect("non-empty");
165    let max_seq = shapes.iter().map(|(_, s)| *s).max().expect("non-empty");
166
167    // Pick the first shape in the shard that carries each dimensional extreme.
168    // "First" is stable (same shape list order across workers on the same host)
169    // so logs are reproducible.
170    let rep_min_batch = *shapes
171        .iter()
172        .find(|(b, _)| *b == min_batch)
173        .expect("non-empty");
174    let rep_max_batch = *shapes
175        .iter()
176        .find(|(b, _)| *b == max_batch)
177        .expect("non-empty");
178    let rep_min_seq = *shapes
179        .iter()
180        .find(|(_, s)| *s == min_seq)
181        .expect("non-empty");
182    let rep_max_seq = *shapes
183        .iter()
184        .find(|(_, s)| *s == max_seq)
185        .expect("non-empty");
186
187    // Deduplicate while preserving the discovery order (min_batch → max_batch
188    // → min_seq → max_seq) so the log is consistent across runs.
189    let mut result: Vec<(usize, usize)> = Vec::with_capacity(4);
190    for s in [rep_min_batch, rep_max_batch, rep_min_seq, rep_max_seq] {
191        if !result.contains(&s) {
192            result.push(s);
193        }
194    }
195    result
196}
197
198/// Partitions `shapes` into a per-worker shard using a stride assignment.
199///
200/// Worker `worker_index` receives shapes at positions
201/// `worker_index, worker_index + worker_count, worker_index + 2*worker_count, …`
202/// in the input slice order.
203///
204/// **Why stride and not contiguous blocks?**\
205/// The default warmup grid is ordered batch-major:
206/// `{1,2,4,8,16,32} × {128,512,2048,8192}`. Each consecutive group of four shapes
207/// belongs to one batch size, and within a group the sequence length grows
208/// monotonically. Stride assignment therefore spreads the work so each GPU
209/// receives one shape from each batch group at a different sequence length.
210/// The expensive `_×8192` shapes land on different workers than each other
211/// (e.g. with 4 workers, worker 3 gets all 8192-seq shapes, which compile in
212/// parallel with the cheaper shapes on workers 0–2). Total wall-clock time is
213/// approximately the serial compile time for worker 3's four shapes, compared
214/// to the serial time for all 24 - a rough 4× speedup on 4 GPUs.
215///
216/// Returns all shapes unchanged when `worker_count ≤ 1`.
217pub(super) fn shard_shapes(
218    shapes: &[(usize, usize)],
219    worker_index: usize,
220    worker_count: usize,
221) -> Vec<(usize, usize)> {
222    if worker_count <= 1 {
223        return shapes.to_vec();
224    }
225    shapes
226        .iter()
227        .enumerate()
228        .filter(|(i, _)| i % worker_count == worker_index)
229        .map(|(_, &s)| s)
230        .collect()
231}
232
233/// Runs a dummy `session.run()` for each `(batch, seq)` shape in
234/// `warmup_shapes` so the `TensorRT` EP compiles and caches engine files
235/// before the first real request arrives.
236///
237/// ## SM-aware cache accounting
238///
239/// `sm` selects which engine plans count toward `engine_count_before`,
240/// `engine_count_after`, the coverage-check fast-path trigger, and the
241/// per-shape persistence WARN. Pass `Some("smXY")` (e.g. `Some("sm120")`
242/// for Blackwell) so a heterogeneous cache containing plans for other GPU
243/// compute capabilities — typical when a fleet is mid-deploy or an EFS
244/// volume was previously used by a different instance family — never
245/// produces a false `cache_hit:true` signal. Pass `None` for the legacy
246/// unfiltered behaviour (only when SM detection failed; see the WARN
247/// emitted in `run_worker`).
248///
249/// ## Warm-cache fast path
250///
251/// When `.engine` files **matching `sm`** already exist in the cache
252/// directory, the function first runs only the dimensional-extreme shapes
253/// (≤ 4) to probe whether the cached profile covers the full shard. If all
254/// extreme shapes complete in under [`CACHE_HIT_THRESHOLD_MS`] the
255/// remaining shapes are **skipped** — they are guaranteed to be cache hits
256/// by the range-based ORT TRT EP profile logic (see module-level
257/// documentation for the proof). If any extreme shape is slow the fast path
258/// is suppressed and all remaining shapes are compiled normally.
259///
260/// ## Cold cache
261///
262/// When no `.engine` files matching `sm` exist the coverage-check phase is
263/// bypassed and every shape is compiled in sequence.  Each may take
264/// 30–170 s on the very first deploy; subsequent starts reuse the cached
265/// `.engine` files for this SM.
266///
267/// Progress is logged at `INFO` with `compile_ms`, `fsync_ms`, and
268/// `cache_hit` (whether the run was under `CACHE_HIT_THRESHOLD_MS`) for
269/// each shape.  After each successful run the engine cache directory is
270/// fsynced so the plan file survives an unexpected OOM-kill — see
271/// `trt_cache::fsync_cache_dir`.
272///
273/// Returns aggregate statistics including `fully_cached` (whether the
274/// shard was served entirely from cache **for this SM**) and `skipped`
275/// (shapes not run).
276#[allow(clippy::too_many_lines)]
277pub(super) fn trt_prewarm(
278    session: &mut ort::session::Session,
279    warmup_shapes: &[(usize, usize)],
280    worker_id: usize,
281    cache_dir: &Path,
282    sm: Option<&str>,
283) -> PrewarmStats {
284    let engine_cache_dir = trt_cache::engine_cache_path(cache_dir);
285
286    let mut warmed = 0usize;
287    let mut fresh_compiles = 0usize;
288    let mut total_compile_ms: u64 = 0;
289    let mut total_fsync_ms: u64 = 0;
290    let mut max_warmed_seq = 0usize;
291    let shape_total = warmup_shapes.len();
292    let engine_count_before = trt_cache::count_engine_files_for_sm(&engine_cache_dir, sm);
293
294    if warmup_shapes.is_empty() {
295        return PrewarmStats {
296            warmed: 0,
297            total_compile_ms: 0,
298            total_fsync_ms: 0,
299            fully_cached: false,
300            skipped: 0,
301            fresh_compiles: 0,
302            engine_count_delta: 0,
303            engine_count_before,
304            engine_count_after: engine_count_before,
305            max_warmed_seq: 0,
306        };
307    }
308
309    // ── Coverage-check fast path ──────────────────────────────────────────
310    // If any engines already exist on disk, run only the dimensional-extreme
311    // shapes to determine whether the full shard is already cached.
312    let check_shapes = if engine_count_before > 0 {
313        coverage_check_shapes(warmup_shapes)
314    } else {
315        Vec::new()
316    };
317
318    // Run check shapes (at most 4).
319    let mut shape_idx = 0usize;
320    let mut all_checks_fast = !check_shapes.is_empty(); // false when check_shapes is empty
321    for &(batch, seq) in &check_shapes {
322        shape_idx += 1;
323        let r = run_warmup_shape(
324            session,
325            batch,
326            seq,
327            worker_id,
328            shape_idx,
329            shape_total,
330            &engine_cache_dir,
331            sm,
332        );
333        if r.succeeded {
334            warmed += 1;
335            max_warmed_seq = max_warmed_seq.max(seq);
336            total_compile_ms = total_compile_ms.saturating_add(r.compile_ms);
337            total_fsync_ms = total_fsync_ms.saturating_add(r.fsync_ms);
338            if !r.cache_hit {
339                fresh_compiles += 1;
340            }
341        }
342        if !r.cache_hit || !r.succeeded {
343            all_checks_fast = false;
344        }
345    }
346
347    if all_checks_fast {
348        // Every dimensional extreme was a sub-threshold cache hit: the ORT TRT
349        // EP's stored profile covers the full shard range.  Skip remaining shapes.
350        let skipped = shape_total.saturating_sub(check_shapes.len());
351        tracing::info!(
352            worker_id,
353            checked = check_shapes.len(),
354            skipped,
355            total = shape_total,
356            detected_sm = sm.unwrap_or("unfiltered"),
357            cache_hit_threshold_ms = CACHE_HIT_THRESHOLD_MS,
358            "TensorRT pre-warm: shard fully cached \
359             (all dimensional-extreme checks fast), skipping remaining shapes"
360        );
361        // One final fsync covers sidecar files (timing cache, `.profile`)
362        // that may have been touched during the check phase.
363        trt_cache::fsync_cache_dir(&engine_cache_dir);
364        let engine_count_after = trt_cache::count_engine_files_for_sm(&engine_cache_dir, sm);
365        return PrewarmStats {
366            warmed,
367            total_compile_ms,
368            total_fsync_ms,
369            fully_cached: true,
370            skipped,
371            fresh_compiles,
372            engine_count_delta: i64::try_from(engine_count_after).unwrap_or(i64::MAX)
373                - i64::try_from(engine_count_before).unwrap_or(i64::MAX),
374            engine_count_before,
375            engine_count_after,
376            // The dimensional-extreme checks include the shard's max-seq shape;
377            // a fully-cached shard therefore has coverage up to its max seq.
378            max_warmed_seq,
379        };
380    }
381
382    // ── Full compile path ─────────────────────────────────────────────────
383    // Cold cache OR at least one extreme was slow → run all shapes not
384    // already executed in the check phase.
385    for &(batch, seq) in warmup_shapes {
386        // Skip shapes already run as coverage checks (avoid double-running).
387        if check_shapes.contains(&(batch, seq)) {
388            continue;
389        }
390        shape_idx += 1;
391        let r = run_warmup_shape(
392            session,
393            batch,
394            seq,
395            worker_id,
396            shape_idx,
397            shape_total,
398            &engine_cache_dir,
399            sm,
400        );
401        if r.succeeded {
402            warmed += 1;
403            max_warmed_seq = max_warmed_seq.max(seq);
404            total_compile_ms = total_compile_ms.saturating_add(r.compile_ms);
405            total_fsync_ms = total_fsync_ms.saturating_add(r.fsync_ms);
406            if !r.cache_hit {
407                fresh_compiles += 1;
408            }
409        }
410    }
411
412    // Final sweep covers any sidecar files (timing cache, `.profile`) that
413    // were touched during the warmup but not associated with a single shape.
414    trt_cache::fsync_cache_dir(&engine_cache_dir);
415
416    let engine_count_after = trt_cache::count_engine_files_for_sm(&engine_cache_dir, sm);
417    PrewarmStats {
418        warmed,
419        total_compile_ms,
420        total_fsync_ms,
421        fully_cached: false,
422        skipped: 0,
423        fresh_compiles,
424        engine_count_delta: i64::try_from(engine_count_after).unwrap_or(i64::MAX)
425            - i64::try_from(engine_count_before).unwrap_or(i64::MAX),
426        engine_count_before,
427        engine_count_after,
428        max_warmed_seq,
429    }
430}