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}