From b5c79e11f69917946627aa48be37d3f05985ff0b Mon Sep 17 00:00:00 2001 From: Ilya Zlobintsev Date: Sun, 7 Jan 2024 16:28:29 +0200 Subject: [PATCH] feat: initial rdna3 fan control support (#7) * feat: WIP RDNA3 fan control support * feat: fan_minimum_pwm and fan_target_temperature * feat: get_fan_curve * feat: set and reset functions for rdna3 fan values * chore: clarify doc * fix: use proper path * attempt to fix fan value write issue * attempt to fix fan value write issue 2 * chore: remove debug print line * bump version --- Cargo.toml | 2 +- src/gpu_handle/fan_control.rs | 159 +++++++++++ src/gpu_handle/mod.rs | 259 +++++++++++++++++- src/sysfs.rs | 9 +- .../fan_ctrl/acoustic_limit_rpm_threshold | 4 + .../fan_ctrl/acoustic_target_rpm_threshold | 4 + tests/data/rx7800xt/gpu_od/fan_ctrl/fan_curve | 9 + .../rx7800xt/gpu_od/fan_ctrl/fan_minimum_pwm | 4 + .../gpu_od/fan_ctrl/fan_target_temperature | 4 + tests/data/rx7800xt/uevent | 1 + tests/rx7800xt.rs | 30 ++ 11 files changed, 473 insertions(+), 12 deletions(-) create mode 100644 src/gpu_handle/fan_control.rs create mode 100644 tests/data/rx7800xt/gpu_od/fan_ctrl/acoustic_limit_rpm_threshold create mode 100644 tests/data/rx7800xt/gpu_od/fan_ctrl/acoustic_target_rpm_threshold create mode 100644 tests/data/rx7800xt/gpu_od/fan_ctrl/fan_curve create mode 100644 tests/data/rx7800xt/gpu_od/fan_ctrl/fan_minimum_pwm create mode 100644 tests/data/rx7800xt/gpu_od/fan_ctrl/fan_target_temperature create mode 100644 tests/data/rx7800xt/uevent create mode 100644 tests/rx7800xt.rs diff --git a/Cargo.toml b/Cargo.toml index 8121ad9..22a0350 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amdgpu-sysfs" -version = "0.12.8" +version = "0.12.9" authors = ["Ilya Zlobintsev "] edition = "2021" license = "GPL-3.0" diff --git a/src/gpu_handle/fan_control.rs b/src/gpu_handle/fan_control.rs new file mode 100644 index 0000000..12f7a79 --- /dev/null +++ b/src/gpu_handle/fan_control.rs @@ -0,0 +1,159 @@ +//! Types for working with the dedicated fan control interface. +//! Only for Navi 3x (RDNA 3) and newer. Older GPUs have to use the HwMon interface. +use crate::{ + error::{Error, ErrorKind}, + Result, +}; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fmt::Write}; + +/// Information about fan characteristics. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FanInfo { + /// Current value + pub current: u32, + /// Minimum and maximum allowed values. + /// This is empty if changes to the value are not supported. + pub allowed_range: Option<(u32, u32)>, +} + +/// Custom fan curve +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FanCurve { + /// Fan curve points in the (temperature, speed) format + pub points: Vec<(u32, u8)>, + /// Allowed value ranges. + /// Empty when changes to the fan curve are not supported. + pub allowed_ranges: Option, +} + +/// Range of values allowed to be used within fan curve points +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FanCurveRanges { + /// Temperature range allowed in curve points + pub temperature_range: (u32, u32), + /// Fan speed range allowed in curve points + pub speed_range: (u8, u8), +} + +#[derive(PartialEq, Eq, Debug)] +pub(crate) struct FanCtrlContents { + pub contents: String, + pub od_range: HashMap, +} + +impl FanCtrlContents { + pub(crate) fn parse(data: &str, expected_section_name: &str) -> Result { + let mut lines = data.lines().enumerate(); + let (_, section_line) = lines + .next() + .ok_or_else(|| Error::unexpected_eol("Section name", 1))?; + + let section_name = section_line.strip_suffix(':').ok_or_else(|| { + Error::basic_parse_error(format!("Section \"{section_line}\" should end with \":\"")) + })?; + + if section_name != expected_section_name { + return Err(Error::basic_parse_error(format!( + "Found section {section_name}, expected {expected_section_name}" + ))); + } + + let mut contents = String::new(); + for (_, line) in &mut lines { + if line == "OD_RANGE:" { + break; + } + writeln!(contents, "{line}").unwrap(); + } + contents.pop(); // Remove newline symbol + + let mut od_range = HashMap::new(); + for (i, range_line) in lines { + let (name, value) = + range_line + .split_once(": ") + .ok_or_else(|| ErrorKind::ParseError { + msg: format!("Range line \"{range_line}\" does not have a separator"), + line: i + 1, + })?; + let (min, max) = value.split_once(' ').ok_or_else(|| ErrorKind::ParseError { + msg: format!( + "Range line \"{range_line}\" does not have a separator between the values" + ), + line: i + 1, + })?; + + od_range.insert(name.to_owned(), (min.to_owned(), max.to_owned())); + } + + Ok(Self { contents, od_range }) + } +} + +#[cfg(test)] +mod tests { + use super::FanCtrlContents; + use pretty_assertions::assert_eq; + + #[test] + fn parse_od_acoustic_limit() { + let data = "\ +OD_ACOUSTIC_LIMIT: +2450 +OD_RANGE: +ACOUSTIC_LIMIT: 500 3100"; + let contents = FanCtrlContents::parse(data, "OD_ACOUSTIC_LIMIT").unwrap(); + let expected_contents = FanCtrlContents { + contents: "2450".to_owned(), + od_range: [( + "ACOUSTIC_LIMIT".to_owned(), + ("500".to_owned(), "3100".to_owned()), + )] + .into_iter() + .collect(), + }; + assert_eq!(expected_contents, contents); + } + + #[test] + fn parse_fan_curve() { + let data = "\ +OD_FAN_CURVE: +0: 0C 0% +1: 0C 0% +2: 0C 0% +3: 0C 0% +4: 0C 0% +OD_RANGE: +FAN_CURVE(hotspot temp): 25C 100C +FAN_CURVE(fan speed): 20% 100%"; + let contents = FanCtrlContents::parse(data, "OD_FAN_CURVE").unwrap(); + let expected_contents = FanCtrlContents { + contents: "\ +0: 0C 0% +1: 0C 0% +2: 0C 0% +3: 0C 0% +4: 0C 0%" + .to_owned(), + od_range: [ + ( + "FAN_CURVE(hotspot temp)".to_owned(), + ("25C".to_owned(), "100C".to_owned()), + ), + ( + "FAN_CURVE(fan speed)".to_owned(), + ("20%".to_owned(), "100%".to_owned()), + ), + ] + .into_iter() + .collect(), + }; + assert_eq!(expected_contents, contents); + } +} diff --git a/src/gpu_handle/mod.rs b/src/gpu_handle/mod.rs index dbe0831..3f4a057 100644 --- a/src/gpu_handle/mod.rs +++ b/src/gpu_handle/mod.rs @@ -3,12 +3,15 @@ pub mod overdrive; #[macro_use] mod power_levels; +pub mod fan_control; pub mod power_profile_mode; pub use power_levels::{PowerLevelKind, PowerLevels}; +use self::fan_control::{FanCurve, FanCurveRanges, FanInfo}; use crate::{ error::{Error, ErrorContext, ErrorKind}, + gpu_handle::fan_control::FanCtrlContents, hw_mon::HwMon, sysfs::SysFS, Result, @@ -20,6 +23,7 @@ use std::{ collections::HashMap, fmt::{self, Display}, fs, + io::Write, path::PathBuf, str::FromStr, }; @@ -103,15 +107,18 @@ impl GpuHandle { } fn get_link(&self, file_name: &str) -> Result { + // Despite being labled NAVI10, newer generations use the same port device ids const NAVI10_UPSTREAM_PORT: &str = "0x1478\n"; const NAVI10_DOWNSTREAM_PORT: &str = "0x1479\n"; let mut sysfs_path = std::fs::canonicalize(self.get_path())?.join("../"); // pcie port for _ in 0..2 { - let Ok(did) = std::fs::read_to_string(&sysfs_path.join("device")) else { break }; + let Ok(did) = std::fs::read_to_string(&sysfs_path.join("device")) else { + break; + }; - if &did == NAVI10_UPSTREAM_PORT || &did == NAVI10_DOWNSTREAM_PORT { + if did == NAVI10_UPSTREAM_PORT || did == NAVI10_DOWNSTREAM_PORT { sysfs_path.push("../"); } else { break; @@ -120,7 +127,12 @@ impl GpuHandle { sysfs_path.pop(); - Self { sysfs_path, hw_monitors: Vec::new(), uevent: HashMap::new() }.read_file(file_name) + Self { + sysfs_path, + hw_monitors: Vec::new(), + uevent: HashMap::new(), + } + .read_file(file_name) } /// Gets the current PCIe link speed. @@ -274,8 +286,6 @@ impl GpuHandle { /// Writes and commits the given clocks table to `pp_od_clk_voltage`. #[cfg(feature = "overdrive")] pub fn set_clocks_table(&self, table: &ClocksTableGen) -> Result<()> { - use std::io::Write; - let path = self.sysfs_path.join("pp_od_clk_voltage"); let mut file = File::create(path)?; @@ -288,8 +298,6 @@ impl GpuHandle { /// Resets the clocks table to the default configuration. #[cfg(feature = "overdrive")] pub fn reset_clocks_table(&self) -> Result<()> { - use std::io::Write; - let path = self.sysfs_path.join("pp_od_clk_voltage"); let mut file = File::create(path)?; file.write_all(b"r\n")?; @@ -310,6 +318,243 @@ impl GpuHandle { pub fn set_active_power_profile_mode(&self, i: u16) -> Result<()> { self.write_file("pp_power_profile_mode", format!("{i}\n")) } + + fn read_fan_info(&self, file: &str, section_name: &str, range_name: &str) -> Result { + let file_path = self.get_path().join("gpu_od/fan_ctrl").join(file); + let data = self.read_file(file_path)?; + let contents = FanCtrlContents::parse(&data, section_name)?; + + let current = contents.contents.parse()?; + + let allowed_range = match contents.od_range.get(range_name) { + Some((raw_min, raw_max)) => { + let min = raw_min.parse()?; + let max = raw_max.parse()?; + Some((min, max)) + } + None => None, + }; + + Ok(FanInfo { + current, + allowed_range, + }) + } + + /// Gets the fan acoustic limit. Values are in RPM. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn get_fan_acoustic_limit(&self) -> Result { + self.read_fan_info( + "acoustic_limit_rpm_threshold", + "OD_ACOUSTIC_LIMIT", + "ACOUSTIC_LIMIT", + ) + } + + /// Gets the fan acoustic target. Values are in RPM. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn get_fan_acoustic_target(&self) -> Result { + self.read_fan_info( + "acoustic_target_rpm_threshold", + "OD_ACOUSTIC_TARGET", + "ACOUSTIC_TARGET", + ) + } + + /// Gets the fan temperature target. Values are in degrees. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn get_fan_target_temperature(&self) -> Result { + self.read_fan_info( + "fan_target_temperature", + "FAN_TARGET_TEMPERATURE", + "TARGET_TEMPERATURE", + ) + } + + /// Gets the fan minimum PWM. Values are in percentages. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn get_fan_minimum_pwm(&self) -> Result { + self.read_fan_info("fan_minimum_pwm", "FAN_MINIMUM_PWM", "MINIMUM_PWM") + } + + fn set_fan_value( + &self, + file: &str, + value: u32, + section_name: &str, + range_name: &str, + ) -> Result<()> { + let info = self.read_fan_info(file, section_name, range_name)?; + match info.allowed_range { + Some((min, max)) => { + if !(min..=max).contains(&value) { + return Err(Error::not_allowed(format!( + "Value {value} is out of range, should be between {min} and {max}" + ))); + } + + let file_path = self.sysfs_path.join("gpu_od/fan_ctrl").join(file); + std::fs::write(&file_path, format!("{value}\n"))?; + std::fs::write(&file_path, "c\n")?; + + Ok(()) + } + None => Err(Error::not_allowed(format!( + "Changes to {range_name} are not allowed" + ))), + } + } + + /// Sets the fan acoustic limit. Value is in RPM. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn set_fan_acoustic_limit(&self, value: u32) -> Result<()> { + self.set_fan_value( + "acoustic_limit_rpm_threshold", + value, + "OD_ACOUSTIC_LIMIT", + "ACOUSTIC_LIMIT", + ) + } + + /// Sets the fan acoustic target. Value is in RPM. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn set_fan_acoustic_target(&self, value: u32) -> Result<()> { + self.set_fan_value( + "acoustic_target_rpm_threshold", + value, + "OD_ACOUSTIC_TARGET", + "ACOUSTIC_TARGET", + ) + } + + /// Sets the fan temperature target. Value is in degrees. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn set_fan_target_temperature(&self, value: u32) -> Result<()> { + self.set_fan_value( + "fan_target_temperature", + value, + "FAN_TARGET_TEMPERATURE", + "TARGET_TEMPERATURE", + ) + } + + /// Sets the fan minimum PWM. Value is a percentage. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn set_fan_minimum_pwm(&self, value: u32) -> Result<()> { + self.set_fan_value("fan_minimum_pwm", value, "FAN_MINIMUM_PWM", "MINIMUM_PWM") + } + + fn reset_fan_value(&self, file: &str) -> Result<()> { + let file_path = self.sysfs_path.join("gpu_od/fan_ctrl").join(file); + let mut file = File::create(file_path)?; + writeln!(file, "r")?; + Ok(()) + } + + /// Resets the fan acoustic limit. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn reset_fan_acoustic_limit(&self) -> Result<()> { + self.reset_fan_value("acoustic_limit_rpm_threshold") + } + + /// Resets the fan acoustic target. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn reset_fan_acoustic_target(&self) -> Result<()> { + self.reset_fan_value("acoustic_target_rpm_threshold") + } + + /// Resets the fan target temperature. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn reset_fan_target_temperature(&self) -> Result<()> { + self.reset_fan_value("fan_target_temperature") + } + + /// Resets the fan minimum pwm. + /// + /// Only available on Navi3x (RDNA 3) or newer. + /// + pub fn reset_fan_minimum_pwm(&self) -> Result<()> { + self.reset_fan_value("fan_minimum_pwm") + } + + /// Gets the fan curve. + /// Note: if no custom curve is used, all of the curve points may be set to 0. + /// + /// Only available on Navi3x (RDNA 3) or newer. + pub fn get_fan_curve(&self) -> Result { + let data = self.read_file("gpu_od/fan_ctrl/fan_curve")?; + let contents = FanCtrlContents::parse(&data, "OD_FAN_CURVE")?; + let points = contents + .contents + .lines() + .enumerate() + .map(|(i, line)| { + let mut split = line.split(' '); + split.next(); // Discard index + + let raw_temp = split + .next() + .ok_or_else(|| Error::unexpected_eol("Temperature value", i))?; + let temp = raw_temp.trim_end_matches('C').parse()?; + + let raw_speed = split + .next() + .ok_or_else(|| Error::unexpected_eol("Speed value", i))?; + let speed = raw_speed.trim_end_matches('%').parse()?; + + Ok((temp, speed)) + }) + .collect::>()?; + + let temp_range = contents.od_range.get("FAN_CURVE(hotspot temp)"); + let speed_range = contents.od_range.get("FAN_CURVE(fan speed)"); + + let allowed_ranges = if let Some(((min_temp, max_temp), (min_speed, max_speed))) = + (temp_range).zip(speed_range) + { + let temperature_range = ( + min_temp.trim_end_matches('C').parse()?, + max_temp.trim_end_matches('C').parse()?, + ); + let speed_range = ( + min_speed.trim_end_matches('%').parse()?, + max_speed.trim_end_matches('%').parse()?, + ); + Some(FanCurveRanges { + temperature_range, + speed_range, + }) + } else { + None + }; + + Ok(FanCurve { + points, + allowed_ranges, + }) + } } impl SysFS for GpuHandle { diff --git a/src/sysfs.rs b/src/sysfs.rs index 678b04c..bd1d1ef 100644 --- a/src/sysfs.rs +++ b/src/sysfs.rs @@ -3,7 +3,7 @@ use crate::{ error::{Error, ErrorContext}, Result, }; -use std::{fs, path::Path, str::FromStr}; +use std::{fmt::Debug, fs, path::Path, str::FromStr}; /// General functionality of a SysFS. pub trait SysFS { @@ -11,9 +11,10 @@ pub trait SysFS { fn get_path(&self) -> &Path; /// Reads the content of a file in the `SysFS`. - fn read_file(&self, file: &str) -> Result { - Ok(fs::read_to_string(self.get_path().join(file)) - .with_context(|| format!("Could not read file {file}"))? + fn read_file(&self, file: impl AsRef + Debug) -> Result { + let path = file.as_ref(); + Ok(fs::read_to_string(self.get_path().join(path)) + .with_context(|| format!("Could not read file {file:?}"))? .replace(char::from(0), "") // Workaround for random null bytes in SysFS entries .trim() .to_owned()) diff --git a/tests/data/rx7800xt/gpu_od/fan_ctrl/acoustic_limit_rpm_threshold b/tests/data/rx7800xt/gpu_od/fan_ctrl/acoustic_limit_rpm_threshold new file mode 100644 index 0000000..80979e3 --- /dev/null +++ b/tests/data/rx7800xt/gpu_od/fan_ctrl/acoustic_limit_rpm_threshold @@ -0,0 +1,4 @@ +OD_ACOUSTIC_LIMIT: +2450 +OD_RANGE: +ACOUSTIC_LIMIT: 500 3100 \ No newline at end of file diff --git a/tests/data/rx7800xt/gpu_od/fan_ctrl/acoustic_target_rpm_threshold b/tests/data/rx7800xt/gpu_od/fan_ctrl/acoustic_target_rpm_threshold new file mode 100644 index 0000000..7e7d681 --- /dev/null +++ b/tests/data/rx7800xt/gpu_od/fan_ctrl/acoustic_target_rpm_threshold @@ -0,0 +1,4 @@ +OD_ACOUSTIC_TARGET: +2200 +OD_RANGE: +ACOUSTIC_TARGET: 500 3100 \ No newline at end of file diff --git a/tests/data/rx7800xt/gpu_od/fan_ctrl/fan_curve b/tests/data/rx7800xt/gpu_od/fan_ctrl/fan_curve new file mode 100644 index 0000000..21a21d2 --- /dev/null +++ b/tests/data/rx7800xt/gpu_od/fan_ctrl/fan_curve @@ -0,0 +1,9 @@ +OD_FAN_CURVE: +0: 0C 0% +1: 0C 0% +2: 0C 0% +3: 0C 0% +4: 0C 0% +OD_RANGE: +FAN_CURVE(hotspot temp): 25C 100C +FAN_CURVE(fan speed): 20% 100% \ No newline at end of file diff --git a/tests/data/rx7800xt/gpu_od/fan_ctrl/fan_minimum_pwm b/tests/data/rx7800xt/gpu_od/fan_ctrl/fan_minimum_pwm new file mode 100644 index 0000000..2b98191 --- /dev/null +++ b/tests/data/rx7800xt/gpu_od/fan_ctrl/fan_minimum_pwm @@ -0,0 +1,4 @@ +FAN_MINIMUM_PWM: +20 +OD_RANGE: +MINIMUM_PWM: 20 100 \ No newline at end of file diff --git a/tests/data/rx7800xt/gpu_od/fan_ctrl/fan_target_temperature b/tests/data/rx7800xt/gpu_od/fan_ctrl/fan_target_temperature new file mode 100644 index 0000000..864254b --- /dev/null +++ b/tests/data/rx7800xt/gpu_od/fan_ctrl/fan_target_temperature @@ -0,0 +1,4 @@ +FAN_TARGET_TEMPERATURE: +95 +OD_RANGE: +TARGET_TEMPERATURE: 25 110 \ No newline at end of file diff --git a/tests/data/rx7800xt/uevent b/tests/data/rx7800xt/uevent new file mode 100644 index 0000000..56f2d79 --- /dev/null +++ b/tests/data/rx7800xt/uevent @@ -0,0 +1 @@ +DRIVER=amdgpu \ No newline at end of file diff --git a/tests/rx7800xt.rs b/tests/rx7800xt.rs new file mode 100644 index 0000000..0c4c662 --- /dev/null +++ b/tests/rx7800xt.rs @@ -0,0 +1,30 @@ +mod sysfs; + +use amdgpu_sysfs::gpu_handle::{ + fan_control::{FanCurve, FanCurveRanges, FanInfo}, + GpuHandle, +}; + +test_with_handle! { + "rx7800xt", + get_fan_acoustic_limit => { + GpuHandle::get_fan_acoustic_limit, + Ok(FanInfo { current: 2450, allowed_range: Some((500, 3100)) }) + }, + get_fan_acoustic_target => { + GpuHandle::get_fan_acoustic_target, + Ok(FanInfo { current: 2200, allowed_range: Some((500, 3100)) }) + }, + get_fan_target_temperature => { + GpuHandle::get_fan_target_temperature, + Ok(FanInfo { current: 95, allowed_range: Some((25, 110)) }) + }, + get_fan_minimum_pwm => { + GpuHandle::get_fan_minimum_pwm, + Ok(FanInfo { current: 20, allowed_range: Some((20, 100)) }) + }, + get_fan_curve => { + GpuHandle::get_fan_curve, + Ok(FanCurve { points: vec![(0, 0); 5], allowed_ranges: Some(FanCurveRanges {temperature_range: (25, 100), speed_range: (20, 100) })}) + } +}