1#[cfg(feature = "overdrive")]
3pub mod overdrive;
4#[macro_use]
5mod power_levels;
6pub mod fan_control;
7pub mod power_profile_mode;
8
9pub use power_levels::{PowerLevelKind, PowerLevels};
10
11use self::fan_control::{FanCurve, FanCurveRanges, FanInfo};
12use crate::{
13 error::{Error, ErrorContext, ErrorKind},
14 gpu_handle::fan_control::FanCtrlContents,
15 hw_mon::HwMon,
16 sysfs::SysFS,
17 Result,
18};
19use power_profile_mode::PowerProfileModesTable;
20#[cfg(feature = "serde")]
21use serde::{Deserialize, Serialize};
22use std::{
23 collections::HashMap,
24 fmt::{self, Display, Write as _},
25 fs,
26 io::Write,
27 path::{Path, PathBuf},
28 str::FromStr,
29};
30#[cfg(feature = "overdrive")]
31use {
32 self::overdrive::{ClocksTable, ClocksTableGen},
33 std::fs::File,
34};
35
36#[derive(Clone, Debug)]
38pub struct GpuHandle {
39 sysfs_path: PathBuf,
40 pub hw_monitors: Vec<HwMon>,
42 uevent: HashMap<String, String>,
43}
44
45impl GpuHandle {
46 pub fn new_from_path(sysfs_path: PathBuf) -> Result<Self> {
51 let mut hw_monitors = Vec::new();
52
53 if let Ok(hw_mons_iter) = fs::read_dir(sysfs_path.join("hwmon")) {
54 for hw_mon_dir in hw_mons_iter.flatten() {
55 if let Ok(hw_mon) = HwMon::new_from_path(hw_mon_dir.path()) {
56 hw_monitors.push(hw_mon);
57 }
58 }
59 }
60
61 let uevent_raw = fs::read_to_string(sysfs_path.join("uevent"))?.replace(char::from(0), "");
62
63 let mut uevent = HashMap::new();
64
65 for (i, line) in uevent_raw.trim().lines().enumerate() {
66 let (key, value) = line
67 .split_once('=')
68 .ok_or_else(|| Error::unexpected_eol("=", i))?;
69
70 uevent.insert(key.to_owned(), value.to_owned());
71 }
72
73 match uevent.get("DRIVER") {
74 Some(_) => Ok(Self {
75 sysfs_path,
76 hw_monitors,
77 uevent,
78 }),
79 None => Err(ErrorKind::InvalidSysFS.into()),
80 }
81 }
82
83 pub fn get_driver(&self) -> &str {
85 self.uevent.get("DRIVER").unwrap()
86 }
87
88 pub fn get_pci_id(&self) -> Option<(&str, &str)> {
90 match self.uevent.get("PCI_ID") {
91 Some(pci_str) => pci_str.split_once(':'),
92 None => None,
93 }
94 }
95
96 pub fn get_pci_subsys_id(&self) -> Option<(&str, &str)> {
98 match self.uevent.get("PCI_SUBSYS_ID") {
99 Some(pci_str) => pci_str.split_once(':'),
100 None => None,
101 }
102 }
103
104 pub fn get_pci_slot_name(&self) -> Option<&str> {
106 self.uevent.get("PCI_SLOT_NAME").map(|s| s.as_str())
107 }
108
109 fn get_link(&self, file_name: &str) -> Result<String> {
110 const NAVI10_UPSTREAM_PORT: &str = "0x1478\n";
112 const NAVI10_DOWNSTREAM_PORT: &str = "0x1479\n";
113
114 let mut sysfs_path = std::fs::canonicalize(self.get_path())?.join("../"); for _ in 0..2 {
117 let Ok(did) = std::fs::read_to_string(sysfs_path.join("device")) else {
118 break;
119 };
120
121 if did == NAVI10_UPSTREAM_PORT || did == NAVI10_DOWNSTREAM_PORT {
122 sysfs_path.push("../");
123 } else {
124 break;
125 }
126 }
127
128 sysfs_path.pop();
129
130 Self {
131 sysfs_path,
132 hw_monitors: Vec::new(),
133 uevent: HashMap::new(),
134 }
135 .read_file(file_name)
136 }
137
138 pub fn get_current_link_speed(&self) -> Result<String> {
140 self.get_link("current_link_speed")
141 }
142
143 pub fn get_current_link_width(&self) -> Result<String> {
145 self.get_link("current_link_width")
146 }
147
148 pub fn get_max_link_speed(&self) -> Result<String> {
150 self.get_link("max_link_speed")
151 }
152
153 pub fn get_max_link_width(&self) -> Result<String> {
155 self.get_link("max_link_width")
156 }
157
158 fn read_vram_file(&self, file: &str) -> Result<u64> {
159 let raw_vram = self.read_file(file)?;
160 Ok(raw_vram.parse()?)
161 }
162
163 pub fn get_total_vram(&self) -> Result<u64> {
165 self.read_vram_file("mem_info_vram_total")
166 }
167
168 pub fn get_used_vram(&self) -> Result<u64> {
170 self.read_vram_file("mem_info_vram_used")
171 }
172
173 pub fn get_busy_percent(&self) -> Result<u8> {
175 let raw_busy = self.read_file("gpu_busy_percent")?;
176 Ok(raw_busy.parse()?)
177 }
178
179 pub fn get_vbios_version(&self) -> Result<String> {
181 self.read_file("vbios_version")
182 }
183
184 pub fn get_vram_vendor(&self) -> Result<String> {
186 self.read_file("mem_info_vram_vendor")
187 }
188
189 pub fn get_power_force_performance_level(&self) -> Result<PerformanceLevel> {
191 let raw_level = self.read_file("power_dpm_force_performance_level")?;
192 PerformanceLevel::from_str(&raw_level)
193 }
194
195 pub fn set_power_force_performance_level(&self, level: PerformanceLevel) -> Result<()> {
197 self.write_file("power_dpm_force_performance_level", level.to_string())
198 }
199
200 pub fn get_clock_levels<T>(&self, kind: PowerLevelKind) -> Result<PowerLevels<T>>
204 where
205 T: FromStr,
206 <T as FromStr>::Err: Display,
207 {
208 self.read_file(kind.filename())
209 .and_then(|content| Self::parse_clock_levels(&content, kind))
210 }
211
212 fn parse_clock_levels<T>(content: &str, kind: PowerLevelKind) -> Result<PowerLevels<T>>
213 where
214 T: FromStr,
215 <T as FromStr>::Err: Display,
216 {
217 let mut levels = Vec::new();
218 let mut active = None;
219 let mut invalid_active = false;
220
221 for mut line in content.trim().split('\n') {
222 if let Some(stripped) = line.strip_suffix('*') {
223 line = stripped;
224
225 if let Some(identifier) = stripped.split(':').next() {
226 if identifier.trim().eq_ignore_ascii_case("S") {
227 continue;
228 }
229
230 if !invalid_active {
231 if active.is_some() {
232 active = None;
233 invalid_active = true;
234 } else {
235 let idx = identifier
236 .trim()
237 .parse()
238 .context("Unexpected power level identifier")?;
239 active = Some(idx);
240 }
241 }
242 }
243 }
244 if let Some(s) = line.split(':').next_back() {
245 let parse_result = if let Some(suffix) = kind.value_suffix() {
246 let raw_value = s.trim().to_lowercase();
247 let value =
248 raw_value
249 .strip_suffix(suffix)
250 .ok_or_else(|| ErrorKind::ParseError {
251 msg: format!("Level did not have the expected suffix {suffix}"),
252 line: levels.len() + 1,
253 })?;
254 T::from_str(value)
255 } else {
256 let value = s.trim();
257 T::from_str(value)
258 };
259
260 let parsed_value = parse_result.map_err(|err| ErrorKind::ParseError {
261 msg: format!("Could not deserialize power level value: {err}"),
262 line: levels.len() + 1,
263 })?;
264 levels.push(parsed_value);
265 }
266 }
267
268 Ok(PowerLevels { levels, active })
269 }
270
271 impl_get_clocks_levels!(get_core_clock_levels, PowerLevelKind::CoreClock, u64);
272 impl_get_clocks_levels!(get_memory_clock_levels, PowerLevelKind::MemoryClock, u64);
273 impl_get_clocks_levels!(get_pcie_clock_levels, PowerLevelKind::PcieSpeed, String);
274
275 pub fn set_enabled_power_levels(&self, kind: PowerLevelKind, levels: &[u8]) -> Result<()> {
279 match self.get_power_force_performance_level()? {
280 PerformanceLevel::Manual => {
281 let mut s = String::new();
282
283 for l in levels {
284 s.push(char::from_digit((*l).into(), 10).unwrap());
285 s.push(' ');
286 }
287
288 Ok(self.write_file(kind.filename(), s)?)
289 }
290 _ => Err(ErrorKind::NotAllowed(
291 "power_force_performance level needs to be set to 'manual' to adjust power levels"
292 .to_string(),
293 )
294 .into()),
295 }
296 }
297
298 #[cfg(feature = "overdrive")]
300 pub fn get_clocks_table(&self) -> Result<ClocksTableGen> {
301 self.read_file_parsed("pp_od_clk_voltage")
302 }
303
304 #[cfg(feature = "overdrive")]
306 pub fn set_clocks_table(&self, new_table: &ClocksTableGen) -> Result<CommitHandle> {
307 let old_table = self.get_clocks_table()?;
308
309 let path = self.sysfs_path.join("pp_od_clk_voltage");
310 let mut file = File::create(&path)?;
311
312 new_table.write_commands(&mut file, &old_table)?;
313
314 Ok(CommitHandle::new(path))
315 }
316
317 #[cfg(feature = "overdrive")]
319 pub fn reset_clocks_table(&self) -> Result<()> {
320 let path = self.sysfs_path.join("pp_od_clk_voltage");
321 let mut file = File::create(path)?;
322 file.write_all(b"r\n")?;
323
324 Ok(())
325 }
326
327 pub fn get_power_profile_modes(&self) -> Result<PowerProfileModesTable> {
331 let contents = self.read_file("pp_power_profile_mode")?;
332 PowerProfileModesTable::parse(&contents)
333 }
334
335 pub fn set_active_power_profile_mode(&self, i: u16) -> Result<()> {
338 self.write_file("pp_power_profile_mode", format!("{i}\n"))
339 }
340
341 pub fn set_custom_power_profile_mode_heuristics(
344 &self,
345 components: &[Vec<Option<i32>>],
346 ) -> Result<()> {
347 let table = self.get_power_profile_modes()?;
348 let (index, current_custom_profile) = table
349 .modes
350 .iter()
351 .find(|(_, profile)| profile.is_custom())
352 .ok_or_else(|| {
353 ErrorKind::NotAllowed("Could not find a custom power profile".to_owned())
354 })?;
355
356 if current_custom_profile.components.len() != components.len() {
357 return Err(ErrorKind::NotAllowed(format!(
358 "Expected {} power profile components, got {}",
359 current_custom_profile.components.len(),
360 components.len()
361 ))
362 .into());
363 }
364
365 if current_custom_profile.components.len() == 1 {
366 let mut values_command = format!("{index}");
367 for heuristic in &components[0] {
368 match heuristic {
369 Some(value) => write!(values_command, " {value}").unwrap(),
370 None => write!(values_command, " -").unwrap(),
371 }
372 }
373
374 values_command.push('\n');
375 self.write_file("pp_power_profile_mode", values_command)
376 } else {
377 for (component_index, heuristics) in components.iter().enumerate() {
378 let mut values_command = format!("{index} {component_index}");
379 for heuristic in heuristics {
380 match heuristic {
381 Some(value) => write!(values_command, " {value}").unwrap(),
382 None => write!(values_command, " -").unwrap(),
383 }
384 }
385 values_command.push('\n');
386
387 self.write_file("pp_power_profile_mode", values_command)?;
388 }
389
390 Ok(())
391 }
392 }
393
394 fn read_fan_info(&self, file: &str, section_name: &str, range_name: &str) -> Result<FanInfo> {
395 let file_path = Path::new("gpu_od/fan_ctrl").join(file);
396 let data = self.read_file(file_path)?;
397 let contents = FanCtrlContents::parse(&data, section_name)?;
398
399 let current = contents.contents.parse()?;
400
401 let allowed_range = match contents.od_range.get(range_name) {
402 Some((raw_min, raw_max)) => {
403 let min = raw_min.parse()?;
404 let max = raw_max.parse()?;
405 Some((min, max))
406 }
407 None => None,
408 };
409
410 Ok(FanInfo {
411 current,
412 allowed_range,
413 })
414 }
415
416 pub fn get_fan_acoustic_limit(&self) -> Result<FanInfo> {
421 self.read_fan_info(
422 "acoustic_limit_rpm_threshold",
423 "OD_ACOUSTIC_LIMIT",
424 "ACOUSTIC_LIMIT",
425 )
426 }
427
428 pub fn get_fan_acoustic_target(&self) -> Result<FanInfo> {
433 self.read_fan_info(
434 "acoustic_target_rpm_threshold",
435 "OD_ACOUSTIC_TARGET",
436 "ACOUSTIC_TARGET",
437 )
438 }
439
440 pub fn get_fan_target_temperature(&self) -> Result<FanInfo> {
445 self.read_fan_info(
446 "fan_target_temperature",
447 "FAN_TARGET_TEMPERATURE",
448 "TARGET_TEMPERATURE",
449 )
450 }
451
452 pub fn get_fan_minimum_pwm(&self) -> Result<FanInfo> {
457 self.read_fan_info("fan_minimum_pwm", "FAN_MINIMUM_PWM", "MINIMUM_PWM")
458 }
459
460 pub fn get_fan_zero_rpm_enable(&self) -> Result<bool> {
464 self.read_fan_info(
465 "fan_zero_rpm_enable",
466 "FAN_ZERO_RPM_ENABLE",
467 "ZERO_RPM_ENABLE",
468 )
469 .map(|info| info.current == 1)
470 }
471
472 pub fn get_fan_zero_rpm_stop_temperature(&self) -> Result<FanInfo> {
476 self.read_fan_info(
477 "fan_zero_rpm_stop_temperature",
478 "FAN_ZERO_RPM_STOP_TEMPERATURE",
479 "ZERO_RPM_STOP_TEMPERATURE",
480 )
481 }
482
483 fn set_fan_value(
484 &self,
485 file: &str,
486 value: u32,
487 section_name: &str,
488 range_name: &str,
489 ) -> Result<CommitHandle> {
490 let info = self.read_fan_info(file, section_name, range_name)?;
491 match info.allowed_range {
492 Some((min, max)) => {
493 if !(min..=max).contains(&value) {
494 return Err(Error::not_allowed(format!(
495 "Value {value} is out of range, should be between {min} and {max}"
496 )));
497 }
498
499 let file_path = self.sysfs_path.join("gpu_od/fan_ctrl").join(file);
500 std::fs::write(&file_path, format!("{value}\n"))?;
501
502 Ok(CommitHandle::new(file_path))
503 }
504 None => Err(Error::not_allowed(format!(
505 "Changes to {range_name} are not allowed"
506 ))),
507 }
508 }
509
510 pub fn set_fan_acoustic_limit(&self, value: u32) -> Result<CommitHandle> {
515 self.set_fan_value(
516 "acoustic_limit_rpm_threshold",
517 value,
518 "OD_ACOUSTIC_LIMIT",
519 "ACOUSTIC_LIMIT",
520 )
521 }
522
523 pub fn set_fan_acoustic_target(&self, value: u32) -> Result<CommitHandle> {
528 self.set_fan_value(
529 "acoustic_target_rpm_threshold",
530 value,
531 "OD_ACOUSTIC_TARGET",
532 "ACOUSTIC_TARGET",
533 )
534 }
535
536 pub fn set_fan_target_temperature(&self, value: u32) -> Result<CommitHandle> {
541 self.set_fan_value(
542 "fan_target_temperature",
543 value,
544 "FAN_TARGET_TEMPERATURE",
545 "TARGET_TEMPERATURE",
546 )
547 }
548
549 pub fn set_fan_minimum_pwm(&self, value: u32) -> Result<CommitHandle> {
553 self.set_fan_value("fan_minimum_pwm", value, "FAN_MINIMUM_PWM", "MINIMUM_PWM")
554 }
555
556 pub fn set_fan_zero_rpm_enable(&self, enabled: bool) -> Result<CommitHandle> {
560 self.set_fan_value(
561 "fan_zero_rpm_enable",
562 enabled as u32,
563 "FAN_ZERO_RPM_ENABLE",
564 "ZERO_RPM_ENABLE",
565 )
566 }
567
568 pub fn set_fan_zero_rpm_stop_temperature(&self, value: u32) -> Result<CommitHandle> {
572 self.set_fan_value(
573 "fan_zero_rpm_stop_temperature",
574 value,
575 "FAN_ZERO_RPM_STOP_TEMPERATURE",
576 "ZERO_RPM_STOP_TEMPERATURE",
577 )
578 }
579
580 fn reset_fan_value(&self, file: &str) -> Result<()> {
581 let file_path = self.sysfs_path.join("gpu_od/fan_ctrl").join(file);
582 let mut file = File::create(file_path)?;
583 writeln!(file, "r")?;
584 Ok(())
585 }
586
587 pub fn reset_fan_acoustic_limit(&self) -> Result<()> {
592 self.reset_fan_value("acoustic_limit_rpm_threshold")
593 }
594
595 pub fn reset_fan_acoustic_target(&self) -> Result<()> {
600 self.reset_fan_value("acoustic_target_rpm_threshold")
601 }
602
603 pub fn reset_fan_target_temperature(&self) -> Result<()> {
608 self.reset_fan_value("fan_target_temperature")
609 }
610
611 pub fn reset_fan_minimum_pwm(&self) -> Result<()> {
616 self.reset_fan_value("fan_minimum_pwm")
617 }
618
619 pub fn get_fan_curve(&self) -> Result<FanCurve> {
625 let data = self.read_file("gpu_od/fan_ctrl/fan_curve")?;
626 let contents = FanCtrlContents::parse(&data, "OD_FAN_CURVE")?;
627 let points = contents
628 .contents
629 .lines()
630 .enumerate()
631 .map(|(i, line)| {
632 let mut split = line.split(' ');
633 split.next(); let raw_temp = split
636 .next()
637 .ok_or_else(|| Error::unexpected_eol("Temperature value", i))?;
638 let temp = raw_temp.trim_end_matches('C').parse()?;
639
640 let raw_speed = split
641 .next()
642 .ok_or_else(|| Error::unexpected_eol("Speed value", i))?;
643 let speed = raw_speed.trim_end_matches('%').parse()?;
644
645 Ok((temp, speed))
646 })
647 .collect::<Result<_>>()?;
648
649 let temp_range = contents.od_range.get("FAN_CURVE(hotspot temp)");
650 let speed_range = contents.od_range.get("FAN_CURVE(fan speed)");
651
652 let allowed_ranges = if let Some(((min_temp, max_temp), (min_speed, max_speed))) =
653 (temp_range).zip(speed_range)
654 {
655 let min_temp: i32 = min_temp.trim_end_matches('C').parse()?;
656 let max_temp: i32 = max_temp.trim_end_matches('C').parse()?;
657
658 let min_speed: u8 = min_speed.trim_end_matches('%').parse()?;
659 let max_speed: u8 = max_speed.trim_end_matches('%').parse()?;
660
661 Some(FanCurveRanges {
662 temperature_range: min_temp..=max_temp,
663 speed_range: min_speed..=max_speed,
664 })
665 } else {
666 None
667 };
668
669 Ok(FanCurve {
670 points,
671 allowed_ranges,
672 })
673 }
674
675 pub fn set_fan_curve(&self, new_curve: &FanCurve) -> Result<CommitHandle> {
680 let current_curve = self.get_fan_curve()?;
681 let allowed_ranges = current_curve.allowed_ranges.ok_or_else(|| {
682 Error::not_allowed("Changes to the fan curve are not supported".to_owned())
683 })?;
684
685 let file_path = self.sysfs_path.join("gpu_od/fan_ctrl/fan_curve");
686
687 for (i, (temperature, speed)) in new_curve.points.iter().enumerate() {
688 if !allowed_ranges.temperature_range.contains(temperature) {
689 Err(Error::not_allowed(format!(
690 "Temperature value {temperature} is outside of the allowed range {:?}",
691 allowed_ranges.temperature_range
692 )))?;
693 }
694 if !allowed_ranges.speed_range.contains(speed) {
695 Err(Error::not_allowed(format!(
696 "Speed value {speed} is outside of the allowed range {:?}",
697 allowed_ranges.speed_range
698 )))?;
699 }
700
701 std::fs::write(&file_path, format!("{i} {temperature} {speed}\n"))?;
702 }
703
704 Ok(CommitHandle::new(file_path))
705 }
706
707 pub fn reset_fan_curve(&self) -> Result<()> {
712 self.reset_fan_value("fan_curve")
713 }
714}
715
716impl SysFS for GpuHandle {
717 fn get_path(&self) -> &std::path::Path {
718 &self.sysfs_path
719 }
720}
721
722#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
726#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
727#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
728pub enum PerformanceLevel {
729 #[default]
731 Auto,
732 Low,
734 High,
736 Manual,
738}
739
740impl FromStr for PerformanceLevel {
741 type Err = Error;
742
743 fn from_str(s: &str) -> Result<Self> {
744 match s {
745 "auto" | "Automatic" => Ok(PerformanceLevel::Auto),
746 "high" | "Highest Clocks" => Ok(PerformanceLevel::High),
747 "low" | "Lowest Clocks" => Ok(PerformanceLevel::Low),
748 "manual" | "Manual" => Ok(PerformanceLevel::Manual),
749 _ => Err(ErrorKind::ParseError {
750 msg: "unrecognized GPU power profile".to_string(),
751 line: 1,
752 }
753 .into()),
754 }
755 }
756}
757
758impl fmt::Display for PerformanceLevel {
759 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
760 write!(
761 f,
762 "{}",
763 match self {
764 PerformanceLevel::Auto => "auto",
765 PerformanceLevel::High => "high",
766 PerformanceLevel::Low => "low",
767 PerformanceLevel::Manual => "manual",
768 }
769 )
770 }
771}
772
773fn trim_sysfs_line(line: &str) -> &str {
775 line.trim_matches(char::from(0)).trim()
776}
777
778#[must_use]
780#[derive(Debug)]
781pub struct CommitHandle {
782 file_path: PathBuf,
783}
784
785impl CommitHandle {
786 pub(crate) fn new(file_path: PathBuf) -> Self {
787 Self { file_path }
788 }
789
790 pub fn commit(self) -> Result<()> {
792 std::fs::write(&self.file_path, "c\n").with_context(|| {
793 format!(
794 "Could not commit values to {:?}",
795 self.file_path.file_name().unwrap()
796 )
797 })
798 }
799}
800
801#[cfg(test)]
802mod tests {
803 use super::{GpuHandle, PowerLevelKind, PowerLevels};
804 use pretty_assertions::assert_eq;
805
806 #[test]
807 fn parse_clock_levels_with_suffix() {
808 let levels = GpuHandle::parse_clock_levels::<u64>(
809 "\
8100: 500Mhz
8111: 2124Mhz *",
812 PowerLevelKind::CoreClock,
813 )
814 .unwrap();
815
816 assert_eq!(
817 PowerLevels {
818 levels: vec![500, 2124],
819 active: Some(1),
820 },
821 levels
822 );
823 }
824
825 #[test]
826 fn parse_clock_levels_without_suffix() {
827 let levels = GpuHandle::parse_clock_levels::<String>(
828 "\
8290: 2.5GT/s, x1 310Mhz
8301: 16.0GT/s, x16 619Mhz *",
831 PowerLevelKind::PcieSpeed,
832 )
833 .unwrap();
834
835 assert_eq!(
836 PowerLevels {
837 levels: vec![
838 "2.5GT/s, x1 310Mhz".to_owned(),
839 "16.0GT/s, x16 619Mhz".to_owned(),
840 ],
841 active: Some(1),
842 },
843 levels
844 );
845 }
846
847 #[test]
848 fn parse_clock_levels_ignores_deep_sleep() {
849 let levels = GpuHandle::parse_clock_levels::<u64>(
850 "\
851S: 19Mhz *
8520: 615Mhz
8531: 800Mhz
8542: 888Mhz
8553: 1000Mhz",
856 PowerLevelKind::CoreClock,
857 )
858 .unwrap();
859
860 assert_eq!(
861 PowerLevels {
862 levels: vec![615, 800, 888, 1000],
863 active: None,
864 },
865 levels
866 );
867 }
868
869 #[test]
870 fn parse_clock_levels_ignores_duplicate_active_markers() {
871 let levels = GpuHandle::parse_clock_levels::<u64>(
874 "\
8750: 400Mhz
8761: 600Mhz
8772: 687Mhz *
8783: 687Mhz *",
879 PowerLevelKind::CoreClock,
880 )
881 .unwrap();
882
883 assert_eq!(
884 PowerLevels {
885 levels: vec![400, 600, 687, 687],
886 active: None,
887 },
888 levels
889 );
890 }
891}