Skip to main content

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}