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}