diff options
| author | Franck Cuny <franck@fcuny.net> | 2025-08-29 08:50:05 -0700 |
|---|---|---|
| committer | Franck Cuny <franck@fcuny.net> | 2025-08-29 08:50:05 -0700 |
| commit | 67b1409f03cdb4435021c3e15daa4cb4affe452f (patch) | |
| tree | d71c6f767ff1181a8de6766b77b1af7fe9e2a47a /src/apple_silicon | |
| parent | add `git-leaderboard` (diff) | |
| parent | Merge pull request #3 from fcuny/dependabot/cargo/thiserror-2.0.11 (diff) | |
| download | x-67b1409f03cdb4435021c3e15daa4cb4affe452f.tar.gz | |
Merge remote-tracking branch 'import/main' into fcuny/rust
Diffstat (limited to 'src/apple_silicon')
| -rw-r--r-- | src/apple_silicon/.editorconfig | 23 | ||||
| -rw-r--r-- | src/apple_silicon/.github/dependabot.yml | 10 | ||||
| -rw-r--r-- | src/apple_silicon/.github/workflows/ci.yml | 35 | ||||
| -rw-r--r-- | src/apple_silicon/.gitignore | 1 | ||||
| -rw-r--r-- | src/apple_silicon/Cargo.lock | 65 | ||||
| -rw-r--r-- | src/apple_silicon/Cargo.toml | 18 | ||||
| -rw-r--r-- | src/apple_silicon/README.md | 1 | ||||
| -rw-r--r-- | src/apple_silicon/src/bin/apple_silicon.rs | 12 | ||||
| -rw-r--r-- | src/apple_silicon/src/error.rs | 23 | ||||
| -rw-r--r-- | src/apple_silicon/src/lib.rs | 2 | ||||
| -rw-r--r-- | src/apple_silicon/src/soc.rs | 302 |
11 files changed, 492 insertions, 0 deletions
diff --git a/src/apple_silicon/.editorconfig b/src/apple_silicon/.editorconfig new file mode 100644 index 0000000..9d9ad7a --- /dev/null +++ b/src/apple_silicon/.editorconfig @@ -0,0 +1,23 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.rs] +indent_size = 4 + +[*.toml] +indent_size = 2 + +[*.{diff,patch}] +end_of_line = unset +insert_final_newline = unset +trim_trailing_whitespace = unset diff --git a/src/apple_silicon/.github/dependabot.yml b/src/apple_silicon/.github/dependabot.yml new file mode 100644 index 0000000..d062b44 --- /dev/null +++ b/src/apple_silicon/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" diff --git a/src/apple_silicon/.github/workflows/ci.yml b/src/apple_silicon/.github/workflows/ci.yml new file mode 100644 index 0000000..59ce8e7 --- /dev/null +++ b/src/apple_silicon/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: 🦀 Rust CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy -- -D warnings + + - name: Run tests + run: cargo test --verbose + + - name: Build + run: cargo build --verbose diff --git a/src/apple_silicon/.gitignore b/src/apple_silicon/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/src/apple_silicon/.gitignore @@ -0,0 +1 @@ +/target diff --git a/src/apple_silicon/Cargo.lock b/src/apple_silicon/Cargo.lock new file mode 100644 index 0000000..1ee0f1d --- /dev/null +++ b/src/apple_silicon/Cargo.lock @@ -0,0 +1,65 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "apple_silicon" +version = "0.1.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" diff --git a/src/apple_silicon/Cargo.toml b/src/apple_silicon/Cargo.toml new file mode 100644 index 0000000..65dcf5e --- /dev/null +++ b/src/apple_silicon/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "apple_silicon" +version = "0.1.0" +edition = "2021" +description = "A common line tool for Apple Silicon" + +license = "MIT" +authors = ["Franck Cuny <franck@fcuny.net>"] +repository = "https://github.com/fcuny/apple_silicon" +homepage = "https://github.com/fcuny/apple_silicon" +categories = ["command-line-utilities"] + +[[bin]] +name = "apple_silicon" +path = "src/bin/apple_silicon.rs" + +[dependencies] +thiserror = "2.0.11" diff --git a/src/apple_silicon/README.md b/src/apple_silicon/README.md new file mode 100644 index 0000000..7d83327 --- /dev/null +++ b/src/apple_silicon/README.md @@ -0,0 +1 @@ +A command line utility to report information about Apple Silicon (inspired by [`asitop`](https://github.com/tlkh/asitop/tree/74ebe2cbc23d5b1eec874aebb1b9bacfe0e670cd)). diff --git a/src/apple_silicon/src/bin/apple_silicon.rs b/src/apple_silicon/src/bin/apple_silicon.rs new file mode 100644 index 0000000..0f71eeb --- /dev/null +++ b/src/apple_silicon/src/bin/apple_silicon.rs @@ -0,0 +1,12 @@ +use apple_silicon::soc::SocInfo; + +fn main() { + let cpu_info = SocInfo::new().unwrap(); + println!( + "our CPU is an {}, and we have {} CPU cores, and {} GPU cores. The TDP is {}.", + cpu_info.cpu_brand_name, + cpu_info.num_cpu_cores, + cpu_info.num_gpu_cores, + cpu_info.cpu_max_power.unwrap(), + ); +} diff --git a/src/apple_silicon/src/error.rs b/src/apple_silicon/src/error.rs new file mode 100644 index 0000000..a70b9b5 --- /dev/null +++ b/src/apple_silicon/src/error.rs @@ -0,0 +1,23 @@ +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("socinfo parsing error: `{0}`")] + Parse(String), + + #[error("I/O error: `{source}`")] + Io { + #[from] + source: std::io::Error, + }, + + #[error("utf8 conversion error: `{source}`")] + Utf8Conversion { + #[from] + source: std::string::FromUtf8Error, + }, + + #[error("integer parsing error: `{source}`")] + ParseInt { + #[from] + source: std::num::ParseIntError, + }, +} diff --git a/src/apple_silicon/src/lib.rs b/src/apple_silicon/src/lib.rs new file mode 100644 index 0000000..74bd62c --- /dev/null +++ b/src/apple_silicon/src/lib.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod soc; diff --git a/src/apple_silicon/src/soc.rs b/src/apple_silicon/src/soc.rs new file mode 100644 index 0000000..0e2bf5c --- /dev/null +++ b/src/apple_silicon/src/soc.rs @@ -0,0 +1,302 @@ +use crate::error::Error; + +use std::process::{Command, Output}; + +pub type Result<T> = std::result::Result<T, Error>; +pub type Watts = u32; +pub type Bandwidth = u32; +pub type CoreCount = u16; + +/// Information about the Silicon chip +pub struct SocInfo { + /// The CPU brand name string + pub cpu_brand_name: String, + /// Number of CPU cores + pub num_cpu_cores: CoreCount, + /// Number of GPU cores + pub num_gpu_cores: CoreCount, + /// Maximum CPU power in watts (if available) + pub cpu_max_power: Option<Watts>, + /// Maximum GPU power in watts (if available) + pub gpu_max_power: Option<Watts>, + /// Maximum CPU bandwidth in GB/s (if available) + pub cpu_max_bw: Option<Bandwidth>, + /// Maximum GPU bandwidth in GB/s (if available) + pub gpu_max_bw: Option<Bandwidth>, + /// Number of efficiency cores + pub e_core_count: CoreCount, + /// Number of performance cores + pub p_core_count: CoreCount, +} + +#[derive(Debug, PartialEq)] +enum AppleChip { + M1, + M1Pro, + M1Max, + M1Ultra, + M2, + M2Pro, + M2Max, + M2Ultra, + M3, + M3Pro, + M3Max, + Unknown, +} + +struct ChipSpecs { + cpu_tdp: Watts, + gpu_tdp: Watts, + cpu_bw: Bandwidth, + gpu_bw: Bandwidth, +} + +impl AppleChip { + fn from_brand_string(brand: &str) -> Self { + match brand { + s if s.contains("M1 Pro") => AppleChip::M1Pro, + s if s.contains("M1 Max") => AppleChip::M1Max, + s if s.contains("M1 Ultra") => AppleChip::M1Ultra, + s if s.contains("M1") => AppleChip::M1, + s if s.contains("M2 Pro") => AppleChip::M2Pro, + s if s.contains("M2 Max") => AppleChip::M2Max, + s if s.contains("M2 Ultra") => AppleChip::M2Ultra, + s if s.contains("M2") => AppleChip::M2, + s if s.contains("M3 Pro") => AppleChip::M3Pro, + s if s.contains("M3 Max") => AppleChip::M3Max, + s if s.contains("M3") => AppleChip::M3, + _ => AppleChip::Unknown, + } + } + + fn get_specs(&self) -> ChipSpecs { + match self { + AppleChip::M1 => ChipSpecs { + cpu_tdp: 20, + gpu_tdp: 20, + cpu_bw: 70, + gpu_bw: 70, + }, + AppleChip::M1Pro => ChipSpecs { + cpu_tdp: 30, + gpu_tdp: 30, + cpu_bw: 200, + gpu_bw: 200, + }, + AppleChip::M1Max => ChipSpecs { + cpu_tdp: 30, + gpu_tdp: 60, + cpu_bw: 250, + gpu_bw: 400, + }, + AppleChip::M1Ultra => ChipSpecs { + cpu_tdp: 60, + gpu_tdp: 120, + cpu_bw: 500, + gpu_bw: 800, + }, + AppleChip::M2 => ChipSpecs { + cpu_tdp: 25, + gpu_tdp: 15, + cpu_bw: 100, + gpu_bw: 100, + }, + AppleChip::M2Pro => ChipSpecs { + cpu_tdp: 30, + gpu_tdp: 35, + cpu_bw: 0, + gpu_bw: 0, + }, + AppleChip::M2Max => ChipSpecs { + cpu_tdp: 30, + gpu_tdp: 40, + cpu_bw: 0, + gpu_bw: 0, + }, + // Add more variants as needed + _ => ChipSpecs { + cpu_tdp: 0, + gpu_tdp: 0, + cpu_bw: 0, + gpu_bw: 0, + }, + } + } +} + +impl SocInfo { + pub fn new() -> Result<SocInfo> { + let (cpu_brand_name, num_cpu_cores, e_core_count, p_core_count) = cpu_info(&RealCommand)?; + let num_gpu_cores = gpu_info(&RealCommand)?; + + let chip = AppleChip::from_brand_string(&cpu_brand_name); + let specs = chip.get_specs(); + + Ok(SocInfo { + cpu_brand_name, + num_cpu_cores, + num_gpu_cores, + cpu_max_power: Some(specs.cpu_tdp), + gpu_max_power: Some(specs.gpu_tdp), + cpu_max_bw: Some(specs.cpu_bw), + gpu_max_bw: Some(specs.gpu_bw), + e_core_count, + p_core_count, + }) + } +} + +// https://github.com/tlkh/asitop/blob/74ebe2cbc23d5b1eec874aebb1b9bacfe0e670cd/asitop/utils.py#L94 +const SYSCTL_PATH: &str = "/usr/sbin/sysctl"; + +fn cpu_info(cmd: &impl SystemCommand) -> Result<(String, u16, u16, u16)> { + let binary = SYSCTL_PATH; + let args = &[ + // don't display the variable name + "-n", + "machdep.cpu.brand_string", + "machdep.cpu.core_count", + "hw.perflevel0.logicalcpu", + "hw.perflevel1.logicalcpu", + ]; + + let output = cmd.execute(binary, args)?; + let buffer = String::from_utf8(output.stdout)?; + + let mut iter = buffer.split('\n'); + let cpu_brand_name = match iter.next() { + Some(s) => s.to_string(), + None => return Err(Error::Parse(buffer.to_string())), + }; + + let num_cpu_cores = match iter.next() { + Some(s) => s.parse::<u16>()?, + None => return Err(Error::Parse(buffer.to_string())), + }; + + let num_performance_cores = match iter.next() { + Some(s) => s.parse::<u16>()?, + None => return Err(Error::Parse(buffer.to_string())), + }; + + let num_efficiency_cores = match iter.next() { + Some(s) => s.parse::<u16>()?, + None => return Err(Error::Parse(buffer.to_string())), + }; + + Ok(( + cpu_brand_name, + num_cpu_cores, + num_performance_cores, + num_efficiency_cores, + )) +} + +// https://github.com/tlkh/asitop/blob/74ebe2cbc23d5b1eec874aebb1b9bacfe0e670cd/asitop/utils.py#L120 +fn gpu_info(cmd: &impl SystemCommand) -> Result<u16> { + let binary = "/usr/sbin/system_profiler"; + let args = &["-detailLevel", "basic", "SPDisplaysDataType"]; + + let output = cmd.execute(binary, args)?; + let buffer = String::from_utf8(output.stdout)?; + + let num_gpu_cores_line = buffer + .lines() + .find(|&line| line.trim_start().starts_with("Total Number of Cores")); + + let num_gpu_cores = match num_gpu_cores_line { + Some(s) => match s.split(": ").last() { + Some(s) => s.parse::<u16>()?, + None => return Err(Error::Parse(buffer.to_string())), + }, + None => return Err(Error::Parse(buffer.to_string())), + }; + + Ok(num_gpu_cores) +} + +/// Trait for system command execution +pub trait SystemCommand { + fn execute(&self, binary: &str, args: &[&str]) -> Result<Output>; +} + +/// Real command executor +pub struct RealCommand; + +impl SystemCommand for RealCommand { + fn execute(&self, binary: &str, args: &[&str]) -> Result<Output> { + Ok(Command::new(binary).args(args).output()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::process::ExitStatusExt; + + struct MockCommand { + output: Vec<u8>, + } + + impl MockCommand { + fn new(output: &str) -> Self { + Self { + output: output.as_bytes().to_vec(), + } + } + } + + impl SystemCommand for MockCommand { + fn execute(&self, _binary: &str, _args: &[&str]) -> Result<Output> { + Ok(Output { + status: std::process::ExitStatus::from_raw(0), + stdout: self.output.clone(), + stderr: Vec::new(), + }) + } + } + + #[test] + fn test_gpu_info() { + let mock_output = r#"Graphics/Displays: + Apple M2: + Total Number of Cores: 10"#; + let cmd = MockCommand::new(mock_output); + + let result = gpu_info(&cmd); + assert_eq!(result.unwrap(), 10); + } + + #[test] + fn test_cpu_info_success() { + let mock_output = "Apple M2\n8\n4\n4\n"; + let cmd = MockCommand::new(mock_output); + + let result = cpu_info(&cmd); + assert!(result.is_ok()); + let (brand, cores, p_cores, e_cores) = result.unwrap(); + assert_eq!(brand, "Apple M2"); + assert_eq!(cores, 8); + assert_eq!(p_cores, 4); + assert_eq!(e_cores, 4); + } + + #[test] + fn test_cpu_info_missing_core_count() { + let mock_output = "Apple M2\n"; + let cmd = MockCommand::new(mock_output); + + let result = cpu_info(&cmd); + assert!(matches!(result, Err(Error::ParseInt { .. }))); + } + + #[test] + fn test_cpu_info_invalid_core_count() { + let mock_output = "Apple M2\ninvalid\n"; + let cmd = MockCommand::new(mock_output); + + let result = cpu_info(&cmd); + assert!(matches!(result, Err(Error::ParseInt { .. }))); + } +} |
