Skip to main content

bge_m3_embedding_server/embedder/
jit_guard.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//! In-band `TensorRT` JIT admission guard.
16//!
17//! ## The failure this prevents
18//!
19//! When a chunk shape `(batch, seq)` reaches `session.run()` that the worker's
20//! `TensorRT` engine profile does **not** already cover, the TRT EP compiles
21//! an engine for it in-band (in the middle of a real request). On the fused
22//! dual-output `/v1/embeddings:both` graph at the maximum sequence length
23//! (`seq = 8192`) the kernel autotuner can request *pathological* scratch
24//! allocations - tens of gigabytes up to multiple terabytes on a single
25//! `LayerNorm + MatMul` foreign node. `BGE_M3_TRT_MAX_WORKSPACE_BYTES` does
26//! **not** bound autotuner tactic scratch (a TRT EP limitation), so on a
27//! VRAM-saturated device (e.g. the warmup-shard worker already holding the
28//! `seq=8192` engines at 90%+ VRAM) the CUDA allocator faults and the process
29//! dies via SIGSEGV / OOM-kill **before** any `Result` is returned. None of
30//! the existing reactive safety nets (`is_trt_jit_oom` retry, the
31//! `is_trt_engine_build_fatal` worker-exit, the circuit breaker) can catch a
32//! hard process death - the only defense is to never issue the dangerous run.
33//!
34//! Startup warmup *catches* a failed compile (`run_warmup_shape` logs a WARN
35//! and continues) so a worker whose `seq=8192` shard failed to compile still
36//! signals ready. The first real `seq≈8192` request then triggers the same
37//! pathological allocation in-band, without warmup's caught-error safety net.
38//!
39//! ## The guard
40//!
41//! [`TrtJitGuard`] refuses - with a clean, retriable error that maps to HTTP
42//! `503` - any chunk whose sequence length is in the dangerous range
43//! (`seq >= guard_seq`) and is **not** already covered by the pool's warmed
44//! engine profile (`seq > warmed_seq_ceiling`). Refusing one request is
45//! strictly better than a SIGSEGV that kills every in-flight request on the
46//! worker and forces an ECS task replacement.
47//!
48//! `warmed_seq_ceiling` is the maximum sequence length **any** worker in the
49//! pool successfully warmed (fresh compile or warm-cache hit), shared via an
50//! `AtomicUsize`. Because TRT engine plans live on the shared EFS cache and a
51//! single profile-based engine file spans `[min_seq, max_seq]` across every
52//! shape compiled to it, a successful warmup of `seq=8192` by *any* worker
53//! means *every* worker can fast-load (not JIT) that shape - so the ceiling is
54//! a sound pool-wide coverage signal. Conversely, if the `seq=8192` shard
55//! failed on every worker, no plan exists on disk, the ceiling stays at the
56//! highest tier that *did* compile (e.g. 2048), and `seq=8192` requests are
57//! refused instead of crashing the process.
58//!
59//! ## Why sequence length (not batch) is the discriminator
60//!
61//! The pathological allocation scales with the attention score matrix
62//! (`O(batch · seq^2)`), which is dominated by `seq` at the top tier. Within a
63//! compiled profile, intermediate batches are covered by the engine's
64//! `[min_batch, max_batch]` range, and `bin_pack` already bounds the per-chunk
65//! batch under the workspace budget (so `seq=8192` chunks never exceed
66//! ~15-18 texts). The only reachable uncovered-and-dangerous region is "a
67//! sequence length tier that warmup failed to compile", which is exactly what
68//! the ceiling tracks.
69//!
70//! ## Self-healing
71//!
72//! The adaptive-warmup loop and cross-worker engine propagation both raise the
73//! ceiling (via [`fetch_max`](std::sync::atomic::AtomicUsize::fetch_max)) when
74//! they successfully compile a higher tier during an idle window, so coverage
75//! that was refused at startup is admitted again once a plan lands on disk.
76
77use std::fmt;
78
79/// Error returned when [`TrtJitGuard`] refuses a chunk to avoid a pathological
80/// in-band `TensorRT` JIT compile.
81///
82/// Carries the offending `(batch, seq)` and the coverage parameters so the
83/// refusal is fully diagnosable in logs. Maps to HTTP `503 Service
84/// Unavailable` (see `crate::error::AppError`'s `From<anyhow::Error>` impl):
85/// the request is *retriable* - coverage may extend via adaptive warmup, or a
86/// peer task may already cover the shape.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub(crate) struct TrtJitRejection {
89    /// Chunk batch size (number of texts in the refused `session.run()` call).
90    pub batch: usize,
91    /// Chunk (padded) sequence length that triggered the refusal.
92    pub seq: usize,
93    /// `guard_seq` threshold in effect at refusal time.
94    pub guard_seq: usize,
95    /// Pool-wide max successfully-warmed sequence length at refusal time.
96    pub warmed_seq_ceiling: usize,
97}
98
99impl fmt::Display for TrtJitRejection {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        write!(
102            f,
103            "refusing in-band TensorRT JIT for chunk (batch={}, seq={}): \
104             seq is at/above the guard threshold ({}) and exceeds the pool's \
105             warmed engine coverage (max warmed seq={}). Issuing this run risks \
106             a pathological autotuner allocation that can crash the worker. \
107             The request is retriable once warmup coverage extends.",
108            self.batch, self.seq, self.guard_seq, self.warmed_seq_ceiling
109        )
110    }
111}
112
113impl std::error::Error for TrtJitRejection {}
114
115/// Per-request snapshot of the in-band JIT admission policy.
116///
117/// Cheap to construct (two `usize`s); the worker builds one per request from
118/// the live `warmed_seq_ceiling` atomic so the decision always reflects the
119/// latest pool-wide coverage. A `None` guard at the call sites disables
120/// checking entirely (non-TRT EPs, or `BGE_M3_TRT_INBAND_JIT_GUARD=0`).
121#[derive(Debug, Clone, Copy)]
122pub(crate) struct TrtJitGuard {
123    guard_seq: usize,
124    warmed_seq_ceiling: usize,
125}
126
127impl TrtJitGuard {
128    /// Builds a guard from the danger threshold and the current pool-wide
129    /// warmed-sequence ceiling.
130    #[must_use]
131    pub(crate) fn new(guard_seq: usize, warmed_seq_ceiling: usize) -> Self {
132        Self {
133            guard_seq,
134            warmed_seq_ceiling,
135        }
136    }
137
138    /// Decides whether a single chunk `(batch, seq)` may be dispatched to
139    /// `session.run()`.
140    ///
141    /// Refuses iff the sequence length is in the dangerous range
142    /// (`seq >= guard_seq`) **and** is not covered by the warmed profile
143    /// (`seq > warmed_seq_ceiling`). Everything else is admitted:
144    ///
145    /// * `seq < guard_seq` - below the dangerous tier; a cold JIT here is
146    ///   bounded and lets the profile grow naturally.
147    /// * `seq <= warmed_seq_ceiling` - covered by an existing engine plan;
148    ///   the run is a cache hit / fast disk-load, never a pathological JIT.
149    pub(crate) fn admit(&self, batch: usize, seq: usize) -> Result<(), TrtJitRejection> {
150        if seq >= self.guard_seq && seq > self.warmed_seq_ceiling {
151            return Err(TrtJitRejection {
152                batch,
153                seq,
154                guard_seq: self.guard_seq,
155                warmed_seq_ceiling: self.warmed_seq_ceiling,
156            });
157        }
158        Ok(())
159    }
160}
161
162/// Validates every chunk produced by `bin_pack` against an optional guard.
163///
164/// Returns `Err(TrtJitRejection)` for the **first** chunk that would trigger a
165/// dangerous in-band JIT, so the whole request is refused atomically before any
166/// `session.run()` executes (no partially-computed output). A `None` guard
167/// admits everything.
168///
169/// `chunks` are the original-index groups returned by
170/// [`crate::binpack::bin_pack`]; `seq_lens` is the per-text tokenized length
171/// (indexed by original position). The per-chunk shape is
172/// `(chunk.len(), max(seq_lens[i] for i in chunk))`.
173pub(crate) fn guard_chunks(
174    guard: Option<&TrtJitGuard>,
175    chunks: &[Vec<usize>],
176    seq_lens: &[usize],
177) -> Result<(), TrtJitRejection> {
178    let Some(guard) = guard else {
179        return Ok(());
180    };
181    for chunk in chunks {
182        let chunk_seq = chunk.iter().map(|&i| seq_lens[i]).max().unwrap_or(0);
183        guard.admit(chunk.len(), chunk_seq)?;
184    }
185    Ok(())
186}
187
188/// Returns `true` when `err` (or anything in its source chain) is a
189/// [`TrtJitRejection`].
190///
191/// The worker uses this to (a) skip the inference circuit breaker for guard
192/// refusals - a refusal means the worker is *healthy* and deliberately
193/// protecting itself, not a failing GPU - and (b) the HTTP layer uses the same
194/// chain walk to map the refusal to `503` rather than `500`. Walking the chain
195/// (not just the top error) lets callers wrap the rejection with
196/// `anyhow::Error::context` for additional log context without breaking
197/// detection.
198pub(crate) fn is_trt_shape_rejected(err: &anyhow::Error) -> bool {
199    err.chain()
200        .any(|cause| cause.downcast_ref::<TrtJitRejection>().is_some())
201}
202
203#[cfg(test)]
204mod tests;