Skip to main content

bge_m3_embedding_server/embedder/worker/
trt_retry.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//! TRT JIT-OOM detection and single retry with halved workspace budget.
16
17use crate::binpack::CostModel;
18
19/// Mirrors `trt_warmup::CACHE_HIT_THRESHOLD_MS`.  Used to classify a
20/// per-request inference as a probable TRT engine cache miss so the
21/// adaptive warmup task can proactively compile the engine.
22pub(super) const CHUNK_CACHE_HIT_THRESHOLD_MS: u64 = 5_000;
23
24/// Returns `true` when an ORT error string indicates a TRT JIT workspace
25/// overflow — a condition that may resolve with a smaller batch or halved
26/// workspace budget.
27///
28/// Patterns verified against ORT 2.0.0-rc.12 `TensorRT` EP
29/// (`ort/src/ep/tensorrt.rs`). Re-verify on every ORT version bump.
30///
31/// # Patterns matched
32///
33/// 1. **`user allocator error`** — direct CUDA allocation failure surfaced
34///    by ORT's user-allocator shim during TRT kernel autotuning.
35/// 2. **`could not find any implementation` + (`workspace` | `alloc`)** —
36///    TRT kernel-autotuner declared no tactic fits, *and* the qualifier
37///    confirms the cause is allocation-driven (otherwise this string also
38///    matches genuine unsupported-op cases where retry is pointless).
39/// 3. **`failed to create engine` + (`workspace` | `alloc` | `memory` |
40///    `oom` | `tactic`)** — TRT EP build-time failure observed in
41///    production on large fused-route requests (e.g. `/v1/embeddings:both`
42///    with high batch and token counts). The qualifier is mandatory: without
43///    it, this same family also
44///    covers unsupported-op and corrupted-cache cases where retrying
45///    with a halved workspace is pointless and doubles caller-visible
46///    latency. `alloc` subsumes `cuMemAlloc`; `memory` subsumes
47///    `out of memory`.
48///
49/// # Known gap
50///
51/// The verbatim production error string often does NOT include any
52/// qualifier — the TRT logger appears to emit workspace/alloc detail to a
53/// separate tracing target rather than propagating it into the outer
54/// `Status Message`. We chose **Option A** here (require a qualifier) over
55/// **Option B** (retry every `failed to create engine` unconditionally) so
56/// we don't regress `does_not_match_unsupported`-style cases. If a follow-up
57/// `CloudWatch` investigation confirms the TRT root-cause is reliably
58/// surfaced only in a sibling `target=ort` event and never in the embed
59/// error string, we may need to relax this to Option B (or pipe the TRT
60/// logger output into the embed error chain). Until then, this function
61/// will continue to return `false` for the verbatim production message and
62/// callers will see HTTP 500 on first build failure.
63///
64/// Tracking: <https://github.com/Fulton-Engineering-Services/bge-m3-embedding-server/issues/78>
65pub(super) fn is_trt_jit_oom(e: &anyhow::Error) -> bool {
66    let s = format!("{e}");
67    let lowercase = s.to_lowercase();
68    // "User allocator error" = direct CUDA allocation failure during TRT kernel autotuning.
69    // "Could not find any implementation" qualifies only when the underlying cause is an
70    // allocation failure (workspace or alloc in the message); without this qualifier it also
71    // matches genuine unsupported-layer errors where retry is pointless and doubles latency.
72    // "Failed to create engine" qualifies only when paired with a workspace/alloc/memory/oom/
73    // tactic keyword for the same reason — see the doc-comment above.
74    lowercase.contains("user allocator error")
75        || (lowercase.contains("could not find any implementation")
76            && (lowercase.contains("workspace") || lowercase.contains("alloc")))
77        || (lowercase.contains("failed to create engine")
78            && (lowercase.contains("workspace")
79                || lowercase.contains("alloc")
80                || lowercase.contains("memory")
81                || lowercase.contains("oom")
82                || lowercase.contains("tactic")))
83}
84
85/// Returns `true` when an ORT error string indicates a TRT engine build
86/// failure severe enough that the worker should exit rather than retry.
87///
88/// Patterns matched:
89///
90/// 1. **`failed to build engine`** — top-level TRT engine build failure,
91///    typically produced by `IBuilder::buildSerializedNetwork` on a corrupted
92///    CUDA context or builder network state.
93/// 2. **`failed to create engine from network`** — TRT network-level
94///    builder failure (distinct from the per-kernel `failed to create engine`
95///    OOM messages that `is_trt_jit_oom` already catches). This pattern
96///    indicates the TRT engine builder itself is in an unrecoverable state;
97///    halving the workspace and retrying will not help.
98///
99/// Unlike [`is_trt_jit_oom`], these patterns are matched **without** an
100/// additional qualifier because they refer to different failure modes.
101/// `failed to create engine from network` is always fatal regardless of
102/// the surrounding context. `failed to build engine` may overlap with the
103/// OOM retry patterns; we detect it here only when `is_trt_jit_oom` has
104/// already returned `false` (and the retry was therefore skipped).
105///
106/// When this function returns `true` from within `run_worker`, the worker
107/// exits immediately (returns `Err`), causing `WorkerGuard` to decrement
108/// `live_workers`. ECS replaces the task once all workers have exited,
109/// resetting the CUDA driver state.
110///
111/// The unqualified `failed to create engine from network` pattern is included
112/// without an OOM qualifier because it has been observed in practice as the
113/// terminal error after the TRT autotuner exhausts its tactic candidates —
114/// typically because of a pathological scratch-buffer allocation request that
115/// the CUDA allocator cannot satisfy. The TRT EP's `trt_max_workspace_bytes`
116/// option does not bound autotuner tactic scratch, so a per-tactic allocation
117/// of many gigabytes (or even terabytes) on a fused multi-precision foreign
118/// node is reachable for shapes outside the pre-warmed engine cache. Once
119/// this pattern fires, the CUDA context is considered unrecoverable for the
120/// lifetime of the process; keep this function's pattern set minimal and
121/// explicit and do not fold it into `is_trt_jit_oom`.
122pub(super) fn is_trt_engine_build_fatal(e: &anyhow::Error) -> bool {
123    // `{e:#}` renders the full anyhow source chain (context + cause), so this
124    // detection still fires when the caller has wrapped the underlying ORT
125    // error with `anyhow::Error::context(...)` (as `run_worker` does, e.g.
126    // "Dual embed error: <original>"). A plain `{e}` would show only the
127    // outermost context string and miss the build-failure substring.
128    let lowercase = format!("{e:#}").to_lowercase();
129    lowercase.contains("failed to build engine")
130        || lowercase.contains("failed to create engine from network")
131}
132/// Wraps an embed call with the standard TRT JIT-OOM retry-once-with-halved-budget
133/// pattern.
134///
135/// If `embed_fn` fails and [`is_trt_jit_oom`] matches the error, retries once
136/// with `max_workspace_bytes / 2`. Logs `trt_jit_retry` on the first attempt and
137/// `trt_jit_retry_exhausted` when the retry also fails. Returns the final result.
138pub(super) fn embed_with_trt_retry<T, F>(
139    mut embed_fn: F,
140    base_cm: &CostModel,
141    worker_id: usize,
142    route: &'static str,
143) -> anyhow::Result<T>
144where
145    F: FnMut(&CostModel) -> anyhow::Result<T>,
146{
147    match embed_fn(base_cm) {
148        Ok(v) => Ok(v),
149        Err(e) if is_trt_jit_oom(&e) => {
150            let halved = CostModel {
151                // Floor at 1 MiB to prevent integer-division from reaching 0
152                // when max_workspace_bytes is very small (e.g. in tests).
153                max_workspace_bytes: (base_cm.max_workspace_bytes / 2).max(1024 * 1024),
154                ..*base_cm
155            };
156            tracing::warn!(
157                worker_id,
158                route,
159                original_workspace_mb = base_cm.max_workspace_bytes / (1024 * 1024),
160                halved_workspace_mb = halved.max_workspace_bytes / (1024 * 1024),
161                error = %e,
162                "trt_jit_retry"
163            );
164            embed_fn(&halved).map_err(|e2| {
165                tracing::error!(
166                    worker_id,
167                    route,
168                    error = %e2,
169                    "trt_jit_retry_exhausted"
170                );
171                e2
172            })
173        }
174        Err(e) => Err(e),
175    }
176}