Skip to main content

rusty_stack/installers/components/
pytorch.rs

1//! PyTorch installer — ports `scripts/install_pytorch_rocm.sh`.
2//!
3//! Constructs correct pip install commands with the right index URL for
4//! ROCm version + Python version wheel selection.
5//!
6//! # Validation Assertion
7//!
8//! - **VAL-INSTALL-003**: PyTorch installer correct wheel selection
9
10use std::fmt;
11
12// ===========================================================================
13// Types
14// ===========================================================================
15
16/// PyTorch release channel.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum TorchChannel {
19    /// Stable releases only.
20    Stable,
21    /// Latest (stable fallback to nightly).
22    Latest,
23    /// Nightly builds.
24    Nightly,
25}
26
27impl fmt::Display for TorchChannel {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            TorchChannel::Stable => write!(f, "stable"),
31            TorchChannel::Latest => write!(f, "latest"),
32            TorchChannel::Nightly => write!(f, "nightly"),
33        }
34    }
35}
36
37impl TorchChannel {
38    /// Parse from a choice number (1-3).
39    pub fn from_choice(choice: u8) -> Option<Self> {
40        match choice {
41            1 => Some(TorchChannel::Stable),
42            2 => Some(TorchChannel::Latest),
43            3 => Some(TorchChannel::Nightly),
44            _ => None,
45        }
46    }
47}
48
49/// AMD's PyTorch wheel index for ROCm 10.x (TheRock) releases.
50pub const AMD_WHL_NEXT_INDEX: &str = "https://stable.repo.amd.com/rocm/whl-next/";
51
52/// Installation method.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum InstallMethod {
55    /// Install globally.
56    Global,
57    /// Install in a virtual environment.
58    Venv,
59    /// Try global, fallback to venv.
60    Auto,
61}
62
63impl fmt::Display for InstallMethod {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            InstallMethod::Global => write!(f, "global"),
67            InstallMethod::Venv => write!(f, "venv"),
68            InstallMethod::Auto => write!(f, "auto"),
69        }
70    }
71}
72
73/// Configuration for the PyTorch installer.
74#[derive(Debug, Clone)]
75pub struct PyTorchConfig {
76    /// PyTorch release channel.
77    pub channel: TorchChannel,
78    /// Installation method.
79    pub method: InstallMethod,
80    /// Python binary to use.
81    pub python_bin: String,
82    /// Whether to force reinstall.
83    pub force_reinstall: bool,
84    /// Whether to run in dry-run mode.
85    pub dry_run: bool,
86}
87
88impl Default for PyTorchConfig {
89    fn default() -> Self {
90        Self {
91            channel: TorchChannel::Latest,
92            method: InstallMethod::Auto,
93            python_bin: "python3".to_string(),
94            force_reinstall: false,
95            dry_run: false,
96        }
97    }
98}
99
100/// A constructed pip install command.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct PipCommand {
103    /// The program to run.
104    pub program: String,
105    /// Arguments to pass.
106    pub args: Vec<String>,
107}
108
109impl PipCommand {
110    /// Format as a shell command string.
111    pub fn to_command_string(&self) -> String {
112        if self.args.is_empty() {
113            self.program.clone()
114        } else {
115            format!("{} {}", self.program, self.args.join(" "))
116        }
117    }
118}
119
120/// The PyTorch installer.
121pub struct PyTorchInstaller {
122    config: PyTorchConfig,
123}
124
125impl PyTorchInstaller {
126    /// Create a new PyTorch installer with the given config.
127    pub fn new(config: PyTorchConfig) -> Self {
128        Self { config }
129    }
130
131    /// Create with default config.
132    pub fn with_defaults() -> Self {
133        Self::new(PyTorchConfig::default())
134    }
135
136    // -----------------------------------------------------------------------
137    // Index URL construction (VAL-INSTALL-003)
138    // -----------------------------------------------------------------------
139
140    /// Get the PyTorch index URL for a given ROCm major.minor version.
141    ///
142    /// Matches the logic in the original script:
143    /// - ROCm 7.x: tries Radeon manylinux first, then PyTorch nightly
144    /// - ROCm 6.4+: uses PyTorch nightly builds
145    /// - ROCm 6.3: uses stable builds
146    /// - ROCm 6.0-6.2: uses stable builds for 6.2
147    /// - ROCm 5.x: uses stable builds for 5.7
148    /// - Fallback: ROCm 6.3 stable
149    pub fn index_url_for_rocm(&self, rocm_mm: &str) -> String {
150        let mm: f32 = rocm_mm.parse().unwrap_or(7.2);
151        if mm >= 10.0 {
152            // ROCm 10.x (TheRock) PyTorch wheels live on AMD's own PEP 503 index,
153            // tagged `+rocm10.x.y`, with per-GPU `[device-*]` extras.
154            AMD_WHL_NEXT_INDEX.to_string()
155        } else if mm >= 7.0 {
156            // PyTorch's OWN PEP 503 simple index for ROCm 7.x. NOT AMD's Radeon
157            // manylinux listing (repo.radeon.com/rocm/manylinux/rocm-rel-X.Y/) —
158            // that is a flat --find-links directory with no per-package /torch/
159            // subpage, so `pip --index-url` against it fails with "No matching
160            // distribution found for torch". PyTorch's index serves proper
161            // manylinux wheels (torch-X.Y+rocm7.2) + torchvision/torchaudio.
162            format!("https://download.pytorch.org/whl/rocm{rocm_mm}")
163        } else if mm >= 6.4 {
164            format!("https://download.pytorch.org/whl/nightly/rocm{rocm_mm}")
165        } else if mm >= 6.3 {
166            format!("https://download.pytorch.org/whl/rocm{rocm_mm}")
167        } else if mm >= 6.0 {
168            "https://download.pytorch.org/whl/rocm6.2".to_string()
169        } else if mm >= 5.0 {
170            "https://download.pytorch.org/whl/rocm5.7".to_string()
171        } else {
172            "https://download.pytorch.org/whl/rocm6.3".to_string()
173        }
174    }
175
176    /// Get the PyTorch nightly index URL for a given ROCm version.
177    pub fn nightly_index_url(&self, rocm_mm: &str) -> String {
178        format!("https://download.pytorch.org/whl/nightly/rocm{rocm_mm}")
179    }
180
181    /// Get the Radeon manylinux index URL.
182    pub fn radeon_index_url(&self, rocm_mm: &str) -> String {
183        format!("https://repo.radeon.com/rocm/manylinux/rocm-rel-{rocm_mm}/")
184    }
185
186    /// Get the fallback index URL (ROCm 7.0 stable).
187    pub fn fallback_index_url(&self) -> String {
188        "https://download.pytorch.org/whl/rocm7.0".to_string()
189    }
190
191    // -----------------------------------------------------------------------
192    // Command construction
193    // -----------------------------------------------------------------------
194
195    /// Construct pip install command for PyTorch with ROCm support.
196    ///
197    /// This matches the original script's pip install logic:
198    /// - Uses `uv pip install` if uv is available
199    /// - Uses `python3 -m pip install` as fallback
200    /// - Adds `--index-url` for the correct ROCm wheel index
201    /// - Adds `--break-system-packages` for global installs
202    /// - Installs torch, torchvision, torchaudio
203    ///
204    /// `target_version` (the planner/manifest target) pins torch to an exact
205    /// version + `--upgrade` for update-driven installs, so pip actually advances
206    /// the installed wheel instead of no-op'ing on an already-satisfied one. A
207    /// `None`/empty target (fresh TUI install) leaves torch unpinned → latest.
208    /// PEP 440: `torch==2.13.0` matches the ROCm wheel `2.13.0+rocm7.2`, so the
209    /// pin resolves on the PyTorch ROCm index.
210    pub fn build_install_command(
211        &self,
212        rocm_mm: &str,
213        use_uv: bool,
214        target_version: Option<&str>,
215    ) -> PipCommand {
216        let index_url = self.index_url_for_rocm(rocm_mm);
217        let is_global = self.config.method == InstallMethod::Global
218            || self.config.method == InstallMethod::Auto;
219
220        // For global installs, always use python3 -m pip with --break-system-packages
221        // instead of `uv pip install --system`, which fails on uv-managed Python
222        // installations with "externally managed" errors.
223        let effective_use_uv = use_uv && !is_global;
224
225        let mut args = Vec::new();
226
227        if effective_use_uv {
228            args.push("pip".to_string());
229            args.push("install".to_string());
230        } else {
231            args.push("-m".to_string());
232            args.push("pip".to_string());
233            args.push("install".to_string());
234            if is_global {
235                args.push("--break-system-packages".to_string());
236            }
237        }
238
239        args.push("--no-cache".to_string());
240        // Pin to the exact target + upgrade for update-driven installs. Without
241        // this pip sees the installed wheel as already satisfying an unpinned
242        // `torch` and exits 0 with the OLD version still active (the false-success
243        // root cause). torchvision/torchaudio stay unpinned so pip's resolver
244        // picks versions compatible with the pinned torch.
245        let pin = target_version.map(str::trim).filter(|v| !v.is_empty());
246        if pin.is_some() {
247            args.push("--upgrade".to_string());
248        }
249        args.push("--index-url".to_string());
250        args.push(index_url);
251        // ROCm 10.x wheels need the `[device-all]` extra to pull the ROCm runtime.
252        let torch_name = if rocm_mm.parse::<f32>().map(|m| m >= 10.0).unwrap_or(false) {
253            "torch[device-all]"
254        } else {
255            "torch"
256        };
257        match pin {
258            Some(v) => args.push(format!("{torch_name}=={v}")),
259            None => args.push(torch_name.to_string()),
260        }
261        args.push("torchvision".to_string());
262        args.push("torchaudio".to_string());
263
264        let program = if effective_use_uv {
265            "uv".to_string()
266        } else {
267            self.config.python_bin.clone()
268        };
269
270        PipCommand { program, args }
271    }
272
273    /// Construct the fallback command when primary install fails.
274    /// Tries the ROCm 7.0 stable index.
275    pub fn build_fallback_command(&self, use_uv: bool) -> PipCommand {
276        let is_global = self.config.method == InstallMethod::Global
277            || self.config.method == InstallMethod::Auto;
278
279        // For global installs, always use python3 -m pip with --break-system-packages
280        // instead of `uv pip install --system`, which fails on uv-managed Python.
281        let effective_use_uv = use_uv && !is_global;
282
283        let mut args = Vec::new();
284        if effective_use_uv {
285            args.push("pip".to_string());
286            args.push("install".to_string());
287        } else {
288            args.push("-m".to_string());
289            args.push("pip".to_string());
290            args.push("install".to_string());
291            if is_global {
292                args.push("--break-system-packages".to_string());
293            }
294        }
295
296        args.push("--no-cache".to_string());
297        args.push("--index-url".to_string());
298        args.push(self.fallback_index_url());
299        args.push("torch".to_string());
300        args.push("torchvision".to_string());
301        args.push("torchaudio".to_string());
302
303        let program = if effective_use_uv {
304            "uv".to_string()
305        } else {
306            self.config.python_bin.clone()
307        };
308
309        PipCommand { program, args }
310    }
311
312    /// Construct the common ML dependencies install command.
313    ///
314    /// The original script installs `torchsde` and `sentencepiece` after PyTorch.
315    pub fn build_common_deps_command(&self, use_uv: bool) -> PipCommand {
316        let is_global = self.config.method == InstallMethod::Global
317            || self.config.method == InstallMethod::Auto;
318
319        // For global installs, always use python3 -m pip with --break-system-packages
320        // instead of `uv pip install --system`, which fails on uv-managed Python.
321        let effective_use_uv = use_uv && !is_global;
322
323        let mut args = Vec::new();
324        if effective_use_uv {
325            args.push("pip".to_string());
326            args.push("install".to_string());
327        } else {
328            args.push("-m".to_string());
329            args.push("pip".to_string());
330            args.push("install".to_string());
331            args.push("--break-system-packages".to_string());
332        }
333        args.push("--no-cache-dir".to_string());
334        args.push("--upgrade".to_string());
335        args.push("torchsde".to_string());
336        args.push("sentencepiece".to_string());
337
338        let program = if effective_use_uv {
339            "uv".to_string()
340        } else {
341            self.config.python_bin.clone()
342        };
343        PipCommand { program, args }
344    }
345}
346
347// ===========================================================================
348// Tests
349// ===========================================================================
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    // --- VAL-INSTALL-003: PyTorch installer correct wheel selection ---
356
357    #[test]
358    fn test_index_url_rocm_72() {
359        let installer = PyTorchInstaller::with_defaults();
360        let url = installer.index_url_for_rocm("7.2");
361        assert_eq!(url, "https://download.pytorch.org/whl/rocm7.2");
362    }
363
364    #[test]
365    fn test_index_url_rocm_101_uses_amd_whl_next() {
366        let installer = PyTorchInstaller::with_defaults();
367        assert_eq!(installer.index_url_for_rocm("10.1"), AMD_WHL_NEXT_INDEX);
368        let cmd = installer.build_install_command("10.1", false, Some("2.14.0"));
369        assert!(cmd.args.contains(&AMD_WHL_NEXT_INDEX.to_string()));
370        assert!(cmd.args.contains(&"torch[device-all]==2.14.0".to_string()));
371        let old = installer.build_install_command("7.2", false, None);
372        assert!(old.args.contains(&"torch".to_string()));
373    }
374
375    #[test]
376    fn test_index_url_rocm_70() {
377        let installer = PyTorchInstaller::with_defaults();
378        let url = installer.index_url_for_rocm("7.0");
379        assert_eq!(url, "https://download.pytorch.org/whl/rocm7.0");
380    }
381
382    #[test]
383    fn test_index_url_rocm_64() {
384        let installer = PyTorchInstaller::with_defaults();
385        let url = installer.index_url_for_rocm("6.4");
386        assert_eq!(url, "https://download.pytorch.org/whl/nightly/rocm6.4");
387    }
388
389    #[test]
390    fn test_index_url_rocm_63() {
391        let installer = PyTorchInstaller::with_defaults();
392        let url = installer.index_url_for_rocm("6.3");
393        assert_eq!(url, "https://download.pytorch.org/whl/rocm6.3");
394    }
395
396    #[test]
397    fn test_index_url_rocm_62() {
398        let installer = PyTorchInstaller::with_defaults();
399        let url = installer.index_url_for_rocm("6.2");
400        assert_eq!(url, "https://download.pytorch.org/whl/rocm6.2");
401    }
402
403    #[test]
404    fn test_index_url_rocm_57() {
405        let installer = PyTorchInstaller::with_defaults();
406        let url = installer.index_url_for_rocm("5.7");
407        assert_eq!(url, "https://download.pytorch.org/whl/rocm5.7");
408    }
409
410    #[test]
411    fn test_index_url_fallback() {
412        let installer = PyTorchInstaller::with_defaults();
413        let url = installer.index_url_for_rocm("4.0");
414        assert_eq!(url, "https://download.pytorch.org/whl/rocm6.3");
415    }
416
417    #[test]
418    fn test_nightly_index_url() {
419        let installer = PyTorchInstaller::with_defaults();
420        let url = installer.nightly_index_url("7.2");
421        assert_eq!(url, "https://download.pytorch.org/whl/nightly/rocm7.2");
422    }
423
424    #[test]
425    fn test_radeon_index_url() {
426        let installer = PyTorchInstaller::with_defaults();
427        let url = installer.radeon_index_url("7.2");
428        assert_eq!(url, "https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/");
429    }
430
431    #[test]
432    fn test_build_install_command_uv_global_uses_pip() {
433        // Global + uv: should use python3 -m pip (not uv --system) to avoid
434        // "externally managed" errors on uv-managed Python installations.
435        let installer = PyTorchInstaller::new(PyTorchConfig {
436            method: InstallMethod::Global,
437            ..Default::default()
438        });
439        let cmd = installer.build_install_command("7.2", true, None);
440        assert_eq!(cmd.program, "python3");
441        assert!(cmd.args.contains(&"-m".to_string()));
442        assert!(cmd.args.contains(&"pip".to_string()));
443        assert!(cmd.args.contains(&"install".to_string()));
444        assert!(cmd.args.contains(&"--break-system-packages".to_string()));
445        assert!(cmd.args.contains(&"--index-url".to_string()));
446        assert!(cmd.args.iter().any(|a| a.contains("rocm7.2")));
447        assert!(cmd.args.contains(&"torch".to_string()));
448        // No target → unpinned, no --upgrade.
449        assert!(!cmd.args.contains(&"--upgrade".to_string()));
450        assert!(!cmd.args.iter().any(|a| a.starts_with("torch==")));
451    }
452
453    #[test]
454    fn test_build_install_command_pip() {
455        let installer = PyTorchInstaller::new(PyTorchConfig {
456            method: InstallMethod::Global,
457            python_bin: "python3".to_string(),
458            ..Default::default()
459        });
460        let cmd = installer.build_install_command("6.4", false, None);
461        assert_eq!(cmd.program, "python3");
462        assert!(cmd.args.contains(&"-m".to_string()));
463        assert!(cmd.args.contains(&"pip".to_string()));
464        assert!(cmd.args.contains(&"--break-system-packages".to_string()));
465        assert!(cmd.args.iter().any(|a| a.contains("rocm6.4")));
466    }
467
468    #[test]
469    fn test_build_install_command_venv_no_break_system() {
470        let installer = PyTorchInstaller::new(PyTorchConfig {
471            method: InstallMethod::Venv,
472            ..Default::default()
473        });
474        let cmd = installer.build_install_command("7.2", true, None);
475        assert!(!cmd.args.contains(&"--break-system-packages".to_string()));
476        assert!(!cmd.args.contains(&"--system".to_string()));
477    }
478
479    #[test]
480    fn test_build_install_command_pins_target_for_update() {
481        // An update-driven install pins torch to the manifest target + adds
482        // --upgrade, so pip advances the wheel instead of no-op'ing on the
483        // already-satisfied old one. torchvision/torchaudio stay unpinned.
484        let installer = PyTorchInstaller::new(PyTorchConfig {
485            method: InstallMethod::Global,
486            ..Default::default()
487        });
488        let cmd = installer.build_install_command("7.2", false, Some("2.13.0"));
489        assert!(cmd.args.contains(&"--upgrade".to_string()));
490        assert!(cmd.args.contains(&"torch==2.13.0".to_string()));
491        assert!(cmd.args.contains(&"torchvision".to_string()));
492        assert!(cmd.args.contains(&"torchaudio".to_string()));
493        // An empty/whitespace target is treated as "no target".
494        let cmd_empty = installer.build_install_command("7.2", false, Some("   "));
495        assert!(!cmd_empty.args.contains(&"--upgrade".to_string()));
496        assert!(cmd_empty.args.contains(&"torch".to_string()));
497    }
498
499    #[test]
500    fn test_build_fallback_command() {
501        let installer = PyTorchInstaller::new(PyTorchConfig {
502            method: InstallMethod::Global,
503            ..Default::default()
504        });
505        let cmd = installer.build_fallback_command(true);
506        assert!(cmd.args.iter().any(|a| a.contains("rocm7.0")));
507        assert!(cmd.args.contains(&"torch".to_string()));
508    }
509
510    #[test]
511    fn test_build_common_deps_command() {
512        let installer = PyTorchInstaller::with_defaults();
513        let cmd = installer.build_common_deps_command(true);
514        assert!(cmd.args.contains(&"torchsde".to_string()));
515        assert!(cmd.args.contains(&"sentencepiece".to_string()));
516    }
517
518    #[test]
519    fn test_channel_from_choice() {
520        assert_eq!(TorchChannel::from_choice(1), Some(TorchChannel::Stable));
521        assert_eq!(TorchChannel::from_choice(2), Some(TorchChannel::Latest));
522        assert_eq!(TorchChannel::from_choice(3), Some(TorchChannel::Nightly));
523        assert_eq!(TorchChannel::from_choice(0), None);
524    }
525}