flodl_hw/vendor.rs
1//! GPU vendor identity and the vendor-shaped architecture token.
2
3use std::fmt;
4
5/// Which GPU stack a device belongs to.
6///
7/// This is an **identity**, deliberately separate from the device string
8/// a tensor library is handed. ROCm libtorch keeps `kCUDA`, the
9/// `c10::cuda` namespaces, and RCCL exports the NCCL symbol names, so an
10/// AMD device is still addressed as CUDA at the API surface while being
11/// `Amd` here. Vendor drives detection, diagnostics, packaging and
12/// feature derivation; the API surface is a different axis.
13///
14/// `#[non_exhaustive]`: Intel is the next entry, and it is *not* a
15/// free-rider on the CUDA API surface the way AMD is (libtorch has a
16/// genuinely distinct `XPU` device type), so adding it will be a real
17/// change at every match site rather than a table row. Matches inside
18/// this crate stay exhaustive, which is the point: the compiler
19/// enumerates the work.
20#[non_exhaustive]
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum GpuVendor {
23 Nvidia,
24 Amd,
25}
26
27impl GpuVendor {
28 /// Lowercase stable token, as written into `.arch` metadata and
29 /// cluster-probe JSON.
30 pub fn as_str(self) -> &'static str {
31 match self {
32 GpuVendor::Nvidia => "nvidia",
33 GpuVendor::Amd => "amd",
34 }
35 }
36
37 /// Parse the token produced by [`GpuVendor::as_str`]. Case- and
38 /// whitespace-insensitive; also accepts the stack names users type
39 /// (`cuda`, `rocm`, `hip`).
40 pub fn parse(s: &str) -> Option<Self> {
41 match s.trim().to_ascii_lowercase().as_str() {
42 "nvidia" | "cuda" => Some(GpuVendor::Nvidia),
43 "amd" | "rocm" | "hip" => Some(GpuVendor::Amd),
44 _ => None,
45 }
46 }
47
48 /// The cargo feature that selects this vendor's libtorch link set.
49 pub fn cargo_feature(self) -> &'static str {
50 match self {
51 GpuVendor::Nvidia => "cuda",
52 GpuVendor::Amd => "rocm",
53 }
54 }
55}
56
57impl fmt::Display for GpuVendor {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(match self {
60 GpuVendor::Nvidia => "NVIDIA",
61 GpuVendor::Amd => "AMD",
62 })
63 }
64}
65
66/// What a libtorch variant label says about its backend.
67///
68/// [`Cpu`](VariantClass::Cpu) and [`Unknown`](VariantClass::Unknown) are
69/// separate on purpose: a CPU variant is a positive statement (this
70/// build has no GPU backend), an unrecognized name says nothing — and
71/// the two deserve different policies at every consumer (fdl warns and
72/// assumes on Unknown; admission gates on neither).
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum VariantClass {
75 /// `cpu` / `cpu-*`: a build with no GPU backend.
76 Cpu,
77 /// A recognized vendor naming (`cu<N>` / `sm<N>` → NVIDIA,
78 /// `rocm<N>` / `gfx<N>` → AMD).
79 Vendor(GpuVendor),
80 /// A name outside the convention (including an empty label).
81 Unknown,
82}
83
84/// Classify a libtorch variant label (`precompiled/cu128`,
85/// `builds/sm61-sm120`, `rocm70`, …) by its basename.
86///
87/// This is the variant NAMING CONVENTION's single home; policy stays
88/// with the callers (fdl's variant router warns and assumes NVIDIA on
89/// [`VariantClass::Unknown`], the join admission gate treats it as
90/// unclassifiable and lets it pass). A vendor prefix counts only when a
91/// digit follows, so `cpu` cannot be read as a `cu`-something and a
92/// stray directory cannot masquerade.
93pub fn classify_variant_label(label: &str) -> VariantClass {
94 let basename = std::path::Path::new(label)
95 .file_name()
96 .and_then(|n| n.to_str())
97 .unwrap_or("");
98 let tagged = |prefix: &str| {
99 basename
100 .strip_prefix(prefix)
101 .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
102 };
103 if basename == "cpu" || basename.starts_with("cpu-") {
104 return VariantClass::Cpu;
105 }
106 if tagged("cu") || tagged("sm") {
107 return VariantClass::Vendor(GpuVendor::Nvidia);
108 }
109 if tagged("rocm") || tagged("gfx") {
110 return VariantClass::Vendor(GpuVendor::Amd);
111 }
112 VariantClass::Unknown
113}
114
115/// A device's architecture token, in whatever shape its vendor uses.
116///
117/// Not flattened to a string: NVIDIA's numeric pair is load-bearing
118/// (nvcc gencode flags, the min/max span logic that picks a libtorch
119/// variant, the major-only compatibility fallback), and stringifying it
120/// would force a re-parse at each of those. Not flattened to a numeric
121/// pair either: `gfx1030` is not one.
122///
123/// `#[non_exhaustive]` for the same reason as [`GpuVendor`].
124#[non_exhaustive]
125#[derive(Debug, Clone, PartialEq, Eq, Hash)]
126pub enum GpuArch {
127 /// NVIDIA compute capability. Renders as `sm_120`.
128 Sm { major: u32, minor: u32 },
129 /// AMD LLVM target. Renders as `gfx1030`.
130 ///
131 /// Always stored bare: the `:sramecc±:xnack±` feature suffix that
132 /// `rocminfo` and `gcnArchName` append is stripped at parse, because
133 /// every downstream comparison wants the bare token.
134 Gfx(String),
135}
136
137impl GpuArch {
138 /// Parse a vendor-appropriate arch token.
139 ///
140 /// - `Nvidia`: `"12.0"` or `"sm_120"` / `"sm120"`.
141 /// - `Amd`: `"gfx1030"`, or `"gfx906:sramecc-:xnack-"` (suffix dropped).
142 ///
143 /// `None` when the token does not fit the vendor's shape, which is a
144 /// real condition worth surfacing rather than defaulting through.
145 pub fn parse(vendor: GpuVendor, token: &str) -> Option<Self> {
146 let t = token.trim();
147 match vendor {
148 GpuVendor::Nvidia => Self::parse_sm(t),
149 GpuVendor::Amd => {
150 // Feature suffixes are colon-separated and never part of
151 // the identity. Lowercase: rocminfo has shipped both cases.
152 let bare = t.split(':').next()?.trim().to_ascii_lowercase();
153 if !bare.starts_with("gfx") || bare.len() <= 3 {
154 return None;
155 }
156 Some(GpuArch::Gfx(bare))
157 }
158 }
159 }
160
161 /// Parse an NVIDIA capability in any of the forms that appear across
162 /// nvidia-smi output, `.arch` metadata and cluster-probe JSON.
163 ///
164 /// `"8.6"` is the canonical `major.minor`. `"sm_86"` / `"sm86"` are
165 /// the concatenated forms: the LAST digit is the minor, because the
166 /// major grew past one digit at Blackwell (`sm_120` is 12.0, not
167 /// 1.20).
168 ///
169 /// A `+PTX` suffix is dropped: `TORCH_CUDA_ARCH_LIST` accepts
170 /// `"8.6+PTX"`, `fdl libtorch build --archs` passes that list through
171 /// verbatim, and the resulting `.arch` line has to keep matching.
172 fn parse_sm(t: &str) -> Option<Self> {
173 let t = t.trim().split('+').next()?.trim();
174 if let Some((maj, min)) = t.split_once('.') {
175 return Some(GpuArch::Sm {
176 major: maj.trim().parse().ok()?,
177 minor: min.trim().parse().ok()?,
178 });
179 }
180 let digits = t.trim_start_matches("sm_").trim_start_matches("sm").trim();
181 if digits.len() < 2 || !digits.chars().all(|c| c.is_ascii_digit()) {
182 return None;
183 }
184 let (maj, min) = digits.split_at(digits.len() - 1);
185 Some(GpuArch::Sm {
186 major: maj.parse().ok()?,
187 minor: min.parse().ok()?,
188 })
189 }
190
191 /// The vendor this arch shape belongs to.
192 pub fn vendor(&self) -> GpuVendor {
193 match self {
194 GpuArch::Sm { .. } => GpuVendor::Nvidia,
195 GpuArch::Gfx(_) => GpuVendor::Amd,
196 }
197 }
198
199 /// NVIDIA compute-capability major, or `None` on a non-NVIDIA arch.
200 /// For nvcc gencode flags and the variant-span logic; display code
201 /// wants [`GpuArch`]'s `Display` instead.
202 pub fn sm_major(&self) -> Option<u32> {
203 match self {
204 GpuArch::Sm { major, .. } => Some(*major),
205 _ => None,
206 }
207 }
208
209 /// NVIDIA compute-capability minor, or `None` on a non-NVIDIA arch.
210 pub fn sm_minor(&self) -> Option<u32> {
211 match self {
212 GpuArch::Sm { minor, .. } => Some(*minor),
213 _ => None,
214 }
215 }
216
217 /// A monotonically-increasing number ordering this arch against
218 /// others **of the same vendor**: newer hardware scores higher.
219 /// `sm_86` is 86, `sm_120` is 120, `gfx1030` is 1030.
220 ///
221 /// **Not comparable across vendors.** The scales are unrelated, and
222 /// the fact that `sm_120` and `gfx1030` land in the same order of
223 /// magnitude is a coincidence of AMD's numbering, not a shared
224 /// axis. Callers ranking a mixed cohort must fall back to something
225 /// that genuinely compares (VRAM, or measured throughput) rather
226 /// than pretending these are one scale.
227 pub fn generation(&self) -> u32 {
228 match self {
229 GpuArch::Sm { major, minor } => major * 10 + minor,
230 // Everything after "gfx" is the numeric target id. A
231 // trailing letter exists on some parts (gfx90a), so take the
232 // leading digits and let the letter break nothing.
233 GpuArch::Gfx(g) => g
234 .trim_start_matches("gfx")
235 .chars()
236 .take_while(|c| c.is_ascii_digit())
237 .collect::<String>()
238 .parse()
239 .unwrap_or(0),
240 }
241 }
242
243 /// How this arch is spelled *inside* a `.arch` `archs=` list, which
244 /// is not how it displays: NVIDIA writes `12.0` there (the CMake
245 /// `TORCH_CUDA_ARCH_LIST` form) but shows `sm_120` to humans. AMD
246 /// spells it the same both ways.
247 ///
248 /// Kept apart from `Display` on purpose. The two were the same
249 /// string for exactly as long as NVIDIA was the only vendor, and
250 /// conflating them puts `sm_120` into a list that
251 /// [`GpuArch::covered_by`] then fails to match.
252 pub fn archs_token(&self) -> String {
253 match self {
254 GpuArch::Sm { major, minor } => format!("{major}.{minor}"),
255 GpuArch::Gfx(g) => g.clone(),
256 }
257 }
258
259 /// Whether a libtorch variant compiled for `archs` covers this
260 /// device.
261 ///
262 /// `archs` is the `.arch` metadata's `archs=` field, whose spelling
263 /// is vendor-specific: `"6.1;12.0"` for NVIDIA (the CMake
264 /// `TORCH_CUDA_ARCH_LIST` form), `"gfx1030;gfx1100"` for AMD.
265 ///
266 /// NVIDIA matches any listed capability of the same major, because
267 /// PTX from another minor of that major is forward-compatible. AMD
268 /// requires an **exact** gfx match: there is no PTX-equivalent
269 /// fallback, and a near-miss fails at the first BLAS call rather
270 /// than running slowly.
271 ///
272 /// **Both arms tokenize first.** The NVIDIA arm used to substring-
273 /// search the raw list, and a bare digit is a substring of half the
274 /// entries in a real one: a Maxwell `sm_50` device tested its major
275 /// `"5"` against cu128's `"7.0 7.5 8.0 8.6 8.9 9.0 12.0"`, matched
276 /// the `5` inside `7.5`, and was reported covered by a build that
277 /// ships no kernel for it. `fdl diagnose` said OK and the first
278 /// kernel launch said `no kernel image is available for execution
279 /// on the device`. An unparsable token contributes nothing rather
280 /// than matching loosely, which is also how `cpu` and a mixed-vendor
281 /// list fall out for free.
282 pub fn covered_by(&self, archs: &str) -> bool {
283 archs.split([';', ',', ' ']).any(|token| {
284 match (self, GpuArch::parse(self.vendor(), token)) {
285 // Same major: exact capability, or another minor of it.
286 (GpuArch::Sm { major, .. }, Some(GpuArch::Sm { major: m, .. })) => m == *major,
287 (GpuArch::Gfx(gfx), Some(GpuArch::Gfx(g))) => g == *gfx,
288 _ => false,
289 }
290 })
291 }
292}
293
294impl fmt::Display for GpuArch {
295 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296 match self {
297 GpuArch::Sm { major, minor } => write!(f, "sm_{major}{minor}"),
298 GpuArch::Gfx(g) => f.write_str(g),
299 }
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn vendor_round_trips_and_accepts_stack_names() {
309 assert_eq!(GpuVendor::parse("nvidia"), Some(GpuVendor::Nvidia));
310 assert_eq!(GpuVendor::parse(" CUDA "), Some(GpuVendor::Nvidia));
311 assert_eq!(GpuVendor::parse("AMD"), Some(GpuVendor::Amd));
312 assert_eq!(GpuVendor::parse("rocm"), Some(GpuVendor::Amd));
313 assert_eq!(GpuVendor::parse("hip"), Some(GpuVendor::Amd));
314 assert_eq!(GpuVendor::parse("intel"), None);
315 for v in [GpuVendor::Nvidia, GpuVendor::Amd] {
316 assert_eq!(GpuVendor::parse(v.as_str()), Some(v));
317 }
318 }
319
320 #[test]
321 fn variant_labels_classify_three_ways() {
322 // Cpu and Unknown are distinct on purpose: a CPU variant is a
323 // positive statement, an unrecognized name says nothing, and
324 // the consumers apply different policies to each.
325 for (label, class) in [
326 ("cpu", VariantClass::Cpu),
327 ("precompiled/cpu", VariantClass::Cpu),
328 ("cpu-static", VariantClass::Cpu),
329 ("precompiled/cu128", VariantClass::Vendor(GpuVendor::Nvidia)),
330 ("builds/sm61-sm120", VariantClass::Vendor(GpuVendor::Nvidia)),
331 ("precompiled/rocm70", VariantClass::Vendor(GpuVendor::Amd)),
332 ("builds/gfx1030", VariantClass::Vendor(GpuVendor::Amd)),
333 // The digit guard: a prefix alone is not a vendor claim.
334 ("builds/gfx", VariantClass::Unknown),
335 ("builds/mybuild", VariantClass::Unknown),
336 ("", VariantClass::Unknown),
337 ] {
338 assert_eq!(classify_variant_label(label), class, "{label:?}");
339 }
340 }
341
342 #[test]
343 fn vendor_picks_its_cargo_feature() {
344 assert_eq!(GpuVendor::Nvidia.cargo_feature(), "cuda");
345 assert_eq!(GpuVendor::Amd.cargo_feature(), "rocm");
346 }
347
348 #[test]
349 fn parses_nvidia_capability_forms() {
350 let expect = GpuArch::Sm { major: 8, minor: 6 };
351 for form in ["8.6", "sm_86", "sm86", " 8.6 "] {
352 assert_eq!(
353 GpuArch::parse(GpuVendor::Nvidia, form).unwrap(),
354 expect,
355 "{form}"
356 );
357 }
358 }
359
360 #[test]
361 fn concatenated_form_takes_the_last_digit_as_minor() {
362 // sm_120 is 12.0, NOT 1.20: the major grew past one digit at
363 // Blackwell, so a "first digit is major" rule silently mislabels
364 // every current card.
365 assert_eq!(
366 GpuArch::parse(GpuVendor::Nvidia, "sm_120").unwrap(),
367 GpuArch::Sm {
368 major: 12,
369 minor: 0
370 }
371 );
372 assert_eq!(
373 GpuArch::parse(GpuVendor::Nvidia, "sm_61").unwrap(),
374 GpuArch::Sm { major: 6, minor: 1 }
375 );
376 }
377
378 #[test]
379 fn rejects_malformed_capability() {
380 for bad in ["", "sm_", "x.y", "sm_1", "notacap", "8."] {
381 assert!(GpuArch::parse(GpuVendor::Nvidia, bad).is_none(), "{bad:?}");
382 }
383 }
384
385 #[test]
386 fn strips_the_gfx_feature_suffix() {
387 // rocminfo / gcnArchName append :sramecc±:xnack±. Every
388 // downstream comparison wants the bare token.
389 assert_eq!(
390 GpuArch::parse(GpuVendor::Amd, "gfx906:sramecc-:xnack-").unwrap(),
391 GpuArch::Gfx("gfx906".into())
392 );
393 assert_eq!(
394 GpuArch::parse(GpuVendor::Amd, "GFX1030").unwrap(),
395 GpuArch::Gfx("gfx1030".into())
396 );
397 }
398
399 #[test]
400 fn rejects_malformed_gfx() {
401 for bad in ["", "gfx", "1030", "radeon"] {
402 assert!(GpuArch::parse(GpuVendor::Amd, bad).is_none(), "{bad:?}");
403 }
404 }
405
406 #[test]
407 fn displays_in_vendor_form() {
408 assert_eq!(
409 GpuArch::Sm {
410 major: 12,
411 minor: 0
412 }
413 .to_string(),
414 "sm_120"
415 );
416 assert_eq!(GpuArch::Gfx("gfx1100".into()).to_string(), "gfx1100");
417 }
418
419 #[test]
420 fn archs_token_differs_from_display_on_nvidia_only() {
421 // `.arch` archs= carries the CMake TORCH_CUDA_ARCH_LIST form.
422 let sm = GpuArch::Sm {
423 major: 12,
424 minor: 0,
425 };
426 assert_eq!(sm.archs_token(), "12.0");
427 assert_ne!(sm.archs_token(), sm.to_string());
428 let gfx = GpuArch::Gfx("gfx1100".into());
429 assert_eq!(gfx.archs_token(), gfx.to_string());
430 }
431
432 #[test]
433 fn an_arch_is_covered_by_a_list_of_its_own_tokens() {
434 // The round trip that keeps archs_token and covered_by honest:
435 // whatever we write into `archs=` must match back.
436 for a in [
437 GpuArch::Sm { major: 6, minor: 1 },
438 GpuArch::Sm {
439 major: 12,
440 minor: 0,
441 },
442 GpuArch::Gfx("gfx1030".into()),
443 ] {
444 assert!(a.covered_by(&a.archs_token()), "{a}");
445 assert!(
446 a.covered_by(&format!("gfx900;{};8.9", a.archs_token())),
447 "{a}"
448 );
449 }
450 }
451
452 #[test]
453 fn nvidia_coverage_falls_back_to_major() {
454 let sm86 = GpuArch::Sm { major: 8, minor: 6 };
455 assert!(sm86.covered_by("6.1;8.6"));
456 assert!(
457 sm86.covered_by("8.0"),
458 "same major is forward-compatible via PTX"
459 );
460 assert!(!sm86.covered_by("6.1;12.0"));
461 }
462
463 #[test]
464 fn nvidia_coverage_does_not_match_a_digit_inside_another_token() {
465 // The regression: a substring search let a major match the minor
466 // of an unrelated entry, so `fdl diagnose` reported OK and the
467 // first kernel launch failed with "no kernel image".
468 let cu128 = "7.0 7.5 8.0 8.6 8.9 9.0 12.0";
469 for (maj, min) in [(5u32, 0u32), (5, 2)] {
470 let dev = GpuArch::Sm {
471 major: maj,
472 minor: min,
473 };
474 assert!(
475 !dev.covered_by(cu128),
476 "sm_{maj}{min} matched the 5 inside 7.5",
477 );
478 }
479 // The mirror: a major that is a substring of a two-digit major.
480 assert!(!GpuArch::Sm { major: 2, minor: 0 }.covered_by("12.0"));
481 // ...while the two-digit major itself still matches.
482 assert!(
483 GpuArch::Sm {
484 major: 12,
485 minor: 0
486 }
487 .covered_by(cu128)
488 );
489 // Real coverage of the rig's own cards is unchanged.
490 assert!(GpuArch::Sm { major: 6, minor: 1 }.covered_by("5.0 5.2 6.0 6.1 7.0"));
491 assert!(!GpuArch::Sm { major: 6, minor: 1 }.covered_by(cu128));
492 }
493
494 #[test]
495 fn a_ptx_suffixed_arch_list_entry_still_matches() {
496 // TORCH_CUDA_ARCH_LIST accepts "8.6+PTX" and `fdl libtorch build`
497 // writes the list through verbatim.
498 let sm86 = GpuArch::Sm { major: 8, minor: 6 };
499 assert!(sm86.covered_by("6.1;8.6+PTX"));
500 assert_eq!(GpuArch::parse(GpuVendor::Nvidia, "8.6+PTX").unwrap(), sm86,);
501 }
502
503 #[test]
504 fn a_cpu_variant_covers_nothing() {
505 // `archs=cpu` is what the CPU download writes.
506 assert!(!GpuArch::Sm { major: 8, minor: 6 }.covered_by("cpu"));
507 assert!(!GpuArch::Gfx("gfx1030".into()).covered_by("cpu"));
508 }
509
510 #[test]
511 fn amd_coverage_is_exact_only() {
512 let gfx1030 = GpuArch::Gfx("gfx1030".into());
513 assert!(gfx1030.covered_by("gfx900;gfx1030;gfx1100"));
514 assert!(gfx1030.covered_by("gfx1030"));
515 // No PTX equivalent on ROCm: a near-miss fails at the first BLAS
516 // call, so substring/prefix leniency would be a false green.
517 assert!(!gfx1030.covered_by("gfx1031"));
518 assert!(!gfx1030.covered_by("gfx10"));
519 assert!(!gfx1030.covered_by("gfx1100"));
520 }
521
522 #[test]
523 fn arch_knows_its_own_vendor() {
524 assert_eq!(
525 GpuArch::Sm { major: 8, minor: 6 }.vendor(),
526 GpuVendor::Nvidia
527 );
528 assert_eq!(GpuArch::Gfx("gfx942".into()).vendor(), GpuVendor::Amd);
529 assert_eq!(GpuArch::Gfx("gfx942".into()).sm_major(), None);
530 }
531}