Skip to main content

bge_m3_embedding_server/embedder/
sm_detect.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//! Per-device GPU compute-capability detection for SM-aware TRT cache filtering.
16//!
17//! ORT names every TRT engine plan with a `_smXX` suffix tied to the GPU's
18//! compute capability (`sm75` = T4, `sm86` = A10G, `sm89` = L40S/L4, `sm120`
19//! = Blackwell). Plans built for one SM cannot be loaded by another — the TRT
20//! runtime silently refuses them and JIT-compiles instead. Filtering the
21//! engine cache by the worker's own SM is the only way to produce a truthful
22//! `cache_hit` signal on heterogeneous-SM fleets (or on fresh hosts where a
23//! previous-SM cache survives on EFS).
24//!
25//! Detection mechanism: shell out to
26//! `nvidia-smi --query-gpu=compute_cap --format=csv,noheader -i <device_id>`.
27//! The subprocess is cheap (single-digit ms) and avoids pulling in a CUDA
28//! driver crate that the project does not otherwise need. The parser is a
29//! pure function so the bulk of the surface area can be unit-tested without
30//! a GPU.
31//!
32//! Failure modes (missing `nvidia-smi`, non-zero exit, parse error) return
33//! `None` and the caller falls back to the legacy unfiltered behaviour so
34//! operators rolling forward mid-deploy never see a hard regression.
35
36/// Returns the GPU compute capability as a `smXY` string for the given CUDA
37/// device, or `None` if detection fails for any reason.
38///
39/// The format is the same one ORT uses in its engine plan basenames (e.g.
40/// `_sm120.engine`), so the return value can be plugged directly into the
41/// SM-filtered enumerators in [`super::trt_cache`].
42///
43/// This function is a thin wrapper around `nvidia-smi`. It is intended to be
44/// called once per worker at TRT prewarm time and the result cached; do not
45/// call it on every request.
46///
47/// Failure-mode contract:
48///
49/// * `nvidia-smi` binary missing or unexecutable → `None`
50/// * subprocess exits non-zero → `None`
51/// * stdout cannot be parsed as `"X.Y"` → `None`
52///
53/// All failure cases are non-panicking. The caller is expected to log a
54/// `WARN` and proceed with `None` semantics (no SM filter applied).
55#[must_use]
56pub(crate) fn detect_sm_for_device(device_id: u32) -> Option<String> {
57    let device_arg = device_id.to_string();
58    let output = std::process::Command::new("nvidia-smi")
59        .args([
60            "--query-gpu=compute_cap",
61            "--format=csv,noheader",
62            "-i",
63            &device_arg,
64        ])
65        .output()
66        .ok()?;
67    if !output.status.success() {
68        return None;
69    }
70    let stdout = std::str::from_utf8(&output.stdout).ok()?;
71    parse_compute_capability(stdout)
72}
73
74/// Parses `nvidia-smi --query-gpu=compute_cap` stdout (`"X.Y\n"` or
75/// `"X.Y\nX.Y\n…"` for multi-device queries without `-i`) into an
76/// ORT-compatible `smXY` string.
77///
78/// Defensive against extra whitespace, trailing newlines, and accidental
79/// multi-line output (only the first non-empty line is consumed). Returns
80/// `None` when:
81///
82/// * the input is empty after trimming;
83/// * the first line is not exactly two dot-separated digit components
84///   (`"X.Y"`, allowing 1+ digits each so `"12.0"` parses to `"sm120"`).
85///
86/// Pure: no I/O, no globals. The exhaustive correctness tests in the
87/// sibling test module exercise every failure shape so the production
88/// invariant ("strict `X.Y` only") cannot drift.
89pub(crate) fn parse_compute_capability(stdout: &str) -> Option<String> {
90    let first_line = stdout.lines().find(|l| !l.trim().is_empty())?.trim();
91    let (major, minor) = first_line.split_once('.')?;
92    if major.is_empty() || minor.is_empty() {
93        return None;
94    }
95    if !major.chars().all(|c| c.is_ascii_digit()) || !minor.chars().all(|c| c.is_ascii_digit()) {
96        return None;
97    }
98    Some(format!("sm{major}{minor}"))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    // ─── parse_compute_capability ─────────────────────────────────────────
106
107    #[test]
108    fn parses_l40s_compute_cap() {
109        assert_eq!(parse_compute_capability("8.9\n"), Some("sm89".to_string()));
110    }
111
112    #[test]
113    fn parses_t4_compute_cap() {
114        assert_eq!(parse_compute_capability("7.5\n"), Some("sm75".to_string()));
115    }
116
117    #[test]
118    fn parses_a10g_compute_cap() {
119        assert_eq!(parse_compute_capability("8.6\n"), Some("sm86".to_string()));
120    }
121
122    /// Blackwell sm120 = compute capability 12.0. The major component is
123    /// two digits, the minor is one: `"sm120"` (not `"sm12_0"` or `"sm1200"`).
124    #[test]
125    fn parses_blackwell_compute_cap() {
126        assert_eq!(
127            parse_compute_capability("12.0\n"),
128            Some("sm120".to_string())
129        );
130    }
131
132    /// No trailing newline (some `nvidia-smi` versions omit it on `-i` query).
133    #[test]
134    fn parses_without_trailing_newline() {
135        assert_eq!(parse_compute_capability("8.9"), Some("sm89".to_string()));
136    }
137
138    #[test]
139    fn parses_with_surrounding_whitespace() {
140        assert_eq!(
141            parse_compute_capability("  8.9  \n"),
142            Some("sm89".to_string())
143        );
144    }
145
146    /// Multi-line stdout (operator forgot `-i`); we take only the first
147    /// non-empty line so we never return data for the wrong device.
148    #[test]
149    fn parses_only_first_non_empty_line() {
150        assert_eq!(
151            parse_compute_capability("\n8.9\n12.0\n"),
152            Some("sm89".to_string())
153        );
154    }
155
156    #[test]
157    fn empty_input_returns_none() {
158        assert_eq!(parse_compute_capability(""), None);
159        assert_eq!(parse_compute_capability("\n\n\n"), None);
160        assert_eq!(parse_compute_capability("   "), None);
161    }
162
163    #[test]
164    fn input_without_dot_returns_none() {
165        assert_eq!(parse_compute_capability("89\n"), None);
166        assert_eq!(parse_compute_capability("garbage\n"), None);
167    }
168
169    #[test]
170    fn input_with_non_digit_components_returns_none() {
171        assert_eq!(parse_compute_capability("8.x\n"), None);
172        assert_eq!(parse_compute_capability("x.9\n"), None);
173        assert_eq!(parse_compute_capability("a.b\n"), None);
174    }
175
176    /// A trailing third component (`"8.9.0"`) would silently lose the third
177    /// part if we split on the first dot only. The parser must reject it so
178    /// we never construct a bogus SM string from malformed input.
179    ///
180    /// `split_once('.')` returns `("8", "9.0")`; the minor part fails the
181    /// all-digits check, so the parser returns `None`.
182    #[test]
183    fn input_with_three_components_returns_none() {
184        assert_eq!(parse_compute_capability("8.9.0\n"), None);
185    }
186
187    #[test]
188    fn input_with_empty_component_returns_none() {
189        assert_eq!(parse_compute_capability(".9\n"), None);
190        assert_eq!(parse_compute_capability("8.\n"), None);
191        assert_eq!(parse_compute_capability(".\n"), None);
192    }
193
194    // ─── detect_sm_for_device (subprocess wrapper) ────────────────────────
195
196    /// On any host without `nvidia-smi` (macOS dev box, CPU-only CI runner,
197    /// CPU EP build) the wrapper must return `None` rather than panicking
198    /// or hanging. The whole degrade-safely path depends on this: the worker
199    /// logs a WARN and proceeds with the unfiltered (legacy) behaviour.
200    ///
201    /// This test is intentionally NOT gated on platform — the function must
202    /// be safe to call on every CI target.
203    #[test]
204    fn detect_returns_none_when_nvidia_smi_unavailable() {
205        // On hosts WITH nvidia-smi (e.g. CI runners with a GPU) this test
206        // would return `Some(...)`. That is also a correct outcome — the
207        // contract is "either Some valid sm string, or None"; never panic.
208        let result = detect_sm_for_device(0);
209        match result {
210            None => {}
211            Some(s) => {
212                assert!(s.starts_with("sm"), "got {s}; expected sm-prefixed");
213                assert!(s.len() >= 4, "got {s}; expected at least 'sm75'");
214            }
215        }
216    }
217}