From c99a12879a67147ad3f72fd7901174fb7cc2caad Mon Sep 17 00:00:00 2001 From: Eugene Volen Date: Tue, 23 Jun 2026 13:10:01 +0000 Subject: [PATCH 1/3] feat: native examine, diagnose & fix in the rocm CLI Add the rocm-doctor capability to the rocm binary: probe the host, diagnose against a closed catalog of known ROCm/PyTorch/llama.cpp misconfigurations, and apply consent-gated fixes. The probe, the closed catalog (checks, keyword tables, scoring), and the fix recipes live in rocm-core as one source of truth, versioned with the binary and usable standalone -- no external scripts or agent required. - examine: structured Examination host probe (Linux full parity, Windows best-effort), with a machine-readable JSON form for tooling - diagnose: the 15 closed-catalog checks with evidence, a fix, a verify step, and upstream routing when nothing matches - fix: consent-gated runners for the four safe fixes; risky fixes print their plan and mutate nothing A field-set test freezes the Examination JSON contract so the probe output and the catalog cannot silently diverge. Signed-off-by: Eugene Volen --- Cargo.lock | 1 + apps/rocm/src/main.rs | 95 +- crates/rocm-core/Cargo.toml | 1 + crates/rocm-core/src/diagnose.rs | 1523 ++++++++++++++++++++++++++++++ crates/rocm-core/src/examine.rs | 1522 +++++++++++++++++++++++++++++ crates/rocm-core/src/fix.rs | 1034 ++++++++++++++++++++ crates/rocm-core/src/lib.rs | 9 + 7 files changed, 4182 insertions(+), 3 deletions(-) create mode 100644 crates/rocm-core/src/diagnose.rs create mode 100644 crates/rocm-core/src/examine.rs create mode 100644 crates/rocm-core/src/fix.rs diff --git a/Cargo.lock b/Cargo.lock index b227ccb6..6972156b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3248,6 +3248,7 @@ dependencies = [ "directories", "libc", "rand 0.8.6", + "regex", "rsa", "serde", "serde_json", diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index f2fb847e..0a06d02c 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -65,7 +65,32 @@ struct Cli { #[derive(Subcommand, Debug)] enum Command { /// Check this computer's GPU, ROCm install, engines, and setup folders. - Examine, + Examine { + /// Diagnose known ROCm/PyTorch/llama.cpp failure modes and suggest fixes. + #[arg(long, conflicts_with = "fix")] + diagnose: bool, + /// Raw error text from the user; sharpens diagnosis keyword scoring. + #[arg(long, requires = "diagnose")] + symptom: Option, + /// With --diagnose, show at most this many matches (default 5). + #[arg(long, requires = "diagnose", default_value_t = 5)] + top: usize, + /// Apply a known fix by id (e.g. fix-4-render-group); see --diagnose output. + #[arg(long, value_name = "FIX_ID")] + fix: Option, + /// With --fix, skip the interactive confirmation (use after approving the plan). + #[arg(long, requires = "fix")] + yes: bool, + /// With --fix, show the plan without changing anything. + #[arg(long = "dry-run", requires = "fix")] + dry_run: bool, + /// For fix-9-igpu-dgpu: the discrete GPU index to pin. + #[arg(long, requires = "fix")] + device_index: Option, + /// Emit machine-readable JSON (the Examination, or the diagnosis with --diagnose). + #[arg(long)] + json: bool, + }, /// Print the rocm-cli version. Version, #[command(hide = true)] @@ -1035,7 +1060,25 @@ fn dispatch(cli: Cli) -> Result<()> { } match cli.command { - Some(Command::Examine) => examine(), + Some(Command::Examine { + diagnose, + symptom, + top, + fix, + yes, + dry_run, + device_index, + json, + }) => examine(ExamineArgs { + diagnose, + symptom, + top, + fix, + yes, + dry_run, + device_index, + json, + }), Some(Command::Version) => { println!("rocm {}", env!("CARGO_PKG_VERSION")); Ok(()) @@ -1412,7 +1455,53 @@ const fn builtin_engine_inventory() -> &'static [(&'static str, &'static str)] { ] } -fn examine() -> Result<()> { +struct ExamineArgs { + diagnose: bool, + symptom: Option, + top: usize, + fix: Option, + yes: bool, + dry_run: bool, + device_index: Option, + json: bool, +} + +fn examine(args: ExamineArgs) -> Result<()> { + if let Some(fix_id) = args.fix { + let opts = rocm_core::FixOptions { + yes: args.yes, + dry_run: args.dry_run, + device_index: args.device_index, + }; + let code = rocm_core::apply_fix(&fix_id, &opts); + if code != 0 { + std::process::exit(code); + } + return Ok(()); + } + if args.diagnose { + let examination = rocm_core::Examination::probe(rocm_core::FrameworkProbe::Auto); + let symptom = args.symptom.unwrap_or_default(); + let report = rocm_core::run_diagnose(&examination, &symptom); + if args.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print!("{}", rocm_core::render_diagnose_text(&report, args.top)); + } + if !report.has_match() { + std::process::exit(1); + } + return Ok(()); + } + if args.json { + let examination = rocm_core::Examination::probe(rocm_core::FrameworkProbe::Auto); + println!("{}", serde_json::to_string_pretty(&examination)?); + let code = examination.exit_code(); + if code != 0 { + std::process::exit(code); + } + return Ok(()); + } print!("{}", render_examine_text()?); Ok(()) } diff --git a/crates/rocm-core/Cargo.toml b/crates/rocm-core/Cargo.toml index 77581639..ce76c4b6 100644 --- a/crates/rocm-core/Cargo.toml +++ b/crates/rocm-core/Cargo.toml @@ -14,6 +14,7 @@ anyhow.workspace = true directories.workspace = true libc.workspace = true rand.workspace = true +regex = "1" rsa.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/rocm-core/src/diagnose.rs b/crates/rocm-core/src/diagnose.rs new file mode 100644 index 00000000..7b8031b3 --- /dev/null +++ b/crates/rocm-core/src/diagnose.rs @@ -0,0 +1,1523 @@ +//! ROCm failure-mode diagnosis. +//! +//! Rust port of the `rocm-doctor` skill's `diagnose.py`. It matches an +//! [`Examination`] (plus optional user symptom text) against a **closed list** +//! of known misconfigurations and returns ranked [`Diagnosis`] results, each +//! with the evidence it used and a [`Fix`] (plan + verify step). When nothing +//! matches it routes the user upstream rather than guessing. +//! +//! The catalog is deliberately closed: new failure modes are added here, not +//! invented at runtime. Keyword tables, thresholds, and tracker URLs are the +//! data; the per-check logic mirrors `diagnose.py` field-for-field so the two +//! stay behaviorally identical. See `plans/rocm-doctor-examine-migration-plan.md`. + +use crate::examine::Examination; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +/// At/above this score a diagnosis is treated as a real match. +pub const MIN_SCORE_FOR_MATCH: i32 = 50; +/// At/above this score the agent may propose the fix immediately. +pub const HIGH_CONFIDENCE: i32 = 75; + +/// A proposed remediation for a [`Diagnosis`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Fix { + pub summary: String, + pub commands: Vec, + pub needs_sudo: bool, + pub needs_reboot: bool, + pub needs_relogin: bool, + pub fix_id: String, + pub auto_applicable: bool, + pub notes: Vec, + pub verify: String, +} + +/// A single scored match against the failure-mode catalog. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Diagnosis { + pub id: String, + pub title: String, + pub score: i32, + pub evidence: Vec, + pub fix: Option, +} + +/// Where to send a report when no catalog entry matches. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Route { + pub target: String, + pub url: String, +} + +/// The full diagnosis output (mirrors `diagnose.py --json`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiagnoseReport { + /// All nonzero-score diagnoses, highest score first. + pub matched: Vec, + pub min_score_for_match: i32, + pub high_confidence_threshold: i32, + pub route_when_no_match: Route, +} + +impl DiagnoseReport { + /// Whether at least one diagnosis cleared [`MIN_SCORE_FOR_MATCH`]. + #[must_use] + pub fn has_match(&self) -> bool { + self.matched.iter().any(|d| d.score >= MIN_SCORE_FOR_MATCH) + } +} + +/// Upstream tracker for a framework key. +fn upstream_tracker(target: &str) -> &'static str { + match target { + "pytorch" => "https://github.com/pytorch/pytorch/issues (tag with rocm label)", + "llama-cpp" => "https://github.com/ggml-org/llama.cpp/issues", + "lemonade" => "https://github.com/lemonade-sdk/lemonade/issues", + "ollama" => "https://github.com/ollama/ollama/issues", + "lm-studio" => "https://lmstudio.ai/docs/app (use in-app support; no public repo)", + "amdgpu-install" => "https://repo.radeon.com (raise via your AMD support contact)", + _ => "https://github.com/ROCm/ROCm/issues", + } +} + +// --------------------------------------------------------------------------- +// Symptom keyword tables: (regex, weight, evidence-label). Patterns are +// lowercase and matched against the lowercased symptom. +// --------------------------------------------------------------------------- + +type KeywordTable = &'static [(&'static str, i32, &'static str)]; + +const KEYWORDS_INVALID_ISA: KeywordTable = &[ + ( + "hiperrornobinaryforgpu", + 45, + "error mentions hipErrorNoBinaryForGpu", + ), + ( + "hsa_status_error_invalid_isa", + 50, + "error mentions HSA_STATUS_ERROR_INVALID_ISA", + ), + ( + "invalid device function", + 40, + "error mentions 'invalid device function'", + ), + ( + "no kernel image is available", + 35, + "error mentions 'no kernel image is available'", + ), + ( + r"gfx\d{3,4}.* not (?:in|on) .*arch", + 35, + "error names a missing gfx in arch list", + ), +]; + +const KEYWORDS_KFD_PERMISSION: KeywordTable = &[ + ( + "unable to open /dev/kfd", + 50, + "error mentions /dev/kfd open failure", + ), + ( + r"/dev/kfd.*permission denied", + 45, + "error mentions /dev/kfd permission denied", + ), + ( + "hsa_status_error_out_of_resources", + 25, + "HSA out-of-resources (often perms)", + ), + ("failed to open kfd", 35, "error mentions kfd open failure"), +]; + +const KEYWORDS_MODULE_NOT_LOADED: KeywordTable = &[ + ( + "rock module is not loaded", + 50, + "rocminfo says ROCk module is NOT loaded", + ), + ("no devices? found", 20, "vague 'no devices found'"), + ("hsa_status_error", 10, "HSA error (broad)"), +]; + +const KEYWORDS_PATH_MISSING: KeywordTable = &[ + ("rocminfo: command not found", 50, "rocminfo not on PATH"), + ("command not found.*hipcc", 40, "hipcc not on PATH"), + ("/opt/rocm/bin", 15, "user mentions /opt/rocm/bin"), +]; + +const KEYWORDS_LIB_MISMATCH: KeywordTable = &[ + (r"libamdhip64\.so", 50, "error mentions libamdhip64.so"), + ("libhsa-runtime", 45, "error mentions libhsa-runtime"), + ("libhipblas", 40, "error mentions libhipblas"), + ( + r"amdhip64_\d+\.dll", + 50, + "error mentions amdhip64_X.dll (Windows)", + ), + (r"hipblas\.dll", 40, "error mentions hipblas.dll (Windows)"), + ("cannot open shared object file", 25, "ldopen failure"), + ("dll load failed", 25, "Windows DLL load failure"), + ("version `?glibc", 5, "tangential glibc version error"), +]; + +const KEYWORDS_HIP_SDK_MISSING: KeywordTable = &[ + ("amdhip64.*not found", 50, "error names amdhip64 missing"), + ("could not find hip", 40, "error mentions HIP not found"), + ("hip_path.*not set", 35, "user mentions HIP_PATH unset"), + ( + "hipinfo.*not recognized", + 45, + "Windows says hipInfo is not a command", + ), +]; + +const KEYWORDS_MSVC_REDIST: KeywordTable = &[ + ( + r"vcruntime140(?:_1)?\.dll", + 50, + "error mentions vcruntime140 / vcruntime140_1", + ), + ( + r"api-ms-win-crt-.*\.dll", + 35, + "error mentions api-ms-win-crt-* DLL", + ), + ( + "the (program|application) can't start because", + 25, + "Windows missing-DLL dialog text", + ), + (r"msvcp140\.dll", 30, "error mentions msvcp140.dll"), +]; + +const KEYWORDS_REPO_BROKEN: KeywordTable = &[ + (r"404.*repo\.radeon\.com", 50, "404 against repo.radeon.com"), + ( + "release file (is )?not (yet )?valid", + 30, + "apt 'release file not valid'", + ), + ( + "the following packages have unmet dependencies", + 25, + "apt unmet dependencies", + ), + ( + "unable to locate package rocm", + 35, + "apt cannot find ROCm package", + ), +]; + +const KEYWORDS_CONTAINER: KeywordTable = &[ + ( + "hsa_status_error.*permission", + 20, + "HSA permission error (often container)", + ), + (r"/dev/dri.*permission", 30, "/dev/dri permission failure"), + ("failed to open device", 25, "device open failure"), +]; + +const KEYWORDS_IOMMU_HANG: KeywordTable = &[ + ("hang", 20, "user mentions 'hang'"), + ("deadlock", 20, "user mentions deadlock"), + ("timed out waiting", 25, "ring/queue timeout"), + ("iommu", 30, "user mentions iommu"), +]; + +const KEYWORDS_DPKG_BROKEN: KeywordTable = &[ + ("half[- ]configured", 50, "dpkg 'half-configured'"), + ("dkms .*failed", 45, "DKMS build failure"), + ("dpkg: error", 25, "generic dpkg error"), + ( + "sub-process /usr/bin/dpkg returned", + 25, + "apt mentions dpkg failure", + ), + ("--accept-eula", 40, "user mentions --accept-eula"), +]; + +const KEYWORDS_PAGE_FAULT: KeywordTable = &[ + ("page fault", 40, "user mentions page fault"), + ("vm_fault", 35, "kernel vm_fault"), + ("hw_fault", 30, "amdgpu HW fault"), + ("out_of_registers", 30, "compiler OUT_OF_REGISTERS"), +]; + +/// Score the strongest (top-2) keyword matches in `table` against `symptom`. +fn keyword_score(symptom: &str, table: KeywordTable) -> (i32, Vec) { + if symptom.is_empty() { + return (0, Vec::new()); + } + let sym = symptom.to_lowercase(); + let mut hits: Vec<(i32, &'static str)> = Vec::new(); + for (pattern, weight, label) in table { + if Regex::new(pattern).is_ok_and(|re| re.is_match(&sym)) { + hits.push((*weight, label)); + } + } + if hits.is_empty() { + return (0, Vec::new()); + } + // Mirror diagnose.py's `hits.sort(reverse=True)`: weight desc, then label desc. + hits.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(a.1))); + hits.truncate(2); + let score = hits.iter().map(|(w, _)| *w).sum(); + let labels = hits.iter().map(|(_, l)| (*l).to_owned()).collect(); + (score, labels) +} + +/// Whether `symptom` (lowercased) matches `pattern`. +fn symptom_matches(symptom: &str, pattern: &str) -> bool { + !symptom.is_empty() && Regex::new(pattern).is_ok_and(|re| re.is_match(&symptom.to_lowercase())) +} + +// --------------------------------------------------------------------------- +// Examination accessors +// --------------------------------------------------------------------------- + +fn amd_gfx_targets(e: &Examination) -> Vec { + e.gpus + .iter() + .filter(|g| g.is_amd && !g.gfx_target.is_empty()) + .map(|g| g.gfx_target.clone()) + .collect() +} + +fn amd_gpu_count(e: &Examination) -> usize { + e.gpus.iter().filter(|g| g.is_amd).count() +} + +fn zero(id: &str, title: &str) -> Diagnosis { + Diagnosis { + id: id.to_owned(), + title: title.to_owned(), + ..Diagnosis::default() + } +} + +fn finalize(id: &str, title: &str, score: i32, evidence: Vec, fix: Fix) -> Diagnosis { + Diagnosis { + id: id.to_owned(), + title: title.to_owned(), + score: score.min(100), + evidence, + fix: Some(fix), + } +} + +// --------------------------------------------------------------------------- +// Per-misconfiguration checkers (1:1 with diagnose.py) +// --------------------------------------------------------------------------- + +fn check_1_arch_not_in_wheel(e: &Examination, symptom: &str) -> Diagnosis { + let mut score = 0; + let mut evidence = Vec::new(); + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_INVALID_ISA); + score += kw_score; + evidence.extend(kw_ev); + + let framework_arch = &e.framework_arch_list; + let gfx_targets = amd_gfx_targets(e); + if !framework_arch.is_empty() && !gfx_targets.is_empty() { + let missing: Vec = gfx_targets + .iter() + .filter(|t| !framework_arch.contains(t)) + .cloned() + .collect(); + if missing.is_empty() { + score -= 30; + evidence.push(format!( + "framework arch list {framework_arch:?} already includes GPU target(s) {gfx_targets:?}" + )); + } else { + score += 55; + evidence.push(format!( + "GPU gfx target(s) {missing:?} not in framework arch list {framework_arch:?}" + )); + } + } + + if matches!(e.framework.as_str(), "pytorch" | "llama-cpp") + && framework_arch.is_empty() + && !gfx_targets.is_empty() + { + evidence.push( + "Framework arch list unknown -- cannot confirm without `python -c 'import torch; print(torch.cuda.get_arch_list())'`." + .to_owned(), + ); + } + + if score <= 0 { + return zero("fix-1-arch", "GPU gfx not in framework arch list"); + } + let fix = Fix { + summary: "Reinstall the framework from a wheel index that includes this GPU's gfx target. Use HSA_OVERRIDE_GFX_VERSION ONLY as a temporary workaround when no native wheel exists.".to_owned(), + commands: vec![ + "# Recommended: PyTorch ROCm nightly that ships the gfx115x kernels.".to_owned(), + "pip uninstall -y torch torchvision torchaudio".to_owned(), + "pip install --pre torch torchvision torchaudio \\\n --index-url https://download.pytorch.org/whl/nightly/rocm6.4".to_owned(), + "# llama.cpp: rebuild with AMDGPU_TARGETS set to this GPU's gfx.".to_owned(), + "# cmake -B build -DGGML_HIP=ON -DAMDGPU_TARGETS=".to_owned(), + ], + fix_id: "fix-1-arch".to_owned(), + auto_applicable: false, + verify: "python -c \"import torch; print(torch.cuda.is_available(), torch.cuda.get_arch_list())\"".to_owned(), + notes: vec![ + "TheRock (rocm/TheRock) ships nightly per-gfx wheels and is the preferred fallback when the official pytorch wheel index does not yet cover your gfx target.".to_owned(), + ], + ..Fix::default() + }; + finalize( + "fix-1-arch", + "GPU gfx target not in framework's build arch list", + score, + evidence, + fix, + ) +} + +fn check_2_hsa_override_unneeded(e: &Examination, symptom: &str) -> Diagnosis { + let override_val = e + .env + .get("HSA_OVERRIDE_GFX_VERSION") + .cloned() + .unwrap_or_default(); + if override_val.is_empty() { + return zero( + "fix-2-unset-override", + "HSA_OVERRIDE_GFX_VERSION set unnecessarily", + ); + } + let mut score = 30; + let mut evidence = vec![format!( + "HSA_OVERRIDE_GFX_VERSION={override_val} is set in the current shell" + )]; + + let (pf_score, pf_ev) = keyword_score(symptom, KEYWORDS_PAGE_FAULT); + score += pf_score; + evidence.extend(pf_ev); + if e.dmesg_amdgpu_tail + .iter() + .any(|l| l.to_lowercase().contains("page fault")) + { + score += 20; + evidence.push("kernel ring shows amdgpu page faults".to_owned()); + } + + let framework_arch = &e.framework_arch_list; + let gfx_targets = amd_gfx_targets(e); + if !framework_arch.is_empty() + && !gfx_targets.is_empty() + && gfx_targets.iter().all(|t| framework_arch.contains(t)) + { + score += 25; + evidence.push(format!( + "every detected GPU target ({gfx_targets:?}) is in the framework arch list ({framework_arch:?}); the override is hiding the native gfx." + )); + } + + let fix = if e.os_family == "windows" { + Fix { + summary: "Clear HSA_OVERRIDE_GFX_VERSION (Windows) and use the native HIP SDK / wheel.".to_owned(), + commands: vec![ + "# Inspect the User and Machine env scopes:".to_owned(), + "[Environment]::GetEnvironmentVariable('HSA_OVERRIDE_GFX_VERSION','User')".to_owned(), + "[Environment]::GetEnvironmentVariable('HSA_OVERRIDE_GFX_VERSION','Machine')".to_owned(), + "# Clear from the User scope (does NOT affect already-open shells):".to_owned(), + "setx HSA_OVERRIDE_GFX_VERSION \"\"".to_owned(), + "# Or remove via System Properties -> Environment Variables.".to_owned(), + ], + fix_id: "fix-2-unset-override".to_owned(), + auto_applicable: true, + verify: "powershell -NoProfile -Command \"[Environment]::GetEnvironmentVariable('HSA_OVERRIDE_GFX_VERSION','User')\"".to_owned(), + ..Fix::default() + } + } else { + Fix { + summary: "Unset HSA_OVERRIDE_GFX_VERSION and use the native wheel.".to_owned(), + commands: vec![ + "unset HSA_OVERRIDE_GFX_VERSION".to_owned(), + "# Also remove it from ~/.bashrc / ~/.zshrc / ~/.profile if persisted.".to_owned(), + ], + fix_id: "fix-2-unset-override".to_owned(), + auto_applicable: true, + verify: "env | grep HSA_OVERRIDE_GFX_VERSION || echo OK_UNSET; python -c \"import torch; print(torch.cuda.is_available())\"".to_owned(), + ..Fix::default() + } + }; + finalize( + "fix-2-unset-override", + "HSA_OVERRIDE_GFX_VERSION set on a GPU that has a native wheel", + score, + evidence, + fix, + ) +} + +fn check_3_rocm_kernel_unsupported(e: &Examination, symptom: &str) -> Diagnosis { + let mut score = 0; + let mut evidence = Vec::new(); + let kernel = &e.kernel_release; + let distro = &e.distro_id; + let distro_v = &e.distro_version; + let rocm_version = &e.rocm_version; + + if !rocm_version.is_empty() && e.amdgpu_loaded == Some(false) { + score += 30; + evidence.push(format!( + "ROCm {rocm_version} is installed but the amdgpu kernel module is not loaded; this is typical when DKMS failed against an unsupported kernel." + )); + } + + let (_kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_DPKG_BROKEN); + if kw_ev.iter().any(|l| l.to_lowercase().contains("dkms")) { + score += 30; + evidence.extend(kw_ev); + } + + if score <= 0 { + return zero("fix-3-rocm-kernel", "ROCm/distro/kernel triple unsupported"); + } + let fix = Fix { + summary: "Cross-check your kernel/distro against the live AMD compatibility matrix before reinstalling.".to_owned(), + commands: vec![ + format!("# Current: kernel={kernel} distro={distro} {distro_v} rocm={rocm_version}"), + "# Compare to the live AMD matrix:".to_owned(), + "# https://rocm.docs.amd.com/projects/install-on-linux/en/latest/reference/system-requirements.html".to_owned(), + "# If your kernel is above the supported range, install the HWE".to_owned(), + "# kernel that matches ROCm, or rerun amdgpu-install with --no-dkms.".to_owned(), + ], + fix_id: "fix-3-rocm-kernel".to_owned(), + auto_applicable: false, + needs_reboot: true, + verify: "lsmod | grep amdgpu && rocminfo | head -n 20".to_owned(), + ..Fix::default() + }; + finalize( + "fix-3-rocm-kernel", + "ROCm version + distro/kernel form an unsupported triple", + score, + evidence, + fix, + ) +} + +fn check_4_render_group(e: &Examination, symptom: &str) -> Diagnosis { + let mut score = 0; + let mut evidence = Vec::new(); + if e.in_render_group == Some(false) { + score += 35; + evidence.push("user is NOT in the 'render' group".to_owned()); + } + if e.in_video_group == Some(false) { + score += 10; + evidence.push("user is NOT in the 'video' group".to_owned()); + } + if let Some(kfd) = &e.kfd + && kfd.exists + && kfd.user_can_write == Some(false) + { + score += 25; + evidence.push(format!( + "/dev/kfd exists (mode {}, group {}) but the current user can't write to it", + kfd.mode, kfd.owner_group + )); + } + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_KFD_PERMISSION); + score += kw_score; + evidence.extend(kw_ev); + + if score <= 0 { + return zero("fix-4-render-group", "User missing render/video group"); + } + let kfd_group = e + .kfd + .as_ref() + .map(|k| k.owner_group.clone()) + .filter(|g| !g.is_empty()) + .unwrap_or_else(|| "render".to_owned()); + let fix = Fix { + summary: format!("Add the current user to '{kfd_group}' (and 'video' for safety) and log out/in."), + commands: vec![format!("sudo usermod -a -G {kfd_group},video \"$USER\"")], + needs_sudo: true, + needs_relogin: true, + fix_id: "fix-4-render-group".to_owned(), + auto_applicable: true, + verify: "groups | tr ' ' '\\n' | grep -E '^(render|video)$' && ls -l /dev/kfd && rocminfo | head -n 5".to_owned(), + notes: vec![ + "Group membership only takes effect after a full re-login (or reboot). `newgrp render` will give the current shell access but not other terminals or services.".to_owned(), + ], + ..Fix::default() + }; + finalize( + "fix-4-render-group", + "User not in render/video group (or /dev/kfd owned by the other group)", + score, + evidence, + fix, + ) +} + +fn check_5_amdgpu_blacklisted(e: &Examination, symptom: &str) -> Diagnosis { + let mut score = 0; + let mut evidence = Vec::new(); + let blacklisted = &e.amdgpu_blacklisted_in; + if !blacklisted.is_empty() { + score += 55; + evidence.push(format!("amdgpu is blacklisted in: {blacklisted:?}")); + } + if e.amdgpu_loaded == Some(false) { + score += 35; + evidence.push("amdgpu module is not loaded".to_owned()); + } + if e.rocminfo_status == "not-loaded" { + score += 25; + evidence.push("rocminfo says 'ROCk module is NOT loaded'".to_owned()); + } + if e.secure_boot == "enabled" && e.amdgpu_loaded == Some(false) { + score += 10; + evidence.push("Secure Boot is enabled and amdgpu didn't load -- DKMS modules are often blocked until you sign them or disable Secure Boot.".to_owned()); + } + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_MODULE_NOT_LOADED); + score += kw_score; + evidence.extend(kw_ev); + + if score <= 0 { + return zero("fix-5-amdgpu-load", "amdgpu not loaded"); + } + let mut commands = Vec::new(); + if !blacklisted.is_empty() { + for f in blacklisted { + commands.push(format!( + "# Inspect & remove the blacklist line: sudo $EDITOR {f}" + )); + } + commands.push("sudo update-initramfs -u # Debian/Ubuntu".to_owned()); + commands.push("sudo dracut -f # Fedora/RHEL".to_owned()); + } + commands.push("sudo modprobe amdgpu".to_owned()); + if e.secure_boot == "enabled" { + commands.push("# Secure Boot is on; if amdgpu still won't load, the DKMS module isn't signed. Sign it (mokutil) or disable Secure Boot.".to_owned()); + } + let fix = Fix { + summary: "Remove amdgpu from any modprobe blacklist and load it.".to_owned(), + commands, + needs_sudo: true, + needs_reboot: !blacklisted.is_empty(), + fix_id: "fix-5-amdgpu-load".to_owned(), + auto_applicable: false, + verify: "lsmod | grep amdgpu && rocminfo | head -n 5".to_owned(), + ..Fix::default() + }; + finalize( + "fix-5-amdgpu-load", + "amdgpu kernel module not loaded (or blacklisted)", + score, + evidence, + fix, + ) +} + +fn check_6_path_missing(e: &Examination, symptom: &str) -> Diagnosis { + let mut score = 0; + let mut evidence = Vec::new(); + let env_path = e.env.get("PATH").cloned().unwrap_or_default(); + let windows = e.os_family == "windows"; + let bin_dir; + + if windows { + let sdk_path = &e.hip_sdk_path; + bin_dir = if sdk_path.is_empty() { + r"C:\Program Files\AMD\ROCm\\bin".to_owned() + } else { + format!("{sdk_path}\\bin") + }; + if !sdk_path.is_empty() && !e.hipinfo_present { + score += 50; + evidence.push(format!( + "{sdk_path} exists but hipInfo.exe wasn't found in its bin directory" + )); + } + if !sdk_path.is_empty() + && !env_path.is_empty() + && !env_path.to_lowercase().contains(&bin_dir.to_lowercase()) + { + score += 20; + evidence.push(format!("{bin_dir} is not in PATH")); + } + } else { + let rocm_path = &e.rocm_path; + bin_dir = if rocm_path.is_empty() { + "/opt/rocm/bin".to_owned() + } else { + format!("{rocm_path}/bin") + }; + if !rocm_path.is_empty() && !e.rocminfo_present { + score += 50; + evidence.push(format!("{rocm_path} exists but `rocminfo` is not on PATH")); + } + if !rocm_path.is_empty() && !env_path.is_empty() && !env_path.contains(&bin_dir) { + score += 20; + evidence.push(format!("{bin_dir} is not in $PATH")); + } + } + + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_PATH_MISSING); + score += kw_score; + evidence.extend(kw_ev); + + if score <= 0 { + return zero("fix-6-path", "ROCm not on PATH"); + } + let fix = if windows { + Fix { + summary: format!("Add {bin_dir} to your User PATH and reopen the shell."), + commands: vec![ + format!("setx PATH \"%PATH%;{bin_dir}\""), + "# Or: System Properties -> Environment Variables -> Path -> Edit -> New." + .to_owned(), + "# `setx` only affects NEW shells; close and reopen this terminal afterwards." + .to_owned(), + ], + fix_id: "fix-6-path".to_owned(), + auto_applicable: true, + verify: format!( + "powershell -NoProfile -Command \"& \\\"{bin_dir}\\hipInfo.exe\\\" | Select-Object -First 5\"" + ), + ..Fix::default() + } + } else { + Fix { + summary: format!("Add {bin_dir} to PATH for this shell and persist in your shell rc."), + commands: vec![ + format!("export PATH={bin_dir}:$PATH"), + format!("echo 'export PATH={bin_dir}:$PATH' >> ~/.bashrc # or ~/.zshrc"), + ], + fix_id: "fix-6-path".to_owned(), + auto_applicable: true, + verify: "rocminfo | head -n 5 && hipcc --version".to_owned(), + ..Fix::default() + } + }; + finalize( + "fix-6-path", + "ROCm/HIP binaries not on PATH after install", + score, + evidence, + fix, + ) +} + +fn check_7_stale_repos(e: &Examination, symptom: &str) -> Diagnosis { + let mut score = 0; + let mut evidence = Vec::new(); + let repos = &e.rocm_repos_seen; + if repos.len() >= 2 { + score += 40; + evidence.push(format!( + "{} ROCm/AMDGPU repo files present: {repos:?}", + repos.len() + )); + } + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_REPO_BROKEN); + score += kw_score; + evidence.extend(kw_ev); + + if score <= 0 { + return zero("fix-7-stale-repos", "Stale ROCm repos"); + } + let mut commands = + vec!["ls /etc/apt/sources.list.d/ | grep -iE 'rocm|amdgpu|radeon' || true".to_owned()]; + for r in repos { + commands.push(format!( + "# sudo mv {r} {r}.bak # quarantine, do not delete yet" + )); + } + commands.push("sudo apt update".to_owned()); + commands.push("# If apt now resolves, reinstall via the correct method only:".to_owned()); + commands.push( + "# amdgpu-install --usecase=rocm,hip --no-dkms # if you want amdgpu-install".to_owned(), + ); + commands.push("# or use the distro packages exclusively".to_owned()); + let fix = Fix { + summary: "Quarantine duplicate ROCm/AMDGPU repo files and resolve apt before re-running any installer.".to_owned(), + commands, + needs_sudo: true, + fix_id: "fix-7-stale-repos".to_owned(), + auto_applicable: false, + verify: "sudo apt update 2>&1 | tail -n 20".to_owned(), + ..Fix::default() + }; + finalize( + "fix-7-stale-repos", + "Stale or conflicting APT/DNF repos from prior installer runs", + score, + evidence, + fix, + ) +} + +fn check_8_wheel_rocm_mismatch(e: &Examination, symptom: &str) -> Diagnosis { + let mut score = 0; + let mut evidence = Vec::new(); + let windows = e.os_family == "windows"; + let fw_rocm = &e.framework_rocm_version; + let sys_rocm = if windows { + &e.hip_sdk_version + } else { + &e.rocm_version + }; + + let fw_major = major_version(fw_rocm); + let sys_major = major_version(sys_rocm); + if let (Some(fw), Some(sys)) = (&fw_major, &sys_major) + && fw != sys + { + score += 50; + let runtime = if windows { "HIP SDK" } else { "ROCm" }; + evidence.push(format!( + "Framework links HIP {fw} but system {runtime} is {sys}" + )); + } + + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_LIB_MISMATCH); + score += kw_score; + evidence.extend(kw_ev); + + if score <= 0 { + return zero("fix-8-wheel-rocm", "Wheel/ROCm mismatch"); + } + let fix = if windows { + Fix { + summary: "Reinstall the framework against the HIP SDK major you have installed (or install the HIP SDK major the wheel needs).".to_owned(), + commands: vec![ + "pip uninstall -y torch torchvision torchaudio".to_owned(), + "# TheRock publishes Windows ROCm wheels per HIP SDK release:".to_owned(), + "# https://github.com/ROCm/TheRock".to_owned(), + "# Match the wheel index to the HIP SDK major you have on disk.".to_owned(), + "python -c \"import torch; print(torch.__version__, torch.version.hip)\"".to_owned(), + ], + fix_id: "fix-8-wheel-rocm".to_owned(), + auto_applicable: false, + verify: "python -c \"import torch; print(torch.cuda.is_available(), torch.version.hip)\"".to_owned(), + ..Fix::default() + } + } else { + Fix { + summary: "Reinstall the framework from the wheel index that matches the system ROCm major (or upgrade the system ROCm to match the wheel).".to_owned(), + commands: vec![ + "pip uninstall -y torch torchvision torchaudio".to_owned(), + "# Pick the index that matches your system ROCm major. Examples:".to_owned(), + "pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.4".to_owned(), + "pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3".to_owned(), + "# Then re-check:".to_owned(), + "python -c \"import torch; print(torch.__version__, torch.version.hip)\"".to_owned(), + ], + fix_id: "fix-8-wheel-rocm".to_owned(), + auto_applicable: false, + verify: "python -c \"import torch; print(torch.cuda.is_available(), torch.version.hip)\"".to_owned(), + ..Fix::default() + } + }; + finalize( + "fix-8-wheel-rocm", + "Framework wheel built for a different ROCm major than the system", + score, + evidence, + fix, + ) +} + +/// Extract `X.Y` from a version-ish string. +fn major_version(s: &str) -> Option { + let re = Regex::new(r"(\d+)\.(\d+)").ok()?; + let caps = re.captures(s)?; + Some(format!("{}.{}", &caps[1], &caps[2])) +} + +fn check_9_igpu_dgpu_collision(e: &Examination, symptom: &str) -> Diagnosis { + if !(e.has_apu && e.has_discrete_amd) { + return zero("fix-9-igpu-dgpu", "iGPU+dGPU collision"); + } + let visible = e + .env + .get("HIP_VISIBLE_DEVICES") + .or_else(|| e.env.get("ROCR_VISIBLE_DEVICES")) + .filter(|v| !v.is_empty()); + let mut score = 40; + let mut evidence = vec!["machine has both an AMD APU and an AMD discrete GPU".to_owned()]; + if visible.is_none() { + score += 25; + evidence.push("HIP_VISIBLE_DEVICES is unset; runtime sees BOTH GPUs".to_owned()); + } + if symptom_matches(symptom, r"(crash|segfault|signal 11)") { + score += 15; + evidence.push("user mentions a crash / segfault".to_owned()); + } + + let gfx_targets = amd_gfx_targets(e); + let fix = if e.os_family == "windows" { + Fix { + summary: "Pin the HIP runtime to the discrete GPU with HIP_VISIBLE_DEVICES so the iGPU is hidden.".to_owned(), + commands: vec![ + "# Confirm which index is the dGPU (hipInfo.exe output order):".to_owned(), + "& \"$env:HIP_PATH\\bin\\hipInfo.exe\" | Select-String \"device#|Name|gcnArchName\"".to_owned(), + "# Then persist HIP_VISIBLE_DEVICES in the User environment:".to_owned(), + "setx HIP_VISIBLE_DEVICES 1".to_owned(), + "# `setx` only takes effect in NEW shells; reopen the terminal.".to_owned(), + ], + fix_id: "fix-9-igpu-dgpu".to_owned(), + auto_applicable: true, + verify: "powershell -NoProfile -Command \"$env:HIP_VISIBLE_DEVICES=1; python -c \\\"import torch; print(torch.cuda.device_count())\\\"\"".to_owned(), + notes: vec![format!("Detected gfx targets: {gfx_targets:?}. The dGPU is usually the higher-numbered family (gfx11xx).")], + ..Fix::default() + } + } else { + Fix { + summary: "Pin the runtime to the discrete GPU with HIP_VISIBLE_DEVICES so the iGPU is hidden.".to_owned(), + commands: vec![ + "# Confirm which index is the dGPU (`rocminfo` output order):".to_owned(), + "rocminfo | grep -E 'Agent |gfx|Marketing'".to_owned(), + "# Then pin HIP to the dGPU (typically index 1 when an APU is index 0):".to_owned(), + "export HIP_VISIBLE_DEVICES=1".to_owned(), + "# Persist in your shell rc or your launch script.".to_owned(), + ], + fix_id: "fix-9-igpu-dgpu".to_owned(), + auto_applicable: false, + verify: "HIP_VISIBLE_DEVICES=1 python -c \"import torch; print(torch.cuda.device_count())\"".to_owned(), + notes: vec![format!("Detected gfx targets: {gfx_targets:?}. The dGPU is usually the higher-numbered family (gfx11xx).")], + ..Fix::default() + } + }; + finalize( + "fix-9-igpu-dgpu", + "iGPU enumerated alongside dGPU and destabilising the runtime", + score, + evidence, + fix, + ) +} + +fn check_10_container_devices(e: &Examination, symptom: &str) -> Diagnosis { + if !e.in_container { + return zero("fix-10-container", "Container missing devices"); + } + let kind = if e.container_kind.is_empty() { + "container".to_owned() + } else { + e.container_kind.clone() + }; + let mut score = 25; + let mut evidence = vec![format!("running inside a {kind}")]; + if let Some(kfd) = &e.kfd { + if !kfd.exists { + score += 40; + evidence.push("/dev/kfd is not present in the container".to_owned()); + } else if kfd.user_can_write == Some(false) { + score += 30; + evidence.push("/dev/kfd is present but not writable by the container user".to_owned()); + } + } else { + score += 40; + evidence.push("/dev/kfd is not present in the container".to_owned()); + } + if e.render_devices.is_empty() { + score += 20; + evidence.push("no /dev/dri/renderD* visible in the container".to_owned()); + } + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_CONTAINER); + score += kw_score; + evidence.extend(kw_ev); + + let fix = Fix { + summary: "Re-launch the container with the AMD devices and the render group passed through.".to_owned(), + commands: vec![ + "# Docker / Podman flags AMD-recommends:".to_owned(), + "docker run --rm -it \\".to_owned(), + " --device=/dev/kfd \\".to_owned(), + " --device=/dev/dri \\".to_owned(), + " --group-add render \\".to_owned(), + " --security-opt seccomp=unconfined \\".to_owned(), + " --shm-size=8g \\".to_owned(), + " rocm/pytorch:latest".to_owned(), + "# Rootless podman: also pass `--userns=keep-id` and ensure the".to_owned(), + "# host user is in the render group; podman maps it through.".to_owned(), + ], + fix_id: "fix-10-container".to_owned(), + auto_applicable: false, + verify: "rocminfo | head -n 5".to_owned(), + notes: vec!["Use rocm/pytorch or rocm/dev-ubuntu-22.04 as a known-good image. Mixing host ROCm + container ROCm versions is a separate footgun.".to_owned()], + ..Fix::default() + }; + finalize( + "fix-10-container", + "Container can't see /dev/kfd or /dev/dri/renderD*", + score, + evidence, + fix, + ) +} + +fn check_11_iommu_hang(e: &Examination, symptom: &str) -> Diagnosis { + if amd_gpu_count(e) < 2 { + return zero("fix-11-iommu", "Multi-GPU IOMMU hang"); + } + let mut score = 0; + let mut evidence = vec![format!("{} AMD GPUs detected", amd_gpu_count(e))]; + let iommu = &e.iommu_kernel_param; + if !iommu.is_empty() && iommu != "pt" { + score += 25; + evidence.push(format!("kernel cmdline has iommu={iommu} (not 'pt')")); + } + if iommu.is_empty() { + score += 10; + evidence.push("no iommu= flag on kernel cmdline (default may be 'on')".to_owned()); + } + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_IOMMU_HANG); + score += kw_score; + evidence.extend(kw_ev); + + if score < 25 { + return zero("fix-11-iommu", "Multi-GPU IOMMU hang"); + } + let fix = Fix { + summary: "Add `iommu=pt` to the kernel command line so DMA goes through pass-through mode. This requires editing GRUB and rebooting.".to_owned(), + commands: vec![ + "# Inspect the current cmdline:".to_owned(), + "cat /proc/cmdline".to_owned(), + "# Edit /etc/default/grub and add iommu=pt to GRUB_CMDLINE_LINUX_DEFAULT:".to_owned(), + "sudo $EDITOR /etc/default/grub".to_owned(), + "sudo update-grub # Debian/Ubuntu".to_owned(), + "sudo grub2-mkconfig -o /boot/grub2/grub.cfg # Fedora/RHEL".to_owned(), + "# Reboot for the change to take effect, then retry the multi-GPU job.".to_owned(), + ], + needs_sudo: true, + needs_reboot: true, + fix_id: "fix-11-iommu".to_owned(), + auto_applicable: false, + verify: "cat /proc/cmdline | grep -o 'iommu=\\w*'".to_owned(), + ..Fix::default() + }; + finalize( + "fix-11-iommu", + "Multi-GPU hang on systems with IOMMU enabled", + score, + evidence, + fix, + ) +} + +fn check_12_amdgpu_install_broken(e: &Examination, symptom: &str) -> Diagnosis { + let mut score = 0; + let mut evidence = Vec::new(); + let method = &e.rocm_install_method; + if method == "amdgpu-install" { + evidence.push("ROCm was installed via amdgpu-install".to_owned()); + } + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_DPKG_BROKEN); + score += kw_score; + evidence.extend(kw_ev); + if method == "amdgpu-install" && kw_score > 0 { + score += 20; + } + + if score <= 0 { + return zero("fix-12-installer", "amdgpu-install broken state"); + } + let fix = Fix { + summary: "Run amdgpu-install's documented uninstall sequence to clear the half-configured state, THEN reinstall without the flag that broke it.".to_owned(), + commands: vec![ + "sudo amdgpu-install --uninstall".to_owned(), + "sudo apt autoremove --purge -y".to_owned(), + "sudo apt update".to_owned(), + "# Reinstall. Drop --accept-eula if you used it previously; the".to_owned(), + "# newer installer rejects it and leaves a half-configured repo.".to_owned(), + "sudo amdgpu-install --usecase=rocm,hip".to_owned(), + ], + needs_sudo: true, + needs_reboot: true, + fix_id: "fix-12-installer".to_owned(), + auto_applicable: false, + verify: "dpkg -l | grep -E 'rocm|amdgpu' | head -n 20 && rocminfo | head -n 5".to_owned(), + notes: vec!["If `apt autoremove` warns it will remove unrelated packages, stop and resolve those by hand before continuing.".to_owned()], + ..Fix::default() + }; + finalize( + "fix-12-installer", + "amdgpu-install left a broken state (repo regression / partial DKMS)", + score, + evidence, + fix, + ) +} + +fn check_13_hip_sdk_missing(e: &Examination, symptom: &str) -> Diagnosis { + if e.os_family != "windows" { + return zero("fix-13-hip-sdk-missing", "HIP SDK not installed"); + } + let mut score = 0; + let mut evidence = Vec::new(); + let sdk_path = &e.hip_sdk_path; + if sdk_path.is_empty() { + score += 35; + evidence.push("No HIP SDK install found under C:\\Program Files\\AMD\\ROCm".to_owned()); + } else if !e.hipinfo_present { + score += 30; + evidence.push(format!( + "HIP SDK at {sdk_path} but hipInfo.exe is missing from its bin directory" + )); + } + if e.has_amd_gpu && e.framework == "pytorch" && e.framework_rocm_version.starts_with("hip=") { + score += 25; + evidence + .push("PyTorch is a HIP build but the HIP SDK is not present on this host".to_owned()); + } + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_HIP_SDK_MISSING); + score += kw_score; + evidence.extend(kw_ev); + + if score <= 0 { + return zero("fix-13-hip-sdk-missing", "HIP SDK not installed"); + } + let fix = Fix { + summary: "Install the AMD HIP SDK for Windows; the HIP runtime DLLs and hipInfo.exe come from there.".to_owned(), + commands: vec![ + "# Download and install the HIP SDK (matched to your framework's HIP major):".to_owned(), + "# https://www.amd.com/en/developer/resources/rocm-hub/hip-sdk.html".to_owned(), + "# After install, reopen the shell so HIP_PATH and PATH pick up the new install.".to_owned(), + ], + fix_id: "fix-13-hip-sdk-missing".to_owned(), + auto_applicable: false, + verify: "powershell -NoProfile -Command \"& \\\"$env:HIP_PATH\\bin\\hipInfo.exe\\\" | Select-Object -First 5\"".to_owned(), + notes: vec!["If you only need PyTorch on Windows AMD and don't need the C/C++ HIP toolchain, the TheRock wheels bundle their own HIP runtime and may not require a system HIP SDK install.".to_owned()], + ..Fix::default() + }; + finalize( + "fix-13-hip-sdk-missing", + "HIP SDK not installed (Windows)", + score, + evidence, + fix, + ) +} + +fn check_14_adrenalin_too_old(e: &Examination, symptom: &str) -> Diagnosis { + if e.os_family != "windows" { + return zero("fix-14-adrenalin-too-old", "Adrenalin driver too old"); + } + let mut score = 0; + let mut evidence = Vec::new(); + let sdk_path = &e.hip_sdk_path; + if !sdk_path.is_empty() && e.hipinfo_present && !matches!(e.hipinfo_status.as_str(), "ok" | "") + { + score += 35; + evidence.push(format!( + "HIP SDK at {sdk_path} is installed but hipInfo.exe reports {:?}; this typically means the kernel-mode driver doesn't match the SDK.", + e.hipinfo_status + )); + } + if !e.adrenalin_version.is_empty() { + evidence.push(format!( + "Adrenalin / kernel-mode driver version: {}", + e.adrenalin_version + )); + } + if symptom_matches(symptom, r"driver.*(too old|out of date|unsupported)") { + score += 35; + evidence.push("error mentions 'driver too old / out of date / unsupported'".to_owned()); + } + if symptom_matches(symptom, r"hsa.*invalid agent|no agents (were )?found") { + score += 25; + evidence.push("HSA error suggests driver/runtime can't enumerate the GPU".to_owned()); + } + + if score <= 0 { + return zero("fix-14-adrenalin-too-old", "Adrenalin driver too old"); + } + let fix = Fix { + summary: "Update the AMD Adrenalin (or PRO) graphics driver to the version the HIP SDK release notes call out as the supported pairing.".to_owned(), + commands: vec![ + "# Cross-check the HIP SDK release notes for the exact driver pairing:".to_owned(), + "# https://rocm.docs.amd.com/projects/install-on-windows/en/latest/install/install.html".to_owned(), + "# Then download the matching driver from:".to_owned(), + "# https://www.amd.com/en/support".to_owned(), + "# Reboot after the install for the kernel-mode driver to take effect.".to_owned(), + ], + needs_reboot: true, + fix_id: "fix-14-adrenalin-too-old".to_owned(), + auto_applicable: false, + verify: "powershell -NoProfile -Command \"(Get-CimInstance Win32_VideoController | Where-Object { $_.Name -like '*AMD*' -or $_.Name -like '*Radeon*' } | Select-Object -First 1).DriverVersion\"".to_owned(), + ..Fix::default() + }; + finalize( + "fix-14-adrenalin-too-old", + "Adrenalin / kernel-mode driver too old for the installed HIP SDK", + score, + evidence, + fix, + ) +} + +fn check_15_msvc_redist(e: &Examination, symptom: &str) -> Diagnosis { + if e.os_family != "windows" { + return zero("fix-15-msvc-redist", "MSVC runtime missing"); + } + let mut score = 0; + let mut evidence = Vec::new(); + if e.msvc_redist_present == Some(false) { + score += 45; + evidence.push("vcruntime140.dll / vcruntime140_1.dll not resolvable on PATH".to_owned()); + } + let (kw_score, kw_ev) = keyword_score(symptom, KEYWORDS_MSVC_REDIST); + score += kw_score; + evidence.extend(kw_ev); + + if score <= 0 { + return zero("fix-15-msvc-redist", "MSVC runtime missing"); + } + let fix = Fix { + summary: "Install the Microsoft Visual C++ 2015-2022 redistributable so the HIP SDK's amdhip64_*.dll can load.".to_owned(), + commands: vec![ + "# Download & install (x64):".to_owned(), + "# https://aka.ms/vs/17/release/vc_redist.x64.exe".to_owned(), + "# After the install, reopen the shell and re-run your import / hipInfo check.".to_owned(), + ], + fix_id: "fix-15-msvc-redist".to_owned(), + auto_applicable: false, + verify: "where vcruntime140.dll && where vcruntime140_1.dll".to_owned(), + notes: vec!["If installing the redistributable still leaves a missing-DLL error, the failing DLL is probably amdhip64_X.dll itself; that points at fix-13-hip-sdk-missing (the HIP SDK install) rather than this fix.".to_owned()], + ..Fix::default() + }; + finalize( + "fix-15-msvc-redist", + "MSVC runtime missing (HIP DLLs cannot load)", + score, + evidence, + fix, + ) +} + +/// A checker plus the OS families it applies to. +type Checker = (fn(&Examination, &str) -> Diagnosis, &'static [&'static str]); + +const CHECKERS: &[Checker] = &[ + (check_1_arch_not_in_wheel, &["linux", "windows"]), + (check_2_hsa_override_unneeded, &["linux", "windows"]), + (check_3_rocm_kernel_unsupported, &["linux"]), + (check_4_render_group, &["linux"]), + (check_5_amdgpu_blacklisted, &["linux"]), + (check_6_path_missing, &["linux", "windows"]), + (check_7_stale_repos, &["linux"]), + (check_8_wheel_rocm_mismatch, &["linux", "windows"]), + (check_9_igpu_dgpu_collision, &["linux", "windows"]), + (check_10_container_devices, &["linux"]), + (check_11_iommu_hang, &["linux"]), + (check_12_amdgpu_install_broken, &["linux"]), + (check_13_hip_sdk_missing, &["windows"]), + (check_14_adrenalin_too_old, &["windows"]), + (check_15_msvc_redist, &["windows"]), +]; + +/// Run every applicable checker, drop zero-score results, sort by score +/// descending (stable, so ties keep catalog order). +fn run_all_checks(e: &Examination, symptom: &str) -> Vec { + let os_family = if e.os_family.is_empty() { + "linux" + } else { + e.os_family.as_str() + }; + let mut results: Vec = CHECKERS + .iter() + .filter(|(_, applicable)| applicable.contains(&os_family)) + .map(|(check, _)| check(e, symptom)) + .filter(|d| d.score > 0) + .collect(); + // Stable sort by score descending: ties keep catalog order. + results.sort_by_key(|d| std::cmp::Reverse(d.score)); + results +} + +fn route_when_no_match(e: &Examination) -> Route { + let target = match e.framework.as_str() { + "pytorch" => "pytorch", + "llama-cpp" => "llama-cpp", + "lemonade" => "lemonade", + "ollama" => "ollama", + "lm-studio" => "lm-studio", + _ => "rocm-core", + }; + Route { + target: target.to_owned(), + url: upstream_tracker(target).to_owned(), + } +} + +/// Diagnose an examination against the closed catalog. +#[must_use] +pub fn diagnose(e: &Examination, symptom: &str) -> DiagnoseReport { + DiagnoseReport { + matched: run_all_checks(e, symptom), + min_score_for_match: MIN_SCORE_FOR_MATCH, + high_confidence_threshold: HIGH_CONFIDENCE, + route_when_no_match: route_when_no_match(e), + } +} + +/// Render the human-facing diagnosis view (mirrors `diagnose.py`'s text output). +#[must_use] +pub fn render_report_text(report: &DiagnoseReport, top: usize) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + if report.matched.is_empty() { + let route = &report.route_when_no_match; + out.push_str("rocm examine: no known misconfiguration matched.\n\n"); + out.push_str("This is the explicit 'I don't recognise this failure mode' case. Do not speculate; file the symptom + this examination output upstream:\n"); + let _ = writeln!(out, " {:>12}: {}", route.target, route.url); + out.push('\n'); + out.push_str("Include the JSON from `rocm examine --json` in your report.\n"); + return out; + } + for (i, d) in report.matched.iter().take(top).enumerate() { + let tier = if d.score >= HIGH_CONFIDENCE { + "HIGH" + } else if d.score >= MIN_SCORE_FOR_MATCH { + "LIKELY" + } else { + "WEAK" + }; + let _ = writeln!(out, "#{} [{tier} score={}/100] {}", i + 1, d.score, d.title); + let _ = writeln!(out, " id: {}", d.id); + for ev in &d.evidence { + let _ = writeln!(out, " - {ev}"); + } + if let Some(fix) = &d.fix { + let _ = writeln!(out, " plan: {}", fix.summary); + for c in &fix.commands { + let _ = writeln!(out, " $ {c}"); + } + let mut flags = Vec::new(); + if fix.needs_sudo { + flags.push("sudo"); + } + if fix.needs_reboot { + flags.push("reboot required"); + } + if fix.needs_relogin { + flags.push("re-login required"); + } + if fix.auto_applicable { + flags.push("rocm examine --fix can run it"); + } + if !flags.is_empty() { + let _ = writeln!(out, " flags: {}", flags.join(", ")); + } + for n in &fix.notes { + let _ = writeln!(out, " note: {n}"); + } + if !fix.verify.is_empty() { + let _ = writeln!(out, " verify after fix: {}", fix.verify); + } + } + out.push('\n'); + } + if let Some(high) = report.matched.iter().find(|d| d.score >= HIGH_CONFIDENCE) { + let _ = writeln!(out, "Next step: propose `rocm examine --fix {}`.", high.id); + } else { + out.push_str("Highest-scoring match is below the HIGH_CONFIDENCE threshold. Confirm one more piece of evidence before applying.\n"); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::examine::{Device, Examination, Gpu}; + + fn linux_base() -> Examination { + Examination { + os_family: "linux".to_owned(), + ..Examination::default() + } + } + + #[test] + fn render_group_missing_is_diagnosed() { + let mut e = linux_base(); + e.in_render_group = Some(false); + e.in_video_group = Some(false); + let report = diagnose(&e, ""); + let top = &report.matched[0]; + assert_eq!(top.id, "fix-4-render-group"); + assert_eq!(top.score, 45); // 35 render + 10 video + assert!(top.fix.as_ref().unwrap().auto_applicable); + } + + #[test] + fn render_group_with_symptom_is_high_confidence() { + let mut e = linux_base(); + e.in_render_group = Some(false); + let report = diagnose(&e, "RuntimeError: unable to open /dev/kfd"); + let top = &report.matched[0]; + assert_eq!(top.id, "fix-4-render-group"); + assert!(top.score >= HIGH_CONFIDENCE, "score was {}", top.score); + assert!(report.has_match()); + } + + #[test] + fn kfd_not_writable_adds_score() { + let mut e = linux_base(); + e.in_render_group = Some(false); + e.kfd = Some(Device { + path: "/dev/kfd".to_owned(), + exists: true, + mode: "crw-rw----".to_owned(), + owner_group: "render".to_owned(), + user_can_write: Some(false), + ..Device::default() + }); + let report = diagnose(&e, ""); + let top = &report.matched[0]; + assert_eq!(top.id, "fix-4-render-group"); + assert_eq!(top.score, 60); // 35 render + 25 kfd + } + + #[test] + fn arch_not_in_wheel_strong_signal() { + let mut e = linux_base(); + e.framework = "pytorch".to_owned(); + e.framework_arch_list = vec!["gfx1100".to_owned()]; + e.gpus = vec![Gpu { + gfx_target: "gfx1151".to_owned(), + is_amd: true, + ..Gpu::default() + }]; + let report = diagnose(&e, "HSA_STATUS_ERROR_INVALID_ISA"); + let top = &report.matched[0]; + assert_eq!(top.id, "fix-1-arch"); + // 50 (keyword) + 55 (missing arch), clamped to 100. + assert_eq!(top.score, 100); + } + + #[test] + fn arch_covered_is_negative_and_filtered() { + let mut e = linux_base(); + e.framework = "pytorch".to_owned(); + e.framework_arch_list = vec!["gfx1151".to_owned()]; + e.gpus = vec![Gpu { + gfx_target: "gfx1151".to_owned(), + is_amd: true, + ..Gpu::default() + }]; + // No symptom: -30 from covered arch => score <= 0 => not reported. + let report = diagnose(&e, ""); + assert!(report.matched.iter().all(|d| d.id != "fix-1-arch")); + } + + #[test] + fn no_match_routes_upstream() { + let mut e = linux_base(); + e.framework = "pytorch".to_owned(); + let report = diagnose(&e, ""); + assert!(report.matched.is_empty()); + assert!(!report.has_match()); + assert_eq!(report.route_when_no_match.target, "pytorch"); + assert!(report.route_when_no_match.url.contains("pytorch/pytorch")); + } + + #[test] + fn no_match_default_route_is_rocm_core() { + let e = linux_base(); + let report = diagnose(&e, ""); + assert_eq!(report.route_when_no_match.target, "rocm-core"); + } + + #[test] + fn windows_only_checks_skipped_on_linux() { + let e = linux_base(); + let report = diagnose(&e, "vcruntime140.dll is missing"); + // fix-15 is windows-only; must not appear on a linux exam. + assert!(report.matched.iter().all(|d| d.id != "fix-15-msvc-redist")); + } + + #[test] + fn msvc_redist_diagnosed_on_windows() { + let mut e = Examination { + os_family: "windows".to_owned(), + ..Examination::default() + }; + e.msvc_redist_present = Some(false); + let report = diagnose( + &e, + "The program can't start because vcruntime140.dll is missing", + ); + let top = &report.matched[0]; + assert_eq!(top.id, "fix-15-msvc-redist"); + assert!(top.score >= MIN_SCORE_FOR_MATCH); + } + + #[test] + fn iommu_requires_two_gpus_and_min_score() { + // Single GPU: never fires. + let mut e = linux_base(); + e.gpus = vec![Gpu { + is_amd: true, + ..Gpu::default() + }]; + e.iommu_kernel_param = "on".to_owned(); + assert!( + diagnose(&e, "hang") + .matched + .iter() + .all(|d| d.id != "fix-11-iommu") + ); + // Two GPUs + iommu=on (25) clears the per-rule >=25 gate. + e.gpus = vec![ + Gpu { + is_amd: true, + ..Gpu::default() + }, + Gpu { + is_amd: true, + ..Gpu::default() + }, + ]; + let report = diagnose(&e, ""); + assert!(report.matched.iter().any(|d| d.id == "fix-11-iommu")); + } + + #[test] + fn keyword_score_takes_top_two() { + // INVALID_ISA: two hits (50 + 40) -> 90, not the sum of all. + let (score, labels) = keyword_score( + "HSA_STATUS_ERROR_INVALID_ISA and invalid device function and no kernel image is available", + KEYWORDS_INVALID_ISA, + ); + assert_eq!(score, 90); + assert_eq!(labels.len(), 2); + } + + #[test] + fn report_serializes_expected_shape() { + let report = diagnose(&linux_base(), ""); + let v = serde_json::to_value(&report).unwrap(); + for key in [ + "matched", + "min_score_for_match", + "high_confidence_threshold", + "route_when_no_match", + ] { + assert!(v.get(key).is_some(), "missing {key}"); + } + assert_eq!(v["min_score_for_match"], 50); + assert_eq!(v["high_confidence_threshold"], 75); + } +} diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs new file mode 100644 index 00000000..c43c4b91 --- /dev/null +++ b/crates/rocm-core/src/examine.rs @@ -0,0 +1,1522 @@ +//! Host examination probe. +//! +//! Rust port of the `rocm-doctor` skill's `examine.py`. It gathers the host +//! signals the diagnosis catalog reasons over and serializes them as the +//! **Examination** JSON document (`rocm examine --json`). The field names and +//! shapes mirror `examine.py` field-for-field so the catalog consumes the CLI's +//! output unchanged. See `plans/rocm-doctor-examine-migration-plan.md`. + +use crate::{runtime_is_linux, runtime_is_windows}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Environment variables that commonly steer (or break) ROCm/HIP runtime +/// behavior. Captured verbatim into `Examination::env`. +const TRACKED_ENV_VARS: &[&str] = &[ + "HSA_OVERRIDE_GFX_VERSION", + "HIP_VISIBLE_DEVICES", + "ROCR_VISIBLE_DEVICES", + "CUDA_VISIBLE_DEVICES", + "GPU_DEVICE_ORDINAL", + "ROCM_PATH", + "ROCM_HOME", + "HIP_PATH", + "HIP_PLATFORM", + "PYTORCH_ROCM_ARCH", + "HCC_AMDGPU_TARGET", + "AMDGPU_TARGETS", + "LD_LIBRARY_PATH", + "PATH", +]; + +/// Repo files dropped by the `amdgpu-install` pipeline; their presence marks an +/// amdgpu-install-managed ROCm. +const AMDGPU_INSTALL_MARKERS: &[&str] = &[ + "/etc/apt/sources.list.d/amdgpu.list", + "/etc/apt/sources.list.d/rocm.list", + "/etc/apt/sources.list.d/radeon.list", + "/etc/yum.repos.d/amdgpu.repo", + "/etc/yum.repos.d/rocm.repo", +]; + +/// Marketing-name fragments that identify an AMD APU when `rocminfo` is absent. +const APU_KEYWORDS: &[&str] = &[ + "strix halo", + "ryzen ai max", + "phoenix", + "hawk point", + "strix point", + "krackan", + "rembrandt", + "raphael", + "barcelo", + "lucienne", + "renoir", + "cezanne", +]; + +/// A single GPU as enumerated by `lspci`/`rocminfo` (Linux) or the display +/// inventory (Windows). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Gpu { + pub name: String, + pub gfx_target: String, + pub pci_id: String, + pub is_apu: Option, + pub is_amd: bool, +} + +/// Stat of a device node such as `/dev/kfd` or `/dev/dri/renderD*`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Device { + pub path: String, + pub exists: bool, + pub mode: String, + pub owner_user: String, + pub owner_group: String, + pub user_can_read: Option, + pub user_can_write: Option, +} + +/// Structured machine state consumed by the diagnosis catalog. Field order and +/// names mirror `examine.py`'s `Examination` dataclass so the JSON contract is +/// identical. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Examination { + // platform + pub os_family: String, + pub os_version: String, + pub distro_id: String, + pub distro_version: String, + pub kernel_release: String, + pub kernel_cmdline: String, + pub is_wsl: bool, + + // hardware + pub cpu_vendor: String, + pub cpu_model: String, + pub gpus: Vec, + pub has_amd_gpu: bool, + pub has_nvidia_gpu: bool, + pub has_apu: bool, + pub has_discrete_amd: bool, + + // driver / runtime (Linux) + pub amdgpu_loaded: Option, + pub amdgpu_blacklisted_in: Vec, + pub amdkfd_loaded: Option, + pub secure_boot: String, + pub iommu_kernel_param: String, + pub kfd: Option, + pub render_devices: Vec, + + // user / groups (Linux) + pub user_name: String, + pub user_groups: Vec, + pub in_render_group: Option, + pub in_video_group: Option, + + // ROCm install (Linux) + pub rocm_version: String, + pub rocm_install_method: String, + pub rocm_path: String, + pub rocminfo_present: bool, + pub rocminfo_status: String, + pub hip_libs_on_ld_path: Option, + pub rocm_repos_seen: Vec, + + // HIP SDK install (Windows) + pub hip_sdk_path: String, + pub hip_sdk_version: String, + pub hipinfo_present: bool, + pub hipinfo_status: String, + pub adrenalin_version: String, + pub msvc_redist_present: Option, + + // framework + pub framework: String, + pub framework_version: String, + pub framework_rocm_version: String, + pub framework_arch_list: Vec, + pub framework_notes: Vec, + + // environment + pub env: BTreeMap, + + // container + pub in_container: bool, + pub container_kind: String, + + // evidence + pub dmesg_amdgpu_tail: Vec, + pub notes: Vec, + pub probe_failures: Vec, +} + +impl Default for Examination { + fn default() -> Self { + Self { + os_family: "unknown".to_owned(), + os_version: String::new(), + distro_id: String::new(), + distro_version: String::new(), + kernel_release: String::new(), + kernel_cmdline: String::new(), + is_wsl: false, + cpu_vendor: "unknown".to_owned(), + cpu_model: String::new(), + gpus: Vec::new(), + has_amd_gpu: false, + has_nvidia_gpu: false, + has_apu: false, + has_discrete_amd: false, + amdgpu_loaded: None, + amdgpu_blacklisted_in: Vec::new(), + amdkfd_loaded: None, + secure_boot: "unknown".to_owned(), + iommu_kernel_param: String::new(), + kfd: None, + render_devices: Vec::new(), + user_name: String::new(), + user_groups: Vec::new(), + in_render_group: None, + in_video_group: None, + rocm_version: String::new(), + rocm_install_method: String::new(), + rocm_path: String::new(), + rocminfo_present: false, + rocminfo_status: String::new(), + hip_libs_on_ld_path: None, + rocm_repos_seen: Vec::new(), + hip_sdk_path: String::new(), + hip_sdk_version: String::new(), + hipinfo_present: false, + hipinfo_status: String::new(), + adrenalin_version: String::new(), + msvc_redist_present: None, + framework: "unknown".to_owned(), + framework_version: String::new(), + framework_rocm_version: String::new(), + framework_arch_list: Vec::new(), + framework_notes: Vec::new(), + env: BTreeMap::new(), + in_container: false, + container_kind: String::new(), + dmesg_amdgpu_tail: Vec::new(), + notes: Vec::new(), + probe_failures: Vec::new(), + } + } +} + +/// Which framework probe to run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameworkProbe { + Auto, + PyTorch, + LlamaCpp, + Skip, +} + +impl Examination { + /// Probe the host and return the examination. Never fails; probe errors are + /// recorded in `probe_failures`/`notes` and the relevant fields are left at + /// their defaults (matching `examine.py`'s degrade-gracefully behavior). + #[must_use] + pub fn probe(framework: FrameworkProbe) -> Self { + let mut e = Self::default(); + probe_os(&mut e); + if e.os_family == "linux" { + probe_cpu_linux(&mut e); + probe_gpus_lspci(&mut e); + probe_gpus_rocminfo(&mut e); + summarise_gpu_categories(&mut e); + probe_modules(&mut e); + probe_user(&mut e); + probe_devices(&mut e); + probe_secure_boot(&mut e); + probe_rocm_install(&mut e); + probe_env(&mut e); + probe_container(&mut e); + probe_dmesg_amdgpu(&mut e); + probe_framework(&mut e, framework); + } else if e.os_family == "windows" { + probe_cpu_windows(&mut e); + probe_gpus_windows(&mut e); + probe_hip_sdk_windows(&mut e); + probe_adrenalin_windows(&mut e); + probe_msvc_redist_windows(&mut e); + summarise_gpu_categories(&mut e); + probe_env(&mut e); + probe_framework(&mut e, framework); + } else { + e.notes.push(format!( + "rocm examine supports Linux and Windows; got {}. This skill cannot help on this platform.", + e.os_family + )); + } + e + } + + /// Exit code mirroring `examine.py`: `2` = wrong platform / WSL / no AMD GPU + /// (skill can't help), `3` = a key probe failed (soft warning), `0` = ok. + #[must_use] + pub fn exit_code(&self) -> i32 { + if self.is_wsl || !matches!(self.os_family.as_str(), "linux" | "windows") { + return 2; + } + if !self.has_amd_gpu { + return 2; + } + if self.os_family == "linux" { + if !self.probe_failures.is_empty() && !self.rocminfo_present && self.gpus.is_empty() { + return 3; + } + } else if !self.probe_failures.is_empty() && !self.hipinfo_present && self.gpus.is_empty() { + return 3; + } + 0 + } +} + +// --------------------------------------------------------------------------- +// Shell / fs helpers (never panic) +// --------------------------------------------------------------------------- + +/// Run a command with a timeout. Returns `(rc, stdout, stderr)`. `rc` is `127` +/// when the program can't be spawned and `124` on timeout. +pub(crate) fn run(program: &str, args: &[&str], timeout: Duration) -> (i32, String, String) { + let Ok(mut child) = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + else { + return (127, String::new(), String::new()); + }; + let stdout_handle = child.stdout.take().map(|mut stdout| { + thread::spawn(move || { + let mut buf = String::new(); + let _ = stdout.read_to_string(&mut buf); + buf + }) + }); + let stderr_handle = child.stderr.take().map(|mut stderr| { + thread::spawn(move || { + let mut buf = String::new(); + let _ = stderr.read_to_string(&mut buf); + buf + }) + }); + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + thread::sleep(Duration::from_millis(20)); + } + Err(_) => break None, + } + }; + let stdout = stdout_handle + .and_then(|handle| handle.join().ok()) + .unwrap_or_default(); + let stderr = stderr_handle + .and_then(|handle| handle.join().ok()) + .unwrap_or_default(); + let rc = match status { + Some(status) => status.code().unwrap_or(-1), + None => 124, + }; + (rc, stdout, stderr) +} + +fn read_text(path: &str) -> String { + std::fs::read_to_string(path).unwrap_or_default() +} + +/// Whether `program` resolves on `PATH` (best-effort, no execution). +pub(crate) fn which(program: &str) -> bool { + let Ok(path) = std::env::var("PATH") else { + return false; + }; + let (sep, exts): (char, &[&str]) = if runtime_is_windows() { + (';', &[".exe", ".bat", ".cmd", ""]) + } else { + (':', &[""]) + }; + for dir in path.split(sep) { + if dir.is_empty() { + continue; + } + for ext in exts { + if Path::new(dir).join(format!("{program}{ext}")).is_file() { + return true; + } + } + } + false +} + +const SHORT: Duration = Duration::from_secs(5); +const MEDIUM: Duration = Duration::from_secs(8); + +// --------------------------------------------------------------------------- +// Platform probes +// --------------------------------------------------------------------------- + +fn probe_os(e: &mut Examination) { + e.os_version = std::env::consts::OS.to_owned(); + if runtime_is_linux() { + e.os_family = "linux".to_owned(); + e.kernel_release = run("uname", &["-r"], SHORT).1.trim().to_owned(); + e.kernel_cmdline = read_text("/proc/cmdline").trim().to_owned(); + let osr = read_text("/etc/os-release"); + for line in osr.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim().trim_matches('"'); + match key { + "ID" => e.distro_id = value.to_owned(), + "VERSION_ID" => e.distro_version = value.to_owned(), + _ => {} + } + } + if let Some(param) = parse_iommu_param(&e.kernel_cmdline) { + e.iommu_kernel_param = param; + } + let proc_version = read_text("/proc/version").to_lowercase(); + if proc_version.contains("microsoft") + || proc_version.contains("wsl") + || std::env::var_os("WSL_DISTRO_NAME").is_some() + { + e.is_wsl = true; + } + } else if runtime_is_windows() { + e.os_family = "windows".to_owned(); + } else { + e.os_family = "other".to_owned(); + } +} + +/// Extract the value of `iommu=` from a kernel cmdline string. +fn parse_iommu_param(cmdline: &str) -> Option { + cmdline.split_whitespace().find_map(|token| { + token + .strip_prefix("iommu=") + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }) +} + +fn probe_cpu_linux(e: &mut Examination) { + let txt = read_text("/proc/cpuinfo"); + for line in txt.lines() { + if (e.cpu_vendor == "unknown") + && line.starts_with("vendor_id") + && let Some((_, value)) = line.split_once(':') + { + let value = value.trim(); + e.cpu_vendor = if value.contains("AMD") { + "amd".to_owned() + } else if value.contains("Intel") { + "intel".to_owned() + } else { + value.to_lowercase() + }; + } + if e.cpu_model.is_empty() + && line.starts_with("model name") + && let Some((_, value)) = line.split_once(':') + { + e.cpu_model = value.trim().to_owned(); + } + if e.cpu_vendor != "unknown" && !e.cpu_model.is_empty() { + break; + } + } +} + +fn probe_cpu_windows(e: &mut Examination) { + let (rc, out, _) = run( + "powershell", + &[ + "-NoProfile", + "-Command", + "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name", + ], + MEDIUM, + ); + if rc == 0 && !out.trim().is_empty() { + e.cpu_model = out + .trim() + .lines() + .next() + .unwrap_or_default() + .trim() + .to_owned(); + let lname = e.cpu_model.to_lowercase(); + e.cpu_vendor = if lname.contains("amd") { + "amd".to_owned() + } else if lname.contains("intel") { + "intel".to_owned() + } else { + "unknown".to_owned() + }; + } else { + e.probe_failures + .push("Get-CimInstance Win32_Processor failed; cannot identify CPU.".to_owned()); + } +} + +// --------------------------------------------------------------------------- +// GPU probes +// --------------------------------------------------------------------------- + +/// Best-effort `(gfx_target, is_apu)` for an AMD marketing name. +fn classify_amd_marketing_name(name: &str) -> (String, bool) { + let mut n = name.to_lowercase(); + for deco in ["(tm)", "(r)", "(c)", "(\u{2122})"] { + n = n.replace(deco, " "); + } + let n = n.split_whitespace().collect::>().join(" "); + let contains = |needle: &str| n.contains(needle); + if contains("ryzen ai max") || contains("strix halo") { + return ("gfx1151".to_owned(), true); + } + if contains("radeon 8050s") || contains("radeon 8060s") || contains("radeon 8045s") { + return ("gfx1151".to_owned(), true); + } + if contains("radeon 880m") + || contains("radeon 890m") + || contains("strix point") + || contains("krackan") + { + return ("gfx1150".to_owned(), true); + } + if contains("radeon 780m") + || contains("radeon 760m") + || contains("radeon 740m") + || contains("phoenix") + || contains("hawk point") + { + return ("gfx1103".to_owned(), true); + } + (String::new(), APU_KEYWORDS.iter().any(|kw| n.contains(kw))) +} + +/// Whether a gfx target belongs to an APU family the doctor cares about +/// (gfx115x / gfx110x / gfx103x). +fn gfx_is_apu_family(gfx: &str) -> bool { + let g = gfx.to_lowercase(); + (g.starts_with("gfx110") || g.starts_with("gfx115")) + && g.len() >= 7 + && g.as_bytes()[6].is_ascii_digit() +} + +fn probe_gpus_lspci(e: &mut Examination) { + if !which("lspci") { + e.probe_failures + .push("lspci not found; cannot enumerate PCI GPUs".to_owned()); + return; + } + let (rc, out, _) = run("lspci", &["-nn", "-D"], MEDIUM); + if rc != 0 { + e.probe_failures + .push("lspci returned non-zero; PCI enumeration incomplete".to_owned()); + return; + } + for line in out.lines() { + let is_controller = line.contains("VGA compatible controller") + || line.contains("3D controller") + || line.contains("Display controller"); + if !is_controller { + continue; + } + let pci_id = line + .split_whitespace() + .next() + .unwrap_or_default() + .to_owned(); + let is_amd = line.contains("[1002") + || line.contains("Advanced Micro Devices") + || line.contains("AMD"); + let is_nvidia = line.contains("[10de") || line.contains("NVIDIA"); + let name = extract_lspci_name(line); + if is_nvidia { + e.has_nvidia_gpu = true; + e.gpus.push(Gpu { + name, + pci_id, + is_amd: false, + is_apu: Some(false), + ..Gpu::default() + }); + continue; + } + if !is_amd { + continue; + } + let (gfx_guess, is_apu_guess) = classify_amd_marketing_name(&name); + e.gpus.push(Gpu { + name, + gfx_target: gfx_guess, + pci_id, + is_apu: Some(is_apu_guess), + is_amd: true, + }); + } +} + +/// Pull the marketing name out of an `lspci -nn` line: the text between the +/// controller-kind `]:` and the trailing `[vendor:device]`. +fn extract_lspci_name(line: &str) -> String { + let after_colon = match line.find("]:") { + Some(idx) => &line[idx + 2..], + None => match line.find(':') { + Some(idx) => &line[idx + 1..], + None => line, + }, + }; + let trimmed = match after_colon.rfind('[') { + Some(idx) => &after_colon[..idx], + None => after_colon, + }; + trimmed.trim().to_owned() +} + +fn probe_gpus_rocminfo(e: &mut Examination) { + if !which("rocminfo") { + e.rocminfo_present = false; + e.rocminfo_status = "missing".to_owned(); + return; + } + e.rocminfo_present = true; + let (rc, out, err) = run("rocminfo", &[], Duration::from_secs(15)); + if rc != 0 { + let merged = format!("{out}\n{err}").to_lowercase(); + e.rocminfo_status = if merged.contains("rock module is not loaded") { + "not-loaded".to_owned() + } else if merged.contains("permission denied") || merged.contains("operation not permitted") + { + "permission-denied".to_owned() + } else { + format!("error rc={rc}") + }; + return; + } + e.rocminfo_status = "ok".to_owned(); + + let mut gfx_targets: Vec<(String, String)> = Vec::new(); + let mut cur_name = String::new(); + let mut cur_marketing = String::new(); + let mut cur_is_gpu = false; + for line in out.lines() { + let s = line.trim(); + if s.starts_with("Agent ") { + if cur_is_gpu && cur_name.starts_with("gfx") { + gfx_targets.push((cur_name.clone(), cur_marketing.clone())); + } + cur_name.clear(); + cur_marketing.clear(); + cur_is_gpu = false; + } else if let Some(rest) = s.strip_prefix("Name:") { + cur_name = rest.trim().to_owned(); + } else if let Some(rest) = s.strip_prefix("Marketing Name:") { + cur_marketing = rest.trim().to_owned(); + } else if let Some(rest) = s.strip_prefix("Device Type:") { + cur_is_gpu = rest.contains("GPU"); + } + } + if cur_is_gpu && cur_name.starts_with("gfx") { + gfx_targets.push((cur_name, cur_marketing)); + } + if gfx_targets.is_empty() { + return; + } + + let amd_indices: Vec = e + .gpus + .iter() + .enumerate() + .filter(|(_, g)| g.is_amd) + .map(|(idx, _)| idx) + .collect(); + for (idx, (gfx, marketing)) in gfx_targets.into_iter().enumerate() { + if let Some(&gpu_idx) = amd_indices.get(idx) { + let gpu = &mut e.gpus[gpu_idx]; + gpu.gfx_target = gfx.clone(); + if !marketing.is_empty() && gpu.name.is_empty() { + gpu.name = marketing; + } + gpu.is_apu = Some(gfx_is_apu_family(&gfx)); + } else { + let is_apu = gfx_is_apu_family(&gfx); + e.gpus.push(Gpu { + name: if marketing.is_empty() { + "AMD GPU".to_owned() + } else { + marketing + }, + gfx_target: gfx, + is_amd: true, + is_apu: Some(is_apu), + ..Gpu::default() + }); + } + } +} + +fn summarise_gpu_categories(e: &mut Examination) { + e.has_amd_gpu = e.gpus.iter().any(|g| g.is_amd); + e.has_apu = e.gpus.iter().any(|g| g.is_amd && g.is_apu == Some(true)); + e.has_discrete_amd = e.gpus.iter().any(|g| g.is_amd && g.is_apu == Some(false)); +} + +// --------------------------------------------------------------------------- +// Kernel module / device probes (Linux) +// --------------------------------------------------------------------------- + +fn probe_modules(e: &mut Examination) { + let (rc, out, _) = run("lsmod", &[], SHORT); + let module_text = if rc == 0 { + Some(out.lines().skip(1).collect::>().join("\n")) + } else { + let txt = read_text("/proc/modules"); + if txt.is_empty() { None } else { Some(txt) } + }; + if let Some(text) = module_text { + let modules: Vec<&str> = text + .lines() + .filter_map(|line| line.split_whitespace().next()) + .collect(); + e.amdgpu_loaded = Some(modules.contains(&"amdgpu")); + e.amdkfd_loaded = Some(modules.contains(&"amdkfd")); + } + + for dir in ["/etc/modprobe.d", "/usr/lib/modprobe.d", "/run/modprobe.d"] { + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("conf") { + continue; + } + let body = read_text(&path.to_string_lossy()); + if body.lines().any(line_blacklists_amdgpu) { + e.amdgpu_blacklisted_in + .push(path.to_string_lossy().into_owned()); + } + } + } +} + +/// Matches `^\s*blacklist\s+amdgpu\b`. +fn line_blacklists_amdgpu(line: &str) -> bool { + let rest = line.trim_start(); + let Some(rest) = rest.strip_prefix("blacklist") else { + return false; + }; + let rest = rest.trim_start(); + rest == "amdgpu" + || rest.strip_prefix("amdgpu").is_some_and(|tail| { + tail.is_empty() || !tail.starts_with(|c: char| c.is_alphanumeric() || c == '_') + }) +} + +fn probe_devices(e: &mut Examination) { + e.kfd = Some(stat_device("/dev/kfd", &e.user_name, &e.user_groups)); + if let Ok(entries) = std::fs::read_dir("/dev/dri") { + let mut render: Vec = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + name.starts_with("renderD") + .then(|| entry.path().to_string_lossy().into_owned()) + }) + .collect(); + render.sort(); + for path in render { + e.render_devices + .push(stat_device(&path, &e.user_name, &e.user_groups)); + } + } +} + +fn stat_device(path: &str, user_name: &str, user_groups: &[String]) -> Device { + let mut device = Device { + path: path.to_owned(), + exists: Path::new(path).exists(), + ..Device::default() + }; + if !device.exists { + return device; + } + let (rc, out, _) = run("stat", &["-c", "%A|%U|%G", path], SHORT); + if rc == 0 { + let fields: Vec<&str> = out.trim().split('|').collect(); + if fields.len() == 3 { + device.mode = fields[0].to_owned(); + device.owner_user = fields[1].to_owned(); + device.owner_group = fields[2].to_owned(); + let (can_read, can_write) = mode_access( + &device.mode, + &device.owner_user, + &device.owner_group, + user_name, + user_groups, + ); + device.user_can_read = can_read; + device.user_can_write = can_write; + } + } + device +} + +/// Derive read/write access from a `stat`-style mode string and group +/// membership, following POSIX precedence (owner, then group, then other). +fn mode_access( + mode: &str, + owner_user: &str, + owner_group: &str, + user_name: &str, + user_groups: &[String], +) -> (Option, Option) { + let bytes = mode.as_bytes(); + if bytes.len() < 10 { + return (None, None); + } + let class = if !user_name.is_empty() && user_name == owner_user { + 1 + } else if user_groups.iter().any(|g| g == owner_group) { + 4 + } else { + 7 + }; + let read = bytes[class] == b'r'; + let write = bytes[class + 1] == b'w'; + (Some(read), Some(write)) +} + +fn probe_user(e: &mut Examination) { + e.user_name = std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .unwrap_or_default(); + let (rc, out, _) = run("id", &["-Gn"], Duration::from_secs(3)); + if rc == 0 { + e.user_groups = out.split_whitespace().map(str::to_owned).collect(); + } + e.in_render_group = Some(e.user_groups.iter().any(|g| g == "render")); + e.in_video_group = Some(e.user_groups.iter().any(|g| g == "video")); +} + +fn probe_secure_boot(e: &mut Examination) { + if !which("mokutil") { + return; + } + let (rc, out, _) = run("mokutil", &["--sb-state"], Duration::from_secs(3)); + if rc == 0 { + let o = out.to_lowercase(); + if o.contains("enabled") { + e.secure_boot = "enabled".to_owned(); + } else if o.contains("disabled") { + e.secure_boot = "disabled".to_owned(); + } + } +} + +// --------------------------------------------------------------------------- +// ROCm install probe (Linux) +// --------------------------------------------------------------------------- + +fn probe_rocm_install(e: &mut Examination) { + let mut rocm_dir = String::new(); + let rocm_path_env = std::env::var("ROCM_PATH").unwrap_or_default(); + for candidate in ["/opt/rocm", rocm_path_env.as_str()] { + if !candidate.is_empty() && Path::new(candidate).is_dir() { + rocm_dir = candidate.to_owned(); + break; + } + } + e.rocm_path = rocm_dir.clone(); + + if !rocm_dir.is_empty() { + for fname in ["version", "version-utils", "version-libs"] { + let f = Path::new(&rocm_dir).join(".info").join(fname); + if f.exists() { + e.rocm_version = read_text(&f.to_string_lossy()).trim().to_owned(); + break; + } + } + if e.rocm_version.is_empty() + && let Ok(real) = std::fs::canonicalize(&rocm_dir) + && let Some(version) = extract_rocm_version(&real.to_string_lossy()) + { + e.rocm_version = version; + } + } + + for marker in AMDGPU_INSTALL_MARKERS { + if Path::new(marker).exists() { + e.rocm_install_method = "amdgpu-install".to_owned(); + e.rocm_repos_seen.push((*marker).to_owned()); + } + } + + if e.rocm_install_method.is_empty() { + if which("dpkg") { + let (rc, out, _) = run("dpkg", &["-l", "rocm-hip-runtime"], MEDIUM); + if rc == 0 && out.contains("rocm-hip-runtime") { + e.rocm_install_method = "apt".to_owned(); + } + } + if e.rocm_install_method.is_empty() && which("rpm") { + let (rc, out, _) = run("rpm", &["-q", "rocm-hip-runtime"], MEDIUM); + if rc == 0 && out.contains("rocm-hip-runtime") { + e.rocm_install_method = "dnf".to_owned(); + } + } + } + if e.rocm_install_method.is_empty() { + e.rocm_install_method = if rocm_dir.is_empty() { + "none".to_owned() + } else { + "tarball-or-other".to_owned() + }; + } + + for dir in ["/etc/apt/sources.list.d", "/etc/yum.repos.d"] { + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_lowercase(); + if name.contains("rocm") || name.contains("amdgpu") || name.contains("radeon") { + let full = entry.path().to_string_lossy().into_owned(); + if !e.rocm_repos_seen.contains(&full) { + e.rocm_repos_seen.push(full); + } + } + } + } +} + +/// Pull `X.Y[.Z]` out of a `rocm-X.Y.Z` path component. +fn extract_rocm_version(path: &str) -> Option { + let idx = path.find("rocm-")?; + let tail = &path[idx + "rocm-".len()..]; + let version: String = tail + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + let trimmed = version.trim_matches('.'); + (trimmed.contains('.')).then(|| trimmed.to_owned()) +} + +// --------------------------------------------------------------------------- +// Framework probes +// --------------------------------------------------------------------------- + +const PYTORCH_PROBE: &str = concat!( + "import json,sys\n", + "out={'ok':False}\n", + "try:\n", + " import torch\n", + " out['ok']=True\n", + " out['version']=torch.__version__\n", + " out['hip']=getattr(torch.version,'hip',None)\n", + " out['cuda']=getattr(torch.version,'cuda',None)\n", + " out['is_available']=bool(torch.cuda.is_available())\n", + " try: out['device_count']=int(torch.cuda.device_count())\n", + " except Exception: out['device_count']=0\n", + " try: out['arch_list']=list(torch.cuda.get_arch_list())\n", + " except Exception: out['arch_list']=[]\n", + "except Exception as ex:\n", + " out['error']=type(ex).__name__+': '+str(ex)\n", + "sys.stdout.write(json.dumps(out))\n", +); + +fn probe_framework(e: &mut Examination, framework: FrameworkProbe) { + match framework { + FrameworkProbe::Skip => e.framework = "skipped".to_owned(), + FrameworkProbe::PyTorch => probe_pytorch(e), + FrameworkProbe::LlamaCpp => probe_llama_cpp(e), + FrameworkProbe::Auto => { + if which("python") || which("python3") { + probe_pytorch(e); + if e.framework == "pytorch" { + return; + } + } + probe_llama_cpp(e); + } + } +} + +fn probe_pytorch(e: &mut Examination) { + let py = if which("python") { + "python" + } else if which("python3") { + "python3" + } else { + e.framework_notes + .push("No python interpreter found to probe torch.".to_owned()); + return; + }; + let (rc, out, err) = run(py, &["-c", PYTORCH_PROBE], Duration::from_secs(20)); + let (out, err) = if (rc != 0 || out.trim().is_empty()) && py == "python" && which("python3") { + let (_, out2, err2) = run("python3", &["-c", PYTORCH_PROBE], Duration::from_secs(20)); + if out2.trim().is_empty() { + (out, err) + } else { + (out2, err2) + } + } else { + (out, err) + }; + if out.trim().is_empty() { + e.framework_notes.push( + "Could not import torch; if PyTorch is in a venv, activate it and re-run inside that venv." + .to_owned(), + ); + if let Some(last) = err.trim().lines().last() { + let snippet: String = last.chars().take(200).collect(); + e.framework_notes.push(format!("python stderr: {snippet}")); + } + return; + } + let Ok(data) = serde_json::from_str::(out.trim()) else { + let snippet: String = out.chars().take(200).collect(); + e.framework_notes + .push(format!("torch probe returned non-JSON: {snippet}")); + return; + }; + if data.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { + let err = data + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + e.framework_notes + .push(format!("torch import failed: {err}")); + return; + } + e.framework = "pytorch".to_owned(); + e.framework_version = data + .get("version") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + let hip = data.get("hip").and_then(serde_json::Value::as_str); + let cuda = data.get("cuda").and_then(serde_json::Value::as_str); + if let Some(hip) = hip.filter(|h| !h.is_empty()) { + e.framework_rocm_version = format!("hip={hip}"); + } else if let Some(cuda) = cuda.filter(|c| !c.is_empty()) { + e.framework_rocm_version = format!("cuda={cuda}"); + e.framework_notes.push( + "This torch wheel is a CUDA build, not a ROCm build. Reinstall from the ROCm wheel index." + .to_owned(), + ); + } + if let Some(arch) = data.get("arch_list").and_then(serde_json::Value::as_array) { + e.framework_arch_list = arch + .iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect(); + } + if data + .get("is_available") + .and_then(serde_json::Value::as_bool) + == Some(false) + { + e.framework_notes.push( + "torch.cuda.is_available() returned False -- runtime can't see a GPU.".to_owned(), + ); + } +} + +fn probe_llama_cpp(e: &mut Examination) { + let binary = ["llama-cli", "llama-server", "main"] + .into_iter() + .find(|name| which(name)); + let Some(binary) = binary else { + e.framework_notes + .push("No llama.cpp binary (llama-cli/llama-server/main) on PATH.".to_owned()); + return; + }; + let (rc, out, err) = run(binary, &["--version"], Duration::from_secs(10)); + let body = format!("{out}{err}"); + if rc != 0 && body.is_empty() { + e.framework_notes + .push(format!("{binary} --version exited rc={rc}")); + return; + } + e.framework = "llama-cpp".to_owned(); + e.framework_version = body.trim().lines().next().map_or_else( + || "unknown".to_owned(), + |line| line.chars().take(200).collect(), + ); + if body.contains("HIP") || body.contains("ROCm") || body.contains("hipBLAS") { + e.framework_rocm_version = "GGML_HIP=ON".to_owned(); + } else { + e.framework_notes.push( + "llama.cpp binary doesn't advertise HIP/ROCm support; was it built with `cmake -DGGML_HIP=ON -DAMDGPU_TARGETS=`?" + .to_owned(), + ); + } +} + +// --------------------------------------------------------------------------- +// Misc probes +// --------------------------------------------------------------------------- + +fn probe_env(e: &mut Examination) { + for key in TRACKED_ENV_VARS { + let Ok(value) = std::env::var(key) else { + continue; + }; + let value = if matches!(*key, "PATH" | "LD_LIBRARY_PATH") && value.len() > 4000 { + format!("{}...[truncated]", &value[..4000]) + } else { + value + }; + e.env.insert((*key).to_owned(), value); + } + let ld = std::env::var("LD_LIBRARY_PATH").unwrap_or_default(); + let mut hit: Option = None; + for dir in ld.split(':') { + if dir.is_empty() { + continue; + } + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + if entry + .file_name() + .to_string_lossy() + .starts_with("libamdhip64") + { + hit = Some(entry.path().to_string_lossy().into_owned()); + break; + } + } + } + if hit.is_some() { + break; + } + } + if let Some(hit) = hit { + e.hip_libs_on_ld_path = Some(true); + e.notes + .push(format!("libamdhip64 visible via LD_LIBRARY_PATH: {hit}")); + } else { + e.hip_libs_on_ld_path = if ld.is_empty() { None } else { Some(false) }; + } +} + +fn probe_container(e: &mut Examination) { + for (marker, kind) in [("/.dockerenv", "docker"), ("/run/.containerenv", "podman")] { + if Path::new(marker).exists() { + e.in_container = true; + e.container_kind = kind.to_owned(); + return; + } + } + let cg = read_text("/proc/1/cgroup"); + if !cg.is_empty() + && ["docker", "containerd", "lxc", "kubepods", "podman"] + .iter() + .any(|x| cg.contains(x)) + { + e.in_container = true; + if e.container_kind.is_empty() { + e.container_kind = "container".to_owned(); + } + } +} + +fn probe_dmesg_amdgpu(e: &mut Examination) { + let (rc, out, _) = run("journalctl", &["-k", "--no-pager", "-n", "400"], MEDIUM); + let text = if rc == 0 && !out.is_empty() { + out + } else { + let (rc2, out2, _) = run("dmesg", &[], SHORT); + if rc2 == 0 { out2 } else { String::new() } + }; + if text.is_empty() { + return; + } + let interesting = [ + "page fault", + "ras controller", + "vm_fault", + "amdgpu_device_init", + "out_of_registers", + "ring", + "gpu reset", + "psp", + "hw_fault", + ]; + let mut hits: Vec = Vec::new(); + for line in text.lines() { + if !line.contains("amdgpu") && !line.contains("amdkfd") { + continue; + } + let lower = line.to_lowercase(); + if interesting.iter().any(|s| lower.contains(s)) { + hits.push(line.trim().chars().take(300).collect()); + } + } + let start = hits.len().saturating_sub(15); + e.dmesg_amdgpu_tail = hits.split_off(start); +} + +// --------------------------------------------------------------------------- +// Windows-specific probes (best-effort) +// --------------------------------------------------------------------------- + +const WIN_GPU_SCRIPT: &str = "Get-CimInstance Win32_VideoController | Where-Object { $_.PNPDeviceID -match 'VEN_1002' -or $_.AdapterCompatibility -match 'AMD|Advanced Micro Devices' -or $_.Name -match 'AMD|Radeon|Instinct' -or $_.PNPDeviceID -match 'VEN_10DE' -or $_.Name -match 'NVIDIA' } | ForEach-Object { \"$($_.Name)`t$($_.DriverVersion)`t$($_.PNPDeviceID)\" }"; + +fn probe_gpus_windows(e: &mut Examination) { + let (rc, out, _) = run( + "powershell", + &["-NoProfile", "-Command", WIN_GPU_SCRIPT], + MEDIUM, + ); + if rc != 0 { + e.probe_failures + .push("Win32_VideoController query failed; cannot enumerate GPUs.".to_owned()); + return; + } + for line in out.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let fields: Vec<&str> = line.split('\t').collect(); + let name = fields + .first() + .copied() + .unwrap_or_default() + .trim() + .to_owned(); + let pnp = fields.get(2).copied().unwrap_or_default().trim().to_owned(); + let lname = name.to_lowercase(); + let is_amd = pnp.to_uppercase().contains("VEN_1002") + || lname.contains("amd") + || lname.contains("radeon") + || lname.contains("instinct"); + let is_nvidia = pnp.to_uppercase().contains("VEN_10DE") || lname.contains("nvidia"); + if is_nvidia && !is_amd { + e.has_nvidia_gpu = true; + e.gpus.push(Gpu { + name, + pci_id: pnp, + is_amd: false, + is_apu: Some(false), + ..Gpu::default() + }); + continue; + } + if !is_amd { + continue; + } + let (gfx_guess, is_apu_guess) = classify_amd_marketing_name(&name); + e.gpus.push(Gpu { + name, + gfx_target: gfx_guess, + pci_id: pnp, + is_apu: Some(is_apu_guess), + is_amd: true, + }); + } +} + +fn probe_hip_sdk_windows(e: &mut Examination) { + let mut root = std::env::var("HIP_PATH").unwrap_or_default(); + if root.is_empty() || !Path::new(&root).is_dir() { + // Scan the conventional install location for the newest ROCm dir. + let base = Path::new(r"C:\Program Files\AMD\ROCm"); + if let Ok(entries) = std::fs::read_dir(base) { + let mut versions: Vec = entries + .flatten() + .filter(|entry| entry.path().is_dir()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + versions.sort(); + if let Some(latest) = versions.last() { + root = base.join(latest).to_string_lossy().into_owned(); + } + } + } + if root.is_empty() || !Path::new(&root).is_dir() { + return; + } + e.hip_sdk_path = root.clone(); + e.hip_sdk_version = Path::new(&root) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + + let hipinfo = Path::new(&root).join("bin").join("hipInfo.exe"); + if hipinfo.is_file() { + e.hipinfo_present = true; + let (rc, out, _) = run(&hipinfo.to_string_lossy(), &[], Duration::from_secs(15)); + if rc == 0 { + e.hipinfo_status = "ok".to_owned(); + for line in out.lines() { + if let Some(rest) = line.trim().strip_prefix("gcnArchName:") + && let Some(gfx) = crate::extract_first_gfx_token(rest) + && let Some(gpu) = e + .gpus + .iter_mut() + .find(|g| g.is_amd && g.gfx_target.is_empty()) + { + gpu.gfx_target = gfx; + gpu.is_apu = Some(gfx_is_apu_family(&gpu.gfx_target)); + } + } + } else { + e.hipinfo_status = format!("error rc={rc}"); + } + } else { + e.hipinfo_present = false; + e.hipinfo_status = "missing".to_owned(); + } +} + +fn probe_adrenalin_windows(e: &mut Examination) { + let script = "(Get-CimInstance Win32_VideoController | Where-Object { $_.PNPDeviceID -match 'VEN_1002' -or $_.Name -match 'AMD|Radeon|Instinct' } | Select-Object -First 1).DriverVersion"; + let (rc, out, _) = run("powershell", &["-NoProfile", "-Command", script], MEDIUM); + if rc == 0 && !out.trim().is_empty() { + e.adrenalin_version = out + .trim() + .lines() + .next() + .unwrap_or_default() + .trim() + .to_owned(); + } +} + +fn probe_msvc_redist_windows(e: &mut Examination) { + let mut search_dirs: Vec = Vec::new(); + if let Ok(path) = std::env::var("PATH") { + search_dirs.extend(path.split(';').map(str::to_owned)); + } + for dir in [r"C:\Windows\System32", r"C:\Windows\SysWOW64"] { + search_dirs.push(dir.to_owned()); + } + let present = search_dirs.iter().any(|dir| { + !dir.is_empty() + && (Path::new(dir).join("vcruntime140.dll").is_file() + || Path::new(dir).join("vcruntime140_1.dll").is_file()) + }); + e.msvc_redist_present = Some(present); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn examination_serializes_expected_keys() { + let e = Examination::default(); + let value = serde_json::to_value(&e).expect("serialize"); + // A representative slice of the contract diagnose.py depends on. + for key in [ + "os_family", + "gpus", + "has_amd_gpu", + "in_render_group", + "amdgpu_loaded", + "kfd", + "rocm_install_method", + "rocminfo_status", + "framework_arch_list", + "env", + "dmesg_amdgpu_tail", + "probe_failures", + ] { + assert!(value.get(key).is_some(), "missing key: {key}"); + } + } + + #[test] + fn examination_top_level_keys_match_examine_py_contract() { + // The exact field set examine.py emits. diagnose.py reads against these + // names, so this is the frozen wire contract — adding/removing/renaming a + // top-level field here is a contract change and must be intentional. + let expected: std::collections::BTreeSet<&str> = [ + "os_family", + "os_version", + "distro_id", + "distro_version", + "kernel_release", + "kernel_cmdline", + "is_wsl", + "cpu_vendor", + "cpu_model", + "gpus", + "has_amd_gpu", + "has_nvidia_gpu", + "has_apu", + "has_discrete_amd", + "amdgpu_loaded", + "amdgpu_blacklisted_in", + "amdkfd_loaded", + "secure_boot", + "iommu_kernel_param", + "kfd", + "render_devices", + "user_name", + "user_groups", + "in_render_group", + "in_video_group", + "rocm_version", + "rocm_install_method", + "rocm_path", + "rocminfo_present", + "rocminfo_status", + "hip_libs_on_ld_path", + "rocm_repos_seen", + "hip_sdk_path", + "hip_sdk_version", + "hipinfo_present", + "hipinfo_status", + "adrenalin_version", + "msvc_redist_present", + "framework", + "framework_version", + "framework_rocm_version", + "framework_arch_list", + "framework_notes", + "env", + "in_container", + "container_kind", + "dmesg_amdgpu_tail", + "notes", + "probe_failures", + ] + .into_iter() + .collect(); + let value = serde_json::to_value(Examination::default()).expect("serialize"); + let actual: std::collections::BTreeSet<&str> = value + .as_object() + .expect("object") + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + actual, expected, + "Examination top-level keys drifted from examine.py" + ); + } + + #[test] + fn default_uses_unknown_sentinels() { + let e = Examination::default(); + assert_eq!(e.os_family, "unknown"); + assert_eq!(e.cpu_vendor, "unknown"); + assert_eq!(e.secure_boot, "unknown"); + assert_eq!(e.framework, "unknown"); + } + + #[test] + fn examination_round_trips_through_json() { + let e = Examination::default(); + let json = serde_json::to_string(&e).expect("serialize"); + let back: Examination = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.os_family, e.os_family); + assert_eq!(back.framework, e.framework); + } + + #[test] + fn optional_bool_serializes_as_null_not_omitted() { + let e = Examination::default(); + let value = serde_json::to_value(&e).expect("serialize"); + assert!(value.get("in_render_group").expect("present").is_null()); + assert!(value.get("amdgpu_loaded").expect("present").is_null()); + assert!(value.get("kfd").expect("present").is_null()); + } + + #[test] + fn iommu_param_parsed_from_cmdline() { + assert_eq!( + parse_iommu_param("BOOT_IMAGE=/vmlinuz iommu=pt amd_iommu=on quiet"), + Some("pt".to_owned()) + ); + assert_eq!(parse_iommu_param("BOOT_IMAGE=/vmlinuz quiet"), None); + } + + #[test] + fn lspci_name_extraction() { + let line = "0000:03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Navi 31 [Radeon RX 7900 XTX] [1002:744c]"; + assert_eq!( + extract_lspci_name(line), + "Advanced Micro Devices, Inc. [AMD/ATI] Navi 31 [Radeon RX 7900 XTX]" + ); + } + + #[test] + fn blacklist_amdgpu_detection() { + assert!(line_blacklists_amdgpu("blacklist amdgpu")); + assert!(line_blacklists_amdgpu(" blacklist amdgpu")); + assert!(!line_blacklists_amdgpu("blacklist amdgpufoo")); + assert!(!line_blacklists_amdgpu("# blacklist amdgpu")); + } + + #[test] + fn mode_access_owner_group_other_precedence() { + // crw-rw---- root render: a render member can write, others cannot. + let groups = vec!["render".to_owned()]; + let (r, w) = mode_access("crw-rw----", "root", "render", "alice", &groups); + assert_eq!((r, w), (Some(true), Some(true))); + let (r, w) = mode_access("crw-rw----", "root", "render", "alice", &[]); + assert_eq!((r, w), (Some(false), Some(false))); + } + + #[test] + fn gfx_apu_family_classification() { + // Mirrors examine.py's `gfx11[05]\d` regex exactly (faithful parity), + // including that it matches gfx1100 — diagnose.py is written against + // this behavior, so we reproduce it rather than "correct" it. + assert!(gfx_is_apu_family("gfx1151")); + assert!(gfx_is_apu_family("gfx1103")); + assert!(gfx_is_apu_family("gfx1100")); + assert!(!gfx_is_apu_family("gfx1200")); + assert!(!gfx_is_apu_family("gfx942")); + } + + #[test] + fn marketing_name_maps_strix_halo() { + assert_eq!( + classify_amd_marketing_name("AMD Radeon(TM) 8060S Graphics"), + ("gfx1151".to_owned(), true) + ); + assert_eq!( + classify_amd_marketing_name("Ryzen AI Max+ 395"), + ("gfx1151".to_owned(), true) + ); + } + + #[test] + fn rocm_version_extracted_from_path() { + assert_eq!( + extract_rocm_version("/opt/rocm-6.4.1"), + Some("6.4.1".to_owned()) + ); + assert_eq!(extract_rocm_version("/opt/rocm"), None); + } +} diff --git a/crates/rocm-core/src/fix.rs b/crates/rocm-core/src/fix.rs new file mode 100644 index 00000000..cd240eb4 --- /dev/null +++ b/crates/rocm-core/src/fix.rs @@ -0,0 +1,1034 @@ +//! Apply remediations for diagnosed ROCm failure modes. +//! +//! Rust port of the `rocm-doctor` skill's `apply_fix.py`. Only small, safe, +//! well-bounded fixes are auto-applicable (the runners below); everything else +//! is advisory and only prints its plan. The consent model mirrors the Python: +//! print the exact change, honor `--dry-run`, refuse on a non-interactive shell +//! without `--yes`, and otherwise confirm before mutating anything. +//! +//! Exit codes match `apply_fix.py`: `0` ok/dry-run/print-only, `2` unknown id, +//! `3` environment/OS not right, `4` a command failed, `5` user declined. +//! See `plans/rocm-doctor-examine-migration-plan.md`. + +use crate::examine::{run, which}; +use crate::{runtime_is_linux, runtime_is_windows}; +use std::io::{IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const RUN_TIMEOUT: Duration = Duration::from_mins(1); +const QUERY_TIMEOUT: Duration = Duration::from_secs(8); + +/// Options controlling how a fix is applied. +#[derive(Debug, Clone, Default)] +pub struct FixOptions { + /// Skip the interactive confirmation (the user already approved the plan). + pub yes: bool, + /// Show the plan without changing anything. + pub dry_run: bool, + /// For `fix-9-igpu-dgpu`: the discrete GPU index to pin. + pub device_index: Option, +} + +/// A remediation recipe keyed by the stable `fix-id`. +struct FixRecipe { + fix_id: &'static str, + title: &'static str, + rationale: &'static str, + auto_applicable: bool, + commands: &'static [&'static str], + needs_sudo: bool, + needs_reboot: bool, + needs_relogin: bool, + verify: &'static str, + notes: &'static [&'static str], + applies_on: &'static [&'static str], + runner: Option i32>, +} + +const LINUX_AND_WINDOWS: &[&str] = &["linux", "windows"]; +const LINUX_ONLY: &[&str] = &["linux"]; +const WINDOWS_ONLY: &[&str] = &["windows"]; + +/// The recipe registry. Mirrors the diagnosis catalog; only the four small, +/// safe fixes carry a `runner` and are auto-applicable. +const RECIPES: &[FixRecipe] = &[ + FixRecipe { + fix_id: "fix-1-arch", + title: "GPU gfx target not in framework arch list", + rationale: "Your GPU's gfx target is not in the framework wheel's compiled kernel list. Re-install the framework from an index that includes this gfx, OR rebuild llama.cpp with AMDGPU_TARGETS=.", + auto_applicable: false, + commands: &[ + "# PyTorch (Linux): switch to the ROCm nightly that ships the gfx115x kernels.", + "pip uninstall -y torch torchvision torchaudio", + "pip install --pre torch torchvision torchaudio \\", + " --index-url https://download.pytorch.org/whl/nightly/rocm6.4", + "# PyTorch (Windows): use TheRock's per-gfx wheels (https://github.com/ROCm/TheRock).", + "# llama.cpp:", + "# cmake -B build -DGGML_HIP=ON -DAMDGPU_TARGETS=", + "# cmake --build build -j", + ], + needs_sudo: false, + needs_reboot: false, + needs_relogin: false, + verify: "python -c \"import torch; print(torch.cuda.is_available(), torch.cuda.get_arch_list())\"", + notes: &[ + "TheRock per-gfx wheels are the recommended fallback when the official pytorch index does not yet cover your gfx (and the only first-party option on Windows AMD).", + "HSA_OVERRIDE_GFX_VERSION is NOT the right fix here -- it papers over the mismatch and risks page faults at runtime.", + ], + applies_on: LINUX_AND_WINDOWS, + runner: None, + }, + FixRecipe { + fix_id: "fix-2-unset-override", + title: "Unset HSA_OVERRIDE_GFX_VERSION", + rationale: "HSA_OVERRIDE_GFX_VERSION is set, but your GPU now has a native wheel. The override hides the real gfx and causes page faults / OUT_OF_REGISTERS at runtime.", + auto_applicable: true, + commands: &[ + "# Linux:", + "unset HSA_OVERRIDE_GFX_VERSION", + "# Then remove the line from ~/.bashrc / ~/.zshrc / ~/.profile.", + "# Windows:", + "setx HSA_OVERRIDE_GFX_VERSION \"\"", + "# Or remove via System Properties -> Environment Variables.", + ], + needs_sudo: false, + needs_reboot: false, + needs_relogin: false, + verify: "env | grep HSA_OVERRIDE_GFX_VERSION || echo OK_UNSET", + notes: &[], + applies_on: LINUX_AND_WINDOWS, + runner: Some(run_unset_override), + }, + FixRecipe { + fix_id: "fix-3-rocm-kernel", + title: "ROCm/distro/kernel triple unsupported", + rationale: "ROCm is installed but your kernel/distro combination is outside the supported matrix. Match the kernel to the matrix before reinstalling, or rerun with --no-dkms and accept the risk.", + auto_applicable: false, + commands: &[ + "# Cross-check the live AMD matrix before changing anything:", + "# https://rocm.docs.amd.com/projects/install-on-linux/en/latest/reference/system-requirements.html", + "# Common fix on Ubuntu: install the HWE kernel that matches your ROCm release, then reboot.", + ], + needs_sudo: false, + needs_reboot: true, + needs_relogin: false, + verify: "lsmod | grep amdgpu && rocminfo | head -n 5", + notes: &[], + applies_on: LINUX_ONLY, + runner: None, + }, + FixRecipe { + fix_id: "fix-4-render-group", + title: "Add user to render/video groups", + rationale: "The current user can't open /dev/kfd because they aren't in the render group. Adding the user is the safe, standard fix.", + auto_applicable: true, + commands: &["sudo usermod -a -G render,video \"$USER\""], + needs_sudo: true, + needs_reboot: false, + needs_relogin: true, + verify: "groups | tr ' ' '\\n' | grep -E '^(render|video)$' && rocminfo | head -n 5", + notes: &[], + applies_on: LINUX_ONLY, + runner: Some(run_render_group), + }, + FixRecipe { + fix_id: "fix-5-amdgpu-load", + title: "Load amdgpu (and clear any blacklist)", + rationale: "The amdgpu kernel module is not loaded. Check /etc/modprobe.d for a blacklist entry, regenerate the initramfs, and modprobe.", + auto_applicable: false, + commands: &[ + "grep -RIl 'blacklist amdgpu' /etc/modprobe.d /usr/lib/modprobe.d 2>/dev/null || true", + "sudo $EDITOR # remove the blacklist line", + "sudo update-initramfs -u # Debian/Ubuntu", + "sudo dracut -f # Fedora/RHEL", + "sudo modprobe amdgpu", + ], + needs_sudo: true, + needs_reboot: true, + needs_relogin: false, + verify: "lsmod | grep amdgpu && rocminfo | head -n 5", + notes: &[ + "If Secure Boot is enabled and amdgpu still won't load, the DKMS module isn't signed. Either sign it with mokutil or disable Secure Boot in firmware.", + ], + applies_on: LINUX_ONLY, + runner: None, + }, + FixRecipe { + fix_id: "fix-6-path", + title: "Add the ROCm/HIP bin directory to PATH", + rationale: "Linux: ROCm is installed at /opt/rocm but its bin directory isn't on PATH, so `rocminfo` / `hipcc` aren't visible to the shell. Windows: the HIP SDK is installed but its bin directory isn't on the User PATH, so `hipInfo.exe` and the runtime DLLs can't be found.", + auto_applicable: true, + commands: &[ + "# Linux:", + "echo 'export PATH=\"/opt/rocm/bin:$PATH\"' >> ~/.bashrc", + "# Windows:", + "setx PATH \"%PATH%;C:\\Program Files\\AMD\\ROCm\\\\bin\"", + ], + needs_sudo: false, + needs_reboot: false, + needs_relogin: false, + verify: "rocminfo | head -n 5 && hipcc --version", + notes: &[], + applies_on: LINUX_AND_WINDOWS, + runner: Some(run_path_export), + }, + FixRecipe { + fix_id: "fix-7-stale-repos", + title: "Quarantine duplicate AMD repos", + rationale: "More than one ROCm/AMDGPU repo file exists. The package manager is mixing versions; quarantine the extras before reinstalling.", + auto_applicable: false, + commands: &[ + "ls /etc/apt/sources.list.d/ | grep -iE 'rocm|amdgpu|radeon'", + "# For each duplicate file:", + "sudo mv /etc/apt/sources.list.d/.list /etc/apt/sources.list.d/.list.bak", + "sudo apt update", + ], + needs_sudo: true, + needs_reboot: false, + needs_relogin: false, + verify: "sudo apt update 2>&1 | tail -n 20", + notes: &[], + applies_on: LINUX_ONLY, + runner: None, + }, + FixRecipe { + fix_id: "fix-8-wheel-rocm", + title: "Reinstall the framework against the system ROCm/HIP major", + rationale: "The framework's bundled HIP version doesn't match the system ROCm (Linux) or HIP SDK (Windows). libamdhip64.so.X / amdhip64_X.dll load failures are the usual signal.", + auto_applicable: false, + commands: &[ + "pip uninstall -y torch torchvision torchaudio", + "# Linux: pick the index that matches your system ROCm major:", + "pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.4", + "pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3", + "# Windows: use TheRock's wheels matching your HIP SDK major:", + "# https://github.com/ROCm/TheRock", + ], + needs_sudo: false, + needs_reboot: false, + needs_relogin: false, + verify: "python -c \"import torch; print(torch.__version__, torch.version.hip, torch.cuda.is_available())\"", + notes: &[], + applies_on: LINUX_AND_WINDOWS, + runner: None, + }, + FixRecipe { + fix_id: "fix-9-igpu-dgpu", + title: "Hide the iGPU with HIP_VISIBLE_DEVICES", + rationale: "Both an APU iGPU and a discrete AMD GPU are visible. Pin the runtime to the dGPU so the iGPU doesn't destabilise it.", + auto_applicable: true, + commands: &[ + "# Linux:", + "rocminfo | grep -E 'Agent |Marketing|gfx' # find the dGPU index", + "export HIP_VISIBLE_DEVICES=", + "# Windows:", + "& \"$env:HIP_PATH\\bin\\hipInfo.exe\" | Select-String \"device#|Name\"", + "setx HIP_VISIBLE_DEVICES ", + ], + needs_sudo: false, + needs_reboot: false, + needs_relogin: false, + verify: "python -c \"import torch; print(torch.cuda.device_count(), torch.cuda.get_device_name(0))\"", + notes: &[ + "Pass --device-index N to persist the env var; without it, this fix only prints the rocminfo / hipInfo query so you can identify N.", + ], + applies_on: LINUX_AND_WINDOWS, + runner: Some(run_hip_visible_devices), + }, + FixRecipe { + fix_id: "fix-10-container", + title: "Re-launch the container with AMD devices passed through", + rationale: "The container can't see /dev/kfd or /dev/dri/renderD*. Pass the devices and the host's render group via the runtime flags.", + auto_applicable: false, + commands: &[ + "docker run --rm -it \\", + " --device=/dev/kfd \\", + " --device=/dev/dri \\", + " --group-add render \\", + " --security-opt seccomp=unconfined \\", + " --shm-size=8g \\", + " rocm/pytorch:latest", + ], + needs_sudo: false, + needs_reboot: false, + needs_relogin: false, + verify: "rocminfo | head -n 5", + notes: &[ + "Rootless podman additionally needs `--userns=keep-id` and a host user that is in the render group; podman maps it through.", + ], + applies_on: LINUX_ONLY, + runner: None, + }, + FixRecipe { + fix_id: "fix-11-iommu", + title: "Add iommu=pt to the kernel command line", + rationale: "Multi-GPU jobs hang when the IOMMU is in the default 'on' mode with translation; pass-through mode fixes the hang. This requires editing GRUB and rebooting; we will not do that for you.", + auto_applicable: false, + commands: &[ + "cat /proc/cmdline", + "sudo $EDITOR /etc/default/grub # add iommu=pt to GRUB_CMDLINE_LINUX_DEFAULT", + "sudo update-grub # Debian/Ubuntu", + "sudo grub2-mkconfig -o /boot/grub2/grub.cfg # Fedora/RHEL", + "# Reboot, then retry the multi-GPU workload.", + ], + needs_sudo: true, + needs_reboot: true, + needs_relogin: false, + verify: "cat /proc/cmdline | grep -o 'iommu=\\w*'", + notes: &[], + applies_on: LINUX_ONLY, + runner: None, + }, + FixRecipe { + fix_id: "fix-12-installer", + title: "Reset amdgpu-install state and reinstall", + rationale: "amdgpu-install left a half-configured DKMS / repo state. Run the documented uninstall, clean up, and reinstall without the flag that broke things (commonly --accept-eula on newer installers).", + auto_applicable: false, + commands: &[ + "sudo amdgpu-install --uninstall", + "sudo apt autoremove --purge -y", + "sudo apt update", + "sudo amdgpu-install --usecase=rocm,hip", + ], + needs_sudo: true, + needs_reboot: true, + needs_relogin: false, + verify: "dpkg -l | grep -E 'rocm|amdgpu' | head -n 20 && rocminfo | head -n 5", + notes: &[ + "If `apt autoremove --purge` warns it will remove unrelated packages, stop and resolve those by hand before continuing.", + ], + applies_on: LINUX_ONLY, + runner: None, + }, + FixRecipe { + fix_id: "fix-13-hip-sdk-missing", + title: "Install the AMD HIP SDK for Windows", + rationale: "Your framework links against HIP but the HIP SDK isn't installed on this host. The runtime DLLs (amdhip64_X.dll, hipblas.dll, hsa-runtime64.dll) and hipInfo.exe ship inside the SDK installer.", + auto_applicable: false, + commands: &[ + "# Download and install the HIP SDK (matched to your framework's HIP major):", + "# https://www.amd.com/en/developer/resources/rocm-hub/hip-sdk.html", + "# After install, reopen the shell so HIP_PATH and PATH pick up the new install.", + ], + needs_sudo: false, + needs_reboot: false, + needs_relogin: false, + verify: "powershell -NoProfile -Command \"& \\\"$env:HIP_PATH\\bin\\hipInfo.exe\\\" | Select-Object -First 5\"", + notes: &[ + "If you only need PyTorch on Windows AMD and don't need the C/C++ HIP toolchain, the TheRock wheels bundle their own HIP runtime and may not require a system HIP SDK install.", + ], + applies_on: WINDOWS_ONLY, + runner: None, + }, + FixRecipe { + fix_id: "fix-14-adrenalin-too-old", + title: "Update the Adrenalin / kernel-mode driver", + rationale: "The HIP SDK is installed but the AMD kernel-mode driver (Adrenalin / Adrenalin Pro) is older than the SDK release notes call out. The user-space SDK and the driver have to match.", + auto_applicable: false, + commands: &[ + "# Cross-check the HIP SDK release notes for the exact driver pairing:", + "# https://rocm.docs.amd.com/projects/install-on-windows/en/latest/install/install.html", + "# Then download the matching driver from:", + "# https://www.amd.com/en/support", + "# Reboot after the install for the kernel-mode driver to take effect.", + ], + needs_sudo: false, + needs_reboot: true, + needs_relogin: false, + verify: "powershell -NoProfile -Command \"(Get-CimInstance Win32_VideoController | Where-Object { $_.Name -like '*AMD*' -or $_.Name -like '*Radeon*' } | Select-Object -First 1).DriverVersion\"", + notes: &[], + applies_on: WINDOWS_ONLY, + runner: None, + }, + FixRecipe { + fix_id: "fix-15-msvc-redist", + title: "Install the MSVC 2015-2022 runtime redistributable", + rationale: "The HIP SDK's amdhip64_X.dll links against the MSVC 2015-2022 runtime. When vcruntime140.dll / vcruntime140_1.dll aren't on PATH, `import torch` fails with a missing-DLL error that points at vcruntime140_1.dll, not at the HIP runtime itself.", + auto_applicable: false, + commands: &[ + "# Download and install (x64):", + "# https://aka.ms/vs/17/release/vc_redist.x64.exe", + "# After the install, reopen the shell and re-run your import / hipInfo check.", + ], + needs_sudo: false, + needs_reboot: false, + needs_relogin: false, + verify: "where vcruntime140.dll && where vcruntime140_1.dll", + notes: &[ + "If installing the redistributable still leaves a missing-DLL error, the failing DLL is probably amdhip64_X.dll itself; that points at fix-13-hip-sdk-missing rather than this fix.", + ], + applies_on: WINDOWS_ONLY, + runner: None, + }, +]; + +fn find_recipe(fix_id: &str) -> Option<&'static FixRecipe> { + RECIPES.iter().find(|r| r.fix_id == fix_id) +} + +const fn current_os() -> &'static str { + if runtime_is_windows() { + "windows" + } else if runtime_is_linux() { + "linux" + } else { + "other" + } +} + +/// List every fix-id (id, kind, OS scope, title). +#[must_use] +pub fn list_recipes() -> String { + use std::fmt::Write as _; + let mut out = String::from("Available fix-ids (mirror the diagnosis catalog):\n"); + for r in RECIPES { + let kind = if r.auto_applicable { + "AUTO" + } else { + "PRINT-ONLY" + }; + let scope = r.applies_on.join("/"); + let _ = writeln!( + out, + " [{kind:>10}] [{scope:>14}] {} -- {}", + r.fix_id, r.title + ); + } + out +} + +fn print_recipe(r: &FixRecipe) { + println!("Fix: {} -- {}", r.fix_id, r.title); + println!("OS scope: {}", r.applies_on.join(", ")); + println!("Rationale: {}", r.rationale); + if !r.commands.is_empty() { + println!("Commands:"); + for c in r.commands { + println!(" $ {c}"); + } + } + let mut flags = Vec::new(); + if r.needs_sudo { + flags.push("requires sudo"); + } + if r.needs_reboot { + flags.push("requires reboot"); + } + if r.needs_relogin { + flags.push("requires re-login"); + } + if !r.auto_applicable { + flags.push("manual only (this command will NOT run it)"); + } + if !flags.is_empty() { + println!("Flags: {}", flags.join(", ")); + } + for n in r.notes { + println!("Note: {n}"); + } + if !r.verify.is_empty() { + println!("Verify: {}", r.verify); + } +} + +/// Apply (or print) the fix identified by `fix_id`. Returns the process exit code. +#[must_use] +pub fn apply(fix_id: &str, opts: &FixOptions) -> i32 { + let Some(recipe) = find_recipe(fix_id) else { + eprintln!("Unknown fix-id: {fix_id}"); + eprintln!("Run `rocm examine --diagnose` to see which fix-id applies."); + return 2; + }; + print_recipe(recipe); + println!(); + + let os = current_os(); + if !recipe.applies_on.contains(&os) { + println!( + "This fix only applies on: {}. Running OS is: {os}.", + recipe.applies_on.join(", ") + ); + return 3; + } + if !recipe.auto_applicable { + println!("This fix is print-only (manual change required)."); + println!("Copy the commands above, run them yourself, then verify with:"); + if !recipe.verify.is_empty() { + println!(" $ {}", recipe.verify); + } + return 0; + } + if let Some(runner) = recipe.runner { + runner(opts) + } else { + eprintln!("Internal error: auto-applicable recipe has no runner."); + 4 + } +} + +// --------------------------------------------------------------------------- +// Consent / environment helpers +// --------------------------------------------------------------------------- + +fn confirm(prompt: &str, assume_yes: bool) -> bool { + if assume_yes { + return true; + } + if !std::io::stdin().is_terminal() { + println!("Non-interactive shell and --yes not passed; refusing to apply."); + return false; + } + print!("{prompt} [y/N]: "); + let _ = std::io::stdout().flush(); + let mut line = String::new(); + if std::io::stdin().read_line(&mut line).is_err() { + return false; + } + matches!(line.trim().to_lowercase().as_str(), "y" | "yes") +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +fn is_root() -> bool { + run("id", &["-u"], QUERY_TIMEOUT).1.trim() == "0" +} + +/// Pick the shell rc file to append to (.zshrc for zsh, else .bashrc). +fn shell_rc_file() -> Option { + let home = home_dir()?; + let shell = std::env::var("SHELL").unwrap_or_default(); + let primary = if shell.contains("zsh") { + home.join(".zshrc") + } else { + home.join(".bashrc") + }; + if !primary.exists() && home.join(".bashrc").exists() { + Some(home.join(".bashrc")) + } else { + Some(primary) + } +} + +fn append_line(path: &Path, header: &str, line: &str) -> std::io::Result<()> { + use std::fs::OpenOptions; + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + writeln!(file, "\n{header}")?; + writeln!(file, "{line}") +} + +// --------------------------------------------------------------------------- +// Runners (one per auto-applicable fix) +// --------------------------------------------------------------------------- + +/// fix-4: add the current user to the render group (and 'video' for safety). +fn run_render_group(opts: &FixOptions) -> i32 { + let user = std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .unwrap_or_default(); + if user.is_empty() { + println!("Could not determine current user from $USER/$LOGNAME."); + return 3; + } + if !which("usermod") { + println!("`usermod` not on PATH; cannot add groups."); + return 3; + } + let root = is_root(); + if !which("sudo") && !root { + println!("`sudo` is not on PATH and we are not root; cannot add groups."); + return 3; + } + let (program, args): (&str, Vec) = if root { + ( + "usermod", + vec![ + "-a".into(), + "-G".into(), + "render,video".into(), + user.clone(), + ], + ) + } else { + ( + "sudo", + vec![ + "usermod".into(), + "-a".into(), + "-G".into(), + "render,video".into(), + user.clone(), + ], + ) + }; + println!("Will run: {program} {}", args.join(" ")); + if opts.dry_run { + println!("(dry-run; not executed)"); + return 0; + } + if !confirm("Add user to render,video groups?", opts.yes) { + return 5; + } + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let (rc, out, err) = run(program, &arg_refs, RUN_TIMEOUT); + print!("{out}"); + eprint!("{err}"); + if rc != 0 { + println!("usermod exited {rc}; group membership NOT changed."); + return 4; + } + println!("Added {user} to render,video."); + println!( + "IMPORTANT: log out and back in (or reboot) for the membership to take effect in new shells and services. `newgrp render` patches the current shell only." + ); + 0 +} + +/// fix-2: help the user clear HSA_OVERRIDE_GFX_VERSION for future shells. +fn run_unset_override(opts: &FixOptions) -> i32 { + if runtime_is_windows() { + run_unset_override_windows(opts) + } else { + run_unset_override_linux() + } +} + +fn run_unset_override_linux() -> i32 { + let current = std::env::var("HSA_OVERRIDE_GFX_VERSION").unwrap_or_default(); + if current.is_empty() { + println!("HSA_OVERRIDE_GFX_VERSION is already unset in this shell."); + } else { + println!("HSA_OVERRIDE_GFX_VERSION={current} is set in this shell."); + println!("In your current shell, run:"); + println!(" unset HSA_OVERRIDE_GFX_VERSION"); + println!("(This command can't unset it in your parent shell; it only sees a copy.)"); + } + let Some(home) = home_dir() else { + return 0; + }; + let candidates = [ + home.join(".bashrc"), + home.join(".bash_profile"), + home.join(".zshrc"), + home.join(".profile"), + home.join(".config").join("fish").join("config.fish"), + ]; + let hits: Vec = candidates + .into_iter() + .filter(|f| { + std::fs::read_to_string(f).is_ok_and(|b| b.contains("HSA_OVERRIDE_GFX_VERSION")) + }) + .collect(); + if hits.is_empty() { + println!("\nNo persistent HSA_OVERRIDE_GFX_VERSION found in your shell rc files."); + return 0; + } + println!("\nPersistent HSA_OVERRIDE_GFX_VERSION found in:"); + for f in &hits { + println!(" - {}", f.display()); + } + println!( + "\nRemove or comment those lines manually. This command does NOT edit your shell rc files for you; that's your dotfiles. Suggested:" + ); + for f in &hits { + println!( + " $ $EDITOR {} # delete or comment the HSA_OVERRIDE_GFX_VERSION line", + f.display() + ); + } + 0 +} + +fn run_unset_override_windows(opts: &FixOptions) -> i32 { + let current = std::env::var("HSA_OVERRIDE_GFX_VERSION").unwrap_or_default(); + if current.is_empty() { + println!("HSA_OVERRIDE_GFX_VERSION is not set in this shell."); + } else { + println!("HSA_OVERRIDE_GFX_VERSION={current} is set in this shell."); + println!("Note: clearing it in your Windows env scope does NOT affect this"); + println!("already-open shell -- close and reopen your terminal afterwards."); + } + let user_val = ps_env_scope("HSA_OVERRIDE_GFX_VERSION", "User"); + let machine_val = ps_env_scope("HSA_OVERRIDE_GFX_VERSION", "Machine"); + if user_val.is_empty() && machine_val.is_empty() { + println!("\nNo persistent HSA_OVERRIDE_GFX_VERSION found in either the User"); + println!("or Machine env scope. You're done after closing/reopening shells."); + return 0; + } + println!("\nPersistent HSA_OVERRIDE_GFX_VERSION found in:"); + if !user_val.is_empty() { + println!(" User scope: {user_val}"); + } + if !machine_val.is_empty() { + println!(" Machine scope: {machine_val}"); + } + if !user_val.is_empty() { + println!("\nClear from the User scope (no admin needed):"); + println!(" Will run: setx HSA_OVERRIDE_GFX_VERSION \"\""); + if opts.dry_run { + println!(" (dry-run; not executed)"); + } else if confirm("Clear HSA_OVERRIDE_GFX_VERSION from User scope?", opts.yes) { + let (rc, out, err) = run("setx", &["HSA_OVERRIDE_GFX_VERSION", ""], RUN_TIMEOUT); + print!("{out}"); + eprint!("{err}"); + if rc != 0 { + println!("setx exited {rc}; User scope NOT changed."); + return 4; + } + println!("Cleared from User scope. Reopen your terminal for it to take effect."); + } + } + if !machine_val.is_empty() { + println!( + "\nThe Machine scope value cannot be cleared without an Admin shell. Either run an elevated PowerShell and execute:" + ); + println!( + " [Environment]::SetEnvironmentVariable('HSA_OVERRIDE_GFX_VERSION', $null, 'Machine')" + ); + println!( + "or remove it through System Properties -> Environment Variables -> System variables. This command does NOT elevate itself." + ); + } + 0 +} + +/// fix-6: persist the ROCm/HIP bin directory on PATH (with consent). +fn run_path_export(opts: &FixOptions) -> i32 { + if runtime_is_windows() { + run_path_export_windows(opts) + } else { + run_path_export_linux(opts) + } +} + +fn run_path_export_linux(opts: &FixOptions) -> i32 { + let bin_dir = "/opt/rocm/bin"; + if !Path::new(bin_dir).is_dir() { + println!("{bin_dir} does not exist; nothing to add to PATH."); + return 3; + } + let Some(rc_file) = shell_rc_file() else { + println!("Could not determine your home directory."); + return 3; + }; + let export_line = format!("export PATH=\"{bin_dir}:$PATH\""); + if let Ok(existing) = std::fs::read_to_string(&rc_file) + && existing + .lines() + .any(|l| l.contains("PATH=") && l.contains(bin_dir)) + { + println!( + "{} already adds {bin_dir} to PATH; no change.", + rc_file.display() + ); + return 0; + } + println!("Plan: append the following line to {}:", rc_file.display()); + println!(" {export_line}"); + if opts.dry_run { + println!("(dry-run; not executed)"); + return 0; + } + if !confirm(&format!("Append to {}?", rc_file.display()), opts.yes) { + return 5; + } + if let Err(exc) = append_line( + &rc_file, + "# Added by rocm examine (fix-6-path)", + &export_line, + ) { + println!("Failed to write {}: {exc}", rc_file.display()); + return 4; + } + println!( + "Appended to {}. Open a new shell or run `source {}` for the change to take effect.", + rc_file.display(), + rc_file.display() + ); + 0 +} + +fn run_path_export_windows(opts: &FixOptions) -> i32 { + let mut sdk_path = std::env::var("HIP_PATH").unwrap_or_default(); + if sdk_path.is_empty() { + sdk_path = newest_rocm_install_dir(); + } + if sdk_path.is_empty() { + println!("No HIP SDK install found. Run fix-13-hip-sdk-missing first."); + return 3; + } + let bin_dir = Path::new(&sdk_path).join("bin"); + if !bin_dir.is_dir() { + println!( + "{} does not exist on disk; HIP SDK install looks incomplete.", + bin_dir.display() + ); + return 3; + } + let bin_dir = bin_dir.to_string_lossy().into_owned(); + let user_path = ps_env_scope("PATH", "User"); + if !user_path.is_empty() && user_path.to_lowercase().contains(&bin_dir.to_lowercase()) { + println!("User PATH already contains {bin_dir}; no change."); + return 0; + } + let new_path = if user_path.is_empty() { + bin_dir.clone() + } else { + format!("{user_path};{bin_dir}") + }; + println!("Plan: prepend {bin_dir} to your User PATH:"); + println!(" setx PATH \"{new_path}\""); + if opts.dry_run { + println!("(dry-run; not executed)"); + return 0; + } + if !confirm("Update User PATH?", opts.yes) { + return 5; + } + let (rc, out, err) = run("setx", &["PATH", &new_path], RUN_TIMEOUT); + print!("{out}"); + eprint!("{err}"); + if rc != 0 { + println!("setx exited {rc}; User PATH NOT changed."); + return 4; + } + println!( + "Added {bin_dir} to your User PATH. setx only takes effect in NEW shells -- close this terminal and reopen it before re-running hipInfo." + ); + 0 +} + +/// fix-9: persist HIP_VISIBLE_DEVICES so the iGPU is hidden. +fn run_hip_visible_devices(opts: &FixOptions) -> i32 { + if runtime_is_windows() { + run_hip_visible_devices_windows(opts) + } else { + run_hip_visible_devices_linux(opts) + } +} + +fn run_hip_visible_devices_linux(opts: &FixOptions) -> i32 { + let Some(idx) = opts.device_index else { + println!( + "Run `rocminfo | grep -E 'Agent |Marketing|gfx'` and identify the row of your DISCRETE GPU (the iGPU is typically Agent 1). Then re-run with --device-index N." + ); + return 3; + }; + let Some(rc_file) = shell_rc_file() else { + println!("Could not determine your home directory."); + return 3; + }; + let export_line = format!("export HIP_VISIBLE_DEVICES={idx}"); + if let Ok(existing) = std::fs::read_to_string(&rc_file) + && existing.contains("HIP_VISIBLE_DEVICES=") + { + println!( + "{} already sets HIP_VISIBLE_DEVICES; edit by hand rather than appending a second copy.", + rc_file.display() + ); + return 0; + } + println!("Plan: append the following line to {}:", rc_file.display()); + println!(" {export_line}"); + if opts.dry_run { + println!("(dry-run; not executed)"); + return 0; + } + if !confirm(&format!("Append to {}?", rc_file.display()), opts.yes) { + return 5; + } + if let Err(exc) = append_line( + &rc_file, + "# Added by rocm examine (fix-9-igpu-dgpu)", + &export_line, + ) { + println!("Failed to write {}: {exc}", rc_file.display()); + return 4; + } + println!( + "Appended to {}. Open a new shell for the change to take effect, then re-run your workload.", + rc_file.display() + ); + 0 +} + +fn run_hip_visible_devices_windows(opts: &FixOptions) -> i32 { + let Some(idx) = opts.device_index else { + println!("Run the following to identify the discrete GPU's index:"); + println!( + " & \"$env:HIP_PATH\\bin\\hipInfo.exe\" | Select-String \"device#|Name|gcnArchName\"" + ); + println!( + "Then re-run with --device-index N (the iGPU is typically device# 0; the dGPU is usually device# 1)." + ); + return 3; + }; + let existing = ps_env_scope("HIP_VISIBLE_DEVICES", "User"); + if !existing.is_empty() { + println!( + "User scope already sets HIP_VISIBLE_DEVICES={existing:?}; remove or update it manually rather than overwriting from this command." + ); + return 0; + } + println!("Plan: persist HIP_VISIBLE_DEVICES in the User env scope:"); + println!(" setx HIP_VISIBLE_DEVICES {idx}"); + if opts.dry_run { + println!("(dry-run; not executed)"); + return 0; + } + if !confirm("Set HIP_VISIBLE_DEVICES in the User scope?", opts.yes) { + return 5; + } + let (rc, out, err) = run( + "setx", + &["HIP_VISIBLE_DEVICES", &idx.to_string()], + RUN_TIMEOUT, + ); + print!("{out}"); + eprint!("{err}"); + if rc != 0 { + println!("setx exited {rc}; HIP_VISIBLE_DEVICES NOT changed."); + return 4; + } + println!( + "setx only takes effect in NEW shells -- close this terminal and reopen it before re-running your workload." + ); + 0 +} + +/// Read a Windows environment variable from a given scope via PowerShell. +fn ps_env_scope(var: &str, scope: &str) -> String { + let script = format!("[Environment]::GetEnvironmentVariable('{var}','{scope}')"); + let (rc, out, _) = run( + "powershell", + &["-NoProfile", "-Command", &script], + QUERY_TIMEOUT, + ); + if rc == 0 { + out.trim().to_owned() + } else { + String::new() + } +} + +/// Newest `C:\Program Files\AMD\ROCm\` install dir, or empty. +fn newest_rocm_install_dir() -> String { + for root in [ + r"C:\Program Files\AMD\ROCm", + r"C:\Program Files (x86)\AMD\ROCm", + ] { + if let Ok(entries) = std::fs::read_dir(root) { + let mut versions: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.is_dir() + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit())) + }) + .collect(); + versions.sort(); + if let Some(latest) = versions.last() { + return latest.to_string_lossy().into_owned(); + } + } + } + String::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_recipe_id_is_unique_and_covers_the_catalog() { + let mut ids: Vec<&str> = RECIPES.iter().map(|r| r.fix_id).collect(); + ids.sort_unstable(); + let count = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), count, "duplicate fix-id in RECIPES"); + assert_eq!(count, 15, "expected 15 catalog entries"); + } + + #[test] + fn auto_applicable_recipes_have_a_runner() { + for r in RECIPES { + assert_eq!( + r.auto_applicable, + r.runner.is_some(), + "{}: auto_applicable must match presence of a runner", + r.fix_id + ); + } + } + + #[test] + fn exactly_the_four_known_fixes_are_auto() { + let auto: Vec<&str> = RECIPES + .iter() + .filter(|r| r.auto_applicable) + .map(|r| r.fix_id) + .collect(); + assert_eq!( + auto, + vec![ + "fix-2-unset-override", + "fix-4-render-group", + "fix-6-path", + "fix-9-igpu-dgpu" + ] + ); + } + + #[test] + fn unknown_fix_id_returns_2() { + let code = apply("fix-does-not-exist", &FixOptions::default()); + assert_eq!(code, 2); + } + + #[test] + fn dry_run_never_mutates_and_returns_zero_for_auto_linux_fix() { + if !runtime_is_linux() { + return; + } + // fix-2 unset-override is print-only on linux (no mutation regardless); + // a dry-run must report success without changing anything. + let opts = FixOptions { + dry_run: true, + ..FixOptions::default() + }; + let code = apply("fix-2-unset-override", &opts); + assert_eq!(code, 0); + } + + #[test] + fn print_only_fix_returns_zero() { + if !runtime_is_linux() { + return; + } + let code = apply("fix-5-amdgpu-load", &FixOptions::default()); + assert_eq!(code, 0); + } + + #[test] + fn windows_only_fix_refused_on_linux() { + if !runtime_is_linux() { + return; + } + let code = apply("fix-13-hip-sdk-missing", &FixOptions::default()); + assert_eq!(code, 3); + } + + #[test] + fn list_includes_all_ids() { + let listing = list_recipes(); + for r in RECIPES { + assert!(listing.contains(r.fix_id), "listing missing {}", r.fix_id); + } + } +} diff --git a/crates/rocm-core/src/lib.rs b/crates/rocm-core/src/lib.rs index 7c78c8d6..e8c42dbc 100644 --- a/crates/rocm-core/src/lib.rs +++ b/crates/rocm-core/src/lib.rs @@ -22,8 +22,17 @@ use windows_sys::Win32::System::Threading::{ WaitForSingleObject, }; +pub mod diagnose; +pub mod examine; +pub mod fix; pub mod runtime; pub mod uv; +pub use diagnose::{ + DiagnoseReport, Diagnosis, Fix, diagnose as run_diagnose, + render_report_text as render_diagnose_text, +}; +pub use examine::{Examination, FrameworkProbe}; +pub use fix::{FixOptions, apply as apply_fix, list_recipes as list_fix_recipes}; use runtime::env_path_override; #[cfg(test)] use runtime::home_rocm_dir; From 20bc92e8eaf6dfd031122d3af9e0e561686b4c69 Mon Sep 17 00:00:00 2001 From: Eugene Volen Date: Tue, 23 Jun 2026 07:19:05 +0000 Subject: [PATCH 2/3] refactor: split examine into examine/diagnose/fix commands Address review feedback: diagnose and fix are distinct verbs backed by separate modules, so expose them as top-level commands rather than mode flags under `examine`. - rocm examine [--json] host probe / report - rocm diagnose [--symptom][--top][--json] match the closed catalog - rocm fix [][--yes][--dry-run][--device-index] apply or list fixes Also register diagnose/fix in the natural-language allowlist so they dispatch as structured commands instead of falling through to the freeform planner. Help strings updated to the new command names. Signed-off-by: Eugene Volen --- apps/rocm/src/main.rs | 136 ++++++++++++++----------------- crates/rocm-core/src/diagnose.rs | 6 +- crates/rocm-core/src/fix.rs | 2 +- 3 files changed, 67 insertions(+), 77 deletions(-) diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index 0a06d02c..d4a5e236 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -66,30 +66,35 @@ struct Cli { enum Command { /// Check this computer's GPU, ROCm install, engines, and setup folders. Examine { - /// Diagnose known ROCm/PyTorch/llama.cpp failure modes and suggest fixes. - #[arg(long, conflicts_with = "fix")] - diagnose: bool, - /// Raw error text from the user; sharpens diagnosis keyword scoring. - #[arg(long, requires = "diagnose")] + /// Emit the machine-readable Examination JSON (for diagnosis tooling). + #[arg(long)] + json: bool, + }, + /// Diagnose known ROCm/PyTorch/llama.cpp failure modes against a closed catalog. + Diagnose { + /// Raw error text from the user; sharpens keyword scoring. + #[arg(long)] symptom: Option, - /// With --diagnose, show at most this many matches (default 5). - #[arg(long, requires = "diagnose", default_value_t = 5)] + /// Show at most this many matches (default 5). + #[arg(long, default_value_t = 5)] top: usize, - /// Apply a known fix by id (e.g. fix-4-render-group); see --diagnose output. - #[arg(long, value_name = "FIX_ID")] - fix: Option, - /// With --fix, skip the interactive confirmation (use after approving the plan). - #[arg(long, requires = "fix")] + /// Emit the machine-readable diagnosis JSON. + #[arg(long)] + json: bool, + }, + /// Apply a known fix by id (see `rocm diagnose`); run with no id to list fixes. + Fix { + /// Fix id, e.g. fix-4-render-group. Omit to list available fixes. + fix_id: Option, + /// Skip the interactive confirmation (use after approving the plan). + #[arg(long)] yes: bool, - /// With --fix, show the plan without changing anything. - #[arg(long = "dry-run", requires = "fix")] + /// Show the plan without changing anything. + #[arg(long = "dry-run")] dry_run: bool, /// For fix-9-igpu-dgpu: the discrete GPU index to pin. - #[arg(long, requires = "fix")] - device_index: Option, - /// Emit machine-readable JSON (the Examination, or the diagnosis with --diagnose). #[arg(long)] - json: bool, + device_index: Option, }, /// Print the rocm-cli version. Version, @@ -1060,25 +1065,14 @@ fn dispatch(cli: Cli) -> Result<()> { } match cli.command { - Some(Command::Examine { - diagnose, - symptom, - top, - fix, + Some(Command::Examine { json }) => examine(json), + Some(Command::Diagnose { symptom, top, json }) => diagnose(symptom, top, json), + Some(Command::Fix { + fix_id, yes, dry_run, device_index, - json, - }) => examine(ExamineArgs { - diagnose, - symptom, - top, - fix, - yes, - dry_run, - device_index, - json, - }), + }) => fix(fix_id, yes, dry_run, device_index), Some(Command::Version) => { println!("rocm {}", env!("CARGO_PKG_VERSION")); Ok(()) @@ -1455,45 +1449,8 @@ const fn builtin_engine_inventory() -> &'static [(&'static str, &'static str)] { ] } -struct ExamineArgs { - diagnose: bool, - symptom: Option, - top: usize, - fix: Option, - yes: bool, - dry_run: bool, - device_index: Option, - json: bool, -} - -fn examine(args: ExamineArgs) -> Result<()> { - if let Some(fix_id) = args.fix { - let opts = rocm_core::FixOptions { - yes: args.yes, - dry_run: args.dry_run, - device_index: args.device_index, - }; - let code = rocm_core::apply_fix(&fix_id, &opts); - if code != 0 { - std::process::exit(code); - } - return Ok(()); - } - if args.diagnose { - let examination = rocm_core::Examination::probe(rocm_core::FrameworkProbe::Auto); - let symptom = args.symptom.unwrap_or_default(); - let report = rocm_core::run_diagnose(&examination, &symptom); - if args.json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else { - print!("{}", rocm_core::render_diagnose_text(&report, args.top)); - } - if !report.has_match() { - std::process::exit(1); - } - return Ok(()); - } - if args.json { +fn examine(json: bool) -> Result<()> { + if json { let examination = rocm_core::Examination::probe(rocm_core::FrameworkProbe::Auto); println!("{}", serde_json::to_string_pretty(&examination)?); let code = examination.exit_code(); @@ -1506,6 +1463,37 @@ fn examine(args: ExamineArgs) -> Result<()> { Ok(()) } +fn diagnose(symptom: Option, top: usize, json: bool) -> Result<()> { + let examination = rocm_core::Examination::probe(rocm_core::FrameworkProbe::Auto); + let report = rocm_core::run_diagnose(&examination, &symptom.unwrap_or_default()); + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print!("{}", rocm_core::render_diagnose_text(&report, top)); + } + if !report.has_match() { + std::process::exit(1); + } + Ok(()) +} + +fn fix(fix_id: Option, yes: bool, dry_run: bool, device_index: Option) -> Result<()> { + let Some(fix_id) = fix_id else { + print!("{}", rocm_core::list_fix_recipes()); + return Ok(()); + }; + let opts = rocm_core::FixOptions { + yes, + dry_run, + device_index, + }; + let code = rocm_core::apply_fix(&fix_id, &opts); + if code != 0 { + std::process::exit(code); + } + Ok(()) +} + fn record_cli_audit_event( paths: &AppPaths, category: &str, @@ -14157,6 +14145,8 @@ fn service_model_names_match(left: &str, right: &str) -> bool { fn treat_as_natural_language(args: &[String]) -> bool { const STRUCTURED: &[&str] = &[ "examine", + "diagnose", + "fix", "status", "bridge-snapshot", "sandbox-run", diff --git a/crates/rocm-core/src/diagnose.rs b/crates/rocm-core/src/diagnose.rs index 7b8031b3..cf874493 100644 --- a/crates/rocm-core/src/diagnose.rs +++ b/crates/rocm-core/src/diagnose.rs @@ -1279,7 +1279,7 @@ pub fn render_report_text(report: &DiagnoseReport, top: usize) -> String { let mut out = String::new(); if report.matched.is_empty() { let route = &report.route_when_no_match; - out.push_str("rocm examine: no known misconfiguration matched.\n\n"); + out.push_str("rocm diagnose: no known misconfiguration matched.\n\n"); out.push_str("This is the explicit 'I don't recognise this failure mode' case. Do not speculate; file the symptom + this examination output upstream:\n"); let _ = writeln!(out, " {:>12}: {}", route.target, route.url); out.push('\n'); @@ -1315,7 +1315,7 @@ pub fn render_report_text(report: &DiagnoseReport, top: usize) -> String { flags.push("re-login required"); } if fix.auto_applicable { - flags.push("rocm examine --fix can run it"); + flags.push("rocm fix can run it"); } if !flags.is_empty() { let _ = writeln!(out, " flags: {}", flags.join(", ")); @@ -1330,7 +1330,7 @@ pub fn render_report_text(report: &DiagnoseReport, top: usize) -> String { out.push('\n'); } if let Some(high) = report.matched.iter().find(|d| d.score >= HIGH_CONFIDENCE) { - let _ = writeln!(out, "Next step: propose `rocm examine --fix {}`.", high.id); + let _ = writeln!(out, "Next step: run `rocm fix {}`.", high.id); } else { out.push_str("Highest-scoring match is below the HIGH_CONFIDENCE threshold. Confirm one more piece of evidence before applying.\n"); } diff --git a/crates/rocm-core/src/fix.rs b/crates/rocm-core/src/fix.rs index cd240eb4..9decef02 100644 --- a/crates/rocm-core/src/fix.rs +++ b/crates/rocm-core/src/fix.rs @@ -437,7 +437,7 @@ fn print_recipe(r: &FixRecipe) { pub fn apply(fix_id: &str, opts: &FixOptions) -> i32 { let Some(recipe) = find_recipe(fix_id) else { eprintln!("Unknown fix-id: {fix_id}"); - eprintln!("Run `rocm examine --diagnose` to see which fix-id applies."); + eprintln!("Run `rocm diagnose` to see which fix-id applies."); return 2; }; print_recipe(recipe); From 0757b125d5f4d38e266ae64b90a5ca68d23fbfb8 Mon Sep 17 00:00:00 2001 From: Eugene Volen Date: Tue, 23 Jun 2026 13:10:01 +0000 Subject: [PATCH 3/3] fix: address review -- WSL2 scope, env-truncation crash, exit-code scheme - probe_env: char-boundary truncation for PATH/LD_LIBRARY_PATH so a multibyte char at the truncation cut no longer panics probe(); the cap is a named constant (16k chars) chosen well past any realistic ROCm bin entry so the fix-6 PATH check isn't tripped by truncation. Regression test added. - Exit codes report execution, not findings (per review): - examine: always exits 0 (a genuine inability to examine propagates as an error). The verdict is a --json `status` field (ok / no-amd-gpu / wsl / unsupported-os / degraded). WSL2 also skips the Linux probe set and shows a route-out note. - diagnose: always exits 0; callers read has_match / out_of_scope / route_when_no_match from --json. WSL2 short-circuits with out_of_scope. - fix: 0 ok/dry-run/list/print, 1 internal error, 2 usage incl. unknown fix-id, 3 not applicable (OS mismatch / missing or negative --device-index), 4 attempted-but-failed, 5 user declined. - check-10 (container): a null kfd contributes 0. Adds is_wsl tests for diagnose, status-precedence and multibyte-truncation tests for examine. Signed-off-by: Eugene Volen --- apps/rocm/src/main.rs | 35 ++++++--- crates/rocm-core/src/diagnose.rs | 79 +++++++++++++++++++-- crates/rocm-core/src/examine.rs | 118 +++++++++++++++++++++++++------ crates/rocm-core/src/fix.rs | 8 ++- crates/rocm-core/src/lib.rs | 2 +- 5 files changed, 204 insertions(+), 38 deletions(-) diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index d4a5e236..9fb09e7d 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -1450,20 +1450,31 @@ const fn builtin_engine_inventory() -> &'static [(&'static str, &'static str)] { } fn examine(json: bool) -> Result<()> { + // `rocm examine` is the general system inspector: the exit code reports + // whether it RAN, not what it found. Any finding (no GPU, WSL, degraded) is + // surfaced in the output and the `--json` `status` field, and the command + // exits 0; a genuine inability to examine propagates as an error via `?`. if json { let examination = rocm_core::Examination::probe(rocm_core::FrameworkProbe::Auto); println!("{}", serde_json::to_string_pretty(&examination)?); - let code = examination.exit_code(); - if code != 0 { - std::process::exit(code); - } return Ok(()); } - print!("{}", render_examine_text()?); + let paths = AppPaths::discover()?; + let config = RocmCliConfig::load(&paths).unwrap_or_default(); + let (text, summary) = examine_human_report(&paths, &config)?; + print!("{text}"); + if summary.wsl.as_ref().is_some_and(|w| w.is_wsl) { + // Informational route-out guidance for humans (the verdict is also in + // the `status` field for `--json` consumers). + println!("\n{}", rocm_core::WSL_ROUTE_OUT_NOTE); + } Ok(()) } fn diagnose(symptom: Option, top: usize, json: bool) -> Result<()> { + // `rocm diagnose` is a query: it exits 0 whether it matched, found nothing, + // or is out of scope. Callers read `has_match` / `out_of_scope` / + // `route_when_no_match` from `--json` rather than branching on the exit code. let examination = rocm_core::Examination::probe(rocm_core::FrameworkProbe::Auto); let report = rocm_core::run_diagnose(&examination, &symptom.unwrap_or_default()); if json { @@ -1471,9 +1482,6 @@ fn diagnose(symptom: Option, top: usize, json: bool) -> Result<()> { } else { print!("{}", rocm_core::render_diagnose_text(&report, top)); } - if !report.has_match() { - std::process::exit(1); - } Ok(()) } @@ -9109,13 +9117,22 @@ pub(crate) fn render_examine_text() -> Result { } fn render_examine_text_with_paths(paths: &AppPaths, config: &RocmCliConfig) -> Result { + Ok(examine_human_report(paths, config)?.0) +} + +/// Build the human examine report and return it alongside the `ExamineSummary` +/// it was built from, so callers can derive scope/exit-code without re-probing. +fn examine_human_report( + paths: &AppPaths, + config: &RocmCliConfig, +) -> Result<(String, ExamineSummary)> { recover_setup_runtime_registration(paths, config)?; let summary = ExamineSummary::gather()?; let mut output = render_examine_plain_header(&summary); output.push_str(&summary.render_text()); append_examine_runtime_state(&mut output, paths, config)?; append_examine_engine_inventory(&mut output, paths, config); - Ok(output) + Ok((output, summary)) } fn render_examine_plain_header(summary: &ExamineSummary) -> String { diff --git a/crates/rocm-core/src/diagnose.rs b/crates/rocm-core/src/diagnose.rs index cf874493..20b1db87 100644 --- a/crates/rocm-core/src/diagnose.rs +++ b/crates/rocm-core/src/diagnose.rs @@ -59,6 +59,11 @@ pub struct DiagnoseReport { pub min_score_for_match: i32, pub high_confidence_threshold: i32, pub route_when_no_match: Route, + /// Set when the host is out of scope for this catalog (e.g. WSL2). When + /// present, `matched` is empty — the catalog is deliberately not run, to + /// avoid emitting bare-metal-Linux diagnoses that don't apply. + #[serde(default)] + pub out_of_scope: Option, } impl DiagnoseReport { @@ -918,6 +923,9 @@ fn check_10_container_devices(e: &Examination, symptom: &str) -> Diagnosis { }; let mut score = 25; let mut evidence = vec![format!("running inside a {kind}")]; + // Mirror diagnose.py: a null kfd contributes 0 (the script reads + // `kfd.get("exists") is False`, which is not True for a missing key). The + // probe always populates kfd, so this only matters for hand-built exams. if let Some(kfd) = &e.kfd { if !kfd.exists { score += 40; @@ -926,9 +934,6 @@ fn check_10_container_devices(e: &Examination, symptom: &str) -> Diagnosis { score += 30; evidence.push("/dev/kfd is present but not writable by the container user".to_owned()); } - } else { - score += 40; - evidence.push("/dev/kfd is not present in the container".to_owned()); } if e.render_devices.is_empty() { score += 20; @@ -1264,19 +1269,51 @@ fn route_when_no_match(e: &Examination) -> Route { /// Diagnose an examination against the closed catalog. #[must_use] pub fn diagnose(e: &Examination, symptom: &str) -> DiagnoseReport { + // WSL2 is a distinct platform (it uses /dev/dxg + the Windows host driver, + // not the in-tree amdgpu module or /dev/kfd), and `examine` already treats + // it as out of scope (exit_code() == 2). Mirror that here: skip the + // bare-metal Linux catalog entirely so we don't emit false positives like + // fix-4-render-group / fix-5-amdgpu-load on a healthy WSL2 box. + let out_of_scope = wsl_out_of_scope_message(e); + let matched = if out_of_scope.is_some() { + Vec::new() + } else { + run_all_checks(e, symptom) + }; DiagnoseReport { - matched: run_all_checks(e, symptom), + matched, min_score_for_match: MIN_SCORE_FOR_MATCH, high_confidence_threshold: HIGH_CONFIDENCE, route_when_no_match: route_when_no_match(e), + out_of_scope, } } +/// ROCm-on-WSL2 setup guidance (distinct from the bare-metal catalog). +const WSL_DOCS_URL: &str = "https://rocm.docs.amd.com/projects/radeon-ryzen/en/latest/docs/install/installryz/wsl/howto_wsl.html"; + +fn wsl_out_of_scope_message(e: &Examination) -> Option { + e.is_wsl.then(|| { + format!( + "ROCm on WSL2 is a distinct platform: it uses /dev/dxg and the Windows host \ + driver (dxgkrnl), not the in-tree amdgpu kernel module or /dev/kfd. This catalog \ + targets bare-metal Linux, so its checks (render group, /dev/kfd, modprobe amdgpu) \ + do not apply here. For ROCm-on-WSL2 setup, see {WSL_DOCS_URL}" + ) + }) +} + /// Render the human-facing diagnosis view (mirrors `diagnose.py`'s text output). #[must_use] pub fn render_report_text(report: &DiagnoseReport, top: usize) -> String { use std::fmt::Write as _; let mut out = String::new(); + if let Some(reason) = &report.out_of_scope { + out.push_str("rocm diagnose: out of scope for this platform.\n\n"); + out.push_str(reason); + out.push('\n'); + return out; + } if report.matched.is_empty() { let route = &report.route_when_no_match; out.push_str("rocm diagnose: no known misconfiguration matched.\n\n"); @@ -1422,6 +1459,40 @@ mod tests { assert!(report.matched.iter().all(|d| d.id != "fix-1-arch")); } + #[test] + fn wsl2_is_out_of_scope_and_emits_no_false_positives() { + let mut e = linux_base(); + e.is_wsl = true; + // Signals that WOULD fire fix-4/fix-5/fix-3/fix-6 on bare-metal Linux — + // all normal/irrelevant on WSL2, so none must surface. + e.in_render_group = Some(false); + e.in_video_group = Some(false); + e.amdgpu_loaded = Some(false); + e.rocm_version = "6.4.1".to_owned(); + e.rocm_path = "/opt/rocm".to_owned(); + e.rocminfo_present = false; + let report = diagnose(&e, "unable to open /dev/kfd permission denied"); + assert!( + report.matched.is_empty(), + "WSL2 must not run the bare-metal catalog" + ); + assert!( + report.out_of_scope.is_some(), + "WSL2 should be flagged out of scope" + ); + assert!(!report.has_match()); + assert!(report.out_of_scope.as_deref().unwrap().contains("WSL2")); + } + + #[test] + fn non_wsl_still_diagnoses_normally() { + let mut e = linux_base(); + e.in_render_group = Some(false); + let report = diagnose(&e, ""); + assert!(report.out_of_scope.is_none()); + assert_eq!(report.matched[0].id, "fix-4-render-group"); + } + #[test] fn no_match_routes_upstream() { let mut e = linux_base(); diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index c43c4b91..217aae5b 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -17,6 +17,12 @@ use std::time::{Duration, Instant}; /// Environment variables that commonly steer (or break) ROCm/HIP runtime /// behavior. Captured verbatim into `Examination::env`. +/// Cap on captured `PATH` / `LD_LIBRARY_PATH` length, to keep the Examination +/// JSON bounded. Generous on purpose: the stored value is read by the catalog +/// (e.g. the `fix-6-path` PATH check), so the cut must sit well past any +/// realistic ROCm/HIP bin entry to avoid false "not on PATH" diagnoses. +const ENV_VALUE_MAX_CHARS: usize = 16_000; + const TRACKED_ENV_VARS: &[&str] = &[ "HSA_OVERRIDE_GFX_VERSION", "HIP_VISIBLE_DEVICES", @@ -156,6 +162,11 @@ pub struct Examination { pub dmesg_amdgpu_tail: Vec, pub notes: Vec, pub probe_failures: Vec, + + // CLI addition (not in examine.py): a coarse machine-readable verdict so + // callers branch on this field instead of the process exit code. + // One of: "ok" | "no-amd-gpu" | "wsl" | "unsupported-os" | "degraded". + pub status: String, } impl Default for Examination { @@ -210,10 +221,15 @@ impl Default for Examination { dmesg_amdgpu_tail: Vec::new(), notes: Vec::new(), probe_failures: Vec::new(), + status: "ok".to_owned(), } } } +/// Route-out guidance shown when WSL2 is detected (out of scope for this +/// catalog, which targets bare-metal Linux). Mirrors `examine.py`. +pub const WSL_ROUTE_OUT_NOTE: &str = "Detected WSL2. rocm examine does not cover the ROCm-on-WSL flow (it requires Adrenalin Pro + the WSL kernel update on the Windows host). Either run `rocm examine` on the native Linux host, or follow AMD's WSL guide directly: https://rocm.docs.amd.com/projects/radeon-ryzen/en/latest/docs/install/installryz/wsl/howto_wsl.html"; + /// Which framework probe to run. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FrameworkProbe { @@ -231,6 +247,15 @@ impl Examination { pub fn probe(framework: FrameworkProbe) -> Self { let mut e = Self::default(); probe_os(&mut e); + if e.is_wsl { + // WSL2 is out of scope: it uses /dev/dxg + the Windows host driver, + // not the in-tree amdgpu module or /dev/kfd. Running the Linux probe + // set would only mislead, so add the route-out note and stop here + // (mirrors examine.py). The "wsl" status carries the verdict. + e.notes.push(WSL_ROUTE_OUT_NOTE.to_owned()); + e.status = "wsl".to_owned(); + return e; + } if e.os_family == "linux" { probe_cpu_linux(&mut e); probe_gpus_lspci(&mut e); @@ -260,27 +285,26 @@ impl Examination { e.os_family )); } + e.status = e.compute_status(); e } - /// Exit code mirroring `examine.py`: `2` = wrong platform / WSL / no AMD GPU - /// (skill can't help), `3` = a key probe failed (soft warning), `0` = ok. - #[must_use] - pub fn exit_code(&self) -> i32 { - if self.is_wsl || !matches!(self.os_family.as_str(), "linux" | "windows") { - return 2; - } - if !self.has_amd_gpu { - return 2; - } - if self.os_family == "linux" { - if !self.probe_failures.is_empty() && !self.rocminfo_present && self.gpus.is_empty() { - return 3; - } - } else if !self.probe_failures.is_empty() && !self.hipinfo_present && self.gpus.is_empty() { - return 3; - } - 0 + /// Coarse machine-readable verdict reported via the `status` field. The + /// process exit code does NOT encode this — `rocm examine` always exits 0 + /// when it ran; callers branch on `status` instead. + fn compute_status(&self) -> String { + let value = if self.is_wsl { + "wsl" + } else if !matches!(self.os_family.as_str(), "linux" | "windows") { + "unsupported-os" + } else if !self.has_amd_gpu { + "no-amd-gpu" + } else if !self.probe_failures.is_empty() { + "degraded" + } else { + "ok" + }; + value.to_owned() } } @@ -1087,8 +1111,8 @@ fn probe_env(e: &mut Examination) { let Ok(value) = std::env::var(key) else { continue; }; - let value = if matches!(*key, "PATH" | "LD_LIBRARY_PATH") && value.len() > 4000 { - format!("{}...[truncated]", &value[..4000]) + let value = if matches!(*key, "PATH" | "LD_LIBRARY_PATH") { + truncate_to_chars(value, ENV_VALUE_MAX_CHARS) } else { value }; @@ -1125,6 +1149,18 @@ fn probe_env(e: &mut Examination) { } } +/// Truncate `value` to at most `max_chars` characters, appending a marker when +/// truncated. Slices on char boundaries (matching Python's `value[:n]`); a byte +/// slice would panic when the cut lands inside a multibyte character. +fn truncate_to_chars(value: String, max_chars: usize) -> String { + if value.chars().count() > max_chars { + let truncated: String = value.chars().take(max_chars).collect(); + format!("{truncated}...[truncated]") + } else { + value + } +} + fn probe_container(e: &mut Examination) { for (marker, kind) in [("/.dockerenv", "docker"), ("/run/.containerenv", "podman")] { if Path::new(marker).exists() { @@ -1355,9 +1391,10 @@ mod tests { #[test] fn examination_top_level_keys_match_examine_py_contract() { - // The exact field set examine.py emits. diagnose.py reads against these - // names, so this is the frozen wire contract — adding/removing/renaming a - // top-level field here is a contract change and must be intentional. + // The field set examine.py emits, plus the CLI-only `status` addition. + // diagnose.py reads against these names, so this is the frozen wire + // contract — adding/removing/renaming a top-level field is a contract + // change and must be intentional. let expected: std::collections::BTreeSet<&str> = [ "os_family", "os_version", @@ -1408,6 +1445,7 @@ mod tests { "dmesg_amdgpu_tail", "notes", "probe_failures", + "status", ] .into_iter() .collect(); @@ -1451,6 +1489,40 @@ mod tests { assert!(value.get("kfd").expect("present").is_null()); } + #[test] + fn status_reflects_scope_precedence() { + let mut e = Examination { + os_family: "linux".to_owned(), + has_amd_gpu: true, + ..Examination::default() + }; + assert_eq!(e.compute_status(), "ok"); + e.probe_failures.push("lspci missing".to_owned()); + assert_eq!(e.compute_status(), "degraded"); + e.probe_failures.clear(); + e.has_amd_gpu = false; + assert_eq!(e.compute_status(), "no-amd-gpu"); + e.os_family = "other".to_owned(); + assert_eq!(e.compute_status(), "unsupported-os"); + e.is_wsl = true; + assert_eq!(e.compute_status(), "wsl"); + } + + #[test] + fn env_truncation_is_char_safe_across_multibyte_boundary() { + // 5000 'é' chars = 10000 bytes; byte 4000 lands inside a char, which a + // byte slice would panic on. Must truncate cleanly to 4000 chars. + let value = "é".repeat(5000); + let out = truncate_to_chars(value, 4000); + assert!(out.ends_with("...[truncated]")); + assert_eq!(out.chars().filter(|&c| c == 'é').count(), 4000); + } + + #[test] + fn env_truncation_leaves_short_values_untouched() { + assert_eq!(truncate_to_chars("short".to_owned(), 4000), "short"); + } + #[test] fn iommu_param_parsed_from_cmdline() { assert_eq!( diff --git a/crates/rocm-core/src/fix.rs b/crates/rocm-core/src/fix.rs index 9decef02..766fea7a 100644 --- a/crates/rocm-core/src/fix.rs +++ b/crates/rocm-core/src/fix.rs @@ -462,8 +462,10 @@ pub fn apply(fix_id: &str, opts: &FixOptions) -> i32 { if let Some(runner) = recipe.runner { runner(opts) } else { + // Internal error (auto-applicable recipe with no runner) -> 1, not 4 + // (4 is reserved for "attempted but the command failed"). eprintln!("Internal error: auto-applicable recipe has no runner."); - 4 + 1 } } @@ -804,6 +806,10 @@ fn run_path_export_windows(opts: &FixOptions) -> i32 { /// fix-9: persist HIP_VISIBLE_DEVICES so the iGPU is hidden. fn run_hip_visible_devices(opts: &FixOptions) -> i32 { + if let Some(idx) = opts.device_index.filter(|&i| i < 0) { + println!("--device-index must be >= 0 (got {idx})."); + return 3; + } if runtime_is_windows() { run_hip_visible_devices_windows(opts) } else { diff --git a/crates/rocm-core/src/lib.rs b/crates/rocm-core/src/lib.rs index e8c42dbc..4d7148d8 100644 --- a/crates/rocm-core/src/lib.rs +++ b/crates/rocm-core/src/lib.rs @@ -31,7 +31,7 @@ pub use diagnose::{ DiagnoseReport, Diagnosis, Fix, diagnose as run_diagnose, render_report_text as render_diagnose_text, }; -pub use examine::{Examination, FrameworkProbe}; +pub use examine::{Examination, FrameworkProbe, WSL_ROUTE_OUT_NOTE}; pub use fix::{FixOptions, apply as apply_fix, list_recipes as list_fix_recipes}; use runtime::env_path_override; #[cfg(test)]